Skip to content

Commit 5b7b01b

Browse files
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>
1 parent 038edcc commit 5b7b01b

17 files changed

Lines changed: 464 additions & 157 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ 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)
1314
- 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)
1415

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/commitDiffPanel/fileDiffList.tsx

Lines changed: 4 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ interface FileDiffListProps {
1111
commitSha: string;
1212
// Null for the initial commit (no parent).
1313
parentSha: string | null;
14-
// When set, scroll the matching file into view on mount.
15-
targetPath?: string;
1614
}
1715

1816
// Constants used to estimate row height up front so the virtualizer can size
@@ -34,33 +32,13 @@ const estimateRowHeight = (file: FileDiff): number => {
3432
return Math.max(estimated, MIN_ROW_HEIGHT_PX);
3533
};
3634

37-
export const FileDiffList = ({ files, repoName, commitSha, parentSha, targetPath }: FileDiffListProps) => {
35+
export const FileDiffList = ({ files, repoName, commitSha, parentSha }: FileDiffListProps) => {
3836
const parentRef = useRef<HTMLDivElement>(null);
3937

40-
// Reorder so the URL-targeted file (matched against either side, so
41-
// renames work regardless of which name the URL points to) is at the top
42-
// of the list. Other files keep their original order behind it.
43-
const orderedFiles = useMemo(() => {
44-
if (!targetPath) {
45-
return files;
46-
}
47-
const targetIdx = files.findIndex(
48-
(file) => file.newPath === targetPath || file.oldPath === targetPath,
49-
);
50-
if (targetIdx < 0) {
51-
return files;
52-
}
53-
return [
54-
files[targetIdx],
55-
...files.slice(0, targetIdx),
56-
...files.slice(targetIdx + 1),
57-
];
58-
}, [files, targetPath]);
59-
6038
const virtualizer = useVirtualizer({
61-
count: orderedFiles.length,
39+
count: files.length,
6240
getScrollElement: () => parentRef.current,
63-
estimateSize: (index) => estimateRowHeight(orderedFiles[index]),
41+
estimateSize: (index) => estimateRowHeight(files[index]),
6442
overscan: 6,
6543
});
6644

@@ -82,7 +60,7 @@ export const FileDiffList = ({ files, repoName, commitSha, parentSha, targetPath
8260
}}
8361
>
8462
{virtualizer.getVirtualItems().map((virtualRow) => {
85-
const file = orderedFiles[virtualRow.index];
63+
const file = files[virtualRow.index];
8664
const rowKey = file.newPath ?? file.oldPath ?? `idx-${virtualRow.index}`;
8765
return (
8866
<div

packages/web/src/app/(app)/browse/[...path]/components/commitDiffPanel/fileDiffRow.tsx

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,23 +7,9 @@ import { useCallback, useMemo } from "react";
77
import { CommitActionLink } from "../../../components/commitParts";
88
import { getBrowsePath } from "../../../hooks/utils";
99
import { computeChangeCounts, DiffStat } from "./diffStat";
10+
import { getFileStatus, StatusBadge } from "./fileStatus";
1011
import { LightweightDiffViewer } from "./lightweightDiffViewer";
1112

12-
type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed';
13-
14-
const getFileStatus = (file: FileDiff): FileStatus => {
15-
if (!file.oldPath) {
16-
return 'added';
17-
}
18-
if (!file.newPath) {
19-
return 'deleted';
20-
}
21-
if (file.oldPath !== file.newPath) {
22-
return 'renamed';
23-
}
24-
return 'modified';
25-
};
26-
2713
const getDisplayPath = (file: FileDiff): string => {
2814
if (getFileStatus(file) === 'renamed') {
2915
return `${file.oldPath}${file.newPath}`;
@@ -103,25 +89,3 @@ export const FileDiffRow = ({ file, yOffset, repoName, commitSha, parentSha }: F
10389
</div>
10490
);
10591
};
106-
107-
const STATUS_LABELS: Record<FileStatus, string> = {
108-
added: 'A',
109-
modified: 'M',
110-
deleted: 'D',
111-
renamed: 'R',
112-
};
113-
114-
const STATUS_COLORS: Record<FileStatus, string> = {
115-
added: 'bg-green-500/20 text-green-700 dark:text-green-400',
116-
modified: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
117-
deleted: 'bg-red-500/20 text-red-700 dark:text-red-400',
118-
renamed: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
119-
};
120-
121-
const StatusBadge = ({ status }: { status: FileStatus }) => (
122-
<span
123-
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-mono font-bold ${STATUS_COLORS[status]}`}
124-
>
125-
{STATUS_LABELS[status]}
126-
</span>
127-
);
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import type { FileDiff } from "@/features/git";
2+
3+
export type FileStatus = 'added' | 'modified' | 'deleted' | 'renamed';
4+
5+
export const getFileStatus = (file: FileDiff): FileStatus => {
6+
if (!file.oldPath) {
7+
return 'added';
8+
}
9+
if (!file.newPath) {
10+
return 'deleted';
11+
}
12+
if (file.oldPath !== file.newPath) {
13+
return 'renamed';
14+
}
15+
return 'modified';
16+
};
17+
18+
const STATUS_BADGE_LABELS: Record<FileStatus, string> = {
19+
added: 'A',
20+
modified: 'M',
21+
deleted: 'D',
22+
renamed: 'R',
23+
};
24+
25+
const STATUS_BADGE_COLORS: Record<FileStatus, string> = {
26+
added: 'bg-green-500/20 text-green-700 dark:text-green-400',
27+
modified: 'bg-yellow-500/20 text-yellow-700 dark:text-yellow-400',
28+
deleted: 'bg-red-500/20 text-red-700 dark:text-red-400',
29+
renamed: 'bg-blue-500/20 text-blue-700 dark:text-blue-400',
30+
};
31+
32+
export const StatusBadge = ({ status }: { status: FileStatus }) => (
33+
<span
34+
className={`inline-flex items-center justify-center w-5 h-5 rounded text-xs font-mono font-bold ${STATUS_BADGE_COLORS[status]}`}
35+
>
36+
{STATUS_BADGE_LABELS[status]}
37+
</span>
38+
);

0 commit comments

Comments
 (0)