FileTree.tsx

  1import { Link } from "@tanstack/react-router";
  2import { formatDistanceToNow } from "date-fns";
  3import { Folder, File } from "lucide-react";
  4
  5import { GitObjectType, type GitTreeEntry } from "@/__generated__/graphql";
  6import { Skeleton } from "@/components/ui/skeleton";
  7
  8export interface TreeEntryWithCommit extends GitTreeEntry {
  9  lastCommit?: {
 10    hash: string;
 11    shortHash: string;
 12    message: string;
 13    date: string;
 14  };
 15}
 16
 17interface FileTreeProps {
 18  repo: string;
 19  currentRef: string;
 20  currentPath: string;
 21  entries: TreeEntryWithCommit[];
 22  loading?: boolean;
 23}
 24
 25// Directory listing table for the code browser. Shows each entry's icon,
 26// name, last-commit message (linked to commit detail), and relative date.
 27export function FileTree({ repo, currentRef, currentPath, entries, loading }: FileTreeProps) {
 28  // Directories first, then files — each group alphabetical
 29  const sorted = entries.toSorted((a, b) => {
 30    if (a.type !== b.type) return a.type === GitObjectType.Tree ? -1 : 1;
 31    return a.name.localeCompare(b.name);
 32  });
 33
 34  if (loading) return <FileTreeSkeleton />;
 35
 36  return (
 37    <div className="border-border overflow-hidden rounded-md border">
 38      <table className="w-full text-sm">
 39        <tbody className="divide-border divide-y">
 40          {currentPath && (
 41            <tr className="hover:bg-muted/40">
 42              <td colSpan={4}>
 43                <Link
 44                  to="/$repo/tree/$ref/$"
 45                  params={{
 46                    repo,
 47                    ref: currentRef,
 48                    _splat: currentPath.split("/").slice(0, -1).join("/"),
 49                  }}
 50                  className="flex items-center gap-3 py-2 pl-4"
 51                >
 52                  <Folder className="size-4 text-blue-500 dark:text-blue-400" />
 53                  <span className="text-muted-foreground font-mono">..</span>
 54                </Link>
 55              </td>
 56            </tr>
 57          )}
 58          {sorted.map((entry) => (
 59            <FileTreeRow
 60              key={entry.name}
 61              entry={entry}
 62              repo={repo}
 63              currentRef={currentRef}
 64              currentPath={currentPath}
 65            />
 66          ))}
 67        </tbody>
 68      </table>
 69    </div>
 70  );
 71}
 72
 73function FileTreeRow({
 74  entry,
 75  repo,
 76  currentRef,
 77  currentPath,
 78}: {
 79  entry: TreeEntryWithCommit;
 80  repo: string;
 81  currentRef: string;
 82  currentPath: string;
 83}) {
 84  const isDir = entry.type === GitObjectType.Tree;
 85  const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
 86
 87  const entryLink = isDir
 88    ? { to: "/$repo/tree/$ref/$" as const, params: { repo, ref: currentRef, _splat: entryPath } }
 89    : { to: "/$repo/blob/$ref/$" as const, params: { repo, ref: currentRef, _splat: entryPath } };
 90
 91  return (
 92    <tr className="hover:bg-muted/40">
 93      <td className="w-6 py-2 pl-4">
 94        {isDir ? (
 95          <Folder className="size-4 text-blue-500 dark:text-blue-400" />
 96        ) : (
 97          <File className="text-muted-foreground size-4" />
 98        )}
 99      </td>
100      <td className="px-3 py-2">
101        <Link {...entryLink} className={`font-mono ${isDir ? "font-medium" : ""} hover:underline`}>
102          {entry.name}
103        </Link>
104      </td>
105      <td className="text-muted-foreground hidden max-w-xs truncate px-3 py-2 md:table-cell">
106        {entry.lastCommit && (
107          <Link
108            to="/$repo/commit/$hash"
109            params={{ repo, hash: entry.lastCommit.hash }}
110            className="hover:text-foreground hover:underline"
111          >
112            {entry.lastCommit.message}
113          </Link>
114        )}
115      </td>
116      <td className="text-muted-foreground hidden px-4 py-2 text-right text-xs whitespace-nowrap md:table-cell">
117        {entry.lastCommit &&
118          formatDistanceToNow(new Date(entry.lastCommit.date), { addSuffix: true })}
119      </td>
120    </tr>
121  );
122}
123
124function FileTreeSkeleton() {
125  return (
126    <div className="border-border overflow-hidden rounded-md border">
127      <div className="divide-border divide-y">
128        {Array.from({ length: 8 }).map((_, i) => (
129          <div key={i} className="flex items-center gap-3 px-4 py-2">
130            <Skeleton className="size-4 rounded-sm" />
131            <Skeleton className="h-4 w-32" />
132            <Skeleton className="ml-6 hidden h-4 w-64 md:block" />
133            <Skeleton className="ml-auto hidden h-4 w-20 md:block" />
134          </div>
135        ))}
136      </div>
137    </div>
138  );
139}