feat(git-graph): VS Code-style git graph with commit actions in History modal (#1431)

* feat(types): add parents to GitLogEntry and new commit action types

* feat(git): add parent hashes and --all flag to getLog

* fix(git): move record separator to start of log format string

* feat(git): add checkoutCommit server function and route

* feat(git): add cherryPick server function and route

* feat(git): add revertCommit server function and route

* feat(git): add resetToCommit server function and route

* fix(tests): make git service tests branch-name portable, add error path tests

* feat(client): add checkoutCommit, cherryPick, revertCommit, resetToCommit API wrappers

* feat(git-graph): add lane assignment algorithm with tests

* feat(git-graph): add GitGraphSegment per-row SVG renderer

* feat(i18n): add locale strings for git graph action buttons

* fix(git-graph): handle lane convergence, fix SVG path coords, add connector tests

* feat(git-graph): add ref badges and action buttons to HistoryCommitRow

* fix(git-graph): add loading guards to reset actions, use theme tokens for ref badges

* fix(git-graph): conditional hooks, stale graph log, conflict handling, i18n

* fix(types): replace toBeDefined with toBeTruthy, fix toast API usage

* fix(lint): remove unused variables

* fix(git-graph): fix SVG height causing 150px row spacing

* fix(git-graph): smooth bezier curves, fill row height, round line caps

* fix(git-graph): non-scaling-stroke fixes bezier white spaces, sort curves on top

* fix(git-graph): remove viewBox scaling, match SVG height to actual row height

* fix(git-graph): ResizeObserver tracks actual row height, eliminates SVG height mismatch

* feat(git-graph): replace SVG with Canvas for graph rendering

* fix(git-graph): isolate canvas from flex layout to prevent replaced-element height leak

* feat(git-graph): align action buttons, add confirmation popups for all actions

* fix(git-graph): address code review findings CR-001 through CR-005

- CR-001: VS Code getGitLog now forwards 'all' option and parses %P parents
- CR-002: VS Code bridge/gitService implement checkoutCommit, cherryPick,
  revertCommit, resetToCommit with conflict detection and hard-reset guard
- CR-003: server-side commit hash validated with /^[0-9a-fA-F]{7,40}$/
  in both routes.js and service.js; 12 new rejection tests added
- CR-004: cherry-pick/revert conflict path now refreshes fetchStatus/
  fetchBranches/fetchLog; conflict toast uses i18n keys in all 7 locales
- CR-005: corrected O(n) comment to O(n x lanes)

* fix(i18n): add zh-TW locale and common.language.traditionalChinese key to all locales

upstream/main added zh-TW.ts after branch diverged; CI type-check fails
when PR is merged because zh-TW.ts was missing all gitView.history.actions.*
keys and loadMore/loadingMore. Also adds common.language.traditionalChinese
to en.ts and all 6 non-English files to match upstream en.ts.

* fix: harden git history actions

* feat: split git history graph view

* chore: remove git graph planning docs

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Erman HAVUÇ
2026-05-27 00:13:25 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent cc3d1bd63c
commit 52ffe9daef
26 changed files with 2373 additions and 111 deletions
+102 -20
View File
@@ -68,6 +68,7 @@ type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
type CommitAction = 'commit' | 'commitAndPush' | null;
type BranchOperation = 'merge' | 'rebase' | null;
type ActionTab = 'commit' | 'branch' | 'pr';
type GitLogDialogMode = 'history' | 'graph';
type HistoryBranchDivider = {
insertBeforeIndex: number;
branchName: string;
@@ -313,9 +314,9 @@ export const GitView: React.FC = () => {
const fetchStatus = useGitStore((state) => state.fetchStatus);
const fetchBranches = useGitStore((state) => state.fetchBranches);
const fetchLog = useGitStore((state) => state.fetchLog);
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
const fetchIdentity = useGitStore((state) => state.fetchIdentity);
const prefetchDiffs = useGitStore((state) => state.prefetchDiffs);
const setLogMaxCount = useGitStore((state) => state.setLogMaxCount);
const moveStatusPathsOptimistically = useGitStore((state) => state.moveStatusPathsOptimistically);
const restoreStatus = useGitStore((state) => state.restoreStatus);
const bumpIndexRevision = useGitStore((state) => state.bumpIndexRevision);
@@ -626,7 +627,7 @@ export const GitView: React.FC = () => {
const [remoteUrl, setRemoteUrl] = React.useState<string | null>(null);
const [gitmojiEmojis, setGitmojiEmojis] = React.useState<GitmojiEntry[]>([]);
const [gitmojiSearch, setGitmojiSearch] = React.useState('');
const [isHistoryDialogOpen, setIsHistoryDialogOpen] = React.useState(false);
const [gitLogDialogMode, setGitLogDialogMode] = React.useState<GitLogDialogMode | null>(null);
const actionTabItems = React.useMemo(() => [
{ id: 'commit', label: t('gitView.tabs.commit'), icon: <Icon name="git-commit" className="h-3.5 w-3.5" /> },
@@ -650,6 +651,9 @@ export const GitView: React.FC = () => {
const [conflictDialogOpen, setConflictDialogOpen] = React.useState(false);
const [conflictFiles, setConflictFiles] = React.useState<string[]>([]);
const [conflictOperation, setConflictOperation] = React.useState<'merge' | 'rebase'>('merge');
const [graphLog, setGraphLog] = React.useState<import('@/lib/api/types').GitLogResponse | null>(null);
const [graphLogLoading, setGraphLogLoading] = React.useState(false);
const [graphLogMaxCount, setGraphLogMaxCount] = React.useState(100);
// Conflict state persistence key
const conflictStorageKey = React.useMemo(() => {
@@ -1631,6 +1635,32 @@ export const GitView: React.FC = () => {
cancelled = true;
};
}, [baseBranch, currentBranch, currentDirectory, git, log, logMaxCountLocal]);
// Clear graph log when directory changes
React.useEffect(() => {
setGraphLog(null);
}, [currentDirectory]);
React.useEffect(() => {
if (gitLogDialogMode !== 'graph' || !currentDirectory) {
if (gitLogDialogMode !== 'graph') setGraphLog(null);
return;
}
let cancelled = false;
setGraphLogLoading(true);
git.getGitLog(currentDirectory, { maxCount: graphLogMaxCount, all: true })
.then((result) => {
if (!cancelled) setGraphLog(result);
})
.catch((err) => {
console.error('Failed to fetch graph log:', err);
})
.finally(() => {
if (!cancelled) setGraphLogLoading(false);
});
return () => { cancelled = true; };
}, [gitLogDialogMode, currentDirectory, graphLogMaxCount, git]);
// Keep these sections stable in layout; individual cards render placeholders when unavailable.
const moveChangePaths = React.useCallback((paths: string[], direction: GitIndexMutationDirection) => {
@@ -1834,16 +1864,7 @@ export const GitView: React.FC = () => {
setIsGitmojiPickerOpen(false);
}, []);
const handleLogMaxCountChange = React.useCallback(
(count: number) => {
setLogMaxCountLocal(count);
if (currentDirectory) {
setLogMaxCount(currentDirectory, count);
fetchLog(currentDirectory, git, count);
}
},
[currentDirectory, setLogMaxCount, fetchLog, git]
);
const isUncommittedChangesError = React.useCallback((error: unknown): boolean => {
const message = error instanceof Error ? error.message.toLowerCase() : '';
@@ -2207,6 +2228,61 @@ export const GitView: React.FC = () => {
[bumpIndexRevision, currentDirectory, git, status, stashDialogOperation, stashDialogBranch, refreshStatusAndBranches, refreshLog, t]
);
const handleLogMaxCountChange = React.useCallback(
(count: number) => {
setLogMaxCountLocal(count);
if (currentDirectory) {
setLogMaxCount(currentDirectory, count);
fetchLog(currentDirectory, git, count);
}
},
[currentDirectory, fetchLog, git, setLogMaxCount]
);
const handleGraphLogMaxCountChange = React.useCallback((count: number) => {
setGraphLogMaxCount(count);
}, []);
const handleGraphActionSuccess = React.useCallback(() => {
setGitLogDialogMode(null);
if (currentDirectory) {
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}
}, [currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
const handleGraphConflict = React.useCallback((result: {
conflict: boolean;
conflictFiles?: string[];
operation: 'cherry-pick' | 'revert' | 'merge' | 'rebase';
}) => {
if (!result.conflict) return;
if (result.operation === 'cherry-pick' || result.operation === 'revert') {
// Cherry-pick and revert conflicts are not supported by the shared ConflictDialog
// Show a toast with manual resolution instructions
toast.error(t('gitView.history.actions.conflictToastTitle'), {
description: t('gitView.history.actions.conflictToastDescription', {
files: result.conflictFiles?.join(', ') ?? 'unknown files',
}),
});
if (currentDirectory) {
fetchStatus(currentDirectory, git);
fetchBranches(currentDirectory, git);
fetchLog(currentDirectory, git, logMaxCountLocal);
}
return;
}
setConflictFiles(result.conflictFiles ?? []);
setConflictOperation(result.operation);
setConflictDialogOpen(true);
if (currentDirectory) {
persistConflictState(currentDirectory, result.conflictFiles ?? [], result.operation);
}
}, [t, setConflictFiles, setConflictOperation, setConflictDialogOpen, persistConflictState, currentDirectory, fetchStatus, fetchBranches, fetchLog, logMaxCountLocal, git]);
if (!currentDirectory) {
return (
<div className="flex h-full items-center justify-center px-4 text-center">
@@ -2282,7 +2358,8 @@ export const GitView: React.FC = () => {
onSelectIdentity={handleApplyIdentity}
isApplyingIdentity={isSettingIdentity}
isWorktreeMode={!!worktreeMetadata}
onOpenHistory={() => setIsHistoryDialogOpen(true)}
onOpenHistory={() => setGitLogDialogMode('history')}
onOpenGraph={() => setGitLogDialogMode('graph')}
onOpenStashes={openStashes}
actionTabItems={actionTabItems}
activeActionTab={actionTab}
@@ -2421,20 +2498,23 @@ export const GitView: React.FC = () => {
</div>
</div>
<Dialog open={isHistoryDialogOpen} onOpenChange={setIsHistoryDialogOpen}>
<Dialog open={gitLogDialogMode !== null} onOpenChange={(open) => { if (!open) setGitLogDialogMode(null); }}>
<DialogContent className="max-w-5xl h-[90vh] max-h-[90vh] flex flex-col overflow-hidden">
<DialogHeader>
<DialogTitle>{t('gitView.history.title')}</DialogTitle>
<DialogTitle>
{gitLogDialogMode === 'graph' ? t('gitView.graph.title') : t('gitView.history.title')}
</DialogTitle>
<DialogDescription>
{t('gitView.history.dialogDescription')}
</DialogDescription>
</DialogHeader>
<div className="flex-1 min-h-0">
<HistorySection
log={log}
isLogLoading={isLogLoading}
logMaxCount={logMaxCountLocal}
onLogMaxCountChange={handleLogMaxCountChange}
mode={gitLogDialogMode === 'graph' ? 'graph' : 'history'}
log={gitLogDialogMode === 'graph' ? graphLog ?? log : log}
isLogLoading={gitLogDialogMode === 'graph' ? graphLogLoading || isLogLoading : isLogLoading}
logMaxCount={gitLogDialogMode === 'graph' ? graphLogMaxCount : logMaxCountLocal}
onLogMaxCountChange={gitLogDialogMode === 'graph' ? handleGraphLogMaxCountChange : handleLogMaxCountChange}
expandedCommitHashes={expandedCommitHashes}
onToggleCommit={handleToggleCommit}
commitFilesMap={commitFilesMap}
@@ -2443,7 +2523,9 @@ export const GitView: React.FC = () => {
directory={currentDirectory ?? undefined}
showHeader={false}
contentMaxHeightClassName="h-full max-h-none"
branchDivider={historyBranchDivider}
branchDivider={gitLogDialogMode === 'graph' ? null : historyBranchDivider}
onConflict={gitLogDialogMode === 'graph' ? handleGraphConflict : undefined}
onActionSuccess={gitLogDialogMode === 'graph' ? handleGraphActionSuccess : undefined}
/>
</div>
</DialogContent>