mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-07-07 06:02:43 +08:00
feat(git): commit graph in history view and cross-spawn everywhere
- render a VSCode-style commit graph in the source control history: lane-assignment algorithm, SVG strip with colored rails, merge and branch curves, commit dots, and branch/tag badges per commit - /commits returns parents and ref decorations across all branches (--branches --remotes --tags --topo-order) using unit-separator fields so pipes in commit subjects can't break parsing - collect stats via a single --shortstat pass instead of one `git show --stat` call per commit; raise the history limit to 50 - replace child_process spawn with cross-spawn in all runtimes, routes, and services: it resolves .cmd shims and PATHEXT on Windows (fixing taskmaster's npx invocations) and delegates to native spawn elsewhere, removing the per-file win32 ternaries - unit tests for the log parser and lane assignment
This commit is contained in:
@@ -1,5 +1,3 @@
|
|||||||
import { spawn } from 'child_process';
|
|
||||||
|
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
import { appendImagesInputTag } from './shared/image-attachments.js';
|
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 { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for better command execution
|
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
// child_process.spawn everywhere else.
|
||||||
|
const spawnFunction = crossSpawn;
|
||||||
|
|
||||||
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
let activeCursorProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from 'child_process';
|
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import path from 'path';
|
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 { providerModelsService } from './modules/providers/services/provider-models.service.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
|
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell } from './shared/utils.js';
|
||||||
|
|
||||||
// Use cross-spawn on Windows for correct .cmd resolution (same pattern as cursor-cli.js)
|
// cross-spawn resolves .cmd shims/PATHEXT on Windows and delegates to
|
||||||
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
|
// child_process.spawn everywhere else.
|
||||||
|
const spawnFunction = crossSpawn;
|
||||||
|
|
||||||
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
let activeGeminiProcesses = new Map(); // Track active processes by session ID
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import fs, { promises as fsPromises } from 'fs';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import http from 'http';
|
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 express from 'express';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
import mime from 'mime-types';
|
import mime from 'mime-types';
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createRequire } from 'node:module';
|
import { createRequire } from 'node:module';
|
||||||
import { randomBytes, randomUUID } from 'node:crypto';
|
import { randomBytes, randomUUID } from 'node:crypto';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
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 { appConfigDb } from '@/modules/database/index.js';
|
||||||
import { providerMcpService } from '@/modules/providers/index.js';
|
import { providerMcpService } from '@/modules/providers/index.js';
|
||||||
import { getModuleDir } from '@/utils/runtime-paths.js';
|
import { getModuleDir } from '@/utils/runtime-paths.js';
|
||||||
@@ -270,8 +272,10 @@ function runCommand(command: string, args: string[]): Promise<void> {
|
|||||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||||
timer.unref?.();
|
timer.unref?.();
|
||||||
|
|
||||||
child.stdout.on('data', (chunk) => output.push(String(chunk)));
|
// stdio config above guarantees the pipes exist; cross-spawn's types
|
||||||
child.stderr.on('data', (chunk) => output.push(String(chunk)));
|
// 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('error', (error) => finish(() => reject(error)));
|
||||||
child.on('close', (code) => finish(() => {
|
child.on('close', (code) => finish(() => {
|
||||||
if (code === 0) {
|
if (code === 0) {
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
import { access, mkdir, rm } from 'node:fs/promises';
|
import { access, mkdir, rm } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
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 { githubTokensDb } from '@/modules/database/index.js';
|
||||||
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
import { createProject } from '@/modules/projects/services/project-management.service.js';
|
||||||
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
import type { WorkspacePathValidationResult } from '@/shared/types.js';
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { access, readdir } from 'node:fs/promises';
|
import { access, readdir } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn } from 'node:child_process';
|
|
||||||
|
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
@@ -589,7 +588,9 @@ type CursorModelRow = {
|
|||||||
|
|
||||||
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
const CURSOR_MODELS_TIMEOUT_MS = 10_000;
|
||||||
const CURSOR_CHATS_ROOT = path.join(os.homedir(), '.cursor', 'chats');
|
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(
|
const ANSI_PATTERN = new RegExp(
|
||||||
// eslint-disable-next-line no-control-regex
|
// eslint-disable-next-line no-control-regex
|
||||||
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
'[\\u001B\\u009B][[\\]()#;?]*(?:'
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { spawn } from 'node:child_process';
|
|
||||||
|
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import crossSpawn from 'cross-spawn';
|
import crossSpawn from 'cross-spawn';
|
||||||
|
|
||||||
@@ -67,7 +65,9 @@ export const OPENCODE_FALLBACK_MODELS: ProviderModelsDefinition = {
|
|||||||
|
|
||||||
const OPEN_CODE_MODELS_TIMEOUT_MS = 20_000;
|
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 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 DATE_TOKEN = /^\d{8}$/;
|
||||||
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
const SIMPLE_NUMBER_TOKEN = /^\d$/;
|
||||||
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
const VERSION_TOKEN = /^[a-z]\d+$/i;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { spawn } from 'child_process';
|
|
||||||
import fsSync from 'node:fs';
|
import fsSync from 'node:fs';
|
||||||
|
|
||||||
import crossSpawn from 'cross-spawn';
|
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 { notifyRunFailed, notifyRunStopped } from './services/notification-orchestrator.js';
|
||||||
import { createCompleteMessage, createNormalizedMessage, flattenPromptForWindowsShell, getOpenCodeDatabasePath } from './shared/utils.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();
|
const activeOpenCodeProcesses = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
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 path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import express from 'express';
|
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 path from 'path';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import { projectsDb } from '../modules/database/index.js';
|
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) => {
|
router.get('/commits', async (req, res) => {
|
||||||
const { project, limit = 10 } = req.query;
|
const { project, limit = 10 } = req.query;
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return res.status(400).json({ error: 'Project id is required' });
|
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
|
const safeLimit = Number.isFinite(parsedLimit) && parsedLimit > 0
|
||||||
? Math.min(parsedLimit, 100)
|
? Math.min(parsedLimit, 100)
|
||||||
: 10;
|
: 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(
|
const { stdout } = await spawnAsync(
|
||||||
'git',
|
'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 },
|
{ cwd: projectPath },
|
||||||
);
|
);
|
||||||
|
|
||||||
const commits = stdout
|
res.json({ commits: parseGitLogWithStats(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 });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Git commits error:', error);
|
console.error('Git commits error:', error);
|
||||||
res.json({ error: error.message });
|
res.json({ error: error.message });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
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
|
// Builds `git status --porcelain=v1 -z` output: NUL-separated entries with a
|
||||||
// trailing NUL, exactly as git emits it.
|
// trailing NUL, exactly as git emits it.
|
||||||
@@ -66,3 +66,41 @@ test('parseGitStatusOutput handles empty output', () => {
|
|||||||
staged: [],
|
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(''), []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ import express from 'express';
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { promises as fsPromises } from 'fs';
|
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 { projectsDb } from '../modules/database/index.js';
|
||||||
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
|
import { detectTaskMasterMCPServer } from '../utils/mcp-detector.js';
|
||||||
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
|
import { broadcastTaskMasterProjectUpdate, broadcastTaskMasterTasksUpdate } from '../utils/taskmaster-websocket.js';
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import express from 'express';
|
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 { userDb } from '../modules/database/index.js';
|
||||||
import { authenticateToken } from '../middleware/auth.js';
|
import { authenticateToken } from '../middleware/auth.js';
|
||||||
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
import { getSystemGitConfig } from '../utils/gitConfig.js';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
function spawnAsync(command, args) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { spawn } from 'child_process';
|
|
||||||
import path from 'path';
|
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';
|
import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
|
||||||
|
|
||||||
// Map<pluginName, { process, port }>
|
// Map<pluginName, { process, port }>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
|
import type { ConfirmActionType, FileStatusCode, GitStatusGroupEntry } from '../types/types';
|
||||||
|
|
||||||
export const DEFAULT_BRANCH = 'main';
|
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[] = [
|
export const FILE_STATUS_GROUPS: GitStatusGroupEntry[] = [
|
||||||
{ key: 'modified', status: 'M' },
|
{ key: 'modified', status: 'M' },
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ export type GitCommitSummary = {
|
|||||||
date: string;
|
date: string;
|
||||||
message: string;
|
message: string;
|
||||||
stats?: 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<string, string>;
|
export type GitDiffMap = Record<string, string>;
|
||||||
|
|||||||
83
src/components/git-panel/utils/commitGraph.test.ts
Normal file
83
src/components/git-panel/utils/commitGraph.test.ts
Normal file
@@ -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]);
|
||||||
|
});
|
||||||
138
src/components/git-panel/utils/commitGraph.ts
Normal file
138
src/components/git-panel/utils/commitGraph.ts
Normal file
@@ -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<number>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
95
src/components/git-panel/view/history/CommitGraphStrip.tsx
Normal file
95
src/components/git-panel/view/history/CommitGraphStrip.tsx
Normal file
@@ -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 (
|
||||||
|
<div aria-hidden className="relative shrink-0 self-stretch overflow-hidden" style={{ width }}>
|
||||||
|
<svg
|
||||||
|
className="absolute left-0 top-0"
|
||||||
|
width={width}
|
||||||
|
height={NODE_ZONE_HEIGHT}
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
{/* Lines passing straight through the row */}
|
||||||
|
{row.passThrough.map((lane) => (
|
||||||
|
<path
|
||||||
|
key={`pass-${lane}`}
|
||||||
|
d={`M ${laneX(lane)} 0 V ${NODE_ZONE_HEIGHT}`}
|
||||||
|
stroke={laneColor(lane)}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* The node's own lane arriving from above / continuing below */}
|
||||||
|
{row.hasTopContinuation && (
|
||||||
|
<path d={`M ${nodeX} 0 V ${NODE_Y}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
|
||||||
|
)}
|
||||||
|
{row.hasParentContinuation && (
|
||||||
|
<path d={`M ${nodeX} ${NODE_Y} V ${NODE_ZONE_HEIGHT}`} stroke={nodeColor} strokeWidth={STROKE_WIDTH} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Extra children merging into the node from the row above */}
|
||||||
|
{row.inbound.map((lane) => (
|
||||||
|
<path
|
||||||
|
key={`in-${lane}`}
|
||||||
|
d={`M ${laneX(lane)} 0 Q ${laneX(lane)} ${NODE_Y} ${nodeX} ${NODE_Y}`}
|
||||||
|
stroke={laneColor(lane)}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Extra parents branching out of the node toward the row below */}
|
||||||
|
{row.outbound.map((lane) => (
|
||||||
|
<path
|
||||||
|
key={`out-${lane}`}
|
||||||
|
d={`M ${nodeX} ${NODE_Y} Q ${laneX(lane)} ${NODE_Y} ${laneX(lane)} ${NODE_ZONE_HEIGHT}`}
|
||||||
|
stroke={laneColor(lane)}
|
||||||
|
strokeWidth={STROKE_WIDTH}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Commit dot — slightly larger for merge/fork points */}
|
||||||
|
<circle
|
||||||
|
cx={nodeX}
|
||||||
|
cy={NODE_Y}
|
||||||
|
r={row.inbound.length > 0 || row.outbound.length > 0 ? NODE_RADIUS + 0.5 : NODE_RADIUS}
|
||||||
|
fill={nodeColor}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* Rails continuing below the node zone (through expanded content) */}
|
||||||
|
{row.bottomLanes.map((lane) => (
|
||||||
|
<div
|
||||||
|
key={`rail-${lane}`}
|
||||||
|
className="absolute"
|
||||||
|
style={{
|
||||||
|
left: laneX(lane) - STROKE_WIDTH / 2,
|
||||||
|
top: NODE_ZONE_HEIGHT,
|
||||||
|
bottom: 0,
|
||||||
|
width: STROKE_WIDTH,
|
||||||
|
backgroundColor: laneColor(lane),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
import { ChevronDown, ChevronRight, GitBranch, Tag } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import type { GitCommitSummary } from '../../types/types';
|
import type { GitCommitSummary } from '../../types/types';
|
||||||
|
import type { CommitGraphRow } from '../../utils/commitGraph';
|
||||||
|
import { laneColor } from '../../utils/commitGraph';
|
||||||
import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';
|
import { getStatusBadgeClass, parseCommitFiles } from '../../utils/gitPanelUtils';
|
||||||
import GitDiffViewer from '../shared/GitDiffViewer';
|
import GitDiffViewer from '../shared/GitDiffViewer';
|
||||||
|
import CommitGraphStrip from './CommitGraphStrip';
|
||||||
|
|
||||||
function formatDate(dateString: string): string {
|
function formatDate(dateString: string): string {
|
||||||
return new Date(dateString).toLocaleDateString('en-US', {
|
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 (
|
||||||
|
<span
|
||||||
|
className="inline-flex max-w-40 items-center gap-1 rounded-full border px-1.5 py-px text-[10px] font-medium leading-4"
|
||||||
|
style={{
|
||||||
|
borderColor: color,
|
||||||
|
color,
|
||||||
|
backgroundColor: isHead ? `${color}22` : 'transparent',
|
||||||
|
}}
|
||||||
|
title={refName}
|
||||||
|
>
|
||||||
|
{isTag ? <Tag className="h-2.5 w-2.5 shrink-0" /> : <GitBranch className="h-2.5 w-2.5 shrink-0" />}
|
||||||
|
<span className="truncate">{label}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type CommitHistoryItemProps = {
|
type CommitHistoryItemProps = {
|
||||||
commit: GitCommitSummary;
|
commit: GitCommitSummary;
|
||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
diff?: string;
|
diff?: string;
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
wrapText: boolean;
|
wrapText: boolean;
|
||||||
|
graphRow?: CommitGraphRow;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -27,6 +54,7 @@ export default function CommitHistoryItem({
|
|||||||
diff,
|
diff,
|
||||||
isMobile,
|
isMobile,
|
||||||
wrapText,
|
wrapText,
|
||||||
|
graphRow,
|
||||||
onToggle,
|
onToggle,
|
||||||
}: CommitHistoryItemProps) {
|
}: CommitHistoryItemProps) {
|
||||||
const fileSummary = useMemo(() => {
|
const fileSummary = useMemo(() => {
|
||||||
@@ -34,8 +62,12 @@ export default function CommitHistoryItem({
|
|||||||
return parseCommitFiles(diff);
|
return parseCommitFiles(diff);
|
||||||
}, [diff]);
|
}, [diff]);
|
||||||
|
|
||||||
|
const badgeColor = graphRow ? laneColor(graphRow.nodeLane) : 'var(--color-primary, #0ea5e9)';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-b border-border last:border-0">
|
<div className="flex border-b border-border last:border-0">
|
||||||
|
{graphRow && <CommitGraphStrip row={graphRow} />}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={isExpanded}
|
aria-expanded={isExpanded}
|
||||||
@@ -48,6 +80,13 @@ export default function CommitHistoryItem({
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
|
{commit.refs && commit.refs.length > 0 && (
|
||||||
|
<span className="mb-0.5 flex flex-wrap gap-1">
|
||||||
|
{commit.refs.map((refName) => (
|
||||||
|
<RefBadge key={refName} refName={refName} color={badgeColor} />
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<p className="truncate text-sm font-medium text-foreground">{commit.message}</p>
|
<p className="truncate text-sm font-medium text-foreground">{commit.message}</p>
|
||||||
<p className="mt-1 text-sm text-muted-foreground">
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
{commit.author}
|
{commit.author}
|
||||||
@@ -145,6 +184,7 @@ export default function CommitHistoryItem({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { History, RefreshCw } from 'lucide-react';
|
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 type { GitDiffMap, GitCommitSummary } from '../../types/types';
|
||||||
|
import { computeCommitGraph } from '../../utils/commitGraph';
|
||||||
import CommitHistoryItem from './CommitHistoryItem';
|
import CommitHistoryItem from './CommitHistoryItem';
|
||||||
|
|
||||||
type HistoryViewProps = {
|
type HistoryViewProps = {
|
||||||
@@ -22,6 +23,15 @@ export default function HistoryView({
|
|||||||
}: HistoryViewProps) {
|
}: HistoryViewProps) {
|
||||||
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(new Set());
|
const [expandedCommits, setExpandedCommits] = useState<Set<string>>(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(
|
const toggleCommitExpanded = useCallback(
|
||||||
(commitHash: string) => {
|
(commitHash: string) => {
|
||||||
const isExpanding = !expandedCommits.has(commitHash);
|
const isExpanding = !expandedCommits.has(commitHash);
|
||||||
@@ -59,7 +69,7 @@ export default function HistoryView({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={isMobile ? 'pb-4' : ''}>
|
<div className={isMobile ? 'pb-4' : ''}>
|
||||||
{recentCommits.map((commit) => (
|
{recentCommits.map((commit, index) => (
|
||||||
<CommitHistoryItem
|
<CommitHistoryItem
|
||||||
key={commit.hash}
|
key={commit.hash}
|
||||||
commit={commit}
|
commit={commit}
|
||||||
@@ -67,6 +77,7 @@ export default function HistoryView({
|
|||||||
diff={commitDiffs[commit.hash]}
|
diff={commitDiffs[commit.hash]}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
wrapText={wrapText}
|
wrapText={wrapText}
|
||||||
|
graphRow={graphRows?.[index]}
|
||||||
onToggle={() => toggleCommitExpanded(commit.hash)}
|
onToggle={() => toggleCommitExpanded(commit.hash)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
Reference in New Issue
Block a user