diff --git a/webui2/src/components/code/CodeBreadcrumb.tsx b/webui2/src/components/code/CodeBreadcrumb.tsx
index bc8f0c7df35fc3687e60cf2494cd39df6d491cbf..c5518447781ad1a83d19dcdc8c99d7d04e31db24 100644
--- a/webui2/src/components/code/CodeBreadcrumb.tsx
+++ b/webui2/src/components/code/CodeBreadcrumb.tsx
@@ -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 (
- onNavigate("")}
+
{repoName}
-
+
{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 ? (
{part}
) : (
- onNavigate(partPath)}
+
{part}
-
+
)}
);
})}
- @ {ref}
+ @ {currentRef}
);
}
diff --git a/webui2/src/components/code/FileViewer.tsx b/webui2/src/components/code/FileViewer.tsx
index 842a966a9b4c97b05472d1b306eb5df2ab07cb98..122ca26653944a3f5a6af5b6d2b6dbf49daca3ee 100644
--- a/webui2/src/components/code/FileViewer.tsx
+++ b/webui2/src/components/code/FileViewer.tsx
@@ -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 (
+
+ );
+ }
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 (
diff --git a/webui2/src/components/layout/Header.tsx b/webui2/src/components/layout/Header.tsx
index 0ed2677d468dded56886288dee076e0707fb19d3..e37cbf4c6d83a7c9fe9d2c7728fa4c6acaabde54 100644
--- a/webui2/src/components/layout/Header.tsx
+++ b/webui2/src/components/layout/Header.tsx
@@ -54,12 +54,7 @@ export function Header() {
{/* Repo-scoped nav links — only shown when inside a repo */}
{effectiveRepo && (
-
+
Code
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,
diff --git a/webui2/src/routes/$repo/_code.tsx b/webui2/src/routes/$repo/_code.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..b027f08dba49cbe5dc5412c514ab44f01895d120
--- /dev/null
+++ b/webui2/src/routes/$repo/_code.tsx
@@ -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(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 (
+
+
+
+
+ {isCommitsView ? (
+
+
+ History
+
+ ) : (
+
+
+ History
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+function CodeLayoutSkeleton() {
+ return (
+
+
+
+
+
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/webui2/src/routes/$repo/_code/blob/$ref/$.tsx b/webui2/src/routes/$repo/_code/blob/$ref/$.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..765058e78b23c8f25cc3b0fe46b934766b00cc4d
--- /dev/null
+++ b/webui2/src/routes/$repo/_code/blob/$ref/$.tsx
@@ -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(BLOB_QUERY, {
+ variables: { repo: repoRef, ref: currentRef, path: currentPath },
+ skip: !currentPath,
+ });
+
+ return ;
+}
diff --git a/webui2/src/routes/$repo/_code/commits/$ref.tsx b/webui2/src/routes/$repo/_code/commits/$ref.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..4f94db6613a053e76b4bbe680e7ca64192c80077
--- /dev/null
+++ b/webui2/src/routes/$repo/_code/commits/$ref.tsx
@@ -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 ;
+}
diff --git a/webui2/src/routes/$repo/_code/tree/$ref/$.tsx b/webui2/src/routes/$repo/_code/tree/$ref/$.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..ec8e00eeb10cd5f5c68b2e3c2251ec7356ed89e9
--- /dev/null
+++ b/webui2/src/routes/$repo/_code/tree/$ref/$.tsx
@@ -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(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(LAST_COMMITS_QUERY, {
+ variables: { repo: repoRef, ref: currentRef, path: currentPath || null, names: entryNames },
+ skip: entryNames.length === 0,
+ });
+ const lastCommitsByName = new Map(
+ (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(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 (
+ <>
+
+ {readme && (
+
+ )}
+ >
+ );
+}
diff --git a/webui2/src/routes/$repo/index.tsx b/webui2/src/routes/$repo/index.tsx
index 3f7ef7cf9a9c8ddf2e483874cecb74f4146915dc..cd8b036d531d833156a19669e12fd71698b8bf78 100644
--- a/webui2/src/routes/$repo/index.tsx
+++ b/webui2/src/routes/$repo/index.tsx
@@ -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[] } | 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;
-
-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(REFS_QUERY, {
+ beforeLoad: async ({ context: { ref }, params: { repo } }) => {
+ const { data } = await client.query({
+ 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(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(LAST_COMMITS_QUERY, {
- variables: { repo: repoRef, ref: currentRef, path: currentPath || null, names: entryNames },
- skip: !inTreeMode || entryNames.length === 0,
- });
- const lastCommitsByName = new Map(
- (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(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(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 (
-
-
-
Code browser unavailable
-
{refsError.message}
-
- );
- }
-
- return (
-
-
-
navigateTo(p, "tree")}
- />
-
-
-
- History
-
-
-
-
-
- {viewMode === "commits" ? (
-
- ) : viewMode === "tree" || !blob ? (
- <>
-
- {readme && (
-
- )}
- >
- ) : (
-
- )}
-
- );
-}
-
-function CodePageSkeleton() {
- return (
-
-
-
-
-
-
- {Array.from({ length: 8 }).map((_, i) => (
-
-
-
-
- ))}
-
-
- );
-}
diff --git a/webui2/src/routes/index.tsx b/webui2/src/routes/index.tsx
index 8a38c6e1dffd260aac7789566effe345bd97d298..f91e1e00b45192f23edcaf8090a2ea596ce8a068 100644
--- a/webui2/src/routes/index.tsx
+++ b/webui2/src/routes/index.tsx
@@ -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"
>