Skip to content

Commit 87c96b4

Browse files
committed
feat(plugin-git): infinite-scroll commit log and commit details panel
Replace the manual Load more button with an IntersectionObserver sentinel that streams the next 30-commit page as the list scrolls, and make commit rows selectable. Selecting a commit opens a details panel in the right sidebar (subject, refs, author/committer, parents, message, changed files, and the patch) backed by a new git:show RPC. The RPC bakes per-commit records for the snapshot window so static builds show details offline.
1 parent 2e773cb commit 87c96b4

11 files changed

Lines changed: 660 additions & 89 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
'use client'
2+
3+
import type { DevframeRpcClient } from 'devframe/client'
4+
import type { CommitDetail } from '../../index'
5+
import { useCallback } from 'react'
6+
import { useRpcResource } from './use-rpc-resource'
7+
import { CommitDetailsView } from './views/commit-details-view'
8+
9+
export interface CommitDetailsPanelProps {
10+
hash: string
11+
onClose?: () => void
12+
}
13+
14+
export function CommitDetailsPanel({ hash, onClose }: CommitDetailsPanelProps) {
15+
const loader = useCallback(
16+
(rpc: DevframeRpcClient): Promise<CommitDetail> => rpc.call('git:show', { hash }),
17+
[hash],
18+
)
19+
const { data, loading, error } = useRpcResource<CommitDetail>(loader)
20+
21+
return <CommitDetailsView data={data} loading={loading} error={error} onClose={onClose} />
22+
}

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

