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
@@ -0,0 +1,171 @@
|
||||
import type { GitLogEntry } from '@/lib/api/types';
|
||||
|
||||
export type LaneColor = string;
|
||||
|
||||
/**
|
||||
* Describes one visible line/curve in a commit row's SVG.
|
||||
* Each segment covers the FULL row height (y=0 to y=100%).
|
||||
*
|
||||
* Types:
|
||||
* - 'passing' : straight vertical line, lane active but this row is not its commit
|
||||
* - 'commit-lane': straight vertical line for this commit's lane (has both incoming and outgoing)
|
||||
* - 'top-stub' : line from y=0 to dot-y only (branch HEAD — no child above)
|
||||
* - 'bottom-stub': line from dot-y to y=100% only (root commit — nothing above)
|
||||
* - 'branch-out' : bezier from (dot-x, dot-y) to (toLane-x, 100%) — new parent lane opens
|
||||
* - 'merge-in' : bezier from (fromLane-x, 0) to (dot-x, dot-y) — lane converges here
|
||||
*/
|
||||
export interface ConnectorSegment {
|
||||
fromLane: number;
|
||||
toLane: number;
|
||||
color: LaneColor;
|
||||
type: 'passing' | 'commit-lane' | 'top-stub' | 'bottom-stub' | 'branch-out' | 'merge-in';
|
||||
}
|
||||
|
||||
export interface LanedCommit {
|
||||
commit: GitLogEntry;
|
||||
lane: number;
|
||||
color: LaneColor;
|
||||
/** All visible line segments in this row's height. */
|
||||
connectors: ConnectorSegment[];
|
||||
}
|
||||
|
||||
const LANE_COLORS: LaneColor[] = [
|
||||
'var(--chart-1)',
|
||||
'var(--chart-2)',
|
||||
'var(--chart-3)',
|
||||
'var(--chart-4)',
|
||||
'var(--chart-5)',
|
||||
'var(--syntax-keyword)',
|
||||
'var(--syntax-string)',
|
||||
'var(--status-info)',
|
||||
];
|
||||
|
||||
export function laneColor(lane: number): LaneColor {
|
||||
return LANE_COLORS[lane % LANE_COLORS.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns visual lanes to a list of commits (newest-first order).
|
||||
*
|
||||
* Greedy lane assignment algorithm (O(n × lanes) where lanes = max concurrent active branches):
|
||||
* - activeLanes[i] holds the hash expected next on lane i (or null if free)
|
||||
* - Each commit takes the lane that was waiting for it, or the next free lane
|
||||
* - Merge commits open new lanes for additional parents
|
||||
* - Connectors describe ALL visible lines in each row (both above and below the dot)
|
||||
*/
|
||||
export function assignLanes(commits: GitLogEntry[]): LanedCommit[] {
|
||||
if (commits.length === 0) return [];
|
||||
|
||||
// activeLanes[i] = hash of the next commit expected on lane i, or null if free
|
||||
const activeLanes: Array<string | null> = [];
|
||||
|
||||
const result: LanedCommit[] = [];
|
||||
|
||||
for (let i = 0; i < commits.length; i++) {
|
||||
const commit = commits[i];
|
||||
|
||||
// Find all lanes waiting for this commit
|
||||
const waitingLanes: number[] = [];
|
||||
for (let li = 0; li < activeLanes.length; li++) {
|
||||
if (activeLanes[li] === commit.hash) {
|
||||
waitingLanes.push(li);
|
||||
}
|
||||
}
|
||||
|
||||
// Use the first waiting lane as the commit's lane
|
||||
let assignedLane = waitingLanes.length > 0 ? waitingLanes[0] : -1;
|
||||
if (assignedLane === -1) {
|
||||
// No existing lane claimed this commit; take the first free lane
|
||||
const freeLane = activeLanes.indexOf(null);
|
||||
if (freeLane !== -1) {
|
||||
assignedLane = freeLane;
|
||||
} else {
|
||||
assignedLane = activeLanes.length;
|
||||
activeLanes.push(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark other waiting lanes as converging here (will emit merge-in connectors)
|
||||
const convergingLanes = waitingLanes.slice(1);
|
||||
|
||||
const color = laneColor(assignedLane);
|
||||
const hasIncoming = activeLanes[assignedLane] === commit.hash;
|
||||
const hasParent = commit.parents.length > 0;
|
||||
|
||||
// Update this commit's lane to point at its first parent
|
||||
if (hasParent) {
|
||||
activeLanes[assignedLane] = commit.parents[0];
|
||||
} else {
|
||||
activeLanes[assignedLane] = null;
|
||||
}
|
||||
|
||||
// Open new lanes for additional parents (merge commits)
|
||||
const extraParentLanes: number[] = [];
|
||||
for (let p = 1; p < commit.parents.length; p++) {
|
||||
const parentHash = commit.parents[p];
|
||||
// Check if another lane is already waiting for this parent
|
||||
const existingLane = activeLanes.indexOf(parentHash);
|
||||
if (existingLane !== -1) {
|
||||
extraParentLanes.push(existingLane);
|
||||
} else {
|
||||
const freeLane = activeLanes.indexOf(null);
|
||||
const newLane = freeLane !== -1 ? freeLane : activeLanes.length;
|
||||
activeLanes[newLane] = parentHash;
|
||||
if (newLane === activeLanes.length) activeLanes.push(parentHash);
|
||||
extraParentLanes.push(newLane);
|
||||
}
|
||||
}
|
||||
|
||||
// Build connectors: ALL visible line segments in this row
|
||||
const connectors: ConnectorSegment[] = [];
|
||||
|
||||
// This commit's own lane segment
|
||||
if (hasIncoming && hasParent) {
|
||||
connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'commit-lane' });
|
||||
} else if (hasIncoming && !hasParent) {
|
||||
connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'top-stub' });
|
||||
} else if (!hasIncoming && hasParent) {
|
||||
connectors.push({ fromLane: assignedLane, toLane: assignedLane, color, type: 'bottom-stub' });
|
||||
}
|
||||
// else: orphan with no parent and no child — just the dot, no lines
|
||||
|
||||
// Merge-in connectors for converging lanes
|
||||
for (const convergingLane of convergingLanes) {
|
||||
connectors.push({
|
||||
fromLane: convergingLane,
|
||||
toLane: assignedLane,
|
||||
color: laneColor(convergingLane),
|
||||
type: 'merge-in',
|
||||
});
|
||||
// Clear the converging lane
|
||||
activeLanes[convergingLane] = null;
|
||||
}
|
||||
|
||||
// Branch-out segments for merge commit's extra parents
|
||||
for (const extraLane of extraParentLanes) {
|
||||
connectors.push({
|
||||
fromLane: assignedLane,
|
||||
toLane: extraLane,
|
||||
color: laneColor(extraLane),
|
||||
type: 'branch-out',
|
||||
});
|
||||
}
|
||||
|
||||
// Passing-through lanes (active but not this commit's lane or extra parent lanes)
|
||||
for (let lane = 0; lane < activeLanes.length; lane++) {
|
||||
if (activeLanes[lane] === null) continue;
|
||||
if (lane === assignedLane) continue;
|
||||
if (extraParentLanes.includes(lane)) continue;
|
||||
connectors.push({
|
||||
fromLane: lane,
|
||||
toLane: lane,
|
||||
color: laneColor(lane),
|
||||
type: 'passing',
|
||||
});
|
||||
}
|
||||
|
||||
result.push({ commit, lane: assignedLane, color, connectors });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user