11'use client'
22
33import type { DevframeRpcClient } from 'devframe/client'
4+ import type { PointerEvent as ReactPointerEvent } from 'react'
45import type { Branch , GitBranches } from '../../index'
56import { 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'
78import { cn } from '../lib/utils'
89import { CommitDetailsPanel } from './commit-details-panel'
910import { DiffPanel } from './diff-panel'
@@ -13,8 +14,6 @@ import { StatusPanel } from './status-panel'
1314import { useTheme } from './theme'
1415import { Badge } from './ui/badge'
1516import { Button } from './ui/button'
16- import { Card , CardContent , CardDescription , CardHeader , CardTitle } from './ui/card'
17- import { ScrollArea } from './ui/scroll-area'
1817import { Skeleton } from './ui/skeleton'
1918import { 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+
3596function 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+
94163function 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