From d157fd186c99d9212ea3c362e9a358efca4e59c6 Mon Sep 17 00:00:00 2001 From: 0x92 <0x92dev@gmail.com> Date: Tue, 11 Aug 2026 11:49:28 +0200 Subject: [PATCH 1/5] Add a repository-wide commit graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pane could show a session's own commits, but never the repository as a whole — which branch a session came from, what is on main, where a tag sits. This adds a project-scoped graph view of every branch and tag, with Pane's own worktrees marked. - Lane diagram solved client-side from a flat commit list, so the layout is unit-testable and independent of `git log --graph` ASCII art - Branch starts, joins and crossing lanes are distinct edge kinds; merges are drawn hollow - Filter by subject, author, hash or ref; focus one branch's history; arrow keys, `/` and Escape to navigate - Pane worktrees, uncommitted work and ahead/behind counts tie the graph back to the sessions - Remote scope defaults to the repo's own remote: `--all` mixes a fork's `upstream` branches into what should be one project's history Commit selection loads the full patch into the same DiffViewer the diff panel uses. Tests: layout solver (13), graph manager and ref parsing (28), including remote-scope resolution and rejection of ref names that could reach a shell. --- .../src/components/ProjectSessionList.tsx | 9 +- frontend/src/components/SessionView.tsx | 12 +- .../components/gitGraph/GitGraphCanvas.tsx | 105 +++ .../gitGraph/GitGraphCommitDetail.tsx | 125 +++ .../src/components/gitGraph/GitGraphView.tsx | 801 ++++++++++++++++++ .../src/components/gitGraph/graphColors.ts | 24 + frontend/src/stores/navigationStore.ts | 18 +- frontend/src/types/electron.d.ts | 6 + frontend/src/utils/api.ts | 13 + frontend/src/utils/gitGraphLayout.test.ts | 186 ++++ frontend/src/utils/gitGraphLayout.ts | 146 ++++ frontend/src/utils/parseUnifiedDiff.test.ts | 81 ++ frontend/src/utils/parseUnifiedDiff.ts | 62 ++ main/src/ipc/daemonRegistryBindings.test.ts | 2 + main/src/ipc/git.ts | 96 +++ main/src/preload.ts | 4 + main/src/services/gitGraphManager.test.ts | 379 +++++++++ main/src/services/gitGraphManager.ts | 309 +++++++ shared/types/gitGraph.ts | 152 ++++ 19 files changed, 2525 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/gitGraph/GitGraphCanvas.tsx create mode 100644 frontend/src/components/gitGraph/GitGraphCommitDetail.tsx create mode 100644 frontend/src/components/gitGraph/GitGraphView.tsx create mode 100644 frontend/src/components/gitGraph/graphColors.ts create mode 100644 frontend/src/utils/gitGraphLayout.test.ts create mode 100644 frontend/src/utils/gitGraphLayout.ts create mode 100644 frontend/src/utils/parseUnifiedDiff.test.ts create mode 100644 frontend/src/utils/parseUnifiedDiff.ts create mode 100644 main/src/services/gitGraphManager.test.ts create mode 100644 main/src/services/gitGraphManager.ts create mode 100644 shared/types/gitGraph.ts diff --git a/frontend/src/components/ProjectSessionList.tsx b/frontend/src/components/ProjectSessionList.tsx index 13a191986..441a230ab 100644 --- a/frontend/src/components/ProjectSessionList.tsx +++ b/frontend/src/components/ProjectSessionList.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef, useId } from 'react'; -import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare } from 'lucide-react'; +import { ChevronDown, ChevronRight, Plus, FolderPlus, GitBranch, MoreHorizontal, Home, Archive, ArchiveRestore, Trash2, GitPullRequest, Pin, Monitor, MessageSquare, Network } from 'lucide-react'; import { SessionDetailTooltip } from './SessionDetailTooltip'; import { useSessionStore } from '../stores/sessionStore'; import { useNavigationStore } from '../stores/navigationStore'; @@ -81,6 +81,7 @@ export function ProjectSessionList({ const navigateToPaneChat = useNavigationStore(s => s.navigateToPaneChat); const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID); const navigateToProject = useNavigationStore(s => s.navigateToProject); + const navigateToGitGraph = useNavigationStore(s => s.navigateToGitGraph); const setSidebarNavigationScope = useNavigationStore(s => s.setSidebarNavigationScope); // Expansion state lives in the navigation store so the always-mounted // session hotkeys (useSessionNavigationHotkeys) see the same visible ordering @@ -413,6 +414,12 @@ export function ProjectSessionList({ icon: GitBranch, onClick: () => navigateToProject(project.id), }, + { + id: 'commit-graph', + label: 'Commit graph', + icon: Network, + onClick: () => navigateToGitGraph(project.id), + }, { id: 'delete', label: 'Delete Project', diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index 5a1a339cb..d25c88cc9 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -16,6 +16,7 @@ import { CommitMessageDialog } from './session/CommitMessageDialog'; import { FolderArchiveDialog } from './session/FolderArchiveDialog'; import { ConfirmDialog } from './ConfirmDialog'; import { ProjectView } from './ProjectView'; +import { GitGraphView } from './gitGraph/GitGraphView'; import { API } from '../utils/api'; import { useResizable } from '../hooks/useResizable'; import { useResizableHeight } from '../hooks/useResizableHeight'; @@ -1217,9 +1218,11 @@ export const SessionView = memo(() => { return () => { cancelled = true; }; }, [activeSession?.id, activeSession?.isMainRepo]); - // Load project data when activeProjectId changes + // Load project data when activeProjectId changes. The commit graph needs it + // too: without a name in its header there is nothing on screen saying which + // repository is being graphed. useEffect(() => { - if (activeView === 'project' && activeProjectId) { + if ((activeView === 'project' || activeView === 'git-graph') && activeProjectId) { const loadProjectData = async () => { setIsProjectLoading(true); try { @@ -1613,6 +1616,11 @@ export const SessionView = memo(() => { // Removed unused variables - now handled by panels + // Repository-wide commit graph — project-scoped, not session-scoped. + if (activeView === 'git-graph' && activeProjectId) { + return ; + } + // Show project view if navigation is set to project if (activeView === 'project' && activeProjectId) { if (isProjectLoading || !projectData) { diff --git a/frontend/src/components/gitGraph/GitGraphCanvas.tsx b/frontend/src/components/gitGraph/GitGraphCanvas.tsx new file mode 100644 index 000000000..113a8aedc --- /dev/null +++ b/frontend/src/components/gitGraph/GitGraphCanvas.tsx @@ -0,0 +1,105 @@ +import { memo } from 'react'; +import type { GitGraphRow } from '../../../../shared/types/gitGraph'; +import { LANE_WIDTH, ROW_HEIGHT, laneColor } from './graphColors'; + +const DOT_RADIUS = 4; +/** Length of the cap drawn above a branch tip's dot. */ +const TIP_CAP = 5; + +/** + * Path for one edge within a row's band. + * + * Every row draws its own slice, so an edge has to cover exactly the part of + * the band it owns: a pass-through spans the full height, a straight or merge + * edge leaves the dot half-way down, and a join arrives at the dot from above. + * Getting this wrong is what turned continuing branches into dashed lines. + */ +function edgePath(kind: string, x1: number, x2: number, centerY: number, rowHeight: number): string { + switch (kind) { + case 'passthrough': + return `M ${x1} 0 L ${x2} ${rowHeight}`; + case 'straight': + return `M ${x1} ${centerY} L ${x2} ${rowHeight}`; + case 'join': + // Mirror of the merge curve: comes down its own lane, then bends in. + return `M ${x1} 0 C ${x1} ${centerY * 0.5}, ${x2} ${centerY * 0.75}, ${x2} ${centerY}`; + default: + return `M ${x1} ${centerY} C ${x1} ${centerY + rowHeight * 0.35}, ${x2} ${centerY + rowHeight * 0.25}, ${x2} ${rowHeight}`; + } +} + +interface GitGraphCanvasProps { + row: GitGraphRow; + laneCount: number; + /** Highlights the dot when this commit is selected in the list. */ + isSelected: boolean; +} + +/** + * Draws one row's slice of the commit graph: the commit dot plus every edge + * descending from this row into the next. + * + * Purely decorative — `aria-hidden`, zero interactivity. The clickable target + * is the row button in {@link GitGraphView}, which keeps the graph free of + * `jsx-a11y/no-static-element-interactions` violations. + */ +export const GitGraphCanvas = memo(function GitGraphCanvas({ row, laneCount, isSelected }: GitGraphCanvasProps) { + const width = Math.max(laneCount, 1) * LANE_WIDTH; + const centerY = ROW_HEIGHT / 2; + const laneX = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2; + const isMerge = row.node.parents.length > 1; + + return ( + + ); +}); + +export default GitGraphCanvas; diff --git a/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx b/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx new file mode 100644 index 000000000..502ab1516 --- /dev/null +++ b/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx @@ -0,0 +1,125 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Loader2, User, Clock, Hash, GitFork } from 'lucide-react'; +import { API } from '../../utils/api'; +import { parseUnifiedDiffToFiles } from '../../utils/parseUnifiedDiff'; +import DiffViewer from '../panels/diff/DiffViewer'; +import { CopyableField } from '../ui/CopyableField'; +import { Badge } from '../ui/Badge'; +import type { GitGraphNode } from '../../../../shared/types/gitGraph'; +import type { GitDiffResult } from '../../types/diff'; + +interface GitGraphCommitDetailProps { + projectId: number; + node: GitGraphNode; +} + +/** + * Right-hand pane of the graph view: metadata for the selected commit plus its + * full patch, rendered with the same {@link DiffViewer} the diff panel uses so + * expand/collapse, split view and syntax highlighting behave identically. + */ +export function GitGraphCommitDetail({ projectId, node }: GitGraphCommitDetailProps) { + const [diff, setDiff] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setDiff(null); + + API.projects.getCommitDetail(projectId, node.hash) + .then(response => { + if (cancelled) return; + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to load commit'); + } + setDiff(response.data); + }) + .catch((err: unknown) => { + if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load commit'); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { cancelled = true; }; + }, [projectId, node.hash]); + + const files = useMemo(() => (diff ? parseUnifiedDiffToFiles(diff.diff) : []), [diff]); + + const fullDate = useMemo(() => { + const date = new Date(node.authorDate); + return Number.isNaN(date.getTime()) + ? node.authorDate + : date.toLocaleString(undefined, { + weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', + hour: '2-digit', minute: '2-digit', + }); + }, [node.authorDate]); + + return ( +
+
+

+ {node.subject} +

+ + {node.refs.length > 0 && ( +
+ {node.refs.map(ref => ( + + {ref.name} + + ))} +
+ )} + +
+ `} /> + + + {node.parents.length > 0 && ( + + )} +
+ + {diff && ( +
+ +{diff.stats.additions} + -{diff.stats.deletions} + + {diff.stats.filesChanged} {diff.stats.filesChanged === 1 ? 'file' : 'files'} + +
+ )} +
+ +
+ {loading ? ( +
+
+ ) : error ? ( +
+ {error} +
+ ) : files.length === 0 ? ( +
+ This commit has no file changes. +
+ ) : ( + + )} +
+
+ ); +} + +export default GitGraphCommitDetail; diff --git a/frontend/src/components/gitGraph/GitGraphView.tsx b/frontend/src/components/gitGraph/GitGraphView.tsx new file mode 100644 index 000000000..fb5952050 --- /dev/null +++ b/frontend/src/components/gitGraph/GitGraphView.tsx @@ -0,0 +1,801 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type PointerEvent as ReactPointerEvent } from 'react'; +import { + Copy, GitBranch, Hash, Loader2, MoreHorizontal, PanelRight, RefreshCw, Search, Tag, X, +} from 'lucide-react'; +import { API } from '../../utils/api'; +import { Dropdown } from '../ui/Dropdown'; +import { computeGitGraphLayout } from '../../utils/gitGraphLayout'; +import { useSessionStore } from '../../stores/sessionStore'; +import { useNavigationStore } from '../../stores/navigationStore'; +import { GitGraphCanvas } from './GitGraphCanvas'; +import { LANE_WIDTH, ROW_HEIGHT, laneColor } from './graphColors'; +import { GitGraphCommitDetail } from './GitGraphCommitDetail'; +import { + GRAPH_REMOTE_ALL, + GRAPH_REMOTE_NONE, + type GitGraphNode, + type PaneWorktreeRef, + type RepoGitGraph, +} from '../../../../shared/types/gitGraph'; + +/** Rows rendered outside the viewport on each side, to hide scroll seams. */ +const VIRTUALIZE_OVERSCAN = 8; +/** Below this many commits, virtualisation costs more than it saves. */ +const VIRTUALIZE_THRESHOLD = 200; +/** A repo-wide `--all` log is expensive; coalesce refresh triggers. */ +const REFRESH_DEBOUNCE_MS = 2000; + +const LIMIT_OPTIONS = [100, 300, 1000] as const; +/** Hard ceiling the backend enforces; "load more" stops here. */ +const MAX_LIMIT = 2000; +/** + * Ref chips shown inline before a subject. Past this many the row is all + * chips and no commit message — the rest collapse into a "+N" pill. + */ +const INLINE_REFS = 2; +/** + * Lanes the gutter reserves. A repository with thirty concurrent branches + * would otherwise push the commit text halfway across the window. + */ +const MAX_GUTTER_LANES = 8; +const DETAIL_MIN_PERCENT = 25; +const DETAIL_MAX_PERCENT = 70; + +function formatDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }); +} + +/** + * "2h", "3d" — a fixed-width age, so the right-hand column lines up and the + * eye can scan down it. The exact timestamp lives in the row's tooltip. + */ +function formatAge(iso: string): string { + const then = new Date(iso).getTime(); + if (Number.isNaN(then)) return ''; + const minutes = Math.max(0, Math.round((Date.now() - then) / 60_000)); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 365) return `${days}d`; + return `${Math.floor(days / 365)}y`; +} + +function initials(name: string): string { + const parts = name.trim().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(); +} + +/** Stable colour per author, so the same person keeps the same badge. */ +function authorColor(seed: string): string { + let hash = 0; + for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0; + return laneColor(Math.abs(hash)); +} + +function RefChip({ + name, + kind, + isCurrent, + ahead, + behind, + worktree, + isFocused, + onFocus, +}: { + name: string; + kind: string; + isCurrent: boolean; + ahead?: number; + behind?: number; + worktree?: PaneWorktreeRef; + isFocused: boolean; + onFocus: (ref: string) => void; +}) { + const Icon = kind === 'tag' ? Tag : GitBranch; + const tone = isFocused + ? 'border-interactive bg-interactive/25 text-interactive' + : kind === 'tag' + ? 'border-status-warning/40 bg-status-warning/10 text-status-warning' + : worktree + ? 'border-interactive/50 bg-interactive/15 text-interactive' + : isCurrent + ? 'border-status-success/40 bg-status-success/10 text-status-success' + : 'border-border-secondary bg-surface-tertiary text-text-secondary'; + + // The session name is only worth appending when it says something the branch + // name does not — otherwise the chip reads "archive · archive". + const sessionLabel = worktree?.sessionName && worktree.sessionName.toLowerCase() !== name.toLowerCase() + ? worktree.sessionName + : null; + + const divergence = [ + ahead ? `${ahead} ahead` : null, + behind ? `${behind} behind` : null, + ].filter(Boolean).join(', '); + + return ( + + ); +} + +export interface GitGraphViewProps { + projectId: number; + projectName?: string; +} + +/** + * Repository-wide commit graph: every branch and tag in one lane diagram, + * with Pane's own worktrees highlighted and a per-commit detail pane. + * + * Scoped to a project rather than a session, which is why it is a top-level + * view instead of a `ToolPanelType` — panels are session-keyed. + */ +export function GitGraphView({ projectId, projectName }: GitGraphViewProps) { + const [graph, setGraph] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [limit, setLimit] = useState(LIMIT_OPTIONS[1]); + /** + * Which remote's branches are graphed. Undefined until the first response + * says which one the repository defaulted to. + */ + const [remoteScope, setRemoteScope] = useState(undefined); + const [selectedHash, setSelectedHash] = useState(null); + const [scrollTop, setScrollTop] = useState(0); + const [viewportHeight, setViewportHeight] = useState(0); + /** Free-text filter over subject, author and hash. */ + const [query, setQuery] = useState(''); + /** Ref the history is narrowed to, if any. */ + const [focusRef, setFocusRef] = useState(undefined); + const [detailPercent, setDetailPercent] = useState(46); + const [detailHidden, setDetailHidden] = useState(false); + + const scrollRef = useRef(null); + const searchRef = useRef(null); + const splitRef = useRef(null); + const requestIdRef = useRef(0); + + const setActiveSession = useSessionStore(state => state.setActiveSession); + const navigateToSessions = useNavigationStore(state => state.navigateToSessions); + const sessions = useSessionStore(state => state.sessions); + + const load = useCallback(async (mode: 'initial' | 'refresh') => { + const requestId = ++requestIdRef.current; + if (mode === 'initial') setLoading(true); + else setRefreshing(true); + + try { + const response = await API.projects.getGitGraph({ + projectId, + limit, + ...(remoteScope === undefined ? {} : { remoteScope }), + ...(focusRef ? { focusRef } : {}), + }); + if (requestId !== requestIdRef.current) return; + if (!response.success || !response.data) { + throw new Error(response.error || 'Failed to load repository graph'); + } + setGraph(response.data); + // Adopt whatever the repository defaulted to, so the picker agrees with + // what is on screen. + setRemoteScope(response.data.remoteScope); + setError(null); + } catch (err: unknown) { + if (requestId !== requestIdRef.current) return; + setError(err instanceof Error ? err.message : 'Failed to load repository graph'); + } finally { + if (requestId === requestIdRef.current) { + setLoading(false); + setRefreshing(false); + } + } + }, [projectId, limit, remoteScope, focusRef]); + + useEffect(() => { + void load('initial'); + }, [load]); + + // Git operations elsewhere in the app invalidate the graph. A repo-wide + // `--all` log per event would be far too heavy, so refreshes are debounced. + useEffect(() => { + let timer: number | undefined; + const schedule = () => { + window.clearTimeout(timer); + timer = window.setTimeout(() => { void load('refresh'); }, REFRESH_DEBOUNCE_MS); + }; + + window.addEventListener('git-status-updated', schedule); + window.addEventListener('panel:event', schedule); + return () => { + window.clearTimeout(timer); + window.removeEventListener('git-status-updated', schedule); + window.removeEventListener('panel:event', schedule); + }; + }, [load]); + + useEffect(() => { + const element = scrollRef.current; + if (!element) return; + const observer = new ResizeObserver(() => setViewportHeight(element.clientHeight)); + observer.observe(element); + setViewportHeight(element.clientHeight); + return () => observer.disconnect(); + }, [graph]); + + const layout = useMemo(() => computeGitGraphLayout(graph?.nodes ?? []), [graph]); + + const worktreeByBranch = useMemo(() => { + const map = new Map(); + for (const worktree of graph?.paneWorktrees ?? []) { + if (worktree.branch) map.set(worktree.branch, worktree); + } + return map; + }, [graph]); + + /** + * Rows matching the filter. + * + * Filtering hides rows, which would leave the lane edges pointing at commits + * that are no longer there — so a filtered list drops the lane drawing and + * shows a plain marker instead. Honest, and what a search result should look + * like anyway. + */ + const isFiltering = query.trim().length > 0; + const rows = useMemo(() => { + const needle = query.trim().toLowerCase(); + if (!needle) return layout.rows; + return layout.rows.filter(row => + row.node.subject.toLowerCase().includes(needle) + || row.node.authorName.toLowerCase().includes(needle) + || row.node.authorEmail.toLowerCase().includes(needle) + || row.node.hash.toLowerCase().startsWith(needle) + || row.node.refs.some(ref => ref.name.toLowerCase().includes(needle)) + ); + }, [layout.rows, query]); + + const selectedNode: GitGraphNode | null = useMemo( + () => layout.rows.find(row => row.node.hash === selectedHash)?.node ?? null, + [layout.rows, selectedHash] + ); + + const shouldVirtualize = rows.length > VIRTUALIZE_THRESHOLD && viewportHeight > 0; + const firstVisible = shouldVirtualize + ? Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - VIRTUALIZE_OVERSCAN) + : 0; + const lastVisible = shouldVirtualize + ? Math.min(rows.length, Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) + VIRTUALIZE_OVERSCAN) + : rows.length; + const visibleRows = rows.slice(firstVisible, lastVisible); + + const gutterWidth = Math.min(Math.max(layout.laneCount, 1), MAX_GUTTER_LANES) * LANE_WIDTH; + + const openSession = useCallback((sessionId: string) => { + void setActiveSession(sessionId).then(() => navigateToSessions()); + }, [setActiveSession, navigateToSessions]); + + /** + * Sessions of this project with uncommitted work. + * + * Git knows nothing about them — they are the rows above the newest commit + * that every other client calls "WIP", and the reason Pane can draw them at + * all is that it tracks each worktree's status itself. + */ + const dirtySessions = useMemo(() => sessions.filter(session => + session.projectId === projectId + && !session.archived + && session.gitStatus + && ['modified', 'untracked', 'conflict'].includes(session.gitStatus.state) + ), [sessions, projectId]); + + /** Newest commit is selected on arrival: an empty detail pane says nothing. */ + useEffect(() => { + if (!selectedHash && layout.rows.length > 0) setSelectedHash(layout.rows[0].node.hash); + }, [selectedHash, layout.rows]); + + const moveSelection = useCallback((delta: number | 'first' | 'last') => { + if (rows.length === 0) return; + const current = rows.findIndex(row => row.node.hash === selectedHash); + const next = delta === 'first' + ? 0 + : delta === 'last' + ? rows.length - 1 + : Math.min(Math.max((current === -1 ? 0 : current) + delta, 0), rows.length - 1); + + setSelectedHash(rows[next].node.hash); + + // Keep the cursor inside the viewport; the list is virtualised, so this is + // arithmetic on the scroll offset rather than a DOM lookup. + const element = scrollRef.current; + if (!element) return; + const top = next * ROW_HEIGHT; + if (top < element.scrollTop) element.scrollTop = top; + else if (top + ROW_HEIGHT > element.scrollTop + element.clientHeight) { + element.scrollTop = top + ROW_HEIGHT - element.clientHeight; + } + }, [rows, selectedHash]); + + /** + * Arrow keys walk the history, `/` jumps to the filter, Escape backs out of + * a filter or a focused branch. Bound on the document because the list is a + * stack of buttons — without this the only way through 300 commits is the + * mouse. + */ + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null; + const typing = target?.isContentEditable + || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? ''); + + if (typing) { + if (event.key === 'Escape' && target === searchRef.current) { + setQuery(''); + searchRef.current?.blur(); + } + return; + } + if (event.ctrlKey || event.metaKey || event.altKey) return; + + switch (event.key) { + case 'ArrowDown': case 'j': event.preventDefault(); moveSelection(1); break; + case 'ArrowUp': case 'k': event.preventDefault(); moveSelection(-1); break; + case 'PageDown': event.preventDefault(); moveSelection(10); break; + case 'PageUp': event.preventDefault(); moveSelection(-10); break; + case 'Home': event.preventDefault(); moveSelection('first'); break; + case 'End': event.preventDefault(); moveSelection('last'); break; + case '/': event.preventDefault(); searchRef.current?.focus(); break; + case 'Escape': + if (query) { event.preventDefault(); setQuery(''); } + else if (focusRef) { event.preventDefault(); setFocusRef(undefined); } + break; + default: break; + } + }; + + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [moveSelection, query, focusRef]); + + /** Drag the divider between the list and the detail pane. */ + const startDetailDrag = useCallback((event: ReactPointerEvent) => { + event.preventDefault(); + const container = splitRef.current; + if (!container) return; + + const onMove = (move: PointerEvent) => { + const rect = container.getBoundingClientRect(); + if (rect.width === 0) return; + const percent = ((rect.right - move.clientX) / rect.width) * 100; + setDetailPercent(Math.min(Math.max(percent, DETAIL_MIN_PERCENT), DETAIL_MAX_PERCENT)); + }; + const onUp = () => { + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }, []); + + const copyToClipboard = useCallback((value: string) => { + void navigator.clipboard?.writeText(value).catch(() => {}); + }, []); + + return ( +
+
+
+
+ +
+
+
+ +
+ Number of commits to load + {LIMIT_OPTIONS.map(option => ( + + ))} +
+ + {/* + A fork's clone carries `origin` and `upstream`, and those are two + different repositories on the hosting side. Graphing every remote + buried this project's history under the other one's branches, so + the remote is an explicit choice that defaults to this repo's own. + */} + + + + + +
+
+ + {graph?.notice && ( +
+ {graph.notice} +
+ )} + + {isFiltering && !loading && !error && ( +
+ {rows.length} of {layout.rows.length} commits match “{query.trim()}” — lanes are hidden while filtering. +
+ )} + + {!isFiltering && layout.laneCount > MAX_GUTTER_LANES && ( +
+ {layout.laneCount} parallel branches in view; the gutter shows the first {MAX_GUTTER_LANES}. + Focus a branch or switch the remote to narrow it down. +
+ )} + +
+ {/* Commit list */} +
setScrollTop(event.currentTarget.scrollTop)} + className="min-w-0 flex-1 overflow-auto" + > + {loading ? ( +
+
+ ) : error ? ( +
+

Could not load the commit graph

+

{error}

+
+ ) : rows.length === 0 ? ( +
+ {isFiltering ? `Nothing matches “${query.trim()}”.` : 'No commits to show yet.'} +
+ ) : ( + <> + {/* + Work git has not been told about yet. Every other client calls + this the WIP row; Pane can name the session it belongs to. + */} + {!isFiltering && dirtySessions.map(session => ( + + ))} + +
+
+ {visibleRows.map(row => { + const isSelected = row.node.hash === selectedHash; + const accent = laneColor(row.colorIndex); + const inlineRefs = row.node.refs.slice(0, INLINE_REFS); + const hiddenRefs = row.node.refs.slice(INLINE_REFS); + + return ( +
+ + + {/* Sibling of the row button — a button inside a button is invalid. */} + copyToClipboard(row.node.hash), + }, + { + id: 'copy-short', + label: `Copy ${row.node.shortHash}`, + icon: Hash, + onClick: () => copyToClipboard(row.node.shortHash), + }, + { + id: 'copy-subject', + label: 'Copy subject', + icon: Copy, + onClick: () => copyToClipboard(row.node.subject), + }, + ...row.node.refs + .map(ref => worktreeByBranch.get(ref.name)) + .filter((worktree): worktree is PaneWorktreeRef => Boolean(worktree?.sessionId)) + .map(worktree => ({ + id: `open-${worktree.sessionId}`, + label: `Open session “${worktree.sessionName ?? worktree.branch}”`, + icon: GitBranch, + onClick: () => openSession(worktree.sessionId as string), + })), + ]} + trigger={ + + } + /> +
+ ); + })} +
+
+ + )} + + {graph?.truncated && !loading && !error && !isFiltering && ( +
+ + Showing the {graph.limit} most recent commits. + + {limit < MAX_LIMIT && ( + + )} +
+ )} +
+ + {/* Detail pane */} + {!detailHidden && ( + <> +
+ + + )} +
+ + {/* Pane worktrees legend */} + {(graph?.paneWorktrees.length ?? 0) > 0 && ( +
+ Pane worktrees + {graph?.paneWorktrees.map(worktree => ( + worktree.sessionId ? ( + + ) : ( + + {worktree.branch}{worktree.isMainCheckout ? ' (main checkout)' : ''} + + ) + ))} +
+ )} +
+ ); +} + +export default GitGraphView; diff --git a/frontend/src/components/gitGraph/graphColors.ts b/frontend/src/components/gitGraph/graphColors.ts new file mode 100644 index 000000000..ff5d93a7c --- /dev/null +++ b/frontend/src/components/gitGraph/graphColors.ts @@ -0,0 +1,24 @@ +/** + * Lane palette for the commit graph. + * + * Explicit hex values rather than CSS variables: these are consumed as SVG + * `stroke`/`fill` attributes, and the hues are chosen to stay legible on both + * the light and dark surfaces. + */ +const LANE_COLORS = [ + '#4f8ef7', + '#37b877', + '#e0913a', + '#c765d6', + '#e05a6b', + '#3fb8c4', + '#8f8ff0', + '#c2a63a', +]; + +export function laneColor(colorIndex: number): string { + return LANE_COLORS[colorIndex % LANE_COLORS.length]; +} + +export const LANE_WIDTH = 14; +export const ROW_HEIGHT = 44; diff --git a/frontend/src/stores/navigationStore.ts b/frontend/src/stores/navigationStore.ts index 9a26e1538..66e72c64a 100644 --- a/frontend/src/stores/navigationStore.ts +++ b/frontend/src/stores/navigationStore.ts @@ -10,8 +10,15 @@ let hasRegisteredInitialProjectIds = false; const toProjectIdArray = (projectIds: Set): number[] => Array.from(projectIds).sort((a, b) => a - b); +/** + * Pane has no router — this enum is the whole navigation model. Adding a value + * here also requires a branch in `SessionView` and an entry in *both* sidebar + * components (`Sidebar` compact rail and `ProjectSessionList` expanded tree). + */ +export type ActiveView = 'sessions' | 'project' | 'pane-chat' | 'git-graph'; + interface NavigationState { - activeView: 'sessions' | 'project' | 'pane-chat'; + activeView: ActiveView; activeProjectId: number | null; // Sidebar collapse @@ -37,11 +44,12 @@ interface NavigationState { setSidebarNavigationScope: (scope: SidebarNavigationScope) => void; // Actions - setActiveView: (view: 'sessions' | 'project' | 'pane-chat') => void; + setActiveView: (view: ActiveView) => void; setActiveProjectId: (projectId: number | null) => void; navigateToProject: (projectId: number) => void; navigateToSessions: () => void; navigateToPaneChat: () => void; + navigateToGitGraph: (projectId: number) => void; } export const useNavigationStore = create((set, get) => ({ @@ -118,4 +126,10 @@ export const useNavigationStore = create((set, get) => ({ activeView: 'pane-chat', activeProjectId: null }), + + // The commit graph is repo-wide, so it keeps the project it belongs to. + navigateToGitGraph: (projectId) => set({ + activeView: 'git-graph', + activeProjectId: projectId + }), })); diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index e0ec4b50b..a6d9e34b8 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -35,6 +35,8 @@ import type { JsonValue } from '../../../shared/validation/boundaryDecoder'; import type { PanelAgentStatusEvent } from '../../../shared/types/agentStatus'; import type { AgentUsageSnapshot } from '../../../shared/types/agentUsage'; import type { PaneChatAgent, PaneChatState } from '../../../shared/types/paneChat'; +import type { RepoGitGraph, RepoGitGraphRequest } from '../../../shared/types/gitGraph'; +import type { GitDiffResult } from './diff'; import type { CreateSessionRequest } from './session'; import type { DetectedProjectConfig } from '../../../shared/types/projectConfig'; import type { CloudVmState } from '../../../shared/types/cloud'; @@ -244,6 +246,10 @@ interface ElectronAPI { detectConfig: (projectId: string) => Promise>; /** Resolve which run script to execute for a session (DB > config files > scripts/pane-run-script.js). Used by PanelTabBar Play button. */ resolveRunScript: (sessionId: string) => Promise>; + /** Repository-wide commit graph across every branch and tag. */ + getGitGraph: (request: RepoGitGraphRequest) => Promise>; + /** Full patch for one commit, resolved against the project checkout. */ + getCommitDetail: (projectId: number, commitHash: string) => Promise>; }; // Git operations diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 2875d3344..2ab501572 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -4,6 +4,7 @@ import type { Project } from '../types/project'; import type { UpdateConfigRequest } from '../types/config'; import type { SessionCreationPreferences } from '../stores/sessionPreferencesStore'; import type { PaneChatAgent, PaneChatState } from '../../../shared/types/paneChat'; +import type { RepoGitGraphRequest } from '../../../shared/types/gitGraph'; import type { RemoteDaemonClientRecord, RemoteDaemonClientSettings, @@ -424,6 +425,18 @@ export class API { if (!isElectron()) throw new Error('Electron API not available'); return window.electronAPI.projects.listBranches(projectId); }, + + /** Repository-wide commit graph across every branch and tag. */ + async getGitGraph(request: RepoGitGraphRequest) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.projects.getGitGraph(request); + }, + + /** Full patch for one commit, resolved against the project checkout. */ + async getCommitDetail(projectId: number, commitHash: string) { + if (!isElectron()) throw new Error('Electron API not available'); + return window.electronAPI.projects.getCommitDetail(projectId, commitHash); + }, }; // Git operations diff --git a/frontend/src/utils/gitGraphLayout.test.ts b/frontend/src/utils/gitGraphLayout.test.ts new file mode 100644 index 000000000..4b2511918 --- /dev/null +++ b/frontend/src/utils/gitGraphLayout.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { computeGitGraphLayout } from './gitGraphLayout'; +import type { GitGraphNode } from '../../../shared/types/gitGraph'; + +function node(hash: string, parents: string[]): GitGraphNode { + return { + hash, + shortHash: hash.slice(0, 7), + parents, + subject: `commit ${hash}`, + authorName: 'Test', + authorEmail: 'test@example.com', + authorDate: '2026-01-01T00:00:00Z', + refs: [], + }; +} + +describe('computeGitGraphLayout', () => { + it('returns an empty layout for no commits', () => { + expect(computeGitGraphLayout([])).toEqual({ rows: [], laneCount: 0 }); + }); + + it('keeps a linear history in a single lane', () => { + const layout = computeGitGraphLayout([ + node('c', ['b']), + node('b', ['a']), + node('a', []), + ]); + + expect(layout.laneCount).toBe(1); + expect(layout.rows.map(row => row.lane)).toEqual([0, 0, 0]); + expect(layout.rows.every(row => row.colorIndex === 0)).toBe(true); + }); + + it('gives a root commit no outgoing edges', () => { + const layout = computeGitGraphLayout([node('a', [])]); + expect(layout.rows[0].edges).toEqual([]); + }); + + it('opens a second lane for a merge and rejoins it', () => { + // m merges feature (f) into main (b); both descend from a. + const layout = computeGitGraphLayout([ + node('m', ['b', 'f']), + node('f', ['a']), + node('b', ['a']), + node('a', []), + ]); + + expect(layout.laneCount).toBe(2); + expect(layout.rows[0].lane).toBe(0); + + const mergeEdge = layout.rows[0].edges.find(edge => edge.kind === 'merge'); + expect(mergeEdge).toBeDefined(); + expect(mergeEdge?.fromLane).toBe(0); + expect(mergeEdge?.toLane).toBe(1); + + // Both sides converge on `a`, which must occupy a single lane — and the + // side that ends there is drawn joining it rather than stopping mid-air. + const rootRow = layout.rows[3]; + expect(rootRow.node.hash).toBe('a'); + expect(rootRow.edges).toEqual([ + { fromLane: 1, toLane: 0, kind: 'join', colorIndex: 1 }, + ]); + }); + + it('handles an octopus merge with three parents', () => { + const layout = computeGitGraphLayout([ + node('m', ['p1', 'p2', 'p3']), + node('p1', []), + node('p2', []), + node('p3', []), + ]); + + const mergeEdges = layout.rows[0].edges.filter(edge => edge.kind === 'merge'); + expect(mergeEdges).toHaveLength(2); + expect(mergeEdges.map(edge => edge.toLane).sort()).toEqual([1, 2]); + expect(layout.laneCount).toBe(3); + }); + + it('places two unrelated roots in separate lanes', () => { + const layout = computeGitGraphLayout([ + node('x', []), + node('y', []), + ]); + + expect(layout.rows[0].lane).toBe(0); + // The first root frees lane 0 immediately, so the second reuses it. + expect(layout.rows[1].lane).toBe(0); + expect(layout.laneCount).toBe(1); + }); + + it('flags edges whose parent falls outside the loaded window', () => { + const layout = computeGitGraphLayout([node('c', ['older-than-limit'])]); + + expect(layout.rows[0].edges).toHaveLength(1); + expect(layout.rows[0].edges[0].danglesBelow).toBe(true); + }); + + it('does not flag edges whose parent is loaded', () => { + const layout = computeGitGraphLayout([node('c', ['b']), node('b', [])]); + expect(layout.rows[0].edges[0].danglesBelow).toBeUndefined(); + }); + + it('carries an unrelated in-flight branch through intervening rows', () => { + // `side` stays pending while the mainline advances, so rows between the + // branch tip and its parent must draw a pass-through edge for it. + const layout = computeGitGraphLayout([ + node('side', ['old']), + node('c', ['b']), + node('b', ['old']), + node('old', []), + ]); + + // The mainline leaves the dot; the unrelated branch crosses the whole row. + expect(layout.rows[1].edges.filter(edge => edge.kind === 'straight')).toHaveLength(1); + expect(layout.rows[1].edges.filter(edge => edge.kind === 'passthrough')).toHaveLength(1); + expect(layout.laneCount).toBeGreaterThanOrEqual(2); + }); + + /** + * A pass-through drawn as `straight` starts at the dot, half a row down, + * which is what left continuing branches looking like dashed lines. + */ + it('spans crossing lanes with pass-through edges, never straight ones', () => { + const layout = computeGitGraphLayout([ + node('side', ['old']), + node('c', ['b']), + node('b', ['old']), + node('old', []), + ]); + + for (const row of layout.rows) { + for (const edge of row.edges) { + // A straight edge always belongs to the row's own commit. + if (edge.kind === 'straight') expect(edge.fromLane).toBe(row.lane); + } + } + }); + + it('marks the newest commit of a branch as its start', () => { + const layout = computeGitGraphLayout([ + node('side', ['old']), + node('c', ['b']), + node('b', ['old']), + node('old', []), + ]); + + // Nothing above points at `side` or at `c`; `b` and `old` are parents. + expect(layout.rows.map(row => row.isBranchTip)).toEqual([true, true, false, false]); + }); + + it('joins a branch that ends at a shared ancestor instead of dropping it', () => { + // Both `a` and `b` have `r` as parent, so one lane must visibly end at it. + const layout = computeGitGraphLayout([ + node('m', ['a', 'b']), + node('a', ['r']), + node('b', ['r']), + node('r', []), + ]); + + const rootRow = layout.rows[3]; + expect(rootRow.node.hash).toBe('r'); + + const joins = rootRow.edges.filter(edge => edge.kind === 'join'); + expect(joins).toHaveLength(1); + expect(joins[0].toLane).toBe(rootRow.lane); + expect(joins[0].fromLane).not.toBe(rootRow.lane); + }); + + it('never assigns a lane index at or beyond laneCount', () => { + const layout = computeGitGraphLayout([ + node('m', ['a', 'b']), + node('a', ['r']), + node('b', ['r']), + node('r', []), + ]); + + for (const row of layout.rows) { + expect(row.lane).toBeLessThan(layout.laneCount); + for (const edge of row.edges) { + expect(edge.fromLane).toBeLessThan(layout.laneCount); + expect(edge.toLane).toBeLessThan(layout.laneCount); + } + } + }); +}); diff --git a/frontend/src/utils/gitGraphLayout.ts b/frontend/src/utils/gitGraphLayout.ts new file mode 100644 index 000000000..15e0a1b1d --- /dev/null +++ b/frontend/src/utils/gitGraphLayout.ts @@ -0,0 +1,146 @@ +import type { + GitGraphEdge, + GitGraphLayout, + GitGraphNode, + GitGraphRow, +} from '../../../shared/types/gitGraph'; + +/** + * Number of distinct lane colours. Lanes map to colours by index so a branch + * keeps the same colour for as long as it occupies the same lane, which is + * what makes the graph readable while scrolling. + */ +export const GRAPH_PALETTE_LENGTH = 8; + +/** + * Assign a lane (column) to every commit and derive the edges to draw between + * consecutive rows — the same job `git log --graph` does with ASCII art, but + * as data. + * + * The algorithm is a single top-to-bottom sweep over a date-ordered commit + * list, maintaining `lanes[i]` = "the hash this lane is currently waiting to + * see". For each row: + * + * 1. The commit takes the leftmost lane reserved for its hash; if no lane is + * waiting for it (a branch tip), it takes the leftmost free lane and the + * row is flagged `isBranchTip` so nothing is drawn above its dot. + * 2. Any *other* lane also waiting for this commit ends here and emits a + * `join` edge into the dot — without it those branches simply vanished + * mid-air. + * 3. Its first parent inherits that same lane — this keeps mainline history + * in a straight vertical line. + * 4. Every additional parent (a merge) reserves the leftmost free lane and + * emits a diagonal `merge` edge. + * 5. Lanes still reserved after that emit `passthrough` edges spanning the + * full row height. They must not be `straight`: a straight edge starts at + * the dot, half a row down, which is what left the vertical lines of + * unrelated branches broken into dashes. + * + * Complexity is O(commits x lanes). The input is not mutated. + */ +export function computeGitGraphLayout(nodes: GitGraphNode[]): GitGraphLayout { + if (nodes.length === 0) return { rows: [], laneCount: 0 }; + + const knownHashes = new Set(nodes.map(node => node.hash)); + // lanes[i] holds the hash lane i is reserved for, or null when free. + const lanes: Array = []; + const rows: GitGraphRow[] = []; + let laneCount = 0; + + const claimLane = (hash: string): number => { + const reserved = lanes.indexOf(hash); + if (reserved >= 0) return reserved; + const free = lanes.indexOf(null); + if (free >= 0) { + lanes[free] = hash; + return free; + } + lanes.push(hash); + return lanes.length - 1; + }; + + for (const node of nodes) { + // Nothing above points at this commit: it is the newest on its branch. + const isBranchTip = lanes.indexOf(node.hash) < 0; + const lane = claimLane(node.hash); + + const edges: GitGraphEdge[] = []; + + // Any other lane also waiting for this commit (two branches converging on + // the same ancestor) ends here, and says so with a join edge. + for (let i = 0; i < lanes.length; i++) { + if (i === lane || lanes[i] !== node.hash) continue; + lanes[i] = null; + edges.push({ + fromLane: i, + toLane: lane, + kind: 'join', + // The ending branch keeps its own colour into the dot, so the eye can + // follow it to where it was absorbed. + colorIndex: i % GRAPH_PALETTE_LENGTH, + }); + } + + lanes[lane] = null; + + const [firstParent, ...otherParents] = node.parents; + + if (firstParent) { + lanes[lane] = firstParent; + edges.push({ + fromLane: lane, + toLane: lane, + kind: 'straight', + colorIndex: lane % GRAPH_PALETTE_LENGTH, + ...(knownHashes.has(firstParent) ? {} : { danglesBelow: true }), + }); + } + + for (const parent of otherParents) { + const parentLane = claimLane(parent); + edges.push({ + fromLane: lane, + toLane: parentLane, + kind: 'merge', + colorIndex: parentLane % GRAPH_PALETTE_LENGTH, + ...(knownHashes.has(parent) ? {} : { danglesBelow: true }), + }); + } + + // Unrelated branches still in flight keep their column through this row, + // top edge to bottom edge — anything shorter leaves a gap. + for (let i = 0; i < lanes.length; i++) { + const reservedFor = lanes[i]; + if (!reservedFor) continue; + if (i === lane && firstParent) continue; + if (edges.some(edge => edge.toLane === i)) continue; + edges.push({ + fromLane: i, + toLane: i, + kind: 'passthrough', + colorIndex: i % GRAPH_PALETTE_LENGTH, + ...(knownHashes.has(reservedFor) ? {} : { danglesBelow: true }), + }); + } + + // Trim trailing free lanes so the gutter does not stay wide after a branch + // ends, while `laneCount` still reflects the widest point of the graph. + while (lanes.length > 0 && lanes[lanes.length - 1] === null) lanes.pop(); + + laneCount = Math.max( + laneCount, + lane + 1, + ...edges.map(edge => Math.max(edge.fromLane, edge.toLane) + 1) + ); + + rows.push({ + node, + lane, + colorIndex: lane % GRAPH_PALETTE_LENGTH, + edges, + isBranchTip, + }); + } + + return { rows, laneCount }; +} diff --git a/frontend/src/utils/parseUnifiedDiff.test.ts b/frontend/src/utils/parseUnifiedDiff.test.ts new file mode 100644 index 000000000..dec8b146e --- /dev/null +++ b/frontend/src/utils/parseUnifiedDiff.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from 'vitest'; +import { parseUnifiedDiffToFiles } from './parseUnifiedDiff'; + +const SAMPLE = `diff --git a/src/a.ts b/src/a.ts +index 111..222 100644 +--- a/src/a.ts ++++ b/src/a.ts +@@ -1,3 +1,4 @@ + context +-removed line ++added line ++another added +diff --git a/src/new.ts b/src/new.ts +new file mode 100644 +index 0000000..333 +--- /dev/null ++++ b/src/new.ts +@@ -0,0 +1,2 @@ ++first ++second +`; + +describe('parseUnifiedDiffToFiles', () => { + it('returns nothing for empty input', () => { + expect(parseUnifiedDiffToFiles('')).toEqual([]); + expect(parseUnifiedDiffToFiles(' ')).toEqual([]); + expect(parseUnifiedDiffToFiles('not a diff')).toEqual([]); + }); + + it('splits one entry per file and classifies the change', () => { + const files = parseUnifiedDiffToFiles(SAMPLE); + expect(files.map(f => f.path)).toEqual(['src/a.ts', 'src/new.ts']); + expect(files[0].type).toBe('modified'); + expect(files[1].type).toBe('added'); + }); + + it('counts changed lines without counting the +++/--- headers', () => { + const files = parseUnifiedDiffToFiles(SAMPLE); + expect(files[0].additions).toBe(2); + expect(files[0].deletions).toBe(1); + expect(files[1].additions).toBe(2); + expect(files[1].deletions).toBe(0); + }); + + it('detects deletions and renames', () => { + const deleted = parseUnifiedDiffToFiles( + 'diff --git a/gone.ts b/gone.ts\ndeleted file mode 100644\n--- a/gone.ts\n+++ /dev/null\n@@ -1 +0,0 @@\n-bye\n' + ); + expect(deleted[0].type).toBe('deleted'); + expect(deleted[0].deletions).toBe(1); + + const renamed = parseUnifiedDiffToFiles( + 'diff --git a/old.ts b/new.ts\nsimilarity index 100%\nrename from old.ts\nrename to new.ts\n' + ); + expect(renamed[0].type).toBe('renamed'); + expect(renamed[0].oldPath).toBe('old.ts'); + expect(renamed[0].path).toBe('new.ts'); + }); + + it('flags binary files', () => { + const files = parseUnifiedDiffToFiles( + 'diff --git a/logo.png b/logo.png\nindex 1..2 100644\nBinary files a/logo.png and b/logo.png differ\n' + ); + expect(files[0].isBinary).toBe(true); + }); + + it('handles a large diff without pathological cost', () => { + // 50k added lines in one file — the shape that made Review crawl. + const body = Array.from({ length: 50_000 }, (_, i) => `+line ${i}`).join('\n'); + const huge = `diff --git a/big.ts b/big.ts\nnew file mode 100644\n--- /dev/null\n+++ b/big.ts\n@@ -0,0 +1,50000 @@\n${body}\n`; + + const started = performance.now(); + const files = parseUnifiedDiffToFiles(huge); + const elapsed = performance.now() - started; + + expect(files).toHaveLength(1); + expect(files[0].additions).toBe(50_000); + // Generous bound: the point is that it is milliseconds, not seconds. + expect(elapsed).toBeLessThan(1000); + }); +}); diff --git a/frontend/src/utils/parseUnifiedDiff.ts b/frontend/src/utils/parseUnifiedDiff.ts new file mode 100644 index 000000000..a8127740a --- /dev/null +++ b/frontend/src/utils/parseUnifiedDiff.ts @@ -0,0 +1,62 @@ +import type { FileDiff } from '../types/diff'; + +/** + * Count added/removed lines without allocating a match array. + * + * `chunk.match(/^\+(?!\+\+)/gm).length` builds an array with one entry per + * changed line — on a 50k-line uncommitted diff that is 50k throwaway strings + * per file, on the UI thread. Scanning line starts directly costs nothing. + */ +function countChangedLines(chunk: string): { additions: number; deletions: number } { + let additions = 0; + let deletions = 0; + let lineStart = 0; + + while (lineStart < chunk.length) { + let lineEnd = chunk.indexOf('\n', lineStart); + if (lineEnd === -1) lineEnd = chunk.length; + + const first = chunk[lineStart]; + if (first === '+') { + // Skip the `+++ b/path` file header. + if (!(chunk[lineStart + 1] === '+' && chunk[lineStart + 2] === '+')) additions++; + } else if (first === '-') { + // Skip the `--- a/path` file header. + if (!(chunk[lineStart + 1] === '-' && chunk[lineStart + 2] === '-')) deletions++; + } + + lineStart = lineEnd + 1; + } + + return { additions, deletions }; +} + +/** + * Split a unified diff into one {@link FileDiff} per `diff --git` chunk. + * + * Single pass, no allocation per hunk: each chunk keeps its raw patch text so + * the renderer can hand it straight to `@git-diff-view/react`. + */ +export function parseUnifiedDiffToFiles(diff: string): FileDiff[] { + if (!diff?.trim()) return []; + + const fileChunks = diff.match(/diff --git[\s\S]*?(?=diff --git|$)/g); + if (!fileChunks) return []; + + return fileChunks.flatMap(chunk => { + const nameMatch = chunk.match(/diff --git a\/(.*?) b\/(.*?)(?:\n|$)/); + if (!nameMatch) return []; + const oldPath = nameMatch[1]; + const newPath = nameMatch[2]; + const isBinary = chunk.includes('Binary files') || chunk.includes('GIT binary patch'); + + let type: FileDiff['type'] = 'modified'; + if (chunk.includes('new file mode')) type = 'added'; + else if (chunk.includes('deleted file mode')) type = 'deleted'; + else if (chunk.includes('rename from') && chunk.includes('rename to')) type = 'renamed'; + + const { additions, deletions } = countChangedLines(chunk); + + return [{ path: newPath || oldPath, oldPath, type, isBinary, additions, deletions, rawDiff: chunk }]; + }); +} diff --git a/main/src/ipc/daemonRegistryBindings.test.ts b/main/src/ipc/daemonRegistryBindings.test.ts index 06adbd52a..e5d0fbb51 100644 --- a/main/src/ipc/daemonRegistryBindings.test.ts +++ b/main/src/ipc/daemonRegistryBindings.test.ts @@ -170,6 +170,8 @@ const GIT_STATUS_CHANNELS = [ 'sessions:get-executions', 'sessions:get-execution-diff', 'sessions:get-git-graph', + 'projects:get-git-graph', + 'projects:get-commit-detail', 'git:file-status', 'sessions:git-diff', 'sessions:get-commit-diff-by-hash', diff --git a/main/src/ipc/git.ts b/main/src/ipc/git.ts index 6c51bc622..c85791ccb 100644 --- a/main/src/ipc/git.ts +++ b/main/src/ipc/git.ts @@ -9,6 +9,8 @@ import { panelEventBus } from '../services/panelEventBus'; import { PanelEventType, PanelEvent } from '../../../shared/types/panels'; import type { Session } from '../types/session'; import type { GitCommit, GitGraphCommit } from '../services/gitDiffManager'; +import { GitGraphManager } from '../services/gitGraphManager'; +import type { RepoGitGraphRequest } from '../../../shared/types/gitGraph'; import { CommandRunner } from '../utils/commandRunner'; import { getShellPath } from '../utils/shellPath'; import { parseWSLPath, validateWSLAvailable } from '../utils/wslUtils'; @@ -53,6 +55,16 @@ interface RawCommitData { } +/** + * Worktree paths from `git worktree list` and from the sessions table can + * disagree on separators and trailing slashes (notably on Windows), so both + * sides are normalised before joining. + */ +function normalizeWorktreePath(path: string | null | undefined): string { + if (!path) return ''; + return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase(); +} + function isValidGitUrl(url: string): boolean { // Accept https://, ssh://, and scp-style git@host:path formats return /^(https?:\/\/[\w./:@-]+|ssh:\/\/[\w./:@-]+|git@[\w.-]+:[\w./-]+)(\.git)?$/.test(url); @@ -68,6 +80,8 @@ const DAEMON_GIT_STATUS_CHANNELS = [ 'sessions:get-executions', 'sessions:get-execution-diff', 'sessions:get-git-graph', + 'projects:get-git-graph', + 'projects:get-commit-detail', 'git:file-status', 'sessions:git-diff', 'sessions:get-commit-diff-by-hash', @@ -113,6 +127,9 @@ export function registerGitHandlers( ): void { const { sessionManager, gitDiffManager, worktreeManager, claudeCodeManager, gitStatusManager } = services; + // Repo-wide graph reads are self-contained; no shared state to register. + const gitGraphManager = new GitGraphManager(); + // Helper function to emit git operation events to all sessions in a project const emitGitOperationToProject = (sessionId: string, eventType: PanelEventType, message: string, details?: JsonObject) => { try { @@ -429,6 +446,85 @@ export function registerGitHandlers( } }); + /** + * Commit graph for one project's repository: every branch and tag of *that* + * repo, not just the commits unique to one session's worktree — and not the + * branches of a second repository that happens to share the clone as a + * remote (see `resolveRemoteScope`). + */ + commandRegistry.register('projects:get-git-graph', async (request: RepoGitGraphRequest) => { + try { + const projectId = Number(request?.projectId); + if (!Number.isFinite(projectId)) { + return { success: false, error: 'A projectId is required' }; + } + + const ctx = sessionManager.getProjectContextByProjectId(projectId); + if (!ctx?.project?.path) { + return { success: false, error: 'Project not found' }; + } + + const { project, commandRunner } = ctx; + + const data = await gitGraphManager.getRepoGraph( + project.path, + { limit: request.limit, remoteScope: request.remoteScope, focusRef: request.focusRef }, + commandRunner, + async () => { + const worktrees = await worktreeManager.listWorktrees(project.path, commandRunner); + const sessions = databaseService.getAllSessions(projectId, { includeHidden: true }); + const sessionByPath = new Map( + sessions + .filter(session => Boolean(session.worktree_path)) + .map(session => [normalizeWorktreePath(session.worktree_path), session]) + ); + + const projectPathKey = normalizeWorktreePath(project.path); + return worktrees.map(worktree => { + const key = normalizeWorktreePath(worktree.path); + const session = sessionByPath.get(key); + return { + path: worktree.path, + branch: worktree.branch, + isMainCheckout: key === projectPathKey, + ...(session ? { sessionId: session.id, sessionName: session.name } : {}), + }; + }); + } + ); + + return { success: true, data }; + } catch (error) { + console.error('Failed to build repository git graph:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to build repository git graph', + }; + } + }); + + /** Full patch for one commit, addressed by project rather than by session. */ + commandRegistry.register('projects:get-commit-detail', async (projectId: number, commitHash: string) => { + try { + const ctx = sessionManager.getProjectContextByProjectId(Number(projectId)); + if (!ctx?.project?.path) { + return { success: false, error: 'Project not found' }; + } + if (!commitHash || !/^[0-9a-fA-F]{4,40}$/.test(commitHash)) { + return { success: false, error: 'A valid commit hash is required' }; + } + + const data = gitDiffManager.getCommitDiff(ctx.project.path, commitHash, ctx.commandRunner); + return { success: true, data }; + } catch (error) { + console.error('Failed to get commit detail:', error); + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to get commit detail', + }; + } + }); + commandRegistry.register('sessions:git-commit', async (sessionId: string, message: string) => { try { const session = await sessionManager.getSession(sessionId); diff --git a/main/src/preload.ts b/main/src/preload.ts index fd872c090..addfe42d2 100644 --- a/main/src/preload.ts +++ b/main/src/preload.ts @@ -604,6 +604,10 @@ contextBridge.exposeInMainWorld('electronAPI', { * Used by `PanelTabBar` to start/stop the dev server for a session. */ resolveRunScript: (sessionId: string): Promise => invokeIpc('projects:resolve-run-script', sessionId), + /** Repository-wide commit graph across every branch and tag. */ + getGitGraph: (request: { projectId: number; limit?: number; remoteScope?: string; focusRef?: string }): Promise => invokeIpc('projects:get-git-graph', request), + /** Full patch for one commit, resolved against the project checkout. */ + getCommitDetail: (projectId: number, commitHash: string): Promise => invokeIpc('projects:get-commit-detail', projectId, commitHash), }, // Git operations diff --git a/main/src/services/gitGraphManager.test.ts b/main/src/services/gitGraphManager.test.ts new file mode 100644 index 000000000..532a1a66a --- /dev/null +++ b/main/src/services/gitGraphManager.test.ts @@ -0,0 +1,379 @@ +import { describe, it, expect, vi } from 'vitest'; +import { + GitGraphManager, + isPlainRefName, + isPlainRemoteName, + parseGraphLog, + parseForEachRef, + resolveRemoteScope, +} from './gitGraphManager'; +import { GRAPH_REMOTE_ALL, GRAPH_REMOTE_NONE } from '../../../shared/types/gitGraph'; +import type { CommandRunner } from '../utils/commandRunner'; + +const REC = '\x01'; +const F = '\x00'; + +function logRecord(fields: string[]): string { + return REC + fields.join(F); +} + +function stubRunner(responses: Array<[match: string, output: string | Error]>): CommandRunner { + const exec = vi.fn((command: string) => { + for (const [match, output] of responses) { + if (command.includes(match)) { + if (output instanceof Error) throw output; + return output; + } + } + return ''; + }); + return { exec } as unknown as CommandRunner; +} + +const noWorktrees = async () => []; + +/** Every command the stub runner was asked to execute, in order. */ +function execCommands(runner: CommandRunner): string[] { + return (runner.exec as unknown as { mock: { calls: string[][] } }).mock.calls.map(call => call[0]); +} + +describe('parseGraphLog', () => { + it('parses commits with parents and metadata', () => { + const raw = + logRecord(['aaa111', 'aaa111'.slice(0, 7), 'bbb222 ccc333', 'merge branches', '2026-01-02T10:00:00Z', 'Ada', 'ada@example.com']) + + logRecord(['bbb222', 'bbb222'.slice(0, 7), '', 'root', '2026-01-01T10:00:00Z', 'Ada', 'ada@example.com']); + + const nodes = parseGraphLog(raw); + + expect(nodes).toHaveLength(2); + expect(nodes[0]).toMatchObject({ + hash: 'aaa111', + parents: ['bbb222', 'ccc333'], + subject: 'merge branches', + authorName: 'Ada', + authorEmail: 'ada@example.com', + }); + expect(nodes[1].parents).toEqual([]); + }); + + it('survives a record separator appearing inside a subject', () => { + // A literal \x01 in the message would otherwise split one commit in two; + // records with no usable hash are dropped rather than corrupting the graph. + const raw = logRecord(['aaa111', 'aaa111', '', `weird${REC}subject`, '2026-01-01T00:00:00Z', 'Ada', 'a@b.c']); + const nodes = parseGraphLog(raw); + expect(nodes[0].hash).toBe('aaa111'); + }); + + it('returns an empty array for empty output', () => { + expect(parseGraphLog('')).toEqual([]); + expect(parseGraphLog(' \n ')).toEqual([]); + }); +}); + +describe('parseForEachRef', () => { + it('classifies heads, remotes and tags and marks the current branch', () => { + const raw = [ + ['commit', 'refs/heads/main', 'aaa', ''].join(F), + ['commit', 'refs/heads/feature', 'bbb', ''].join(F), + ['commit', 'refs/remotes/origin/main', 'aaa', ''].join(F), + ['tag', 'refs/tags/v1.0.0', 'tagobj', 'ccc'].join(F), + ].join('\n'); + + const refs = parseForEachRef(raw, 'main'); + + expect(refs).toEqual([ + { kind: 'localBranch', name: 'main', hash: 'aaa', isCurrent: true }, + { kind: 'localBranch', name: 'feature', hash: 'bbb', isCurrent: false }, + { kind: 'remoteBranch', name: 'origin/main', hash: 'aaa', isCurrent: false }, + // Annotated tag peels to the commit in %(*objectname), not the tag object. + { kind: 'tag', name: 'v1.0.0', hash: 'ccc', isCurrent: false }, + ]); + }); + + it('skips origin/HEAD, which is a symbolic alias', () => { + const raw = ['commit', 'refs/remotes/origin/HEAD', 'aaa', ''].join(F); + expect(parseForEachRef(raw, null)).toEqual([]); + }); +}); + +describe('GitGraphManager.getRepoGraph', () => { + it('builds a graph and attaches refs to their commits', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ['commit', 'refs/heads/main', 'aaa', ''].join(F)], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/repo', {}, runner, noWorktrees); + + expect(graph.currentBranch).toBe('main'); + expect(graph.nodes).toHaveLength(1); + expect(graph.nodes[0].refs).toEqual([ + { kind: 'localBranch', name: 'main', hash: 'aaa', isCurrent: true }, + ]); + expect(graph.truncated).toBe(false); + }); + + it('reports a detached HEAD as its own ref', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', new Error('fatal: ref HEAD is not a symbolic ref')], + ['for-each-ref', ''], + ['rev-parse HEAD', 'aaa\n'], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/repo', {}, runner, noWorktrees); + + expect(graph.currentBranch).toBeNull(); + expect(graph.refs).toContainEqual({ kind: 'head', name: 'HEAD', hash: 'aaa', isCurrent: true }); + }); + + it('flags truncation when more commits exist than the limit', async () => { + const log = Array.from({ length: 4 }, (_, i) => + logRecord([`h${i}`, `h${i}`, '', `c${i}`, '2026-01-01T00:00:00Z', 'Ada', 'a@b.c']) + ).join(''); + const runner = stubRunner([ + ['git log', log], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/repo', { limit: 3 }, runner, noWorktrees); + + expect(graph.nodes).toHaveLength(3); + expect(graph.truncated).toBe(true); + expect(graph.limit).toBe(3); + }); + + it('returns an empty graph with a notice for a repo with no commits', async () => { + const runner = stubRunner([ + ['git log', new Error('fatal: your current branch does not have any commits yet')], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/repo', {}, runner, noWorktrees); + + expect(graph.nodes).toEqual([]); + expect(graph.notice).toMatch(/no commits/i); + }); + + it('returns an empty graph with a notice for a non-repository path', async () => { + const runner = stubRunner([ + ['git log', new Error('fatal: not a git repository (or any of the parent directories): .git')], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/tmp/plain-dir', {}, runner, noWorktrees); + + expect(graph.nodes).toEqual([]); + expect(graph.notice).toMatch(/not a git repository/i); + }); + + it('still returns the graph when worktree resolution fails', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/repo', {}, runner, async () => { + throw new Error('worktree list failed'); + }); + + expect(graph.nodes).toHaveLength(1); + expect(graph.paneWorktrees).toEqual([]); + }); + + it('omits remote refs entirely for the local-only scope', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ['git remote', 'origin\nupstream\n'], + ]); + + await new GitGraphManager().getRepoGraph( + '/repo', + { remoteScope: GRAPH_REMOTE_NONE }, + runner, + noWorktrees + ); + + const commands = execCommands(runner); + expect(commands.some(cmd => cmd.includes('git log') && cmd.includes('--branches --tags'))).toBe(true); + expect(commands.some(cmd => cmd.includes('for-each-ref') && cmd.includes('refs/remotes'))).toBe(false); + }); + + /** + * The reason this scoping exists: a fork's clone carries `origin` *and* + * `upstream`, and `--all` graphed the other repository's branches alongside + * this one's. + */ + it('graphs only the chosen remote, not every remote in the clone', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ['git remote', 'origin\nupstream\n'], + ]); + + const graph = await new GitGraphManager().getRepoGraph('/repo', {}, runner, noWorktrees); + + expect(graph.remotes).toEqual(['origin', 'upstream']); + expect(graph.remoteScope).toBe('origin'); + + const commands = execCommands(runner); + expect(commands.some(cmd => cmd.includes('git log') && cmd.includes('--glob=refs/remotes/origin'))).toBe(true); + expect(commands.some(cmd => cmd.includes('git log') && cmd.includes('--all'))).toBe(false); + expect(commands.some(cmd => cmd.includes('for-each-ref') && cmd.includes('refs/remotes/origin'))).toBe(true); + }); + + it('takes every remote only when asked', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ['git remote', 'origin\nupstream\n'], + ]); + + const graph = await new GitGraphManager().getRepoGraph( + '/repo', + { remoteScope: GRAPH_REMOTE_ALL }, + runner, + noWorktrees + ); + + expect(graph.remoteScope).toBe(GRAPH_REMOTE_ALL); + expect(execCommands(runner).some(cmd => cmd.includes('git log') && cmd.includes('--all'))).toBe(true); + }); +}); + +describe('GitGraphManager focus and divergence', () => { + it('narrows history to one ref when asked to focus it', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ['git remote', 'origin\n'], + ]); + + const graph = await new GitGraphManager().getRepoGraph( + '/repo', + { focusRef: 'feature/x' }, + runner, + noWorktrees + ); + + expect(graph.focusRef).toBe('feature/x'); + const logCmd = execCommands(runner).find(cmd => cmd.includes('git log')) ?? ''; + expect(logCmd).toContain('git log feature/x'); + expect(logCmd).not.toContain('--branches'); + }); + + it('ignores a focus ref that could reach the shell', async () => { + const runner = stubRunner([ + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ['git remote', ''], + ]); + + const graph = await new GitGraphManager().getRepoGraph( + '/repo', + { focusRef: 'main; rm -rf /' }, + runner, + noWorktrees + ); + + expect(graph.focusRef).toBeUndefined(); + expect(execCommands(runner).some(cmd => cmd.includes('rm -rf'))).toBe(false); + }); + + it('falls back to the plain ref format when git has no ahead-behind', async () => { + let attempt = 0; + const exec = vi.fn((command: string) => { + if (command.includes('for-each-ref')) { + attempt += 1; + // git < 2.41 rejects the whole command rather than the one atom. + if (command.includes('ahead-behind')) throw new Error("fatal: unknown field name: 'ahead-behind:HEAD'"); + return ['commit', 'refs/heads/main', 'aaa', ''].join(F); + } + if (command.includes('git log')) { + return logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c']); + } + if (command.includes('symbolic-ref')) return 'main\n'; + return ''; + }); + const runner = { exec } as unknown as CommandRunner; + + const graph = await new GitGraphManager().getRepoGraph('/repo', {}, runner, noWorktrees); + + expect(attempt).toBe(2); + expect(graph.refs).toHaveLength(1); + expect(graph.refs[0].ahead).toBeUndefined(); + }); +}); + +describe('parseForEachRef divergence', () => { + it('reads ahead and behind counts when git reports them', () => { + const raw = ['commit', 'refs/heads/feature', 'bbb', '', '3 1'].join(F); + expect(parseForEachRef(raw, 'main')[0]).toMatchObject({ ahead: 3, behind: 1 }); + }); + + it('leaves them absent when the field is empty', () => { + const raw = ['commit', 'refs/heads/feature', 'bbb', '', ''].join(F); + const ref = parseForEachRef(raw, 'main')[0]; + expect(ref.ahead).toBeUndefined(); + expect(ref.behind).toBeUndefined(); + }); +}); + +describe('isPlainRefName', () => { + it('accepts branch and remote-branch names', () => { + expect(isPlainRefName('main')).toBe(true); + expect(isPlainRefName('origin/main')).toBe(true); + expect(isPlainRefName('feature/agent-api')).toBe(true); + expect(isPlainRefName('v2.4.45')).toBe(true); + }); + + it('rejects options, ranges and shell metacharacters', () => { + expect(isPlainRefName('--all')).toBe(false); + expect(isPlainRefName('main..dev')).toBe(false); + expect(isPlainRefName('main; echo hi')).toBe(false); + expect(isPlainRefName('$(whoami)')).toBe(false); + }); +}); + +describe('resolveRemoteScope', () => { + it('prefers origin — the remote a project was cloned from', () => { + expect(resolveRemoteScope(undefined, ['upstream', 'origin'])).toBe('origin'); + }); + + it('falls back to the first remote when there is no origin', () => { + expect(resolveRemoteScope(undefined, ['fork', 'upstream'])).toBe('fork'); + }); + + it('is local-only for a repository with no remotes', () => { + expect(resolveRemoteScope(undefined, [])).toBe(GRAPH_REMOTE_NONE); + }); + + it('keeps the explicit sentinels', () => { + expect(resolveRemoteScope(GRAPH_REMOTE_ALL, ['origin'])).toBe(GRAPH_REMOTE_ALL); + expect(resolveRemoteScope(GRAPH_REMOTE_NONE, ['origin'])).toBe(GRAPH_REMOTE_NONE); + }); + + it('ignores a remote this repository does not have', () => { + expect(resolveRemoteScope('ghost', ['origin'])).toBe('origin'); + }); +}); + +describe('isPlainRemoteName', () => { + it('accepts ordinary remote names', () => { + expect(isPlainRemoteName('origin')).toBe(true); + expect(isPlainRemoteName('my-fork.2')).toBe(true); + }); + + it('rejects anything that could reach the shell or a glob', () => { + expect(isPlainRemoteName('a b')).toBe(false); + expect(isPlainRemoteName('origin;rm -rf /')).toBe(false); + expect(isPlainRemoteName('*')).toBe(false); + }); +}); diff --git a/main/src/services/gitGraphManager.ts b/main/src/services/gitGraphManager.ts new file mode 100644 index 000000000..e2f24b90a --- /dev/null +++ b/main/src/services/gitGraphManager.ts @@ -0,0 +1,309 @@ +import type { Logger } from '../utils/logger'; +import type { CommandRunner } from '../utils/commandRunner'; +import { + DEFAULT_GRAPH_LIMIT, + GRAPH_REMOTE_ALL, + GRAPH_REMOTE_NONE, + MAX_GRAPH_LIMIT, + MAX_GRAPH_REFS, + type GitGraphNode, + type GitRef, + type PaneWorktreeRef, + type RepoGitGraph, +} from '../../../shared/types/gitGraph'; + +/** + * Record and field delimiters for `git log --format`. + * + * `%x01` separates commits and `%x00` separates fields, because both are + * impossible in a ref name and vanishingly unlikely in a commit subject. The + * same trick is used by `GitDiffManager.getGraphCommitHistory`. + */ +const RECORD_SEP = '\x01'; +const FIELD_SEP = '\x00'; +const LOG_FORMAT = '%x01%H%x00%h%x00%P%x00%s%x00%aI%x00%an%x00%ae'; + +/** Git messages that mean "valid repo, just nothing to show". */ +function isEmptyRepoError(message: string): boolean { + return /does not have any commits yet|bad default revision|unknown revision/i.test(message); +} + +function isNotARepoError(message: string): boolean { + return /not a git repository/i.test(message); +} + +/** + * Parse `git log --format=LOG_FORMAT`. Commit subjects may contain newlines + * after `%s` only in pathological cases, so records are split on RECORD_SEP + * rather than by line. + */ +export function parseGraphLog(raw: string): GitGraphNode[] { + if (!raw.trim()) return []; + + return raw + .split(RECORD_SEP) + .filter(record => record.trim().length > 0) + .map(record => { + const [hash, shortHash, parentStr, subject, authorDate, authorName, authorEmail] = + record.replace(/\n$/, '').split(FIELD_SEP); + + return { + hash: (hash ?? '').trim(), + shortHash: shortHash ?? '', + parents: parentStr ? parentStr.trim().split(' ').filter(Boolean) : [], + subject: subject ?? '', + authorDate: authorDate ?? '', + authorName: authorName ?? '', + authorEmail: (authorEmail ?? '').replace(/\n[\s\S]*$/, ''), + refs: [], + } satisfies GitGraphNode; + }) + .filter(node => node.hash.length > 0); +} + +/** + * Parse `git for-each-ref --format='%(objecttype)%00%(refname)%00%(objectname)%00%(*objectname)'`. + * + * `%(*objectname)` is non-empty only for annotated tags, where it holds the + * commit the tag peels to — that is the hash the graph must key on. + */ +export function parseForEachRef(raw: string, currentBranch: string | null): GitRef[] { + const refs: GitRef[] = []; + + for (const line of raw.split('\n')) { + if (!line.trim()) continue; + const [, refName, objectName, peeled, aheadBehind] = line.split(FIELD_SEP); + if (!refName || !objectName) continue; + + const hash = peeled && peeled.trim() ? peeled.trim() : objectName.trim(); + // `%(ahead-behind:HEAD)` prints "3 1"; older git prints nothing at all. + const [ahead, behind] = (aheadBehind ?? '').trim().split(/\s+/).map(Number); + const divergence = Number.isFinite(ahead) && Number.isFinite(behind) + ? { ahead, behind } + : {}; + + if (refName.startsWith('refs/heads/')) { + const name = refName.slice('refs/heads/'.length); + refs.push({ kind: 'localBranch', name, hash, isCurrent: name === currentBranch, ...divergence }); + } else if (refName.startsWith('refs/remotes/')) { + const name = refName.slice('refs/remotes/'.length); + // `origin/HEAD` is a symbolic alias, not a branch anyone wants to see. + if (name.endsWith('/HEAD')) continue; + refs.push({ kind: 'remoteBranch', name, hash, isCurrent: false, ...divergence }); + } else if (refName.startsWith('refs/tags/')) { + refs.push({ kind: 'tag', name: refName.slice('refs/tags/'.length), hash, isCurrent: false }); + } + } + + return refs; +} + +/** A remote name git would accept — no whitespace, no glob, no ref magic. */ +export function isPlainRemoteName(value: string): boolean { + return /^[A-Za-z0-9._-]+$/.test(value); +} + +/** + * A ref name safe to interpolate into a command. + * + * Slashes are legal (`origin/main`, `feature/x`), a leading dash is not — it + * would be read as an option — and neither is anything the shell reacts to. + */ +export function isPlainRefName(value: string): boolean { + return /^[A-Za-z0-9._][A-Za-z0-9._/-]*$/.test(value) && !value.includes('..'); +} + +/** + * Decide which refs the graph covers. + * + * A fork's clone holds `origin` *and* `upstream`, and those are two different + * repositories as far as the user is concerned — graphing `--all` mixed a + * hundred `upstream/*` branches into what should be one project's history. + * The default is therefore the repo's own remote, with everything else a + * deliberate choice. + */ +export function resolveRemoteScope(requested: string | undefined, remotes: string[]): string { + if (requested === GRAPH_REMOTE_NONE || requested === GRAPH_REMOTE_ALL) return requested; + if (requested && remotes.includes(requested)) return requested; + // Unknown or absent: prefer origin, then whatever came first, then local only. + if (remotes.includes('origin')) return 'origin'; + return remotes[0] ?? GRAPH_REMOTE_NONE; +} + +export class GitGraphManager { + constructor(private logger?: Logger) {} + + /** + * Build a repository-wide commit graph: every branch and tag, ordered by + * date, with Pane's own worktrees resolved so the UI can highlight them. + * + * Returns an empty graph (never throws) for empty repos, non-repos and + * detached HEADs — those are ordinary states the view renders as-is. + */ + async getRepoGraph( + projectPath: string, + options: { limit?: number; remoteScope?: string; focusRef?: string }, + commandRunner: CommandRunner, + resolveWorktrees: () => Promise + ): Promise { + const limit = Math.min(Math.max(options.limit ?? DEFAULT_GRAPH_LIMIT, 1), MAX_GRAPH_LIMIT); + const remotes = this.getRemotes(projectPath, commandRunner); + const remoteScope = resolveRemoteScope(options.remoteScope, remotes); + // A focused ref replaces the ref set entirely: "just this branch's history". + const focusRef = options.focusRef && isPlainRefName(options.focusRef) + ? options.focusRef + : undefined; + + const empty: RepoGitGraph = { + nodes: [], + refs: [], + currentBranch: null, + paneWorktrees: [], + truncated: false, + limit, + remotes, + remoteScope, + ...(focusRef ? { focusRef } : {}), + }; + + let nodes: GitGraphNode[]; + // Asking for one extra commit is how we detect that history continues past + // the window. + const refScope = focusRef + ? focusRef + : remoteScope === GRAPH_REMOTE_ALL + ? '--all' + : remoteScope === GRAPH_REMOTE_NONE + ? '--branches --tags' + : `--branches --tags --glob=refs/remotes/${remoteScope}`; + const logCommand = `git log ${refScope} --date-order --format="${LOG_FORMAT}" -n ${limit + 1}`; + + try { + nodes = parseGraphLog(commandRunner.exec(logCommand, projectPath)); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (isNotARepoError(message)) { + return { ...empty, notice: 'This project directory is not a git repository.' }; + } + if (isEmptyRepoError(message)) { + return { ...empty, notice: 'This repository has no commits yet.' }; + } + this.logger?.error('Failed to read repository graph log', error instanceof Error ? error : undefined); + throw error; + } + + const truncated = nodes.length > limit; + if (truncated) nodes = nodes.slice(0, limit); + + if (nodes.length === 0) { + return { ...empty, notice: 'This repository has no commits yet.' }; + } + + const currentBranch = this.getCurrentBranch(projectPath, commandRunner); + const { refs, refsTruncated } = this.getRefs(projectPath, currentBranch, remoteScope, commandRunner); + + // A detached HEAD has no branch ref, so surface it as its own marker. + if (!currentBranch) { + const headHash = this.getHeadHash(projectPath, commandRunner); + if (headHash) refs.push({ kind: 'head', name: 'HEAD', hash: headHash, isCurrent: true }); + } + + const refsByHash = new Map(); + for (const ref of refs) { + const bucket = refsByHash.get(ref.hash); + if (bucket) bucket.push(ref); + else refsByHash.set(ref.hash, [ref]); + } + for (const node of nodes) { + node.refs = refsByHash.get(node.hash) ?? []; + } + + let paneWorktrees: PaneWorktreeRef[] = []; + try { + paneWorktrees = await resolveWorktrees(); + } catch (error) { + this.logger?.verbose( + `Could not resolve worktrees for ${projectPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + + return { + nodes, + refs, + currentBranch, + paneWorktrees, + truncated, + limit, + remotes, + remoteScope, + ...(focusRef ? { focusRef } : {}), + ...(refsTruncated ? { notice: `Showing the first ${MAX_GRAPH_REFS} refs.` } : {}), + }; + } + + /** Remotes configured in this clone; empty for a repo with none. */ + private getRemotes(projectPath: string, commandRunner: CommandRunner): string[] { + try { + return commandRunner.exec('git remote', projectPath) + .split('\n') + .map(line => line.trim()) + .filter(name => name.length > 0 && isPlainRemoteName(name)); + } catch { + // Not a repo, or no remotes — both are just "nothing to offer". + return []; + } + } + + private getCurrentBranch(projectPath: string, commandRunner: CommandRunner): string | null { + try { + const output = commandRunner.exec('git symbolic-ref --short -q HEAD', projectPath).trim(); + return output || null; + } catch { + // Non-zero exit means a detached HEAD, which is not an error here. + return null; + } + } + + private getHeadHash(projectPath: string, commandRunner: CommandRunner): string | null { + try { + return commandRunner.exec('git rev-parse HEAD', projectPath).trim() || null; + } catch { + return null; + } + } + + private getRefs( + projectPath: string, + currentBranch: string | null, + remoteScope: string, + commandRunner: CommandRunner + ): { refs: GitRef[]; refsTruncated: boolean } { + const scopes = remoteScope === GRAPH_REMOTE_ALL + ? 'refs/heads refs/remotes refs/tags' + : remoteScope === GRAPH_REMOTE_NONE + ? 'refs/heads refs/tags' + : `refs/heads refs/tags refs/remotes/${remoteScope}`; + const baseFormat = '%(objecttype)%00%(refname)%00%(objectname)%00%(*objectname)'; + // `%(ahead-behind:)` needs git 2.41; older versions fail the whole command, + // so the divergence numbers are attempted first and silently dropped. + const formats = [`${baseFormat}%00%(ahead-behind:HEAD)`, baseFormat]; + + for (const format of formats) { + try { + const raw = commandRunner.exec( + `git for-each-ref --count=${MAX_GRAPH_REFS + 1} --format="${format}" ${scopes}`, + projectPath + ); + const parsed = parseForEachRef(raw, currentBranch); + const refsTruncated = parsed.length > MAX_GRAPH_REFS; + return { refs: refsTruncated ? parsed.slice(0, MAX_GRAPH_REFS) : parsed, refsTruncated }; + } catch (error) { + this.logger?.verbose( + `Could not list refs for ${projectPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } + } + + return { refs: [], refsTruncated: false }; + } +} diff --git a/shared/types/gitGraph.ts b/shared/types/gitGraph.ts new file mode 100644 index 000000000..3146de0ae --- /dev/null +++ b/shared/types/gitGraph.ts @@ -0,0 +1,152 @@ +/** + * Wire and layout types for the repository-wide commit graph. + * + * The backend returns a flat, ordered node list plus refs; lane assignment is + * done client-side by a pure solver so it stays unit-testable and does not + * depend on `git log --graph`'s version-specific ASCII art. + */ + +export type GitRefKind = 'localBranch' | 'remoteBranch' | 'tag' | 'head'; + +export interface GitRef { + kind: GitRefKind; + /** Short name: `main`, `origin/main`, `v1.2.0`. */ + name: string; + /** Commit the ref resolves to (peeled, for annotated tags). */ + hash: string; + /** True for the branch HEAD currently points at in the main checkout. */ + isCurrent: boolean; + /** + * Commits this ref has that HEAD does not, and vice versa. Absent when git + * is too old for `%(ahead-behind:)` (< 2.41) or the ref is HEAD itself. + */ + ahead?: number; + behind?: number; +} + +export interface GitGraphNode { + hash: string; + shortHash: string; + parents: string[]; + subject: string; + authorName: string; + authorEmail: string; + /** ISO-8601 author date. */ + authorDate: string; + refs: GitRef[]; +} + +export interface PaneWorktreeRef { + /** Absolute worktree path as git reports it. */ + path: string; + branch: string; + sessionId?: string; + sessionName?: string; + /** True for the project's own checkout rather than a session worktree. */ + isMainCheckout: boolean; +} + +export interface RepoGitGraph { + nodes: GitGraphNode[]; + refs: GitRef[]; + /** null on a detached HEAD. */ + currentBranch: string | null; + paneWorktrees: PaneWorktreeRef[]; + /** True when `limit` was hit — more history exists further back. */ + truncated: boolean; + limit: number; + /** Remotes configured in this clone, in `git remote` order. */ + remotes: string[]; + /** The scope actually applied — see {@link RepoGitGraphRequest.remoteScope}. */ + remoteScope: string; + /** The ref the history was narrowed to, when one was requested. */ + focusRef?: string; + /** Non-fatal problems worth surfacing (no commits yet, ref cap hit, …). */ + notice?: string; +} + +/** + * "Local only" — heads and tags, nothing from a remote. + * + * Not a legal remote name (git rejects the empty string), so it can never + * collide with one. + */ +export const GRAPH_REMOTE_NONE = ''; +/** + * Every remote at once. + * + * A fork's clone usually carries both `origin` and `upstream`, and those are + * two different repositories on the hosting side. Graphing them together is + * occasionally useful and confusing by default, hence the explicit opt-in. + * `*` is not a legal remote name either. + */ +export const GRAPH_REMOTE_ALL = '*'; + +export interface RepoGitGraphRequest { + projectId: number; + /** Commits to load. Defaults to {@link DEFAULT_GRAPH_LIMIT}. */ + limit?: number; + /** + * Which remote's branches to include: {@link GRAPH_REMOTE_NONE}, + * {@link GRAPH_REMOTE_ALL}, or a remote name. Defaults to `origin` when the + * repository has one, otherwise its first remote, otherwise local only. + */ + remoteScope?: string; + /** + * Narrow the history to one ref's ancestry — "show me just this branch". + * Ignored when the name is not a ref this repository has. + */ + focusRef?: string; +} + +export const DEFAULT_GRAPH_LIMIT = 300; +export const MAX_GRAPH_LIMIT = 2000; +export const MAX_GRAPH_REFS = 2000; + +// --- Client-side layout --- + +/** + * Which part of a row's band an edge occupies. Rows are drawn independently, + * so each edge has to say where it starts and ends vertically or the lines + * come out as dashes. + * + * - `straight` — this commit to its first parent: dot → bottom edge. + * - `merge` — this commit to a further parent in another lane: dot → bottom. + * - `passthrough` — a branch that neither starts nor ends here, crossing the + * whole row: top edge → bottom edge. + * - `join` — a lane above that ends at this commit: top edge → dot. + */ +export type GitGraphEdgeKind = 'straight' | 'merge' | 'passthrough' | 'join'; + +export interface GitGraphEdge { + fromLane: number; + toLane: number; + kind: GitGraphEdgeKind; + /** Stable palette index derived from the lane the edge belongs to. */ + colorIndex: number; + /** + * True when the edge leaves the loaded window (its parent is older than + * `limit`), so the renderer can fade it out instead of drawing a dead end. + */ + danglesBelow?: boolean; +} + +export interface GitGraphRow { + node: GitGraphNode; + /** 0-based lane (column) holding this commit's dot. */ + lane: number; + colorIndex: number; + /** Edges drawn in this row's band, including pass-throughs. */ + edges: GitGraphEdge[]; + /** + * True when no lane above was waiting for this commit — the newest commit on + * its branch. Nothing connects to it from above, and the renderer marks the + * start rather than drawing a line out of nowhere. + */ + isBranchTip: boolean; +} + +export interface GitGraphLayout { + rows: GitGraphRow[]; + laneCount: number; +} From 68121f1363a84009b9d11ab974a92a24240865d5 Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Sun, 23 Aug 2026 12:32:01 -0700 Subject: [PATCH 2/5] fix(review): repair commit graph wiring and scope --- frontend/src/components/SessionView.tsx | 2 +- frontend/src/components/Sidebar.tsx | 16 +++++++++++++++- main/src/ipc/git.ts | 12 ++++++++++-- main/src/services/gitGraphManager.test.ts | 22 ++++++++++++++++++++++ main/src/services/gitGraphManager.ts | 19 ++++++++++++++++++- 5 files changed, 66 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/SessionView.tsx b/frontend/src/components/SessionView.tsx index d25c88cc9..512d7ed6b 100644 --- a/frontend/src/components/SessionView.tsx +++ b/frontend/src/components/SessionView.tsx @@ -1618,7 +1618,7 @@ export const SessionView = memo(() => { // Repository-wide commit graph — project-scoped, not session-scoped. if (activeView === 'git-graph' && activeProjectId) { - return ; + return ; } // Show project view if navigation is set to project diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index af6552040..35c80e6b9 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'; import { CreateSessionDialog } from './CreateSessionDialog'; import { ProjectSessionList, ArchivedSessions } from './ProjectSessionList'; import { ArchiveProgress } from './ArchiveProgress'; -import { Archive, ArrowUpDown, ChevronDown, ChevronRight, Cpu, FolderGit2, Home, Monitor, MoreHorizontal, PanelLeftClose, PanelLeftOpen, Pin, Settings as SettingsIcon, Plus, RefreshCw, MessageSquare, SquareTerminal } from 'lucide-react'; +import { Archive, ArrowUpDown, ChevronDown, ChevronRight, Cpu, FolderGit2, Home, Monitor, MoreHorizontal, Network, PanelLeftClose, PanelLeftOpen, Pin, Settings as SettingsIcon, Plus, RefreshCw, MessageSquare, SquareTerminal } from 'lucide-react'; import { SessionDetailTooltip } from './SessionDetailTooltip'; import { usePaneLogo } from '../hooks/usePaneLogo'; import { IconButton } from './ui/Button'; @@ -388,6 +388,7 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick, const activeView = useNavigationStore((state) => state.activeView); const expandedProjects = useNavigationStore((state) => state.expandedProjects); const navigateToProject = useNavigationStore((state) => state.navigateToProject); + const navigateToGitGraph = useNavigationStore((state) => state.navigateToGitGraph); const navigateToSessions = useNavigationStore((state) => state.navigateToSessions); const navigateToPaneChat = useNavigationStore((state) => state.navigateToPaneChat); const paneChatStatus = useSessionAgentDisplayStatus(PANE_CHAT_SESSION_ID); @@ -645,6 +646,19 @@ export function Sidebar({ onAboutClick, onSettingsClick, onRemoteSettingsClick, + + + + {expandedProjects.has(project.id) && projectSessions.map((session) => ( { describe('GitGraphManager focus and divergence', () => { it('narrows history to one ref when asked to focus it', async () => { const runner = stubRunner([ + ['rev-parse --verify', 'aaa\n'], ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], ['symbolic-ref', 'main\n'], ['for-each-ref', ''], @@ -268,6 +269,27 @@ describe('GitGraphManager focus and divergence', () => { expect(logCmd).not.toContain('--branches'); }); + it('ignores a well-formed focus ref that does not resolve in this repository', async () => { + const runner = stubRunner([ + ['rev-parse --verify', new Error('unknown revision')], + ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], + ['symbolic-ref', 'main\n'], + ['for-each-ref', ''], + ]); + + const graph = await new GitGraphManager().getRepoGraph( + '/repo', + { focusRef: 'feature/from-another-repo' }, + runner, + noWorktrees + ); + + expect(graph.focusRef).toBeUndefined(); + const logCmd = execCommands(runner).find(cmd => cmd.includes('git log')) ?? ''; + expect(logCmd).toContain('--branches --tags'); + expect(logCmd).not.toContain('feature/from-another-repo'); + }); + it('ignores a focus ref that could reach the shell', async () => { const runner = stubRunner([ ['git log', logRecord(['aaa', 'aaa', '', 'first', '2026-01-01T00:00:00Z', 'Ada', 'a@b.c'])], diff --git a/main/src/services/gitGraphManager.ts b/main/src/services/gitGraphManager.ts index e2f24b90a..88f8b6eaa 100644 --- a/main/src/services/gitGraphManager.ts +++ b/main/src/services/gitGraphManager.ts @@ -150,7 +150,9 @@ export class GitGraphManager { const remotes = this.getRemotes(projectPath, commandRunner); const remoteScope = resolveRemoteScope(options.remoteScope, remotes); // A focused ref replaces the ref set entirely: "just this branch's history". - const focusRef = options.focusRef && isPlainRefName(options.focusRef) + const focusRef = options.focusRef + && isPlainRefName(options.focusRef) + && this.isResolvableCommit(projectPath, options.focusRef, commandRunner) ? options.focusRef : undefined; @@ -272,6 +274,21 @@ export class GitGraphManager { } } + private isResolvableCommit( + projectPath: string, + refName: string, + commandRunner: CommandRunner + ): boolean { + try { + return commandRunner.exec( + `git rev-parse --verify --quiet "${refName}^{commit}"`, + projectPath + ).trim().length > 0; + } catch { + return false; + } + } + private getRefs( projectPath: string, currentBranch: string | null, From 3e5d8c6743b67a7e6635b16f613c562fb4c90fbc Mon Sep 17 00:00:00 2001 From: parsakhaz Date: Sun, 23 Aug 2026 12:46:32 -0700 Subject: [PATCH 3/5] refactor(simplify): tighten commit graph contracts --- .../components/gitGraph/GitGraphCanvas.tsx | 2 - .../gitGraph/GitGraphCommitDetail.tsx | 4 +- .../src/components/gitGraph/GitGraphView.tsx | 45 ++++++++++--------- frontend/src/utils/gitGraphLayout.ts | 23 +++++----- frontend/src/utils/parseUnifiedDiff.ts | 2 +- main/src/ipc/git.ts | 10 +++-- main/src/preload.ts | 3 +- main/src/services/gitGraphManager.test.ts | 28 +++++++----- main/src/services/gitGraphManager.ts | 25 ++++++----- 9 files changed, 79 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/gitGraph/GitGraphCanvas.tsx b/frontend/src/components/gitGraph/GitGraphCanvas.tsx index 113a8aedc..cba02f09d 100644 --- a/frontend/src/components/gitGraph/GitGraphCanvas.tsx +++ b/frontend/src/components/gitGraph/GitGraphCanvas.tsx @@ -101,5 +101,3 @@ export const GitGraphCanvas = memo(function GitGraphCanvas({ row, laneCount, isS ); }); - -export default GitGraphCanvas; diff --git a/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx b/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx index 502ab1516..59132a9ca 100644 --- a/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx +++ b/frontend/src/components/gitGraph/GitGraphCommitDetail.tsx @@ -37,7 +37,7 @@ export function GitGraphCommitDetail({ projectId, node }: GitGraphCommitDetailPr } setDiff(response.data); }) - .catch((err: unknown) => { + .catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load commit'); }) .finally(() => { @@ -121,5 +121,3 @@ export function GitGraphCommitDetail({ projectId, node }: GitGraphCommitDetailPr
); } - -export default GitGraphCommitDetail; diff --git a/frontend/src/components/gitGraph/GitGraphView.tsx b/frontend/src/components/gitGraph/GitGraphView.tsx index fb5952050..bbb85afc6 100644 --- a/frontend/src/components/gitGraph/GitGraphView.tsx +++ b/frontend/src/components/gitGraph/GitGraphView.tsx @@ -13,9 +13,11 @@ import { GitGraphCommitDetail } from './GitGraphCommitDetail'; import { GRAPH_REMOTE_ALL, GRAPH_REMOTE_NONE, + MAX_GRAPH_LIMIT, type GitGraphNode, type PaneWorktreeRef, type RepoGitGraph, + type RepoGitGraphRequest, } from '../../../../shared/types/gitGraph'; /** Rows rendered outside the viewport on each side, to hide scroll seams. */ @@ -26,8 +28,6 @@ const VIRTUALIZE_THRESHOLD = 200; const REFRESH_DEBOUNCE_MS = 2000; const LIMIT_OPTIONS = [100, 300, 1000] as const; -/** Hard ceiling the backend enforces; "load more" stops here. */ -const MAX_LIMIT = 2000; /** * Ref chips shown inline before a subject. Past this many the row is all * chips and no commit message — the rest collapse into a "+N" pill. @@ -142,6 +142,12 @@ function RefChip({ ); } +type SessionWorktree = PaneWorktreeRef & { sessionId: string }; + +function hasSessionId(worktree: PaneWorktreeRef | undefined): worktree is SessionWorktree { + return worktree?.sessionId !== undefined; +} + export interface GitGraphViewProps { projectId: number; projectName?: string; @@ -190,12 +196,10 @@ export function GitGraphView({ projectId, projectName }: GitGraphViewProps) { else setRefreshing(true); try { - const response = await API.projects.getGitGraph({ - projectId, - limit, - ...(remoteScope === undefined ? {} : { remoteScope }), - ...(focusRef ? { focusRef } : {}), - }); + const request: RepoGitGraphRequest = { projectId, limit }; + if (remoteScope !== undefined) request.remoteScope = remoteScope; + if (focusRef) request.focusRef = focusRef; + const response = await API.projects.getGitGraph(request); if (requestId !== requestIdRef.current) return; if (!response.success || !response.data) { throw new Error(response.error || 'Failed to load repository graph'); @@ -347,7 +351,7 @@ export function GitGraphView({ projectId, projectName }: GitGraphViewProps) { */ useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { - const target = event.target as HTMLElement | null; + const target = event.target instanceof HTMLElement ? event.target : null; const typing = target?.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target?.tagName ?? ''); @@ -692,12 +696,12 @@ export function GitGraphView({ projectId, projectName }: GitGraphViewProps) { }, ...row.node.refs .map(ref => worktreeByBranch.get(ref.name)) - .filter((worktree): worktree is PaneWorktreeRef => Boolean(worktree?.sessionId)) + .filter(hasSessionId) .map(worktree => ({ id: `open-${worktree.sessionId}`, label: `Open session “${worktree.sessionName ?? worktree.branch}”`, icon: GitBranch, - onClick: () => openSession(worktree.sessionId as string), + onClick: () => openSession(worktree.sessionId), })), ]} trigger={ @@ -723,12 +727,12 @@ export function GitGraphView({ projectId, projectName }: GitGraphViewProps) { Showing the {graph.limit} most recent commits. - {limit < MAX_LIMIT && ( + {limit < MAX_GRAPH_LIMIT && ( + {/* Sibling of the row button — a button inside a button is invalid. */} - +