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:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
cc3d1bd63c
commit
52ffe9daef
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user