Vectrum
Sign In
133 lines · 6.0 KB · TypeScript
import Link from "next/link";
import { commitCount, lastCommit, listTree, type Commit, type TreeEntry } from "@/lib/git";
import { encodePath, shortSha, timeAgo } from "@/lib/format";
import type { Repo } from "@/lib/repo";
import { ClockIcon, CommitIcon, DirIcon, FileIcon, SubmoduleIcon } from "./icons";
import { liftRowClass } from "./repo-rows";
import { Avatar, Box } from "./ui";

// Looking up the last commit is one git call per entry; skip it for huge directories.
const MAX_ENTRIES_WITH_COMMITS = 200;

/** A short sha on a small raised key: the commit glyph and the mono hash. */
export function ShaChip({ sha, href, className = "" }: { sha: string; href: string; className?: string }) {
  return (
    <Link
      href={href}
      className={`material-cap inline-flex h-6 shrink-0 items-center gap-1 rounded-key px-1.5 font-mono text-[11px] text-ink2 transition-colors hover:text-ink focus-visible:focus-ring ${className}`}
    >
      <CommitIcon width={12} height={12} className="text-ink3" /> {shortSha(sha)}
    </Link>
  );
}

/** The last commit to touch a path, sunk into the head of its plate: who, what, which and when, and the way to the history. */
export function LatestCommitBar({ repo, commit, count, gitRef, path }: {
  repo: Repo;
  commit: Commit;
  count?: number;
  gitRef: string;
  path?: string;
}) {
  const historyHref = `${repo.base}/commits/${encodePath(gitRef)}${path ? `/${encodePath(path)}` : ""}`;
  return (
    <div className="material-well flex min-h-11 items-center gap-2.5 rounded-plate py-1.5 pr-1.5 pl-3 type-ui">
      <Avatar name={commit.authorName} size={20} />
      <span className="shrink-0 font-semibold text-ink">{commit.authorName}</span>
      <Link href={`${repo.base}/commit/${commit.sha}`} className="truncate text-ink2 transition-colors hover:text-ink">
        {commit.subject}
      </Link>
      <span className="ml-auto flex shrink-0 items-center gap-2.5">
        <ShaChip sha={commit.sha} href={`${repo.base}/commit/${commit.sha}`} className="hidden sm:inline-flex" />
        <time dateTime={new Date(commit.date).toISOString()} className="hidden type-readout text-ink3 sm:inline">
          {timeAgo(commit.date)}
        </time>
        <Link
          href={historyHref}
          className="mu-icon-trigger flex h-7 items-center gap-1.5 rounded-pill px-2.5 text-ink transition-colors hover:recipe-icon-button-ghost-hover focus-visible:focus-ring"
        >
          <ClockIcon width={14} height={14} className="text-ink2" />
          {count !== undefined ? (
            <>
              <span className="type-readout">{count}</span> commit{count === 1 ? "" : "s"}
            </>
          ) : (
            "History"
          )}
        </Link>
      </span>
    </div>
  );
}

function entryIcon(entry: TreeEntry) {
  if (entry.type === "tree") return <DirIcon width={15} height={15} className="shrink-0 fill-ink3/20 text-ink3" />;
  if (entry.type === "commit") return <SubmoduleIcon width={15} height={15} className="shrink-0 text-ink3" />;
  return <FileIcon width={15} height={15} className="shrink-0 text-ink3" />;
}

// One row of the listing: the name, the last commit to touch it (md and up) and when.
const ROW = `${liftRowClass} grid h-9 grid-cols-[minmax(0,1fr)_8.5rem] items-center gap-4 px-3 type-ui md:grid-cols-[minmax(0,1fr)_minmax(0,2fr)_8.5rem]`;

export async function FileTable({ repo, gitRef, path }: { repo: Repo; gitRef: string; path: string }) {
  const entries = await listTree(repo.dir, gitRef, path);
  const withCommits = entries.length <= MAX_ENTRIES_WITH_COMMITS;
  const [latest, count, commits] = await Promise.all([
    lastCommit(repo.dir, gitRef, path || undefined),
    path ? undefined : commitCount(repo.dir, gitRef),
    withCommits ? Promise.all(entries.map((e) => lastCommit(repo.dir, gitRef, e.path))) : [],
  ]);
  const parent = path.split("/").slice(0, -1).join("/");

  return (
    <Box className="p-1.5">
      {latest && <LatestCommitBar repo={repo} commit={latest} count={count} gitRef={gitRef} path={path} />}
      <ul aria-label="Files" className={latest ? "mt-1.5" : ""}>
        {path && (
          <li className={ROW}>
            <Link
              href={`${repo.base}/tree/${encodePath(gitRef)}${parent ? `/${encodePath(parent)}` : ""}`}
              aria-label="Parent directory"
              className="col-span-full flex items-center gap-2.5 text-ink2 transition-colors hover:text-ink"
            >
              <DirIcon width={15} height={15} className="fill-ink3/20 text-ink3" /> ..
            </Link>
          </li>
        )}
        {entries.map((entry, i) => {
          const commit = commits[i];
          const kind = entry.type === "tree" ? "tree" : "blob";
          return (
            <li key={entry.name} className={ROW}>
              <span className="flex min-w-0 items-center gap-2.5">
                {entryIcon(entry)}
                {entry.type === "commit" ? (
                  <span className="truncate text-ink">
                    {entry.name} <span className="font-mono text-[11px] text-ink3">@ {shortSha(entry.sha)}</span>
                  </span>
                ) : (
                  <Link
                    href={`${repo.base}/${kind}/${encodePath(gitRef)}/${encodePath(entry.path)}`}
                    className="truncate text-ink decoration-ink3 underline-offset-4 hover:underline"
                  >
                    {entry.name}
                  </Link>
                )}
              </span>
              <span className="hidden min-w-0 truncate text-ink2 md:block">
                {commit && (
                  <Link href={`${repo.base}/commit/${commit.sha}`} className="transition-colors hover:text-ink">
                    {commit.subject}
                  </Link>
                )}
              </span>
              <span className="text-right whitespace-nowrap type-readout text-ink3">
                {commit && <time dateTime={new Date(commit.date).toISOString()}>{timeAgo(commit.date)}</time>}
              </span>
            </li>
          );
        })}
      </ul>
    </Box>
  );
}