Skip to content

Commit b7b59c9

Browse files
committed
refactor(plugin-git): rolldown-style resizable panel shell
Rework the dashboard into a devtools shell modeled on vite-devtools' rolldown UI: a fixed-height app that fills the viewport with no page scroll, flat panels separated by a single 1px border that doubles as a drag-to-resize handle (left sidebar and the commit-details rail persist their widths to localStorage), and each panel scrolling inside its own region. Drops the rounded card chrome, folds the branch list into the left rail under its own panel heading, and makes the Status and commit-details panels fill and scroll like the commit log already does. The patch renderer gains an inline (non-scrolling) mode so the details rail keeps a single scroll context. Storybook's decorator now frames stories in a fixed-height bordered panel to match.
1 parent 79e9978 commit b7b59c9

5 files changed

Lines changed: 215 additions & 137 deletions

File tree

‎plugins/git/.storybook/preview.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ const withTheme: Decorator = (Story, context) => {
88
document.documentElement.classList.toggle('dark', theme === 'dark')
99
}, [theme])
1010
return (
11-
<div className="bg-background text-foreground min-h-svh p-6">
12-
<div className="mx-auto max-w-2xl">
11+
<div className="bg-background text-foreground flex h-svh justify-center p-6">
12+
<div className="flex h-full w-full max-w-2xl flex-col overflow-hidden rounded-lg border p-3">
1313
<Story />
1414
</div>
1515
</div>

‎plugins/git/src/client/components/dashboard.tsx‎

Lines changed: 194 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
'use client'
22

33
import type { DevframeRpcClient } from 'devframe/client'
4+
import type { PointerEvent as ReactPointerEvent } from 'react'
45
import type { Branch, GitBranches } from '../../index'
56
import { FileDiff, GitBranch, GitCommitHorizontal, GitGraph, ListTree, Moon, RefreshCw, Sun } from 'lucide-react'
6-
import { useCallback, useEffect, useState } from 'react'
7+
import { useCallback, useEffect, useRef, useState } from 'react'
78
import { cn } from '../lib/utils'
89
import { CommitDetailsPanel } from './commit-details-panel'
910
import { DiffPanel } from './diff-panel'
@@ -13,8 +14,6 @@ import { StatusPanel } from './status-panel'
1314
import { useTheme } from './theme'
1415
import { Badge } from './ui/badge'
1516
import { Button } from './ui/button'
16-
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
17-
import { ScrollArea } from './ui/scroll-area'
1817
import { Skeleton } from './ui/skeleton'
1918
import { useRpcResource } from './use-rpc-resource'
2019

@@ -32,6 +31,68 @@ const NAV_ITEMS: NavItem[] = [
3231
{ id: 'diff', label: 'Diff', icon: FileDiff },
3332
]
3433

34+
function clamp(value: number, min: number, max: number): number {
35+
return Math.min(Math.max(value, min), max)
36+
}
37+
38+
/**
39+
* A draggable rail width, persisted to localStorage. `dir` is +1 for a
40+
* left-edge rail (drag right widens) and -1 for a right-edge rail (drag left
41+
* widens), matching the resizer's position relative to the panel it sizes.
42+
*/
43+
function useRailWidth(key: string, initial: number, min: number, max: number, dir: 1 | -1) {
44+
const [width, setWidth] = useState(initial)
45+
const widthRef = useRef(initial)
46+
widthRef.current = width
47+
48+
useEffect(() => {
49+
try {
50+
const saved = Number(localStorage.getItem(key))
51+
if (Number.isFinite(saved) && saved > 0)
52+
setWidth(clamp(saved, min, max))
53+
}
54+
catch {}
55+
}, [key, min, max])
56+
57+
const onPointerDown = useCallback((event: ReactPointerEvent) => {
58+
event.preventDefault()
59+
const startX = event.clientX
60+
const startW = widthRef.current
61+
const move = (ev: PointerEvent) => setWidth(clamp(startW + dir * (ev.clientX - startX), min, max))
62+
const up = () => {
63+
window.removeEventListener('pointermove', move)
64+
window.removeEventListener('pointerup', up)
65+
document.body.style.cursor = ''
66+
document.body.style.userSelect = ''
67+
try {
68+
localStorage.setItem(key, String(Math.round(widthRef.current)))
69+
}
70+
catch {}
71+
}
72+
window.addEventListener('pointermove', move)
73+
window.addEventListener('pointerup', up)
74+
document.body.style.cursor = 'col-resize'
75+
document.body.style.userSelect = 'none'
76+
}, [key, min, max, dir])
77+
78+
return { width, onPointerDown }
79+
}
80+
81+
/** The single 1px border between two panels, doubling as a drag handle. */
82+
function Resizer({ onPointerDown, label }: { onPointerDown: (e: ReactPointerEvent) => void, label: string }) {
83+
return (
84+
<div
85+
role="separator"
86+
aria-orientation="vertical"
87+
aria-label={label}
88+
onPointerDown={onPointerDown}
89+
className="group bg-border relative w-px shrink-0 cursor-col-resize"
90+
>
91+
<div className="group-hover:bg-primary/40 group-active:bg-primary/60 absolute inset-y-0 -left-1 -right-1 z-10 transition-colors" />
92+
</div>
93+
)
94+
}
95+
3596
function ConnectionBadge() {
3697
const { rpc, error } = useRpc()
3798
if (error)
@@ -91,11 +152,22 @@ function BranchRow({
91152
)
92153
}
93154

155+
function PanelHeading({ children }: { children: React.ReactNode }) {
156+
return (
157+
<div className="flex h-9 shrink-0 items-center justify-between gap-2 border-b px-3">
158+
{children}
159+
</div>
160+
)
161+
}
162+
94163
function DashboardBody() {
95164
const [pane, setPane] = useState<DashboardPane>('commits')
96165
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
97166
const [selectedCommit, setSelectedCommit] = useState<string | null>(null)
98167

168+
const leftRail = useRailWidth('devframe-git:rail-left', 264, 200, 420, 1)
169+
const rightRail = useRailWidth('devframe-git:rail-right', 360, 280, 560, -1)
170+
99171
const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('git:branches'), [])
100172
const {
101173
data: branches,
@@ -122,12 +194,11 @@ function DashboardBody() {
122194
setPane('commits')
123195
}
124196

125-
// The detail panel only makes sense alongside the commit list.
126197
const showCommitDetails = pane === 'commits' && selectedCommit !== null
127198

128199
return (
129-
<main className="flex min-h-svh w-full flex-col">
130-
<header className="bg-background/80 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-20 flex items-center justify-between gap-3 border-b px-4 py-3 backdrop-blur md:px-6">
200+
<div className="bg-background flex h-svh w-full flex-col overflow-hidden">
201+
<header className="flex shrink-0 items-center justify-between gap-3 border-b px-4 py-3">
131202
<div className="flex items-center gap-2.5">
132203
<div className="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-lg">
133204
<GitGraph className="size-5" />
@@ -145,131 +216,132 @@ function DashboardBody() {
145216
</div>
146217
</header>
147218

148-
<div className="grid min-h-0 flex-1 gap-4 px-4 py-4 md:px-6 xl:grid-cols-[250px_minmax(0,1fr)_320px]">
149-
<aside className="space-y-4">
150-
<Card>
151-
<CardHeader className="pb-2">
152-
<CardTitle className="text-sm">Workspace</CardTitle>
153-
<CardDescription>Switch views and choose the log branch.</CardDescription>
154-
</CardHeader>
155-
<CardContent className="space-y-4">
156-
<div className="space-y-1.5">
157-
<label htmlFor="branch-select" className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
158-
Branch
159-
</label>
160-
<select
161-
id="branch-select"
162-
value={selectedBranch ?? ''}
163-
onChange={event => selectBranch(event.target.value)}
164-
disabled={branchesLoading || !branches?.isRepo || branches.branches.length === 0}
165-
className="bg-background border-input focus:ring-ring h-9 w-full rounded-md border px-2 text-sm outline-none focus:ring-2"
219+
<div className="flex min-h-0 flex-1">
220+
{/* Left rail: views + branch picker, then the branch list. */}
221+
<aside className="flex min-h-0 flex-col" style={{ width: leftRail.width }}>
222+
<div className="shrink-0 space-y-3 border-b p-3">
223+
<div className="space-y-1.5">
224+
<label htmlFor="branch-select" className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
225+
Branch
226+
</label>
227+
<select
228+
id="branch-select"
229+
value={selectedBranch ?? ''}
230+
onChange={event => selectBranch(event.target.value)}
231+
disabled={branchesLoading || !branches?.isRepo || branches.branches.length === 0}
232+
className="bg-background border-input focus:ring-ring h-9 w-full rounded-md border px-2 text-sm outline-none focus:ring-2"
233+
>
234+
{!branches?.isRepo && <option value="">Not a repository</option>}
235+
{branches?.isRepo && branches.branches.length === 0 && <option value="">No branches</option>}
236+
{branches?.isRepo && branches.branches.map(branch => (
237+
<option key={branch.name} value={branch.name}>{branch.name}</option>
238+
))}
239+
</select>
240+
</div>
241+
242+
<nav className="space-y-1">
243+
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
244+
<Button
245+
key={id}
246+
type="button"
247+
variant={pane === id ? 'secondary' : 'ghost'}
248+
className="w-full justify-start"
249+
onClick={() => setPane(id)}
166250
>
167-
{!branches?.isRepo && <option value="">Not a repository</option>}
168-
{branches?.isRepo && branches.branches.length === 0 && <option value="">No branches</option>}
169-
{branches?.isRepo && branches.branches.map(branch => (
170-
<option key={branch.name} value={branch.name}>{branch.name}</option>
171-
))}
172-
</select>
173-
</div>
251+
<Icon className="size-4" />
252+
{label}
253+
</Button>
254+
))}
255+
</nav>
174256

175-
<nav className="space-y-1">
176-
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
177-
<Button
178-
key={id}
179-
type="button"
180-
variant={pane === id ? 'secondary' : 'ghost'}
181-
className="w-full justify-start"
182-
onClick={() => setPane(id)}
183-
>
184-
<Icon className="size-4" />
185-
{label}
186-
</Button>
187-
))}
188-
</nav>
257+
{branchesError && <p className="text-destructive text-xs">{branchesError}</p>}
258+
</div>
189259

