From 00784f20b623a523fcbabc41403f6ab5255fe71b Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 27 Apr 2026 08:23:51 -0400 Subject: [PATCH 01/14] feat(web): scaffold commit diff routing Adds a `commit` pathType to the browse routes (`/browse/@/-/commit/[/]`) that renders a placeholder CommitDiffPanel. Refactors browse path helpers into a discriminated `BrowseProps` union so commitSha is required only for pathType: 'commit'. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../[...path]/components/commitDiffPanel.tsx | 20 +++ .../src/app/(app)/browse/[...path]/page.tsx | 104 ++++++----- .../(app)/browse/hooks/useBrowseNavigation.ts | 19 +- .../web/src/app/(app)/browse/hooks/utils.ts | 170 ++++++++++++------ 4 files changed, 194 insertions(+), 119 deletions(-) create mode 100644 packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx new file mode 100644 index 000000000..486e281e1 --- /dev/null +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx @@ -0,0 +1,20 @@ +interface CommitDiffPanelProps { + repoName: string; + revisionName?: string; + commitSha: string; + path: string; +} + +export const CommitDiffPanel = ({ repoName, revisionName, commitSha, path }: CommitDiffPanelProps) => { + return ( +
+

Hello World

+
+
repo: {repoName}
+
revision: {revisionName ?? '(none)'}
+
commit: {commitSha}
+
file: {path || '(none)'}
+
+
+ ); +}; diff --git a/packages/web/src/app/(app)/browse/[...path]/page.tsx b/packages/web/src/app/(app)/browse/[...path]/page.tsx index 9274e7735..8e888114d 100644 --- a/packages/web/src/app/(app)/browse/[...path]/page.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import { getBrowseParamsFromPathParam } from "../hooks/utils"; import { CodePreviewPanel } from "./components/codePreviewPanel"; +import { CommitDiffPanel } from "./components/commitDiffPanel"; import { CommitsPanel } from "./components/commitsPanel"; import { Loader2 } from "lucide-react"; import { TreePreviewPanel } from "./components/treePreviewPanel"; @@ -17,58 +18,63 @@ import { Metadata } from "next"; * @returns A formatted title string. */ const parsePathForTitle = (path: string[]): string => { - const pathParam = path.join('/'); - - const { repoName, revisionName, path: filePath, pathType } = getBrowseParamsFromPathParam(pathParam); - - // Build the base repository and revision string. - const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix - const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`; - - switch (pathType) { - case 'blob': { - // For blobs, get the filename from the end of the path. - const fileName = filePath.split('/').pop() || filePath; - return `${fileName} - ${repoAndRevision}`; - } - case 'tree': { - // If the path is empty, it's the repo root. - if (filePath === '' || filePath === '/') { - return repoAndRevision; - } - // Otherwise, show the directory path. - const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`; - return `${directoryPath} - ${repoAndRevision}`; + const pathParam = path.join('/'); + + const browseProps = getBrowseParamsFromPathParam(pathParam); + const { repoName, revisionName, path: filePath } = browseProps; + + // Build the base repository and revision string. + const cleanRepoName = repoName.split('/').slice(1).join('/'); // Remove the version control system prefix + const repoAndRevision = `${cleanRepoName}${revisionName ? ` @ ${revisionName}` : ''}`; + + switch (browseProps.pathType) { + case 'blob': { + // For blobs, get the filename from the end of the path. + const fileName = filePath.split('/').pop() || filePath; + return `${fileName} - ${repoAndRevision}`; + } + case 'tree': { + // If the path is empty, it's the repo root. + if (filePath === '' || filePath === '/') { + return repoAndRevision; + } + // Otherwise, show the directory path. + const directoryPath = filePath.endsWith('/') ? filePath : `${filePath}/`; + return `${directoryPath} - ${repoAndRevision}`; + } + case 'commits': { + if (filePath === '' || filePath === '/') { + return `History - ${repoAndRevision}`; + } + return `History: ${filePath} - ${repoAndRevision}`; + } + case 'commit': { + const shortSha = browseProps.commitSha.substring(0, 7); + return `Commit ${shortSha} - ${repoAndRevision}`; + } } - case 'commits': { - if (filePath === '' || filePath === '/') { - return `History - ${repoAndRevision}`; - } - return `History: ${filePath} - ${repoAndRevision}`; - } - } } type Props = { - params: Promise<{ - path: string[]; - }>; + params: Promise<{ + path: string[]; + }>; }; export async function generateMetadata({ params: paramsPromise }: Props): Promise { - let title = 'Browse'; // Default Fallback + let title = 'Browse'; // Default Fallback - try { - const params = await paramsPromise; - title = parsePathForTitle(params.path); + try { + const params = await paramsPromise; + title = parsePathForTitle(params.path); - } catch (error) { - console.error("Failed to generate metadata title from path:", error); - } + } catch (error) { + console.error("Failed to generate metadata title from path:", error); + } - return { - title, - }; + return { + title, + }; } interface BrowsePageProps { @@ -91,7 +97,8 @@ export default async function BrowsePage(props: BrowsePageProps) { } = params; const rawPath = _rawPath.join('/'); - const { repoName, revisionName, path, pathType } = getBrowseParamsFromPathParam(rawPath); + const browseProps = getBrowseParamsFromPathParam(rawPath); + const { repoName, revisionName, path } = browseProps; const page = Math.max(1, parseInt(searchParams.page ?? '1', 10) || 1); const author = searchParams.author || undefined; @@ -106,13 +113,13 @@ export default async function BrowsePage(props: BrowsePageProps) { Loading... }> - {pathType === 'blob' ? ( + {browseProps.pathType === 'blob' ? ( - ) : pathType === 'commits' ? ( + ) : browseProps.pathType === 'commits' ? ( + ) : browseProps.pathType === 'commit' ? ( + ) : ( { const router = useRouter(); - const navigateToPath = useCallback(({ - repoName, - revisionName = 'HEAD', - path, - pathType, - highlightRange, - setBrowseState, - }: GetBrowsePathProps) => { + const navigateToPath = useCallback((props: BrowseProps) => { const browsePath = getBrowsePath({ - repoName, - revisionName, - path, - pathType, - highlightRange, - setBrowseState, + ...props, + revisionName: props.revisionName ?? 'HEAD', }); router.push(browsePath); diff --git a/packages/web/src/app/(app)/browse/hooks/utils.ts b/packages/web/src/app/(app)/browse/hooks/utils.ts index afa1feb49..48305db1a 100644 --- a/packages/web/src/app/(app)/browse/hooks/utils.ts +++ b/packages/web/src/app/(app)/browse/hooks/utils.ts @@ -10,21 +10,50 @@ export type BrowseHighlightRange = { end: { lineNumber: number; }; } -export type BrowsePathType = 'blob' | 'tree' | 'commits'; - -export interface GetBrowsePathProps { +type BaseProps = { repoName: string; - revisionName?: string; path: string; - pathType: BrowsePathType; - highlightRange?: BrowseHighlightRange; + revisionName?: string; setBrowseState?: Partial; } -export const getBrowseParamsFromPathParam = (pathParam: string) => { - const sentinelIndex = pathParam.search(/\/-\/(tree|blob|commits)/); +type BlobProps = BaseProps & { + pathType: 'blob', + highlightRange?: BrowseHighlightRange; +} + +type TreeProps = BaseProps & { + pathType: 'tree', +} + +type CommitsProps = BaseProps & { + pathType: 'commits' +} + +type CommitProps = BaseProps & { + pathType: 'commit', + commitSha: string +} + +export type BrowseProps = + BlobProps | + TreeProps | + CommitsProps | + CommitProps; + +export type BrowsePathType = BrowseProps['pathType']; + +// Repo-relative paths shouldn't have leading slashes — `git log -- /foo` (or +// just `--`) treats them as absolute filesystem paths. Repo root and `/` +// both map to the empty path. +const normalizeRepoPath = (path: string): string => path.replace(/^\/+/, ''); + +export const getBrowseParamsFromPathParam = (pathParam: string): BrowseProps => { + // @note: order matters — `commits` must come before `commit` so the regex + // engine doesn't greedily match `commit` against `/-/commits/...`. + const sentinelIndex = pathParam.search(/\/-\/(tree|blob|commits|commit)/); if (sentinelIndex === -1) { - throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob|commits)/" pattern`); + throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain "/-/(tree|blob|commits|commit)/" pattern`); } const repoAndRevisionPart = decodeURIComponent(pathParam.substring(0, sentinelIndex)); @@ -33,63 +62,83 @@ export const getBrowseParamsFromPathParam = (pathParam: string) => { const repoName = lastAtIndex === -1 ? repoAndRevisionPart : repoAndRevisionPart.substring(0, lastAtIndex); const revisionName = lastAtIndex === -1 ? undefined : repoAndRevisionPart.substring(lastAtIndex + 1); - const { path, pathType } = ((): { path: string, pathType: BrowsePathType } => { - const path = pathParam.substring(sentinelIndex + '/-/'.length); - const pathType: BrowsePathType = path.startsWith('tree') - ? 'tree' - : path.startsWith('commits') - ? 'commits' - : 'blob'; - - // @note: decodedURIComponent is needed here incase the path contains a space. - switch (pathType) { - case 'tree': - return { - path: decodeURIComponent(path.startsWith('tree/') ? path.substring('tree/'.length) : path.substring('tree'.length)), - pathType, - }; - case 'commits': - return { - path: decodeURIComponent(path.startsWith('commits/') ? path.substring('commits/'.length) : path.substring('commits'.length)), - pathType, - }; - case 'blob': - return { - path: decodeURIComponent(path.startsWith('blob/') ? path.substring('blob/'.length) : path.substring('blob'.length)), - pathType, - }; + const tail = pathParam.substring(sentinelIndex + '/-/'.length); + const pathType = ((): BrowsePathType => { + if (tail.startsWith('tree')) { + return 'tree'; + } + else if (tail.startsWith('commits')) { + return 'commits'; + } + else if (tail.startsWith('commit')) { + return 'commit'; } - })(); - - // Normalize parsed paths the same way URL generation does, so URLs that - // happen to contain a leading slash (e.g. legacy bookmarks with `%2F`) - // don't leak `/foo` into git log args. - const normalizedPath = path.replace(/^\/+/, ''); - if (pathType === 'blob' && normalizedPath === '') { - throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`); - } + return 'blob'; + })(); - return { - repoName, - revisionName, - path: normalizedPath, - pathType, + // @note: decodeURIComponent is needed in case the path contains a space. + switch (pathType) { + case 'tree': { + const rest = tail.startsWith('tree/') ? tail.substring('tree/'.length) : tail.substring('tree'.length); + return { + repoName, + revisionName, + pathType, + path: normalizeRepoPath(decodeURIComponent(rest)), + }; + } + case 'commits': { + const rest = tail.startsWith('commits/') ? tail.substring('commits/'.length) : tail.substring('commits'.length); + return { + repoName, + revisionName, + pathType, + path: normalizeRepoPath(decodeURIComponent(rest)), + }; + } + case 'commit': { + const rest = tail.startsWith('commit/') ? tail.substring('commit/'.length) : tail.substring('commit'.length); + const firstSlash = rest.indexOf('/'); + const commitSha = decodeURIComponent(firstSlash === -1 ? rest : rest.substring(0, firstSlash)); + const filePath = firstSlash === -1 ? '' : rest.substring(firstSlash + 1); + + if (!commitSha) { + throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a commit SHA for commit type`); + } + + return { + repoName, + revisionName, + pathType, + commitSha, + path: normalizeRepoPath(decodeURIComponent(filePath)), + }; + } + case 'blob': { + const rest = tail.startsWith('blob/') ? tail.substring('blob/'.length) : tail.substring('blob'.length); + const path = normalizeRepoPath(decodeURIComponent(rest)); + + if (path === '') { + throw new Error(`Invalid browse pathname: "${pathParam}" - expected to contain a path for blob type`); + } + + return { + repoName, + revisionName, + pathType, + path, + }; + } } }; -// Repo-relative paths shouldn't have leading slashes — `git log -- /foo` (or -// just `--`) treats them as absolute filesystem paths. Repo root and `/` -// both map to the empty path. -const normalizeRepoPath = (path: string): string => path.replace(/^\/+/, ''); - -export const getBrowsePath = ({ - repoName, revisionName, path, pathType, highlightRange, setBrowseState, -}: GetBrowsePathProps) => { +export const getBrowsePath = (props: BrowseProps) => { + const { repoName, revisionName, path, pathType, setBrowseState } = props; const params = new URLSearchParams(); - if (highlightRange) { - const { start, end } = highlightRange; + if (pathType === 'blob' && props.highlightRange) { + const { start, end } = props.highlightRange; if ('column' in start && 'column' in end) { params.set(HIGHLIGHT_RANGE_QUERY_PARAM, `${start.lineNumber}:${start.column},${end.lineNumber}:${end.column}`); @@ -103,7 +152,10 @@ export const getBrowsePath = ({ } const encodedPath = encodeURIComponent(normalizeRepoPath(path)); - const browsePath = `/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${encodedPath}${params.size > 0 ? `?${params.toString()}` : ''}`; + const tail = props.pathType === 'commit' + ? `${encodeURIComponent(props.commitSha)}${encodedPath ? `/${encodedPath}` : ''}` + : encodedPath; + const browsePath = `/browse/${repoName}${revisionName ? `@${revisionName}` : ''}/-/${pathType}/${tail}${params.size > 0 ? `?${params.toString()}` : ''}`; return browsePath; }; From 059240ac7f091347ac730c19e14bf3ce0f061c68 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 27 Apr 2026 10:00:11 -0400 Subject: [PATCH 02/14] feat(web): scaffold commit diff merge view Wires up @codemirror/merge (via react-codemirror-merge) inside CommitDiffPanel with a static before/after demo. Adds a CodeDiff component that owns its language extension + view ref so each pane can reconfigure its language compartment independently. Also gates the react-grab dev scripts behind DEBUG_ENABLE_REACT_GRAP so they don't load on every dev page render. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/env.server.ts | 1 + packages/web/package.json | 1 + .../[...path]/components/commitDiffPanel.tsx | 206 +++++++++++++++++- packages/web/src/app/layout.tsx | 4 +- yarn.lock | 78 +++++++ 5 files changed, 286 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts index 0b28f5602..390c9f277 100644 --- a/packages/shared/src/env.server.ts +++ b/packages/shared/src/env.server.ts @@ -246,6 +246,7 @@ const options = { DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'), DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'), + DEBUG_ENABLE_REACT_GRAP: booleanSchema.default('false'), LANGFUSE_SECRET_KEY: z.string().optional(), diff --git a/packages/web/package.json b/packages/web/package.json index 4e8350c23..5c61c441e 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -166,6 +166,7 @@ "pretty-bytes": "^6.1.1", "psl": "^1.15.0", "react": "19.2.4", + "react-codemirror-merge": "^4.25.9", "react-day-picker": "^9.14.0", "react-device-detect": "^2.2.3", "react-dom": "19.2.4", diff --git a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx index 486e281e1..dec5a9b25 100644 --- a/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx +++ b/packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel.tsx @@ -1,3 +1,12 @@ +'use client'; + +import { useCodeMirrorLanguageExtension } from "@/hooks/useCodeMirrorLanguageExtension"; +import { useCodeMirrorTheme } from "@/hooks/useCodeMirrorTheme"; +import { EditorState } from "@codemirror/state"; +import { EditorView, lineNumbers } from "@codemirror/view"; +import { useCallback, useMemo, useState } from "react"; +import CodeMirrorMerge, { CodeMirrorMergeRef } from "react-codemirror-merge"; + interface CommitDiffPanelProps { repoName: string; revisionName?: string; @@ -5,16 +14,209 @@ interface CommitDiffPanelProps { path: string; } +const DEMO_BEFORE = ` +import { readFile } from 'fs/promises'; +import path from 'path'; + +const CONFIG_PATH = './config.json'; +const DEFAULT_TIMEOUT = 5000; +const MAX_RETRIES = 3; + +interface Config { + name: string; + version: string; + timeout?: number; +} + +async function loadConfig(): Promise { + const raw = await readFile(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw); +} + +function logInfo(message: string) { + console.log(\`[INFO] \${message}\`); +} + +function logError(message: string) { + console.error(\`[ERROR] \${message}\`); +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + return \`\${minutes}m \${seconds % 60}s\`; +} + +async function fetchWithRetry(url: string): Promise { + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + return await fetch(url); + } catch (err) { + if (attempt === MAX_RETRIES - 1) { + throw err; + } + } + } + throw new Error('unreachable'); +} + +function isValidName(name: string): boolean { + return name.length > 0 && name.length < 100; +} + +function sanitize(input: string): string { + return input.trim().toLowerCase(); +} + +async function main() { + const config = await loadConfig(); + logInfo(\`Loaded config for \${config.name}\`); + + const start = Date.now(); + const response = await fetchWithRetry('https://example.com'); + const duration = Date.now() - start; + logInfo(\`Request took \${formatDuration(duration)}\`); +} + +main().catch(logError); +`.trim(); + +const DEMO_AFTER = ` +import { readFile, writeFile } from 'fs/promises'; +import path from 'path'; + +const CONFIG_PATH = './config.json'; +const DEFAULT_TIMEOUT = 10000; +const MAX_RETRIES = 5; + +interface Config { + name: string; + version: string; + timeout?: number; + debug?: boolean; +} + +async function loadConfig(): Promise { + const raw = await readFile(CONFIG_PATH, 'utf-8'); + return JSON.parse(raw); +} + +function logInfo(message: string) { + console.log(\`[INFO] \${message}\`); +} + +function logError(message: string) { + console.error(\`[ERROR] \${message}\`); +} + +function formatDuration(ms: number): string { + const seconds = Math.floor(ms / 1000); + const minutes = Math.floor(seconds / 60); + return \`\${minutes}m \${seconds % 60}s\`; +} + +async function fetchWithRetry(url: string, timeoutMs = DEFAULT_TIMEOUT): Promise { + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + const result = await fetch(url, { signal: controller.signal }); + clearTimeout(timer); + return result; + } catch (err) { + if (attempt === MAX_RETRIES - 1) { + throw err; + } + } + } + throw new Error('unreachable'); +} + +function isValidName(name: string): boolean { + return name.length > 0 && name.length < 100; +} + +function sanitize(input: string): string { + return input.trim().toLowerCase(); +} + +async function main() { + const config = await loadConfig(); + logInfo(\`Loaded config for \${config.name} v\${config.version}\`); + + const start = Date.now(); + const response = await fetchWithRetry('https://example.com', config.timeout); + const duration = Date.now() - start; + logInfo(\`Request took \${formatDuration(duration)}\`); + + if (config.debug) { + await writeFile('./last-response.txt', await response.text()); + } +} + +main().catch(logError); +`.trim(); + + export const CommitDiffPanel = ({ repoName, revisionName, commitSha, path }: CommitDiffPanelProps) => { + const theme = useCodeMirrorTheme(); + const [originalView, setOriginalView] = useState(); + const [modifiedView, setModifiedView] = useState(); + + const captureRef = useCallback((node: CodeMirrorMergeRef | null) => { + setOriginalView(node?.view?.a); + setModifiedView(node?.view?.b); + }, []); + return ( -
-

Hello World

+
repo: {repoName}
revision: {revisionName ?? '(none)'}
commit: {commitSha}
file: {path || '(none)'}
+
+ + + + +
); }; + +interface CodeDiffProps { + side: 'original' | 'modified'; + value: string; + language: string; + view: EditorView | undefined; +} + +const CodeDiff = ({ side, value, language, view }: CodeDiffProps) => { + const languageExtension = useCodeMirrorLanguageExtension(language, view); + + const extensions = useMemo(() => [ + EditorView.editable.of(false), + EditorState.readOnly.of(true), + EditorView.lineWrapping, + lineNumbers(), + languageExtension, + ], [languageExtension]); + + const Editor = side === 'original' ? CodeMirrorMerge.Original : CodeMirrorMerge.Modified; + return ; +}; diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx index 60d521ad2..882c0936c 100644 --- a/packages/web/src/app/layout.tsx +++ b/packages/web/src/app/layout.tsx @@ -44,14 +44,14 @@ export default function RootLayout({ /> )} - {env.NODE_ENV === "development" && ( + {env.NODE_ENV === "development" && env.DEBUG_ENABLE_REACT_GRAP === 'true' && (