Detailed changes
@@ -1,26 +1,27 @@
+import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
interface CodeBreadcrumbProps {
repoName: string;
- ref: string;
+ currentRef: string;
path: string;
- // called when user clicks a breadcrumb segment — returns new path
- onNavigate: (path: string) => void;
+ repo: string;
}
-// Path breadcrumb for the code browser: repo name / ref / path segments.
-// Each segment is clickable to navigate up the tree.
-export function CodeBreadcrumb({ repoName, ref, path, onNavigate }: CodeBreadcrumbProps) {
+// Path breadcrumb for the code browser: repo name / path segments.
+// Each segment is a Link to the corresponding tree path.
+export function CodeBreadcrumb({ repoName, currentRef, path, repo }: CodeBreadcrumbProps) {
const parts = path ? path.split("/").filter(Boolean) : [];
return (
<div className="flex flex-wrap items-center gap-1 font-mono text-sm">
- <button
- onClick={() => onNavigate("")}
+ <Link
+ to="/$repo/tree/$ref/$"
+ params={{ repo, ref: currentRef, _splat: "" }}
className="text-foreground font-medium hover:underline"
>
{repoName}
- </button>
+ </Link>
{parts.map((part, i) => {
const partPath = parts.slice(0, i + 1).join("/");
@@ -31,18 +32,19 @@ export function CodeBreadcrumb({ repoName, ref, path, onNavigate }: CodeBreadcru
{isLast ? (
<span className="text-foreground font-medium">{part}</span>
) : (
- <button
- onClick={() => onNavigate(partPath)}
+ <Link
+ to="/$repo/tree/$ref/$"
+ params={{ repo, ref: currentRef, _splat: partPath }}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{part}
- </button>
+ </Link>
)}
</span>
);
})}
- <span className="text-muted-foreground ml-2 text-xs">@ {ref}</span>
+ <span className="text-muted-foreground ml-2 text-xs">@ {currentRef}</span>
</div>
);
}
@@ -9,11 +9,23 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
interface FileViewerProps {
- blob: GitBlob;
+ blob: GitBlob | null;
loading?: boolean;
}
-export function FileViewer({ blob, loading }: FileViewerProps) {
+export function FileViewer({ blob, loading = false }: FileViewerProps) {
+ if (loading || !blob) {
+ return (
+ <div className="divide-border border-border divide-y rounded-md border">
+ <div className="flex items-center gap-2 px-4 py-2">
+ <Skeleton className="h-4 w-48" />
+ </div>
+ <div className="p-4">
+ <Skeleton className="h-64 w-full" />
+ </div>
+ </div>
+ );
+ }
const [highlighted, setHighlighted] = useState<{ html: string; lineCount: number } | null>(null);
useEffect(() => {
@@ -43,7 +55,7 @@ export function FileViewer({ blob, loading }: FileViewerProps) {
const { html, lineCount } = highlighted;
function copyToClipboard() {
- if (blob.text) void navigator.clipboard.writeText(blob.text);
+ if (blob?.text) void navigator.clipboard.writeText(blob.text);
}
return (
@@ -54,12 +54,7 @@ export function Header() {
{/* Repo-scoped nav links — only shown when inside a repo */}
{effectiveRepo && (
<nav className="flex items-center gap-1">
- <NavLink
- to="/$repo"
- params={{ repo: effectiveRepo }}
- search={{ ref: "", path: "", type: "tree" as const }}
- activeOptions={{ exact: true }}
- >
+ <NavLink to="/$repo" params={{ repo: effectiveRepo }} activeOptions={{ exact: true }}>
Code
</NavLink>
<NavLink
@@ -14,11 +14,15 @@ import { Route as IndexRouteImport } from './routes/index'
import { Route as RepoIndexRouteImport } from './routes/$repo/index'
import { Route as AuthSelectIdentityRouteImport } from './routes/auth/select-identity'
import { Route as RepoIssuesRouteImport } from './routes/$repo/_issues'
+import { Route as RepoCodeRouteImport } from './routes/$repo/_code'
import { Route as RepoCommitHashRouteImport } from './routes/$repo/commit/$hash'
import { Route as RepoIssuesIssuesIndexRouteImport } from './routes/$repo/_issues/issues/index'
import { Route as RepoIssuesUserIdRouteImport } from './routes/$repo/_issues/user/$id'
import { Route as RepoIssuesIssuesNewRouteImport } from './routes/$repo/_issues/issues/new'
import { Route as RepoIssuesIssuesIdRouteImport } from './routes/$repo/_issues/issues/$id'
+import { Route as RepoCodeCommitsRefRouteImport } from './routes/$repo/_code/commits/$ref'
+import { Route as RepoCodeTreeRefSplatRouteImport } from './routes/$repo/_code/tree/$ref/$'
+import { Route as RepoCodeBlobRefSplatRouteImport } from './routes/$repo/_code/blob/$ref/$'
const RepoRoute = RepoRouteImport.update({
id: '/$repo',
@@ -44,6 +48,10 @@ const RepoIssuesRoute = RepoIssuesRouteImport.update({
id: '/_issues',
getParentRoute: () => RepoRoute,
} as any)
+const RepoCodeRoute = RepoCodeRouteImport.update({
+ id: '/_code',
+ getParentRoute: () => RepoRoute,
+} as any)
const RepoCommitHashRoute = RepoCommitHashRouteImport.update({
id: '/commit/$hash',
path: '/commit/$hash',
@@ -69,6 +77,21 @@ const RepoIssuesIssuesIdRoute = RepoIssuesIssuesIdRouteImport.update({
path: '/issues/$id',
getParentRoute: () => RepoIssuesRoute,
} as any)
+const RepoCodeCommitsRefRoute = RepoCodeCommitsRefRouteImport.update({
+ id: '/commits/$ref',
+ path: '/commits/$ref',
+ getParentRoute: () => RepoCodeRoute,
+} as any)
+const RepoCodeTreeRefSplatRoute = RepoCodeTreeRefSplatRouteImport.update({
+ id: '/tree/$ref/$',
+ path: '/tree/$ref/$',
+ getParentRoute: () => RepoCodeRoute,
+} as any)
+const RepoCodeBlobRefSplatRoute = RepoCodeBlobRefSplatRouteImport.update({
+ id: '/blob/$ref/$',
+ path: '/blob/$ref/$',
+ getParentRoute: () => RepoCodeRoute,
+} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
@@ -76,33 +99,43 @@ export interface FileRoutesByFullPath {
'/auth/select-identity': typeof AuthSelectIdentityRoute
'/$repo/': typeof RepoIndexRoute
'/$repo/commit/$hash': typeof RepoCommitHashRoute
+ '/$repo/commits/$ref': typeof RepoCodeCommitsRefRoute
'/$repo/issues/$id': typeof RepoIssuesIssuesIdRoute
'/$repo/issues/new': typeof RepoIssuesIssuesNewRoute
'/$repo/user/$id': typeof RepoIssuesUserIdRoute
'/$repo/issues/': typeof RepoIssuesIssuesIndexRoute
+ '/$repo/blob/$ref/$': typeof RepoCodeBlobRefSplatRoute
+ '/$repo/tree/$ref/$': typeof RepoCodeTreeRefSplatRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/$repo': typeof RepoIndexRoute
'/auth/select-identity': typeof AuthSelectIdentityRoute
'/$repo/commit/$hash': typeof RepoCommitHashRoute
+ '/$repo/commits/$ref': typeof RepoCodeCommitsRefRoute
'/$repo/issues/$id': typeof RepoIssuesIssuesIdRoute
'/$repo/issues/new': typeof RepoIssuesIssuesNewRoute
'/$repo/user/$id': typeof RepoIssuesUserIdRoute
'/$repo/issues': typeof RepoIssuesIssuesIndexRoute
+ '/$repo/blob/$ref/$': typeof RepoCodeBlobRefSplatRoute
+ '/$repo/tree/$ref/$': typeof RepoCodeTreeRefSplatRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/$repo': typeof RepoRouteWithChildren
+ '/$repo/_code': typeof RepoCodeRouteWithChildren
'/$repo/_issues': typeof RepoIssuesRouteWithChildren
'/auth/select-identity': typeof AuthSelectIdentityRoute
'/$repo/': typeof RepoIndexRoute
'/$repo/commit/$hash': typeof RepoCommitHashRoute
+ '/$repo/_code/commits/$ref': typeof RepoCodeCommitsRefRoute
'/$repo/_issues/issues/$id': typeof RepoIssuesIssuesIdRoute
'/$repo/_issues/issues/new': typeof RepoIssuesIssuesNewRoute
'/$repo/_issues/user/$id': typeof RepoIssuesUserIdRoute
'/$repo/_issues/issues/': typeof RepoIssuesIssuesIndexRoute
+ '/$repo/_code/blob/$ref/$': typeof RepoCodeBlobRefSplatRoute
+ '/$repo/_code/tree/$ref/$': typeof RepoCodeTreeRefSplatRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
@@ -112,32 +145,42 @@ export interface FileRouteTypes {
| '/auth/select-identity'
| '/$repo/'
| '/$repo/commit/$hash'
+ | '/$repo/commits/$ref'
| '/$repo/issues/$id'
| '/$repo/issues/new'
| '/$repo/user/$id'
| '/$repo/issues/'
+ | '/$repo/blob/$ref/$'
+ | '/$repo/tree/$ref/$'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/$repo'
| '/auth/select-identity'
| '/$repo/commit/$hash'
+ | '/$repo/commits/$ref'
| '/$repo/issues/$id'
| '/$repo/issues/new'
| '/$repo/user/$id'
| '/$repo/issues'
+ | '/$repo/blob/$ref/$'
+ | '/$repo/tree/$ref/$'
id:
| '__root__'
| '/'
| '/$repo'
+ | '/$repo/_code'
| '/$repo/_issues'
| '/auth/select-identity'
| '/$repo/'
| '/$repo/commit/$hash'
+ | '/$repo/_code/commits/$ref'
| '/$repo/_issues/issues/$id'
| '/$repo/_issues/issues/new'
| '/$repo/_issues/user/$id'
| '/$repo/_issues/issues/'
+ | '/$repo/_code/blob/$ref/$'
+ | '/$repo/_code/tree/$ref/$'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
@@ -183,6 +226,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RepoIssuesRouteImport
parentRoute: typeof RepoRoute
}
+ '/$repo/_code': {
+ id: '/$repo/_code'
+ path: ''
+ fullPath: '/$repo'
+ preLoaderRoute: typeof RepoCodeRouteImport
+ parentRoute: typeof RepoRoute
+ }
'/$repo/commit/$hash': {
id: '/$repo/commit/$hash'
path: '/commit/$hash'
@@ -218,9 +268,46 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof RepoIssuesIssuesIdRouteImport
parentRoute: typeof RepoIssuesRoute
}
+ '/$repo/_code/commits/$ref': {
+ id: '/$repo/_code/commits/$ref'
+ path: '/commits/$ref'
+ fullPath: '/$repo/commits/$ref'
+ preLoaderRoute: typeof RepoCodeCommitsRefRouteImport
+ parentRoute: typeof RepoCodeRoute
+ }
+ '/$repo/_code/tree/$ref/$': {
+ id: '/$repo/_code/tree/$ref/$'
+ path: '/tree/$ref/$'
+ fullPath: '/$repo/tree/$ref/$'
+ preLoaderRoute: typeof RepoCodeTreeRefSplatRouteImport
+ parentRoute: typeof RepoCodeRoute
+ }
+ '/$repo/_code/blob/$ref/$': {
+ id: '/$repo/_code/blob/$ref/$'
+ path: '/blob/$ref/$'
+ fullPath: '/$repo/blob/$ref/$'
+ preLoaderRoute: typeof RepoCodeBlobRefSplatRouteImport
+ parentRoute: typeof RepoCodeRoute
+ }
}
}
+interface RepoCodeRouteChildren {
+ RepoCodeCommitsRefRoute: typeof RepoCodeCommitsRefRoute
+ RepoCodeBlobRefSplatRoute: typeof RepoCodeBlobRefSplatRoute
+ RepoCodeTreeRefSplatRoute: typeof RepoCodeTreeRefSplatRoute
+}
+
+const RepoCodeRouteChildren: RepoCodeRouteChildren = {
+ RepoCodeCommitsRefRoute: RepoCodeCommitsRefRoute,
+ RepoCodeBlobRefSplatRoute: RepoCodeBlobRefSplatRoute,
+ RepoCodeTreeRefSplatRoute: RepoCodeTreeRefSplatRoute,
+}
+
+const RepoCodeRouteWithChildren = RepoCodeRoute._addFileChildren(
+ RepoCodeRouteChildren,
+)
+
interface RepoIssuesRouteChildren {
RepoIssuesIssuesIdRoute: typeof RepoIssuesIssuesIdRoute
RepoIssuesIssuesNewRoute: typeof RepoIssuesIssuesNewRoute
@@ -240,12 +327,14 @@ const RepoIssuesRouteWithChildren = RepoIssuesRoute._addFileChildren(
)
interface RepoRouteChildren {
+ RepoCodeRoute: typeof RepoCodeRouteWithChildren
RepoIssuesRoute: typeof RepoIssuesRouteWithChildren
RepoIndexRoute: typeof RepoIndexRoute
RepoCommitHashRoute: typeof RepoCommitHashRoute
}
const RepoRouteChildren: RepoRouteChildren = {
+ RepoCodeRoute: RepoCodeRouteWithChildren,
RepoIssuesRoute: RepoIssuesRouteWithChildren,
RepoIndexRoute: RepoIndexRoute,
RepoCommitHashRoute: RepoCommitHashRoute,
@@ -0,0 +1,136 @@
+// Pathless layout for the code browser. Preloads refs (branches/tags)
+// and renders the shared header (breadcrumb + ref selector + history toggle).
+// Child routes (tree, blob, commits) render inside the Outlet.
+
+import { gql } from "@apollo/client";
+import { useReadQuery } from "@apollo/client/react";
+import { createFileRoute, Outlet, useMatchRoute, useParams } from "@tanstack/react-router";
+import { GitCommit } from "lucide-react";
+
+import type { GitRef } from "@/__generated__/graphql";
+import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
+import { RefSelector } from "@/components/code/RefSelector";
+import { ButtonLink } from "@/components/ui/button-link";
+import { Skeleton } from "@/components/ui/skeleton";
+
+export const REFS_QUERY = gql`
+ query CodePageRefs($repo: String) {
+ repository(ref: $repo) {
+ name
+ refs {
+ nodes {
+ name
+ shortName
+ type
+ hash
+ isDefault
+ }
+ }
+ }
+ }
+`;
+
+export interface RefsQueryData {
+ repository: {
+ name: string;
+ refs: { nodes: GitRef[] } | null;
+ } | null;
+}
+
+export const Route = createFileRoute("/$repo/_code")({
+ component: CodeLayout,
+ pendingComponent: CodeLayoutSkeleton,
+ beforeLoad: ({ context: { preloadQuery, ref } }) => {
+ const refsRef = preloadQuery<RefsQueryData>(REFS_QUERY, {
+ variables: { repo: ref },
+ });
+ return { refsRef };
+ },
+});
+
+function CodeLayout() {
+ const { repo } = Route.useParams();
+ const { ref: repoRef } = Route.useRouteContext();
+ const { refsRef } = Route.useRouteContext();
+ const { data: refsData } = useReadQuery(refsRef);
+ const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
+ const repoName = refsData?.repository?.name ?? repoRef ?? "default-repo";
+
+ // Read child route params (ref and splat path) via loose useParams
+ const allParams = useParams({ strict: false }) as {
+ ref?: string;
+ _splat?: string;
+ };
+ const currentRef = allParams.ref ?? "";
+ const currentPath = allParams._splat ?? "";
+
+ const matchRoute = useMatchRoute();
+ const isCommitsView = !!matchRoute({
+ to: "/$repo/commits/$ref",
+ params: { repo, ref: currentRef },
+ fuzzy: true,
+ });
+
+ function handleRefSelect(ref: GitRef) {
+ // When switching refs, always go to tree root
+ window.location.href = `/${repo}/tree/${ref.shortName}`;
+ }
+
+ return (
+ <div className="space-y-4">
+ <div className="flex flex-wrap items-center justify-between gap-3">
+ <CodeBreadcrumb
+ repoName={repoName}
+ currentRef={currentRef}
+ path={currentPath}
+ repo={repo}
+ />
+ <div className="flex items-center gap-2">
+ {isCommitsView ? (
+ <ButtonLink
+ to="/$repo/tree/$ref/$"
+ params={{ repo, ref: currentRef, _splat: currentPath }}
+ variant="secondary"
+ size="sm"
+ >
+ <GitCommit className="size-3.5" />
+ History
+ </ButtonLink>
+ ) : (
+ <ButtonLink
+ to="/$repo/commits/$ref"
+ params={{ repo, ref: currentRef }}
+ variant="outline"
+ size="sm"
+ >
+ <GitCommit className="size-3.5" />
+ History
+ </ButtonLink>
+ )}
+ <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
+ </div>
+ </div>
+
+ <Outlet />
+ </div>
+ );
+}
+
+function CodeLayoutSkeleton() {
+ return (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <Skeleton className="h-5 w-48" />
+ <Skeleton className="h-8 w-28" />
+ </div>
+ <div className="divide-border border-border divide-y rounded-md border">
+ {Array.from({ length: 8 }).map((_, i) => (
+ <div key={i} className="flex items-center gap-3 px-4 py-2">
+ <Skeleton className="size-4 rounded-sm" />
+ <Skeleton className="h-4 w-32" />
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
@@ -0,0 +1,43 @@
+// Blob (file) view: /$repo/blob/$ref/...path
+
+import { gql } from "@apollo/client";
+import { useQuery } from "@apollo/client/react";
+import { createFileRoute } from "@tanstack/react-router";
+
+import type { GitBlob } from "@/__generated__/graphql";
+import { FileViewer } from "@/components/code/FileViewer";
+
+const BLOB_QUERY = gql`
+ query CodePageBlob($repo: String, $ref: String!, $path: String!) {
+ repository(ref: $repo) {
+ blob(ref: $ref, path: $path) {
+ path
+ hash
+ text
+ size
+ isBinary
+ isTruncated
+ }
+ }
+ }
+`;
+
+interface BlobQueryData {
+ repository: { blob: GitBlob | null } | null;
+}
+
+export const Route = createFileRoute("/$repo/_code/blob/$ref/$")({
+ component: BlobView,
+});
+
+function BlobView() {
+ const { ref: currentRef, _splat: currentPath = "" } = Route.useParams();
+ const { ref: repoRef } = Route.useRouteContext();
+
+ const { data, loading } = useQuery<BlobQueryData>(BLOB_QUERY, {
+ variables: { repo: repoRef, ref: currentRef, path: currentPath },
+ skip: !currentPath,
+ });
+
+ return <FileViewer blob={data?.repository?.blob ?? null} loading={loading} />;
+}
@@ -0,0 +1,16 @@
+// Commit history view: /$repo/commits/$ref
+
+import { createFileRoute } from "@tanstack/react-router";
+
+import { CommitList } from "@/components/code/CommitList";
+
+export const Route = createFileRoute("/$repo/_code/commits/$ref")({
+ component: CommitsView,
+});
+
+function CommitsView() {
+ const { ref: currentRef } = Route.useParams();
+ const { ref: repoRef } = Route.useRouteContext();
+
+ return <CommitList repo={repoRef} ref_={currentRef} />;
+}
@@ -0,0 +1,150 @@
+// Tree view: /$repo/tree/$ref/...path
+
+import { gql } from "@apollo/client";
+import { useQuery } from "@apollo/client/react";
+import { createFileRoute, useNavigate } from "@tanstack/react-router";
+
+import {
+ GitObjectType,
+ type GitTreeEntry,
+ type GitLastCommit,
+ type GitBlob,
+} from "@/__generated__/graphql";
+import { FileTree } from "@/components/code/FileTree";
+import type { TreeEntryWithCommit } from "@/components/code/FileTree";
+import { Markdown } from "@/components/content/Markdown";
+
+const TREE_QUERY = gql`
+ query CodePageTree($repo: String, $ref: String!, $path: String) {
+ repository(ref: $repo) {
+ tree(ref: $ref, path: $path) {
+ name
+ type
+ hash
+ }
+ }
+ }
+`;
+
+const LAST_COMMITS_QUERY = gql`
+ query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
+ repository(ref: $repo) {
+ lastCommits(ref: $ref, path: $path, names: $names) {
+ name
+ commit {
+ hash
+ shortHash
+ message
+ date
+ }
+ }
+ }
+ }
+`;
+
+const BLOB_QUERY = gql`
+ query CodePageReadme($repo: String, $ref: String!, $path: String!) {
+ repository(ref: $repo) {
+ blob(ref: $ref, path: $path) {
+ text
+ }
+ }
+ }
+`;
+
+interface TreeQueryData {
+ repository: { tree: GitTreeEntry[] | null } | null;
+}
+interface LastCommitsQueryData {
+ repository: { lastCommits: GitLastCommit[] | null } | null;
+}
+interface ReadmeQueryData {
+ repository: { blob: GitBlob | null } | null;
+}
+
+export const Route = createFileRoute("/$repo/_code/tree/$ref/$")({
+ component: TreeView,
+});
+
+function TreeView() {
+ const { repo, ref: currentRef, _splat: currentPath = "" } = Route.useParams();
+ const { ref: repoRef } = Route.useRouteContext();
+ const navigate = useNavigate();
+
+ const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
+ variables: { repo: repoRef, ref: currentRef, path: currentPath || null },
+ });
+ const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
+
+ const entryNames = entries.map((e) => e.name);
+ const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
+ variables: { repo: repoRef, ref: currentRef, path: currentPath || null, names: entryNames },
+ skip: entryNames.length === 0,
+ });
+ const lastCommitsByName = new Map<string, GitLastCommit>(
+ (lastCommitsData?.repository?.lastCommits ?? []).map((lc) => [lc.name, lc]),
+ );
+ const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e) => ({
+ ...e,
+ lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
+ }));
+
+ const readmeEntry = entries.find(
+ (e) => e.type === GitObjectType.Blob && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
+ );
+ const readmePath = readmeEntry
+ ? currentPath
+ ? `${currentPath}/${readmeEntry.name}`
+ : readmeEntry.name
+ : null;
+ const { data: readmeBlobData } = useQuery<ReadmeQueryData>(BLOB_QUERY, {
+ variables: { repo: repoRef, ref: currentRef, path: readmePath },
+ skip: !readmePath,
+ });
+ const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
+
+ function handleEntryClick(entry: TreeEntryWithCommit) {
+ const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
+ if (entry.type === GitObjectType.Blob) {
+ void navigate({
+ to: "/$repo/blob/$ref/$",
+ params: { repo, ref: currentRef, _splat: newPath },
+ });
+ } else {
+ void navigate({
+ to: "/$repo/tree/$ref/$",
+ params: { repo, ref: currentRef, _splat: newPath },
+ });
+ }
+ }
+
+ function handleNavigateUp() {
+ const parts = currentPath.split("/").filter(Boolean);
+ parts.pop();
+ void navigate({
+ to: "/$repo/tree/$ref/$",
+ params: { repo, ref: currentRef, _splat: parts.join("/") },
+ });
+ }
+
+ return (
+ <>
+ <FileTree
+ repo={repo}
+ entries={entriesWithCommits}
+ path={currentPath}
+ loading={treeLoading}
+ onNavigate={handleEntryClick}
+ onNavigateUp={handleNavigateUp}
+ />
+ {readme && (
+ <div className="rounded-md border">
+ <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">README</div>
+ <div className="px-6 py-4">
+ <Markdown content={readme} />
+ </div>
+ </div>
+ )}
+ </>
+ );
+}
@@ -1,40 +1,17 @@
-// Code browser page. Switches between tree view, file viewer, and commit
-// history via ?type= search param. Ref is selected via ?ref=.
+// /$repo index — redirects to the tree view with the default ref.
import { gql } from "@apollo/client";
-import { useQuery, useReadQuery } from "@apollo/client/react";
-import { createFileRoute, useNavigate, useSearch } from "@tanstack/react-router";
-import { AlertCircle, GitCommit } from "lucide-react";
-import { useEffect } from "react";
-import * as v from "valibot";
+import { createFileRoute, redirect } from "@tanstack/react-router";
-import {
- GitObjectType,
- type GitRef,
- type GitTreeEntry,
- type GitBlob,
- type GitLastCommit,
-} from "@/__generated__/graphql";
-import { CodeBreadcrumb } from "@/components/code/CodeBreadcrumb";
-import { CommitList } from "@/components/code/CommitList";
-import { FileTree } from "@/components/code/FileTree";
-import type { TreeEntryWithCommit } from "@/components/code/FileTree";
-import { FileViewer } from "@/components/code/FileViewer";
-import { RefSelector } from "@/components/code/RefSelector";
-import { Markdown } from "@/components/content/Markdown";
-import { ButtonLink } from "@/components/ui/button-link";
-import { Skeleton } from "@/components/ui/skeleton";
+import type { GitRef } from "@/__generated__/graphql";
+import { client } from "@/lib/apollo";
const REFS_QUERY = gql`
- query CodePageRefs($repo: String) {
+ query RepoDefaultRef($repo: String) {
repository(ref: $repo) {
- name
refs {
nodes {
- name
shortName
- type
- hash
isDefault
}
}
@@ -42,265 +19,23 @@ const REFS_QUERY = gql`
}
`;
-const TREE_QUERY = gql`
- query CodePageTree($repo: String, $ref: String!, $path: String) {
- repository(ref: $repo) {
- tree(ref: $ref, path: $path) {
- name
- type
- hash
- }
- }
- }
-`;
-
-const LAST_COMMITS_QUERY = gql`
- query CodePageLastCommits($repo: String, $ref: String!, $path: String, $names: [String!]!) {
- repository(ref: $repo) {
- lastCommits(ref: $ref, path: $path, names: $names) {
- name
- commit {
- hash
- shortHash
- message
- date
- }
- }
- }
- }
-`;
-
-const BLOB_QUERY = gql`
- query CodePageBlob($repo: String, $ref: String!, $path: String!) {
- repository(ref: $repo) {
- blob(ref: $ref, path: $path) {
- path
- hash
- text
- size
- isBinary
- isTruncated
- }
- }
- }
-`;
-
-interface RefsQueryData {
- repository: {
- name: string;
- refs: { nodes: GitRef[] } | null;
- } | null;
-}
-
-interface TreeQueryData {
- repository: {
- tree: GitTreeEntry[] | null;
- } | null;
+interface DefaultRefQueryData {
+ repository: { refs: { nodes: Pick<GitRef, "shortName" | "isDefault">[] } | null } | null;
}
-interface LastCommitsQueryData {
- repository: {
- lastCommits: GitLastCommit[] | null;
- } | null;
-}
-
-interface BlobQueryData {
- repository: {
- blob: GitBlob | null;
- } | null;
-}
-
-const codePageSearchSchema = v.object({
- ref: v.fallback(v.string(), ""),
- path: v.fallback(v.string(), ""),
- type: v.fallback(v.picklist(["tree", "blob", "commits"]), "tree"),
-});
-
-export type CodePageSearch = v.InferOutput<typeof codePageSearchSchema>;
-
-type ViewMode = CodePageSearch["type"];
-
export const Route = createFileRoute("/$repo/")({
- component: RouteComponent,
- pendingComponent: CodePageSkeleton,
- validateSearch: (search) => v.parse(codePageSearchSchema, search),
- loader: async ({ context: { preloadQuery, ref } }) => {
- const refsRef = preloadQuery<RefsQueryData>(REFS_QUERY, {
+ beforeLoad: async ({ context: { ref }, params: { repo } }) => {
+ const { data } = await client.query<DefaultRefQueryData>({
+ query: REFS_QUERY,
variables: { repo: ref },
});
- return { refsRef: await preloadQuery.toPromise(refsRef) };
+ const refs = data?.repository?.refs?.nodes ?? [];
+ const defaultRef = refs.find((r) => r.isDefault) ?? refs[0];
+ const refName = defaultRef?.shortName ?? "master";
+
+ throw redirect({
+ to: "/$repo/tree/$ref/$",
+ params: { repo, ref: refName, _splat: "" },
+ });
},
});
-
-function RouteComponent() {
- const { ref: repoRef } = Route.useRouteContext();
- const { repo } = Route.useParams();
- const navigate = useNavigate({ from: "/$repo/" });
- const { ref: currentRef, path: currentPath, type: viewMode } = useSearch({ from: "/$repo/" });
-
- const { refsRef } = Route.useLoaderData();
- const { data: refsData, error: refsError } = useReadQuery(refsRef);
- const refs: GitRef[] = refsData?.repository?.refs?.nodes ?? [];
-
- // Set default ref once loaded
- useEffect(() => {
- if (refs.length === 0 || currentRef) return;
- const defaultRef = refs.find((r: GitRef) => r.isDefault) ?? refs[0];
- if (defaultRef) {
- void navigate({
- search: (prev) => ({ ...prev, ref: defaultRef.shortName }),
- replace: true,
- });
- }
- }, [refs.length]); // eslint-disable-line react-hooks/exhaustive-deps
-
- const inTreeMode = viewMode === "tree" && !!currentRef;
- const inBlobMode = viewMode === "blob" && !!currentRef && !!currentPath;
-
- const { data: treeData, loading: treeLoading } = useQuery<TreeQueryData>(TREE_QUERY, {
- variables: { repo: repoRef, ref: currentRef, path: currentPath || null },
- skip: !inTreeMode,
- });
- const entries: GitTreeEntry[] = treeData?.repository?.tree ?? [];
-
- const entryNames = entries.map((e: GitTreeEntry) => e.name);
- const { data: lastCommitsData } = useQuery<LastCommitsQueryData>(LAST_COMMITS_QUERY, {
- variables: { repo: repoRef, ref: currentRef, path: currentPath || null, names: entryNames },
- skip: !inTreeMode || entryNames.length === 0,
- });
- const lastCommitsByName = new Map<string, GitLastCommit>(
- (lastCommitsData?.repository?.lastCommits ?? []).map((lc: GitLastCommit) => [lc.name, lc]),
- );
- const entriesWithCommits: TreeEntryWithCommit[] = entries.map((e: GitTreeEntry) => ({
- ...e,
- lastCommit: lastCommitsByName.get(e.name)?.commit ?? undefined,
- }));
-
- const { data: blobData, loading: blobLoading } = useQuery<BlobQueryData>(BLOB_QUERY, {
- variables: { repo: repoRef, ref: currentRef, path: currentPath },
- skip: !inBlobMode,
- });
- const blob: GitBlob | null = blobData?.repository?.blob ?? null;
-
- const readmeEntry = entries.find(
- (e: GitTreeEntry) =>
- e.type === GitObjectType.Blob && /^readme(\.md|\.txt|\.rst)?$/i.test(e.name),
- );
- const readmePath = readmeEntry
- ? currentPath
- ? `${currentPath}/${readmeEntry.name}`
- : readmeEntry.name
- : null;
- const { data: readmeBlobData } = useQuery<BlobQueryData>(BLOB_QUERY, {
- variables: { repo: repoRef, ref: currentRef, path: readmePath },
- skip: !inTreeMode || !readmePath,
- });
- const readme: string | null = readmeBlobData?.repository?.blob?.text ?? null;
-
- const repoName = refsData?.repository?.name ?? repoRef ?? "default-repo";
-
- function navigateTo(path: string, type: ViewMode = "tree") {
- void navigate({ search: (prev) => ({ ...prev, path, type }) });
- }
-
- function handleEntryClick(entry: TreeEntryWithCommit) {
- const newPath = currentPath ? `${currentPath}/${entry.name}` : entry.name;
- navigateTo(newPath, entry.type === GitObjectType.Blob ? "blob" : "tree");
- }
-
- function handleNavigateUp() {
- const parts = currentPath.split("/").filter(Boolean);
- parts.pop();
- navigateTo(parts.join("/"), "tree");
- }
-
- function handleRefSelect(ref: GitRef) {
- void navigate({ search: { ref: ref.shortName, path: "", type: "tree" } });
- }
-
- if (refsError) {
- return (
- <div className="flex flex-col items-center gap-3 py-16 text-center">
- <AlertCircle className="text-muted-foreground size-8" />
- <p className="text-sm font-medium">Code browser unavailable</p>
- <p className="text-muted-foreground max-w-sm text-xs">{refsError.message}</p>
- </div>
- );
- }
-
- return (
- <div className="space-y-4">
- <div className="flex flex-wrap items-center justify-between gap-3">
- <CodeBreadcrumb
- repoName={repoName}
- ref={currentRef}
- path={currentPath}
- onNavigate={(p) => navigateTo(p, "tree")}
- />
- <div className="flex items-center gap-2">
- <ButtonLink
- to="/$repo"
- params={{ repo }}
- search={{
- ref: currentRef,
- path: currentPath,
- type: viewMode === "commits" ? "tree" : "commits",
- }}
- variant={viewMode === "commits" ? "secondary" : "outline"}
- size="sm"
- >
- <GitCommit className="size-3.5" />
- History
- </ButtonLink>
- <RefSelector refs={refs} currentRef={currentRef} onSelect={handleRefSelect} />
- </div>
- </div>
-
- {viewMode === "commits" ? (
- <CommitList repo={repoRef} ref_={currentRef} path={currentPath || undefined} />
- ) : viewMode === "tree" || !blob ? (
- <>
- <FileTree
- repo={repo}
- entries={entriesWithCommits}
- path={currentPath}
- loading={treeLoading}
- onNavigate={handleEntryClick}
- onNavigateUp={handleNavigateUp}
- />
- {readme && (
- <div className="rounded-md border">
- <div className="text-muted-foreground border-b px-4 py-2 text-xs font-medium">
- README
- </div>
- <div className="px-6 py-4">
- <Markdown content={readme} />
- </div>
- </div>
- )}
- </>
- ) : (
- <FileViewer blob={blob} loading={blobLoading} />
- )}
- </div>
- );
-}
-
-function CodePageSkeleton() {
- return (
- <div className="space-y-4">
- <div className="flex items-center justify-between">
- <Skeleton className="h-5 w-48" />
- <Skeleton className="h-8 w-28" />
- </div>
- <div className="divide-border border-border divide-y rounded-md border">
- {Array.from({ length: 8 }).map((_, i) => (
- <div key={i} className="flex items-center gap-3 px-4 py-2">
- <Skeleton className="size-4 rounded-sm" />
- <Skeleton className="h-4 w-32" />
- </div>
- ))}
- </div>
- </div>
- );
-}
@@ -35,7 +35,6 @@ function RouteComponent() {
void navigate({
to: "/$repo",
params: { repo: repoSlug(data.repositories.nodes[0]?.name) },
- search: { ref: "", path: "", type: "tree" as const },
replace: true,
});
}
@@ -61,7 +60,6 @@ function RouteComponent() {
key={repoSlug(repo.name)}
to="/$repo"
params={{ repo: repoSlug(repo.name) }}
- search={{ ref: "", path: "", type: "tree" as const }}
className="hover:bg-muted/50 flex items-center gap-3 px-4 py-4 transition-colors"
>
<FolderOpen className="text-muted-foreground size-5 shrink-0" />