From 3e1b81f522dab0c0a47511e7a8cc22992672c15e Mon Sep 17 00:00:00 2001 From: Philipp Feldner Date: Thu, 7 May 2026 15:37:29 +0200 Subject: [PATCH 1/3] feat(git): add graph view + branch switcher dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new affordances to the git surface: - **Graph view in GitLogViewer** — toggleable List/Graph view via header buttons. Graph mode renders commits with parent-hash topology using @gitgraph/react, supports clicking commits to drive the existing diff panel, and persists view preference via localStorage. Side-branch commits only visible via `git log --all` are tracked separately so the right panel stays in sync. - **Branch switcher dropdown on header branch chip** — single click still opens the git log (debounced ~220ms); double click opens a filterable branch picker that switches the working tree via `git switch `. Failures (e.g. dirty working tree) surface the git stderr in the dropdown. New IPC: `git:graph` (returns commits with parent hashes for lane rendering) and `git:switch` (returns success + stderr instead of throwing). Both honor the existing SSH remote pathway. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 19 ++ package.json | 1 + .../renderer/components/MainPanel.test.tsx | 41 ++- src/main/ipc/handlers/git.ts | 66 +++++ src/main/preload/git.ts | 36 +++ src/renderer/components/GitGraphView.tsx | 161 ++++++++++++ src/renderer/components/GitLogViewer.tsx | 167 ++++++++++-- .../MainPanel/BranchSwitcherDropdown.tsx | 239 ++++++++++++++++++ .../components/MainPanel/MainPanelHeader.tsx | 101 +++++++- src/renderer/constants/modalPriorities.ts | 3 + src/renderer/global.d.ts | 23 ++ src/renderer/services/git.ts | 52 ++++ 12 files changed, 876 insertions(+), 33 deletions(-) create mode 100644 src/renderer/components/GitGraphView.tsx create mode 100644 src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx diff --git a/package-lock.json b/package-lock.json index b5419e35f4..bd87a65c85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^9.0.0", + "@gitgraph/react": "^1.6.0", "@sentry/electron": "^7.5.0", "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", @@ -1882,6 +1883,24 @@ "dev": true, "license": "MIT" }, + "node_modules/@gitgraph/core": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@gitgraph/core/-/core-1.5.0.tgz", + "integrity": "sha512-8CeeHbkKoFHM1y9vfjYiHyEpzl1mEhVrg5c/eFgDBsntOYswoDKU2yOf6DjtVINcE60wmcuynBSJqjMkQo07Ww==", + "license": "MIT" + }, + "node_modules/@gitgraph/react": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@gitgraph/react/-/react-1.6.0.tgz", + "integrity": "sha512-cLFNZDoEiNbsnMfdT82zeZti5saYghQamfCbTpVvCRr+BrrQ/k94glkZqYPXKKNTEqbQV6L9JeOfAk1hNiFYXA==", + "license": "MIT", + "dependencies": { + "@gitgraph/core": "1.5.0" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", diff --git a/package.json b/package.json index c8a33d534d..cd32a092ed 100644 --- a/package.json +++ b/package.json @@ -253,6 +253,7 @@ "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^9.0.0", + "@gitgraph/react": "^1.6.0", "@sentry/electron": "^7.5.0", "@tanstack/react-virtual": "^3.13.13", "@types/d3-force": "^3.0.10", diff --git a/src/__tests__/renderer/components/MainPanel.test.tsx b/src/__tests__/renderer/components/MainPanel.test.tsx index 643818fa57..1c6ce3ee20 100644 --- a/src/__tests__/renderer/components/MainPanel.test.tsx +++ b/src/__tests__/renderer/components/MainPanel.test.tsx @@ -21,6 +21,12 @@ import { // Mock child components to simplify testing - must be before MainPanel import +// useModalLayer is no-op in tests — MainPanel does not provide LayerStackProvider, +// and the BranchSwitcherDropdown registers a layer when rendered. +vi.mock('../../../renderer/hooks/ui/useModalLayer', () => ({ + useModalLayer: () => {}, +})); + // TerminalView: forwardRef stub that records render calls per session so we can // assert persistence (kept mounted) vs destruction (unmounted) across sessions. const terminalViewSessions: string[] = []; @@ -198,6 +204,9 @@ vi.mock('../../../renderer/components/InlineWizard', () => ({ vi.mock('../../../renderer/services/git', () => ({ gitService: { getDiff: vi.fn().mockResolvedValue({ diff: 'mock diff content' }), + getBranches: vi.fn().mockResolvedValue([]), + getGraph: vi.fn().mockResolvedValue([]), + switchBranch: vi.fn().mockResolvedValue({ success: true, stderr: '' }), }, })); @@ -1750,8 +1759,7 @@ describe('MainPanel', () => { expect(writeText).toHaveBeenCalledWith('main'); }); - it('should open git log when clicking on SSH remote git badge', async () => { - const setGitLogOpen = vi.fn(); + it('should open branch switcher when double-clicking on SSH remote git badge', async () => { const session = createSession({ isGitRepo: true, sessionSshRemoteConfig: { enabled: true, remoteId: 'ssh-remote-123' }, @@ -1764,6 +1772,32 @@ describe('MainPanel', () => { }); vi.mocked(window.maestro.sshRemote.getConfigs).mockImplementation(mockGetConfigs); + render(); + + await waitFor(() => { + expect(screen.getByText('my-ssh-remote')).toBeInTheDocument(); + }); + + fireEvent.doubleClick(screen.getByText('my-ssh-remote')); + + // Branch switcher dropdown opens (revealed by its filter input). + expect(await screen.findByPlaceholderText(/Filter branches/)).toBeInTheDocument(); + expect(gitService.getBranches).toHaveBeenCalled(); + }); + + it('should open git log when single-clicking on SSH remote git badge', async () => { + const setGitLogOpen = vi.fn(); + const session = createSession({ + isGitRepo: true, + sessionSshRemoteConfig: { enabled: true, remoteId: 'ssh-remote-123' }, + }); + + const mockGetConfigs = vi.fn().mockResolvedValue({ + success: true, + configs: [{ id: 'ssh-remote-123', name: 'my-ssh-remote' }], + }); + vi.mocked(window.maestro.sshRemote.getConfigs).mockImplementation(mockGetConfigs); + render(); await waitFor(() => { @@ -1772,7 +1806,8 @@ describe('MainPanel', () => { fireEvent.click(screen.getByText('my-ssh-remote')); - expect(setGitLogOpen).toHaveBeenCalledWith(true); + // Single click is debounced (~220ms) before opening git log. + await waitFor(() => expect(setGitLogOpen).toHaveBeenCalledWith(true), { timeout: 1000 }); }); it('should call gitService.getDiff with SSH remote ID when session has SSH remote config enabled', async () => { diff --git a/src/main/ipc/handlers/git.ts b/src/main/ipc/handlers/git.ts index 2d3211c5c6..34faf972fb 100644 --- a/src/main/ipc/handlers/git.ts +++ b/src/main/ipc/handlers/git.ts @@ -352,6 +352,72 @@ export function registerGitHandlers(_deps: GitHandlerDependencies): void { ) ); + // Topology data for graph view: includes parent hashes for lane rendering. + ipcMain.handle( + 'git:graph', + withIpcErrorLogging( + handlerOpts('graph'), + async ( + cwd: string, + options?: { limit?: number }, + sshRemoteId?: string, + remoteCwd?: string + ) => { + const sshRemote = sshRemoteId ? getSshRemoteById(sshRemoteId) : undefined; + const effectiveRemoteCwd = sshRemote ? remoteCwd || cwd : undefined; + const limit = options?.limit || 200; + const args = [ + 'log', + '--all', + `--max-count=${limit}`, + '--pretty=format:GRAPH_START%H|%P|%an|%ad|%D|%s', + '--date=iso-strict', + ]; + const result = await execGit(args, cwd, sshRemote, effectiveRemoteCwd); + if (result.exitCode !== 0) { + return { nodes: [], error: result.stderr }; + } + const nodes = result.stdout + .split('GRAPH_START') + .filter((c) => c.trim()) + .map((block) => { + const trimmed = block.trim(); + const [hash = '', parents = '', author = '', date = '', refs = '', ...subj] = + trimmed.split('|'); + return { + hash, + shortHash: hash.slice(0, 7), + parents: parents ? parents.split(' ').filter(Boolean) : [], + author, + date, + refs: refs ? refs.split(', ').filter((r) => r.trim()) : [], + subject: subj.join('|'), + }; + }); + return { nodes, error: null }; + } + ) + ); + + // Switch to an existing branch in the current working tree. + // Returns success=false with stderr text on failure (e.g., dirty working tree). + ipcMain.handle( + 'git:switch', + withIpcErrorLogging( + handlerOpts('switch'), + async (cwd: string, branchName: string, sshRemoteId?: string, remoteCwd?: string) => { + const sshRemote = sshRemoteId ? getSshRemoteById(sshRemoteId) : undefined; + const effectiveRemoteCwd = sshRemote ? remoteCwd || cwd : undefined; + const result = await execGit(['switch', branchName], cwd, sshRemote, effectiveRemoteCwd); + return { + success: result.exitCode === 0, + stdout: result.stdout, + stderr: result.stderr, + }; + } + ) + ); + ipcMain.handle( 'git:commitCount', withIpcErrorLogging( diff --git a/src/main/preload/git.ts b/src/main/preload/git.ts index 810d7c725a..148cb2ecc4 100644 --- a/src/main/preload/git.ts +++ b/src/main/preload/git.ts @@ -56,6 +56,19 @@ export interface GitLogEntry { subject: string; } +/** + * Git graph node — like GitLogEntry but with parent hashes for topology rendering. + */ +export interface GitGraphNode { + hash: string; + shortHash: string; + parents: string[]; + author: string; + date: string; + refs: string[]; + subject: string; +} + /** * Discovered worktree event data */ @@ -207,6 +220,29 @@ export function createGitApi() { error: string | null; }> => ipcRenderer.invoke('git:log', cwd, options, sshRemoteId), + /** + * Get topology graph data (commits with parent hashes) for graph rendering + */ + graph: ( + cwd: string, + options?: { limit?: number }, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ nodes: GitGraphNode[]; error: string | null }> => + ipcRenderer.invoke('git:graph', cwd, options, sshRemoteId, remoteCwd), + + /** + * Switch the current working tree to an existing branch. + * Returns success=false on dirty working tree (stderr contains git's message). + */ + switchBranch: ( + cwd: string, + branchName: string, + sshRemoteId?: string, + remoteCwd?: string + ): Promise<{ success: boolean; stdout: string; stderr: string }> => + ipcRenderer.invoke('git:switch', cwd, branchName, sshRemoteId, remoteCwd), + /** * Get commit count */ diff --git a/src/renderer/components/GitGraphView.tsx b/src/renderer/components/GitGraphView.tsx new file mode 100644 index 0000000000..e1396c7016 --- /dev/null +++ b/src/renderer/components/GitGraphView.tsx @@ -0,0 +1,161 @@ +import { memo, useMemo } from 'react'; +import { Gitgraph, templateExtend, TemplateName, type Branch } from '@gitgraph/react'; +import type { Theme } from '../types'; +import type { GitGraphNode } from '../services/git'; + +interface GitGraphViewProps { + nodes: GitGraphNode[]; + theme: Theme; + onCommitClick?: (hash: string) => void; + selectedHash?: string; +} + +// Pull a branch label out of a commit's refs (e.g. "HEAD -> main, origin/main, tag: v1"). +// Prefers local branches over remote-tracking refs; ignores tag: entries. +function pickBranchFromRefs(refs: string[]): string | null { + const cleaned = refs + .map((r) => r.replace(/^HEAD -> /, '').trim()) + .filter((r) => r && !r.startsWith('tag:')); + if (cleaned.length === 0) return null; + const local = cleaned.find((r) => !r.includes('/')); + return local || cleaned[0]; +} + +export const GitGraphView = memo(function GitGraphView({ + nodes, + theme, + onCommitClick, + selectedHash, +}: GitGraphViewProps) { + const template = useMemo( + () => + templateExtend(TemplateName.Metro, { + colors: [ + theme.colors.accent, + 'rgb(34, 197, 94)', + 'rgb(59, 130, 246)', + 'rgb(234, 179, 8)', + 'rgb(168, 85, 247)', + 'rgb(244, 63, 94)', + 'rgb(20, 184, 166)', + 'rgb(236, 72, 153)', + ], + branch: { + lineWidth: 2, + spacing: 14, + label: { + display: true, + bgColor: theme.colors.bgSidebar, + color: theme.colors.textMain, + strokeColor: theme.colors.border, + borderRadius: 4, + font: '10px sans-serif', + }, + }, + commit: { + spacing: 26, + hasTooltipInCompactMode: false, + dot: { + size: 5, + strokeWidth: 0, + }, + message: { + display: true, + displayAuthor: false, + displayHash: false, + color: theme.colors.textMain, + font: '12px sans-serif', + }, + }, + tag: { + bgColor: 'rgba(234, 179, 8, 0.2)', + color: 'rgb(234, 179, 8)', + strokeColor: 'rgb(234, 179, 8)', + borderRadius: 3, + font: '9px sans-serif', + }, + }), + [theme] + ); + + // Sort oldest → newest so we can build branches forward. + const ordered = useMemo( + () => [...nodes].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()), + [nodes] + ); + + return ( +
+ + {(gitgraph) => { + const branches = new Map(); + const commitToBranch = new Map(); + let laneCounter = 0; + + const ensureBranch = (name: string, parentHash?: string): Branch => { + const existing = branches.get(name); + if (existing) return existing; + let created: Branch; + if (parentHash && commitToBranch.has(parentHash)) { + const parentBranch = commitToBranch.get(parentHash)!; + created = parentBranch.branch(name); + } else { + created = gitgraph.branch(name); + } + branches.set(name, created); + return created; + }; + + for (const node of ordered) { + const refBranch = pickBranchFromRefs(node.refs); + const firstParent = node.parents[0]; + const inheritedBranchName = firstParent + ? ([...branches.entries()].find( + ([, br]) => commitToBranch.get(firstParent) === br + )?.[0] ?? null) + : null; + + const branchName = refBranch ?? inheritedBranchName ?? `lane-${++laneCounter}`; + + const branch = ensureBranch(branchName, firstParent); + + const subject = node.subject || '(no message)'; + const truncated = subject.length > 60 ? subject.slice(0, 57) + '…' : subject; + const commitOptions = { + hash: node.shortHash, + subject: truncated, + author: node.author, + onClick: onCommitClick ? () => onCommitClick(node.hash) : undefined, + style: + selectedHash && selectedHash === node.hash + ? { dot: { size: 10, strokeWidth: 2, strokeColor: theme.colors.accent } } + : undefined, + }; + + if (node.parents.length >= 2) { + const secondParent = node.parents[1]; + const sourceBranch = commitToBranch.get(secondParent); + if (sourceBranch) { + branch.merge({ branch: sourceBranch, commitOptions }); + } else { + branch.commit(commitOptions); + } + } else { + branch.commit(commitOptions); + } + + commitToBranch.set(node.hash, branch); + + // Attach tag refs (skip duplicate branch labels — gitgraph adds those automatically). + for (const ref of node.refs) { + const cleaned = ref.replace(/^HEAD -> /, '').trim(); + if (cleaned.startsWith('tag:')) { + branch.tag(cleaned.replace(/^tag:\s*/, '')); + } + } + } + }} + +
+ ); +}); diff --git a/src/renderer/components/GitLogViewer.tsx b/src/renderer/components/GitLogViewer.tsx index 1a4592ddd2..656b0a2b80 100644 --- a/src/renderer/components/GitLogViewer.tsx +++ b/src/renderer/components/GitLogViewer.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useEffect, useRef, useCallback, memo } from 'react'; -import { GitCommit, GitBranch, Tag } from 'lucide-react'; +import { GitCommit, GitBranch, Tag, List, Network } from 'lucide-react'; import type { Theme } from '../types'; import { useModalLayer } from '../hooks/ui/useModalLayer'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; @@ -7,8 +7,13 @@ import { Diff, Hunk } from 'react-diff-view'; import { parseGitDiff } from '../utils/gitDiffParser'; import { useListNavigation } from '../hooks'; import { generateDiffViewStyles } from '../utils/markdownConfig'; +import { gitService, type GitGraphNode } from '../services/git'; +import { GitGraphView } from './GitGraphView'; import 'react-diff-view/style/index.css'; +const VIEW_MODE_STORAGE_KEY = 'maestro:gitLogViewer:viewMode'; +type ViewMode = 'list' | 'graph'; + interface GitLogEntry { hash: string; shortHash: string; @@ -39,6 +44,27 @@ export const GitLogViewer = memo(function GitLogViewer({ const [error, setError] = useState(null); const [selectedCommitDiff, setSelectedCommitDiff] = useState(null); const [loadingDiff, setLoadingDiff] = useState(false); + const [viewMode, setViewMode] = useState(() => { + const stored = + typeof window !== 'undefined' ? localStorage.getItem(VIEW_MODE_STORAGE_KEY) : null; + return stored === 'graph' ? 'graph' : 'list'; + }); + const [graphNodes, setGraphNodes] = useState([]); + const [graphLoading, setGraphLoading] = useState(false); + // Commit clicked from the graph that isn't part of `entries` (e.g. a side-branch + // commit only visible via `git log --all`). Drives the right-side detail panel + // when the list mode's selected entry would otherwise be out of sync. + const [graphSelected, setGraphSelected] = useState(null); + + useEffect(() => { + try { + localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode); + } catch { + // localStorage may be unavailable; ignore. + } + // When leaving graph mode, clear graph-only selection so list selection drives the right panel. + if (viewMode !== 'graph') setGraphSelected(null); + }, [viewMode]); const listRef = useRef(null); const itemRefs = useRef<(HTMLDivElement | null)[]>([]); @@ -85,6 +111,24 @@ export const GitLogViewer = memo(function GitLogViewer({ loadLog(); }, [cwd]); + // Lazy-load graph data the first time the user switches to the Graph view (and on cwd change). + useEffect(() => { + if (viewMode !== 'graph') return; + let cancelled = false; + setGraphLoading(true); + gitService + .getGraph(cwd, { limit: 100 }, sshRemoteId) + .then((nodes) => { + if (!cancelled) setGraphNodes(nodes); + }) + .finally(() => { + if (!cancelled) setGraphLoading(false); + }); + return () => { + cancelled = true; + }; + }, [viewMode, cwd, sshRemoteId]); + // Load diff when selected entry changes const loadCommitDiff = useCallback( async (hash: string) => { @@ -101,12 +145,35 @@ export const GitLogViewer = memo(function GitLogViewer({ [cwd] ); - // Auto-load diff for selected commit + // Auto-load diff for selected commit (priority: graph-only selection, else list selection) useEffect(() => { + if (graphSelected) { + loadCommitDiff(graphSelected.hash); + return; + } if (entries.length > 0 && entries[selectedIndex]) { loadCommitDiff(entries[selectedIndex].hash); } - }, [selectedIndex, entries, loadCommitDiff]); + }, [selectedIndex, entries, loadCommitDiff, graphSelected]); + + // Effective commit displayed on the right side: graph-clicked commit overrides list selection. + const displayedCommit: { + hash: string; + shortHash: string; + subject: string; + author: string; + date: string; + } | null = graphSelected + ? graphSelected + : entries[selectedIndex] + ? { + hash: entries[selectedIndex].hash, + shortHash: entries[selectedIndex].shortHash, + subject: entries[selectedIndex].subject, + author: entries[selectedIndex].author, + date: entries[selectedIndex].date, + } + : null; useModalLayer(MODAL_PRIORITIES.GIT_LOG, 'Git Log Viewer', () => onCloseRef.current(), { focusTrap: 'lenient', @@ -299,24 +366,88 @@ export const GitLogViewer = memo(function GitLogViewer({ : `${entries.length} commits`} - +
+ {/* List | Graph toggle */} +
+ + +
+ +
{/* Content */}
- {/* Left side: Commit list */} + {/* Left side: Commit list OR graph (graph gets more room for lanes) */}
- {loading ? ( + {viewMode === 'graph' ? ( + graphLoading ? ( +
+

+ Loading graph... +

+
+ ) : graphNodes.length === 0 ? ( +
+

+ No commits found +

+
+ ) : ( + { + const idx = entries.findIndex((e) => e.hash === hash); + if (idx >= 0) { + setGraphSelected(null); + setSelectedIndex(idx); + } else { + const node = graphNodes.find((n) => n.hash === hash); + if (node) setGraphSelected(node); + } + }} + /> + ) + ) : loading ? (

Loading git log... @@ -420,7 +551,7 @@ export const GitLogViewer = memo(function GitLogViewer({ {/* Right side: Commit details & diff */}

- {entries[selectedIndex] && ( + {displayedCommit && (
{/* Commit header */}
@@ -428,7 +559,7 @@ export const GitLogViewer = memo(function GitLogViewer({ className="text-lg font-semibold mb-2" style={{ color: theme.colors.textMain }} > - {entries[selectedIndex].subject} + {displayedCommit.subject}
- {entries[selectedIndex].hash} + {displayedCommit.hash} - {entries[selectedIndex].author} - {new Date(entries[selectedIndex].date).toLocaleString('en-US')} + {displayedCommit.author} + {new Date(displayedCommit.date).toLocaleString('en-US')}
diff --git a/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx b/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx new file mode 100644 index 0000000000..9cf39ca76d --- /dev/null +++ b/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx @@ -0,0 +1,239 @@ +import { useEffect, useRef, useState, useMemo, useCallback, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { Check, GitBranch, Search, Loader2 } from 'lucide-react'; +import type { Theme } from '../../types'; +import { useModalLayer } from '../../hooks/ui/useModalLayer'; +import { MODAL_PRIORITIES } from '../../constants/modalPriorities'; +import { gitService } from '../../services/git'; +import { notifyToast } from '../../stores/notificationStore'; +import { notifyCenterFlash } from '../../stores/centerFlashStore'; + +interface BranchSwitcherDropdownProps { + cwd: string; + currentBranch: string; + theme: Theme; + sshRemoteId?: string; + /** Element the dropdown should be anchored under. Required for portal positioning. */ + anchorEl: HTMLElement | null; + onClose: () => void; + onSwitched: () => void; +} + +const DROPDOWN_WIDTH = 320; + +export function BranchSwitcherDropdown({ + cwd, + currentBranch, + theme, + sshRemoteId, + anchorEl, + onClose, + onSwitched, +}: BranchSwitcherDropdownProps) { + const [branches, setBranches] = useState([]); + const [loading, setLoading] = useState(true); + const [filter, setFilter] = useState(''); + const [highlight, setHighlight] = useState(0); + const [switching, setSwitching] = useState(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useModalLayer(MODAL_PRIORITIES.BRANCH_SWITCHER, 'Branch switcher', onClose, { + focusTrap: 'lenient', + blocksLowerLayers: false, + }); + + useEffect(() => { + let cancelled = false; + setLoading(true); + gitService + .getBranches(cwd, sshRemoteId) + .then((b) => { + if (cancelled) return; + setBranches(b); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [cwd, sshRemoteId]); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + // Compute viewport position from anchor rect; recompute on resize/scroll. + useLayoutEffect(() => { + if (!anchorEl) return; + const update = () => { + const r = anchorEl.getBoundingClientRect(); + const left = Math.max(8, Math.min(r.left, window.innerWidth - DROPDOWN_WIDTH - 8)); + setPos({ top: r.bottom + 6, left }); + }; + update(); + window.addEventListener('resize', update); + window.addEventListener('scroll', update, true); + return () => { + window.removeEventListener('resize', update); + window.removeEventListener('scroll', update, true); + }; + }, [anchorEl]); + + // Click-outside to close (ignore clicks on the anchor itself). + useEffect(() => { + const handler = (e: MouseEvent) => { + const target = e.target as Node; + if (containerRef.current?.contains(target)) return; + if (anchorEl?.contains(target)) return; + onClose(); + }; + const id = window.setTimeout(() => window.addEventListener('mousedown', handler), 0); + return () => { + window.clearTimeout(id); + window.removeEventListener('mousedown', handler); + }; + }, [onClose, anchorEl]); + + const filtered = useMemo(() => { + const q = filter.trim().toLowerCase(); + if (!q) return branches; + return branches.filter((b) => b.toLowerCase().includes(q)); + }, [branches, filter]); + + useEffect(() => { + setHighlight(0); + }, [filter]); + + const doSwitch = useCallback( + async (branch: string) => { + if (branch === currentBranch || switching) return; + setSwitching(branch); + const result = await gitService.switchBranch(cwd, branch, sshRemoteId); + setSwitching(null); + if (result.success) { + notifyCenterFlash({ + message: `Switched to ${branch}`, + color: 'green', + }); + onSwitched(); + onClose(); + } else { + notifyToast({ + color: 'red', + title: 'Branch switch failed', + message: result.stderr.trim() || 'git switch returned a non-zero exit code.', + dismissible: true, + }); + } + }, + [cwd, sshRemoteId, currentBranch, switching, onSwitched, onClose] + ); + + const onKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + setHighlight((h) => Math.min(h + 1, filtered.length - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + setHighlight((h) => Math.max(h - 1, 0)); + } else if (e.key === 'Enter') { + e.preventDefault(); + const target = filtered[highlight]; + if (target) doSwitch(target); + } + }; + + if (!pos) return null; + + const dropdown = ( +
e.stopPropagation()} + > + {/* Search */} +
+ + setFilter(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Filter branches…" + className="flex-1 bg-transparent outline-none text-sm" + style={{ color: theme.colors.textMain }} + /> +
+ + {/* List */} +
+ {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +
+ No matching branches +
+ ) : ( + filtered.map((branch, i) => { + const isCurrent = branch === currentBranch; + const isHighlighted = i === highlight; + const isSwitching = switching === branch; + return ( + + ); + }) + )} +
+ + {/* Footer */} +
+ ↑↓ navigate · Enter switch · Esc close + {filtered.length} +
+
+ ); + + return createPortal(dropdown, document.body); +} diff --git a/src/renderer/components/MainPanel/MainPanelHeader.tsx b/src/renderer/components/MainPanel/MainPanelHeader.tsx index c7689fe602..39180084da 100644 --- a/src/renderer/components/MainPanel/MainPanelHeader.tsx +++ b/src/renderer/components/MainPanel/MainPanelHeader.tsx @@ -1,4 +1,4 @@ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { Wand2, ExternalLink, @@ -14,12 +14,14 @@ import { Server, Bookmark, Brain, + History, } from 'lucide-react'; import { GhostIconButton } from '../ui/GhostIconButton'; import { Spinner } from '../ui/Spinner'; import { formatShortcutKeys } from '../../utils/shortcutFormatter'; import { remoteUrlToBrowserUrl } from '../../../shared/gitUtils'; import { GitStatusWidget } from '../GitStatusWidget'; +import { BranchSwitcherDropdown } from './BranchSwitcherDropdown'; import { useHoverTooltip } from '../../hooks'; import { useSettingsStore } from '../../stores/settingsStore'; import { useUIStore } from '../../stores/uiStore'; @@ -98,6 +100,32 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ const headerRef = useRef(null); const gitTooltip = useHoverTooltip(150); const contextTooltip = useHoverTooltip(150); + const [branchSwitcherOpen, setBranchSwitcherOpen] = useState(false); + const branchChipContainerRef = useRef(null); + const branchClickTimerRef = useRef | null>(null); + + // Single click → open git log (delayed to differentiate from a double click). + // Double click → open branch switcher dropdown. + const handleBranchChipClick = () => { + if (!activeSession.isGitRepo) return; + refreshGitStatus(); + gitTooltip.close(); + if (branchClickTimerRef.current) clearTimeout(branchClickTimerRef.current); + branchClickTimerRef.current = setTimeout(() => { + branchClickTimerRef.current = null; + setGitLogOpen?.(true); + }, 220); + }; + + const handleBranchChipDoubleClick = () => { + if (!activeSession.isGitRepo) return; + if (branchClickTimerRef.current) { + clearTimeout(branchClickTimerRef.current); + branchClickTimerRef.current = null; + } + gitTooltip.close(); + setBranchSwitcherOpen((v) => !v); + }; return (
)}
{ e.stopPropagation(); - if (activeSession.isGitRepo) { - refreshGitStatus(); // Refresh git info immediately on click - setGitLogOpen?.(true); - } + handleBranchChipClick(); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + handleBranchChipDoubleClick(); }} > @@ -159,12 +189,17 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ }`} onClick={(e) => { e.stopPropagation(); - if (activeSession.isGitRepo) { - refreshGitStatus(); // Refresh git info immediately on click - setGitLogOpen?.(true); - } + handleBranchChipClick(); + }} + onDoubleClick={(e) => { + e.stopPropagation(); + handleBranchChipDoubleClick(); }} - title={activeSession.isGitRepo && gitInfo?.branch ? gitInfo.branch : undefined} + title={ + activeSession.isGitRepo && gitInfo?.branch + ? `${gitInfo.branch} — click: log, double-click: switch branch` + : undefined + } > {activeSession.isGitRepo ? ( <> @@ -179,7 +214,31 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ )} )} - {activeSession.isGitRepo && gitTooltip.isOpen && gitInfo && ( + {/* Branch switcher dropdown (portaled to body to escape header overflow:hidden) */} + {branchSwitcherOpen && activeSession.isGitRepo && gitInfo && ( + setBranchSwitcherOpen(false)} + onSwitched={() => { + refreshGitStatus(); + }} + /> + )} + {activeSession.isGitRepo && gitTooltip.isOpen && gitInfo && !branchSwitcherOpen && ( <> {/* Invisible bridge to prevent hover gap */}
+ {/* View commit history (was the chip's previous click action) */} + {setGitLogOpen && ( + + )} {/* Configure Worktrees - only for parent sessions (not worktree children) */} {!isWorktreeChild && onOpenWorktreeConfig && (
+ ) : graphError ? ( +
+

{graphError}

+
) : graphNodes.length === 0 ? (

@@ -435,16 +469,7 @@ export const GitLogViewer = memo(function GitLogViewer({ nodes={graphNodes} theme={theme} selectedHash={displayedCommit?.hash} - onCommitClick={(hash) => { - const idx = entries.findIndex((e) => e.hash === hash); - if (idx >= 0) { - setGraphSelected(null); - setSelectedIndex(idx); - } else { - const node = graphNodes.find((n) => n.hash === hash); - if (node) setGraphSelected(node); - } - }} + onCommitClick={handleGraphCommitClick} /> ) ) : loading ? ( diff --git a/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx b/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx index 9cf39ca76d..6a4ae38768 100644 --- a/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx +++ b/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx @@ -111,22 +111,32 @@ export function BranchSwitcherDropdown({ async (branch: string) => { if (branch === currentBranch || switching) return; setSwitching(branch); - const result = await gitService.switchBranch(cwd, branch, sshRemoteId); - setSwitching(null); - if (result.success) { - notifyCenterFlash({ - message: `Switched to ${branch}`, - color: 'green', - }); - onSwitched(); - onClose(); - } else { + try { + const result = await gitService.switchBranch(cwd, branch, sshRemoteId); + if (result.success) { + notifyCenterFlash({ + message: `Switched to ${branch}`, + color: 'green', + }); + onSwitched(); + onClose(); + } else { + notifyToast({ + color: 'red', + title: 'Branch switch failed', + message: result.stderr.trim() || 'git switch returned a non-zero exit code.', + dismissible: true, + }); + } + } catch (err) { notifyToast({ color: 'red', title: 'Branch switch failed', - message: result.stderr.trim() || 'git switch returned a non-zero exit code.', + message: err instanceof Error ? err.message : String(err), dismissible: true, }); + } finally { + setSwitching(null); } }, [cwd, sshRemoteId, currentBranch, switching, onSwitched, onClose] diff --git a/src/renderer/components/MainPanel/MainPanelHeader.tsx b/src/renderer/components/MainPanel/MainPanelHeader.tsx index 39180084da..77eb649240 100644 --- a/src/renderer/components/MainPanel/MainPanelHeader.tsx +++ b/src/renderer/components/MainPanel/MainPanelHeader.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Wand2, ExternalLink, @@ -127,6 +127,26 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ setBranchSwitcherOpen((v) => !v); }; + // Keyboard a11y: Shift+Enter on the chip opens the branch switcher, mirroring double-click. + // Plain Enter falls through to the default button activation, which fires onClick. + const handleBranchChipKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && e.shiftKey) { + e.preventDefault(); + handleBranchChipDoubleClick(); + } + }; + + // Cancel the 220ms single-click debounce on unmount so the callback can't + // fire against a stale parent (resource leak / React 17 setState warning). + useEffect(() => { + return () => { + if (branchClickTimerRef.current) { + clearTimeout(branchClickTimerRef.current); + branchClickTimerRef.current = null; + } + }; + }, []); + return (

{ e.stopPropagation(); handleBranchChipClick(); @@ -176,6 +196,7 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ e.stopPropagation(); handleBranchChipDoubleClick(); }} + onKeyDown={activeSession.isGitRepo ? handleBranchChipKeyDown : undefined} > {sshRemoteName} @@ -195,9 +216,10 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ e.stopPropagation(); handleBranchChipDoubleClick(); }} + onKeyDown={activeSession.isGitRepo ? handleBranchChipKeyDown : undefined} title={ activeSession.isGitRepo && gitInfo?.branch - ? `${gitInfo.branch} — click: log, double-click: switch branch` + ? `${gitInfo.branch} — click / Enter: log, double-click / Shift+Enter: switch branch` : undefined } > diff --git a/src/renderer/services/git.ts b/src/renderer/services/git.ts index c3e533722b..753005e983 100644 --- a/src/renderer/services/git.ts +++ b/src/renderer/services/git.ts @@ -178,20 +178,24 @@ export const gitService = { }, /** - * Get topology graph nodes (commits with parent hashes) for graph rendering + * Get topology graph nodes (commits with parent hashes) for graph rendering. + * Throws on a main-process git error so the caller can render a real error + * state instead of an indistinguishable empty list. */ async getGraph( cwd: string, options?: { limit?: number }, - sshRemoteId?: string + sshRemoteId?: string, + remoteCwd?: string ): Promise { return createIpcMethod({ call: async () => { - const result = await window.maestro.git.graph(cwd, options, sshRemoteId); + const result = await window.maestro.git.graph(cwd, options, sshRemoteId, remoteCwd); + if (result.error) throw new Error(result.error); return result.nodes || []; }, errorContext: 'Git graph', - defaultValue: [], + rethrow: true, }); }, @@ -202,11 +206,17 @@ export const gitService = { async switchBranch( cwd: string, branchName: string, - sshRemoteId?: string + sshRemoteId?: string, + remoteCwd?: string ): Promise { return createIpcMethod({ call: async () => { - const result = await window.maestro.git.switchBranch(cwd, branchName, sshRemoteId); + const result = await window.maestro.git.switchBranch( + cwd, + branchName, + sshRemoteId, + remoteCwd + ); return { success: result.success, stderr: result.stderr }; }, errorContext: 'Git switch', From a348c3dab711957e334631c313044b4aea7eefcf Mon Sep 17 00:00:00 2001 From: Philipp Feldner Date: Fri, 8 May 2026 08:04:45 +0200 Subject: [PATCH 3/3] fix(git): handle refreshGitStatus + getBranches rejections Wrap fire-and-forget refreshGitStatus() in MainPanelHeader so unhandled rejections surface to Sentry instead of becoming console noise, and add a .catch on the BranchSwitcherDropdown branch fetch so IPC failures show a toast instead of silently looking like a repo with zero branches. Addresses CodeRabbit findings on PR #962. --- .../MainPanel/BranchSwitcherDropdown.tsx | 9 +++++++++ .../components/MainPanel/MainPanelHeader.tsx | 15 +++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx b/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx index 6a4ae38768..258b4a7499 100644 --- a/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx +++ b/src/renderer/components/MainPanel/BranchSwitcherDropdown.tsx @@ -53,6 +53,15 @@ export function BranchSwitcherDropdown({ if (cancelled) return; setBranches(b); }) + .catch((err) => { + if (cancelled) return; + notifyToast({ + color: 'red', + title: 'Failed to load branches', + message: err instanceof Error ? err.message : String(err), + dismissible: true, + }); + }) .finally(() => { if (!cancelled) setLoading(false); }); diff --git a/src/renderer/components/MainPanel/MainPanelHeader.tsx b/src/renderer/components/MainPanel/MainPanelHeader.tsx index 77eb649240..70e05c2578 100644 --- a/src/renderer/components/MainPanel/MainPanelHeader.tsx +++ b/src/renderer/components/MainPanel/MainPanelHeader.tsx @@ -31,6 +31,7 @@ import { openUrl } from '../../utils/openUrl'; import { calculateDisplayInputTokens } from '../../utils/contextUsage'; import { flashCopiedToClipboard } from '../../utils/flashCopiedToClipboard'; import { safeClipboardWrite } from '../../utils/clipboard'; +import { captureException } from '../../utils/sentry'; export interface MainPanelHeaderProps { activeSession: Session; @@ -108,7 +109,11 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ // Double click → open branch switcher dropdown. const handleBranchChipClick = () => { if (!activeSession.isGitRepo) return; - refreshGitStatus(); + void refreshGitStatus().catch((error) => { + captureException(error, { + extra: { source: 'MainPanelHeader.handleBranchChipClick' }, + }); + }); gitTooltip.close(); if (branchClickTimerRef.current) clearTimeout(branchClickTimerRef.current); branchClickTimerRef.current = setTimeout(() => { @@ -256,7 +261,13 @@ export const MainPanelHeader = React.memo(function MainPanelHeader({ anchorEl={branchChipContainerRef.current} onClose={() => setBranchSwitcherOpen(false)} onSwitched={() => { - refreshGitStatus(); + void refreshGitStatus().catch((error) => { + captureException(error, { + extra: { + source: 'MainPanelHeader.BranchSwitcherDropdown.onSwitched', + }, + }); + }); }} /> )}