Skip to content

Commit 976ce16

Browse files
authored
feat(git): redesign commit history with hover cards (#88)
1 parent 36cde4e commit 976ce16

25 files changed

Lines changed: 870 additions & 399 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/log-panel.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps
109109
await loadPage(rpc, skip, 'append')
110110
}, [rpc, skip, loadPage])
111111

112+
// Feeds the row hover card: metadata + changed-file stats for one commit.
113+
// The patch is skipped here — the card only needs totals, and the full diff
114+
// lives in the details panel.
115+
const loadDetail = useCallback(
116+
(hash: string) => {
117+
if (!rpc)
118+
return Promise.reject(new Error('rpc unavailable'))
119+
return rpc.call('git:show', { hash, patch: false })
120+
},
121+
[rpc],
122+
)
123+
112124
return (
113125
<LogPanelView
114126
rpcConnected={!!rpc}
@@ -124,6 +136,7 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps
124136
onRefresh={refresh}
125137
onLoadMore={loadMore}
126138
onSelectCommit={onSelectCommit}
139+
onLoadDetail={rpc ? loadDetail : undefined}
127140
/>
128141
)
129142
}

plugins/git/src/client/components/rpc-provider.tsx

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

33
import type { DevframeRpcClient } from 'devframe/client'
4+
import type { ConnectionMeta } from 'devframe/types'
45
import type { ReactNode } from 'react'
56
import { connectDevframe } from 'devframe/client'
7+
import { DEVFRAME_WS_ROUTE } from 'devframe/constants'
68
import { createContext, use, useEffect, useState } from 'react'
79

810
interface ConnectionState {
@@ -28,8 +30,13 @@ export function RpcProvider({ children }: { children: ReactNode }) {
2830
// Next.js statically inlines `process.env.NEXT_PUBLIC_*` at build time.
2931
// eslint-disable-next-line node/prefer-global/process
3032
const devWs = process.env.NEXT_PUBLIC_DEVFRAME_WS
31-
const options = devWs
32-
? { connectionMeta: { backend: 'websocket' as const, websocket: devWs } }
33+
// A bare port (what `scripts/dev.mjs` sets) becomes a same-host socket on
34+
// the backend's WS route; a full `ws(s)://` URL is used verbatim.
35+
const websocket: ConnectionMeta['websocket'] | undefined = devWs
36+
? (/^\d+$/.test(devWs) ? { port: Number(devWs), path: DEVFRAME_WS_ROUTE } : devWs)
37+
: undefined
38+
const options = websocket
39+
? { connectionMeta: { backend: 'websocket' as const, websocket } }
3340
: undefined
3441
connectDevframe(options).then(
3542
(rpc) => {

0 commit comments

Comments
 (0)