Skip to content

Commit f9f41d0

Browse files
feat(web): add Code/Blame toggle and file stats to code preview panel
Adds a segmented toggle next to the path header that switches between "Code" (plain source) and "Blame" (gutter view). The toggle is hidden when previewRef is set since the preview banner handles that state. Also displays line count and file size next to the toggle (e.g. "1,246 lines · 42.6 KB"). Line count is derived from the source string (newlines, ignoring trailing); byte size uses Buffer.byteLength on the already-fetched source (no extra git call). Pulls in @radix-ui/react-toggle-group and a shadcn toggle-group.tsx component to render the segmented control. Items are styled with gap-0 + rounded-*-none + -ml-px to share a single border at the seam, matching the GitHub-style segmented control look. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c95d4d2 commit f9f41d0

5 files changed

Lines changed: 240 additions & 9 deletions

File tree

packages/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@
8686
"@radix-ui/react-switch": "^1.2.4",
8787
"@radix-ui/react-tabs": "^1.1.2",
8888
"@radix-ui/react-toast": "^1.2.2",
89-
"@radix-ui/react-toggle": "^1.1.0",
89+
"@radix-ui/react-toggle": "^1.1.10",
90+
"@radix-ui/react-toggle-group": "^1.1.11",
9091
"@radix-ui/react-tooltip": "^1.1.4",
9192
"@react-email/components": "^1.0.2",
9293
"@react-email/render": "^2.0.0",
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
'use client';
2+
3+
import { useRouter } from "next/navigation";
4+
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
5+
import { getBrowsePath } from "@/app/(app)/browse/hooks/utils";
6+
7+
interface BlameViewToggleProps {
8+
repoName: string;
9+
revisionName?: string;
10+
path: string;
11+
blame: boolean;
12+
}
13+
14+
export const BlameViewToggle = ({ repoName, revisionName, path, blame }: BlameViewToggleProps) => {
15+
const router = useRouter();
16+
17+
const handleValueChange = (value: string) => {
18+
// Radix calls onValueChange with an empty string when the user clicks
19+
// the already-selected item (would deselect). Ignore that — we want
20+
// exactly one of Code / Blame to always be selected.
21+
if (!value) {
22+
return;
23+
}
24+
router.push(getBrowsePath({
25+
repoName,
26+
revisionName,
27+
path,
28+
pathType: 'blob',
29+
blame: value === 'blame',
30+
}));
31+
};
32+
33+
// The Toggle "default" size is icon-sized (h-7 w-7 p-0) since it's the
34+
// codebase's only declared size. `w-auto min-w-0 px-3` lets the items size
35+
// to their text. The remaining classes turn the two items into a connected
36+
// segmented control: gap-0 on the group removes the flex gap, rounded-*-none
37+
// squares off the inner corners, and -ml-px pulls the second item over so
38+
// its left border overlaps the first item's right border (no double seam).
39+
const baseItemClass = "w-auto min-w-0 px-3";
40+
41+
return (
42+
<ToggleGroup
43+
type="single"
44+
value={blame ? 'blame' : 'code'}
45+
onValueChange={handleValueChange}
46+
variant="outline"
47+
className="gap-0"
48+
>
49+
<ToggleGroupItem
50+
value="code"
51+
aria-label="View source code"
52+
className={`${baseItemClass} rounded-r-none`}
53+
>
54+
Code
55+
</ToggleGroupItem>
56+
<ToggleGroupItem
57+
value="blame"
58+
aria-label="View blame"
59+
className={`${baseItemClass} rounded-l-none -ml-px`}
60+
>
61+
Blame
62+
</ToggleGroupItem>
63+
</ToggleGroup>
64+
);
65+
};

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,20 @@ import { X } from "lucide-react";
88
import Image from "next/image";
99
import Link from "next/link";
1010
import { getBrowsePath } from "../../../hooks/utils";
11+
import { BlameViewToggle } from "./blameViewToggle";
1112
import { PureCodePreviewPanel } from "./pureCodePreviewPanel";
1213
import { getFileBlame, getFileSource } from '@/features/git';
1314

15+
const formatFileSize = (bytes: number): string => {
16+
if (bytes < 1024) {
17+
return `${bytes} B`;
18+
}
19+
if (bytes < 1024 * 1024) {
20+
return `${(bytes / 1024).toFixed(1)} KB`;
21+
}
22+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
23+
};
24+
1425
interface CodePreviewPanelProps {
1526
path: string;
1627
repoName: string;
@@ -54,6 +65,13 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
5465
return <div>Error loading blame: {blameResponse.message}</div>
5566
}
5667

