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
+82 -1
View File
@@ -766,6 +766,85 @@ export function registerGitRoutes(app) {
}
});
app.post('/api/git/checkout-commit', async (req, res) => {
const { checkoutCommit } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { hash } = req.body;
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
return res.status(400).json({ error: 'Invalid commit hash' });
}
const result = await checkoutCommit(directory, hash);
res.json(result);
} catch (error) {
console.error('Failed to checkout commit:', error);
res.status(500).json({ error: error.message || 'Failed to checkout commit' });
}
});
app.post('/api/git/cherry-pick', async (req, res) => {
const { cherryPick } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { hash } = req.body;
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
return res.status(400).json({ error: 'Invalid commit hash' });
}
const result = await cherryPick(directory, hash);
res.json(result);
} catch (error) {
console.error('Failed to cherry-pick:', error);
res.status(500).json({ error: error.message || 'Failed to cherry-pick' });
}
});
app.post('/api/git/revert-commit', async (req, res) => {
const { revertCommit } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { hash } = req.body;
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
return res.status(400).json({ error: 'Invalid commit hash' });
}
const result = await revertCommit(directory, hash);
res.json(result);
} catch (error) {
console.error('Failed to revert commit:', error);
res.status(500).json({ error: error.message || 'Failed to revert commit' });
}
});
app.post('/api/git/reset-to-commit', async (req, res) => {
const { resetToCommit } = await getGitLibraries();
try {
const directory = req.query.directory;
if (!directory) {
return res.status(400).json({ error: 'directory parameter is required' });
}
const { hash, mode, force } = req.body;
if (!req.body.hash || typeof req.body.hash !== 'string' || !/^[0-9a-fA-F]{7,40}$/.test(req.body.hash)) {
return res.status(400).json({ error: 'Invalid commit hash' });
}
if (!['soft', 'mixed', 'hard'].includes(mode)) {
return res.status(400).json({ error: 'mode must be soft, mixed, or hard' });
}
const result = await resetToCommit(directory, hash, mode, force === true);
res.json(result);
} catch (error) {
console.error('Failed to reset to commit:', error);
res.status(500).json({ error: error.message || 'Failed to reset' });
}
});
app.get('/api/git/worktrees', async (req, res) => {
const { getWorktrees } = await getGitLibraries();
try {
@@ -956,11 +1035,13 @@ export function registerGitRoutes(app) {
}
const { maxCount, from, to, file } = req.query;
const all = req.query.all === 'true';
const log = await getLog(directory, {
maxCount: maxCount ? parseInt(maxCount) : undefined,
from,
to,
file
file,
all
});
res.json(log);
} catch (error) {