~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
139 lines · 4.4 KB Raw History
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>
  );
}