Skip to content

Commit e1bc871

Browse files
feat(web): Commit diffs (#1154)
* feat(web): scaffold commit diff routing Adds a `commit` pathType to the browse routes (`/browse/<repo>@<branch>/-/commit/<sha>[/<file>]`) 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * refactor browse components into seperate folders * feat(web): swap commit diff renderer for lightweight viewer Replace the CodeMirror MergeView-based commit diff rendering with a DOM based split-view that renders directly from git's hunks, inspired by Chrome DevTools' DiffView. Per-file editor instances and the matching getFileSource fetches are gone — a 50-file commit drops from ~100 network requests to 0, and per-row render cost from a full editor mount to a synchronous Lezer highlight + grid emit. - New `LightweightDiffViewer` builds a single 2-column subgrid with hunk headers spanning both sides; each cell uses `subgrid` so line numbers, markers, and content align across all rows. - Pure helpers split out: `hunkParser` (body string → DiffLine[]), `splitPairing` (DiffLine[] → SplitRow[]). - `presentableDiff` from @codemirror/merge supplies character-level intra-line diff highlighting on paired modifications. - Lezer highlight code lifted from `lightweightCodeHighlighter` into `lib/codeHighlight` so both files share the helper. - Drop `react-codemirror-merge` and `commitDiffEditor`. Long lines wrap via `whitespace-pre-wrap break-words` + `minmax(0, 1fr)` on the grid. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(web): polish commit diff layout and sticky behavior - File path header now sticks to the top of the scroll viewport while scrolling through that file's diff, using the negative-yOffset trick to compensate for the virtualizer's translateY positioning. Same pattern as searchResultsPanel/fileMatchContainer. - Lightweight diff viewer's grid uses `minmax(<min>, max-content)` for line-number and marker columns so they don't collapse to zero width when one side of the diff is entirely blank (fully-added or fully-deleted files), keeping the right pane aligned across files. - Drop the column gap between left and right panes and instead draw a `border-r` separator on the left cells for a cleaner divider. - Hunk header gets an optional className so the first hunk renders with just `border-b` (the file header above already provides the top border), while subsequent hunks render with `border-y` between them. - Drop the per-row footer padding in the virtualizer; rows now sit flush against each other. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add diffstat indicators and per-file actions to commit diff - New `DiffStat` component renders GitHub-style additions/deletions counts with a 5-square indicator scaled log-ish to total change size. Added on the right of each file row header and on the right of the "N files changed" subheader for the commit total. Hidden when there are no line-level changes (pure renames). - Each file row gets a `CopyIconButton` next to the path (copies newPath, falling back to oldPath) and a `CommitActionLink` that opens the file at the commit. Deleted files link to the old path at the parent commit so the user lands on the file's last existing state rather than a 404. - `repoName`, `commitSha`, and `parentSha` are plumbed from the panel through `FileDiffList` to `FileDiffRow` to support the new link. - `computeChangeCounts` is memoized per file in the row. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * move logger out of utils * feat(web): clickable history rows, multi-author commit header, file anchoring History panel rows in both the bottom panel and the commits page are now clickable — they navigate to the matching commit diff via router.push, with closest('button, a') short-circuit so inner action buttons keep their own behavior. Bottom-panel history rows also highlight via bg-accent when their commit is the one currently being viewed. Commit diff header now uses AuthorsAvatarGroup + getCommitAuthors + formatAuthorsText, matching latestCommitInfo and historyRow — co-authors parsed from the commit body show up correctly. When the URL trailing path matches one of the commit's files, that file is moved to the top of the FileDiffList rather than scrolled to. Avoids estimateSize-based scroll inaccuracy and works regardless of which side of a rename the URL points to. Lightweight diff viewer short-circuits with "Diff too large to display" for files containing lines over 1000 chars, matching the cutoff in lightweightCodeHighlighter. PathHeader's breadcrumb measurement reserved 175px for "copy button and padding"; the actual reservation needed is ~40px. Reduced so breadcrumbs no longer collapse prematurely on wide layouts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * wip * fix(web): polish PathHeader rendering for SHAs and empty paths - Lift `truncateSha` (was a private helper in getDiffToolComponent) to `lib/utils` so PathHeader can reuse it. The branch/ref display now renders a 40-char SHA as `abc1234`, preserving any `^` / `~N` suffix. - Hide the `·` separator and the path's CopyIconButton when there's no path (repo root). Previously a dangling `·` and copy button rendered with nothing between them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): add optional path filter to /api/diff and get_diff tool Adds a `path` query/tool parameter to restrict diff output to changes touching a single file via git's `-- <pathspec>` separator. Refactors the route handler to use the shared `getDiffRequestSchema`. Fixes SOU-1154 (#1154) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(web): focused single-file commit diff view - Move single-file commit diffs from `/-/commit/<sha>/<path>` to `/-/blob/<path>?ref=<sha>&diff=true`, keeping the user's browsing revision in the URL. `/-/commit/<sha>` still renders the full multi-file diff. - New `FocusedCommitDiffPanel` with status row (file status badge + authors + relative commit date + "View full commit" + DiffStat + exit-X) and path-filtered `getDiff` so only the single file's diff is fetched. - New preview banner in `CodePreviewPanel` when `?ref=` is set, with a close button that strips the param. - Make `PathHeader`'s revision clickable, linking to that ref's full commit view. - New `HoverPrefetchLink` defers Next.js prefetching until hover; used in history rows to avoid firing many prefetches on render. - Hide the bottom panel on `/-/commit/` views. - Extract `getFileStatus` / `StatusBadge` to a shared `fileStatus.tsx`. - Workaround Radix Tooltip + RSC re-render bug (drop `asChild` from `<TooltipTrigger>`, add `key={commitSha}`) so X / Browse-files buttons survive client navigation between commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feedback * typo --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 178b9c0 commit e1bc871

49 files changed

Lines changed: 1749 additions & 357 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111
- Added commit history viewer to code browser. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
12+
- Added commit diff viewer to code browser. [#1154](https://github.com/sourcebot-dev/sourcebot/pull/1154)
1213
- Added `/api/commits/authors` to the public API to allow fetching a list of authors for a given path and revision. [#1150](https://github.com/sourcebot-dev/sourcebot/pull/1150)
14+
- Added optional `path` query parameter to the `/api/diff` endpoint and `get_diff` MCP tool to restrict diffs to changes touching a specific file. [#1154](https://github.com/sourcebot-dev/sourcebot/pull/1154)
1315

1416
### Fixed
1517
- Bumped `postcss` to `8.5.10`. [#1155](https://github.com/sourcebot-dev/sourcebot/pull/1155)

docs/api-reference/sourcebot-public.openapi.json

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1585,6 +1585,16 @@
15851585
"description": "The head git ref (branch, tag, or commit SHA) to diff to.",
15861586
"name": "head",
15871587
"in": "query"
1588+
},
1589+
{
1590+
"schema": {
1591+
"type": "string",
1592+
"description": "Restrict the diff to changes touching this file path. Omit to diff all changes between the two refs."
1593+
},
1594+
"required": false,
1595+
"description": "Restrict the diff to changes touching this file path. Omit to diff all changes between the two refs.",
1596+
"name": "path",
1597+
"in": "query"
15881598
}
15891599
],
15901600
"responses": {

packages/shared/src/env.server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ const options = {
246246

247247
DEBUG_WRITE_CHAT_MESSAGES_TO_FILE: booleanSchema.default('false'),
248248
DEBUG_ENABLE_REACT_SCAN: booleanSchema.default('false'),
249+
DEBUG_ENABLE_REACT_GRAB: booleanSchema.default('false'),
249250

250251
LANGFUSE_SECRET_KEY: z.string().optional(),
251252

packages/web/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
"@codemirror/language": "^6.0.0",
5151
"@codemirror/language-data": "^6.5.1",
5252
"@codemirror/legacy-modes": "^6.4.2",
53+
"@codemirror/merge": "^6.12.1",
5354
"@codemirror/search": "^6.5.6",
5455
"@codemirror/state": "^6.4.1",
5556
"@codemirror/view": "^6.33.0",

packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel.tsx renamed to packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/codePreviewPanel.tsx

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,33 @@
11
import { getRepoInfoByName } from "@/actions";
22
import { PathHeader } from "@/app/(app)/components/pathHeader";
3+
import { Button } from "@/components/ui/button";
34
import { Separator } from "@/components/ui/separator";
4-
import { cn, getCodeHostInfoForRepo, isServiceError } from "@/lib/utils";
5+
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
6+
import { cn, getCodeHostInfoForRepo, isServiceError, truncateSha } from "@/lib/utils";
7+
import { X } from "lucide-react";
58
import Image from "next/image";
9+
import Link from "next/link";
10+
import { getBrowsePath } from "../../../hooks/utils";
611
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
712
import { getFileSource } from '@/features/git';
813

914
interface CodePreviewPanelProps {
1015
path: string;
1116
repoName: string;
1217
revisionName?: string;
18+
// When set, the file's content is fetched at this ref while the
19+
// surrounding browse context (path header) stays at `revisionName`.
20+
previewRef?: string;
1321
}
1422

15-
export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePreviewPanelProps) => {
23+
export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRef }: CodePreviewPanelProps) => {
24+
const contentRef = previewRef ?? revisionName;
25+
1626
const [fileSourceResponse, repoInfoResponse] = await Promise.all([
1727
getFileSource({
1828
path,
1929
repo: repoName,
20-
ref: revisionName,
30+
ref: contentRef,
2131
}, { source: 'sourcebot-web-client' }),
2232
getRepoInfoByName(repoName),
2333
]);
@@ -53,7 +63,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
5363
displayName: repoInfoResponse.displayName,
5464
externalWebUrl: repoInfoResponse.externalWebUrl,
5565
}}
56-
revisionName={revisionName}
66+
revisionName={contentRef}
5767
/>
5868

5969
{fileWebUrl && (
@@ -74,12 +84,54 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
7484
)}
7585
</div>
7686
<Separator />
87+
{previewRef && (
88+
<div className="flex flex-row items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
89+
<span className="text-sm">
90+
Previewing file at revision{" "}
91+
<Link
92+
href={getBrowsePath({
93+
repoName,
94+
revisionName,
95+
path: '',
96+
pathType: 'commit',
97+
commitSha: previewRef,
98+
})}
99+
className="font-mono text-link hover:underline"
100+
>
101+
{truncateSha(previewRef)}
102+
</Link>
103+
</span>
104+
<Tooltip key={previewRef}>
105+
<TooltipTrigger>
106+
<Button
107+
asChild
108+
variant="ghost"
109+
size="icon"
110+
className="h-6 w-6 text-muted-foreground"
111+
>
112+
<Link
113+
href={getBrowsePath({
114+
repoName,
115+
revisionName,
116+
path,
117+
pathType: 'blob',
118+
})}
119+
aria-label="Close preview"
120+
>
121+
<X className="h-4 w-4" />
122+
</Link>
123+
</Button>
124+
</TooltipTrigger>
125+
<TooltipContent>Close preview</TooltipContent>
126+
</Tooltip>
127+
</div>
128+
)}
77129
<PureCodePreviewPanel
78130
source={fileSourceResponse.source}
79131
language={fileSourceResponse.language}
80132
repoName={repoName}
81133
path={path}
82-
revisionName={revisionName ?? 'HEAD'}
134+
revisionName={contentRef ?? 'HEAD'}
83135
/>
84136
</>
85137
)

packages/web/src/app/(app)/browse/[...path]/components/pureCodePreviewPanel.tsx renamed to packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/pureCodePreviewPanel.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
1111
import { search } from "@codemirror/search";
1212
import CodeMirror, { EditorSelection, EditorView, ReactCodeMirrorRef, SelectionRange, ViewUpdate } from "@uiw/react-codemirror";
1313
import { useEffect, useMemo, useState } from "react";
14-
import { EditorContextMenu } from "../../../components/editorContextMenu";
15-
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "../../hooks/utils";
14+
import { EditorContextMenu } from "@/app/(app)/components/editorContextMenu";
15+
import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM } from "@/app/(app)/browse/hooks/utils";
1616
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
1717

1818
interface PureCodePreviewPanelProps {

packages/web/src/app/(app)/browse/[...path]/components/rangeHighlightingExtension.ts renamed to packages/web/src/app/(app)/browse/[...path]/components/codePreviewPanel/rangeHighlightingExtension.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { StateField, Range } from "@codemirror/state";
44
import { Decoration, DecorationSet, EditorView } from "@codemirror/view";
5-
import { BrowseHighlightRange } from "../../hooks/utils";
5+
import { BrowseHighlightRange } from "@/app/(app)/browse/hooks/utils";
66

77
const markDecoration = Decoration.mark({
88
class: "searchMatch-selected",
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
'use client';
2+
3+
import { CopyIconButton } from "@/app/(app)/components/copyIconButton";
4+
import { useToast } from "@/components/hooks/use-toast";
5+
import Link from "next/link";
6+
import { Fragment, useCallback } from "react";
7+
import { getBrowsePath } from "../../../hooks/utils";
8+
9+
interface CommitHashLineProps {
10+
repoName: string;
11+
commitHash: string;
12+
parents: string[];
13+
}
14+
15+
export const CommitHashLine = ({ repoName, commitHash, parents }: CommitHashLineProps) => {
16+
const { toast } = useToast();
17+
18+
const onCopyHash = useCallback(() => {
19+
navigator.clipboard.writeText(commitHash).then(() => {
20+
toast({ description: "✅ Copied commit SHA to clipboard" });
21+
});
22+
return true;
23+
}, [commitHash, toast]);
24+
25+
return (
26+
<div className="text-xs font-mono text-muted-foreground flex flex-row items-center gap-1">
27+
{parents.length > 0 && (
28+
<>
29+
<span>
30+
{parents.length} parent{parents.length > 1 ? 's' : ''}
31+
</span>
32+
{parents.map((parent, i) => (
33+
<Fragment key={parent}>
34+
{i > 0 && <span>+</span>}
35+
<Link
36+
href={getBrowsePath({
37+
repoName,
38+
path: '',
39+
pathType: 'commit',
40+
commitSha: parent,
41+
})}
42+
className="underline hover:text-foreground"
43+
title={parent}
44+
>
45+
{parent.slice(0, 7)}
46+
</Link>
47+
</Fragment>
48+
))}
49+
</>
50+
)}
51+
<span>commit {commitHash.slice(0, 7)}</span>
52+
<CopyIconButton onCopy={onCopyHash} />
53+
</div>
54+
);
55+
};
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use client';
2+
3+
import { CommitBody, CommitBodyToggle } from "@/app/(app)/browse/components/commitParts";
4+
import { useState } from "react";
5+
6+
interface CommitMessageProps {
7+
subject: string;
8+
body: string;
9+
}
10+
11+
export const CommitMessage = ({ subject, body }: CommitMessageProps) => {
12+
const [isBodyExpanded, setIsBodyExpanded] = useState(false);
13+
const hasBody = body.trim().length > 0;
14+
15+
return (
16+
<>
17+
<div className="flex flex-row items-center gap-2">
18+
<h1 className="text-lg font-semibold">{subject}</h1>
19+
{hasBody && (
20+
<CommitBodyToggle
21+
pressed={isBodyExpanded}
22+
onPressedChange={setIsBodyExpanded}
23+
/>
24+
)}
25+
</div>
26+
{hasBody && isBodyExpanded && (
27+
<CommitBody body={body} className="rounded max-h-[40vh] overflow-y-auto" />
28+
)}
29+
</>
30+
);
31+
};
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { FileDiff } from "@/features/git";
2+
3+
const TOTAL_SQUARES = 5;
4+
5+
// Count `+`/`-` lines across all hunks in a file.
6+
export const computeChangeCounts = (file: FileDiff) => {
7+
let additions = 0;
8+
let deletions = 0;
9+
for (const hunk of file.hunks) {
10+
for (const raw of hunk.body.split('\n')) {
11+
if (raw.startsWith('+')) {
12+
additions++;
13+
} else if (raw.startsWith('-')) {
14+
deletions++;
15+
}
16+
}
17+
}
18+
return { additions, deletions };
19+
};
20+
21+
// Sum line-level change counts across multiple files.
22+
export const computeTotalChangeCounts = (files: FileDiff[]) => {
23+
let additions = 0;
24+
let deletions = 0;
25+
for (const file of files) {
26+
const counts = computeChangeCounts(file);
27+
additions += counts.additions;
28+
deletions += counts.deletions;
29+
}
30+
return { additions, deletions };
31+
};
32+
33+
// Map a total change count to a number of filled squares (0–5) using a
34+
// log-ish scale so tiny diffs still show one square and huge diffs cap out.
35+
// Mirrors GitHub's diffstat indicator behavior.
36+
const filledSquaresForTotal = (total: number): number => {
37+
if (total === 0) {
38+
return 0;
39+
}
40+
if (total < 5) {
41+
return 1;
42+
}
43+
if (total < 10) {
44+
return 2;
45+
}
46+
if (total < 30) {
47+
return 3;
48+
}
49+
if (total < 100) {
50+
return 4;
51+
}
52+
return 5;
53+
};
54+
55+
interface DiffStatProps {
56+
additions: number;
57+
deletions: number;
58+
}
59+
60+
export const DiffStat = ({ additions, deletions }: DiffStatProps) => {
61+
const total = additions + deletions;
62+
63+
// Skip rendering when there are no line-level changes (e.g. pure renames).
64+
if (total === 0) {
65+
return null;
66+
}
67+
68+
const filled = filledSquaresForTotal(total);
69+
const greenCount = Math.round((filled * additions) / total);
70+
const redCount = filled - greenCount;
71+
const emptyCount = TOTAL_SQUARES - filled;
72+
73+
return (
74+
<div
75+
className="flex flex-row items-center gap-2 text-xs flex-shrink-0 font-mono"
76+
title={`${additions} additions, ${deletions} deletions`}
77+
>
78+
{additions > 0 && (
79+
<span className="text-green-700 dark:text-green-400">+{additions}</span>
80+
)}
81+
{deletions > 0 && (
82+
<span className="text-red-700 dark:text-red-400">-{deletions}</span>
83+
)}
84+
<div className="flex flex-row gap-px">
85+
{Array.from({ length: greenCount }).map((_, i) => (
86+
<span key={`g-${i}`} className="w-2 h-2 bg-green-500 dark:bg-green-400 rounded-[1px]" />
87+
))}
88+
{Array.from({ length: redCount }).map((_, i) => (
89+
<span key={`r-${i}`} className="w-2 h-2 bg-red-500 dark:bg-red-400 rounded-[1px]" />
90+
))}
91+
{Array.from({ length: emptyCount }).map((_, i) => (
92+
<span key={`e-${i}`} className="w-2 h-2 bg-border rounded-[1px]" />
93+
))}
94+
</div>
95+
</div>
96+
);
97+
};

0 commit comments

Comments
 (0)