Skip to content

Commit e055091

Browse files
committed
feat(git): nav branch picker, merged Changes tab, floating hover card
Reshape the Git dashboard around the commit graph: - Drop the branches sidebar; the branch picker now lives in the top nav. - Merge the Status and Diff tabs into one "Changes" surface — the working tree's staged / unstaged / untracked files, each revealing its diff on click, with stage / unstage / commit in write mode. - Rebuild the commit hover card on @floating-ui/react (hover + focus, safe polygon, flip/shift), styled as an @antfu/design floating panel. - Compact the commit list (tighter rows and padding). - Widen the commit-details panel and render each changed file with a catppuccin file-type icon (ported from @antfu/design's FileIcon) and an A/M/D status mark; git:show now reports per-file change status.
1 parent a9cc28c commit e055091

19 files changed

Lines changed: 601 additions & 487 deletions

plugins/git/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@
5252
},
5353
"devDependencies": {
5454
"@antfu/design": "catalog:frontend",
55+
"@floating-ui/react": "catalog:frontend",
56+
"@iconify-json/catppuccin": "catalog:frontend",
5557
"@pierre/diffs": "catalog:frontend",
5658
"@radix-ui/react-scroll-area": "catalog:frontend",
5759
"@radix-ui/react-separator": "catalog:frontend",

plugins/git/src/client/components/branches-panel.tsx

Lines changed: 0 additions & 13 deletions
This file was deleted.

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

Lines changed: 39 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,20 @@
22

33
import type { DevframeRpcClient } from 'devframe/client'
44
import type { PointerEvent as ReactPointerEvent } from 'react'
5-
import type { Branch, GitBranches } from '../../index'
5+
import type { GitBranches } from '../../index'
66
import { useCallback, useEffect, useRef, useState } from 'react'
77
import { nav as navBar, navBrand, tab as tabClass, tabsList } from '../lib/design'
8-
import { cn } from '../lib/utils'
98
import { CommitDetailsPanel } from './commit-details-panel'
10-
import { DiffPanel } from './diff-panel'
119
import { LogPanel } from './log-panel'
1210
import { RpcProvider, useRpc } from './rpc-provider'
1311
import { StatusPanel } from './status-panel'
1412
import { useTheme } from './theme'
1513
import { Badge } from './ui/badge'
1614
import { IconButton } from './ui/button'
1715
import { Icon } from './ui/icon'
18-
import { Skeleton } from './ui/skeleton'
1916
import { useRpcResource } from './use-rpc-resource'
2017

21-
type DashboardPane = 'status' | 'commits' | 'diff'
18+
type DashboardPane = 'commits' | 'changes'
2219

2320
interface NavItem {
2421
id: DashboardPane
@@ -27,9 +24,8 @@ interface NavItem {
2724
}
2825

2926
const NAV_ITEMS: NavItem[] = [
30-
{ id: 'status', label: 'Status', icon: 'i-ph-tree-view-duotone' },
3127
{ id: 'commits', label: 'Commits', icon: 'i-ph-git-commit-duotone' },
32-
{ id: 'diff', label: 'Diff', icon: 'i-ph-git-diff-duotone' },
28+
{ id: 'changes', label: 'Changes', icon: 'i-ph-git-diff-duotone' },
3329
]
3430

3531
function clamp(value: number, min: number, max: number): number {
@@ -117,47 +113,36 @@ function ThemeToggle() {
117113
)
118114
}
119115

120-
function BranchRow({
121-
branch,
122-
selected,
116+
/** Branch picker, living in the nav bar. */
117+
function BranchSelect({
118+
branches,
119+
disabled,
120+
value,
123121
onSelect,
124122
}: {
125-
branch: Branch
126-
selected: boolean
123+
branches: GitBranches | null
124+
disabled: boolean
125+
value: string | null
127126
onSelect: (name: string) => void
128127
}) {
129128
return (
130-
<li>
131-
<button
132-
type="button"
133-
onClick={() => onSelect(branch.name)}
134-
className={cn(
135-
'hover:bg-active w-full rounded-md px-2 py-1.5 text-left transition-colors',
136-
selected && 'bg-active',
137-
)}
129+
<label className="border-base bg-base focus-within:ring-primary-500/40 flex h-7 min-w-0 items-center gap-1.5 rounded-md border pr-1.5 pl-2 focus-within:ring-2">
130+
<Icon name="i-ph-git-branch-duotone" className="color-active size-3.5 shrink-0" />
131+
<select
132+
aria-label="Branch"
133+
value={value ?? ''}
134+
onChange={event => onSelect(event.target.value)}
135+
disabled={disabled}
136+
className="color-base h-full max-w-44 min-w-0 cursor-pointer appearance-none truncate bg-transparent pr-4 text-sm outline-none disabled:cursor-default"
138137
>
139-
<div className="flex items-center gap-2">
140-
<Icon name="i-ph-git-branch-duotone" className={cn('size-3.5', branch.current ? 'color-active' : 'color-muted')} />
141-
<span className="truncate font-mono text-xs" title={branch.name}>{branch.name}</span>
142-
{branch.current && <Badge variant="success" className="px-1 py-0 text-[10px]">current</Badge>}
143-
</div>
144-
{(branch.ahead > 0 || branch.behind > 0) && (
145-
<p className="color-muted mt-0.5 text-[11px] tabular-nums">
146-
{branch.ahead > 0 && `ahead ${branch.ahead}`}
147-
{branch.ahead > 0 && branch.behind > 0 && ' · '}
148-
{branch.behind > 0 && `behind ${branch.behind}`}
149-
</p>
150-
)}
151-
</button>
152-
</li>
153-
)
154-
}
155-
156-
function PanelHeading({ children }: { children: React.ReactNode }) {
157-
return (
158-
<div className="flex h-9 shrink-0 items-center justify-between gap-2 border-b px-3">
159-
{children}
160-
</div>
138+
{!branches?.isRepo && <option value="">Not a repository</option>}
139+
{branches?.isRepo && branches.branches.length === 0 && <option value="">No branches</option>}
140+
{branches?.isRepo && branches.branches.map(branch => (
141+
<option key={branch.name} value={branch.name}>{branch.name}</option>
142+
))}
143+
</select>
144+
<Icon name="i-ph-caret-up-down" className="color-faint pointer-events-none -ml-4 size-3.5 shrink-0" />
145+
</label>
161146
)
162147
}
163148

@@ -166,15 +151,12 @@ function DashboardBody() {
166151
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
167152
const [selectedCommit, setSelectedCommit] = useState<string | null>(null)
168153

169-
const leftRail = useRailWidth('devframe-git:rail-left', 264, 200, 420, 1)
170-
const rightRail = useRailWidth('devframe-git:rail-right', 360, 280, 560, -1)
154+
const rightRail = useRailWidth('devframe-git:rail-right', 480, 340, 760, -1)
171155

172156
const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('git:branches'), [])
173157
const {
174158
data: branches,
175159
loading: branchesLoading,
176-
error: branchesError,
177-
refresh: refreshBranches,
178160
} = useRpcResource<GitBranches>(branchesLoader)
179161

180162
useEffect(() => {
@@ -202,9 +184,16 @@ function DashboardBody() {
202184
<header className={navBar()}>
203185
<div className={navBrand()}>
204186
<Icon name="i-ph-git-fork-duotone" className="text-base color-active" />
205-
<span>Git Dashboard</span>
187+
<span>Git</span>
206188
</div>
207189

190+
<BranchSelect
191+
branches={branches}
192+
disabled={branchesLoading || !branches?.isRepo || branches.branches.length === 0}
193+
value={selectedBranch}
194+
onSelect={selectBranch}
195+
/>
196+
208197
<nav className={tabsList()} role="tablist" aria-label="Git views">
209198
{NAV_ITEMS.map(({ id, label, icon }) => (
210199
<button
@@ -228,100 +217,22 @@ function DashboardBody() {
228217
</header>
229218

230219
<div className="flex min-h-0 flex-1">
231-
{/* Left rail: views + branch picker, then the branch list. */}
232-
<aside className="flex min-h-0 flex-col" style={{ width: leftRail.width }}>
233-
<div className="shrink-0 space-y-3 border-b p-3">
234-
<div className="space-y-1.5">
235-
<label htmlFor="branch-select" className="color-muted text-[11px] font-medium tracking-wide uppercase">
236-
Branch
237-
</label>
238-
<select
239-
id="branch-select"
240-
value={selectedBranch ?? ''}
241-
onChange={event => selectBranch(event.target.value)}
242-
disabled={branchesLoading || !branches?.isRepo || branches.branches.length === 0}
243-
className="bg-base border-base focus:ring-primary-500/40 h-9 w-full rounded-md border px-2 text-sm outline-none focus:ring-2"
244-
>
245-
{!branches?.isRepo && <option value="">Not a repository</option>}
246-
{branches?.isRepo && branches.branches.length === 0 && <option value="">No branches</option>}
247-
{branches?.isRepo && branches.branches.map(branch => (
248-
<option key={branch.name} value={branch.name}>{branch.name}</option>
249-
))}
250-
</select>
251-
</div>
252-
253-
{branchesError && <p className="text-error text-xs">{branchesError}</p>}
254-
</div>
255-
256-
<div className="flex min-h-0 flex-1 flex-col">
257-
<PanelHeading>
258-
<span className="text-xs font-medium">Branches</span>
259-
<div className="flex items-center gap-1">
260-
<span className="color-muted text-[11px] tabular-nums">
261-
{branches?.isRepo ? branches.branches.length : ''}
262-
</span>
263-
<IconButton
264-
variant="ghost"
265-
size="sm"
266-
onClick={refreshBranches}
267-
disabled={branchesLoading}
268-
aria-label="Refresh branches"
269-
>
270-
<Icon name="i-ph-arrows-clockwise" className={cn('size-4', branchesLoading && 'animate-spin')} />
271-
</IconButton>
272-
</div>
273-
</PanelHeading>
274-
275-
<div className="min-h-0 flex-1 overflow-y-auto p-2">
276-
{!branches && (
277-
<div className="space-y-2">
278-
{Array.from({ length: 6 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
279-
</div>
280-
)}
281-
282-
{branches && !branches.isRepo && (
283-
<p className="color-muted p-2 text-sm">The working directory is not a git repository.</p>
284-
)}
285-
286-
{branches?.isRepo && (
287-
<ul className="space-y-0.5">
288-
{branches.branches.map(branch => (
289-
<BranchRow
290-
key={branch.name}
291-
branch={branch}
292-
selected={branch.name === selectedBranch}
293-
onSelect={selectBranch}
294-
/>
295-
))}
296-
</ul>
297-
)}
298-
</div>
299-
</div>
300-
</aside>
301-
302-
<Resizer onPointerDown={leftRail.onPointerDown} label="Resize sidebar" />
303-
304220
{/* Center: the active pane, scrolling inside its own region. */}
305221
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
306222
{pane === 'commits' && (
307-
<div className="flex min-h-0 flex-1 flex-col p-3">
223+
<div className="flex min-h-0 flex-1 flex-col px-3 py-2.5">
308224
<LogPanel
309225
branch={selectedBranch}
310226
selectedHash={selectedCommit}
311227
onSelectCommit={setSelectedCommit}
312228
/>
313229
</div>
314230
)}
315-
{pane === 'status' && (
316-
<div className="flex min-h-0 flex-1 flex-col p-3">
231+
{pane === 'changes' && (
232+
<div className="flex min-h-0 flex-1 flex-col px-3 py-2.5">
317233
<StatusPanel />
318234
</div>
319235
)}
320-
{pane === 'diff' && (
321-
<div className="min-h-0 flex-1 overflow-y-auto p-3">
322-
<DiffPanel />
323-
</div>
324-
)}
325236
</section>
326237

327238
{showCommitDetails && selectedCommit && (

plugins/git/src/client/components/diff-panel.tsx

Lines changed: 0 additions & 50 deletions
This file was deleted.

plugins/git/src/client/components/status-panel.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,38 @@ import type { DevframeRpcClient } from 'devframe/client'
44
import { useCallback, useState } from 'react'
55
import { useRpc } from './rpc-provider'
66
import { useRpcResource } from './use-rpc-resource'
7+
import { DiffPatchView } from './views/diff-panel-view'
78
import { StatusPanelView } from './views/status-panel-view'
89

10+
function PatchViewer({ staged, path }: { staged: boolean, path: string }) {
11+
const loader = useCallback(
12+
(rpc: DevframeRpcClient) => rpc.call('git:diff', { staged, path }),
13+
[staged, path],
14+
)
15+
const { data, loading } = useRpcResource(loader)
16+
return (
17+
<DiffPatchView
18+
patch={data?.patch ?? null}
19+
loading={loading || !data}
20+
truncated={data?.truncated ?? false}
21+
/>
22+
)
23+
}
24+
25+
/**
26+
* The merged "Changes" view: the working tree's staged / unstaged / untracked
27+
* files (with stage / unstage / commit actions in write mode), and — when a
28+
* file is selected — its diff below, folding the old Status and Diff tabs into
29+
* one surface.
30+
*/
931
export function StatusPanel() {
1032
const { rpc } = useRpc()
1133
const loader = useCallback((r: DevframeRpcClient) => r.call('git:status'), [])
1234
const { data, loading, refresh, setData } = useRpcResource(loader)
1335
const [busy, setBusy] = useState(false)
1436
const [message, setMessage] = useState('')
1537
const [note, setNote] = useState<string | null>(null)
38+
const [selected, setSelected] = useState<{ path: string, staged: boolean } | null>(null)
1639

1740
const canWrite = !!data?.canWrite && rpc?.connectionMeta.backend === 'websocket'
1841

@@ -60,6 +83,10 @@ export function StatusPanel() {
6083
}
6184
}, [rpc, message, setData])
6285

86+
const selectFile = useCallback((path: string, staged: boolean) => {
87+
setSelected(prev => (prev && prev.path === path && prev.staged === staged ? null : { path, staged }))
88+
}, [])
89+
6390
return (
6491
<StatusPanelView
6592
data={data}
@@ -68,11 +95,16 @@ export function StatusPanel() {
6895
canWrite={!!canWrite}
6996
message={message}
7097
note={note}
98+
selectedKey={selected ? `${selected.staged}:${selected.path}` : null}
7199
onRefresh={refresh}
72100
onStage={stage}
73101
onUnstage={unstage}
74102
onCommit={commit}
75103
onMessageChange={setMessage}
104+
onSelectFile={selectFile}
105+
patchSlot={selected
106+
? <PatchViewer key={`${selected.staged}:${selected.path}`} staged={selected.staged} path={selected.path} />
107+
: null}
76108
/>
77109
)
78110
}

0 commit comments

Comments
 (0)