From 4ef960a5f6c7a2e6c78d00413bcbb93725dcc50e Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Tue, 14 Apr 2026 22:02:06 -0400 Subject: [PATCH 1/3] feat: add file explorer with tree sidebar, syntax highlighting, and blob/tree routes Add a split-panel code explorer accessible from the repo overview file tree. Clicking any file or folder navigates to tree/blob routes matching GitHub's URL convention. The explorer features: - Resizable left sidebar with recursive file tree (lazy-loaded directories) - Folder view with commit messages and timestamps in the right pane - Shiki syntax-highlighted code viewer with line numbers, copy, and raw download - File-specific latest commit bar (fetches last commit affecting that file) - Progressive BFS prefetching of all tree nodes to eliminate loading spinners - Mobile drawer pattern for file tree (matches review page) - Ref parsing from splat URLs with longest-branch-match strategy --- .../src/components/repo/code-file-view.tsx | 431 ++++++++++++++++++ .../src/components/repo/file-tree.tsx | 41 +- .../src/components/repo/folder-view.tsx | 135 ++++++ .../components/repo/repo-explorer-layout.tsx | 286 ++++++++++++ .../repo/repo-file-tree-sidebar.tsx | 242 ++++++++++ .../components/repo/repo-overview-page.tsx | 7 +- .../src/components/repo/use-prefetch-tree.ts | 92 ++++ apps/dashboard/src/lib/github.functions.ts | 59 +++ apps/dashboard/src/lib/github.query.ts | 18 + apps/dashboard/src/lib/github.types.ts | 7 + apps/dashboard/src/lib/parse-repo-ref.ts | 64 +++ apps/dashboard/src/routeTree.gen.ts | 44 ++ .../routes/_protected/$owner/$repo/blob.$.tsx | 102 +++++ .../routes/_protected/$owner/$repo/tree.$.tsx | 101 ++++ 14 files changed, 1619 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/src/components/repo/code-file-view.tsx create mode 100644 apps/dashboard/src/components/repo/folder-view.tsx create mode 100644 apps/dashboard/src/components/repo/repo-explorer-layout.tsx create mode 100644 apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx create mode 100644 apps/dashboard/src/components/repo/use-prefetch-tree.ts create mode 100644 apps/dashboard/src/lib/parse-repo-ref.ts create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx create mode 100644 apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx diff --git a/apps/dashboard/src/components/repo/code-file-view.tsx b/apps/dashboard/src/components/repo/code-file-view.tsx new file mode 100644 index 0000000..234da15 --- /dev/null +++ b/apps/dashboard/src/components/repo/code-file-view.tsx @@ -0,0 +1,431 @@ +import { + CopyIcon, + DownloadIcon, + FileIcon, + GitCommitIcon, +} from "@diffkit/icons"; +import { highlightCode } from "@diffkit/ui/components/markdown"; +import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@diffkit/ui/components/tooltip"; +import { useQuery } from "@tanstack/react-query"; +import { Suspense, use, useCallback, useMemo, useRef, useState } from "react"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import { + type GitHubQueryScope, + githubFileLastCommitQueryOptions, + githubRepoFileContentQueryOptions, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import type { FileLastCommit } from "#/lib/github.types"; + +const EXT_TO_LANG: Record = { + ts: "typescript", + mts: "typescript", + cts: "typescript", + tsx: "tsx", + js: "javascript", + mjs: "javascript", + cjs: "javascript", + jsx: "jsx", + json: "json", + jsonc: "json", + json5: "json", + md: "markdown", + mdx: "markdown", + html: "html", + htm: "html", + xhtml: "html", + svg: "html", + xml: "xml", + css: "css", + scss: "scss", + sass: "sass", + less: "less", + py: "python", + pyi: "python", + pyw: "python", + rs: "rust", + go: "go", + rb: "ruby", + erb: "ruby", + java: "java", + c: "c", + cpp: "cpp", + cc: "cpp", + cxx: "cpp", + h: "c", + hpp: "cpp", + hxx: "cpp", + cs: "csharp", + swift: "swift", + kt: "kotlin", + kts: "kotlin", + sql: "sql", + graphql: "graphql", + gql: "graphql", + yml: "yaml", + yaml: "yaml", + toml: "toml", + ini: "ini", + cfg: "ini", + conf: "ini", + env: "bash", + sh: "bash", + bash: "bash", + zsh: "bash", + fish: "bash", + ps1: "powershell", + dockerfile: "dockerfile", + diff: "diff", + patch: "diff", + lua: "lua", + r: "r", + R: "r", + pl: "perl", + pm: "perl", + php: "php", + ex: "elixir", + exs: "elixir", + erl: "erlang", + hs: "haskell", + scala: "scala", + clj: "clojure", + vim: "viml", + tf: "hcl", + tfvars: "hcl", + proto: "protobuf", + prisma: "prisma", +}; + +const NAME_TO_LANG: Record = { + dockerfile: "dockerfile", + makefile: "bash", + gemfile: "ruby", + rakefile: "ruby", + justfile: "bash", + vagrantfile: "ruby", + brewfile: "ruby", + ".gitignore": "bash", + ".gitattributes": "bash", + ".dockerignore": "bash", + ".editorconfig": "ini", + ".env": "bash", + ".env.local": "bash", + ".env.example": "bash", + ".npmrc": "ini", + ".eslintrc": "json", + ".prettierrc": "json", + ".babelrc": "json", +}; + +function detectLang(path: string): string { + const name = path.split("/").pop() ?? ""; + const lower = name.toLowerCase(); + const nameMatch = NAME_TO_LANG[lower]; + if (nameMatch) return nameMatch; + const ext = lower.split(".").pop() ?? ""; + return EXT_TO_LANG[ext] ?? "text"; +} + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(2)} MB`; +} + +export function CodeFileView({ + owner, + repo, + currentRef, + path, + scope, +}: { + owner: string; + repo: string; + currentRef: string; + path: string; + scope: GitHubQueryScope; +}) { + const contentQuery = useQuery( + githubRepoFileContentQueryOptions(scope, { + owner, + repo, + ref: currentRef, + path, + }), + ); + + const fileCommitQuery = useQuery( + githubFileLastCommitQueryOptions(scope, { + owner, + repo, + ref: currentRef, + path, + }), + ); + + // Fetch parent directory tree to get file metadata (size) + const parentPath = path.includes("/") + ? path.slice(0, path.lastIndexOf("/")) + : ""; + const parentTreeQuery = useQuery({ + ...githubRepoTreeQueryOptions(scope, { + owner, + repo, + ref: currentRef, + path: parentPath, + }), + }); + + const fileName = path.split("/").pop() ?? path; + const fileEntry = useMemo( + () => parentTreeQuery.data?.find((e) => e.name === fileName), + [parentTreeQuery.data, fileName], + ); + + if (contentQuery.isLoading) { + return ; + } + + if (contentQuery.error || contentQuery.data == null) { + return ( +
+ +
+ Unable to load file content. +
+
+ ); + } + + const code = contentQuery.data.replace(/\n$/, ""); + const lang = detectLang(path); + const lineCount = code.split("\n").length; + const commit = fileCommitQuery.data; + + return ( +
+ +
+ +
+ }> + + +
+
+
+ ); +} + +function FileViewHeader({ + fileName, + lineCount, + size, + code, + owner, + repo, + currentRef, + path, +}: { + fileName: string; + lineCount?: number; + size?: number | null; + code?: string; + owner?: string; + repo?: string; + currentRef?: string; + path?: string; +}) { + const [copied, setCopied] = useState(false); + const timeoutRef = useRef>(undefined); + + const handleCopy = useCallback(() => { + if (!code) return; + navigator.clipboard.writeText(code); + setCopied(true); + clearTimeout(timeoutRef.current); + timeoutRef.current = setTimeout(() => setCopied(false), 1500); + }, [code]); + + return ( +
+ + {fileName} +
+ {lineCount != null && ( + + {lineCount} {lineCount === 1 ? "line" : "lines"} + + )} + {lineCount != null && size != null && ( + + )} + {size != null && {formatFileSize(size)}} +
+
+ {code && ( + + )} + {owner && repo && currentRef && path && ( + + + + )} +
+
+ ); +} + +function FileCommitBar({ + commit, +}: { + commit: FileLastCommit | null | undefined; +}) { + if (!commit) { + return ( +
+ + +
+ ); + } + + const shortSha = commit.sha.slice(0, 7); + const firstLine = commit.message.split("\n")[0]; + + return ( +
+ {commit.author && ( + {commit.author.login} + )} + {commit.author?.login ?? "Unknown"} + + + + {firstLine} + + + {firstLine.length > 60 && ( + + {firstLine} + + )} + +
+ + + + + {shortSha} + + + + {commit.sha} + + + {formatRelativeTime(commit.date)} +
+
+ ); +} + +function LineNumbers({ count }: { count: number }) { + return ( +
+ {Array.from({ length: count }, (_, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: line numbers are stable indices + + {i + 1} + + ))} +
+ ); +} + +function PlainCode({ code }: { code: string }) { + const lineCount = code.split("\n").length; + + return ( +
+ +
+				{code}
+			
+
+ ); +} + +function HighlightedCode({ code, lang }: { code: string; lang: string }) { + const html = use(highlightCode(code, lang)); + const lineCount = code.split("\n").length; + + return ( +
+ +
+
+ ); +} + +const skeletonWidths = [ + "75%", + "60%", + "85%", + "45%", + "90%", + "55%", + "70%", + "80%", + "50%", + "65%", + "88%", + "42%", +]; + +function CodeFileViewSkeleton({ fileName }: { fileName: string }) { + return ( +
+ +
+ {skeletonWidths.map((width) => ( + + ))} +
+
+ ); +} diff --git a/apps/dashboard/src/components/repo/file-tree.tsx b/apps/dashboard/src/components/repo/file-tree.tsx index 5614284..b49d7e7 100644 --- a/apps/dashboard/src/components/repo/file-tree.tsx +++ b/apps/dashboard/src/components/repo/file-tree.tsx @@ -1,15 +1,29 @@ import { FileIcon, FolderIcon } from "@diffkit/icons"; import { cn } from "@diffkit/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; import { formatRelativeTime } from "#/lib/format-relative-time"; import type { RepoTreeEntry } from "#/lib/github.types"; -export function FileTree({ entries }: { entries: RepoTreeEntry[] }) { +export function FileTree({ + entries, + owner, + repo, + currentRef, +}: { + entries: RepoTreeEntry[]; + owner: string; + repo: string; + currentRef: string; +}) { return (
{entries.map((entry, index) => ( ))} @@ -19,15 +33,28 @@ export function FileTree({ entries }: { entries: RepoTreeEntry[] }) { function FileTreeRow({ entry, + owner, + repo, + currentRef, isLast, }: { entry: RepoTreeEntry; + owner: string; + repo: string; + currentRef: string; isLast: boolean; }) { const Icon = entry.type === "dir" ? FolderIcon : FileIcon; + const isDir = entry.type === "dir"; return ( -
{entry.name} @@ -63,6 +86,6 @@ function FileTreeRow({ ? formatRelativeTime(entry.lastCommit.date) : ""} -
+ ); } diff --git a/apps/dashboard/src/components/repo/folder-view.tsx b/apps/dashboard/src/components/repo/folder-view.tsx new file mode 100644 index 0000000..ebd5a95 --- /dev/null +++ b/apps/dashboard/src/components/repo/folder-view.tsx @@ -0,0 +1,135 @@ +import { FileIcon, FolderIcon } from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { Link } from "@tanstack/react-router"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { GitHubQueryScope } from "#/lib/github.query"; +import type { RepoOverview, RepoTreeEntry } from "#/lib/github.types"; +import { LatestCommitBar } from "./latest-commit-bar"; +import { RepoMarkdownFiles } from "./repo-markdown-files"; + +export function FolderView({ + entries, + repo, + owner, + repoName, + currentRef, + currentPath, + scope, +}: { + entries: RepoTreeEntry[]; + repo: RepoOverview; + owner: string; + repoName: string; + currentRef: string; + currentPath: string; + scope: GitHubQueryScope; +}) { + return ( +
+
+ +
+ {entries.map((entry, index) => ( + + ))} +
+
+ + +
+ ); +} + +function FolderViewRow({ + entry, + owner, + repoName, + currentRef, + currentPath, + isLast, +}: { + entry: RepoTreeEntry; + owner: string; + repoName: string; + currentRef: string; + currentPath: string; + isLast: boolean; +}) { + const Icon = entry.type === "dir" ? FolderIcon : FileIcon; + const isDir = entry.type === "dir"; + const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; + return ( + +
+ + + {entry.name} + +
+ + {entry.lastCommit?.message ?? ""} + + + {entry.lastCommit?.date + ? formatRelativeTime(entry.lastCommit.date) + : ""} + + + ); +} + +export function FolderViewSkeleton() { + const rows = Array.from({ length: 8 }, (_, i) => i); + return ( +
+ {rows.map((key) => ( +
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-explorer-layout.tsx b/apps/dashboard/src/components/repo/repo-explorer-layout.tsx new file mode 100644 index 0000000..3eeaeab --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-explorer-layout.tsx @@ -0,0 +1,286 @@ +import { ChevronLeftIcon, PanelLeftIcon } from "@diffkit/icons"; +import { + Drawer, + DrawerContent, + DrawerTitle, +} from "@diffkit/ui/components/drawer"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@diffkit/ui/components/resizable"; +import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { useQuery } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { memo, useMemo, useState, useSyncExternalStore } from "react"; +import { + type GitHubQueryScope, + githubRepoOverviewQueryOptions, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import type { RepoOverview } from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; +import { useRegisterTab } from "#/lib/use-register-tab"; +import { CodeExplorerToolbar } from "./code-explorer-toolbar"; +import { CodeFileView } from "./code-file-view"; +import { FolderView, FolderViewSkeleton } from "./folder-view"; +import { RepoFileTreeSidebar } from "./repo-file-tree-sidebar"; +import { usePrefetchTree } from "./use-prefetch-tree"; + +const MD_QUERY = "(min-width: 768px)"; +const mdSubscribe = (cb: () => void) => { + const mql = window.matchMedia(MD_QUERY); + mql.addEventListener("change", cb); + return () => mql.removeEventListener("change", cb); +}; +const getMdSnapshot = () => window.matchMedia(MD_QUERY).matches; +const getMdServerSnapshot = () => true; + +function useIsDesktop() { + return useSyncExternalStore(mdSubscribe, getMdSnapshot, getMdServerSnapshot); +} + +export function RepoExplorerLayout({ + owner, + repo: repoName, + scope, + currentRef: currentRefProp, + currentPath, + viewMode, +}: { + owner: string; + repo: string; + scope: GitHubQueryScope; + currentRef: string | null; + currentPath: string; + viewMode: "tree" | "blob"; +}) { + const hasMounted = useHasMounted(); + const navigate = useNavigate(); + const isDesktop = useIsDesktop(); + const [drawerOpen, setDrawerOpen] = useState(false); + + const overviewQuery = useQuery({ + ...githubRepoOverviewQueryOptions(scope, { owner, repo: repoName }), + enabled: hasMounted, + }); + + const repoData = overviewQuery.data; + const activeRef = currentRefProp ?? repoData?.defaultBranch ?? "main"; + + useRegisterTab( + repoData + ? { + type: "repo", + title: `${owner}/${repoData.name}`, + url: `/${owner}/${repoName}`, + repo: `${owner}/${repoName}`, + iconColor: "text-muted-foreground", + avatarUrl: repoData.ownerAvatarUrl, + } + : null, + ); + + const rootTreeQuery = useQuery({ + ...githubRepoTreeQueryOptions(scope, { + owner, + repo: repoName, + ref: activeRef, + path: "", + }), + enabled: hasMounted && !!repoData, + }); + + const prefetchInput = useMemo( + () => ({ owner, repo: repoName, ref: activeRef }), + [owner, repoName, activeRef], + ); + usePrefetchTree(scope, prefetchInput, rootTreeQuery.data); + + const currentTreeQuery = useQuery({ + ...githubRepoTreeQueryOptions(scope, { + owner, + repo: repoName, + ref: activeRef, + path: currentPath, + }), + enabled: + hasMounted && !!repoData && viewMode === "tree" && currentPath !== "", + }); + + const handleBranchChange = (branch: string) => { + if (currentPath) { + void navigate({ + to: "/$owner/$repo/tree/$", + params: { + owner, + repo: repoName, + _splat: `${branch}/${currentPath}`, + }, + }); + } else { + void navigate({ + to: "/$owner/$repo", + params: { owner, repo: repoName }, + }); + } + }; + + if (overviewQuery.error) throw overviewQuery.error; + if (!repoData) return ; + + const treeEntries = + viewMode === "tree" + ? currentPath === "" + ? rootTreeQuery.data + : currentTreeQuery.data + : null; + + const contentPane = + viewMode === "blob" ? ( +
+ +
+ ) : treeEntries ? ( +
+ +
+ ) : ( +
+ +
+ ); + + const sidebarContent = rootTreeQuery.data ? ( + + ) : null; + + return ( +
+ setDrawerOpen(true)} + isDesktop={isDesktop} + /> + + {isDesktop ? ( + + + {sidebarContent} + + + + + {contentPane} + + ) : ( + <> +
{contentPane}
+ + + File tree + {sidebarContent} + + + + )} +
+ ); +} + +const ExplorerToolbar = memo(function ExplorerToolbar({ + owner, + repoName, + repo, + currentRef, + scope, + onBranchChange, + onOpenFileSheet, + isDesktop, +}: { + owner: string; + repoName: string; + repo: RepoOverview; + currentRef: string; + scope: GitHubQueryScope; + onBranchChange: (branch: string) => void; + onOpenFileSheet: () => void; + isDesktop: boolean; +}) { + return ( +
+ {!isDesktop && ( + + )} + + + + + {owner}/{repoName} + + + +
+ +
+
+ ); +}); + +function ExplorerSkeleton() { + return ( +
+
+ +
+ +
+
+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx b/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx new file mode 100644 index 0000000..278a903 --- /dev/null +++ b/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx @@ -0,0 +1,242 @@ +import { ChevronRightIcon, FileIcon, FolderIcon } from "@diffkit/icons"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { cn } from "@diffkit/ui/lib/utils"; +import { useQuery } from "@tanstack/react-query"; +import { Link } from "@tanstack/react-router"; +import { memo, useState } from "react"; +import { + type GitHubQueryScope, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import type { RepoTreeEntry } from "#/lib/github.types"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +export const RepoFileTreeSidebar = memo(function RepoFileTreeSidebar({ + owner, + repoName, + currentRef, + currentPath, + scope, + entries, +}: { + owner: string; + repoName: string; + currentRef: string; + currentPath: string; + scope: GitHubQueryScope; + entries: RepoTreeEntry[]; +}) { + return ( +
+
+ {entries.map((entry) => ( + + ))} +
+
+ ); +}); + +const TreeNode = memo(function TreeNode({ + entry, + owner, + repoName, + currentRef, + currentPath, + scope, + depth, + parentPath, +}: { + entry: RepoTreeEntry; + owner: string; + repoName: string; + currentRef: string; + currentPath: string; + scope: GitHubQueryScope; + depth: number; + parentPath: string; +}) { + const entryPath = parentPath ? `${parentPath}/${entry.name}` : entry.name; + const isDir = entry.type === "dir"; + + if (isDir) { + return ( + + ); + } + + return ( + + ); +}); + +const DirectoryNode = memo(function DirectoryNode({ + entry, + owner, + repoName, + currentRef, + currentPath, + scope, + depth, + entryPath, +}: { + entry: RepoTreeEntry; + owner: string; + repoName: string; + currentRef: string; + currentPath: string; + scope: GitHubQueryScope; + depth: number; + entryPath: string; +}) { + const isActive = currentPath === entryPath; + const isAncestor = currentPath.startsWith(`${entryPath}/`); + const [isOpen, setIsOpen] = useState(isAncestor || isActive); + const hasMounted = useHasMounted(); + + const treeQuery = useQuery({ + ...githubRepoTreeQueryOptions(scope, { + owner, + repo: repoName, + ref: currentRef, + path: entryPath, + }), + enabled: hasMounted && isOpen, + }); + + const handleToggle = () => { + setIsOpen((prev) => !prev); + }; + + return ( +
+ + + {isOpen && ( +
+ {treeQuery.isLoading && ( +
+ + Loading... +
+ )} + {treeQuery.data?.map((child) => ( + + ))} +
+ )} +
+ ); +}); + +const FileNode = memo(function FileNode({ + entry, + owner, + repoName, + currentRef, + currentPath, + depth, + entryPath, +}: { + entry: RepoTreeEntry; + owner: string; + repoName: string; + currentRef: string; + currentPath: string; + depth: number; + entryPath: string; +}) { + const isActive = currentPath === entryPath; + + return ( + + + + {entry.name} + + + ); +}); diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index 586cd3d..e3b04d0 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -77,7 +77,12 @@ export function RepoOverviewPage() {
{treeQuery.data ? ( - + ) : ( )} diff --git a/apps/dashboard/src/components/repo/use-prefetch-tree.ts b/apps/dashboard/src/components/repo/use-prefetch-tree.ts new file mode 100644 index 0000000..3f2489a --- /dev/null +++ b/apps/dashboard/src/components/repo/use-prefetch-tree.ts @@ -0,0 +1,92 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import { + type GitHubQueryScope, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import type { RepoTreeEntry } from "#/lib/github.types"; + +const BATCH_SIZE = 3; +const BATCH_DELAY_MS = 150; + +/** + * Progressively prefetch all directory subtrees in BFS order. + * Starts from the root entries and walks deeper level by level, + * fetching BATCH_SIZE directories at a time with a small delay + * between batches to avoid flooding the network. + */ +export function usePrefetchTree( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string }, + rootEntries: RepoTreeEntry[] | undefined, +) { + const queryClient = useQueryClient(); + const runIdRef = useRef(0); + + useEffect(() => { + if (!rootEntries) return; + + const runId = ++runIdRef.current; + + const queue: string[] = []; + for (const entry of rootEntries) { + if (entry.type === "dir") { + queue.push(entry.name); + } + } + + let cursor = 0; + + async function processNextBatch() { + if (runIdRef.current !== runId) return; + if (cursor >= queue.length) return; + + const batch = queue.slice(cursor, cursor + BATCH_SIZE); + cursor += batch.length; + + const results = await Promise.all( + batch.map((path) => { + const options = githubRepoTreeQueryOptions(scope, { + ...input, + path, + }); + + // Skip if already cached + if (queryClient.getQueryData(options.queryKey)) { + return queryClient.getQueryData(options.queryKey) as + | RepoTreeEntry[] + | undefined; + } + + return queryClient.fetchQuery(options).catch(() => undefined); + }), + ); + + if (runIdRef.current !== runId) return; + + // Enqueue child directories discovered in this batch + for (let i = 0; i < batch.length; i++) { + const parentPath = batch[i]; + const children = results[i]; + if (children) { + for (const child of children) { + if (child.type === "dir") { + queue.push(`${parentPath}/${child.name}`); + } + } + } + } + + // Schedule next batch + if (cursor < queue.length) { + setTimeout(processNextBatch, BATCH_DELAY_MS); + } + } + + processNextBatch(); + + return () => { + runIdRef.current++; + }; + }, [rootEntries, scope, input, queryClient]); +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 81ff697..a64cfc7 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -8,6 +8,7 @@ import type { CreateLabelInput, CreateReviewCommentInput, DiscussionsResult, + FileLastCommit, GitHubActor, GitHubContributionCalendar, GitHubLabel, @@ -6873,6 +6874,64 @@ export const getRepoFileContent = createServerFn({ method: "GET" }) }).catch(() => null); }); +// --------------------------------------------------------------------------- +// File last commit +// --------------------------------------------------------------------------- + +type FileLastCommitInput = { + owner: string; + repo: string; + path: string; + ref: string; +}; + +export const getFileLastCommit = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return null; + + return getCachedGitHubRequest< + Awaited>["data"], + FileLastCommit | null + >({ + context, + resource: "repo.fileLastCommit.v1", + params: data, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [githubRevalidationSignalKeys.repoCode(data)], + namespaceKeys: [githubRevalidationSignalKeys.repoCode(data)], + cacheMode: "split", + request: (headers) => + context.octokit.rest.repos.listCommits({ + owner: data.owner, + repo: data.repo, + sha: data.ref, + path: data.path, + per_page: 1, + headers, + }), + mapData: (commits) => { + const commit = commits[0]; + if (!commit) return null; + return { + sha: commit.sha, + message: commit.commit.message, + date: + commit.commit.committer?.date ?? commit.commit.author?.date ?? "", + author: commit.author + ? { + login: commit.author.login, + avatarUrl: commit.author.avatar_url, + url: commit.author.html_url, + type: commit.author.type, + } + : null, + }; + }, + }).catch(() => null); + }); + // --------------------------------------------------------------------------- // Repository contributors // --------------------------------------------------------------------------- diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 82b3a36..8f9fb35 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -2,6 +2,7 @@ import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { type CommandPaletteSearchInput, getCommentPage, + getFileLastCommit, getGitHubViewer, getIssueComments, getIssueFromRepo, @@ -199,6 +200,10 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string; path: string }, ) => ["github", scope.userId, "repo", "fileContent", input] as const, + fileLastCommit: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; path: string }, + ) => ["github", scope.userId, "repo", "fileLastCommit", input] as const, contributors: ( scope: GitHubQueryScope, input: { owner: string; repo: string }, @@ -661,6 +666,19 @@ export function githubRepoFileContentQueryOptions( }); } +export function githubFileLastCommitQueryOptions( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; path: string }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.fileLastCommit(scope, input), + queryFn: () => getFileLastCommit({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + }); +} + export function githubRepoDiscussionsQueryOptions( scope: GitHubQueryScope, input: { owner: string; repo: string }, diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index 199d0a4..64e289d 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -470,6 +470,13 @@ export type RepoTreeEntry = { } | null; }; +export type FileLastCommit = { + sha: string; + message: string; + date: string; + author: GitHubActor | null; +}; + export type RepoBranch = { name: string; isProtected: boolean; diff --git a/apps/dashboard/src/lib/parse-repo-ref.ts b/apps/dashboard/src/lib/parse-repo-ref.ts new file mode 100644 index 0000000..8b100f3 --- /dev/null +++ b/apps/dashboard/src/lib/parse-repo-ref.ts @@ -0,0 +1,64 @@ +import type { RepoBranch } from "#/lib/github.types"; + +/** + * Parse a splat string like "main/src/lib/foo.ts" into { ref, path }. + * + * Strategy: + * 1. If branches are available, find the longest branch name that matches a prefix. + * 2. Otherwise, check if defaultBranch is a prefix. + * 3. Fallback: treat the first segment as the ref. + */ +export function parseRepoRef( + splat: string, + options: { + branches?: RepoBranch[]; + defaultBranch?: string; + } = {}, +): { ref: string; path: string } { + if (!splat) { + return { ref: options.defaultBranch ?? "main", path: "" }; + } + + const { branches, defaultBranch } = options; + + // Try matching against known branches (longest match first) + if (branches && branches.length > 0) { + const sortedBranches = [...branches].sort( + (a, b) => b.name.length - a.name.length, + ); + for (const branch of sortedBranches) { + if (splat === branch.name) { + return { ref: branch.name, path: "" }; + } + if (splat.startsWith(`${branch.name}/`)) { + return { + ref: branch.name, + path: splat.slice(branch.name.length + 1), + }; + } + } + } + + // Try default branch + if (defaultBranch) { + if (splat === defaultBranch) { + return { ref: defaultBranch, path: "" }; + } + if (splat.startsWith(`${defaultBranch}/`)) { + return { + ref: defaultBranch, + path: splat.slice(defaultBranch.length + 1), + }; + } + } + + // Fallback: first segment is the ref + const slashIndex = splat.indexOf("/"); + if (slashIndex === -1) { + return { ref: splat, path: "" }; + } + return { + ref: splat.slice(0, slashIndex), + path: splat.slice(slashIndex + 1), + }; +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 5014470..a1531ad 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -30,10 +30,12 @@ import { Route as ApiGithubAppCallbackRouteImport } from './routes/api/github/ap import { Route as ApiGithubAppAuthorizeRouteImport } from './routes/api/github/app/authorize' import { Route as ProtectedOwnerRepoPullsRouteImport } from './routes/_protected/$owner/$repo/pulls' import { Route as ProtectedOwnerRepoIssuesIndexRouteImport } from './routes/_protected/$owner/$repo/issues.index' +import { Route as ProtectedOwnerRepoTreeSplatRouteImport } from './routes/_protected/$owner/$repo/tree.$' import { Route as ProtectedOwnerRepoReviewPullIdRouteImport } from './routes/_protected/$owner/$repo/review.$pullId' import { Route as ProtectedOwnerRepoPullPullIdRouteImport } from './routes/_protected/$owner/$repo/pull.$pullId' import { Route as ProtectedOwnerRepoIssuesNewRouteImport } from './routes/_protected/$owner/$repo/issues.new' import { Route as ProtectedOwnerRepoIssuesIssueIdRouteImport } from './routes/_protected/$owner/$repo/issues.$issueId' +import { Route as ProtectedOwnerRepoBlobSplatRouteImport } from './routes/_protected/$owner/$repo/blob.$' const TermsRoute = TermsRouteImport.update({ id: '/terms', @@ -141,6 +143,12 @@ const ProtectedOwnerRepoIssuesIndexRoute = path: '/$owner/$repo/issues/', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedOwnerRepoTreeSplatRoute = + ProtectedOwnerRepoTreeSplatRouteImport.update({ + id: '/$owner/$repo/tree/$', + path: '/$owner/$repo/tree/$', + getParentRoute: () => ProtectedRoute, + } as any) const ProtectedOwnerRepoReviewPullIdRoute = ProtectedOwnerRepoReviewPullIdRouteImport.update({ id: '/$owner/$repo/review/$pullId', @@ -165,6 +173,12 @@ const ProtectedOwnerRepoIssuesIssueIdRoute = path: '/$owner/$repo/issues/$issueId', getParentRoute: () => ProtectedRoute, } as any) +const ProtectedOwnerRepoBlobSplatRoute = + ProtectedOwnerRepoBlobSplatRouteImport.update({ + id: '/$owner/$repo/blob/$', + path: '/$owner/$repo/blob/$', + getParentRoute: () => ProtectedRoute, + } as any) export interface FileRoutesByFullPath { '/$': typeof SplatRoute @@ -186,10 +200,12 @@ export interface FileRoutesByFullPath { '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute + '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute + '/$owner/$repo/tree/$': typeof ProtectedOwnerRepoTreeSplatRoute '/$owner/$repo/issues/': typeof ProtectedOwnerRepoIssuesIndexRoute } export interface FileRoutesByTo { @@ -211,10 +227,12 @@ export interface FileRoutesByTo { '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/$owner/$repo': typeof ProtectedOwnerRepoIndexRoute + '/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute + '/$owner/$repo/tree/$': typeof ProtectedOwnerRepoTreeSplatRoute '/$owner/$repo/issues': typeof ProtectedOwnerRepoIssuesIndexRoute } export interface FileRoutesById { @@ -239,10 +257,12 @@ export interface FileRoutesById { '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute '/_protected/$owner/$repo/': typeof ProtectedOwnerRepoIndexRoute + '/_protected/$owner/$repo/blob/$': typeof ProtectedOwnerRepoBlobSplatRoute '/_protected/$owner/$repo/issues/$issueId': typeof ProtectedOwnerRepoIssuesIssueIdRoute '/_protected/$owner/$repo/issues/new': typeof ProtectedOwnerRepoIssuesNewRoute '/_protected/$owner/$repo/pull/$pullId': typeof ProtectedOwnerRepoPullPullIdRoute '/_protected/$owner/$repo/review/$pullId': typeof ProtectedOwnerRepoReviewPullIdRoute + '/_protected/$owner/$repo/tree/$': typeof ProtectedOwnerRepoTreeSplatRoute '/_protected/$owner/$repo/issues/': typeof ProtectedOwnerRepoIssuesIndexRoute } export interface FileRouteTypes { @@ -267,10 +287,12 @@ export interface FileRouteTypes { | '/api/github/app/authorize' | '/api/github/app/callback' | '/$owner/$repo/' + | '/$owner/$repo/blob/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' + | '/$owner/$repo/tree/$' | '/$owner/$repo/issues/' fileRoutesByTo: FileRoutesByTo to: @@ -292,10 +314,12 @@ export interface FileRouteTypes { | '/api/github/app/authorize' | '/api/github/app/callback' | '/$owner/$repo' + | '/$owner/$repo/blob/$' | '/$owner/$repo/issues/$issueId' | '/$owner/$repo/issues/new' | '/$owner/$repo/pull/$pullId' | '/$owner/$repo/review/$pullId' + | '/$owner/$repo/tree/$' | '/$owner/$repo/issues' id: | '__root__' @@ -319,10 +343,12 @@ export interface FileRouteTypes { | '/api/github/app/authorize' | '/api/github/app/callback' | '/_protected/$owner/$repo/' + | '/_protected/$owner/$repo/blob/$' | '/_protected/$owner/$repo/issues/$issueId' | '/_protected/$owner/$repo/issues/new' | '/_protected/$owner/$repo/pull/$pullId' | '/_protected/$owner/$repo/review/$pullId' + | '/_protected/$owner/$repo/tree/$' | '/_protected/$owner/$repo/issues/' fileRoutesById: FileRoutesById } @@ -488,6 +514,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedOwnerRepoIssuesIndexRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/$owner/$repo/tree/$': { + id: '/_protected/$owner/$repo/tree/$' + path: '/$owner/$repo/tree/$' + fullPath: '/$owner/$repo/tree/$' + preLoaderRoute: typeof ProtectedOwnerRepoTreeSplatRouteImport + parentRoute: typeof ProtectedRoute + } '/_protected/$owner/$repo/review/$pullId': { id: '/_protected/$owner/$repo/review/$pullId' path: '/$owner/$repo/review/$pullId' @@ -516,6 +549,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedOwnerRepoIssuesIssueIdRouteImport parentRoute: typeof ProtectedRoute } + '/_protected/$owner/$repo/blob/$': { + id: '/_protected/$owner/$repo/blob/$' + path: '/$owner/$repo/blob/$' + fullPath: '/$owner/$repo/blob/$' + preLoaderRoute: typeof ProtectedOwnerRepoBlobSplatRouteImport + parentRoute: typeof ProtectedRoute + } } } @@ -541,10 +581,12 @@ interface ProtectedRouteChildren { ProtectedOwnerIndexRoute: typeof ProtectedOwnerIndexRoute ProtectedOwnerRepoPullsRoute: typeof ProtectedOwnerRepoPullsRoute ProtectedOwnerRepoIndexRoute: typeof ProtectedOwnerRepoIndexRoute + ProtectedOwnerRepoBlobSplatRoute: typeof ProtectedOwnerRepoBlobSplatRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoIssuesNewRoute: typeof ProtectedOwnerRepoIssuesNewRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute + ProtectedOwnerRepoTreeSplatRoute: typeof ProtectedOwnerRepoTreeSplatRoute ProtectedOwnerRepoIssuesIndexRoute: typeof ProtectedOwnerRepoIssuesIndexRoute } @@ -557,10 +599,12 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedOwnerIndexRoute: ProtectedOwnerIndexRoute, ProtectedOwnerRepoPullsRoute: ProtectedOwnerRepoPullsRoute, ProtectedOwnerRepoIndexRoute: ProtectedOwnerRepoIndexRoute, + ProtectedOwnerRepoBlobSplatRoute: ProtectedOwnerRepoBlobSplatRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoIssuesNewRoute: ProtectedOwnerRepoIssuesNewRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, + ProtectedOwnerRepoTreeSplatRoute: ProtectedOwnerRepoTreeSplatRoute, ProtectedOwnerRepoIssuesIndexRoute: ProtectedOwnerRepoIssuesIndexRoute, } diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx new file mode 100644 index 0000000..93562d7 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx @@ -0,0 +1,102 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { RepoExplorerLayout } from "#/components/repo/repo-explorer-layout"; +import { + githubRepoBranchesQueryOptions, + githubRepoFileContentQueryOptions, + githubRepoOverviewQueryOptions, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import { parseRepoRef } from "#/lib/parse-repo-ref"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +export const Route = createFileRoute("/_protected/$owner/$repo/blob/$")({ + ssr: false, + loader: ({ context, params }) => { + const scope = { userId: context.user.id }; + const splat = params._splat ?? ""; + + const overviewOptions = githubRepoOverviewQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }); + + void context.queryClient.prefetchQuery(overviewOptions); + void context.queryClient.prefetchQuery( + githubRepoBranchesQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }), + ); + + const cachedOverview = context.queryClient.getQueryData( + overviewOptions.queryKey, + ); + const cachedBranches = context.queryClient.getQueryData( + githubRepoBranchesQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }).queryKey, + ); + + const { ref, path } = parseRepoRef(splat, { + branches: cachedBranches ?? undefined, + defaultBranch: cachedOverview?.defaultBranch, + }); + + // Prefetch file content + if (path) { + void context.queryClient.prefetchQuery( + githubRepoFileContentQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + path, + }), + ); + } + + // Prefetch root tree for the sidebar + void context.queryClient.prefetchQuery( + githubRepoTreeQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + path: "", + }), + ); + + return { ref, path }; + }, + head: ({ match, params }) => { + const fileName = match.loaderData?.path?.split("/").pop(); + return buildSeo({ + path: match.pathname, + title: formatPageTitle( + fileName + ? `${fileName} - ${params.owner}/${params.repo}` + : `${params.owner}/${params.repo}`, + ), + description: `View file in ${params.owner}/${params.repo}.`, + robots: "noindex", + }); + }, + component: BlobPage, +}); + +function BlobPage() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const { ref, path } = Route.useLoaderData(); + const scope = { userId: user.id }; + + return ( + + ); +} diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx new file mode 100644 index 0000000..a2d49b5 --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/tree.$.tsx @@ -0,0 +1,101 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { RepoExplorerLayout } from "#/components/repo/repo-explorer-layout"; +import { + githubRepoBranchesQueryOptions, + githubRepoOverviewQueryOptions, + githubRepoTreeQueryOptions, +} from "#/lib/github.query"; +import { parseRepoRef } from "#/lib/parse-repo-ref"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; + +export const Route = createFileRoute("/_protected/$owner/$repo/tree/$")({ + ssr: false, + loader: ({ context, params }) => { + const scope = { userId: context.user.id }; + const splat = params._splat ?? ""; + + const overviewOptions = githubRepoOverviewQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }); + + // Prefetch overview and branches in parallel + void context.queryClient.prefetchQuery(overviewOptions); + void context.queryClient.prefetchQuery( + githubRepoBranchesQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }), + ); + + // Parse ref from splat using cached data if available + const cachedOverview = context.queryClient.getQueryData( + overviewOptions.queryKey, + ); + const cachedBranches = context.queryClient.getQueryData( + githubRepoBranchesQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + }).queryKey, + ); + + const { ref, path } = parseRepoRef(splat, { + branches: cachedBranches ?? undefined, + defaultBranch: cachedOverview?.defaultBranch, + }); + + // Prefetch the tree for this path + void context.queryClient.prefetchQuery( + githubRepoTreeQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + path, + }), + ); + + // Also prefetch root tree for the sidebar + if (path !== "") { + void context.queryClient.prefetchQuery( + githubRepoTreeQueryOptions(scope, { + owner: params.owner, + repo: params.repo, + ref, + path: "", + }), + ); + } + + return { ref, path }; + }, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle( + match.loaderData?.path + ? `${match.loaderData.path} - ${params.owner}/${params.repo}` + : `${params.owner}/${params.repo}`, + ), + description: `Browse files in ${params.owner}/${params.repo}.`, + robots: "noindex", + }), + component: TreePage, +}); + +function TreePage() { + const { user } = Route.useRouteContext(); + const { owner, repo } = Route.useParams(); + const { ref, path } = Route.useLoaderData(); + const scope = { userId: user.id }; + + return ( + + ); +} From e13238249300cb10594ae7038615a716f7c7aad8 Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Wed, 15 Apr 2026 22:47:52 -0400 Subject: [PATCH 2/3] feat: shared file search, per-entry commits, image preview, and UI polish - Extract file search card into shared component for reuse across repo sidebar and PR review page - Add fuzzy file search library (fuzzy-file-search.ts) - Fetch per-entry last commit data in folder view and file tree (commit message + relative time) - Render image files (png, jpg, gif, etc.) as media preview instead of blob content - Add preview/code toggle for SVG files - Fix long-line background rendering by moving overflow to outer wrapper with sticky line numbers - Split directory node click: arrow toggles expand, name navigates to folder - Center README content with max-w-3xl and increased vertical padding - Use bg-surface-1 for latest commit bar - Align search card top padding with content pane - Add F shortcut to focus search in PR review page --- .../components/pulls/review/review-page.tsx | 84 ++-- .../src/components/repo/code-file-view.tsx | 194 ++++++++- .../src/components/repo/file-tree.tsx | 36 +- .../src/components/repo/folder-view.tsx | 36 +- .../src/components/repo/latest-commit-bar.tsx | 2 +- .../repo/repo-file-tree-sidebar.tsx | 151 +++++-- .../components/repo/repo-markdown-files.tsx | 4 +- .../components/repo/repo-overview-page.tsx | 1 + .../components/shared/file-search-card.tsx | 383 ++++++++++++++++++ apps/dashboard/src/lib/fuzzy-file-search.ts | 94 +++++ 10 files changed, 883 insertions(+), 102 deletions(-) create mode 100644 apps/dashboard/src/components/shared/file-search-card.tsx create mode 100644 apps/dashboard/src/lib/fuzzy-file-search.ts diff --git a/apps/dashboard/src/components/pulls/review/review-page.tsx b/apps/dashboard/src/components/pulls/review/review-page.tsx index e7b0e2e..aee68bb 100644 --- a/apps/dashboard/src/components/pulls/review/review-page.tsx +++ b/apps/dashboard/src/components/pulls/review/review-page.tsx @@ -1,9 +1,4 @@ -import { - FileIcon, - GitPullRequestIcon, - PanelLeftIcon, - SearchIcon, -} from "@diffkit/icons"; +import { FileIcon, GitPullRequestIcon, PanelLeftIcon } from "@diffkit/icons"; import { Drawer, DrawerContent, @@ -30,13 +25,13 @@ import { memo, Suspense, useCallback, - useDeferredValue, useMemo, useRef, useState, useSyncExternalStore, } from "react"; import { getPrStateConfig } from "#/components/pulls/detail/pull-detail-header"; +import { FileSearchCard } from "#/components/shared/file-search-card"; import { getPullFiles, submitPullReview } from "#/lib/github.functions"; import { githubPullFileSummariesQueryOptions, @@ -66,11 +61,10 @@ import { import { ReviewSubmitPopover } from "./review-submit-popover"; import type { ActiveCommentForm, - FileTreeNode, PendingComment, ReviewEvent, } from "./review-types"; -import { buildFileTree } from "./review-utils"; +import { buildFileTree, encodeFileId } from "./review-utils"; const routeApi = getRouteApi("/_protected/$owner/$repo/review/$pullId"); const PULL_FILES_PAGE_SIZE = 25; @@ -735,54 +729,46 @@ const ReviewSidebar = memo(function ReviewSidebar({ activeFileStore: ActiveFileStore; onFileClick: (path: string) => void; }) { - const [fileFilter, setFileFilter] = useState(""); - const deferredFileFilter = useDeferredValue(fileFilter); - const fileTree = useMemo(() => buildFileTree(sidebarFiles), [sidebarFiles]); - const filteredTree = useMemo(() => { - if (!deferredFileFilter) return fileTree; - const lower = deferredFileFilter.toLowerCase(); - - function filterNodes(nodes: FileTreeNode[]): FileTreeNode[] { - return nodes - .map((node) => { - if (node.type === "file") { - return node.name.toLowerCase().includes(lower) ? node : null; - } - - const filteredChildren = filterNodes(node.children); - return filteredChildren.length > 0 - ? { ...node, children: filteredChildren } - : null; - }) - .filter(Boolean) as FileTreeNode[]; - } + const searchEntries = useMemo( + () => + sidebarFiles.map((f) => ({ + path: f.filename, + name: f.filename.split("/").pop() ?? f.filename, + type: "file" as const, + })), + [sidebarFiles], + ); - return filterNodes(fileTree); - }, [deferredFileFilter, fileTree]); + const activeFile = useSyncExternalStore( + activeFileStore.subscribe, + activeFileStore.get, + ); + + const handleSearchSelect = useCallback( + (entry: { path: string }) => { + onFileClick(entry.path); + const hash = `#${encodeFileId(entry.path)}`; + if (window.location.hash !== hash) { + history.replaceState(null, "", hash); + } + }, + [onFileClick], + ); return (
-
-
- - setFileFilter(event.target.value)} - className="ml-2 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground" - /> -
-
+
- {filteredTree.map((node) => ( + {fileTree.map((node) => ( = { ts: "typescript", mts: "typescript", @@ -187,6 +207,37 @@ export function CodeFileView({ [parentTreeQuery.data, fileName], ); + const commit = fileCommitQuery.data; + const isImage = isImageFile(path); + const isSvg = path.toLowerCase().endsWith(".svg"); + + // Non-SVG images — no code content needed + if (isImage && !isSvg) { + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${currentRef}/${path}`; + return ( +
+ +
+ +
+ {fileName} +
+
+
+ ); + } + if (contentQuery.isLoading) { return ; } @@ -205,7 +256,24 @@ export function CodeFileView({ const code = contentQuery.data.replace(/\n$/, ""); const lang = detectLang(path); const lineCount = code.split("\n").length; - const commit = fileCommitQuery.data; + + // SVG — toggle between preview and code + if (isSvg) { + return ( + + ); + } return (
@@ -231,6 +299,95 @@ export function CodeFileView({ ); } +function SvgFileView({ + code, + lang, + lineCount, + fileName, + size, + commit, + owner, + repo, + currentRef, + path, +}: { + code: string; + lang: string; + lineCount: number; + fileName: string; + size: number | null; + commit: FileLastCommit | null | undefined; + owner: string; + repo: string; + currentRef: string; + path: string; +}) { + const [mode, setMode] = useState<"preview" | "code">("preview"); + const rawUrl = `https://raw.githubusercontent.com/${owner}/${repo}/${currentRef}/${path}`; + + return ( +
+ +
+ +
+ + +
+
+ {mode === "preview" ? ( +
+ {fileName} +
+ ) : ( +
+ }> + + +
+ )} +
+
+ ); +} + function FileViewHeader({ fileName, lineCount, @@ -240,6 +397,7 @@ function FileViewHeader({ repo, currentRef, path, + children, }: { fileName: string; lineCount?: number; @@ -249,6 +407,7 @@ function FileViewHeader({ repo?: string; currentRef?: string; path?: string; + children?: React.ReactNode; }) { const [copied, setCopied] = useState(false); const timeoutRef = useRef>(undefined); @@ -276,7 +435,8 @@ function FileViewHeader({ )} {size != null && {formatFileSize(size)}}
-
+
+ {children} {code && ( + + { + if (!isOpen) setIsOpen(true); + }} + className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pl-1.5 pr-3" + > + + {entry.name} + +
{isOpen && (
diff --git a/apps/dashboard/src/components/repo/repo-markdown-files.tsx b/apps/dashboard/src/components/repo/repo-markdown-files.tsx index 51f6ae8..1db7877 100644 --- a/apps/dashboard/src/components/repo/repo-markdown-files.tsx +++ b/apps/dashboard/src/components/repo/repo-markdown-files.tsx @@ -180,7 +180,7 @@ function MarkdownFileContent({ } return ( -
+
@@ -191,7 +191,7 @@ function MarkdownFileContent({ } > {contentQuery.data} diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index e3b04d0..b0be5d3 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -82,6 +82,7 @@ export function RepoOverviewPage() { owner={owner} repo={repo} currentRef={activeRef} + scope={scope} /> ) : ( diff --git a/apps/dashboard/src/components/shared/file-search-card.tsx b/apps/dashboard/src/components/shared/file-search-card.tsx new file mode 100644 index 0000000..321f43b --- /dev/null +++ b/apps/dashboard/src/components/shared/file-search-card.tsx @@ -0,0 +1,383 @@ +import { FileIcon, FolderIcon, SearchIcon, XIcon } from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { AnimatePresence, motion } from "motion/react"; +import { + memo, + useCallback, + useDeferredValue, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { + type FileSearchEntry, + type FileSearchResult, + getMatchIndices, + searchFiles, +} from "#/lib/fuzzy-file-search"; + +// --------------------------------------------------------------------------- +// Highlighted text for fuzzy match results +// --------------------------------------------------------------------------- + +function HighlightedText({ + text, + indices, + className, +}: { + text: string; + indices: Set; + className?: string; +}) { + if (indices.size === 0) { + return {text}; + } + + const parts: React.ReactNode[] = []; + let i = 0; + while (i < text.length) { + if (indices.has(i)) { + const start = i; + while (i < text.length && indices.has(i)) i++; + parts.push( + + {text.slice(start, i)} + , + ); + } else { + const start = i; + while (i < text.length && !indices.has(i)) i++; + parts.push({text.slice(start, i)}); + } + } + + return {parts}; +} + +// --------------------------------------------------------------------------- +// Spring config +// --------------------------------------------------------------------------- + +const spring = { + type: "spring" as const, + duration: 0.18, + bounce: 0.05, +}; + +// --------------------------------------------------------------------------- +// FileSearchCard +// --------------------------------------------------------------------------- + +export type FileSearchCardProps = { + /** All searchable file entries. */ + entries: FileSearchEntry[]; + /** Called when the user selects a result (Enter key or click). */ + onSelect: (entry: FileSearchResult) => void; + /** Placeholder text for the search input. */ + placeholder?: string; + /** Entries to display when the query is empty. If not provided, shows first 5 file entries. */ + defaultEntries?: FileSearchEntry[]; + /** Whether the currently selected path matches a result (for active highlight). */ + activePath?: string; + /** Global keyboard shortcut character to focus the input (default: "f"). Set to null to disable. */ + shortcutKey?: string | null; +}; + +export const FileSearchCard = memo(function FileSearchCard({ + entries, + onSelect, + placeholder = "Search files...", + defaultEntries, + activePath, + shortcutKey = "f", +}: FileSearchCardProps) { + const [query, setQuery] = useState(""); + const deferredQuery = useDeferredValue(query); + const [focused, setFocused] = useState(false); + const [activeIndex, setActiveIndex] = useState(0); + const inputRef = useRef(null); + const cardRef = useRef(null); + const isExpanded = focused || query.length > 0; + const hasResults = deferredQuery.trim().length > 0; + + const searchResults = useMemo( + () => searchFiles(deferredQuery, entries), + [deferredQuery, entries], + ); + + const computedDefaults = useMemo( + () => + (defaultEntries ?? entries.filter((f) => f.type === "file")) + .slice(0, 5) + .map((f) => ({ ...f, score: 0 })), + [defaultEntries, entries], + ); + + const visibleResults = hasResults ? searchResults : computedDefaults; + + // Reset active index when results change + // biome-ignore lint/correctness/useExhaustiveDependencies: reset index when query or result count changes + useEffect(() => { + setActiveIndex(0); + }, [deferredQuery, visibleResults.length]); + + const handleClear = useCallback(() => { + setQuery(""); + inputRef.current?.focus(); + }, []); + + const handleSelect = useCallback( + (entry: FileSearchResult) => { + onSelect(entry); + setFocused(false); + setQuery(""); + inputRef.current?.blur(); + }, + [onSelect], + ); + + const handleInputKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((prev) => + prev < visibleResults.length - 1 ? prev + 1 : 0, + ); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((prev) => + prev > 0 ? prev - 1 : visibleResults.length - 1, + ); + } else if (e.key === "Enter" && visibleResults.length > 0) { + e.preventDefault(); + const item = visibleResults[activeIndex]; + if (item) handleSelect(item); + } + }, + [visibleResults, activeIndex, handleSelect], + ); + + // Close card on click outside + useEffect(() => { + if (!isExpanded) return; + function handlePointerDown(e: PointerEvent) { + if (cardRef.current && !cardRef.current.contains(e.target as Node)) { + setFocused(false); + inputRef.current?.blur(); + } + } + document.addEventListener("pointerdown", handlePointerDown); + return () => document.removeEventListener("pointerdown", handlePointerDown); + }, [isExpanded]); + + // Close on Escape + global shortcut + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape" && isExpanded) { + setFocused(false); + setQuery(""); + inputRef.current?.blur(); + return; + } + if ( + shortcutKey && + (e.key === "/" || e.key === shortcutKey) && + !e.metaKey && + !e.ctrlKey && + document.activeElement?.tagName !== "INPUT" && + document.activeElement?.tagName !== "TEXTAREA" + ) { + e.preventDefault(); + inputRef.current?.focus(); + } + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isExpanded, shortcutKey]); + + return ( +
+
+ + {/* Floating card — absolutely positioned over the tree */} +
+
+ + + + setQuery(e.target.value)} + onFocus={() => setFocused(true)} + onKeyDown={handleInputKeyDown} + placeholder={placeholder} + className={cn( + "w-full text-[13px] text-foreground placeholder:text-muted-foreground transition-[height,padding] duration-150 ease-out focus:outline-none", + isExpanded ? "h-10 pr-9 pl-9" : "h-8 pr-8 pl-8", + )} + /> + {query ? ( + + ) : ( + + {!isExpanded && shortcutKey && ( + + {shortcutKey.toUpperCase()} + + )} + + )} +
+ + + {isExpanded && ( + +
+ {visibleResults.length > 0 ? ( + visibleResults.map((result, i) => ( + + )) + ) : ( +
+ No files found +
+ )} +
+
+ )} +
+
+
+ ); +}); + +// --------------------------------------------------------------------------- +// Search result row +// --------------------------------------------------------------------------- + +const SearchResultRow = memo(function SearchResultRow({ + result, + isHighlighted, + isActive, + query, + onSelect, +}: { + result: FileSearchResult; + isHighlighted: boolean; + isActive: boolean; + query: string; + onSelect: (entry: FileSearchResult) => void; +}) { + const isDir = result.type === "dir"; + const Icon = isDir ? FolderIcon : FileIcon; + const dir = result.path.includes("/") + ? result.path.slice(0, result.path.lastIndexOf("/")) + : null; + const ref = useRef(null); + const nameIndices = useMemo( + () => getMatchIndices(query, result.name), + [query, result.name], + ); + const dirIndices = useMemo( + () => (dir ? getMatchIndices(query, dir) : new Set()), + [query, dir], + ); + + // Scroll highlighted item into view + useEffect(() => { + if (isHighlighted && ref.current) { + ref.current.scrollIntoView({ block: "nearest" }); + } + }, [isHighlighted]); + + return ( + + ); +}); diff --git a/apps/dashboard/src/lib/fuzzy-file-search.ts b/apps/dashboard/src/lib/fuzzy-file-search.ts new file mode 100644 index 0000000..3f50534 --- /dev/null +++ b/apps/dashboard/src/lib/fuzzy-file-search.ts @@ -0,0 +1,94 @@ +export type FileSearchEntry = { + path: string; + name: string; + type: "file" | "dir" | "submodule"; +}; + +export type FileSearchResult = FileSearchEntry & { + score: number; +}; + +function scoreMatch(query: string, filePath: string): number { + const lowerQuery = query.toLowerCase(); + const lowerPath = filePath.toLowerCase(); + const name = lowerPath.split("/").pop() ?? lowerPath; + + // No match at all — check if every character appears in order + let qi = 0; + for (let pi = 0; pi < lowerPath.length && qi < lowerQuery.length; pi++) { + if (lowerPath[pi] === lowerQuery[qi]) qi++; + } + if (qi < lowerQuery.length) return -1; + + let score = 0; + + // Exact name match + if (name === lowerQuery) return 1000; + + // Name starts with query + if (name.startsWith(lowerQuery)) score += 100; + + // Name contains query as substring + if (name.includes(lowerQuery)) score += 50; + + // Path contains query as substring + if (lowerPath.includes(lowerQuery)) score += 25; + + // Consecutive character match bonus — reward longer runs + let maxRun = 0; + let currentRun = 0; + let qj = 0; + for (let pi = 0; pi < lowerPath.length && qj < lowerQuery.length; pi++) { + if (lowerPath[pi] === lowerQuery[qj]) { + currentRun++; + qj++; + if (currentRun > maxRun) maxRun = currentRun; + } else { + currentRun = 0; + } + } + score += maxRun * 5; + + // Prefer shorter paths (less nesting) + const depth = filePath.split("/").length; + score -= depth * 2; + + // Prefer shorter file names + score -= name.length; + + return score; +} + +/** Returns indices of characters in `text` that match `query` in order. */ +export function getMatchIndices(query: string, text: string): Set { + const indices = new Set(); + const lowerQuery = query.toLowerCase(); + const lowerText = text.toLowerCase(); + let qi = 0; + for (let ti = 0; ti < lowerText.length && qi < lowerQuery.length; ti++) { + if (lowerText[ti] === lowerQuery[qi]) { + indices.add(ti); + qi++; + } + } + return indices; +} + +export function searchFiles( + query: string, + entries: FileSearchEntry[], + limit = 50, +): FileSearchResult[] { + if (!query.trim()) return []; + + const results: FileSearchResult[] = []; + for (const entry of entries) { + const score = scoreMatch(query, entry.path); + if (score >= 0) { + results.push({ ...entry, score }); + } + } + + results.sort((a, b) => b.score - a.score); + return results.slice(0, limit); +} From 87025c0b85ebce34e438bf3b475eadc20d4c50bc Mon Sep 17 00:00:00 2001 From: Alan Daniel Date: Thu, 16 Apr 2026 17:15:29 -0400 Subject: [PATCH 3/3] perf: optimize re-renders and batch commit queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Batch per-entry commit messages into a single GraphQL query with aliased history fields instead of N separate REST calls - Add explorer-path-store (useSyncExternalStore) so tree nodes subscribe to derived isActive booleans — only 2 nodes re-render on navigation - Memoize scope objects in route components to stop busting memo on every child - Prevent same-URL navigation on already-active tree nodes - Stabilize callbacks in RepoExplorerLayout (useCallback + imperative reads from store) - Isolate pathname subscription in dashboard-tabs via ScrollActiveTabIntoView renderless component - Fix branch switch in blob view navigating to tree route - Add light-mode border to branch selector button --- .../src/components/layouts/dashboard-tabs.tsx | 67 ++++++--- .../components/repo/code-explorer-toolbar.tsx | 2 +- .../src/components/repo/file-tree.tsx | 41 +++--- .../src/components/repo/folder-view.tsx | 45 +++--- .../components/repo/repo-explorer-layout.tsx | 63 +++++--- .../repo/repo-file-tree-sidebar.tsx | 34 +++-- .../components/repo/repo-overview-page.tsx | 4 +- apps/dashboard/src/lib/explorer-path-store.ts | 69 +++++++++ apps/dashboard/src/lib/github.functions.ts | 137 ++++++++++++++++++ apps/dashboard/src/lib/github.query.ts | 30 ++++ .../routes/_protected/$owner/$repo/blob.$.tsx | 3 +- .../routes/_protected/$owner/$repo/tree.$.tsx | 3 +- 12 files changed, 399 insertions(+), 99 deletions(-) create mode 100644 apps/dashboard/src/lib/explorer-path-store.ts diff --git a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx index 7bb1f20..5902d86 100644 --- a/apps/dashboard/src/components/layouts/dashboard-tabs.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-tabs.tsx @@ -90,7 +90,6 @@ interface DashboardTabsProps { export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { const openTabs = useTabs(); - const pathname = useRouterState({ select: (s) => s.location.pathname }); const contextTabRef = useRef<{ tab: Tab; index: number } | null>(null); const { scrollRef, @@ -100,31 +99,17 @@ export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { handleWheel, } = useScrollShadows(openTabs.length); - // biome-ignore lint/correctness/useExhaustiveDependencies: pathname is intentionally used as a trigger to re-run when the route changes - useEffect(() => { - const container = scrollRef.current; - if (!container) return; - const activeTab = container.querySelector(".active"); - if (!activeTab) return; - - const { left: cLeft, right: cRight } = container.getBoundingClientRect(); - const { left: tLeft, right: tRight } = activeTab.getBoundingClientRect(); - - if (tLeft < cLeft || tRight > cRight) { - activeTab.scrollIntoView({ inline: "nearest", block: "nearest" }); - updateScrollState(); - } - }, [pathname, scrollRef, updateScrollState]); - + // Read pathname imperatively in event handlers (rerender-defer-reads) + // so the callbacks are stable and don't bust memo on DetailTab. const handleCloseTab = useCallback( (id: string, tabUrl: string) => { - const isActive = pathname === tabUrl; + const currentPath = routerRef.current.state.location.pathname; removeTab(id); - if (isActive) { + if (currentPath === tabUrl) { void routerRef.current.navigate({ to: "/" }); } }, - [pathname, routerRef], + [routerRef], ); const handleContextClose = useCallback(() => { @@ -136,11 +121,12 @@ export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { const handleContextCloseOthers = useCallback(() => { const ctx = contextTabRef.current; if (!ctx) return; - if (pathname !== ctx.tab.url) { + const currentPath = routerRef.current.state.location.pathname; + if (currentPath !== ctx.tab.url) { void routerRef.current.navigate({ to: ctx.tab.url }); } removeOtherTabs(ctx.tab.id); - }, [pathname, routerRef]); + }, [routerRef]); const handleContextCloseRight = useCallback(() => { const ctx = contextTabRef.current; @@ -184,6 +170,10 @@ export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { onMouseEnter={updateScrollState} className="no-scrollbar flex w-0 min-w-full items-center gap-0.5 overflow-x-auto" > + {openTabs.map((tab, index) => { const Icon = tabIconMap[tab.type]; return ( @@ -227,6 +217,39 @@ export function DashboardTabs({ tabsReady, routerRef }: DashboardTabsProps) { ); } +/** + * Isolated component that subscribes to pathname changes to scroll the active + * tab into view. Extracted so the parent DashboardTabs doesn't re-render on + * every navigation — only this tiny renderless component does. + */ +function ScrollActiveTabIntoView({ + scrollRef, + updateScrollState, +}: { + scrollRef: React.RefObject; + updateScrollState: () => void; +}) { + const pathname = useRouterState({ select: (s) => s.location.pathname }); + + // biome-ignore lint/correctness/useExhaustiveDependencies: pathname triggers scroll-into-view on route change + useEffect(() => { + const container = scrollRef.current; + if (!container) return; + const activeTab = container.querySelector(".active"); + if (!activeTab) return; + + const { left: cLeft, right: cRight } = container.getBoundingClientRect(); + const { left: tLeft, right: tRight } = activeTab.getBoundingClientRect(); + + if (tLeft < cLeft || tRight > cRight) { + activeTab.scrollIntoView({ inline: "nearest", block: "nearest" }); + updateScrollState(); + } + }, [pathname, scrollRef, updateScrollState]); + + return null; +} + const DetailTab = memo(function DetailTab({ tab, icon: Icon, diff --git a/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx b/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx index ce948f7..8b6e75d 100644 --- a/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx +++ b/apps/dashboard/src/components/repo/code-explorer-toolbar.tsx @@ -93,7 +93,7 @@ function BranchSelector({ size="sm" onMouseEnter={prefetchBranches} onFocus={prefetchBranches} - className="max-w-[220px]" + className="max-w-[220px] border border-border dark:border-transparent" > {currentRef} diff --git a/apps/dashboard/src/components/repo/file-tree.tsx b/apps/dashboard/src/components/repo/file-tree.tsx index 879680d..ea8ca10 100644 --- a/apps/dashboard/src/components/repo/file-tree.tsx +++ b/apps/dashboard/src/components/repo/file-tree.tsx @@ -3,12 +3,13 @@ import { Skeleton } from "@diffkit/ui/components/skeleton"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { type GitHubQueryScope, - githubFileLastCommitQueryOptions, + githubTreeEntryCommitsQueryOptions, } from "#/lib/github.query"; -import type { RepoTreeEntry } from "#/lib/github.types"; +import type { FileLastCommit, RepoTreeEntry } from "#/lib/github.types"; export function FileTree({ entries, @@ -23,6 +24,18 @@ export function FileTree({ currentRef: string; scope: GitHubQueryScope; }) { + const entryNames = useMemo(() => entries.map((e) => e.name), [entries]); + + const commitsQuery = useQuery( + githubTreeEntryCommitsQueryOptions(scope, { + owner, + repo, + ref: currentRef, + dirPath: "", + entries: entryNames, + }), + ); + return (
{entries.map((entry, index) => ( @@ -32,7 +45,8 @@ export function FileTree({ owner={owner} repo={repo} currentRef={currentRef} - scope={scope} + commit={commitsQuery.data?.[entry.name] ?? null} + isCommitLoading={commitsQuery.isLoading} isLast={index === entries.length - 1} /> ))} @@ -45,30 +59,21 @@ function FileTreeRow({ owner, repo, currentRef, - scope, + commit, + isCommitLoading, isLast, }: { entry: RepoTreeEntry; owner: string; repo: string; currentRef: string; - scope: GitHubQueryScope; + commit: FileLastCommit | null; + isCommitLoading: boolean; isLast: boolean; }) { const Icon = entry.type === "dir" ? FolderIcon : FileIcon; const isDir = entry.type === "dir"; - const commitQuery = useQuery( - githubFileLastCommitQueryOptions(scope, { - owner, - repo, - ref: currentRef, - path: entry.name, - }), - ); - - const commit = commitQuery.data; - return ( {commit ? ( commit.message.split("\n")[0] - ) : commitQuery.isLoading ? ( + ) : isCommitLoading ? ( ) : null} {commit?.date ? ( formatRelativeTime(commit.date) - ) : commitQuery.isLoading ? ( + ) : isCommitLoading ? ( ) : null} diff --git a/apps/dashboard/src/components/repo/folder-view.tsx b/apps/dashboard/src/components/repo/folder-view.tsx index 14ed717..5f5c4e8 100644 --- a/apps/dashboard/src/components/repo/folder-view.tsx +++ b/apps/dashboard/src/components/repo/folder-view.tsx @@ -3,12 +3,17 @@ import { Skeleton } from "@diffkit/ui/components/skeleton"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; import { formatRelativeTime } from "#/lib/format-relative-time"; import { type GitHubQueryScope, - githubFileLastCommitQueryOptions, + githubTreeEntryCommitsQueryOptions, } from "#/lib/github.query"; -import type { RepoOverview, RepoTreeEntry } from "#/lib/github.types"; +import type { + FileLastCommit, + RepoOverview, + RepoTreeEntry, +} from "#/lib/github.types"; import { LatestCommitBar } from "./latest-commit-bar"; import { RepoMarkdownFiles } from "./repo-markdown-files"; @@ -29,6 +34,18 @@ export function FolderView({ currentPath: string; scope: GitHubQueryScope; }) { + const entryNames = useMemo(() => entries.map((e) => e.name), [entries]); + + const commitsQuery = useQuery( + githubTreeEntryCommitsQueryOptions(scope, { + owner, + repo: repoName, + ref: currentRef, + dirPath: currentPath, + entries: entryNames, + }), + ); + return (
@@ -42,7 +59,8 @@ export function FolderView({ repoName={repoName} currentRef={currentRef} currentPath={currentPath} - scope={scope} + commit={commitsQuery.data?.[entry.name] ?? null} + isCommitLoading={commitsQuery.isLoading} isLast={index === entries.length - 1} /> ))} @@ -66,7 +84,8 @@ function FolderViewRow({ repoName, currentRef, currentPath, - scope, + commit, + isCommitLoading, isLast, }: { entry: RepoTreeEntry; @@ -74,24 +93,14 @@ function FolderViewRow({ repoName: string; currentRef: string; currentPath: string; - scope: GitHubQueryScope; + commit: FileLastCommit | null; + isCommitLoading: boolean; isLast: boolean; }) { const Icon = entry.type === "dir" ? FolderIcon : FileIcon; const isDir = entry.type === "dir"; const entryPath = currentPath ? `${currentPath}/${entry.name}` : entry.name; - const commitQuery = useQuery( - githubFileLastCommitQueryOptions(scope, { - owner, - repo: repoName, - ref: currentRef, - path: entryPath, - }), - ); - - const commit = commitQuery.data; - return ( {commit ? ( commit.message.split("\n")[0] - ) : commitQuery.isLoading ? ( + ) : isCommitLoading ? ( ) : null} {commit?.date ? ( formatRelativeTime(commit.date) - ) : commitQuery.isLoading ? ( + ) : isCommitLoading ? ( ) : null} diff --git a/apps/dashboard/src/components/repo/repo-explorer-layout.tsx b/apps/dashboard/src/components/repo/repo-explorer-layout.tsx index 3eeaeab..ae17e33 100644 --- a/apps/dashboard/src/components/repo/repo-explorer-layout.tsx +++ b/apps/dashboard/src/components/repo/repo-explorer-layout.tsx @@ -12,7 +12,14 @@ import { import { Skeleton } from "@diffkit/ui/components/skeleton"; import { useQuery } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; -import { memo, useMemo, useState, useSyncExternalStore } from "react"; +import { + memo, + useCallback, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import { getExplorerPath, setExplorerPath } from "#/lib/explorer-path-store"; import { type GitHubQueryScope, githubRepoOverviewQueryOptions, @@ -68,6 +75,14 @@ export function RepoExplorerLayout({ const repoData = overviewQuery.data; const activeRef = currentRefProp ?? repoData?.defaultBranch ?? "main"; + // Sync the explorer path store so tree nodes can subscribe to derived + // isActive booleans instead of receiving currentPath as a prop. + // Called during render (not in an effect) so children read the correct + // value on their first render pass — critical for DirectoryNode's + // initial isOpen state. The internal guard (if path === current) makes + // duplicate calls in StrictMode a no-op. + setExplorerPath(currentPath); + useRegisterTab( repoData ? { @@ -108,23 +123,32 @@ export function RepoExplorerLayout({ hasMounted && !!repoData && viewMode === "tree" && currentPath !== "", }); - const handleBranchChange = (branch: string) => { - if (currentPath) { - void navigate({ - to: "/$owner/$repo/tree/$", - params: { - owner, - repo: repoName, - _splat: `${branch}/${currentPath}`, - }, - }); - } else { - void navigate({ - to: "/$owner/$repo", - params: { owner, repo: repoName }, - }); - } - }; + const handleBranchChange = useCallback( + (branch: string) => { + const path = getExplorerPath(); + if (path) { + void navigate({ + to: + viewMode === "blob" + ? "/$owner/$repo/blob/$" + : "/$owner/$repo/tree/$", + params: { + owner, + repo: repoName, + _splat: `${branch}/${path}`, + }, + }); + } else { + void navigate({ + to: "/$owner/$repo", + params: { owner, repo: repoName }, + }); + } + }, + [navigate, owner, repoName, viewMode], + ); + + const handleOpenFileSheet = useCallback(() => setDrawerOpen(true), []); if (overviewQuery.error) throw overviewQuery.error; if (!repoData) return ; @@ -170,7 +194,6 @@ export function RepoExplorerLayout({ owner={owner} repoName={repoName} currentRef={activeRef} - currentPath={currentPath} scope={scope} entries={rootTreeQuery.data} /> @@ -185,7 +208,7 @@ export function RepoExplorerLayout({ currentRef={activeRef} scope={scope} onBranchChange={handleBranchChange} - onOpenFileSheet={() => setDrawerOpen(true)} + onOpenFileSheet={handleOpenFileSheet} isDesktop={isDesktop} /> diff --git a/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx b/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx index 10650a3..3470ba4 100644 --- a/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx +++ b/apps/dashboard/src/components/repo/repo-file-tree-sidebar.tsx @@ -5,6 +5,11 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useNavigate } from "@tanstack/react-router"; import { memo, useCallback, useMemo, useState } from "react"; import { FileSearchCard } from "#/components/shared/file-search-card"; +import { + useExplorerPath, + useIsActivePath, + useIsAncestorPath, +} from "#/lib/explorer-path-store"; import type { FileSearchEntry, FileSearchResult, @@ -66,18 +71,17 @@ export const RepoFileTreeSidebar = memo(function RepoFileTreeSidebar({ owner, repoName, currentRef, - currentPath, scope, entries, }: { owner: string; repoName: string; currentRef: string; - currentPath: string; scope: GitHubQueryScope; entries: RepoTreeEntry[]; }) { const navigate = useNavigate(); + const currentPath = useExplorerPath(); const allFiles = useAllCachedFiles(scope, owner, repoName, currentRef); const defaultEntries = useMemo( @@ -118,7 +122,6 @@ export const RepoFileTreeSidebar = memo(function RepoFileTreeSidebar({ owner={owner} repoName={repoName} currentRef={currentRef} - currentPath={currentPath} scope={scope} depth={0} parentPath="" @@ -138,7 +141,6 @@ const TreeNode = memo(function TreeNode({ owner, repoName, currentRef, - currentPath, scope, depth, parentPath, @@ -147,7 +149,6 @@ const TreeNode = memo(function TreeNode({ owner: string; repoName: string; currentRef: string; - currentPath: string; scope: GitHubQueryScope; depth: number; parentPath: string; @@ -162,7 +163,6 @@ const TreeNode = memo(function TreeNode({ owner={owner} repoName={repoName} currentRef={currentRef} - currentPath={currentPath} scope={scope} depth={depth} entryPath={entryPath} @@ -176,7 +176,6 @@ const TreeNode = memo(function TreeNode({ owner={owner} repoName={repoName} currentRef={currentRef} - currentPath={currentPath} depth={depth} entryPath={entryPath} /> @@ -188,7 +187,6 @@ const DirectoryNode = memo(function DirectoryNode({ owner, repoName, currentRef, - currentPath, scope, depth, entryPath, @@ -197,13 +195,12 @@ const DirectoryNode = memo(function DirectoryNode({ owner: string; repoName: string; currentRef: string; - currentPath: string; scope: GitHubQueryScope; depth: number; entryPath: string; }) { - const isActive = currentPath === entryPath; - const isAncestor = currentPath.startsWith(`${entryPath}/`); + const isActive = useIsActivePath(entryPath); + const isAncestor = useIsAncestorPath(entryPath); const [isOpen, setIsOpen] = useState(isAncestor || isActive); const hasMounted = useHasMounted(); @@ -246,7 +243,12 @@ const DirectoryNode = memo(function DirectoryNode({ repo: repoName, _splat: `${currentRef}/${entryPath}`, }} - onClick={() => { + onClick={(e) => { + if (isActive) { + e.preventDefault(); + setIsOpen((prev) => !prev); + return; + } if (!isOpen) setIsOpen(true); }} className="flex min-w-0 flex-1 items-center gap-1.5 py-1.5 pl-1.5 pr-3" @@ -278,7 +280,6 @@ const DirectoryNode = memo(function DirectoryNode({ owner={owner} repoName={repoName} currentRef={currentRef} - currentPath={currentPath} scope={scope} depth={depth + 1} parentPath={entryPath} @@ -295,7 +296,6 @@ const FileNode = memo(function FileNode({ owner, repoName, currentRef, - currentPath, depth, entryPath, }: { @@ -303,11 +303,10 @@ const FileNode = memo(function FileNode({ owner: string; repoName: string; currentRef: string; - currentPath: string; depth: number; entryPath: string; }) { - const isActive = currentPath === entryPath; + const isActive = useIsActivePath(entryPath); return ( { + if (isActive) e.preventDefault(); + }} className={cn( "flex w-full items-center gap-1.5 px-3 py-1.5 text-[13px] transition-colors hover:bg-surface-1", isActive && "bg-surface-1", diff --git a/apps/dashboard/src/components/repo/repo-overview-page.tsx b/apps/dashboard/src/components/repo/repo-overview-page.tsx index b0be5d3..6c93ad1 100644 --- a/apps/dashboard/src/components/repo/repo-overview-page.tsx +++ b/apps/dashboard/src/components/repo/repo-overview-page.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { getRouteApi } from "@tanstack/react-router"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { SidePanelPortal } from "#/components/layouts/dashboard-side-panel"; import { githubRepoOverviewQueryOptions, @@ -22,7 +22,7 @@ const routeApi = getRouteApi("/_protected/$owner/$repo/"); export function RepoOverviewPage() { const { user } = routeApi.useRouteContext(); const { owner, repo } = routeApi.useParams(); - const scope = { userId: user.id }; + const scope = useMemo(() => ({ userId: user.id }), [user.id]); const hasMounted = useHasMounted(); const overviewQuery = useQuery({ diff --git a/apps/dashboard/src/lib/explorer-path-store.ts b/apps/dashboard/src/lib/explorer-path-store.ts new file mode 100644 index 0000000..a29ec29 --- /dev/null +++ b/apps/dashboard/src/lib/explorer-path-store.ts @@ -0,0 +1,69 @@ +import { useSyncExternalStore } from "react"; + +/** + * Lightweight external store for the current explorer path. + * + * Tree nodes subscribe via `useIsActivePath(entryPath)` which returns a + * derived boolean. Because `useSyncExternalStore` compares snapshots by + * value (===), React bails out of re-rendering any node whose active + * status didn't actually change — so navigating between files only + * re-renders the old-active and new-active nodes instead of the entire tree. + */ + +let currentPath = ""; +const listeners = new Set<() => void>(); + +function emit() { + for (const fn of listeners) fn(); +} + +function subscribe(cb: () => void) { + listeners.add(cb); + return () => { + listeners.delete(cb); + }; +} + +export function setExplorerPath(path: string) { + if (path === currentPath) return; + currentPath = path; + emit(); +} + +/** Read the current path imperatively (outside of React). */ +export function getExplorerPath() { + return currentPath; +} + +export function useExplorerPath() { + return useSyncExternalStore( + subscribe, + () => currentPath, + () => "", + ); +} + +/** + * Returns true only when `entryPath` exactly matches the current explorer + * path. Because the snapshot is a boolean primitive, nodes that remain + * inactive skip re-rendering entirely. + */ +export function useIsActivePath(entryPath: string) { + return useSyncExternalStore( + subscribe, + () => currentPath === entryPath, + () => false, + ); +} + +/** + * Returns true when the current path is a descendant of `entryPath` + * (i.e. the directory is an ancestor of the active file/folder). + */ +export function useIsAncestorPath(entryPath: string) { + return useSyncExternalStore( + subscribe, + () => currentPath.startsWith(`${entryPath}/`), + () => false, + ); +} diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index a64cfc7..a51de02 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -6932,6 +6932,143 @@ export const getFileLastCommit = createServerFn({ method: "GET" }) }).catch(() => null); }); +// --------------------------------------------------------------------------- +// Batch tree entry commits (single GraphQL query for all entries in a dir) +// --------------------------------------------------------------------------- + +type TreeEntryCommitsInput = { + owner: string; + repo: string; + ref: string; + /** Directory path (empty string for root). */ + dirPath: string; + /** Entry names within the directory. */ + entries: string[]; +}; + +type TreeEntryCommitsResult = Record; + +export const getTreeEntryCommits = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContextForRepository(data); + if (!context) return {}; + + const repoCodeKey = githubRevalidationSignalKeys.repoCode(data); + + return getOrRevalidateGitHubResource({ + userId: context.session.user.id, + resource: "repo.treeEntryCommits.v1", + params: { + owner: data.owner, + repo: data.repo, + ref: data.ref, + dirPath: data.dirPath, + }, + freshForMs: githubCachePolicy.detail.staleTimeMs, + signalKeys: [repoCodeKey], + namespaceKeys: [repoCodeKey], + cacheMode: "split", + fetcher: async () => { + // Build aliased history fields — one per entry + const aliases = data.entries.map((name, i) => { + const entryPath = data.dirPath ? `${data.dirPath}/${name}` : name; + return `e${i}: history(first: 1, path: ${JSON.stringify(entryPath)}) { + nodes { + oid + message + committedDate + author { + user { + login + avatarUrl + url + } + } + } + }`; + }); + + const query = `query($owner: String!, $repo: String!, $ref: String!) { + repository(owner: $owner, name: $repo) { + object(expression: $ref) { + ... on Commit { + ${aliases.join("\n")} + } + } + } + rateLimit { cost remaining resetAt } + }`; + + const response = await executeGitHubGraphQL<{ + repository: { + object: Record< + string, + { + nodes: Array<{ + oid: string; + message: string; + committedDate: string; + author: { + user: { + login: string; + avatarUrl: string; + url: string; + } | null; + } | null; + }>; + } + > | null; + } | null; + rateLimit: GitHubGraphQLRateLimit | null; + }>( + context, + `github tree entry commits ${data.owner}/${data.repo}`, + query, + { + owner: data.owner, + repo: data.repo, + ref: data.ref, + }, + ); + + const commitObj = response.repository?.object; + const result: TreeEntryCommitsResult = {}; + + for (let i = 0; i < data.entries.length; i++) { + const alias = `e${i}`; + const node = commitObj?.[alias]?.nodes?.[0]; + const name = data.entries[i]; + if (!name) continue; + if (!node) { + result[name] = null; + continue; + } + const user = node.author?.user; + result[name] = { + sha: node.oid, + message: node.message, + date: node.committedDate, + author: user + ? { + login: user.login, + avatarUrl: user.avatarUrl, + url: user.url, + type: "User", + } + : null, + }; + } + + return { + kind: "success", + data: result, + metadata: createGraphQLResponseMetadata(response.rateLimit), + }; + }, + }); + }); + // --------------------------------------------------------------------------- // Repository contributors // --------------------------------------------------------------------------- diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index 8f9fb35..7b16dff 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -31,6 +31,7 @@ import { getRepoTree, getReviewThreadStatuses, getTimelineEventPage, + getTreeEntryCommits, getUserActivity, getUserContributions, getUserPinnedRepos, @@ -204,6 +205,10 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string; ref: string; path: string }, ) => ["github", scope.userId, "repo", "fileLastCommit", input] as const, + treeEntryCommits: ( + scope: GitHubQueryScope, + input: { owner: string; repo: string; ref: string; dirPath: string }, + ) => ["github", scope.userId, "repo", "treeEntryCommits", input] as const, contributors: ( scope: GitHubQueryScope, input: { owner: string; repo: string }, @@ -679,6 +684,31 @@ export function githubFileLastCommitQueryOptions( }); } +export function githubTreeEntryCommitsQueryOptions( + scope: GitHubQueryScope, + input: { + owner: string; + repo: string; + ref: string; + dirPath: string; + entries: string[]; + }, +) { + return queryOptions({ + queryKey: githubQueryKeys.repo.treeEntryCommits(scope, { + owner: input.owner, + repo: input.repo, + ref: input.ref, + dirPath: input.dirPath, + }), + queryFn: () => getTreeEntryCommits({ data: input }), + staleTime: githubCachePolicy.detail.staleTimeMs, + gcTime: githubCachePolicy.detail.gcTimeMs, + meta: tabPersistedMeta, + enabled: input.entries.length > 0, + }); +} + export function githubRepoDiscussionsQueryOptions( scope: GitHubQueryScope, input: { owner: string; repo: string }, diff --git a/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx b/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx index 93562d7..f414987 100644 --- a/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx +++ b/apps/dashboard/src/routes/_protected/$owner/$repo/blob.$.tsx @@ -1,4 +1,5 @@ import { createFileRoute } from "@tanstack/react-router"; +import { useMemo } from "react"; import { RepoExplorerLayout } from "#/components/repo/repo-explorer-layout"; import { githubRepoBranchesQueryOptions, @@ -87,7 +88,7 @@ function BlobPage() { const { user } = Route.useRouteContext(); const { owner, repo } = Route.useParams(); const { ref, path } = Route.useLoaderData(); - const scope = { userId: user.id }; + const scope = useMemo(() => ({ userId: user.id }), [user.id]); return ( ({ userId: user.id }), [user.id]); return (