190-
{branchesError && <p className="text-destructive text-xs">{branchesError}</p>}
191-
</CardContent>
192-
</Card>
193-
</aside>
260+
<div className="flex min-h-0 flex-1 flex-col">
261+
<PanelHeading>
262+
<span className="text-xs font-medium">Branches</span>
263+
<div className="flex items-center gap-1">
264+
<span className="text-muted-foreground text-[11px] tabular-nums">
265+
{branches?.isRepo ? branches.branches.length : ''}
266+
</span>
267+
<Button
268+
variant="ghost"
269+
size="icon"
270+
className="size-6"
271+
onClick={refreshBranches}
272+
disabled={branchesLoading}
273+
aria-label="Refresh branches"
274+
>
275+
<RefreshCw className={cn('size-3.5', branchesLoading && 'animate-spin')} />
276+
</Button>
277+
</div>
278+
</PanelHeading>
194279

195-
<section className="flex min-w-0 flex-col">
196-
<Card className="flex h-full min-h-0 flex-col py-4">
197-
<CardContent className="flex min-h-0 flex-1 flex-col px-4">
198-
{pane === 'status' && <StatusPanel />}
199-
{pane === 'commits' && (
200-
<LogPanel
201-
branch={selectedBranch}
202-
selectedHash={selectedCommit}
203-
onSelectCommit={setSelectedCommit}
204-
/>
280+
<div className="scrollbar-slim min-h-0 flex-1 overflow-y-auto p-2">
281+
{!branches && (
282+
<div className="space-y-2">
283+
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
284+
</div>
205285
)}
206-
{pane === 'diff' && <DiffPanel />}
207-
</CardContent>
208-
</Card>
209-
</section>
210286

211-
<aside className="hidden min-w-0 xl:block">
212-
<Card className="h-full py-4">
213-
<CardContent className="px-4">
214-
{showCommitDetails && selectedCommit
215-
? (
216-
<CommitDetailsPanel
217-
hash={selectedCommit}
218-
onClose={() => setSelectedCommit(null)}
287+
{branches && !branches.isRepo && (
288+
<p className="text-muted-foreground p-2 text-sm">The working directory is not a git repository.</p>
289+
)}
290+
291+
{branches?.isRepo && (
292+
<ul className="space-y-0.5">
293+
{branches.branches.map(branch => (
294+
<BranchRow
295+
key={branch.name}
296+
branch={branch}
297+
selected={branch.name === selectedBranch}
298+
onSelect={selectBranch}
219299
/>
220-
)
221-
: (
222-
<>
223-
<div className="flex items-center justify-between pb-2">
224-
<div>
225-
<CardTitle className="text-sm">Branches</CardTitle>
226-
<CardDescription>
227-
{branches?.isRepo ? `${branches.branches.length} branches` : ' '}
228-
</CardDescription>
229-
</div>
230-
<Button
231-
variant="ghost"
232-
size="icon"
233-
className="size-7"
234-
onClick={refreshBranches}
235-
disabled={branchesLoading}
236-
aria-label="Refresh branches"
237-
>
238-
<RefreshCw className={cn('size-3.5', branchesLoading && 'animate-spin')} />
239-
</Button>
240-
</div>
241-
242-
{!branches && (
243-
<div className="space-y-2">
244-
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
245-
</div>
246-
)}
247-
248-
{branches && !branches.isRepo && (
249-
<p className="text-muted-foreground text-sm">The working directory is not a git repository.</p>
250-
)}
251-
252-
{branches?.isRepo && (
253-
<ScrollArea className="h-[calc(100vh-16rem)] pr-2">
254-
<ul className="space-y-1">
255-
{branches.branches.map(branch => (
256-
<BranchRow
257-
key={branch.name}
258-
branch={branch}
259-
selected={branch.name === selectedBranch}
260-
onSelect={selectBranch}
261-
/>
262-
))}
263-
</ul>
264-
</ScrollArea>
265-
)}
266-
</>
267-
)}
268-
</CardContent>
269-
</Card>
300+
))}
301+
</ul>
302+
)}
303+
</div>
304+
</div>
270305
</aside>
306+
307+
<Resizer onPointerDown={leftRail.onPointerDown} label="Resize sidebar" />
308+
309+
{/* Center: the active pane, scrolling inside its own region. */}
310+
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
311+
{pane === 'commits' && (
312+
<div className="flex min-h-0 flex-1 flex-col p-3">
313+
<LogPanel
314+
branch={selectedBranch}
315+
selectedHash={selectedCommit}
316+
onSelectCommit={setSelectedCommit}
317+
/>
318+
</div>
319+
)}
320+
{pane === 'status' && (
321+
<div className="flex min-h-0 flex-1 flex-col p-3">
322+
<StatusPanel />
323+
</div>
324+
)}
325+
{pane === 'diff' && (
326+
<div className="scrollbar-slim min-h-0 flex-1 overflow-y-auto p-3">
327+
<DiffPanel />
328+
</div>
329+
)}
330+
</section>
331+
332+
{showCommitDetails && selectedCommit && (
333+
<>
334+
<Resizer onPointerDown={rightRail.onPointerDown} label="Resize commit details" />
335+
<aside className="flex min-h-0 flex-col p-3" style={{ width: rightRail.width }}>
336+
<CommitDetailsPanel
337+
hash={selectedCommit}
338+
onClose={() => setSelectedCommit(null)}
339+
/>
340+
</aside>
341+
</>
342+
)}
271343
</div>
272-
</main>
344+
</div>
273345
)
274346
}
275347

0 commit comments

Comments
 (0)