chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import {
ChevronDownIcon,
ChevronRightIcon,
FileIcon,
FolderIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link } from '#/components/atoms/link.jsx';
import { buildFileTree, filterFiles } from '#/lib/file-tree.js';
import { cn } from '#/lib/utils.js';
/** Whether a directory holds, or is, the path in view. */
const onPath = (dir, path) =>
dir === path || path.startsWith(`${dir}/`) || dir.startsWith(`${path}/`);
/**
* One row per node, indented by depth. A directory opens on its own state;
* one on the way to the path in view opens by itself, so the branch a reader
* is standing on is the one they see. A filter opens everything it matched.
*/
function Node({ node, depth, base, refName, path, selected, filter }) {
const [manual, setManual] = useState(/** @type {boolean|null} */ (null));
const pad = { paddingLeft: `${depth * 12 + 10}px` };
if (node.type === 'file') {
const current = node.path === selected;
return (
<Link
href={`${base}/blob/${refName}/${encodeURI(node.path)}`}
style={pad}
aria-current={current ? 'page' : undefined}
title={node.path}
className={cn(
'flex items-center gap-1.5 border-l-2 py-[5px] pr-2 text-[13px] no-underline hover:bg-accent hover:no-underline',
current
? 'border-key bg-accent font-semibold text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
<span className="size-3.5 shrink-0" />
<FileIcon className="size-3.5 shrink-0 text-faint" />
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</Link>
);
}
const open = filter ? true : (manual ?? onPath(node.path, path));
const current = node.path === selected;
const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
return (
<div>
<div
style={pad}
className={cn(
'flex items-center gap-1.5 border-l-2 py-[5px] pr-2 text-[13px]',
current
? 'border-key bg-accent font-semibold text-foreground'
: 'border-transparent text-foreground hover:bg-accent',
)}
>
<button
type="button"
onClick={() => setManual(!open)}
aria-expanded={open}
aria-label={open ? `Close ${node.name}` : `Open ${node.name}`}
className="-m-1 cursor-pointer rounded-sm p-1 text-faint hover:text-foreground"
>
<Chevron className="size-3.5" />
</button>
<Link
href={`${base}/tree/${refName}/${encodeURI(node.path)}`}
title={node.path}
className="flex min-w-0 flex-1 items-center gap-1.5 text-inherit no-underline hover:no-underline"
>
<FolderIcon className="size-3.5 shrink-0 text-key" />
<span className="min-w-0 flex-1 truncate">{node.name}</span>
</Link>
</div>
{open &&
node.children.map((child) => (
<Node
key={child.path}
node={child}
depth={depth + 1}
base={base}
refName={refName}
path={path}
selected={selected}
filter={filter}
/>
))}
</div>
);
}
/**
* The whole repository as a tree, down the rail beside whatever is open:
* every directory from the root, closed but for the ones on the way to the
* path in view, the way a forge's file rail reads.
*
* `path` is the directory in view, which opens the branch leading to it.
* `selected` is the row a reader is standing on, a file or the directory
* itself. `filter` narrows the tree to the paths holding the text.
* @param {{repo: string, refName: string, files: Array<{path: string}>, path: string, selected?: string, filter?: string}} props
*/
export function RepoTree({
repo,
refName,
files,
path,
selected = '',
filter = '',
}) {
const base = `/${encodeURIComponent(repo)}`;
const ref = encodeURI(refName);
const roots = useMemo(
() => buildFileTree(filterFiles(files, filter)),
[files, filter],
);
if (roots.length === 0) {
return (
<p className="px-(--gutter) py-4 text-[12.5px] text-faint">
{filter ? 'Nothing matches.' : 'No files.'}
</p>
);
}
return (
<div className="py-1">
{roots.map((node) => (
<Node
key={node.path}
node={node}
depth={0}
base={base}
refName={ref}
path={path}
selected={selected}
filter={filter.trim()}
/>
))}
</div>
);
}