Lines changed: 70 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { Branch, GitBranches } from '../../index'
55
import { FileDiff, GitBranch, GitCommitHorizontal, GitGraph, ListTree, Moon, RefreshCw, Sun } from 'lucide-react'
66
import { useCallback, useEffect, useState } from 'react'
77
import { cn } from '../lib/utils'
8+
import { CommitDetailsPanel } from './commit-details-panel'
89
import { DiffPanel } from './diff-panel'
910
import { LogPanel } from './log-panel'
1011
import { RpcProvider, useRpc } from './rpc-provider'
@@ -93,6 +94,7 @@ function BranchRow({
9394
function DashboardBody() {
9495
const [pane, setPane] = useState<DashboardPane>('commits')
9596
const [selectedBranch, setSelectedBranch] = useState<string | null>(null)
97+
const [selectedCommit, setSelectedCommit] = useState<string | null>(null)
9698

9799
const branchesLoader = useCallback((rpc: DevframeRpcClient) => rpc.call('git:branches'), [])
98100
const {
@@ -116,9 +118,13 @@ function DashboardBody() {
116118

117119
const selectBranch = (name: string) => {
118120
setSelectedBranch(name)
121+
setSelectedCommit(null)
119122
setPane('commits')
120123
}
121124

125+
// The detail panel only makes sense alongside the commit list.
126+
const showCommitDetails = pane === 'commits' && selectedCommit !== null
127+
122128
return (
123129
<main className="flex min-h-svh w-full flex-col gap-4 px-4 py-5 md:px-6">
124130
<header className="flex items-center justify-between gap-3">
@@ -184,61 +190,79 @@ function DashboardBody() {
184190
</Card>
185191
</aside>
186192

187-
<section className="min-w-0">
188-
<Card className="h-full">
189-
<CardContent className="p-4">
193+
<section className="flex min-w-0 flex-col">
194+
<Card className="flex h-full min-h-0 flex-col py-4">
195+
<CardContent className="flex min-h-0 flex-1 flex-col px-4">
190196
{pane === 'status' && <StatusPanel />}
191-
{pane === 'commits' && <LogPanel branch={selectedBranch} />}
197+
{pane === 'commits' && (
198+
<LogPanel
199+
branch={selectedBranch}
200+
selectedHash={selectedCommit}
201+
onSelectCommit={setSelectedCommit}
202+
/>
203+
)}
192204
{pane === 'diff' && <DiffPanel />}
193205
</CardContent>
194206
</Card>
195207
</section>
196208

197209
<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-
)}
210+
<Card className="h-full py-4">
211+
<CardContent className="px-4">
212+
{showCommitDetails && selectedCommit
213+
? (
214+
<CommitDetailsPanel
215+
hash={selectedCommit}
216+
onClose={() => setSelectedCommit(null)}
217+
/>
218+
)
219+
: (
220+
<>
221+
<div className="flex items-center justify-between pb-2">
222+
<div>
223+
<CardTitle className="text-sm">Branches</CardTitle>
224+
<CardDescription>
225+
{branches?.isRepo ? `${branches.branches.length} branches` : ' '}
226+
</CardDescription>
227+
</div>
228+
<Button
229+
variant="ghost"
230+
size="icon"
231+
className="size-7"
232+
onClick={refreshBranches}
233+
disabled={branchesLoading}
234+
aria-label="Refresh branches"
235+
>
236+
<RefreshCw className={cn('size-3.5', branchesLoading && 'animate-spin')} />
237+
</Button>
238+
</div>
223239

224-
{branches && !branches.isRepo && (
225-
<p className="text-muted-foreground text-sm">The working directory is not a git repository.</p>
226-
)}
240+
{!branches && (
241+
<div className="space-y-2">
242+
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-9 w-full" />)}
243+
</div>
244+
)}
227245

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-
)}
246+
{branches && !branches.isRepo && (
247+
<p className="text-muted-foreground text-sm">The working directory is not a git repository.</p>
248+
)}
249+
250+
{branches?.isRepo && (
251+
<ScrollArea className="h-[calc(100vh-16rem)] pr-2">
252+
<ul className="space-y-1">
253+
{branches.branches.map(branch => (
254+
<BranchRow
255+
key={branch.name}
256+
branch={branch}
257+
selected={branch.name === selectedBranch}
258+
onSelect={selectBranch}
259+
/>
260+
))}
261+
</ul>
262+
</ScrollArea>
263+
)}
264+
</>
265+
)}
242266
</CardContent>
243267
</Card>
244268
</aside>

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

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,13 @@ const PAGE = 30
1010

1111
export interface LogPanelProps {
1212
branch?: string | null
13+
/** Hash of the currently selected commit (drives row highlight). */
14+
selectedHash?: string | null
15+
/** Called when a commit row is activated. */
16+
onSelectCommit?: (hash: string) => void
1317
}
1418

15-
export function LogPanel({ branch }: LogPanelProps) {
19+
export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps) {
1620
const { rpc } = useRpc()
1721
const [isRepo, setIsRepo] = useState<boolean | null>(null)
1822
const [commits, setCommits] = useState<Commit[]>([])
@@ -116,8 +120,10 @@ export function LogPanel({ branch }: LogPanelProps) {
116120
selectedRef={branch ?? null}
117121
currentBranch={currentBranch}
118122
workingChanges={workingChanges}
123+
selectedHash={selectedHash}
119124
onRefresh={refresh}
120125
onLoadMore={loadMore}
126+
onSelectCommit={onSelectCommit}
121127
/>
122128
)
123129
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite'
2+
import type { CommitDetail } from '../../../index'
3+
import { CommitDetailsView } from './commit-details-view'
4+
5+
const now = Date.now()
6+
7+
const PATCH = `diff --git a/src/client/components/views/log-panel-view.tsx b/src/client/components/views/log-panel-view.tsx
8+
index 1234567..89abcde 100644
9+
--- a/src/client/components/views/log-panel-view.tsx
10+
+++ b/src/client/components/views/log-panel-view.tsx
11+
@@ -300,12 +300,9 @@ export function LogPanelView(props: LogPanelViewProps) {
12+
- {isRepo === true && hasMore && (
13+
- <Button variant="outline" size="sm" onClick={onLoadMore}>Load more</Button>
14+
- )}
15+
+ {hasMore && (
16+
+ <div ref={sentinelRef}>Loading more…</div>
17+
+ )}`
18+
19+
const detail: CommitDetail = {
20+
isRepo: true,
21+
found: true,
22+
hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0',
23+
shortHash: 'a1b2c3d',
24+
author: 'Ada Lovelace',
25+
email: 'ada@example.dev',
26+
date: now - 36e5,
27+
committer: 'Ada Lovelace',
28+
committerEmail: 'ada@example.dev',
29+
commitDate: now - 36e5,
30+
subject: 'Auto-load commits on scroll instead of a Load more button',
31+
body: 'Replaces the manual pagination control with an IntersectionObserver\nsentinel so history streams in as the user scrolls.',
32+
parents: ['b2c3d4e5f6a7b8c9'],
33+
refs: ['HEAD -> main', 'origin/main'],
34+
files: [
35+
{ path: 'src/client/components/views/log-panel-view.tsx', additions: 41, deletions: 18, binary: false },
36+
{ path: 'src/client/components/log-panel.tsx', additions: 6, deletions: 1, binary: false },
37+
{ path: 'public/preview.png', additions: 0, deletions: 0, binary: true },
38+
],
39+
totalAdditions: 47,
40+
totalDeletions: 19,
41+
patch: PATCH,
42+
truncated: false,
43+
}
44+
45+
const meta = {
46+
title: 'Panels/CommitDetails',
47+
component: CommitDetailsView,
48+
args: {
49+
data: detail,
50+
loading: false,
51+
error: null,
52+
onClose: () => undefined,
53+
},
54+
} satisfies Meta<typeof CommitDetailsView>
55+
56+
export default meta
57+
type Story = StoryObj<typeof meta>
58+
59+
export const Default: Story = {}
60+
export const Loading: Story = { args: { data: null, loading: true } }
61+
export const StaticNoPatch: Story = { args: { data: { ...detail, patch: null } } }
62+
export const NotFound: Story = { args: { data: { ...detail, found: false } } }
63+
export const Error: Story = { args: { data: null, error: 'fatal: bad object deadbeef' } }

0 commit comments

Comments
 (0)