Skip to content

Commit ce652df

Browse files
committed
feat(plugin-git): ship full-width dashboard with branch dropdown
1 parent 749f3b4 commit ce652df

9 files changed

Lines changed: 258 additions & 59 deletions

File tree

examples/next-runtime-snapshot/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"next-runtime-snapshot": "./bin.mjs"
1111
},
1212
"scripts": {
13-
"build": "next build src/client && node -e \"const fs=require('fs'); fs.rmSync('dist/client',{recursive:true,force:true}); fs.mkdirSync('dist',{recursive:true}); fs.cpSync('src/client/out','dist/client',{recursive:true});\"",
13+
"build": "next build src/client && node scripts/build-spa.mjs",
1414
"cli:build": "node bin.mjs build --out-dir dist/static",
1515
"dev": "node bin.mjs",
1616
"next:dev": "next dev src/client",
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { cpSync, mkdirSync, rmSync } from 'node:fs'
2+
3+
rmSync('dist/client', { recursive: true, force: true })
4+
mkdirSync('dist', { recursive: true })
5+
cpSync('src/client/out', 'dist/client', { recursive: true })

plugins/git/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
],
3535
"scripts": {
3636
"build": "tsdown && pnpm run build:spa",
37-
"build:spa": "next build src/client && node -e \"const fs=require('fs'); fs.rmSync('dist/client',{recursive:true,force:true}); fs.cpSync('src/client/out','dist/client',{recursive:true});\"",
37+
"build:spa": "next build src/client && node scripts/build-spa.mjs",
3838
"cli:build": "node bin.mjs build --out-dir dist/static",
3939
"dev": "node scripts/dev.mjs",
4040
"dev:server": "node src/cli.ts",

plugins/git/scripts/build-spa.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { cpSync, rmSync } from 'node:fs'
2+
3+
rmSync('dist/client', { recursive: true, force: true })
4+
cpSync('src/client/out', 'dist/client', { recursive: true })
Lines changed: 216 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,35 @@
11
'use client'
22

3-
import { FileDiff, GitBranch, GitCommitHorizontal, GitGraph, ListTree, Moon, Sun } from 'lucide-react'
4-
import { BranchesPanel } from './branches-panel'
3+
import type { DevframeRpcClient } from 'devframe/client'
4+
import type { Branch, GitBranches } from '../../index'
5+
import { FileDiff, GitBranch, GitCommitHorizontal, GitGraph, ListTree, Moon, RefreshCw, Sun } from 'lucide-react'
6+
import { useCallback, useEffect, useState } from 'react'
7+
import { cn } from '../lib/utils'
58
import { DiffPanel } from './diff-panel'
69
import { LogPanel } from './log-panel'
710
import { RpcProvider, useRpc } from './rpc-provider'
811
import { StatusPanel } from './status-panel'
912
import { useTheme } from './theme'
1013
import { Badge } from './ui/badge'
1114
import { Button } from './ui/button'
12-
import { Card, CardContent } from './ui/card'
13-
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'
15+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from './ui/card'
16+
import { ScrollArea } from './ui/scroll-area'
17+
import { Skeleton } from './ui/skeleton'
18+
import { useRpcResource } from './use-rpc-resource'
19+
20+
type DashboardPane = 'status' | 'commits' | 'diff'
21+
22+
interface NavItem {
23+
id: DashboardPane
24+
label: string
25+
icon: typeof ListTree
26+
}
27+
28+
const NAV_ITEMS: NavItem[] = [
29+
{ id: 'status', label: 'Status', icon: ListTree },
30+
{ id: 'commits', label: 'Commits', icon: GitCommitHorizontal },
31+
{ id: 'diff', label: 'Diff', icon: FileDiff },
32+
]
1433

1534
function ConnectionBadge() {
1635
const { rpc, error } = useRpc()
@@ -35,56 +54,203 @@ function ThemeToggle() {
3554
)
3655
}
3756

38-
export function Dashboard() {
57+
function BranchRow({
58+
branch,
59+
selected,
60+
onSelect,
61+
}: {
62+
branch: Branch
63+
selected: boolean
64+
onSelect: (name: string) => void
65+
}) {
3966
return (
40-
<RpcProvider>
41-
<main className="mx-auto flex max-w-2xl flex-col gap-4 px-4 py-6">
42-
<header className="flex items-center justify-between gap-3">
43-
<div className="flex items-center gap-2">
44-
<GitGraph className="text-primary size-6" />
45-
<div>
46-
<h1 className="text-base leading-none font-semibold">Git Dashboard</h1>
47-
<p className="text-muted-foreground text-[11px]">
48-
devframe + Next.js · type-safe RPC into the host repository
49-
</p>
50-
</div>
51-
</div>
52-
<div className="flex items-center gap-2">
53-
<ConnectionBadge />
54-
<ThemeToggle />
67+
<li>
68+
<button
69+
type="button"
70+
onClick={() => onSelect(branch.name)}
71+
className={cn(
72+
'hover:bg-accent/60 w-full rounded-md px-2 py-1.5 text-left transition-colors',
73+
selected && 'bg-accent',
74+
)}
75+
>
76+
<div className="flex items-center gap-2">
77+
<GitBranch className={cn('size-3.5 shrink-0', branch.current ? 'text-primary' : 'text-muted-foreground')} />
78+
<span className="truncate font-mono text-xs">{branch.name}</span>
79+
{branch.current && <Badge variant="success" className="px-1 py-0 text-[10px]">current</Badge>}
80+
</div>
81+
{(branch.ahead > 0 || branch.behind > 0) && (
82+
<p className="text-muted-foreground mt-0.5 text-[11px]">
83+
{branch.ahead > 0 && `ahead ${branch.ahead}`}
84+
{branch.ahead > 0 && branch.behind > 0 && ' · '}
85+
{branch.behind > 0 && `behind ${branch.behind}`}
86+
</p>
87+
)}
88+
</button>
89+
</li>
90+
)
91+
}
92+
93+
function DashboardBody() {
94+
const [pane, setPane] = useState<DashboardPane>('commits')
95+
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
96+
97+
const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('git:branches'), [])
98+
const {
99+
data: branches,
100+
loading: branchesLoading,
101+
error: branchesError,
102+
refresh: refreshBranches,
103+
} = useRpcResource<GitBranches>(branchesLoader)
104+
105+
useEffect(() => {
106+
if (!branches?.isRepo) {
107+
setSelectedBranch(null)
108+
return
109+
}
110+
const names = branches.branches.map(branch => branch.name)
111+
const current = branches.current
112+
?? branches.branches.find(branch => branch.current)?.name
113+
?? null
114+
setSelectedBranch(prev => (prev && names.includes(prev)) ? prev : (current ?? names[0] ?? null))
115+
}, [branches])
116+
117+
const selectBranch = (name: string) => {
118+
setSelectedBranch(name)
119+
setPane('commits')
120+
}
121+
122+
return (
123+
<main className="flex min-h-svh w-full flex-col gap-4 px-4 py-5 md:px-6">
124+
<header className="flex items-center justify-between gap-3">
125+
<div className="flex items-center gap-2">
126+
<GitGraph className="text-primary size-6" />
127+
<div>
128+
<h1 className="text-base leading-none font-semibold">Git Dashboard</h1>
129+
<p className="text-muted-foreground text-[11px]">
130+
devframe + Next.js · type-safe RPC into the host repository
131+
</p>
55132
</div>
56-
</header>
57-
58-
<Tabs defaultValue="status">
59-
<TabsList className="w-full">
60-
<TabsTrigger value="status">
61-
<ListTree className="size-4" />
62-
Status
63-
</TabsTrigger>
64-
<TabsTrigger value="commits">
65-
<GitCommitHorizontal className="size-4" />
66-
Commits
67-
</TabsTrigger>
68-
<TabsTrigger value="branches">
69-
<GitBranch className="size-4" />
70-
Branches
71-
</TabsTrigger>
72-
<TabsTrigger value="diff">
73-
<FileDiff className="size-4" />
74-
Diff
75-
</TabsTrigger>
76-
</TabsList>
77-
78-
<Card className="mt-1">
79-
<CardContent className="px-4">
80-
<TabsContent value="status"><StatusPanel /></TabsContent>
81-
<TabsContent value="commits"><LogPanel /></TabsContent>
82-
<TabsContent value="branches"><BranchesPanel /></TabsContent>
83-
<TabsContent value="diff"><DiffPanel /></TabsContent>
133+
</div>
134+
<div className="flex items-center gap-2">
135+
<ConnectionBadge />
136+
<ThemeToggle />
137+
</div>
138+
</header>
139+
140+
<div className="grid min-h-0 flex-1 gap-4 xl:grid-cols-[250px_minmax(0,1fr)_320px]">
141+
<aside className="space-y-4">
142+
<Card>
143+
<CardHeader className="pb-2">
144+
<CardTitle className="text-sm">Workspace</CardTitle>
145+
<CardDescription>Switch views and choose the log branch.</CardDescription>
146+
</CardHeader>
147+
<CardContent className="space-y-4">
148+
<div className="space-y-1.5">
149+
<label htmlFor="branch-select" className="text-muted-foreground text-[11px] font-medium tracking-wide uppercase">
150+
Branch
151+
</label>
152+
<select
153+
id="branch-select"
154+
value={selectedBranch ?? ''}
155+
onChange={event => selectBranch(event.target.value)}
156+
disabled={branchesLoading || !branches?.isRepo || branches.branches.length === 0}
157+
className="bg-background border-input focus:ring-ring h-9 w-full rounded-md border px-2 text-sm outline-none focus:ring-2"
158+
>
159+
{!branches?.isRepo && <option value="">Not a repository</option>}
160+
{branches?.isRepo && branches.branches.length === 0 && <option value="">No branches</option>}
161+
{branches?.isRepo && branches.branches.map(branch => (
162+
<option key={branch.name} value={branch.name}>{branch.name}</option>
163+
))}
164+
</select>
165+
</div>
166+
167+
<nav className="space-y-1">
168+
{NAV_ITEMS.map(({ id, label, icon: Icon }) => (
169+
<Button
170+
key={id}
171+
type="button"
172+
variant={pane === id ? 'secondary' : 'ghost'}
173+
className="w-full justify-start"
174+
onClick={() => setPane(id)}
175+
>
176+
<Icon className="size-4" />
177+
{label}
178+
</Button>
179+
))}
180+
</nav>
181+
182+
{branchesError && <p className="text-destructive text-xs">{branchesError}</p>}
183+
</CardContent>
184+
</Card>
185+
</aside>
186+
187+
<section className="min-w-0">
188+
<Card className="h-full">
189+
<CardContent className="p-4">
190+
{pane === 'status' && <StatusPanel />}
191+
{pane === 'commits' && <LogPanel branch={selectedBranch} />}
192+
{pane === 'diff' && <DiffPanel />}
193+
</CardContent>
194+
</Card>
195+
</section>
196+
197+
<aside className="hidden min-w-0 xl:block">
198+
<Card className="h-full">
199+
<CardHeader className="flex-row items-center justify-between space-y-0 pb-2">
200+
<div>
201+
<CardTitle className="text-sm">Branches</CardTitle>
202+
<CardDescription>
203+
{branches?.isRepo ? `${branches.branches.length} branches` : ' '}
204+
</CardDescription>
205+
</div>
206+
<Button
207+
variant="ghost"
208+
size="icon"
209+
className="size-7"
210+
onClick={refreshBranches}
211+
disabled={branchesLoading}
212+
aria-label="Refresh branches"
213+
>
214+
<RefreshCw className={cn('size-3.5', branchesLoading && 'animate-spin')} />
215+
</Button>
216+
</CardHeader>
217+
<CardContent>
218+
{!branches && (
219+
<div className="space-y-2">
220+
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
221+
</div>
222+
)}
223+
224+
{branches && !branches.isRepo && (
225+
<p className="text-muted-foreground text-sm">The working directory is not a git repository.</p>
226+
)}
227+
228+
{branches?.isRepo && (
229+
<ScrollArea className="h-[calc(100vh-16rem)] pr-2">
230+
<ul className="space-y-1">
231+
{branches.branches.map(branch => (
232+
<BranchRow
233+
key={branch.name}
234+
branch={branch}
235+
selected={branch.name === selectedBranch}
236+
onSelect={selectBranch}
237+
/>
238+
))}
239+
</ul>
240+
</ScrollArea>
241+
)}
84242
</CardContent>
85243
</Card>
86-
</Tabs>
87-
</main>
244+
</aside>
245+
</div>
246+
</main>
247+
)
248+
}
249+
250+
export function Dashboard() {
251+
return (
252+
<RpcProvider>
253+
<DashboardBody />
88254
</RpcProvider>
89255
)
90256
}

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ import { LogPanelView } from './views/log-panel-view'
88

99
const PAGE = 30
1010

11-
export function LogPanel() {
11+
export interface LogPanelProps {
12+
branch?: string | null
13+
}
14+
15+
export function LogPanel({ branch }: LogPanelProps) {
1216
const { rpc } = useRpc()
1317
const [isRepo, setIsRepo] = useState<boolean | null>(null)
1418
const [commits, setCommits] = useState<Commit[]>([])
@@ -32,7 +36,11 @@ export function LogPanel() {
3236
setLoading(true)
3337
setError(null)
3438
try {
35-
const page = await client.call('git:log', { limit: PAGE, skip: nextSkip })
39+
const page = await client.call('git:log', {
40+
limit: PAGE,
41+
skip: nextSkip,
42+
ref: branch ?? undefined,
43+
})
3644
setIsRepo(page.isRepo)
3745
if (mode === 'replace') {
3846
setCommits(page.commits)
@@ -62,7 +70,7 @@ export function LogPanel() {
6270
finally {
6371
setLoading(false)
6472
}
65-
}, [])
73+
}, [branch])
6674

6775
const loadStatus = useCallback(async (client: DevframeRpcClient) => {
6876
try {
@@ -105,6 +113,7 @@ export function LogPanel() {
105113
hasMore={hasMore}
106114
loading={loading}
107115
error={error}
116+
selectedRef={branch ?? null}
108117
currentBranch={currentBranch}
109118
workingChanges={workingChanges}
110119
onRefresh={refresh}

plugins/git/src/client/components/views/log-panel-view.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export interface LogPanelViewProps {
2727
hasMore: boolean
2828
loading: boolean
2929
error: string | null
30+
/** Optional ref currently used for this log query. */
31+
selectedRef?: string | null
3032
/** Active branch name, used to flag the checked-out ref. */
3133
currentBranch?: string | null
3234
/** Number of changed working-tree files; drives the "Work In Progress" row. */
@@ -253,6 +255,7 @@ export function LogPanelView(props: LogPanelViewProps) {
253255
hasMore,
254256
loading,
255257
error,
258+
selectedRef,
256259
currentBranch,
257260
workingChanges,
258261
onRefresh,
@@ -265,14 +268,18 @@ export function LogPanelView(props: LogPanelViewProps) {
265268
)
266269
const gutter = Math.max(graph.columns, 1) * COL_W + COL_W / 2 + PAD_L
267270

268-
const showWip = (workingChanges ?? 0) > 0 && commits.length > 0
271+
const showWip = (workingChanges ?? 0) > 0
272+
&& commits.length > 0
273+
&& (!selectedRef || selectedRef === currentBranch)
269274
const headRow = graph.rows[0]
270275

271276
return (
272277
<div className="space-y-3">
273278
<div className="flex items-center justify-between">
274279
<span className="text-muted-foreground text-xs">
275-
{isRepo ? `${commits.length} commits` : ' '}
280+
{isRepo
281+
? `${commits.length} commits${selectedRef ? ` · ${selectedRef}` : ''}`
282+
: ' '}
276283
</span>
277284
<Button variant="ghost" size="icon" className="size-7" onClick={onRefresh} disabled={loading} aria-label="Refresh log">
278285
<RefreshCw className={cn('size-3.5', loading && 'animate-spin')} />

0 commit comments

Comments
 (0)