68+
const source = fileSourceResponse.source;
69+
const lineCount = source.length === 0
70+
? 0
71+
: source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
72+
const byteSize = Buffer.byteLength(source, 'utf-8');
73+
const fileSize = formatFileSize(byteSize);
74+
5775
const codeHostInfo = getCodeHostInfoForRepo({
5876
codeHostType: repoInfoResponse.codeHostType,
5977
name: repoInfoResponse.name,
@@ -98,6 +116,19 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName, previewRe
98116
)}
99117
</div>
100118
<Separator />
119+
{!previewRef && (
120+
<div className="flex flex-row items-center gap-3 px-4 py-1 border-b shrink-0">
121+
<BlameViewToggle
122+
repoName={repoName}
123+
revisionName={revisionName}
124+
path={path}
125+
blame={blame ?? false}
126+
/>
127+
<span className="text-sm text-muted-foreground">
128+
{lineCount.toLocaleString()} lines · {fileSize}
129+
</span>
130+
</div>
131+
)}
101132
{previewRef && (
102133
<div className="flex flex-row items-center justify-between gap-2 px-4 py-2 border-b shrink-0">
103134
<span className="text-sm">
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
5+
import { type VariantProps } from "class-variance-authority"
6+
7+
import { cn } from "@/lib/utils"
8+
import { toggleVariants } from "@/components/ui/toggle"
9+
10+
const ToggleGroupContext = React.createContext<
11+
VariantProps<typeof toggleVariants>
12+
>({
13+
size: "default",
14+
variant: "default",
15+
})
16+
17+
const ToggleGroup = React.forwardRef<
18+
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
19+
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
20+
VariantProps<typeof toggleVariants>
21+
>(({ className, variant, size, children, ...props }, ref) => (
22+
<ToggleGroupPrimitive.Root
23+
ref={ref}
24+
className={cn("flex items-center justify-center gap-1", className)}
25+
{...props}
26+
>
27+
<ToggleGroupContext.Provider value={{ variant, size }}>
28+
{children}
29+
</ToggleGroupContext.Provider>
30+
</ToggleGroupPrimitive.Root>
31+
))
32+
33+
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
34+
35+
const ToggleGroupItem = React.forwardRef<
36+
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
37+
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
38+
VariantProps<typeof toggleVariants>
39+
>(({ className, children, variant, size, ...props }, ref) => {
40+
const context = React.useContext(ToggleGroupContext)
41+
42+
return (
43+
<ToggleGroupPrimitive.Item
44+
ref={ref}
45+
className={cn(
46+
toggleVariants({
47+
variant: context.variant || variant,
48+
size: context.size || size,
49+
}),
50+
className
51+
)}
52+
{...props}
53+
>
54+
{children}
55+
</ToggleGroupPrimitive.Item>
56+
)
57+
})
58+
59+
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
60+
61+
export { ToggleGroup, ToggleGroupItem }

yarn.lock

Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5196,6 +5196,13 @@ __metadata:
51965196
languageName: node
51975197
linkType: hard
51985198

5199+
"@radix-ui/primitive@npm:1.1.3":
5200+
version: 1.1.3
5201+
resolution: "@radix-ui/primitive@npm:1.1.3"
5202+
checksum: 10c0/88860165ee7066fa2c179f32ffcd3ee6d527d9dcdc0e8be85e9cb0e2c84834be8e3c1a976c74ba44b193f709544e12f54455d892b28e32f0708d89deda6b9f1d
5203+
languageName: node
5204+
linkType: hard
5205+
51995206
"@radix-ui/react-accordion@npm:^1.2.11":
52005207
version: 1.2.11
52015208
resolution: "@radix-ui/react-accordion@npm:1.2.11"
@@ -6108,6 +6115,33 @@ __metadata:
61086115
languageName: node
61096116
linkType: hard
61106117

6118+
"@radix-ui/react-roving-focus@npm:1.1.11":
6119+
version: 1.1.11
6120+
resolution: "@radix-ui/react-roving-focus@npm:1.1.11"
6121+
dependencies:
6122+
"@radix-ui/primitive": "npm:1.1.3"
6123+
"@radix-ui/react-collection": "npm:1.1.7"
6124+
"@radix-ui/react-compose-refs": "npm:1.1.2"
6125+
"@radix-ui/react-context": "npm:1.1.2"
6126+
"@radix-ui/react-direction": "npm:1.1.1"
6127+
"@radix-ui/react-id": "npm:1.1.1"
6128+
"@radix-ui/react-primitive": "npm:2.1.3"
6129+
"@radix-ui/react-use-callback-ref": "npm:1.1.1"
6130+
"@radix-ui/react-use-controllable-state": "npm:1.2.2"
6131+
peerDependencies:
6132+
"@types/react": "*"
6133+
"@types/react-dom": "*"
6134+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
6135+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
6136+
peerDependenciesMeta:
6137+
"@types/react":
6138+
optional: true
6139+
"@types/react-dom":
6140+
optional: true
6141+
checksum: 10c0/2cd43339c36e89a3bf1db8aab34b939113dfbde56bf3a33df2d74757c78c9489b847b1962f1e2441c67e41817d120cb6177943e0f655f47bc1ff8e44fd55b1a2
6142+
languageName: node
6143+
linkType: hard
6144+
61116145
"@radix-ui/react-roving-focus@npm:1.1.2":
61126146
version: 1.1.2
61136147
resolution: "@radix-ui/react-roving-focus@npm:1.1.2"
@@ -6377,13 +6411,17 @@ __metadata:
63776411
languageName: node
63786412
linkType: hard
63796413

6380-
"@radix-ui/react-toggle@npm:^1.1.0":
6381-
version: 1.1.2
6382-
resolution: "@radix-ui/react-toggle@npm:1.1.2"
6414+
"@radix-ui/react-toggle-group@npm:^1.1.11":
6415+
version: 1.1.11
6416+
resolution: "@radix-ui/react-toggle-group@npm:1.1.11"
63836417
dependencies:
6384-
"@radix-ui/primitive": "npm:1.1.1"
6385-
"@radix-ui/react-primitive": "npm:2.0.2"
6386-
"@radix-ui/react-use-controllable-state": "npm:1.1.0"
6418+
"@radix-ui/primitive": "npm:1.1.3"
6419+
"@radix-ui/react-context": "npm:1.1.2"
6420+
"@radix-ui/react-direction": "npm:1.1.1"
6421+
"@radix-ui/react-primitive": "npm:2.1.3"
6422+
"@radix-ui/react-roving-focus": "npm:1.1.11"
6423+
"@radix-ui/react-toggle": "npm:1.1.10"
6424+
"@radix-ui/react-use-controllable-state": "npm:1.2.2"
63876425
peerDependencies:
63886426
"@types/react": "*"
63896427
"@types/react-dom": "*"
@@ -6394,7 +6432,28 @@ __metadata:
63946432
optional: true
63956433
"@types/react-dom":
63966434
optional: true
6397-
checksum: 10c0/2cd8dc6b64c2680f4c0662ff2424963e8cc432de3a925a549e8fd5e5e7b48da1a08434ef4ab49b6b627faea1628160f89a16f098399104ed06a00220170f72a2
6435+
checksum: 10c0/c8cbccda3e25754ed9f3145c67792df2d5d0ee1a910bde6dc07c4577ab508d4b939f145569d4e2af5b17dc4a5c701473380d8695248f8620cf0a372c05b8e958
6436+
languageName: node
6437+
linkType: hard
6438+
6439+
"@radix-ui/react-toggle@npm:1.1.10, @radix-ui/react-toggle@npm:^1.1.10":
6440+
version: 1.1.10
6441+
resolution: "@radix-ui/react-toggle@npm:1.1.10"
6442+
dependencies:
6443+
"@radix-ui/primitive": "npm:1.1.3"
6444+
"@radix-ui/react-primitive": "npm:2.1.3"
6445+
"@radix-ui/react-use-controllable-state": "npm:1.2.2"
6446+
peerDependencies:
6447+
"@types/react": "*"
6448+
"@types/react-dom": "*"
6449+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
6450+
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
6451+
peerDependenciesMeta:
6452+
"@types/react":
6453+
optional: true
6454+
"@types/react-dom":
6455+
optional: true
6456+
checksum: 10c0/5406cdf5dd7299ae6cfdb4865dc5fd43ca3c475ebcd4e86830bd296d734255b61f749c9bde452ebfaad126033f92dd1112ee9d95982344ffad34491238dcc9b1
63986457
languageName: node
63996458
linkType: hard
64006459

@@ -6456,6 +6515,19 @@ __metadata:
64566515
languageName: node
64576516
linkType: hard
64586517

6518+
"@radix-ui/react-use-callback-ref@npm:1.1.1":
6519+
version: 1.1.1
6520+
resolution: "@radix-ui/react-use-callback-ref@npm:1.1.1"
6521+
peerDependencies:
6522+
"@types/react": "*"
6523+
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
6524+
peerDependenciesMeta:
6525+
"@types/react":
6526+
optional: true
6527+
checksum: 10c0/5f6aff8592dea6a7e46589808912aba3fb3b626cf6edd2b14f01638b61dbbe49eeb9f67cd5601f4c15b2fb547b9a7e825f7c4961acd4dd70176c969ae405f8d8
6528+
languageName: node
6529+
linkType: hard
6530+
64596531
"@radix-ui/react-use-controllable-state@npm:1.0.1":
64606532
version: 1.0.1
64616533
resolution: "@radix-ui/react-use-controllable-state@npm:1.0.1"
@@ -8729,7 +8801,8 @@ __metadata:
87298801
"@radix-ui/react-switch": "npm:^1.2.4"
87308802
"@radix-ui/react-tabs": "npm:^1.1.2"
87318803
"@radix-ui/react-toast": "npm:^1.2.2"
8732-
"@radix-ui/react-toggle": "npm:^1.1.0"
8804+
"@radix-ui/react-toggle": "npm:^1.1.10"
8805+
"@radix-ui/react-toggle-group": "npm:^1.1.11"
87338806
"@radix-ui/react-tooltip": "npm:^1.1.4"
87348807
"@react-email/components": "npm:^1.0.2"
87358808
"@react-email/preview-server": "npm:5.2.10"

0 commit comments

Comments
 (0)