diff --git a/server/cursor-cli.js b/server/cursor-cli.js index 4cba51ce..979415cc 100644 --- a/server/cursor-cli.js +++ b/server/cursor-cli.js @@ -1,5 +1,3 @@ -import { spawn } from 'child_process'; - import crossSpawn from 'cross-spawn'; import { appendImagesInputTag } from './shared/image-attachments.js'; @@ -9,8 +7,9 @@ import { providerAuthService } from './modules/providers/services/provider-auth. import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js'; -// Use cross-spawn on Windows for better command execution -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; let activeCursorProcesses = new Map(); // Track active processes by session ID diff --git a/server/gemini-cli.js b/server/gemini-cli.js index a3953a7a..34e53b3f 100644 --- a/server/gemini-cli.js +++ b/server/gemini-cli.js @@ -1,4 +1,3 @@ -import { spawn } from 'child_process'; import { promises as fs } from 'fs'; import os from 'os'; import path from 'path'; @@ -13,8 +12,9 @@ import { providerAuthService } from './modules/providers/services/provider-auth. import { providerModelsService } from './modules/providers/services/provider-models.service.js'; import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js'; -// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js) -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; let activeGeminiProcesses = new Map(); // Track active processes by session ID diff --git a/server/index.js b/server/index.js index 467fe307..c7cf322d 100755 --- a/server/index.js +++ b/server/index.js @@ -5,8 +5,10 @@ import fs, { promises as fsPromises } from 'fs'; import path from 'path'; import os from 'os'; import http from 'http'; -import { spawn } from 'child_process'; +// cross-spawn is a drop-in for child_process.spawn that resolves .cmd +// shims/PATHEXT on Windows and delegates to the native spawn elsewhere. +import spawn from 'cross-spawn'; import express from 'express'; import cors from 'cors'; import mime from 'mime-types'; diff --git a/server/modules/browser-use/browser-use.service.ts b/server/modules/browser-use/browser-use.service.ts index 280ff730..3bda8e78 100644 --- a/server/modules/browser-use/browser-use.service.ts +++ b/server/modules/browser-use/browser-use.service.ts @@ -1,10 +1,12 @@ import { createRequire } from 'node:module'; import { randomBytes, randomUUID } from 'node:crypto'; -import { spawn } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; + import { appConfigDb } from '@/modules/database/index.js'; import { providerMcpService } from '@/modules/providers/index.js'; import { getModuleDir } from '@/utils/runtime-paths.js'; @@ -270,8 +272,10 @@ function runCommand(command: string, args: string[]): Promise { }, INSTALL_COMMAND_TIMEOUT_MS); timer.unref?.(); - child.stdout.on('data', (chunk) => output.push(String(chunk))); - child.stderr.on('data', (chunk) => output.push(String(chunk))); + // stdio config above guarantees the pipes exist; cross-spawn's types + // just don't narrow them the way node's spawn overloads do. + child.stdout?.on('data', (chunk) => output.push(String(chunk))); + child.stderr?.on('data', (chunk) => output.push(String(chunk))); child.on('error', (error) => finish(() => reject(error))); child.on('close', (code) => finish(() => { if (code === 0) { diff --git a/server/modules/projects/services/project-clone.service.ts b/server/modules/projects/services/project-clone.service.ts index 1a91b879..4211f560 100644 --- a/server/modules/projects/services/project-clone.service.ts +++ b/server/modules/projects/services/project-clone.service.ts @@ -1,7 +1,9 @@ -import { spawn } from 'node:child_process'; import { access, mkdir, rm } from 'node:fs/promises'; import path from 'node:path'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; + import { githubTokensDb } from '@/modules/database/index.js'; import { createProject } from '@/modules/projects/services/project-management.service.js'; import type { WorkspacePathValidationResult } from '@/shared/types.js'; diff --git a/server/modules/providers/list/cursor/cursor-models.provider.ts b/server/modules/providers/list/cursor/cursor-models.provider.ts index da8daaed..bf2c4881 100644 --- a/server/modules/providers/list/cursor/cursor-models.provider.ts +++ b/server/modules/providers/list/cursor/cursor-models.provider.ts @@ -1,7 +1,6 @@ import { access, readdir } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; -import { spawn } from 'node:child_process'; import crossSpawn from 'cross-spawn'; @@ -589,7 +588,9 @@ type CursorModelRow = { const CURSOR_MODELS_TIMEOUT_MS = 10_000; const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats'); -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; const ANSI_PATTERN = new RegExp( // eslint-disable-next-line no-control-regex '[\\u001B\\u009B][[\\]()#;?]*(?:' diff --git a/server/modules/providers/list/opencode/opencode-models.provider.ts b/server/modules/providers/list/opencode/opencode-models.provider.ts index 6891b4df..592eeb1d 100644 --- a/server/modules/providers/list/opencode/opencode-models.provider.ts +++ b/server/modules/providers/list/opencode/opencode-models.provider.ts @@ -1,5 +1,3 @@ -import { spawn } from 'node:child_process'; - import Database from 'better-sqlite3'; import crossSpawn from 'cross-spawn'; @@ -67,7 +65,9 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = { const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000; const MODEL_ID_LINE = /^[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*$/i; -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; const DATE_TOKEN = /^\d{8}$/; const SIMPLE_NUMBER_TOKEN = /^\d$/; const VERSION_TOKEN = /^[a-z]\d+$/i; diff --git a/server/opencode-cli.js b/server/opencode-cli.js index c165e48e..9b961f07 100644 --- a/server/opencode-cli.js +++ b/server/opencode-cli.js @@ -1,4 +1,3 @@ -import { spawn } from 'child_process'; import fsSync from 'node:fs'; import crossSpawn from 'cross-spawn'; @@ -11,7 +10,9 @@ import { providerModelsService } from './modules/providers/services/provider-mod import { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js'; import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.js'; -const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn; +// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to +// child_process.spawn everywhere else. +const spawnFunction = crossSpawn; const activeOpenCodeProcesses = new Map(); diff --git a/server/routes/agent.js b/server/routes/agent.js index 8207d5c0..2f9a29e2 100644 --- a/server/routes/agent.js +++ b/server/routes/agent.js @@ -1,5 +1,6 @@ import express from 'express'; -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import path from 'path'; import os from 'os'; import { promises as fs } from 'fs'; diff --git a/server/routes/git.js b/server/routes/git.js index 8a2cf293..90801719 100755 --- a/server/routes/git.js +++ b/server/routes/git.js @@ -1,5 +1,6 @@ import express from 'express'; -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import path from 'path'; import { promises as fs } from 'fs'; import { projectsDb } from '../modules/database/index.js'; @@ -861,10 +862,57 @@ router.post('/delete-branch', async (req, res) => { } }); -// Get recent commits +// Fields are joined with the ASCII unit separator so pipes (or anything else +// typed into a commit subject) cannot break parsing. +const GIT_LOG_FIELD_SEPARATOR = '\u001f'; +const GIT_LOG_PRETTY_FORMAT = '%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s'; + +/** + * Parses `git log --shortstat` output produced with GIT_LOG_PRETTY_FORMAT. + * + * Each commit is one format line (hash, parent hashes, ref decorations, + * author, email, date, subject) optionally followed by its `--shortstat` + * summary line ("N files changed, ..."). Parents and refs feed the commit + * graph rendered by the History view; merge commits carry no shortstat line, + * so their `stats` stays empty. + * + * Exported for tests. + */ +export function parseGitLogWithStats(stdout) { + const commits = []; + + for (const rawLine of stdout.split('\n')) { + const line = rawLine.trimEnd(); + if (!line.trim()) continue; + + if (line.includes(GIT_LOG_FIELD_SEPARATOR)) { + const [hash, parents, refs, author, email, date, ...messageParts] = line.split(GIT_LOG_FIELD_SEPARATOR); + commits.push({ + hash, + parents: parents ? parents.split(' ').filter(Boolean) : [], + // `%D` decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0". + refs: refs ? refs.split(', ').filter(Boolean) : [], + author, + email, + date, + message: messageParts.join(GIT_LOG_FIELD_SEPARATOR), + stats: '' + }); + continue; + } + + if (commits.length > 0 && /files? changed/.test(line)) { + commits[commits.length - 1].stats = line.trim(); + } + } + + return commits; +} + +// Get recent commits (across all branches, in graph order) router.get('/commits', async (req, res) => { const { project, limit = 10 } = req.query; - + if (!project) { return res.status(400).json({ error: 'Project id is required' }); } @@ -876,42 +924,28 @@ router.get('/commits', async (req, res) => { const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 10; - - // Get commit log with stats + + // Branches/remotes/tags (not --all, which would drag in refs/stash) with + // `--topo-order` guarantee children appear before their parents across + // every branch, which the frontend lane-assignment relies on. + // `--shortstat` replaces the previous per-commit `git show --stat` calls. const { stdout } = await spawnAsync( 'git', - ['log', '--pretty=format:%H|%an|%ae|%ad|%s', '--date=iso-strict', '-n', String(safeLimit)], + [ + 'log', + '--branches', + '--remotes', + '--tags', + '--topo-order', + '--shortstat', + `--pretty=format:${GIT_LOG_PRETTY_FORMAT}`, + '--date=iso-strict', + '-n', String(safeLimit) + ], { cwd: projectPath }, ); - - const commits = stdout - .split('\n') - .filter(line => line.trim()) - .map(line => { - const [hash, author, email, date, ...messageParts] = line.split('|'); - return { - hash, - author, - email, - date, - message: messageParts.join('|') - }; - }); - - // Get stats for each commit - for (const commit of commits) { - try { - const { stdout: stats } = await spawnAsync( - 'git', ['show', '--stat', '--format=', commit.hash], - { cwd: projectPath } - ); - commit.stats = stats.trim().split('\n').pop(); // Get the summary line - } catch (error) { - commit.stats = ''; - } - } - - res.json({ commits }); + + res.json({ commits: parseGitLogWithStats(stdout) }); } catch (error) { console.error('Git commits error:', error); res.json({ error: error.message }); diff --git a/server/routes/git.test.js b/server/routes/git.test.js index 43a1e7d7..9dcda053 100644 --- a/server/routes/git.test.js +++ b/server/routes/git.test.js @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { parseGitStatusOutput } from './git.js'; +import { parseGitLogWithStats, parseGitStatusOutput } from './git.js'; // Builds `git status --porcelain=v1 -z` output: NUL-separated entries with a // trailing NUL, exactly as git emits it. @@ -66,3 +66,41 @@ test('parseGitStatusOutput handles empty output', () => { staged: [], }); }); + +// Builds one `git log --pretty=format:%H%x1f%P%x1f%D%x1f%an%x1f%ae%x1f%ad%x1f%s` line. +const US = ''; +const logLine = (hash, parents, refs, subject) => + [hash, parents, refs, 'Alice', 'a@x.com', '2026-07-06T10:00:00+03:00', subject].join(US); + +test('parseGitLogWithStats parses commits with parents, refs, and shortstat lines', () => { + const output = [ + logLine('c3', 'c2', 'HEAD -> main, origin/main, tag: v1.0', 'feat: add | pipes | to subject'), + ' 3 files changed, 10 insertions(+), 2 deletions(-)', + '', + logLine('c2', 'c1 c0', '', 'Merge branch feature'), + '', + logLine('c0', '', '', 'initial commit'), + ' 1 file changed, 1 insertion(+)', + ].join('\n'); + + const commits = parseGitLogWithStats(output); + + assert.equal(commits.length, 3); + assert.deepEqual(commits[0].parents, ['c2']); + assert.deepEqual(commits[0].refs, ['HEAD -> main', 'origin/main', 'tag: v1.0']); + // Pipes in the subject survive because fields are joined with . + assert.equal(commits[0].message, 'feat: add | pipes | to subject'); + assert.equal(commits[0].stats, '3 files changed, 10 insertions(+), 2 deletions(-)'); + + // Merge commit: two parents, no shortstat line. + assert.deepEqual(commits[1].parents, ['c1', 'c0']); + assert.equal(commits[1].stats, ''); + + // Root commit: no parents. + assert.deepEqual(commits[2].parents, []); + assert.equal(commits[2].stats, '1 file changed, 1 insertion(+)'); +}); + +test('parseGitLogWithStats handles empty output', () => { + assert.deepEqual(parseGitLogWithStats(''), []); +}); diff --git a/server/routes/taskmaster.js b/server/routes/taskmaster.js index 01a8d801..64dcde37 100644 --- a/server/routes/taskmaster.js +++ b/server/routes/taskmaster.js @@ -12,7 +12,9 @@ import express from 'express'; import fs from 'fs'; import path from 'path'; import { promises as fsPromises } from 'fs'; -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution — required +// here since task-master/npx are .cmd shims on Windows. +import spawn from 'cross-spawn'; import { projectsDb } from '../modules/database/index.js'; import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js'; import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js'; diff --git a/server/routes/user.js b/server/routes/user.js index dcb8ecd7..95215a8c 100644 --- a/server/routes/user.js +++ b/server/routes/user.js @@ -1,8 +1,9 @@ import express from 'express'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import { userDb } from '../modules/database/index.js'; import { authenticateToken } from '../middleware/auth.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; -import { spawn } from 'child_process'; const router = express.Router(); diff --git a/server/utils/gitConfig.js b/server/utils/gitConfig.js index 586933a0..f418b397 100644 --- a/server/utils/gitConfig.js +++ b/server/utils/gitConfig.js @@ -1,4 +1,5 @@ -import { spawn } from 'child_process'; +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; function spawnAsync(command, args) { return new Promise((resolve, reject) => { diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js index f7e325af..847b50bb 100644 --- a/server/utils/plugin-process-manager.js +++ b/server/utils/plugin-process-manager.js @@ -1,5 +1,7 @@ -import { spawn } from 'child_process'; import path from 'path'; + +// cross-spawn: drop-in spawn with Windows .cmd/PATHEXT resolution. +import spawn from 'cross-spawn'; import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js'; // Map diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 5e2ff191..414e191c 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -1,7 +1,8 @@ import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types'; export const DEFAULT_BRANCH = 'main'; -export const RECENT_COMMITS_LIMIT = 10; +// High enough for the commit graph to show meaningful branch structure. +export const RECENT_COMMITS_LIMIT = 50; export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [ { key: 'modified', status: 'M' }, diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index 565d6f72..eb6ef196 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -51,6 +51,10 @@ export type GitCommitSummary = { date: string; message: string; stats?: string; + /** Parent commit hashes — drives the History view commit graph. */ + parents?: string[]; + /** Ref decorations, e.g. "HEAD -> main", "origin/main", "tag: v1.0". */ + refs?: string[]; }; export type GitDiffMap = Record; diff --git a/src/components/git-panel/utils/commitGraph.test.ts b/src/components/git-panel/utils/commitGraph.test.ts new file mode 100644 index 00000000..7cdd93b1 --- /dev/null +++ b/src/components/git-panel/utils/commitGraph.test.ts @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { computeCommitGraph } from './commitGraph'; + +test('linear history stays in a single lane', () => { + const rows = computeCommitGraph([ + { hash: 'c3', parents: ['c2'] }, + { hash: 'c2', parents: ['c1'] }, + { hash: 'c1', parents: [] }, + ]); + + assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0, 0]); + assert.deepEqual(rows.map((row) => row.laneCount), [1, 1, 1]); + // Tip has no line from above; root has no line below. + assert.equal(rows[0].hasTopContinuation, false); + assert.equal(rows[0].hasParentContinuation, true); + assert.equal(rows[2].hasParentContinuation, false); + assert.deepEqual(rows[1].passThrough, []); +}); + +test('merge commit opens a second lane that joins back at the fork point', () => { + // main: m2 --- m1 --- base + // feature: \- f1 -/ (m2 merges f1; both branch from base) + const rows = computeCommitGraph([ + { hash: 'm2', parents: ['m1', 'f1'] }, + { hash: 'm1', parents: ['base'] }, + { hash: 'f1', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]); + + // Merge commit sits in lane 0 and branches a line out to lane 1. + assert.equal(rows[0].nodeLane, 0); + assert.deepEqual(rows[0].outbound, [1]); + assert.equal(rows[0].laneCount, 2); + + // m1 passes lane 1 (feature line) straight through. + assert.equal(rows[1].nodeLane, 0); + assert.deepEqual(rows[1].passThrough, [1]); + + // f1 is the feature commit in lane 1; lane 0 (main) passes through. + assert.equal(rows[2].nodeLane, 1); + assert.deepEqual(rows[2].passThrough, [0]); + + // base: both lanes converge — lane 1 merges into the node in lane 0. + assert.equal(rows[3].nodeLane, 0); + assert.deepEqual(rows[3].inbound, [1]); + assert.deepEqual(rows[3].bottomLanes, []); +}); + +test('independent branch tips get their own lanes', () => { + // Two branch tips pointing at the same parent (e.g. main and a feature). + const rows = computeCommitGraph([ + { hash: 'tipA', parents: ['base'] }, + { hash: 'tipB', parents: ['base'] }, + { hash: 'base', parents: [] }, + ]); + + assert.equal(rows[0].nodeLane, 0); + assert.equal(rows[1].nodeLane, 1); + // Both lanes collapse into base. + assert.equal(rows[2].nodeLane, 0); + assert.deepEqual(rows[2].inbound, [1]); +}); + +test('freed lanes are reused by later branch tips', () => { + const rows = computeCommitGraph([ + { hash: 'a2', parents: ['a1'] }, + { hash: 'a1', parents: [] }, // lane 0 ends here + { hash: 'b1', parents: [] }, // new tip should reuse lane 0 + ]); + + assert.equal(rows[2].nodeLane, 0); + assert.equal(rows[2].laneCount, 1); +}); + +test('commits without parents metadata degrade gracefully', () => { + const rows = computeCommitGraph([{ hash: 'x' }, { hash: 'y' }]); + // Without parent info every commit is a standalone tip; the second one + // reuses the freed lane. + assert.deepEqual(rows.map((row) => row.nodeLane), [0, 0]); + assert.deepEqual(rows.map((row) => row.hasParentContinuation), [false, false]); +}); diff --git a/src/components/git-panel/utils/commitGraph.ts b/src/components/git-panel/utils/commitGraph.ts new file mode 100644 index 00000000..e7ba4107 --- /dev/null +++ b/src/components/git-panel/utils/commitGraph.ts @@ -0,0 +1,138 @@ +/** + * Lane assignment for the History view commit graph (VSCode Git Graph style). + * + * Commits must arrive in graph order (children before their parents — the + * backend guarantees this via `git log --all --topo-order`). Each commit is + * assigned a lane; lines connect commits to their parents across rows. + */ + +export type CommitGraphRow = { + /** Lane the commit dot sits in. */ + nodeLane: number; + /** Total lanes visible in this row — determines the strip width. */ + laneCount: number; + /** A line arrives at the node from the row above (some child expects this commit). */ + hasTopContinuation: boolean; + /** The node's own lane continues below toward its first parent. */ + hasParentContinuation: boolean; + /** Extra top lanes that merge into the node (multiple children / branch tips joining). */ + inbound: number[]; + /** Bottom lanes branching out of the node toward its extra parents (merge commits). */ + outbound: number[]; + /** Lanes whose lines pass straight through this row untouched. */ + passThrough: number[]; + /** Every lane still active below this row — rails continue through expanded content. */ + bottomLanes: number[]; +}; + +type GraphCommit = { + hash: string; + parents?: string[]; +}; + +// Colors cycle per lane, VSCode Git Graph style. Chosen to stay readable on +// both light and dark backgrounds. +const GRAPH_COLORS = [ + '#0ea5e9', // sky + '#f97316', // orange + '#a855f7', // purple + '#22c55e', // green + '#ef4444', // red + '#eab308', // yellow + '#14b8a6', // teal + '#ec4899', // pink + '#6366f1', // indigo + '#84cc16', // lime +]; + +export const laneColor = (lane: number) => GRAPH_COLORS[lane % GRAPH_COLORS.length]; + +export function computeCommitGraph(commits: GraphCommit[]): CommitGraphRow[] { + // Each slot holds the commit hash that lane is waiting to reach, or null + // when the lane is free. + const lanes: (string | null)[] = []; + const rows: CommitGraphRow[] = []; + + const takeFirstFreeLane = (): number => { + const free = lanes.indexOf(null); + if (free !== -1) { + return free; + } + lanes.push(null); + return lanes.length - 1; + }; + + for (const commit of commits) { + const activeBefore = new Set(); + lanes.forEach((expected, index) => { + if (expected !== null) { + activeBefore.add(index); + } + }); + + // Lanes whose next expected commit is this one. + const waiting: number[] = []; + lanes.forEach((expected, index) => { + if (expected === commit.hash) { + waiting.push(index); + } + }); + + const hasTopContinuation = waiting.length > 0; + const nodeLane = hasTopContinuation ? waiting[0] : takeFirstFreeLane(); + + // Additional lanes converging on this commit merge into the node and free up. + const inbound = waiting.slice(1); + for (const lane of inbound) { + lanes[lane] = null; + } + + const parents = commit.parents ?? []; + lanes[nodeLane] = parents.length > 0 ? parents[0] : null; + + // Extra parents (merge commits) either join a lane already heading to that + // parent or open a new lane for it. + const outbound: number[] = []; + for (const parent of parents.slice(1)) { + const existing = lanes.findIndex((expected) => expected === parent); + if (existing !== -1 && existing !== nodeLane) { + outbound.push(existing); + } else { + const lane = takeFirstFreeLane(); + lanes[lane] = parent; + outbound.push(lane); + } + } + + const passThrough = [...activeBefore] + .filter((lane) => lane !== nodeLane && !waiting.includes(lane)) + .sort((a, b) => a - b); + + const bottomLanes: number[] = []; + lanes.forEach((expected, index) => { + if (expected !== null) { + bottomLanes.push(index); + } + }); + + const laneCount = Math.max(lanes.length, nodeLane + 1); + + // Keep the lane array tight so later rows don't inherit phantom width. + while (lanes.length > 0 && lanes[lanes.length - 1] === null) { + lanes.pop(); + } + + rows.push({ + nodeLane, + laneCount, + hasTopContinuation, + hasParentContinuation: parents.length > 0, + inbound, + outbound, + passThrough, + bottomLanes, + }); + } + + return rows; +} diff --git a/src/components/git-panel/view/history/CommitGraphStrip.tsx b/src/components/git-panel/view/history/CommitGraphStrip.tsx new file mode 100644 index 00000000..4e149e52 --- /dev/null +++ b/src/components/git-panel/view/history/CommitGraphStrip.tsx @@ -0,0 +1,95 @@ +import type { CommitGraphRow } from '../../utils/commitGraph'; +import { laneColor } from '../../utils/commitGraph'; + +// Geometry: each lane is a fixed-width column; the commit dot sits at NODE_Y +// inside a fixed-height top zone (matching the collapsed row header), and +// plain rails continue below it so lines stretch through expanded content. +const LANE_WIDTH = 12; +const NODE_ZONE_HEIGHT = 56; +const NODE_Y = 28; +const NODE_RADIUS = 3.5; +const STROKE_WIDTH = 2; + +const laneX = (lane: number) => lane * LANE_WIDTH + LANE_WIDTH / 2; + +type CommitGraphStripProps = { + row: CommitGraphRow; +}; + +export default function CommitGraphStrip({ row }: CommitGraphStripProps) { + const width = row.laneCount * LANE_WIDTH; + const nodeX = laneX(row.nodeLane); + const nodeColor = laneColor(row.nodeLane); + + return ( +
+ + {/* Lines passing straight through the row */} + {row.passThrough.map((lane) => ( + + ))} + + {/* The node's own lane arriving from above / continuing below */} + {row.hasTopContinuation && ( + + )} + {row.hasParentContinuation && ( + + )} + + {/* Extra children merging into the node from the row above */} + {row.inbound.map((lane) => ( + + ))} + + {/* Extra parents branching out of the node toward the row below */} + {row.outbound.map((lane) => ( + + ))} + + {/* Commit dot — slightly larger for merge/fork points */} + 0 || row.outbound.length > 0 ? NODE_RADIUS + 0.5 : NODE_RADIUS} + fill={nodeColor} + /> + + + {/* Rails continuing below the node zone (through expanded content) */} + {row.bottomLanes.map((lane) => ( +
+ ))} +
+ ); +} diff --git a/src/components/git-panel/view/history/CommitHistoryItem.tsx b/src/components/git-panel/view/history/CommitHistoryItem.tsx index 33a41394..f07449a7 100644 --- a/src/components/git-panel/view/history/CommitHistoryItem.tsx +++ b/src/components/git-panel/view/history/CommitHistoryItem.tsx @@ -1,8 +1,11 @@ -import { ChevronDown, ChevronRight } from 'lucide-react'; +import { ChevronDown, ChevronRight, GitBranch, Tag } from 'lucide-react'; import { useMemo } from 'react'; import type { GitCommitSummary } from '../../types/types'; +import type { CommitGraphRow } from '../../utils/commitGraph'; +import { laneColor } from '../../utils/commitGraph'; import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils'; import GitDiffViewer from '../shared/GitDiffViewer'; +import CommitGraphStrip from './CommitGraphStrip'; function formatDate(dateString: string): string { return new Date(dateString).toLocaleDateString('en-US', { @@ -12,12 +15,36 @@ function formatDate(dateString: string): string { }); } +// One "HEAD -> main" / "origin/x" / "tag: v1" decoration pill next to the +// commit message, tinted with the commit's graph lane color. +function RefBadge({ refName, color }: { refName: string; color: string }) { + const isTag = refName.startsWith('tag: '); + const isHead = refName.startsWith('HEAD -> '); + const label = isTag ? refName.slice(5) : isHead ? refName.slice(8) : refName; + + return ( + + {isTag ? : } + {label} + + ); +} + type CommitHistoryItemProps = { commit: GitCommitSummary; isExpanded: boolean; diff?: string; isMobile: boolean; wrapText: boolean; + graphRow?: CommitGraphRow; onToggle: () => void; }; @@ -27,6 +54,7 @@ export default function CommitHistoryItem({ diff, isMobile, wrapText, + graphRow, onToggle, }: CommitHistoryItemProps) { const fileSummary = useMemo(() => { @@ -34,8 +62,12 @@ export default function CommitHistoryItem({ return parseCommitFiles(diff); }, [diff]); + const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : 'var(--color-primary, #0ea5e9)'; + return ( -
+
+ {graphRow && } +
); } diff --git a/src/components/git-panel/view/history/HistoryView.tsx b/src/components/git-panel/view/history/HistoryView.tsx index 4636af20..26664c38 100644 --- a/src/components/git-panel/view/history/HistoryView.tsx +++ b/src/components/git-panel/view/history/HistoryView.tsx @@ -1,6 +1,7 @@ import { History, RefreshCw } from 'lucide-react'; -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import type { GitDiffMap, GitCommitSummary } from '../../types/types'; +import { computeCommitGraph } from '../../utils/commitGraph'; import CommitHistoryItem from './CommitHistoryItem'; type HistoryViewProps = { @@ -22,6 +23,15 @@ export default function HistoryView({ }: HistoryViewProps) { const [expandedCommits, setExpandedCommits] = useState>(new Set()); + // Lane layout for the commit graph; rows align 1:1 with recentCommits. + // Older API responses without `parents` degrade to plain rows (no strip). + const graphRows = useMemo(() => { + if (!recentCommits.some((commit) => commit.parents !== undefined)) { + return null; + } + return computeCommitGraph(recentCommits); + }, [recentCommits]); + const toggleCommitExpanded = useCallback( (commitHash: string) => { const isExpanding = !expandedCommits.has(commitHash); @@ -59,7 +69,7 @@ export default function HistoryView({
) : (
- {recentCommits.map((commit) => ( + {recentCommits.map((commit, index) => ( toggleCommitExpanded(commit.hash)} /> ))}