CodePage.tsx

  1// Code browser page. Switches between tree view, file viewer, and commit
  2// history via ?type= search param. Ref is selected via ?ref=.
  3
  4import { gql, useQuery } from "@apollo/client";
  5import { AlertCircle, GitCommit } from "lucide-react";
  6import { useEffect } from "react";
  7import { useSearchParams } from "react-router-dom";
  8
  9import type { GitRef, GitTreeEntry, GitBlob, GitLastCommit } from "@/__generated__/graphql";
 10import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
 11import { CommitList } from "@/components/code/CommitList";
 12import { FileTree } from "@/components/code/FileTree";
 13import type { TreeEntryWithCommit } from "@/components/code/FileTree";
 14import { FileViewer } from "@/components/code/FileViewer";
 15import { RefSelector } from "@/components/code/RefSelector";
 16import { Markdown } from "@/components/content/Markdown";
 17import { Button } from "@/components/ui/button";
 18import { Skeleton } from "@/components/ui/skeleton";
 19import { useRepo } from "@/lib/repo";
 20
 21const REFS_QUERY = gql`
 22  query CodePageRefs($repo: String) {
 23    repository(ref: $repo) {
 24      name
 25      refs {
 26        nodes {
 27          name
 28          shortName
 29          type
 30          hash
 31          isDefault
 32        }
 33      }
 34    }
 35  }
 36`;
 37
 38const TREE_QUERY = gql`
 39  query CodePageTree($repo: String, $ref: String!, $path: String) {
 40    repository(ref: $repo) {
 41      tree(ref: $ref, path: $path) {
 42        name
 43        type
 44        hash
 45      }
 46    }
 47  }
 48`;
 49
 50const LAST_COMMITS_QUERY = gql`
 51  query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
 52    repository(ref: $repo) {
 53      lastCommits(ref: $ref, path: $path, names: $names) {
 54        name
 55        commit {
 56          hash
 57          shortHash
 58          message
 59          date
 60        }
 61      }
 62    }
 63  }
 64`;
 65
 66const BLOB_QUERY = gql`
 67  query CodePageBlob($repo: String, $ref: String!, $path: String!) {
 68    repository(ref: $repo) {
 69      blob(ref: $ref, path: $path) {
 70        path
 71        hash
 72        text
 73        size
 74        isBinary
 75        isTruncated
 76      }
 77    }
 78  }
 79`;
 80
 81type ViewMode = "tree" | "blob" | "commits";
 82
 83export function CodePage() {
 84  const repo = useRepo();
 85  const [searchParams, setSearchParams] = useSearchParams();
 86
 87  const currentRef = searchParams.get("ref") ?? "";
 88  const currentPath = searchParams.get("path") ?? "";
 89  const viewMode: ViewMode = (searchParams.get("type") as ViewMode) ?? "tree";
 90
 91  const {
 92    data: refsData,
 93    loading: refsLoading,
 94    error: refsError,
 95  } = useQuery(REFS_QUERY, {
 96    variables: { repo },
 97  });
 98  const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
 99
100  // Set default ref from query result once loaded
101  useEffect(() => {
102    if (refsLoading || refs.length === 0 || searchParams.get("ref")) return;
103    const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
104    if (defaultRef) {
105      setSearchParams(
106        (prev) => {
107          prev.set("ref", defaultRef.shortName);
108          return prev;
109        },
110        { replace: true },
111      );
112    }
113  }, [refsLoading, refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
114
115  const inTreeMode = viewMode === "tree" && !!currentRef;
116  const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
117
118  const { data: treeData, loading: treeLoading } = useQuery(TREE_QUERY, {
119    variables: { repo, ref: currentRef, path: currentPath || null },
120    skip: !inTreeMode,
121  });
122  const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
123
124  const entryNames = entries.map((e: GitTreeEntry) => e.name);
125  const { data: lastCommitsData } = useQuery(LAST_COMMITS_QUERY, {
126    variables: { repo, ref: currentRef, path: currentPath || null, names: entryNames },
127    skip: !inTreeMode || entryNames.length === 0,
128  });
129  const lastCommitsByName = new Map<string, GitLastCommit>(
130    (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
131  );
132  const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
133    ...e,
134    lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
135  }));
136
137  const { data: blobData, loading: blobLoading } = useQuery(BLOB_QUERY, {
138    variables: { repo, ref: currentRef, path: currentPath },
139    skip: !inBlobMode,
140  });
141  const blob: GitBlob | null = blobData?.repository?.blob ?? null;
142
143  const readmeEntry = entries.find(
144    (e: GitTreeEntry) => e.type === "BLOB" && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
145  );
146  const readmePath = readmeEntry
147    ? currentPath
148      ? `${currentPath}/${readmeEntry.name}`
149      : readmeEntry.name
150    : null;
151  const { data: readmeBlobData } = useQuery(BLOB_QUERY, {
152    variables: { repo, ref: currentRef, path: readmePath },
153    skip: !inTreeMode || !readmePath,
154  });
155  const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
156
157  const repoName = refsData?.repository?.name ?? repo ?? "default-repo";
158
159  function navigate(path: string, type: ViewMode = "tree") {
160    setSearchParams((prev) => {
161      prev.set("path", path);
162      prev.set("type", type);
163      return prev;
164    });
165  }
166
167  function handleEntryClick(entry: TreeEntryWithCommit) {
168    const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
169    navigate(newPath, entry.type === "BLOB" ? "blob" : "tree");
170  }
171
172  function handleNavigateUp() {
173    const parts = currentPath.split("/").filter(Boolean);
174    parts.pop();
175    navigate(parts.join("/"), "tree");
176  }
177
178  function handleRefSelect(ref: GitRef) {
179    setSearchParams((prev) => {
180      prev.set("ref", ref.shortName);
181      prev.set("path", "");
182      prev.set("type", "tree");
183      return prev;
184    });
185  }
186
187  if (refsError) {
188    return (
189      <div className="flex flex-col items-center gap-3 py-16 text-center">
190        <AlertCircle className="size-8 text-muted-foreground" />
191        <p className="text-sm font-medium">Code browser unavailable</p>
192        <p className="max-w-sm text-xs text-muted-foreground">{refsError.message}</p>
193      </div>
194    );
195  }
196
197  return (
198    <div className="space-y-4">
199      <div className="flex flex-wrap items-center justify-between gap-3">
200        {refsLoading ? (
201          <Skeleton className="h-5 w-48" />
202        ) : (
203          <CodeBreadcrumb
204            repoName={repoName}
205            ref={currentRef}
206            path={currentPath}
207            onNavigate={(p) => navigate(p, "tree")}
208          />
209        )}
210        <div className="flex items-center gap-2">
211          {!refsLoading && (
212            <Button
213              variant={viewMode === "commits" ? "secondary" : "outline"}
214              size="sm"
215              onClick={() => navigate(currentPath, viewMode === "commits" ? "tree" : "commits")}
216            >
217              <GitCommit className="size-3.5" />
218              History
219            </Button>
220          )}
221          {refsLoading ? (
222            <Skeleton className="h-8 w-28" />
223          ) : (
224            <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
225          )}
226        </div>
227      </div>
228
229      {viewMode === "commits" ? (
230        <CommitList ref_={currentRef} path={currentPath || undefined} />
231      ) : viewMode === "tree" || !blob ? (
232        <>
233          <FileTree
234            entries={entriesWithCommits}
235            path={currentPath}
236            loading={treeLoading}
237            onNavigate={handleEntryClick}
238            onNavigateUp={handleNavigateUp}
239          />
240          {readme && (
241            <div className="rounded-md border">
242              <div className="border-b px-4 py-2 text-xs font-medium text-muted-foreground">
243                README
244              </div>
245              <div className="px-6 py-4">
246                <Markdown content={readme} />
247              </div>
248            </div>
249          )}
250        </>
251      ) : (
252        <FileViewer blob={blob} loading={blobLoading} />
253      )}
254    </div>
255  );
256}