From a9cc28c789a0b1a692779de66840361a98b1ff77 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 13 Jul 2026 00:49:31 +0000 Subject: [PATCH 1/5] feat(git): redesign commit history with hover cards Rework the Git plugin's commit history view: a "Commit History" heading, a hollow HEAD ring over solid lane-colored dots, off-mainline commits that recede, and a floating hover card per commit (avatar, author, time, body, and changed-file stats with a link to full details). Graph lanes now lead with blue for the mainline, then warm tones for branches. --- .../git/src/client/components/log-panel.tsx | 13 + .../git/src/client/components/ui/avatar.tsx | 63 ++++ .../views/log-panel-view.stories.tsx | 64 +++- .../components/views/log-panel-view.tsx | 279 ++++++++++++++---- plugins/git/src/client/lib/commit-graph.ts | 21 +- 5 files changed, 360 insertions(+), 80 deletions(-) create mode 100644 plugins/git/src/client/components/ui/avatar.tsx diff --git a/plugins/git/src/client/components/log-panel.tsx b/plugins/git/src/client/components/log-panel.tsx index 94e403f8..4774f318 100644 --- a/plugins/git/src/client/components/log-panel.tsx +++ b/plugins/git/src/client/components/log-panel.tsx @@ -109,6 +109,18 @@ export function LogPanel({ branch, selectedHash, onSelectCommit }: LogPanelProps await loadPage(rpc, skip, 'append') }, [rpc, skip, loadPage]) + // Feeds the row hover card: metadata + changed-file stats for one commit. + // The patch is skipped here — the card only needs totals, and the full diff + // lives in the details panel. + const loadDetail = useCallback( + (hash: string) => { + if (!rpc) + return Promise.reject(new Error('rpc unavailable')) + return rpc.call('git:show', { hash, patch: false }) + }, + [rpc], + ) + return ( ) } diff --git a/plugins/git/src/client/components/ui/avatar.tsx b/plugins/git/src/client/components/ui/avatar.tsx new file mode 100644 index 00000000..054174fb --- /dev/null +++ b/plugins/git/src/client/components/ui/avatar.tsx @@ -0,0 +1,63 @@ +import type * as React from 'react' +import { cn } from '../../lib/utils' + +// A deterministic, offline avatar: initials drawn on a stable background color +// derived from the author's identity. No network round-trip (keeping the +// dashboard self-contained), yet every author reads as a distinct chip — the +// same visual role the photo avatar plays in the commit hover card. + +// Saturated-but-legible hues that sit well behind white text in both themes. +const AVATAR_COLORS = [ + '#3b82f6', // blue + '#f59e0b', // amber + '#ef4444', // red + '#8b5cf6', // violet + '#10b981', // emerald + '#ec4899', // pink + '#06b6d4', // cyan + '#f97316', // orange + '#14b8a6', // teal + '#6366f1', // indigo +] + +function hashString(input: string): number { + let hash = 0 + for (let i = 0; i < input.length; i++) + hash = (hash * 31 + input.charCodeAt(i)) | 0 + return Math.abs(hash) +} + +/** Up to two initials from a display name (or the email local-part). */ +function initialsOf(name: string, email?: string): string { + const source = name.trim() || (email ?? '').split('@')[0] || '?' + const parts = source.split(/[\s._-]+/).filter(Boolean) + if (parts.length === 0) + return '?' + if (parts.length === 1) + return parts[0].slice(0, 2).toUpperCase() + return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase() +} + +export function Avatar({ + name, + email, + className, + ...props +}: { name: string, email?: string } & React.ComponentProps<'span'>) { + const key = (email || name || '?').toLowerCase() + const color = AVATAR_COLORS[hashString(key) % AVATAR_COLORS.length] + return ( + + {initialsOf(name, email)} + + ) +} diff --git a/plugins/git/src/client/components/views/log-panel-view.stories.tsx b/plugins/git/src/client/components/views/log-panel-view.stories.tsx index 118b443f..a1ea6c0d 100644 --- a/plugins/git/src/client/components/views/log-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/log-panel-view.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite' -import type { Commit } from '../../../index' +import type { Commit, CommitDetail } from '../../../index' import { LogPanelView } from './log-panel-view' const now = Date.now() @@ -11,20 +11,57 @@ function at(minutesAgo: number): number { // A small multi-branch history: `main` (current) sits on lane 0, a merge fans // the graph into a second lane, and several branch tips / a tag carry labels. const commits: Commit[] = [ - { hash: 'c01', shortHash: 'c01a1b2', parents: ['c02'], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(8), subject: 'Optimizes CSS output', body: '', refs: ['HEAD -> main'] }, - { hash: 'c02', shortHash: 'c02b2c3', parents: ['c03'], author: 'Grace Hopper', email: 'grace@example.dev', date: at(34), subject: 'Add overflow corrector', body: '', refs: [] }, - { hash: 'c03', shortHash: 'c03c3d4', parents: ['c04'], author: 'Lin Chen', email: 'lin@example.dev', date: at(96), subject: 'Fixes stash node icon alignment', body: '', refs: ['origin/feature/onboard'] }, - { hash: 'c04', shortHash: 'c04d4e5', parents: ['c05', 'c07'], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(140), subject: 'Removes dead code', body: '', refs: [] }, - { hash: 'c05', shortHash: 'c05e5f6', parents: ['c06'], author: 'Mara Singh', email: 'mara@example.dev', date: at(210), subject: 'Fix bugs in telemetry system', body: '', refs: ['feature/graph'] }, - { hash: 'c06', shortHash: 'c06f607', parents: ['c08'], author: 'Lin Chen', email: 'lin@example.dev', date: at(320), subject: 'Ensures proper date ordering for Graph', body: '', refs: [] }, - { hash: 'c07', shortHash: 'c07a708', parents: ['c08'], author: 'Grace Hopper', email: 'grace@example.dev', date: at(360), subject: 'Use gitconfig to suggest profile author and email', body: '', refs: [] }, - { hash: 'c08', shortHash: 'c08b809', parents: ['c09'], author: 'Mara Singh', email: 'mara@example.dev', date: at(540), subject: 'Log error instead of throwing', body: '', refs: ['bug/error-log'] }, - { hash: 'c09', shortHash: 'c09c90a', parents: ['c10'], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(880), subject: 'Runs yarn install after unlink', body: '', refs: [] }, - { hash: 'c10', shortHash: 'c10d0ab', parents: ['c11'], author: 'Lin Chen', email: 'lin@example.dev', date: at(1450), subject: 'Add file-diff icons, bump component version', body: '', refs: ['feature/icons'] }, - { hash: 'c11', shortHash: 'c11e1bc', parents: ['c12'], author: 'Grace Hopper', email: 'grace@example.dev', date: at(2600), subject: 'Centralize command registrations', body: '', refs: ['development'] }, - { hash: 'c12', shortHash: 'c12f2cd', parents: [], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(9000), subject: 'Add type safety to date ordering', body: '', refs: ['tag: v0.1.0'] }, + { hash: 'c01', shortHash: 'c01a1b2', parents: ['c02'], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(8), subject: 'Optimizes CSS output', body: 'Trims unused utilities and dedupes the emitted stylesheet.', refs: ['HEAD -> main'] }, + { hash: 'c02', shortHash: 'c02b2c3', parents: ['c03'], author: 'Grace Hopper', email: 'grace@example.dev', date: at(34), subject: 'Add overflow corrector', body: 'Prepared merge preview for affected spreadsheet rows.', refs: [] }, + { hash: 'c03', shortHash: 'c03c3d4', parents: ['c04'], author: 'Lin Chen', email: 'lin@example.dev', date: at(96), subject: 'Fixes stash node icon alignment', body: 'Nudges the stash node so it lines up with commit dots.', refs: ['origin/feature/onboard'] }, + { hash: 'c04', shortHash: 'c04d4e5', parents: ['c05', 'c07'], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(140), subject: 'Removes dead code', body: 'Drops the legacy graph renderer and its helpers.', refs: [] }, + { hash: 'c05', shortHash: 'c05e5f6', parents: ['c06'], author: 'Mara Singh', email: 'mara@example.dev', date: at(210), subject: 'Fix bugs in telemetry system', body: 'Guards against undefined spans during teardown.', refs: ['feature/graph'] }, + { hash: 'c06', shortHash: 'c06f607', parents: ['c08'], author: 'Lin Chen', email: 'lin@example.dev', date: at(320), subject: 'Ensures proper date ordering for Graph', body: 'Sorts commits by author date before lane assignment.', refs: [] }, + { hash: 'c07', shortHash: 'c07a708', parents: ['c08'], author: 'Grace Hopper', email: 'grace@example.dev', date: at(360), subject: 'Use gitconfig to suggest profile author and email', body: 'Reads user.name / user.email as defaults.', refs: [] }, + { hash: 'c08', shortHash: 'c08b809', parents: ['c09'], author: 'Mara Singh', email: 'mara@example.dev', date: at(540), subject: 'Log error instead of throwing', body: 'Downgrades a fatal path to a reported diagnostic.', refs: ['bug/error-log'] }, + { hash: 'c09', shortHash: 'c09c90a', parents: ['c10'], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(880), subject: 'Runs yarn install after unlink', body: 'Restores dependencies once a linked package is removed.', refs: [] }, + { hash: 'c10', shortHash: 'c10d0ab', parents: ['c11'], author: 'Lin Chen', email: 'lin@example.dev', date: at(1450), subject: 'Add file-diff icons, bump component version', body: 'Ships per-status diff icons and a minor version bump.', refs: ['feature/icons'] }, + { hash: 'c11', shortHash: 'c11e1bc', parents: ['c12'], author: 'Grace Hopper', email: 'grace@example.dev', date: at(2600), subject: 'Centralize command registrations', body: 'Moves scattered command wiring into one registry.', refs: ['development'] }, + { hash: 'c12', shortHash: 'c12f2cd', parents: [], author: 'Ada Lovelace', email: 'ada@example.dev', date: at(9000), subject: 'Add type safety to date ordering', body: 'Types the comparator so bad inputs fail at compile time.', refs: ['tag: v0.1.0'] }, ] +// Stand-in for the `git:show` call the live dashboard makes to fill the hover +// card. Derives plausible changed-file stats from the hash so each commit reads +// distinctly. +async function loadDetail(hash: string): Promise { + const commit = commits.find(c => c.hash === hash) + const seed = hash.charCodeAt(hash.length - 1) + const additions = 20 + ((seed * 37) % 180) + const deletions = seed % 60 + const fileCount = 1 + (seed % 4) + return { + isRepo: true, + found: true, + hash, + shortHash: commit?.shortHash ?? hash, + author: commit?.author ?? '', + email: commit?.email ?? '', + date: commit?.date ?? now, + committer: commit?.author ?? '', + committerEmail: commit?.email ?? '', + commitDate: commit?.date ?? now, + subject: commit?.subject ?? '', + body: commit?.body ?? '', + parents: commit?.parents ?? [], + refs: commit?.refs ?? [], + files: Array.from({ length: fileCount }, (_, i) => ({ + path: `src/module-${i}.ts`, + additions: Math.round(additions / fileCount), + deletions: Math.round(deletions / fileCount), + binary: false, + })), + totalAdditions: additions, + totalDeletions: deletions, + patch: null, + truncated: false, + } +} + const meta = { title: 'Panels/Log', component: LogPanelView, @@ -41,6 +78,7 @@ const meta = { onRefresh: () => undefined, onLoadMore: () => undefined, onSelectCommit: () => undefined, + onLoadDetail: loadDetail, }, } satisfies Meta diff --git a/plugins/git/src/client/components/views/log-panel-view.tsx b/plugins/git/src/client/components/views/log-panel-view.tsx index 962dd780..ad22404f 100644 --- a/plugins/git/src/client/components/views/log-panel-view.tsx +++ b/plugins/git/src/client/components/views/log-panel-view.tsx @@ -1,23 +1,23 @@ 'use client' -import type { Commit } from '../../../index' +import type { Commit, CommitDetail } from '../../../index' import type { GraphRow } from '../../lib/commit-graph' import type { GitRef } from '../../lib/refs' -import { memo, useEffect, useMemo, useRef } from 'react' +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { computeGraph } from '../../lib/commit-graph' import { parseRefs } from '../../lib/refs' import { cn } from '../../lib/utils' +import { Avatar } from '../ui/avatar' import { IconButton } from '../ui/button' import { Icon } from '../ui/icon' import { Skeleton } from '../ui/skeleton' -const ROW_H = 46 -const COL_W = 16 +const ROW_H = 48 +const COL_W = 18 const NODE_R = 5 -const PAD_L = 6 -// Width reserved on the left for branch / tag labels, right-aligned against -// the graph so the active branch hugs its node. -const REFS_W = 152 +const PAD_L = 12 +const HOVER_CARD_W = 320 +const HOVER_CARD_EST_H = 150 export interface LogPanelViewProps { rpcConnected: boolean @@ -38,6 +38,11 @@ export interface LogPanelViewProps { onLoadMore: () => void | Promise /** Called when a commit row is activated. */ onSelectCommit?: (hash: string) => void + /** + * Lazily resolve a commit's detail (author, body, changed-file stats) for the + * hover card. Omitted in previews or when detail can't be fetched. + */ + onLoadDetail?: (hash: string) => Promise } function relativeTime(epoch: number): string { @@ -46,17 +51,17 @@ function relativeTime(epoch: number): string { if (mins < 1) return 'just now' if (mins < 60) - return `${mins}m ago` + return `${mins} minute${mins === 1 ? '' : 's'} ago` const hours = Math.round(mins / 60) if (hours < 24) - return `${hours}h ago` + return `${hours} hour${hours === 1 ? '' : 's'} ago` const days = Math.round(hours / 24) if (days < 30) - return `${days}d ago` + return `${days} day${days === 1 ? '' : 's'} ago` return new Date(epoch).toLocaleDateString() } -/** `#rrggbb` → `rgba(...)`, for lane-tinted backgrounds. */ +/** `#rrggbb` → `rgba(...)`, for lane-tinted accents. */ function withAlpha(hex: string, alpha: number): string { const r = Number.parseInt(hex.slice(1, 3), 16) const g = Number.parseInt(hex.slice(3, 5), 16) @@ -110,13 +115,17 @@ function GraphCell({ row, width, isHead, topStub }: { /> ))} {isHead && ( - + )} + {/* HEAD reads as a hollow ring ("you are here"); every other commit is a + solid lane-colored dot. The ring's fill tracks the base surface + (`bg-base` → white / #111) so it reads as hollow in both themes. */} @@ -165,7 +174,7 @@ function RefLabel({ refToken, color }: { refToken: GitRef, color: string }) { ) } -const CommitRow = memo(({ commit, row, gutter, currentBranch, isHead, topStub, selected, onSelect }: { +const CommitRow = memo(({ commit, row, gutter, currentBranch, isHead, topStub, selected, active, onSelect, onHoverEnter, onHoverLeave }: { commit: Commit row: GraphRow gutter: number @@ -173,51 +182,50 @@ const CommitRow = memo(({ commit, row, gutter, currentBranch, isHead, topStub, s isHead: boolean topStub: boolean selected: boolean + active: boolean onSelect?: (hash: string) => void + onHoverEnter: (hash: string, el: HTMLElement) => void + onHoverLeave: () => void }) => { const refs = useMemo(() => parseRefs(commit.refs, currentBranch), [commit.refs, currentBranch]) - const hasCurrent = refs.some(r => r.kind === 'branch' && r.current) + // Commits off the mainline (lane 0) recede, so the checked-out line reads as + // the spine of the history — matching the emphasized/faded rows in the design. + const dim = row.col !== 0 && !selected && !active return (
  • @@ -233,11 +241,6 @@ function WipRow({ col, color, gutter, changes }: { const mid = ROW_H / 2 return (
  • -
    -
    - +
    -
    +
    - Work in Progress + Work in Progress {changes} {' '} @@ -264,6 +267,88 @@ function WipRow({ col, color, gutter, changes }: { ) } +interface DetailState { + loading: boolean + data: CommitDetail | null +} + +function DiffStat({ additions, deletions }: { additions: number, deletions: number }) { + return ( + + {`+${additions}`} + {' '} + {`−${deletions}`} + + ) +} + +/** The floating commit card shown while hovering a row. */ +function CommitHoverCard({ commit, detail, position, onOpen, onMouseEnter, onMouseLeave }: { + commit: Commit + detail: DetailState | undefined + position: { top: number, left: number } + onOpen: () => void + onMouseEnter: () => void + onMouseLeave: () => void +}) { + const body = (detail?.data?.body || commit.body || commit.subject).trim() + const files = detail?.data?.files.length ?? 0 + + return ( +
    +
    +
    + +
    + {commit.author} + + {relativeTime(commit.date)} + +
    +
    +

    {body}

    +
    + +
    + {detail?.loading || !detail + ? ( + + + Loading… + + ) + : detail.data + ? ( + + + {files} + {' '} + {files === 1 ? 'file' : 'files'} + {' '} + changed + + {' '} + + + ) + : ( + {commit.shortHash} + )} + + + + +
    +
    + ) +} + export function LogPanelView(props: LogPanelViewProps) { const { rpcConnected, @@ -279,6 +364,7 @@ export function LogPanelView(props: LogPanelViewProps) { onRefresh, onLoadMore, onSelectCommit, + onLoadDetail, } = props const graph = useMemo( @@ -292,6 +378,65 @@ export function LogPanelView(props: LogPanelViewProps) { && (!selectedRef || selectedRef === currentBranch) const headRow = graph.rows[0] + // Hover card: an anchored floating card that resolves the hovered commit's + // detail lazily. `hovered` carries the fixed-position anchor; `details` + // caches per-hash results so re-hovering is instant. + const [hovered, setHovered] = useState<{ hash: string, top: number, left: number } | null>(null) + const [details, setDetails] = useState>({}) + const showTimer = useRef>(undefined) + const hideTimer = useRef>(undefined) + const detailsRef = useRef(details) + detailsRef.current = details + const onLoadDetailRef = useRef(onLoadDetail) + onLoadDetailRef.current = onLoadDetail + + const commitByHash = useMemo(() => { + const map = new Map() + for (const c of commits) + map.set(c.hash, c) + return map + }, [commits]) + + const loadDetail = useCallback((hash: string) => { + if (!onLoadDetailRef.current || detailsRef.current[hash]) + return + setDetails(prev => ({ ...prev, [hash]: { loading: true, data: null } })) + onLoadDetailRef.current(hash) + .then(data => setDetails(prev => ({ ...prev, [hash]: { loading: false, data } }))) + .catch(() => setDetails(prev => ({ ...prev, [hash]: { loading: false, data: null } }))) + }, []) + + const onHoverEnter = useCallback((hash: string, el: HTMLElement) => { + clearTimeout(hideTimer.current) + clearTimeout(showTimer.current) + showTimer.current = setTimeout(() => { + const rect = el.getBoundingClientRect() + let left = rect.left + Math.min(gutter, 64) + 8 + left = Math.min(left, window.innerWidth - HOVER_CARD_W - 12) + left = Math.max(12, left) + let top = rect.bottom + 4 + if (top + HOVER_CARD_EST_H > window.innerHeight) + top = Math.max(12, rect.top - HOVER_CARD_EST_H - 4) + setHovered({ hash, top, left }) + loadDetail(hash) + }, 220) + }, [gutter, loadDetail]) + + const scheduleHide = useCallback(() => { + clearTimeout(showTimer.current) + clearTimeout(hideTimer.current) + hideTimer.current = setTimeout(setHovered, 160, null) + }, []) + + const cancelHide = useCallback(() => { + clearTimeout(hideTimer.current) + }, []) + + useEffect(() => () => { + clearTimeout(showTimer.current) + clearTimeout(hideTimer.current) + }, []) + // Auto-load the next page when the bottom sentinel scrolls into view, instead // of a manual "Load more" button. `busyRef` debounces the observer so one // page is requested per visibility change. @@ -325,14 +470,19 @@ export function LogPanelView(props: LogPanelViewProps) { return () => observer.disconnect() }, [hasMore, loading, commits.length]) + const hoveredCommit = hovered ? commitByHash.get(hovered.hash) : undefined + return (
    -
    - - {isRepo - ? `${commits.length}${hasMore ? '+' : ''} commits${selectedRef ? ` · ${selectedRef}` : ''}` - : ' '} - +
    +
    +

    Commit History

    + + {isRepo + ? `${commits.length}${hasMore ? '+' : ''}${selectedRef ? ` · ${selectedRef}` : ''}` + : ''} + +
    @@ -357,7 +507,7 @@ export function LogPanelView(props: LogPanelViewProps) { )} {isRepo === true && commits.length > 0 && ( -
    +
      {showWip && headRow && ( @@ -372,7 +522,10 @@ export function LogPanelView(props: LogPanelViewProps) { isHead={i === 0} topStub={i === 0 && showWip} selected={commit.hash === selectedHash} + active={commit.hash === hovered?.hash} onSelect={onSelectCommit} + onHoverEnter={onHoverEnter} + onHoverLeave={scheduleHide} /> ))}
    @@ -385,6 +538,20 @@ export function LogPanelView(props: LogPanelViewProps) { )}
    )} + + {hovered && hoveredCommit && ( + { + onSelectCommit?.(hovered.hash) + setHovered(null) + }} + onMouseEnter={cancelHide} + onMouseLeave={scheduleHide} + /> + )}
    ) } diff --git a/plugins/git/src/client/lib/commit-graph.ts b/plugins/git/src/client/lib/commit-graph.ts index 13501606..c2eb79b4 100644 --- a/plugins/git/src/client/lib/commit-graph.ts +++ b/plugins/git/src/client/lib/commit-graph.ts @@ -33,19 +33,18 @@ export interface CommitGraph { columns: number } -// Lane palette tuned to read clearly on the dark dashboard, à la GitLens' -// commit graph: a vivid violet leads (the mainline / current branch tends to -// land in lane 0), then warm/cool tones alternate so adjacent lanes stay -// distinct. +// Lane palette tuned to read clearly in both themes: a blue leads (the mainline +// / current branch tends to land in lane 0), then warm tones — orange, red — +// pick up branches as they fan out, so adjacent lanes stay distinct. export const GRAPH_COLORS = [ - '#a78bfa', // violet + '#3b82f6', // blue + '#f59e0b', // amber / orange + '#ef4444', // red + '#8b5cf6', // violet + '#10b981', // emerald '#ec4899', // pink - '#f5b14c', // amber - '#34d399', // emerald - '#38bdf8', // sky - '#22d3ee', // cyan - '#f87171', // red - '#a3e635', // lime + '#06b6d4', // cyan + '#f97316', // orange ] interface Lane { From e05509134ee3133265d384727b2ff0eb5b4fc757 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Mon, 13 Jul 2026 01:31:10 +0000 Subject: [PATCH 2/5] feat(git): nav branch picker, merged Changes tab, floating hover card MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/git/package.json | 2 + .../src/client/components/branches-panel.tsx | 13 - .../git/src/client/components/dashboard.tsx | 167 ++------ .../git/src/client/components/diff-panel.tsx | 50 --- .../src/client/components/status-panel.tsx | 32 ++ .../src/client/components/ui/file-icon.tsx | 16 + .../src/client/components/ui/status-mark.tsx | 44 +++ .../views/commit-details-view.stories.tsx | 6 +- .../components/views/commit-details-view.tsx | 10 +- .../components/views/diff-panel-view.tsx | 22 +- .../views/log-panel-view.stories.tsx | 1 + .../components/views/log-panel-view.tsx | 360 ++++++++---------- .../views/status-panel-view.stories.tsx | 12 +- .../components/views/status-panel-view.tsx | 154 ++++---- plugins/git/src/client/lib/file-icon.ts | 57 +++ plugins/git/src/rpc/functions/show.ts | 41 +- plugins/git/test/git.test.ts | 2 + pnpm-lock.yaml | 97 ++++- pnpm-workspace.yaml | 2 + 19 files changed, 601 insertions(+), 487 deletions(-) delete mode 100644 plugins/git/src/client/components/branches-panel.tsx delete mode 100644 plugins/git/src/client/components/diff-panel.tsx create mode 100644 plugins/git/src/client/components/ui/file-icon.tsx create mode 100644 plugins/git/src/client/components/ui/status-mark.tsx create mode 100644 plugins/git/src/client/lib/file-icon.ts diff --git a/plugins/git/package.json b/plugins/git/package.json index d5149130..2cda8cb4 100644 --- a/plugins/git/package.json +++ b/plugins/git/package.json @@ -52,6 +52,8 @@ }, "devDependencies": { "@antfu/design": "catalog:frontend", + "@floating-ui/react": "catalog:frontend", + "@iconify-json/catppuccin": "catalog:frontend", "@pierre/diffs": "catalog:frontend", "@radix-ui/react-scroll-area": "catalog:frontend", "@radix-ui/react-separator": "catalog:frontend", diff --git a/plugins/git/src/client/components/branches-panel.tsx b/plugins/git/src/client/components/branches-panel.tsx deleted file mode 100644 index d6bb8a60..00000000 --- a/plugins/git/src/client/components/branches-panel.tsx +++ /dev/null @@ -1,13 +0,0 @@ -'use client' - -import type { DevframeRpcClient } from 'devframe/client' -import { useCallback } from 'react' -import { useRpcResource } from './use-rpc-resource' -import { BranchesPanelView } from './views/branches-panel-view' - -export function BranchesPanel() { - const loader = useCallback((rpc: DevframeRpcClient) => rpc.call('git:branches'), []) - const { data, loading, refresh } = useRpcResource(loader) - - return -} diff --git a/plugins/git/src/client/components/dashboard.tsx b/plugins/git/src/client/components/dashboard.tsx index 9ce75ad7..df295ff2 100644 --- a/plugins/git/src/client/components/dashboard.tsx +++ b/plugins/git/src/client/components/dashboard.tsx @@ -2,12 +2,10 @@ import type { DevframeRpcClient } from 'devframe/client' import type { PointerEvent as ReactPointerEvent } from 'react' -import type { Branch, GitBranches } from '../../index' +import type { GitBranches } from '../../index' import { useCallback, useEffect, useRef, useState } from 'react' import { nav as navBar, navBrand, tab as tabClass, tabsList } from '../lib/design' -import { cn } from '../lib/utils' import { CommitDetailsPanel } from './commit-details-panel' -import { DiffPanel } from './diff-panel' import { LogPanel } from './log-panel' import { RpcProvider, useRpc } from './rpc-provider' import { StatusPanel } from './status-panel' @@ -15,10 +13,9 @@ import { useTheme } from './theme' import { Badge } from './ui/badge' import { IconButton } from './ui/button' import { Icon } from './ui/icon' -import { Skeleton } from './ui/skeleton' import { useRpcResource } from './use-rpc-resource' -type DashboardPane = 'status' | 'commits' | 'diff' +type DashboardPane = 'commits' | 'changes' interface NavItem { id: DashboardPane @@ -27,9 +24,8 @@ interface NavItem { } const NAV_ITEMS: NavItem[] = [ - { id: 'status', label: 'Status', icon: 'i-ph-tree-view-duotone' }, { id: 'commits', label: 'Commits', icon: 'i-ph-git-commit-duotone' }, - { id: 'diff', label: 'Diff', icon: 'i-ph-git-diff-duotone' }, + { id: 'changes', label: 'Changes', icon: 'i-ph-git-diff-duotone' }, ] function clamp(value: number, min: number, max: number): number { @@ -117,47 +113,36 @@ function ThemeToggle() { ) } -function BranchRow({ - branch, - selected, +/** Branch picker, living in the nav bar. */ +function BranchSelect({ + branches, + disabled, + value, onSelect, }: { - branch: Branch - selected: boolean + branches: GitBranches | null + disabled: boolean + value: string | null onSelect: (name: string) => void }) { return ( -
  • - + + {isMounted && ( + +
    + { + onSelect?.(commit.hash) + setOpen(false) + }} + /> +
    +
    + )}
  • ) }) @@ -254,9 +387,9 @@ function WipRow({ col, color, gutter, changes }: { -
    +
    - Work in Progress + Work in Progress {changes} {' '} @@ -267,88 +400,6 @@ function WipRow({ col, color, gutter, changes }: { ) } -interface DetailState { - loading: boolean - data: CommitDetail | null -} - -function DiffStat({ additions, deletions }: { additions: number, deletions: number }) { - return ( - - {`+${additions}`} - {' '} - {`−${deletions}`} - - ) -} - -/** The floating commit card shown while hovering a row. */ -function CommitHoverCard({ commit, detail, position, onOpen, onMouseEnter, onMouseLeave }: { - commit: Commit - detail: DetailState | undefined - position: { top: number, left: number } - onOpen: () => void - onMouseEnter: () => void - onMouseLeave: () => void -}) { - const body = (detail?.data?.body || commit.body || commit.subject).trim() - const files = detail?.data?.files.length ?? 0 - - return ( -
    -
    -
    - -
    - {commit.author} - - {relativeTime(commit.date)} - -
    -
    -

    {body}

    -
    - -
    - {detail?.loading || !detail - ? ( - - - Loading… - - ) - : detail.data - ? ( - - - {files} - {' '} - {files === 1 ? 'file' : 'files'} - {' '} - changed - - {' '} - - - ) - : ( - {commit.shortHash} - )} - - - - -
    -
    - ) -} - export function LogPanelView(props: LogPanelViewProps) { const { rpcConnected, @@ -378,65 +429,6 @@ export function LogPanelView(props: LogPanelViewProps) { && (!selectedRef || selectedRef === currentBranch) const headRow = graph.rows[0] - // Hover card: an anchored floating card that resolves the hovered commit's - // detail lazily. `hovered` carries the fixed-position anchor; `details` - // caches per-hash results so re-hovering is instant. - const [hovered, setHovered] = useState<{ hash: string, top: number, left: number } | null>(null) - const [details, setDetails] = useState>({}) - const showTimer = useRef>(undefined) - const hideTimer = useRef>(undefined) - const detailsRef = useRef(details) - detailsRef.current = details - const onLoadDetailRef = useRef(onLoadDetail) - onLoadDetailRef.current = onLoadDetail - - const commitByHash = useMemo(() => { - const map = new Map() - for (const c of commits) - map.set(c.hash, c) - return map - }, [commits]) - - const loadDetail = useCallback((hash: string) => { - if (!onLoadDetailRef.current || detailsRef.current[hash]) - return - setDetails(prev => ({ ...prev, [hash]: { loading: true, data: null } })) - onLoadDetailRef.current(hash) - .then(data => setDetails(prev => ({ ...prev, [hash]: { loading: false, data } }))) - .catch(() => setDetails(prev => ({ ...prev, [hash]: { loading: false, data: null } }))) - }, []) - - const onHoverEnter = useCallback((hash: string, el: HTMLElement) => { - clearTimeout(hideTimer.current) - clearTimeout(showTimer.current) - showTimer.current = setTimeout(() => { - const rect = el.getBoundingClientRect() - let left = rect.left + Math.min(gutter, 64) + 8 - left = Math.min(left, window.innerWidth - HOVER_CARD_W - 12) - left = Math.max(12, left) - let top = rect.bottom + 4 - if (top + HOVER_CARD_EST_H > window.innerHeight) - top = Math.max(12, rect.top - HOVER_CARD_EST_H - 4) - setHovered({ hash, top, left }) - loadDetail(hash) - }, 220) - }, [gutter, loadDetail]) - - const scheduleHide = useCallback(() => { - clearTimeout(showTimer.current) - clearTimeout(hideTimer.current) - hideTimer.current = setTimeout(setHovered, 160, null) - }, []) - - const cancelHide = useCallback(() => { - clearTimeout(hideTimer.current) - }, []) - - useEffect(() => () => { - clearTimeout(showTimer.current) - clearTimeout(hideTimer.current) - }, []) - // Auto-load the next page when the bottom sentinel scrolls into view, instead // of a manual "Load more" button. `busyRef` debounces the observer so one // page is requested per visibility change. @@ -470,10 +462,8 @@ export function LogPanelView(props: LogPanelViewProps) { return () => observer.disconnect() }, [hasMore, loading, commits.length]) - const hoveredCommit = hovered ? commitByHash.get(hovered.hash) : undefined - return ( -
    +

    Commit History

    @@ -489,8 +479,8 @@ export function LogPanelView(props: LogPanelViewProps) {
    {!rpcConnected && ( -
    - {Array.from({ length: 5 }).map((_, i) => )} +
    + {Array.from({ length: 6 }).map((_, i) => )}
    )} @@ -507,7 +497,7 @@ export function LogPanelView(props: LogPanelViewProps) { )} {isRepo === true && commits.length > 0 && ( -
    +
      {showWip && headRow && ( @@ -522,10 +512,8 @@ export function LogPanelView(props: LogPanelViewProps) { isHead={i === 0} topStub={i === 0 && showWip} selected={commit.hash === selectedHash} - active={commit.hash === hovered?.hash} onSelect={onSelectCommit} - onHoverEnter={onHoverEnter} - onHoverLeave={scheduleHide} + onLoadDetail={onLoadDetail} /> ))}
    @@ -538,20 +526,6 @@ export function LogPanelView(props: LogPanelViewProps) { )}
    )} - - {hovered && hoveredCommit && ( - { - onSelectCommit?.(hovered.hash) - setHovered(null) - }} - onMouseEnter={cancelHide} - onMouseLeave={scheduleHide} - /> - )}
    ) } diff --git a/plugins/git/src/client/components/views/status-panel-view.stories.tsx b/plugins/git/src/client/components/views/status-panel-view.stories.tsx index 65ee26f7..2677fc68 100644 --- a/plugins/git/src/client/components/views/status-panel-view.stories.tsx +++ b/plugins/git/src/client/components/views/status-panel-view.stories.tsx @@ -57,7 +57,7 @@ function Harness(props: Partial>) { } const meta = { - title: 'Panels/Status', + title: 'Panels/Changes', component: Harness, } satisfies Meta @@ -69,3 +69,13 @@ export const ReadOnly: Story = { args: { canWrite: false } } export const Clean: Story = { args: { data: clean } } export const Loading: Story = { args: { data: null, loading: true } } export const NotARepo: Story = { args: { data: { ...clean, isRepo: false } } } +export const FileSelected: Story = { + args: { + selectedKey: 'false:README.md', + patchSlot: ( +
    + diff --git a/README.md b/README.md +
    + ), + }, +} diff --git a/plugins/git/src/client/components/views/status-panel-view.tsx b/plugins/git/src/client/components/views/status-panel-view.tsx index 101a855f..a57609ef 100644 --- a/plugins/git/src/client/components/views/status-panel-view.tsx +++ b/plugins/git/src/client/components/views/status-panel-view.tsx @@ -1,12 +1,15 @@ 'use client' import type { ReactNode } from 'react' -import type { FileStatusCode, GitStatus, StatusFileEntry } from '../../../index' +import type { GitStatus, StatusFileEntry } from '../../../index' +import { cn } from '../../lib/utils' import { Badge } from '../ui/badge' import { Button, IconButton } from '../ui/button' +import { FileIcon } from '../ui/file-icon' import { Icon } from '../ui/icon' import { ScrollArea } from '../ui/scroll-area' import { Skeleton } from '../ui/skeleton' +import { StatusMark } from '../ui/status-mark' import { Textarea } from '../ui/textarea' export interface StatusPanelViewProps { @@ -16,44 +19,47 @@ export interface StatusPanelViewProps { canWrite: boolean message: string note: string | null + /** `${staged}:${path}` of the file whose diff is shown, or `null`. */ + selectedKey?: string | null onRefresh: () => void | Promise onStage: (paths: string[]) => void | Promise onUnstage: (paths: string[]) => void | Promise onCommit: () => void | Promise onMessageChange: (value: string) => void + /** Reveal a file's diff. `staged` picks the index-vs-HEAD diff. */ + onSelectFile?: (path: string, staged: boolean) => void + /** The selected file's diff viewer, rendered below the file list. */ + patchSlot?: ReactNode } -const STATUS_LABEL: Record = { - 'modified': 'M', - 'added': 'A', - 'deleted': 'D', - 'renamed': 'R', - 'copied': 'C', - 'type-changed': 'T', - 'unmerged': 'U', - 'unknown': '?', +function fileKey(path: string, staged: boolean): string { + return `${staged}:${path}` } -function statusColor(code: FileStatusCode): string { - switch (code) { - case 'added': return 'text-success' - case 'deleted': return 'text-error' - case 'modified': return 'text-warning' - case 'unmerged': return 'text-error' - default: return 'color-muted' - } -} - -function FileRow({ entry, action }: { entry: StatusFileEntry, action?: ReactNode }) { +function FileRow({ entry, staged, selected, action, onSelect }: { + entry: StatusFileEntry + staged: boolean + selected: boolean + action?: ReactNode + onSelect?: (path: string, staged: boolean) => void +}) { const label = entry.from ? `${entry.from} → ${entry.path}` : entry.path return ( -
  • - - {STATUS_LABEL[entry.status]} - - - {label} - +
  • + {action}
  • ) @@ -87,7 +93,7 @@ function Section({ } export function StatusPanelView(props: StatusPanelViewProps) { - const { data, loading, busy, canWrite, message, note, onRefresh, onStage, onUnstage, onCommit, onMessageChange } = props + const { data, loading, busy, canWrite, message, note, selectedKey, onRefresh, onStage, onUnstage, onCommit, onMessageChange, onSelectFile, patchSlot } = props const stageBtn = (paths: string[], label: string) => ( onStage(paths)}> @@ -103,48 +109,45 @@ export function StatusPanelView(props: StatusPanelViewProps) { return (
    -
    - {data?.isRepo - ? ( - <> - - - {data.detached ? `detached @ ${data.head}` : data.branch} - - {data.upstream && ( - - {data.upstream} - {data.ahead > 0 && ( - - - {data.ahead} - - )} - {data.behind > 0 && ( - - - {data.behind} - - )} - - )} - {data.clean - ? ( - - - clean - - ) - : working tree dirty} - - ) - : } +
    +

    Changes

    + {data?.isRepo && !data.clean && ( + + {data.staged.length + data.unstaged.length + data.untracked.length} + {' '} + changed + + )} +
    +
    + {data?.isRepo && (data.ahead > 0 || data.behind > 0) && ( + + {data.ahead > 0 && ( + + + {data.ahead} + + )} + {data.behind > 0 && ( + + + {data.behind} + + )} + + )} + + +
    - - -
    + {!data && ( +
    + {Array.from({ length: 4 }).map((_, i) => )} +
    + )} + {data && !data.isRepo && (

    The working directory is not a git repository.

    )} @@ -168,6 +171,9 @@ export function StatusPanelView(props: StatusPanelViewProps) { ))} @@ -184,6 +190,9 @@ export function StatusPanelView(props: StatusPanelViewProps) { ))} @@ -200,6 +209,9 @@ export function StatusPanelView(props: StatusPanelViewProps) { ))} @@ -207,6 +219,12 @@ export function StatusPanelView(props: StatusPanelViewProps) {
    + {patchSlot && ( +
    + {patchSlot} +
    + )} + {canWrite && data.staged.length > 0 && (