chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import {
ChevronDownIcon,
ChevronRightIcon,
FileIcon,
FolderIcon,
SearchIcon,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { buildFileTree, filterFiles } from '#/lib/file-tree.js';
import { cn } from '#/lib/utils.js';
const STATUS_MARK = { added: '+', deleted: '−', modified: '±' };
const STATUS_COLOR = {
added: 'text-foreground',
deleted: 'text-faint',
modified: 'text-faint',
};
/**
* One row per node, indented by depth. Directories carry their own open
* state, so closing one leaves the rest as they were.
*/
function Node({ node, depth, onPick, picked }) {
const [open, setOpen] = useState(true);
const pad = { paddingLeft: `${depth * 12 + 8}px` };
if (node.type === 'file') {
const status = node.file.status;
return (
<button
type="button"
style={pad}
onClick={() => onPick(node.path)}
title={node.path}
className={cn(
'flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 pr-2 text-left text-[13px] transition-colors',
'outline-none focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2',
picked === node.path
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-foreground',
)}
>
<FileIcon className="size-3.5 shrink-0 text-faint" />
<span className="min-w-0 flex-1 truncate">{node.name}</span>
<span
className={cn(
'shrink-0 font-mono text-[12px]',
STATUS_COLOR[status] ?? 'text-faint',
)}
>
{STATUS_MARK[status] ?? ''}
</span>
</button>
);
}
const Chevron = open ? ChevronDownIcon : ChevronRightIcon;
return (
<div>
<button
type="button"
style={pad}
onClick={() => setOpen((was) => !was)}
aria-expanded={open}
className={cn(
'flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 pr-2 text-left text-[13px] text-foreground transition-colors hover:bg-accent',
'outline-none focus-visible:outline-2 focus-visible:outline-ring focus-visible:-outline-offset-2',
)}
>
<Chevron className="size-3.5 shrink-0 text-faint" />
<FolderIcon className="size-3.5 shrink-0 text-key" />
<span className="min-w-0 flex-1 truncate" title={node.path}>
{node.name}
</span>
</button>
{open &&
node.children.map((child) => (
<Node
key={child.path}
node={child}
depth={depth + 1}
onPick={onPick}
picked={picked}
/>
))}
</div>
);
}
/**
* The changed files of a commit, as the tree they came from, beside the
* diffs themselves. Picking one moves to it rather than opening a page: the
* diffs are all on this screen already.
*/
export function DiffTree({ files, onPick, picked }) {
const [filter, setFilter] = useState('');
const shown = useMemo(() => filterFiles(files, filter), [files, filter]);
const tree = useMemo(() => buildFileTree(shown), [shown]);
return (
<div className="flex flex-col gap-2">
<div className="relative">
<SearchIcon className="-translate-y-1/2 pointer-events-none absolute top-1/2 left-2.5 size-3.5 text-faint" />
<input
type="search"
value={filter}
onChange={(event) => setFilter(event.target.value)}
placeholder="Filter files"
aria-label="Filter changed files"
className="h-9 w-full rounded-xl border border-input bg-background pr-2.5 pl-8 text-[13px] outline-none transition-colors placeholder:text-faint focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
</div>
{shown.length === 0 ? (
<p className="px-2 py-3 text-[12.5px] text-faint">
No file matches that.
</p>
) : (
<div>
{tree.map((node) => (
<Node
key={node.path}
node={node}
depth={0}
onPick={onPick}
picked={picked}
/>
))}
</div>
)}
{shown.length !== files.length && (
// Only while a filter is narrowing things. The commit card above
// already says how many files changed.
<p className="px-2 pt-1 text-[12px] text-faint">
{shown.length} of {files.length} shown
</p>
)}
</div>
);
}