diff --git a/src/main/git/history.ts b/src/main/git/history.ts new file mode 100644 index 00000000000..080f2fba154 --- /dev/null +++ b/src/main/git/history.ts @@ -0,0 +1,14 @@ +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' +import { loadGitHistoryFromExecutor } from '../../shared/git-history' +import { gitExecFileAsync } from './runner' + +export async function getHistory( + worktreePath: string, + options: GitHistoryOptions = {} +): Promise { + return loadGitHistoryFromExecutor( + (args, cwd) => gitExecFileAsync(args, { cwd }), + worktreePath, + options + ) +} diff --git a/src/main/git/status.test.ts b/src/main/git/status.test.ts index 1fb923fa19c..ea0af25a26a 100644 --- a/src/main/git/status.test.ts +++ b/src/main/git/status.test.ts @@ -535,6 +535,10 @@ describe('getBranchCompare', () => { .mockResolvedValueOnce({ stdout: 'M\tfile-a.ts\nR100\told-name.ts\tnew-name.ts\nC100\told-copy.ts\tnew-copy.ts\n' }) + .mockResolvedValueOnce({ + stdout: + '10\t2\tfile-a.ts\n1\t1\told-name.ts => new-name.ts\n3\t0\told-copy.ts => new-copy.ts\n' + }) .mockResolvedValueOnce({ stdout: '7\n' }) const result = await getBranchCompare('/repo', 'origin/main') @@ -550,9 +554,9 @@ describe('getBranchCompare', () => { status: 'ready' }) expect(result.entries).toEqual([ - { path: 'file-a.ts', status: 'modified' }, - { path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed' }, - { path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied' } + { path: 'file-a.ts', status: 'modified', added: 10, removed: 2 }, + { path: 'new-name.ts', oldPath: 'old-name.ts', status: 'renamed', added: 1, removed: 1 }, + { path: 'new-copy.ts', oldPath: 'old-copy.ts', status: 'copied', added: 3, removed: 0 } ]) }) @@ -602,6 +606,7 @@ describe('getBranchCompare', () => { .mockResolvedValueOnce({ stdout: 'base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'M\tdocs/日本語/sample.md\n' }) + .mockResolvedValueOnce({ stdout: '2\t1\tdocs/日本語/sample.md\n' }) .mockResolvedValueOnce({ stdout: '1\n' }) const result = await getBranchCompare('/repo', 'origin/main') @@ -620,6 +625,8 @@ describe('getBranchCompare', () => { ], expect.objectContaining({ cwd: '/repo' }) ) - expect(result.entries).toEqual([{ path: 'docs/日本語/sample.md', status: 'modified' }]) + expect(result.entries).toEqual([ + { path: 'docs/日本語/sample.md', status: 'modified', added: 2, removed: 1 } + ]) }) }) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 45803e80290..40ff61d967c 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -7,6 +7,7 @@ import type { GitBranchChangeStatus, GitBranchCompareResult, GitBranchCompareSummary, + GitCommitCompareResult, GitConflictKind, GitConflictOperation, GitDiffResult, @@ -510,6 +511,94 @@ export async function getBranchDiff( } } +export async function getCommitCompare( + worktreePath: string, + commitId: string +): Promise { + let commitOid = '' + try { + commitOid = await resolveRefOid(worktreePath, `${commitId}^{commit}`) + } catch { + return { + summary: { + commitOid: '', + parentOid: null, + compareRef: commitId, + baseRef: 'parent', + changedFiles: 0, + status: 'invalid-commit', + errorMessage: `Commit ${commitId} could not be resolved in this repository.` + }, + entries: [] + } + } + + const summary = { + commitOid, + parentOid: null as string | null, + compareRef: commitOid.slice(0, 7), + baseRef: 'empty tree', + changedFiles: 0, + status: 'ready' as const + } + + try { + const { stdout } = await gitExecFileAsync(['rev-list', '--parents', '-n', '1', commitOid], { + cwd: worktreePath + }) + const [, firstParent] = stdout.trim().split(/\s+/) + summary.parentOid = firstParent ?? null + summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' + + const entries = await loadCommitChanges(worktreePath, summary.parentOid, commitOid) + summary.changedFiles = entries.length + return { summary, entries } + } catch (error) { + return { + summary: { + ...summary, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' + }, + entries: [] + } + } +} + +export async function getCommitDiff( + worktreePath: string, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + } +): Promise { + try { + const leftPath = args.oldPath ?? args.filePath + const leftBlob = args.parentOid + ? await readGitBlobAtOidPath(worktreePath, args.parentOid, leftPath) + : { content: '', isBinary: false } + const rightBlob = await readGitBlobAtOidPath(worktreePath, args.commitOid, args.filePath) + + return buildDiffResult( + leftBlob.content, + rightBlob.content, + leftBlob.isBinary, + rightBlob.isBinary, + args.filePath + ) + } catch { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } +} + async function loadBranchChanges( worktreePath: string, mergeBase: string, @@ -517,10 +606,20 @@ async function loadBranchChanges( ): Promise { // Why: see core.quotePath=false rationale in getStatus — same reason here so // branch-diff entries render with their real UTF-8 paths. - const { stdout } = await gitExecFileAsync( - ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], - { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } - ) + const gitOptions = { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } + // Why: both diffs walk the same range and are independent, so start them + // together instead of serializing two potentially large git operations. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], + gitOptions + ), + gitExecFileAsync( + ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + gitOptions + ) + ]) + const statsByPath = parseBranchChangeNumstat(numstat) const entries: GitBranchChangeEntry[] = [] // [Fix]: Split by /\r?\n/ instead of '\n' to handle Git CRLF output on Windows, @@ -531,12 +630,108 @@ async function loadBranchChanges( } const entry = parseBranchChangeLine(line) if (entry) { - entries.push(entry) + entries.push({ ...entry, ...statsByPath.get(entry.path) }) } } return entries } +async function loadCommitChanges( + worktreePath: string, + parentOid: string | null, + commitOid: string +): Promise { + // Why: root commits have no parent tree; diff-tree --root asks git to + // compare against the repository's empty tree without hardcoding hash format. + const args = parentOid + ? ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', parentOid, commitOid] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-M', + '-C', + commitOid + ] + const numstatArgs = parentOid + ? ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', parentOid, commitOid] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--numstat', + '-r', + '-M', + '-C', + commitOid + ] + const gitOptions = { cwd: worktreePath, maxBuffer: MAX_GIT_SHOW_BYTES } + // Why: commit diff rows need metadata and line counts, but those git queries + // do not depend on each other. + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + gitExecFileAsync(args, gitOptions), + gitExecFileAsync(numstatArgs, gitOptions) + ]) + const statsByPath = parseBranchChangeNumstat(numstat) + + const entries: GitBranchChangeEntry[] = [] + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const entry = parseBranchChangeLine(line) + if (entry) { + entries.push({ ...entry, ...statsByPath.get(entry.path) }) + } + } + return entries +} + +function parseBranchChangeCount(value: string): number | undefined { + if (value === '-') { + return undefined + } + const count = Number.parseInt(value, 10) + return Number.isFinite(count) ? count : undefined +} + +function normalizeBranchNumstatPath(path: string): string { + const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path) + if (bracedRename) { + return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}` + } + const renameMarker = ' => ' + const markerIndex = path.lastIndexOf(renameMarker) + return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length) +} + +function parseBranchChangeNumstat( + stdout: string +): Map> { + const stats = new Map>() + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const parts = line.split('\t') + const rawPath = parts.slice(2).join('\t') + if (!rawPath) { + continue + } + stats.set(normalizeBranchNumstatPath(rawPath), { + added: parseBranchChangeCount(parts[0] ?? ''), + removed: parseBranchChangeCount(parts[1] ?? '') + }) + } + return stats +} + function parseBranchChangeLine(line: string): GitBranchChangeEntry | null { const parts = line.split('\t') const rawStatus = parts[0] ?? '' diff --git a/src/main/ipc/filesystem.ts b/src/main/ipc/filesystem.ts index b93efc0237e..ed896826de6 100644 --- a/src/main/ipc/filesystem.ts +++ b/src/main/ipc/filesystem.ts @@ -9,6 +9,7 @@ import type { Store } from '../persistence' import type { DirEntry, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, @@ -18,6 +19,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { buildRgArgs, createAccumulator, @@ -39,8 +41,11 @@ import { discardChanges, getStagedCommitContext, getBranchCompare, - getBranchDiff + getBranchDiff, + getCommitCompare, + getCommitDiff } from '../git/status' +import { getHistory } from '../git/history' import { cancelGenerateCommitMessageLocal, generateCommitMessageFromContext, @@ -75,6 +80,7 @@ import { // ordinary JSON/log files inaccessible before the editor can degrade features. const MAX_TEXT_FILE_SIZE = 50 * 1024 * 1024 // 50MB const BINARY_PROBE_BYTES = 8192 +const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ // Why: previewable binaries (PDFs, images) are rendered by the viewer as // base64 blobs, not parsed as text — 5MB is tight for real-world PDFs, and // raising this cap only affects binary preview, not text/search paths. @@ -95,6 +101,13 @@ const PREVIEWABLE_BINARY_MIME_TYPES: Record = { '.pdf': 'application/pdf' } +function validateFullGitObjectId(value: string, label: string): string { + if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { + throw new Error(`${label} must be a full git object id`) + } + return value +} + /** * Check if a buffer appears to be binary (contains null bytes in first 8KB). */ @@ -499,6 +512,25 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:history', + async ( + _event, + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => { + const options: GitHistoryOptions = { limit: args.limit, baseRef: args.baseRef } + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getHistory(args.worktreePath, options) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return getHistory(worktreePath, options) + } + ) + // Why: lightweight fs-only check for conflict operation state. Used to poll // non-active worktrees so their "Rebasing"/"Merging" badges clear when the // operation finishes, without running a full `git status`. @@ -677,6 +709,25 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:commitCompare', + async ( + _event, + args: { worktreePath: string; commitId: string; connectionId?: string } + ): Promise => { + const commitId = validateFullGitObjectId(args.commitId, 'commitId') + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getCommitCompare(args.worktreePath, commitId) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + return getCommitCompare(worktreePath, commitId) + } + ) + ipcMain.handle( 'git:upstreamStatus', async ( @@ -809,6 +860,47 @@ export function registerFilesystemHandlers( } ) + ipcMain.handle( + 'git:commitDiff', + async ( + _event, + args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + } + ): Promise => { + const commitOid = validateFullGitObjectId(args.commitOid, 'commitOid') + const parentOid = args.parentOid ? validateFullGitObjectId(args.parentOid, 'parentOid') : null + if (args.connectionId) { + const provider = getSshGitProvider(args.connectionId) + if (!provider) { + throw new Error(`No git provider for connection "${args.connectionId}"`) + } + return provider.getCommitDiff(args.worktreePath, { + commitOid, + parentOid, + filePath: args.filePath, + oldPath: args.oldPath + }) + } + const worktreePath = await resolveRegisteredWorktreePath(args.worktreePath, store) + const filePath = validateGitRelativeFilePath(worktreePath, args.filePath) + const oldPath = args.oldPath + ? validateGitRelativeFilePath(worktreePath, args.oldPath) + : undefined + return getCommitDiff(worktreePath, { + commitOid, + parentOid, + filePath, + oldPath + }) + } + ) + ipcMain.handle( 'git:stage', async ( diff --git a/src/main/ipc/runtime-environment-call-queue.ts b/src/main/ipc/runtime-environment-call-queue.ts index 2a06416bb94..8b913055488 100644 --- a/src/main/ipc/runtime-environment-call-queue.ts +++ b/src/main/ipc/runtime-environment-call-queue.ts @@ -23,6 +23,7 @@ function isBackgroundRuntimeMethod(method: string): boolean { method === 'github.listWorkItems' || method === 'github.countWorkItems' || method === 'git.status' || + method === 'git.history' || method === 'git.conflictOperation' || method === 'git.branchCompare' || method === 'git.upstreamStatus' diff --git a/src/main/providers/ssh-git-provider.test.ts b/src/main/providers/ssh-git-provider.test.ts index 8c948fdaefb..0d517126438 100644 --- a/src/main/providers/ssh-git-provider.test.ts +++ b/src/main/providers/ssh-git-provider.test.ts @@ -57,6 +57,29 @@ describe('SshGitProvider', () => { }) }) + it('getHistory sends git.history request', async () => { + const historyResult = { + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 50 + } + mux.request.mockResolvedValue(historyResult) + + const result = await provider.getHistory('/home/user/repo', { + limit: 25, + baseRef: 'origin/main' + }) + + expect(mux.request).toHaveBeenCalledWith('git.history', { + worktreePath: '/home/user/repo', + limit: 25, + baseRef: 'origin/main' + }) + expect(result).toEqual(historyResult) + }) + it('commit sends git.commit request', async () => { const commitResult = { success: true } mux.request.mockResolvedValue(commitResult) diff --git a/src/main/providers/ssh-git-provider.ts b/src/main/providers/ssh-git-provider.ts index 24b236a24de..55f112482e3 100644 --- a/src/main/providers/ssh-git-provider.ts +++ b/src/main/providers/ssh-git-provider.ts @@ -9,11 +9,13 @@ import type { GitStatusResult, GitDiffResult, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitPushTarget, GitUpstreamStatus, GitWorktreeInfo } from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { CommitMessagePlan } from '../../shared/commit-message-plan' import type { RemoteCommitMessageExecResult } from '../text-generation/commit-message-text-generation' @@ -42,6 +44,16 @@ export class SshGitProvider implements IGitProvider { })) as GitStatusResult } + async getHistory( + worktreePath: string, + options: GitHistoryOptions = {} + ): Promise { + return (await this.mux.request('git.history', { + worktreePath, + ...options + })) as GitHistoryResult + } + async commit( worktreePath: string, message: string @@ -151,6 +163,13 @@ export class SshGitProvider implements IGitProvider { })) as GitBranchCompareResult } + async getCommitCompare(worktreePath: string, commitId: string): Promise { + return (await this.mux.request('git.commitCompare', { + worktreePath, + commitId + })) as GitCommitCompareResult + } + async getUpstreamStatus(worktreePath: string): Promise { return (await this.mux.request('git.upstreamStatus', { worktreePath @@ -185,6 +204,16 @@ export class SshGitProvider implements IGitProvider { })) as GitDiffResult[] } + async getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + return (await this.mux.request('git.commitDiff', { + worktreePath, + ...args + })) as GitDiffResult + } + async listWorktrees( repoPath: string, options?: { signal?: AbortSignal } diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index 7241e214858..b930f1c5ea6 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -4,6 +4,7 @@ import type { GitStatusResult, GitDiffResult, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitPushTarget, GitUpstreamStatus, @@ -11,6 +12,7 @@ import type { SearchOptions, SearchResult } from '../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' @@ -143,6 +145,7 @@ export type IFilesystemProvider = { export type IGitProvider = { getStatus(worktreePath: string, options?: { includeIgnored?: boolean }): Promise + getHistory(worktreePath: string, options?: GitHistoryOptions): Promise commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }> getStagedCommitContext(worktreePath: string): Promise getDiff( @@ -159,6 +162,7 @@ export type IGitProvider = { bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise detectConflictOperation(worktreePath: string): Promise getBranchCompare(worktreePath: string, baseRef: string): Promise + getCommitCompare(worktreePath: string, commitId: string): Promise getUpstreamStatus(worktreePath: string): Promise pushBranch(worktreePath: string, publish?: boolean, pushTarget?: GitPushTarget): Promise pullBranch(worktreePath: string): Promise @@ -168,6 +172,10 @@ export type IGitProvider = { baseRef: string, options?: { includePatch?: boolean; filePath?: string; oldPath?: string } ): Promise + getCommitDiff( + worktreePath: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise addWorktree( repoPath: string, diff --git a/src/main/runtime/orca-runtime-git.ts b/src/main/runtime/orca-runtime-git.ts index fec2824a48f..f5fa5ada36f 100644 --- a/src/main/runtime/orca-runtime-git.ts +++ b/src/main/runtime/orca-runtime-git.ts @@ -1,6 +1,7 @@ /* eslint-disable max-lines -- Why: runtime git dispatch stays in one boundary so local, SSH, and runtime-environment behavior remains comparable. */ import type { GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, @@ -11,6 +12,7 @@ import type { Worktree } from '../../shared/types' import type { CommitMessageDraftContext } from '../../shared/commit-message-generation' +import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history' import { getRemoteFileUrl } from '../git/repo' import { bulkDiscardChanges, @@ -21,12 +23,15 @@ import { discardChanges, getBranchCompare, getBranchDiff, + getCommitCompare, + getCommitDiff, getDiff, getStagedCommitContext, getStatus as getGitStatus, stageFile, unstageFile } from '../git/status' +import { getHistory as getGitHistory } from '../git/history' import { getUpstreamStatus } from '../git/upstream' import { gitFetch, gitPull, gitPush } from '../git/remote' import { getSshGitProvider } from '../providers/ssh-git-dispatch' @@ -85,6 +90,21 @@ export class RuntimeGitCommands { : getGitStatus(target.worktree.path) } + async getRuntimeGitHistory( + worktreeSelector: string, + options: GitHistoryOptions = {} + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getHistory(target.worktree.path, options) + } + return getGitHistory(target.worktree.path, options) + } + async getRuntimeGitConflictOperation(worktreeSelector: string): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -130,6 +150,21 @@ export class RuntimeGitCommands { return getBranchCompare(target.worktree.path, baseRef) } + async getRuntimeGitCommitCompare( + worktreeSelector: string, + commitId: string + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getCommitCompare(target.worktree.path, commitId) + } + return getCommitCompare(target.worktree.path, commitId) + } + async getRuntimeGitUpstreamStatus(worktreeSelector: string): Promise { const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null @@ -225,6 +260,33 @@ export class RuntimeGitCommands { }) } + async getRuntimeGitCommitDiff( + worktreeSelector: string, + args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string } + ): Promise { + const target = await this.host.resolveRuntimeGitTarget(worktreeSelector) + const relativePath = normalizeRuntimeRelativePath(args.filePath) + const oldRelativePath = args.oldPath ? normalizeRuntimeRelativePath(args.oldPath) : undefined + const provider = target.connectionId ? getSshGitProvider(target.connectionId) : null + if (target.connectionId) { + if (!provider) { + throw new Error('remote_git_unavailable') + } + return provider.getCommitDiff(target.worktree.path, { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }) + } + return getCommitDiff(target.worktree.path, { + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: relativePath, + oldPath: oldRelativePath + }) + } + async commitRuntimeGit( worktreeSelector: string, message: string diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5b1cf7272f5..78df37f6f92 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1200,12 +1200,16 @@ export class OrcaRuntimeService { getRuntimeGitStatus: RuntimeGitCommands['getRuntimeGitStatus'] = this.gitCommands.getRuntimeGitStatus.bind(this.gitCommands) + getRuntimeGitHistory: RuntimeGitCommands['getRuntimeGitHistory'] = + this.gitCommands.getRuntimeGitHistory.bind(this.gitCommands) getRuntimeGitConflictOperation: RuntimeGitCommands['getRuntimeGitConflictOperation'] = this.gitCommands.getRuntimeGitConflictOperation.bind(this.gitCommands) getRuntimeGitDiff: RuntimeGitCommands['getRuntimeGitDiff'] = this.gitCommands.getRuntimeGitDiff.bind(this.gitCommands) getRuntimeGitBranchCompare: RuntimeGitCommands['getRuntimeGitBranchCompare'] = this.gitCommands.getRuntimeGitBranchCompare.bind(this.gitCommands) + getRuntimeGitCommitCompare: RuntimeGitCommands['getRuntimeGitCommitCompare'] = + this.gitCommands.getRuntimeGitCommitCompare.bind(this.gitCommands) getRuntimeGitUpstreamStatus: RuntimeGitCommands['getRuntimeGitUpstreamStatus'] = this.gitCommands.getRuntimeGitUpstreamStatus.bind(this.gitCommands) fetchRuntimeGit: RuntimeGitCommands['fetchRuntimeGit'] = this.gitCommands.fetchRuntimeGit.bind( @@ -1219,6 +1223,8 @@ export class OrcaRuntimeService { ) getRuntimeGitBranchDiff: RuntimeGitCommands['getRuntimeGitBranchDiff'] = this.gitCommands.getRuntimeGitBranchDiff.bind(this.gitCommands) + getRuntimeGitCommitDiff: RuntimeGitCommands['getRuntimeGitCommitDiff'] = + this.gitCommands.getRuntimeGitCommitDiff.bind(this.gitCommands) commitRuntimeGit: RuntimeGitCommands['commitRuntimeGit'] = this.gitCommands.commitRuntimeGit.bind( this.gitCommands ) diff --git a/src/main/runtime/rpc/methods/git.test.ts b/src/main/runtime/rpc/methods/git.test.ts index 1bfdd570b7f..47d9b079ee6 100644 --- a/src/main/runtime/rpc/methods/git.test.ts +++ b/src/main/runtime/rpc/methods/git.test.ts @@ -83,6 +83,35 @@ describe('git RPC methods', () => { }) }) + it('returns bounded git history for a selected worktree', async () => { + const history = { + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 50 + } + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitHistory: vi.fn().mockResolvedValue(history) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.history', { + worktree: 'id:wt-1', + limit: 25, + baseRef: 'origin/main' + }) + ) + + expect(runtime.getRuntimeGitHistory).toHaveBeenCalledWith('id:wt-1', { + limit: 25, + baseRef: 'origin/main' + }) + expect(response).toMatchObject({ ok: true, result: history }) + }) + it('routes common mutations to the runtime', async () => { const runtime = { getRuntimeId: () => 'test-runtime', @@ -267,4 +296,22 @@ describe('git RPC methods', () => { expect(response.ok).toBe(false) expect(runtime.getRuntimeGitBranchCompare).not.toHaveBeenCalled() }) + + it('rejects git history limits above the runtime cap', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + getRuntimeGitHistory: vi.fn() + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GIT_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('git.history', { + worktree: 'id:wt-1', + limit: 201 + }) + ) + + expect(response.ok).toBe(false) + expect(runtime.getRuntimeGitHistory).not.toHaveBeenCalled() + }) }) diff --git a/src/main/runtime/rpc/methods/git.ts b/src/main/runtime/rpc/methods/git.ts index 1b8e4713917..c6db872eb4b 100644 --- a/src/main/runtime/rpc/methods/git.ts +++ b/src/main/runtime/rpc/methods/git.ts @@ -41,6 +41,18 @@ const FullGitObjectId = z .string() .regex(/^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/, 'Expected a full git object id') +const GitCommitCompare = WorktreeSelector.extend({ + commitId: z + .unknown() + .transform((v) => (typeof v === 'string' ? v : '')) + .pipe(FullGitObjectId) +}) + +const GitHistory = WorktreeSelector.extend({ + limit: z.number().int().min(1).max(200).optional(), + baseRef: z.string().nullable().optional() +}) + const GitBranchDiff = GitFilePath.extend({ compare: z.object({ baseRef: z.string().optional(), @@ -51,6 +63,12 @@ const GitBranchDiff = GitFilePath.extend({ oldPath: z.string().optional() }) +const GitCommitDiff = GitFilePath.extend({ + commitOid: FullGitObjectId, + parentOid: FullGitObjectId.nullable().optional(), + oldPath: z.string().optional() +}) + const GitCommit = WorktreeSelector.extend({ message: z .unknown() @@ -99,6 +117,15 @@ export const GIT_METHODS: RpcMethod[] = [ ? runtime.getRuntimeGitStatus(params.worktree) : runtime.getRuntimeGitStatus(params.worktree, { includeIgnored: params.includeIgnored }) }), + defineMethod({ + name: 'git.history', + params: GitHistory, + handler: async (params, { runtime }) => + runtime.getRuntimeGitHistory(params.worktree, { + limit: params.limit, + baseRef: params.baseRef + }) + }), defineMethod({ name: 'git.conflictOperation', params: WorktreeSelector, @@ -121,6 +148,12 @@ export const GIT_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.getRuntimeGitBranchCompare(params.worktree, params.baseRef) }), + defineMethod({ + name: 'git.commitCompare', + params: GitCommitCompare, + handler: async (params, { runtime }) => + runtime.getRuntimeGitCommitCompare(params.worktree, params.commitId) + }), defineMethod({ name: 'git.upstreamStatus', params: WorktreeSelector, @@ -153,6 +186,17 @@ export const GIT_METHODS: RpcMethod[] = [ params.oldPath ) }), + defineMethod({ + name: 'git.commitDiff', + params: GitCommitDiff, + handler: async (params, { runtime }) => + runtime.getRuntimeGitCommitDiff(params.worktree, { + commitOid: params.commitOid, + parentOid: params.parentOid, + filePath: params.filePath, + oldPath: params.oldPath + }) + }), defineMethod({ name: 'git.commit', params: GitCommit, diff --git a/src/main/startup/first-window-startup-services.test.ts b/src/main/startup/first-window-startup-services.test.ts index ab93eb30423..6faf7d8d967 100644 --- a/src/main/startup/first-window-startup-services.test.ts +++ b/src/main/startup/first-window-startup-services.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it, vi } from 'vitest' -import { startFirstWindowStartupServices } from './first-window-startup-services' +import { + FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS, + startFirstWindowStartupServices +} from './first-window-startup-services' describe('startFirstWindowStartupServices', () => { it('starts daemon and hook services concurrently before awaiting either', async () => { @@ -55,4 +58,27 @@ describe('startFirstWindowStartupServices', () => { expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) expect(onAgentHookServerError).toHaveBeenCalledWith(expect.any(Error)) }) + + it('fails open when a pre-window startup service hangs', async () => { + vi.useFakeTimers() + const onDaemonError = vi.fn() + const onAgentHookServerError = vi.fn() + + try { + const started = startFirstWindowStartupServices({ + startDaemonPtyProvider: () => new Promise(() => {}), + startAgentHookServer: () => Promise.resolve(), + onDaemonError, + onAgentHookServerError + }) + + await vi.advanceTimersByTimeAsync(FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + await expect(started).resolves.toBeUndefined() + + expect(onDaemonError).toHaveBeenCalledWith(expect.any(Error)) + expect(onAgentHookServerError).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/startup/first-window-startup-services.ts b/src/main/startup/first-window-startup-services.ts index 9a6de0783d9..0f7f1ed236f 100644 --- a/src/main/startup/first-window-startup-services.ts +++ b/src/main/startup/first-window-startup-services.ts @@ -5,6 +5,33 @@ type FirstWindowStartupServices = { onAgentHookServerError: (error: unknown) => void } +export const FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS = 12_000 + +async function startServiceWithTimeout( + label: string, + start: () => Promise, + onError: (error: unknown) => void +): Promise { + let timeout: ReturnType | null = null + try { + const startPromise = start() + await Promise.race([ + startPromise, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + reject(new Error(`${label} startup timed out`)) + }, FIRST_WINDOW_STARTUP_SERVICE_TIMEOUT_MS) + }) + ]) + } catch (error) { + onError(error) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + /** * Starts the services that must be ready before restored terminal panes mount. */ @@ -16,8 +43,10 @@ export async function startFirstWindowStartupServices({ }: FirstWindowStartupServices): Promise { // Why: daemon startup and hook-server binding are independent, but both gate // restored terminals; run them together so cold-start latency is max(), not sum(). + // They are also fail-open services: a wedged daemon/hook startup must not + // prevent the first BrowserWindow from existing. await Promise.all([ - startDaemonPtyProvider().catch(onDaemonError), - startAgentHookServer().catch(onAgentHookServerError) + startServiceWithTimeout('daemon PTY provider', startDaemonPtyProvider, onDaemonError), + startServiceWithTimeout('agent hook server', startAgentHookServer, onAgentHookServerError) ]) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 363954ffa2c..f4b7c93fb4d 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -26,6 +26,7 @@ import type { GhosttyImportPreview, GlobalSettings, GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, @@ -95,6 +96,7 @@ import type { WorktreeStartupLaunch, WorkspaceSessionState } from '../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' import type { RuntimeRpcResponse } from '../shared/runtime-rpc-envelope' import type { @@ -1350,6 +1352,9 @@ export type PreloadApi = { connectionId?: string includeIgnored?: boolean }) => Promise + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ) => Promise conflictOperation: (args: { worktreePath: string connectionId?: string @@ -1366,6 +1371,11 @@ export type PreloadApi = { baseRef: string connectionId?: string }) => Promise + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }) => Promise upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -1390,6 +1400,14 @@ export type PreloadApi = { oldPath?: string connectionId?: string }) => Promise + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }) => Promise commit: (args: { worktreePath: string message: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 949c4f7ee17..d4a93db12be 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,7 @@ import type { WorktreeBaseStatusEvent, WorktreeRemoteBranchConflictEvent } from '../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { ShellOpenLocalPathResult } from '../shared/shell-open-types' import type { SkillDiscoveryResult } from '../shared/skills' import type { @@ -1901,6 +1902,9 @@ const api = { connectionId?: string includeIgnored?: boolean }): Promise => ipcRenderer.invoke('git:status', args), + history: ( + args: { worktreePath: string; connectionId?: string } & GitHistoryOptions + ): Promise => ipcRenderer.invoke('git:history', args), conflictOperation: (args: { worktreePath: string; connectionId?: string }): Promise => ipcRenderer.invoke('git:conflictOperation', args), diff: (args: { @@ -1915,6 +1919,11 @@ const api = { baseRef: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchCompare', args), + commitCompare: (args: { + worktreePath: string + commitId: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitCompare', args), upstreamStatus: (args: { worktreePath: string connectionId?: string @@ -1936,6 +1945,14 @@ const api = { oldPath?: string connectionId?: string }): Promise => ipcRenderer.invoke('git:branchDiff', args), + commitDiff: (args: { + worktreePath: string + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + connectionId?: string + }): Promise => ipcRenderer.invoke('git:commitDiff', args), commit: (args: { worktreePath: string message: string diff --git a/src/relay/git-handler-commit-diff-ops.ts b/src/relay/git-handler-commit-diff-ops.ts new file mode 100644 index 00000000000..96ff993240e --- /dev/null +++ b/src/relay/git-handler-commit-diff-ops.ts @@ -0,0 +1,157 @@ +import { readBlobAtOid, type GitBufferExec, type GitExec } from './git-handler-ops' +import { buildDiffResult, parseBranchDiff, parseBranchDiffNumstat } from './git-handler-utils' + +const FULL_GIT_OBJECT_ID_PATTERN = /^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$/ + +function assertFullGitObjectId(value: string, label: string): void { + if (!FULL_GIT_OBJECT_ID_PATTERN.test(value)) { + throw new Error(`${label} must be a full git object id`) + } +} + +export async function commitCompare(git: GitExec, worktreePath: string, commitId: string) { + assertFullGitObjectId(commitId, 'commitId') + let commitOid = '' + try { + const { stdout } = await git( + ['rev-parse', '--verify', '--end-of-options', `${commitId}^{commit}`], + worktreePath + ) + commitOid = stdout.trim() + } catch { + return { + summary: { + commitOid: '', + parentOid: null, + compareRef: commitId, + baseRef: 'parent', + changedFiles: 0, + status: 'invalid-commit', + errorMessage: `Commit ${commitId} could not be resolved in this repository.` + }, + entries: [] + } + } + + const summary = { + commitOid, + parentOid: null as string | null, + compareRef: commitOid.slice(0, 7), + baseRef: 'empty tree', + changedFiles: 0, + status: 'ready' as const + } + + try { + const { stdout: parentsOut } = await git( + ['rev-list', '--parents', '-n', '1', commitOid], + worktreePath + ) + const [, firstParent] = parentsOut.trim().split(/\s+/) + summary.parentOid = firstParent ?? null + summary.baseRef = firstParent ? firstParent.slice(0, 7) : 'empty tree' + + // Why: root commits have no parent tree; diff-tree --root asks git to + // compare against the repository's empty tree without hardcoding hash format. + const diffArgs = summary.parentOid + ? [ + '-c', + 'core.quotePath=false', + 'diff', + '--name-status', + '-M', + '-C', + summary.parentOid, + commitOid + ] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--name-status', + '-r', + '-M', + '-C', + commitOid + ] + const numstatArgs = summary.parentOid + ? [ + '-c', + 'core.quotePath=false', + 'diff', + '--numstat', + '-M', + '-C', + summary.parentOid, + commitOid + ] + : [ + '-c', + 'core.quotePath=false', + 'diff-tree', + '--root', + '--no-commit-id', + '--numstat', + '-r', + '-M', + '-C', + commitOid + ] + const [{ stdout }, { stdout: numstat }] = await Promise.all([ + git(diffArgs, worktreePath), + git(numstatArgs, worktreePath) + ]) + const entries = parseBranchDiff(stdout, parseBranchDiffNumstat(numstat)) + summary.changedFiles = entries.length + return { summary, entries } + } catch (error) { + return { + summary: { + ...summary, + status: 'error', + errorMessage: error instanceof Error ? error.message : 'Failed to load commit diff' + }, + entries: [] + } + } +} + +export async function commitDiffEntry( + gitBuffer: GitBufferExec, + worktreePath: string, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + } +) { + assertFullGitObjectId(args.commitOid, 'commitOid') + if (args.parentOid) { + assertFullGitObjectId(args.parentOid, 'parentOid') + } + try { + const oldPath = args.oldPath ?? args.filePath + const left = args.parentOid + ? await readBlobAtOid(gitBuffer, worktreePath, args.parentOid, oldPath) + : { content: '', isBinary: false } + const right = await readBlobAtOid(gitBuffer, worktreePath, args.commitOid, args.filePath) + return buildDiffResult( + left.content, + right.content, + left.isBinary, + right.isBinary, + args.filePath + ) + } catch { + return { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } + } +} diff --git a/src/relay/git-handler-utils.ts b/src/relay/git-handler-utils.ts index a0c72179007..e84e04a170e 100644 --- a/src/relay/git-handler-utils.ts +++ b/src/relay/git-handler-utils.ts @@ -100,7 +100,52 @@ export function parseUnmergedEntry( /** * Parse `git diff --name-status` output into structured change entries. */ -export function parseBranchDiff(stdout: string): Record[] { +export type BranchDiffLineStats = { + added?: number + removed?: number +} + +function parseNumstatCount(value: string): number | undefined { + if (value === '-') { + return undefined + } + const count = Number.parseInt(value, 10) + return Number.isFinite(count) ? count : undefined +} + +function normalizeNumstatPath(path: string): string { + const bracedRename = /^(.*)\{(.+) => (.+)\}(.*)$/.exec(path) + if (bracedRename) { + return `${bracedRename[1]}${bracedRename[3]}${bracedRename[4]}` + } + const renameMarker = ' => ' + const markerIndex = path.lastIndexOf(renameMarker) + return markerIndex === -1 ? path : path.slice(markerIndex + renameMarker.length) +} + +export function parseBranchDiffNumstat(stdout: string): Map { + const stats = new Map() + for (const line of stdout.split(/\r?\n/)) { + if (!line) { + continue + } + const parts = line.split('\t') + const rawPath = parts.slice(2).join('\t') + if (!rawPath) { + continue + } + stats.set(normalizeNumstatPath(rawPath), { + added: parseNumstatCount(parts[0] ?? ''), + removed: parseNumstatCount(parts[1] ?? '') + }) + } + return stats +} + +export function parseBranchDiff( + stdout: string, + statsByPath: Map = new Map() +): Record[] { const entries: Record[] = [] for (const line of stdout.split(/\r?\n/)) { if (!line) { @@ -114,12 +159,12 @@ export function parseBranchDiff(stdout: string): Record[] { const oldPath = parts[1] const filePath = parts[2] if (filePath) { - entries.push({ path: filePath, oldPath, status }) + entries.push({ path: filePath, oldPath, status, ...statsByPath.get(filePath) }) } } else { const filePath = parts[1] if (filePath) { - entries.push({ path: filePath, status }) + entries.push({ path: filePath, status, ...statsByPath.get(filePath) }) } } } diff --git a/src/relay/git-handler.test.ts b/src/relay/git-handler.test.ts index 3e40cdeb365..5ddcc934d57 100644 --- a/src/relay/git-handler.test.ts +++ b/src/relay/git-handler.test.ts @@ -37,6 +37,7 @@ describe('GitHandler', () => { it('registers all expected handlers', () => { const methods = Array.from(dispatcher._requestHandlers.keys()) expect(methods).toContain('git.status') + expect(methods).toContain('git.history') expect(methods).toContain('git.commit') expect(methods).toContain('git.diff') expect(methods).toContain('git.stage') @@ -59,6 +60,33 @@ describe('GitHandler', () => { expect(methods).toContain('git.isGitRepo') }) + describe('history', () => { + it('returns bounded git history for a repo', async () => { + gitInit(tmpDir) + writeFileSync(path.join(tmpDir, 'file.txt'), 'hello') + gitCommit(tmpDir, 'initial') + writeFileSync(path.join(tmpDir, 'file.txt'), 'changed') + gitCommit(tmpDir, 'second') + + const result = (await dispatcher.callRequest('git.history', { + worktreePath: tmpDir, + limit: 10 + })) as { + items: { subject: string; displayId?: string }[] + currentRef?: { category?: string; revision?: string } + hasMore: boolean + limit: number + } + + expect(result.items.map((item) => item.subject)).toEqual(['second', 'initial']) + expect(result.currentRef?.category).toBe('branches') + expect(result.currentRef?.revision).toMatch(/^[0-9a-f]{40}$/) + expect(result.items[0]?.displayId).toHaveLength(7) + expect(result.hasMore).toBe(false) + expect(result.limit).toBe(10) + }) + }) + describe('status', () => { it('returns empty entries for clean repo', async () => { gitInit(tmpDir) diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index ecc4bd0e9ae..39002a2b3d9 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -7,17 +7,19 @@ import * as path from 'path' import type { RelayDispatcher } from './dispatcher' import type { RelayContext } from './context' import { expandTilde } from './context' -import { parseBranchDiff, parseWorktreeList } from './git-handler-utils' +import { parseBranchDiff, parseBranchDiffNumstat, parseWorktreeList } from './git-handler-utils' import { computeDiff, branchCompare as branchCompareOp, branchDiffEntries, validateGitExecArgs } from './git-handler-ops' +import { commitCompare as commitCompareOp, commitDiffEntry } from './git-handler-commit-diff-ops' import { commitChangesRelay, addWorktreeOp, removeWorktreeOp } from './git-handler-worktree-ops' import { detectConflictOperation, getStatusOp } from './git-handler-status-ops' import { resolveRelayPushTarget } from './git-handler-push-target' import { normalizeGitErrorMessage, isNoUpstreamError } from '../shared/git-remote-error' +import { loadGitHistoryFromExecutor } from '../shared/git-history' const execFileAsync = promisify(execFile) const MAX_GIT_BUFFER = 10 * 1024 * 1024 @@ -35,6 +37,7 @@ export class GitHandler { private registerHandlers(): void { this.dispatcher.onRequest('git.status', (p) => this.getStatus(p)) + this.dispatcher.onRequest('git.history', (p) => this.history(p)) this.dispatcher.onRequest('git.commit', (p) => this.commit(p)) this.dispatcher.onRequest('git.diff', (p) => this.getDiff(p)) this.dispatcher.onRequest('git.stage', (p) => this.stage(p)) @@ -45,11 +48,13 @@ export class GitHandler { this.dispatcher.onRequest('git.bulkDiscard', (p) => this.bulkDiscard(p)) this.dispatcher.onRequest('git.conflictOperation', (p) => this.conflictOperation(p)) this.dispatcher.onRequest('git.branchCompare', (p) => this.branchCompare(p)) + this.dispatcher.onRequest('git.commitCompare', (p) => this.commitCompare(p)) this.dispatcher.onRequest('git.upstreamStatus', (p) => this.upstreamStatus(p)) this.dispatcher.onRequest('git.fetch', (p) => this.fetch(p)) this.dispatcher.onRequest('git.push', (p) => this.push(p)) this.dispatcher.onRequest('git.pull', (p) => this.pull(p)) this.dispatcher.onRequest('git.branchDiff', (p) => this.branchDiff(p)) + this.dispatcher.onRequest('git.commitDiff', (p) => this.commitDiff(p)) this.dispatcher.onRequest('git.listWorktrees', (p) => this.listWorktrees(p)) this.dispatcher.onRequest('git.addWorktree', (p) => this.addWorktree(p)) this.dispatcher.onRequest('git.removeWorktree', (p) => this.removeWorktree(p)) @@ -82,6 +87,14 @@ export class GitHandler { return getStatusOp(this.git.bind(this), params) } + private async history(params: Record) { + const worktreePath = params.worktreePath as string + return loadGitHistoryFromExecutor(this.git.bind(this), worktreePath, { + limit: typeof params.limit === 'number' ? params.limit : undefined, + baseRef: typeof params.baseRef === 'string' ? params.baseRef : null + }) + } + private async getDiff(params: Record) { const worktreePath = params.worktreePath as string const filePath = params.filePath as string @@ -245,10 +258,20 @@ export class GitHandler { ['-c', 'core.quotePath=false', 'diff', '--name-status', '-M', '-C', mergeBase, headOid], worktreePath ) - return parseBranchDiff(stdout) + const { stdout: numstat } = await gitBound( + ['-c', 'core.quotePath=false', 'diff', '--numstat', '-M', '-C', mergeBase, headOid], + worktreePath + ) + return parseBranchDiff(stdout, parseBranchDiffNumstat(numstat)) }) } + private async commitCompare(params: Record) { + const worktreePath = params.worktreePath as string + const commitId = params.commitId as string + return commitCompareOp(this.git.bind(this), worktreePath, commitId) + } + private async upstreamStatus(params: Record) { const worktreePath = params.worktreePath as string @@ -362,6 +385,16 @@ export class GitHandler { ) } + private async commitDiff(params: Record) { + const worktreePath = params.worktreePath as string + return commitDiffEntry(this.gitBuffer.bind(this), worktreePath, { + commitOid: params.commitOid as string, + parentOid: params.parentOid as string | null | undefined, + filePath: params.filePath as string, + oldPath: params.oldPath as string | undefined + }) + } + private async exec(params: Record) { const args = params.args as string[] const cwd = params.cwd as string diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 9110b0d2448..7ef67effc94 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -57,6 +57,14 @@ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); --color-sidebar-border: var(--sidebar-border); --color-sidebar-ring: var(--sidebar-ring); + --color-git-graph-ref: var(--git-graph-ref); + --color-git-graph-remote-ref: var(--git-graph-remote-ref); + --color-git-graph-base-ref: var(--git-graph-base-ref); + --color-git-graph-lane-1: var(--git-graph-lane-1); + --color-git-graph-lane-2: var(--git-graph-lane-2); + --color-git-graph-lane-3: var(--git-graph-lane-3); + --color-git-graph-lane-4: var(--git-graph-lane-4); + --color-git-graph-lane-5: var(--git-graph-lane-5); --radius-sm: calc(var(--radius) * 0.6); --radius-md: calc(var(--radius) * 0.8); --radius-lg: var(--radius); @@ -115,6 +123,14 @@ --git-decoration-untracked: #007100; --git-decoration-copied: #007acc; --git-decoration-ignored: #8c8c8c; + --git-graph-ref: #007acc; + --git-graph-remote-ref: #b66dff; + --git-graph-base-ref: #ea5c00; + --git-graph-lane-1: #ffb000; + --git-graph-lane-2: #dc267f; + --git-graph-lane-3: #994f00; + --git-graph-lane-4: #40b0a6; + --git-graph-lane-5: #b66dff; } /* ── Dark Mode ───────────────────────────────────────── */ @@ -160,6 +176,14 @@ --git-decoration-untracked: #73c991; --git-decoration-copied: #73c991; --git-decoration-ignored: #6e6e6e; + --git-graph-ref: #3794ff; + --git-graph-remote-ref: #b66dff; + --git-graph-base-ref: #ea5c00; + --git-graph-lane-1: #ffb000; + --git-graph-lane-2: #dc267f; + --git-graph-lane-3: #ce9178; + --git-graph-lane-4: #40b0a6; + --git-graph-lane-5: #b66dff; } /* ── Base Layer ──────────────────────────────────────── */ diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx index 486cc0621df..2e48be0fc8d 100644 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ b/src/renderer/src/components/editor/CombinedDiffViewer.tsx @@ -8,13 +8,19 @@ import { useVirtualizer } from '@tanstack/react-virtual' import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { joinPath } from '@/lib/path' +import { detectLanguage } from '@/lib/language-detect' import { setWithLRU } from '@/lib/scroll-cache' import { getConnectionId } from '@/lib/connection-context' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { writeRuntimeFile } from '@/runtime/runtime-file-client' import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -import { getRuntimeGitBranchDiff, getRuntimeGitDiff } from '@/runtime/runtime-git-client' import { formatDiffComments } from '@/lib/diff-comments-format' +import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' +import { + getRuntimeGitBranchDiff, + getRuntimeGitCommitDiff, + getRuntimeGitDiff +} from '@/runtime/runtime-git-client' import '@/lib/monaco-setup' import { Button } from '@/components/ui/button' import { @@ -35,14 +41,20 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip import { QuickLaunchAgentMenuItems } from '@/components/tab-bar/QuickLaunchButton' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import type { OpenFile } from '@/store/slices/editor' -import type { DiffComment, GitBranchChangeEntry, GitDiffResult } from '../../../../shared/types' -import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' +import type { + DiffComment, + GitBranchChangeEntry, + GitDiffResult, + GitStatusEntry +} from '../../../../shared/types' import { Check, Copy, MessageSquare, Send, Trash2 } from 'lucide-react' import { toast } from 'sonner' import { DiffSectionItem } from './DiffSectionItem' import { getCombinedUncommittedEntries } from './combined-diff-entries' import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from './diff-section-layout' import type { DiffSection } from './diff-section-types' +import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load' +import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler' type CachedCombinedDiffViewState = { entrySignature: string @@ -56,6 +68,49 @@ type CachedCombinedDiffViewState = { const combinedDiffViewStateCache = new Map() const combinedDiffScrollTopCache = new Map() const COMBINED_DIFF_OVERSCAN = 5 +const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = [] +const EMPTY_GIT_BRANCH_ENTRIES: GitBranchChangeEntry[] = [] +let combinedDiffCollapsedPreference: boolean | null = null +let combinedDiffSideBySidePreference: boolean | null = null +// Why: local Electron IPC has no RPC timeout; a hung git diff should turn into +// a retryable row error instead of leaving the editor in "Loading..." forever. +const COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS = 30_000 + +class CombinedDiffSectionLoadTimeoutError extends Error { + constructor() { + super('Diff did not finish loading.') + this.name = 'CombinedDiffSectionLoadTimeoutError' + } +} + +function withDiffSectionLoadTimeout(promise: Promise): Promise { + let timeoutId: number | null = null + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new CombinedDiffSectionLoadTimeoutError()) + }, COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS) + }) + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + } + }) +} + +function getDiffSectionLoadErrorMessage(error: unknown): string { + if (error instanceof CombinedDiffSectionLoadTimeoutError) { + return 'Diff did not finish loading.' + } + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'Unable to load diff.' +} + +function getInitialCombinedDiffSideBySide(diffDefaultView: string | undefined): boolean { + return combinedDiffSideBySidePreference ?? diffDefaultView === 'side-by-side' +} export default function CombinedDiffViewer({ file, @@ -65,10 +120,17 @@ export default function CombinedDiffViewer({ viewStateKey: string }): React.JSX.Element { const settings = useAppStore((s) => s.settings) - const gitStatusByWorktree = useAppStore((s) => s.gitStatusByWorktree) - const gitBranchChangesByWorktree = useAppStore((s) => s.gitBranchChangesByWorktree) - const gitBranchCompareSummaryByWorktree = useAppStore((s) => s.gitBranchCompareSummaryByWorktree) + const gitStatusEntries = useAppStore( + (s) => s.gitStatusByWorktree[file.worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES + ) + const liveBranchEntries = useAppStore( + (s) => s.gitBranchChangesByWorktree[file.worktreeId] ?? EMPTY_GIT_BRANCH_ENTRIES + ) + const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId]) const openAllDiffs = useAppStore((s) => s.openAllDiffs) + const openFile = useAppStore((s) => s.openFile) + const openBranchDiff = useAppStore((s) => s.openBranchDiff) + const openCommitDiff = useAppStore((s) => s.openCommitDiff) const openConflictReview = useAppStore((s) => s.openConflictReview) const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) const clearDiffComments = useAppStore((s) => s.clearDiffComments) @@ -92,7 +154,9 @@ export default function CombinedDiffViewer({ ) const [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(settings?.diffDefaultView === 'side-by-side') + const [sideBySide, setSideBySide] = useState(() => + getInitialCombinedDiffSideBySide(settings?.diffDefaultView) + ) const [sectionHeights, setSectionHeights] = useState>({}) const [clearNotesDialogOpen, setClearNotesDialogOpen] = useState(false) const [isClearingNotes, setIsClearingNotes] = useState(false) @@ -104,21 +168,34 @@ export default function CombinedDiffViewer({ const [generation, setGeneration] = useState(0) const scrollContainerRef = useRef(null) const pendingRestoreScrollTopRef = useRef(null) + const loadedIndicesRef = useRef>(new Set()) + const loadingIndicesRef = useRef>(new Set()) + const sectionsRef = useRef([]) + const generationRef = useRef(0) + const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) + const loadSchedulerRef = useRef( + createCombinedDiffLoadScheduler({ + loadSection: (index) => loadSectionRef.current(index) + }) + ) + sectionsRef.current = sections - // Why: When the user changes their global diff-view preference in Settings, - // sync the local toggle to match, even if they manually toggled it this session. + // Why: Settings should seed combined diffs until the user picks a toolbar + // mode in this session. After that, commit-to-commit navigation follows the + // last toolbar choice instead of snapping back to the global default. useEffect(() => { - if (settings?.diffDefaultView !== undefined) { + if (settings?.diffDefaultView !== undefined && combinedDiffSideBySidePreference === null) { setSideBySide(settings.diffDefaultView === 'side-by-side') } }, [settings?.diffDefaultView]) - const branchSummary = gitBranchCompareSummaryByWorktree[file.worktreeId] const isBranchMode = file.diffSource === 'combined-branch' + const isCommitMode = file.diffSource === 'combined-commit' const branchCompare = file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase ? file.branchCompare : null + const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null // Why: prefer the snapshot taken at tab-open time so a commit that changes // gitStatusByWorktree does not rebuild all sections and lose loaded content. @@ -132,27 +209,28 @@ export default function CombinedDiffViewer({ ) const uncommittedEntries = React.useMemo( () => - snapshotEntries ?? - getCombinedUncommittedEntries( - gitStatusByWorktree[file.worktreeId] ?? [], - file.combinedAreaFilter - ), - [snapshotEntries, file.worktreeId, file.combinedAreaFilter, gitStatusByWorktree] + snapshotEntries ?? getCombinedUncommittedEntries(gitStatusEntries, file.combinedAreaFilter), + [snapshotEntries, gitStatusEntries, file.combinedAreaFilter] ) const branchEntries = React.useMemo(() => { const snapshotEntries = file.branchEntriesSnapshot ?? [] if (snapshotEntries.length > 0) { return snapshotEntries } - return gitBranchChangesByWorktree[file.worktreeId] ?? [] - }, [file.branchEntriesSnapshot, file.worktreeId, gitBranchChangesByWorktree]) - const entries = isBranchMode ? branchEntries : uncommittedEntries + return liveBranchEntries + }, [file.branchEntriesSnapshot, liveBranchEntries]) + const commitEntries = React.useMemo( + () => file.commitEntriesSnapshot ?? [], + [file.commitEntriesSnapshot] + ) + const entries = isBranchMode ? branchEntries : isCommitMode ? commitEntries : uncommittedEntries const entrySignature = React.useMemo( () => JSON.stringify({ mode: file.diffSource, areaFilter: file.combinedAreaFilter ?? null, compareVersion: file.branchCompare?.compareVersion ?? null, + commitVersion: file.commitCompare?.compareVersion ?? null, compare: isBranchMode && branchCompare ? { @@ -161,20 +239,32 @@ export default function CombinedDiffViewer({ mergeBase: branchCompare.mergeBase } : null, + commit: + isCommitMode && commitCompare + ? { + commitOid: commitCompare.commitOid, + parentOid: commitCompare.parentOid ?? null + } + : null, entries: entries.map((entry) => ({ path: entry.path, status: entry.status, oldPath: entry.oldPath ?? null, - area: 'area' in entry ? entry.area : null + area: 'area' in entry ? entry.area : null, + added: 'added' in entry ? (entry.added ?? null) : null, + removed: 'removed' in entry ? (entry.removed ?? null) : null })) }), [ branchCompare, + commitCompare, entries, file.branchCompare?.compareVersion, file.combinedAreaFilter, + file.commitCompare?.compareVersion, file.diffSource, - isBranchMode + isBranchMode, + isCommitMode ] ) @@ -190,10 +280,21 @@ export default function CombinedDiffViewer({ cached.entrySignature === entrySignature && (cached.sections.length > 0 || entries.length === 0) if (canRestoreCachedSections && cached) { - setSections(cached.sections) + const collapsedPreference = combinedDiffCollapsedPreference + const restoredSections = + collapsedPreference === null + ? cached.sections + : cached.sections.map((section) => ({ + ...section, + collapsed: collapsedPreference + })) + setSections(restoredSections) setSectionHeights(cached.sectionHeights) - setSideBySide(cached.sideBySide) - loadedIndicesRef.current = new Set(cached.loadedIndices) + setSideBySide(combinedDiffSideBySidePreference ?? cached.sideBySide) + loadedIndicesRef.current = new Set( + cached.loadedIndices.filter((index) => !restoredSections[index]?.loading) + ) + loadingIndicesRef.current.clear() pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop return @@ -202,80 +303,111 @@ export default function CombinedDiffViewer({ pendingRestoreScrollTopRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? null setSections( entries.map((entry) => ({ - key: `${'area' in entry ? entry.area : 'branch'}:${entry.path}`, + key: `${'area' in entry ? entry.area : (file.diffSource ?? 'compare')}:${entry.path}`, path: entry.path, status: entry.status, area: 'area' in entry ? entry.area : undefined, oldPath: entry.oldPath, + added: 'added' in entry ? entry.added : undefined, + removed: 'removed' in entry ? entry.removed : undefined, originalContent: '', modifiedContent: '', - collapsed: false, + collapsed: combinedDiffCollapsedPreference ?? false, loading: true, + error: undefined, dirty: false, diffResult: null })) ) setSectionHeights({}) loadedIndicesRef.current.clear() + loadingIndicesRef.current.clear() + loadSchedulerRef.current.reset() generationRef.current += 1 setGeneration((prev) => prev + 1) - }, [entries, entrySignature, viewStateKey]) + }, [entries, entrySignature, file.diffSource, viewStateKey]) - // Progressive loading: load diff content when a section becomes visible - const loadedIndicesRef = useRef>(new Set()) - const generationRef = useRef(0) - const loadSection = useCallback( + const loadSectionNow = useCallback( async (index: number) => { - if (loadedIndicesRef.current.has(index)) { + if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { return } - loadedIndicesRef.current.add(index) + loadingIndicesRef.current.add(index) const gen = generationRef.current - const entries = isBranchMode ? branchEntries : uncommittedEntries + const entries = isBranchMode + ? branchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries const entry = entries[index] if (!entry) { + loadingIndicesRef.current.delete(index) return } let result: GitDiffResult + let error: string | undefined try { const connectionId = getConnectionId(file.worktreeId) ?? undefined const state = useAppStore.getState() const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - result = - isBranchMode && branchCompare - ? ((await getRuntimeGitBranchDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId + if (isBranchMode && branchCompare) { + result = await withDiffSectionLoadTimeout( + getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! }, - { - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! - }, - filePath: entry.path, - oldPath: entry.oldPath - } - )) as GitDiffResult) - : ((await getRuntimeGitDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId - }, - { - filePath: entry.path, - staged: 'area' in entry && entry.area === 'staged' - } - )) as GitDiffResult) - } catch { + filePath: entry.path, + oldPath: entry.oldPath + } + ) + ) + } else if (isCommitMode && commitCompare) { + result = await withDiffSectionLoadTimeout( + getRuntimeGitCommitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + commitOid: commitCompare.commitOid, + parentOid: commitCompare.parentOid, + filePath: entry.path, + oldPath: entry.oldPath + } + ) + ) + } else { + result = await withDiffSectionLoadTimeout( + getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + filePath: entry.path, + staged: 'area' in entry && entry.area === 'staged' + } + ) + ) + } + } catch (err) { + error = getDiffSectionLoadErrorMessage(err) result = { kind: 'text', originalContent: '', @@ -285,10 +417,12 @@ export default function CombinedDiffViewer({ } as GitDiffResult } + loadingIndicesRef.current.delete(index) + if (generationRef.current !== gen) { + return + } + loadedIndicesRef.current.add(index) setSections((prev) => { - if (generationRef.current !== gen) { - return prev - } return prev.map((s, i) => i === index ? { @@ -296,7 +430,8 @@ export default function CombinedDiffViewer({ diffResult: result, originalContent: result.kind === 'text' ? result.originalContent : '', modifiedContent: result.kind === 'text' ? result.modifiedContent : '', - loading: false + loading: false, + error } : s ) @@ -308,12 +443,80 @@ export default function CombinedDiffViewer({ branchCompare?.headOid, branchCompare?.mergeBase, branchEntries, + commitCompare?.commitOid, + commitCompare?.parentOid, + commitEntries, file.filePath, file.runtimeEnvironmentId, isBranchMode, + isCommitMode, uncommittedEntries ] ) + loadSectionRef.current = loadSectionNow + + useEffect(() => { + // Why: React StrictMode replays effect cleanup during development. Resetting + // here revives the scheduler for the replayed mount instead of leaving all + // later visibility requests ignored. + const scheduler = loadSchedulerRef.current + scheduler.reset() + return () => scheduler.dispose() + }, []) + + // Progressive loading: queue diff content when a section becomes visible. + const loadSection = useCallback((index: number) => { + if (sectionsRef.current[index]?.collapsed) { + return + } + loadSchedulerRef.current.request(index) + }, []) + + useEffect(() => { + // Why: VS Code's multi-diff resolves an initial resource model before + // virtualizing editors. Queue the first rows deterministically so the + // visible viewport is not dependent on IntersectionObserver delivery. + const currentSections = sectionsRef.current + for (let index = 0; index < currentSections.length; index += 1) { + if (currentSections[index]?.loading && loadedIndicesRef.current.has(index)) { + loadedIndicesRef.current.delete(index) + } + } + + const initialIndices = getInitialCombinedDiffSectionLoadIndices({ + sectionCount: currentSections.length, + loadedIndices: loadedIndicesRef.current + }) + + for (const index of initialIndices) { + if (!currentSections[index]?.collapsed) { + loadSection(index) + } + } + }, [entrySignature, loadSection, sections.length]) + + const retrySection = useCallback( + (index: number) => { + loadedIndicesRef.current.delete(index) + loadingIndicesRef.current.delete(index) + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index + ? { + ...section, + loading: true, + error: undefined, + diffResult: null, + originalContent: '', + modifiedContent: '' + } + : section + ) + ) + loadSection(index) + }, + [loadSection] + ) const modifiedEditorsRef = useRef>(new Map()) @@ -352,9 +555,84 @@ export default function CombinedDiffViewer({ }, [sideBySide, virtualizer]) const toggleSection = useCallback((index: number) => { + const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false setSections((prev) => prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s))) + if (shouldLoadAfterExpand) { + loadSchedulerRef.current.request(index) + } }, []) + const setAllSectionsCollapsed = useCallback((collapsed: boolean) => { + combinedDiffCollapsedPreference = collapsed + setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) + if (!collapsed) { + const initialIndices = getInitialCombinedDiffSectionLoadIndices({ + sectionCount: sectionsRef.current.length, + loadedIndices: loadedIndicesRef.current + }) + for (const index of initialIndices) { + loadSchedulerRef.current.request(index) + } + } + }, []) + + const toggleSideBySide = useCallback(() => { + setSideBySide((prev) => { + const next = !prev + combinedDiffSideBySidePreference = next + return next + }) + }, []) + + const openSection = useCallback( + (index: number) => { + const section = sectionsRef.current[index] + if (!section) { + return + } + + const language = detectLanguage(section.path) + const entry: GitBranchChangeEntry = { + path: section.path, + status: section.status as GitBranchChangeEntry['status'], + oldPath: section.oldPath, + added: section.added, + removed: section.removed + } + + if (isBranchMode && branchCompare) { + openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language) + return + } + + if (isCommitMode && commitCompare) { + openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language) + return + } + + openFile({ + filePath: joinPath(file.filePath, section.path), + relativePath: section.path, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language, + mode: 'edit' + }) + }, + [ + branchCompare, + commitCompare, + file.filePath, + file.runtimeEnvironmentId, + file.worktreeId, + isBranchMode, + isCommitMode, + openBranchDiff, + openCommitDiff, + openFile + ] + ) + const handleSectionSave = useCallback( async (index: number) => { const section = sections[index] @@ -421,7 +699,9 @@ export default function CombinedDiffViewer({ entrySignature, sections, sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current), + loadedIndices: Array.from(loadedIndicesRef.current).filter( + (index) => !sections[index]?.loading + ), scrollTop: preservedScrollTop, sideBySide }) @@ -638,6 +918,7 @@ export default function CombinedDiffViewer({ ) : null + const allSectionsCollapsed = sections.every((section) => section.collapsed) return ( <> @@ -647,6 +928,7 @@ export default function CombinedDiffViewer({ {sections.length} changed files {isBranchMode && branchCompare ? ` vs ${branchCompare.baseRef}` : ''} + {isCommitMode && commitCompare ? ` in ${commitCompare.compareRef}` : ''} {diffCommentCount > 0 && (
@@ -716,20 +998,14 @@ export default function CombinedDiffViewer({ )} - @@ -765,9 +1041,11 @@ export default function CombinedDiffViewer({ settings={settings} sectionHeight={sectionHeights[virtualItem.index]} worktreeId={file.worktreeId} - worktreeRoot={file.filePath} loadSection={loadSection} + retrySection={retrySection} toggleSection={toggleSection} + openSection={openSection} + openSectionTitle={isBranchMode || isCommitMode ? 'Open diff' : 'Open in editor'} setSectionHeights={setSectionHeights} setSections={setSections} modifiedEditorsRef={modifiedEditorsRef} diff --git a/src/renderer/src/components/editor/DiffSectionHeader.tsx b/src/renderer/src/components/editor/DiffSectionHeader.tsx index 41ec33e3033..d97e459874e 100644 --- a/src/renderer/src/components/editor/DiffSectionHeader.tsx +++ b/src/renderer/src/components/editor/DiffSectionHeader.tsx @@ -8,7 +8,8 @@ export function DiffSectionHeader({ added, removed, onToggle, - onOpenInEditor + onOpenSection, + openSectionTitle }: { path: string dirty: boolean @@ -16,7 +17,8 @@ export function DiffSectionHeader({ added: number removed: number onToggle: () => void - onOpenInEditor: (event: MouseEvent) => void + onOpenSection: (event: MouseEvent) => void + openSectionTitle: string }): ReactElement { return (
diff --git a/src/renderer/src/components/editor/DiffSectionItem.tsx b/src/renderer/src/components/editor/DiffSectionItem.tsx index 2ffd125ccf3..b32c2b9127d 100644 --- a/src/renderer/src/components/editor/DiffSectionItem.tsx +++ b/src/renderer/src/components/editor/DiffSectionItem.tsx @@ -8,10 +8,10 @@ import { useState, type MutableRefObject } from 'react' +import { AlertCircle, RefreshCw } from 'lucide-react' import { DiffEditor, type DiffOnMount } from '@monaco-editor/react' import type { editor as monacoEditor } from 'monaco-editor' import { monaco } from '@/lib/monaco-setup' -import { joinPath } from '@/lib/path' import { detectLanguage } from '@/lib/language-detect' import { useAppStore } from '@/store' import { computeEditorFontSize } from '@/lib/editor-font-zoom' @@ -30,6 +30,7 @@ import type { DiffSection } from './diff-section-types' import type { DiffComment } from '../../../../shared/types' import { cn } from '@/lib/utils' import { isDiffComment } from '@/lib/diff-comment-compat' +import { Button } from '@/components/ui/button' const ImageDiffViewer = lazy(() => import('./ImageDiffViewer')) @@ -42,9 +43,11 @@ export function DiffSectionItem({ settings, sectionHeight, worktreeId, - worktreeRoot, loadSection, + retrySection, toggleSection, + openSection, + openSectionTitle, setSectionHeights, setSections, modifiedEditorsRef, @@ -58,16 +61,16 @@ export function DiffSectionItem({ settings: { terminalFontSize?: number; terminalFontFamily?: string } | null sectionHeight: number | undefined worktreeId: string - /** The worktree root directory — not a file path; used to resolve absolute paths for opening files. */ - worktreeRoot: string loadSection: (index: number) => void + retrySection: (index: number) => void toggleSection: (index: number) => void + openSection: (index: number) => void + openSectionTitle: string setSectionHeights: React.Dispatch>> setSections: React.Dispatch> modifiedEditorsRef: MutableRefObject> handleSectionSaveRef: MutableRefObject<(index: number) => Promise> }): React.JSX.Element { - const openFile = useAppStore((s) => s.openFile) const editorFontZoomLevel = useAppStore((s) => s.editorFontZoomLevel) const addDiffComment = useAppStore((s) => s.addDiffComment) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) @@ -228,10 +231,16 @@ export function DiffSectionItem({ const lineStats = useMemo( () => - section.loading + section.loading || section.error ? null : computeLineStats(section.originalContent, section.modifiedContent, section.status), - [section.loading, section.originalContent, section.modifiedContent, section.status] + [ + section.error, + section.loading, + section.originalContent, + section.modifiedContent, + section.status + ] ) // Why: image diffs need document-flow height in the combined view; the text // fallback only knows line counts and would squash screenshots into one row. @@ -243,18 +252,6 @@ export function DiffSectionItem({ useIntrinsicImageHeight }) - const handleOpenInEditor = (e: React.MouseEvent): void => { - e.stopPropagation() - const absolutePath = joinPath(worktreeRoot, section.path) - openFile({ - filePath: absolutePath, - relativePath: section.path, - worktreeId, - language, - mode: 'edit' - }) - } - const handleMount: DiffOnMount = (editor, monaco) => { diffEditorRef.current = editor lineNumberOptionsSubRef.current?.dispose() @@ -334,10 +331,14 @@ export function DiffSectionItem({ path={section.path} dirty={section.dirty} collapsed={section.collapsed} - added={lineStats?.added ?? 0} - removed={lineStats?.removed ?? 0} + added={lineStats?.added ?? section.added ?? 0} + removed={lineStats?.removed ?? section.removed ?? 0} onToggle={() => toggleSection(index)} - onOpenInEditor={handleOpenInEditor} + onOpenSection={(event) => { + event.stopPropagation() + openSection(index) + }} + openSectionTitle={openSectionTitle} /> {!section.collapsed && ( @@ -361,8 +362,29 @@ export function DiffSectionItem({ /> )} {section.loading ? ( -
- Loading... +
+ + Loading diff... +
+ ) : section.error ? ( +
+
+ + {section.error} +
+
) : section.diffResult?.kind === 'binary' ? ( section.diffResult.isImage ? ( diff --git a/src/renderer/src/components/editor/EditorContent.tsx b/src/renderer/src/components/editor/EditorContent.tsx index 4afce4ffa48..ac20aec8d00 100644 --- a/src/renderer/src/components/editor/EditorContent.tsx +++ b/src/renderer/src/components/editor/EditorContent.tsx @@ -143,7 +143,8 @@ export function EditorContent({ const isCombinedDiff = activeFile.mode === 'diff' && (activeFile.diffSource === 'combined-uncommitted' || - activeFile.diffSource === 'combined-branch') + activeFile.diffSource === 'combined-branch' || + activeFile.diffSource === 'combined-commit') const renderMonacoEditor = (fc: FileContent): React.JSX.Element => ( // Why: Without a key, React reuses the same MonacoEditor instance when diff --git a/src/renderer/src/components/editor/combined-diff-initial-section-load.test.ts b/src/renderer/src/components/editor/combined-diff-initial-section-load.test.ts new file mode 100644 index 00000000000..31e30f05758 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-initial-section-load.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' +import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load' + +describe('combined diff initial section load', () => { + it('queues the initial section window', () => { + expect( + getInitialCombinedDiffSectionLoadIndices({ + sectionCount: 10, + loadedIndices: new Set(), + maxCount: 4 + }) + ).toEqual([0, 1, 2, 3]) + }) + + it('skips sections that were restored from cache', () => { + expect( + getInitialCombinedDiffSectionLoadIndices({ + sectionCount: 6, + loadedIndices: new Set([0, 2, 5]), + maxCount: 6 + }) + ).toEqual([1, 3, 4]) + }) + + it('handles an empty diff', () => { + expect( + getInitialCombinedDiffSectionLoadIndices({ + sectionCount: 0, + loadedIndices: new Set(), + maxCount: 6 + }) + ).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff-initial-section-load.ts b/src/renderer/src/components/editor/combined-diff-initial-section-load.ts new file mode 100644 index 00000000000..01e669c33f7 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-initial-section-load.ts @@ -0,0 +1,22 @@ +export const COMBINED_DIFF_INITIAL_SECTION_LOAD_COUNT = 6 + +export function getInitialCombinedDiffSectionLoadIndices({ + sectionCount, + loadedIndices, + maxCount = COMBINED_DIFF_INITIAL_SECTION_LOAD_COUNT +}: { + sectionCount: number + loadedIndices: ReadonlySet + maxCount?: number +}): number[] { + const limit = Math.max(0, Math.min(sectionCount, maxCount)) + const indices: number[] = [] + + for (let index = 0; index < limit; index += 1) { + if (!loadedIndices.has(index)) { + indices.push(index) + } + } + + return indices +} diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts b/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts new file mode 100644 index 00000000000..94685129a58 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts @@ -0,0 +1,178 @@ +import { describe, expect, it } from 'vitest' +import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler' + +function deferred(): { promise: Promise; resolve: () => void } { + let resolve!: () => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +async function flushMicrotasks(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +describe('combined diff load scheduler', () => { + it('defaults to serial section loads', async () => { + const blockers = [deferred(), deferred()] + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + await blockers[index - 1]!.promise + } + }) + + scheduler.request(1) + scheduler.request(2) + expect(started).toEqual([1]) + + blockers[0]!.resolve() + await flushMicrotasks() + expect(started).toEqual([1, 2]) + + blockers[1]!.resolve() + await flushMicrotasks() + }) + + it('limits concurrent section loads', async () => { + const blockers = [deferred(), deferred(), deferred()] + const started: number[] = [] + let active = 0 + let maxActive = 0 + const scheduler = createCombinedDiffLoadScheduler({ + maxConcurrent: 2, + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + active += 1 + maxActive = Math.max(maxActive, active) + await blockers[started.length - 1]!.promise + active -= 1 + } + }) + + scheduler.request(1) + scheduler.request(2) + scheduler.request(3) + expect(started).toEqual([1, 2]) + + blockers[0]!.resolve() + await flushMicrotasks() + expect(started).toEqual([1, 2, 3]) + expect(maxActive).toBe(2) + + blockers[1]!.resolve() + blockers[2]!.resolve() + await flushMicrotasks() + }) + + it('continues loading later sections when the first visible section is slow', async () => { + const slow = deferred() + const fast = deferred() + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + maxConcurrent: 2, + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + if (index === 1) { + await slow.promise + } + if (index === 2) { + await fast.promise + } + } + }) + + scheduler.request(1) + scheduler.request(2) + scheduler.request(3) + + expect(started).toEqual([1, 2]) + fast.resolve() + await flushMicrotasks() + expect(started).toEqual([1, 2, 3]) + + slow.resolve() + await flushMicrotasks() + }) + + it('dedupes repeated visibility notifications', async () => { + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + } + }) + + scheduler.request(4) + scheduler.request(4) + await flushMicrotasks() + + expect(started).toEqual([4]) + }) + + it('allows a section to be requested again after a settled load', async () => { + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + } + }) + + scheduler.request(4) + await flushMicrotasks() + scheduler.request(4) + await flushMicrotasks() + + expect(started).toEqual([4, 4]) + }) + + it('drops stale pending work after reset', async () => { + const blocker = deferred() + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + maxConcurrent: 1, + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + if (index === 1) { + await blocker.promise + } + } + }) + + scheduler.request(1) + scheduler.request(2) + scheduler.reset() + scheduler.request(3) + blocker.resolve() + await flushMicrotasks() + + expect(started).toEqual([1, 3]) + }) + + it('revives after dispose when reset for a StrictMode remount', async () => { + const started: number[] = [] + const scheduler = createCombinedDiffLoadScheduler({ + schedule: (callback) => callback(), + loadSection: async (index) => { + started.push(index) + } + }) + + scheduler.dispose() + scheduler.request(1) + scheduler.reset() + scheduler.request(2) + await flushMicrotasks() + + expect(started).toEqual([2]) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.ts b/src/renderer/src/components/editor/combined-diff-load-scheduler.ts new file mode 100644 index 00000000000..6548d9bc892 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff-load-scheduler.ts @@ -0,0 +1,70 @@ +export type CombinedDiffLoadScheduler = { + request: (index: number) => void + reset: () => void + dispose: () => void +} + +export function createCombinedDiffLoadScheduler({ + loadSection, + schedule = (callback) => queueMicrotask(callback), + // Why: a settled section usually mounts a Monaco DiffEditor. Serializing by + // default keeps large lockfile-style diffs from stacking render work. + maxConcurrent = 1 +}: { + loadSection: (index: number) => Promise + schedule?: (callback: () => void) => void + maxConcurrent?: number +}): CombinedDiffLoadScheduler { + const pending: number[] = [] + const queued = new Set() + let active = 0 + let disposed = false + let version = 0 + + const drain = (drainVersion: number): void => { + if (disposed || drainVersion !== version) { + return + } + + while (active < maxConcurrent) { + const nextIndex = pending.shift() + if (nextIndex === undefined) { + return + } + + active += 1 + void loadSection(nextIndex).finally(() => { + queued.delete(nextIndex) + if (disposed || drainVersion !== version) { + return + } + active = Math.max(0, active - 1) + schedule(() => drain(drainVersion)) + }) + } + } + + return { + request(index) { + if (disposed || queued.has(index)) { + return + } + queued.add(index) + pending.push(index) + const requestVersion = version + schedule(() => drain(requestVersion)) + }, + reset() { + disposed = false + version += 1 + pending.length = 0 + queued.clear() + active = 0 + }, + dispose() { + disposed = true + pending.length = 0 + queued.clear() + } + } +} diff --git a/src/renderer/src/components/editor/diff-section-types.ts b/src/renderer/src/components/editor/diff-section-types.ts index d74266c0b27..50c5bff273a 100644 --- a/src/renderer/src/components/editor/diff-section-types.ts +++ b/src/renderer/src/components/editor/diff-section-types.ts @@ -6,10 +6,13 @@ export type DiffSection = { status: string area?: GitStatusEntry['area'] oldPath?: string + added?: number + removed?: number originalContent: string modifiedContent: string collapsed: boolean loading: boolean + error?: string dirty: boolean diffResult: GitDiffResult | null } diff --git a/src/renderer/src/components/editor/editor-header.ts b/src/renderer/src/components/editor/editor-header.ts index 14f0b7028e3..f5d03b1c786 100644 --- a/src/renderer/src/components/editor/editor-header.ts +++ b/src/renderer/src/components/editor/editor-header.ts @@ -25,7 +25,9 @@ export function getEditorHeaderCopyState(file: OpenFile): EditorHeaderCopyState const isCombinedDiff = file.mode === 'diff' && - (file.diffSource === 'combined-uncommitted' || file.diffSource === 'combined-branch') + (file.diffSource === 'combined-uncommitted' || + file.diffSource === 'combined-branch' || + file.diffSource === 'combined-commit') if (isCombinedDiff) { return { @@ -55,7 +57,8 @@ export function getEditorHeaderOpenFileState( file.mode === 'diff' && file.diffSource !== undefined && file.diffSource !== 'combined-uncommitted' && - file.diffSource !== 'combined-branch' + file.diffSource !== 'combined-branch' && + file.diffSource !== 'combined-commit' if (!isSingleDiff) { return { canOpen: false } @@ -64,6 +67,9 @@ export function getEditorHeaderOpenFileState( if (file.diffSource === 'branch') { return { canOpen: branchEntry?.status !== 'deleted' || !branchEntry } } + if (file.diffSource === 'commit') { + return { canOpen: false } + } // Why: diff tabs can outlive the current Source Control snapshot. If the // live entry is missing, keep the action enabled instead of hiding a valid diff --git a/src/renderer/src/components/editor/editor-labels.ts b/src/renderer/src/components/editor/editor-labels.ts index 188af3d218f..20c3ad3414d 100644 --- a/src/renderer/src/components/editor/editor-labels.ts +++ b/src/renderer/src/components/editor/editor-labels.ts @@ -17,7 +17,8 @@ function getBaseLabel(file: OpenFile, variant: EditorLabelVariant): string { const DIFF_SOURCE_LABELS: Record = { staged: 'staged diff', unstaged: 'diff', - branch: 'branch diff' + branch: 'branch diff', + commit: 'commit diff' } export function getEditorDisplayLabel( @@ -43,6 +44,11 @@ export function getEditorDisplayLabel( if (source === 'combined-branch') { return `Branch Changes (${file.branchCompare?.baseRef ?? 'base'})` } + if (source === 'combined-commit') { + return file.commitCompare?.subject + ? `Commit ${file.commitCompare.compareRef}: ${file.commitCompare.subject}` + : `Commit ${file.commitCompare?.compareRef ?? 'diff'}` + } const baseLabel = getBaseLabel(file, variant) const suffix = (source && DIFF_SOURCE_LABELS[source]) ?? 'diff' diff --git a/src/renderer/src/components/editor/editor-panel-render-model.ts b/src/renderer/src/components/editor/editor-panel-render-model.ts index 7b588567f30..c5361e36449 100644 --- a/src/renderer/src/components/editor/editor-panel-render-model.ts +++ b/src/renderer/src/components/editor/editor-panel-render-model.ts @@ -36,11 +36,13 @@ export function getEditorPanelRenderModel({ activeFile.mode === 'diff' && activeFile.diffSource !== undefined && activeFile.diffSource !== 'combined-uncommitted' && - activeFile.diffSource !== 'combined-branch' + activeFile.diffSource !== 'combined-branch' && + activeFile.diffSource !== 'combined-commit' const isCombinedDiff = activeFile.mode === 'diff' && (activeFile.diffSource === 'combined-uncommitted' || - activeFile.diffSource === 'combined-branch') + activeFile.diffSource === 'combined-branch' || + activeFile.diffSource === 'combined-commit') const resolvedLanguage = activeFile.mode === 'diff' ? detectLanguage(activeFile.relativePath) @@ -48,7 +50,8 @@ export function getEditorPanelRenderModel({ const worktreeEntries = gitStatusByWorktree[activeFile.worktreeId] ?? [] const branchEntries = gitBranchChangesByWorktree[activeFile.worktreeId] ?? [] const matchingWorktreeEntry = - activeFile.mode === 'diff' && activeFile.diffSource !== 'branch' + activeFile.mode === 'diff' && + (activeFile.diffSource === 'staged' || activeFile.diffSource === 'unstaged') ? (worktreeEntries.find( (entry) => entry.path === activeFile.relativePath && diff --git a/src/renderer/src/components/editor/markdown-preview-controls.ts b/src/renderer/src/components/editor/markdown-preview-controls.ts index a6ee3aa7716..2d8232099f0 100644 --- a/src/renderer/src/components/editor/markdown-preview-controls.ts +++ b/src/renderer/src/components/editor/markdown-preview-controls.ts @@ -47,7 +47,8 @@ export function getMarkdownViewModes(target: MarkdownPreviewTarget): readonly Ma if ( target.mode === 'diff' && target.diffSource !== 'combined-uncommitted' && - target.diffSource !== 'combined-branch' + target.diffSource !== 'combined-branch' && + target.diffSource !== 'combined-commit' ) { return MARKDOWN_DIFF_VIEW_MODES } diff --git a/src/renderer/src/components/editor/useEditorPanelContentState.ts b/src/renderer/src/components/editor/useEditorPanelContentState.ts index f26154be5cd..8b93d8e5a6d 100644 --- a/src/renderer/src/components/editor/useEditorPanelContentState.ts +++ b/src/renderer/src/components/editor/useEditorPanelContentState.ts @@ -6,6 +6,7 @@ import { getRuntimeFileReadScope, readRuntimeFileContent } from '@/runtime/runti import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' import { getRuntimeGitBranchDiff, + getRuntimeGitCommitDiff, getRuntimeGitDiff, getRuntimeGitScope } from '@/runtime/runtime-git-client' @@ -50,7 +51,11 @@ function inFlightDiffKey( file.diffSource === 'branch' && file.branchCompare ? `${file.branchCompare.baseOid ?? ''}..${file.branchCompare.headOid ?? ''}::${file.branchOldPath ?? ''}` : '' - return `${connectionId ?? ''}::${file.diffSource ?? ''}::${compareAgainstHead ? 'head' : 'default'}::${file.filePath}::${branch}` + const commit = + file.diffSource === 'commit' && file.commitCompare + ? `${file.commitCompare.parentOid ?? 'empty-tree'}..${file.commitCompare.commitOid}::${file.branchOldPath ?? ''}` + : '' + return `${connectionId ?? ''}::${file.diffSource ?? ''}::${compareAgainstHead ? 'head' : 'default'}::${file.filePath}::${branch}::${commit}` } export function useEditorPanelContentState({ @@ -134,6 +139,7 @@ export function useEditorPanelContentState({ file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase ? file.branchCompare : null + const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null const connectionId = getConnectionId(file.worktreeId) ?? undefined const activeSettings = useAppStore.getState().settings const fileSettings = settingsForRuntimeOwner(activeSettings, file.runtimeEnvironmentId) @@ -149,38 +155,55 @@ export function useEditorPanelContentState({ let pending = inFlightDiffReads.get(key) if (!pending) { pending = ( - effectiveDiffSource === 'branch' && branchCompare - ? getRuntimeGitBranchDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath, - connectionId - }, - { - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! + effectiveDiffSource === 'commit' + ? commitCompare + ? getRuntimeGitCommitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId }, - filePath: file.relativePath, - oldPath: file.branchOldPath - } - ) - : getRuntimeGitDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath, - connectionId - }, - { - filePath: file.relativePath, - staged: effectiveDiffSource === 'staged', - compareAgainstHead - } - ) + { + commitOid: commitCompare.commitOid, + parentOid: commitCompare.parentOid, + filePath: file.relativePath, + oldPath: file.branchOldPath + } + ) + : Promise.reject(new Error('Missing commit comparison for diff tab.')) + : effectiveDiffSource === 'branch' && branchCompare + ? getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId + }, + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: file.relativePath, + oldPath: file.branchOldPath + } + ) + : getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath, + connectionId + }, + { + filePath: file.relativePath, + staged: effectiveDiffSource === 'staged', + compareAgainstHead + } + ) ) as Promise inFlightDiffReads.set(key, pending) queueMicrotask(() => { @@ -240,6 +263,7 @@ export function useEditorPanelContentState({ activeFile.diffSource !== undefined && activeFile.diffSource !== 'combined-uncommitted' && activeFile.diffSource !== 'combined-branch' && + activeFile.diffSource !== 'combined-commit' && !diffContents[activeFile.id] ) { void loadDiffContent(activeFile) diff --git a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts index bf82c7bbc09..bcda787907a 100644 --- a/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts +++ b/src/renderer/src/components/editor/useEditorPanelExternalContentEvents.ts @@ -44,7 +44,8 @@ export function useEditorPanelExternalContentEvents({ } else if ( file.mode === 'diff' && file.diffSource !== 'combined-uncommitted' && - file.diffSource !== 'combined-branch' + file.diffSource !== 'combined-branch' && + file.diffSource !== 'combined-commit' ) { void loadDiffContent(file) } diff --git a/src/renderer/src/components/right-sidebar/GitHistoryGraphSvg.tsx b/src/renderer/src/components/right-sidebar/GitHistoryGraphSvg.tsx new file mode 100644 index 00000000000..b1e1b293751 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/GitHistoryGraphSvg.tsx @@ -0,0 +1,224 @@ +import React from 'react' +import type { GitHistoryGraphColorId } from '../../../../shared/git-history' +import { + getGitHistoryItemLaneIndex, + getGitHistoryMergeParentLaneIndex, + type GitHistoryItemViewModel +} from '../../../../shared/git-history-graph' + +const SWIMLANE_HEIGHT = 22 +const SWIMLANE_WIDTH = 11 +const SWIMLANE_CURVE_RADIUS = 5 +const CIRCLE_RADIUS = 3.5 +const CIRCLE_STROKE_WIDTH = 1.5 + +export function graphColor(color: GitHistoryGraphColorId): string { + return `var(--${color})` +} + +function GraphPath({ + d, + color, + strokeWidth = 1 +}: { + d: string + color: GitHistoryGraphColorId + strokeWidth?: number +}): React.JSX.Element { + return ( + + ) +} + +export function GitHistoryGraphSvg({ + viewModel +}: { + viewModel: GitHistoryItemViewModel +}): React.JSX.Element { + const historyItem = viewModel.historyItem + const inputSwimlanes = viewModel.inputSwimlanes + const outputSwimlanes = viewModel.outputSwimlanes + const inputIndex = inputSwimlanes.findIndex((node) => node.id === historyItem.id) + const circleIndex = getGitHistoryItemLaneIndex(viewModel) + const circleColor = + circleIndex < outputSwimlanes.length + ? outputSwimlanes[circleIndex]!.color + : circleIndex < inputSwimlanes.length + ? inputSwimlanes[circleIndex]!.color + : 'git-graph-ref' + + const paths: React.JSX.Element[] = [] + let outputSwimlaneIndex = 0 + + for (let index = 0; index < inputSwimlanes.length; index += 1) { + const color = inputSwimlanes[index]!.color + if (inputSwimlanes[index]!.id === historyItem.id) { + if (index !== circleIndex) { + paths.push( + + ) + } else { + outputSwimlaneIndex += 1 + } + continue + } + + if ( + outputSwimlaneIndex < outputSwimlanes.length && + inputSwimlanes[index]!.id === outputSwimlanes[outputSwimlaneIndex]!.id + ) { + if (index === outputSwimlaneIndex) { + paths.push( + + ) + } else { + paths.push( + + ) + } + outputSwimlaneIndex += 1 + } + } + + for (let index = 1; index < historyItem.parentIds.length; index += 1) { + const parentId = historyItem.parentIds[index]! + const parentOutputIndex = getGitHistoryMergeParentLaneIndex(viewModel, parentId) + if (parentOutputIndex === -1) { + continue + } + paths.push( + + ) + } + + if (inputIndex !== -1) { + paths.push( + + ) + } + if (historyItem.parentIds.length > 0) { + paths.push( + + ) + } + + const cx = SWIMLANE_WIDTH * (circleIndex + 1) + const cy = SWIMLANE_WIDTH + const width = SWIMLANE_WIDTH * (Math.max(inputSwimlanes.length, outputSwimlanes.length, 1) + 1) + const isBoundaryNode = + viewModel.kind === 'incoming-changes' || viewModel.kind === 'outgoing-changes' + const isMergeNode = historyItem.parentIds.length > 1 + + return ( + + ) +} diff --git a/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx b/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx new file mode 100644 index 00000000000..5e9844cc8b4 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/GitHistoryPanel.tsx @@ -0,0 +1,201 @@ +import React, { useMemo } from 'react' +import { ChevronDown, RefreshCw } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { cn } from '@/lib/utils' +import type { GitHistoryItem, GitHistoryResult } from '../../../../shared/git-history' +import { + buildDefaultGitHistoryColorMap, + buildGitHistoryViewModels, + type GitHistoryItemViewModel +} from '../../../../shared/git-history-graph' +import { GitHistoryGraphSvg, graphColor } from './GitHistoryGraphSvg' + +export type GitHistoryPanelState = + | { status: 'idle' | 'loading'; result?: GitHistoryResult; error?: string } + | { status: 'refreshing' | 'ready'; result: GitHistoryResult; error?: string } + | { status: 'error'; result?: GitHistoryResult; error: string } + +function formatHistoryTimestamp(timestamp: number | undefined): string { + if (!timestamp) { + return '' + } + return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format( + new Date(timestamp) + ) +} + +function GitHistoryRefBadge({ + itemRef +}: { + itemRef: NonNullable +}): React.JSX.Element { + return ( + + {itemRef.name} + + ) +} + +function GitHistoryRow({ + viewModel, + onOpenCommit +}: { + viewModel: GitHistoryItemViewModel + onOpenCommit?: (item: GitHistoryItem) => void +}): React.JSX.Element { + const item = viewModel.historyItem + const timestamp = formatHistoryTimestamp(item.timestamp) + const isBoundaryNode = + viewModel.kind === 'incoming-changes' || viewModel.kind === 'outgoing-changes' + const canOpenCommit = !isBoundaryNode && Boolean(onOpenCommit) + + return ( + + ) +} + +export function GitHistoryPanel({ + state, + collapsed, + onToggle, + onRefresh, + onOpenCommit +}: { + state: GitHistoryPanelState + collapsed: boolean + onToggle: () => void + onRefresh: () => void + onOpenCommit?: (item: GitHistoryItem) => void +}): React.JSX.Element | null { + const result = state.result + const viewModels = useMemo(() => { + if (!result) { + return [] + } + return buildGitHistoryViewModels( + result.items, + buildDefaultGitHistoryColorMap(result), + result.currentRef, + result.remoteRef, + result.baseRef, + result.hasIncomingChanges, + result.hasOutgoingChanges, + result.mergeBase + ) + }, [result]) + + const loading = state.status === 'loading' || state.status === 'refreshing' + const count = result?.items.length ?? 0 + + if (!result && state.status === 'idle') { + return null + } + + return ( +
+
+
+ + + + + + + Refresh graph + + +
+
+ {!collapsed && state.status === 'error' && !result && ( +
{state.error}
+ )} + {!collapsed && state.status === 'loading' && !result && ( +
+ + Loading graph... +
+ )} + {!collapsed && result && viewModels.length === 0 && ( +
No commits yet
+ )} + {!collapsed && viewModels.length > 0 && ( +
+ {viewModels.map((viewModel) => ( + + ))} +
+ )} +
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index df926144858..096028a78c7 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -116,12 +116,16 @@ import { discardRuntimeGitPath, generateRuntimeCommitMessage, getRuntimeGitBranchCompare, + getRuntimeGitCommitCompare, + getRuntimeGitHistory, stageRuntimeGitPath, unstageRuntimeGitPath } from '@/runtime/runtime-git-client' import { getRuntimeRepoBaseRefDefault } from '@/runtime/runtime-repo-client' import { PullRequestIcon } from './checks-panel-content' import { CreatePullRequestDialog } from './CreatePullRequestDialog' +import { GitHistoryPanel, type GitHistoryPanelState } from './GitHistoryPanel' +import type { GitHistoryItem } from '../../../../shared/git-history' import type { DiffComment, GitBranchChangeEntry, @@ -185,6 +189,7 @@ const SOURCE_CONTROL_ROW_ACTION_OVERLAY_CLASS = const SOURCE_CONTROL_TREE_INDENT_PX = 12 const SOURCE_CONTROL_TREE_DIRECTORY_PADDING_PX = 8 const SOURCE_CONTROL_TREE_FILE_PADDING_PX = 20 +const EMPTY_GIT_HISTORY_STATE: GitHistoryPanelState = { status: 'idle' } // Why: the pure state-machine logic now lives in // ./source-control-primary-action.ts. It is imported directly by callers @@ -344,6 +349,7 @@ function SourceControlInner(): React.JSX.Element { const openBranchDiff = useAppStore((s) => s.openBranchDiff) const openAllDiffs = useAppStore((s) => s.openAllDiffs) const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) + const openCommitAllDiffs = useAppStore((s) => s.openCommitAllDiffs) const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) const clearDiffComments = useAppStore((s) => s.clearDiffComments) const clearDiffCommentsForFile = useAppStore((s) => s.clearDiffCommentsForFile) @@ -514,6 +520,14 @@ function SourceControlInner(): React.JSX.Element { const commitMessage = readCommitDraftForWorktree(commitDrafts, activeWorktreeId) const commitError = commitErrors[activeWorktreeId ?? ''] ?? null const remoteActionError = remoteActionErrors[activeWorktreeId ?? ''] ?? null + const [gitHistoryByWorktree, setGitHistoryByWorktree] = useState< + Record + >({}) + const gitHistoryRequestSeqRef = useRef(0) + const gitHistoryRequestByWorktreeRef = useRef>({}) + const gitHistoryState = activeWorktreeId + ? (gitHistoryByWorktree[activeWorktreeId] ?? EMPTY_GIT_HISTORY_STATE) + : EMPTY_GIT_HISTORY_STATE const isFolder = activeRepo ? isFolderRepo(activeRepo) : false const worktreePath = activeWorktree?.path ?? null @@ -862,6 +876,7 @@ function SourceControlInner(): React.JSX.Element { setCommitInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateInFlightByWorktree((prev) => pruneRecord(prev)) setGenerateErrors((prev) => pruneRecord(prev)) + setGitHistoryByWorktree((prev) => pruneRecord(prev)) // Refs don't need setState — mutate in place to drop stale keys. for (const key of Object.keys(commitInFlightRef.current)) { if (!worktreeMap.has(key)) { @@ -873,6 +888,11 @@ function SourceControlInner(): React.JSX.Element { delete generateInFlightRef.current[key] } } + for (const key of Object.keys(gitHistoryRequestByWorktreeRef.current)) { + if (!worktreeMap.has(key)) { + delete gitHistoryRequestByWorktreeRef.current[key] + } + } }, [worktreeMap]) // Why: the sidebar no longer uses key={activeWorktreeId} to force a full @@ -981,6 +1001,7 @@ function SourceControlInner(): React.JSX.Element { ) } void refreshBranchCompareRef.current() + void refreshGitHistoryRef.current() return true } catch (error) { setCommitErrors((prev) => ({ @@ -1148,6 +1169,8 @@ function SourceControlInner(): React.JSX.Element { message: resolveRemoteActionError(kind, error) } })) + } finally { + void refreshGitHistoryRef.current() } }, [ @@ -1795,6 +1818,60 @@ function SourceControlInner(): React.JSX.Element { const refreshBranchCompareRef = useRef(refreshBranchCompare) refreshBranchCompareRef.current = refreshBranchCompare + const refreshGitHistory = useCallback(async (): Promise => { + if (!activeWorktreeId || !worktreePath || isFolder || !isBranchVisible) { + return + } + + const worktreeId = activeWorktreeId + const requestId = gitHistoryRequestSeqRef.current + 1 + gitHistoryRequestSeqRef.current = requestId + gitHistoryRequestByWorktreeRef.current[worktreeId] = requestId + setGitHistoryByWorktree((prev) => { + const previous = prev[worktreeId] + return { + ...prev, + [worktreeId]: previous?.result + ? { status: 'refreshing', result: previous.result } + : { status: 'loading' } + } + }) + + try { + const connectionId = getConnectionId(worktreeId) ?? undefined + const result = await getRuntimeGitHistory( + { + settings: useAppStore.getState().settings, + worktreeId, + worktreePath, + connectionId + }, + { limit: 50, baseRef: effectiveBaseRef } + ) + if (gitHistoryRequestByWorktreeRef.current[worktreeId] !== requestId) { + return + } + setGitHistoryByWorktree((prev) => ({ ...prev, [worktreeId]: { status: 'ready', result } })) + } catch (error) { + if (gitHistoryRequestByWorktreeRef.current[worktreeId] !== requestId) { + return + } + const message = error instanceof Error ? error.message : 'Failed to load git graph' + setGitHistoryByWorktree((prev) => { + const previous = prev[worktreeId] + return { + ...prev, + [worktreeId]: previous?.result + ? { status: 'error', result: previous.result, error: message } + : { status: 'error', error: message } + } + }) + } + }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + + const refreshGitHistoryRef = useRef(refreshGitHistory) + refreshGitHistoryRef.current = refreshGitHistory + useEffect(() => { if (!activeWorktreeId || !worktreePath || !isBranchVisible || !effectiveBaseRef || isFolder) { return @@ -1817,6 +1894,16 @@ function SourceControlInner(): React.JSX.Element { } }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + useEffect(() => { + // Why: history shells out to git, but unlike branch compare it only needs + // visible-load and mutation refreshes. Avoid polling so long sessions don't + // spawn git processes for a decorative graph. + if (!isBranchVisible) { + return + } + void refreshGitHistoryRef.current() + }, [activeWorktreeId, effectiveBaseRef, isBranchVisible, isFolder, worktreePath]) + useEffect(() => { // Why: gate on isBranchVisible so we don't spawn git processes while the // sidebar is closed. Store-slice remote operations refresh upstream-status @@ -1874,6 +1961,41 @@ function SourceControlInner(): React.JSX.Element { [activeWorktreeId, branchSummary, openBranchDiff, worktreePath] ) + const openHistoryCommitDiff = useCallback( + async (item: GitHistoryItem): Promise => { + if (!activeWorktreeId || !worktreePath) { + return + } + + try { + const connectionId = getConnectionId(activeWorktreeId) ?? undefined + const result = await getRuntimeGitCommitCompare( + { + settings: useAppStore.getState().settings, + worktreeId: activeWorktreeId, + worktreePath, + connectionId + }, + item.id + ) + if (result.summary.status !== 'ready') { + toast.error(result.summary.errorMessage ?? 'Failed to load commit diff') + return + } + openCommitAllDiffs( + activeWorktreeId, + worktreePath, + result.summary, + result.entries, + item.subject + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : 'Failed to load commit diff') + } + }, + [activeWorktreeId, openCommitAllDiffs, worktreePath] + ) + // Why: a note's filePath is the same relative path used by GitStatusEntry / // GitBranchChangeEntry, so we can route the click to whichever diff surface // currently owns that file. Prefer the `unstaged` entry when a path is also @@ -2480,7 +2602,7 @@ function SourceControlInner(): React.JSX.Element {
0 ? 50 : undefined }} > {unresolvedConflictReviewEntries.length > 0 && ( @@ -2859,6 +2981,21 @@ function SourceControlInner(): React.JSX.Element { )))}
)} + + {scope === 'all' && !normalizedFilter && ( + // Why: the graph is reference context for the whole panel, so when + // file sections are short it should occupy the bottom instead of + // crowding the commit controls. +
+ toggleSection('history')} + onRefresh={() => void refreshGitHistory()} + onOpenCommit={(item) => void openHistoryCommitDiff(item)} + /> +
+ )}
{selectedKeys.size > 0 && ( diff --git a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts index 66ffb16bc47..107f0c99589 100644 --- a/src/renderer/src/components/sidebar/use-worktree-activity-status.ts +++ b/src/renderer/src/components/sidebar/use-worktree-activity-status.ts @@ -34,8 +34,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { const tabIds = new Set(wtTabs.map((tab) => tab.id)) const now = Date.now() for (const [paneKey, entry] of Object.entries(s.agentStatusByPaneKey)) { - const parsed = parsePaneKey(paneKey) - if (!parsed || !tabIds.has(parsed.tabId)) { + const tabId = getPaneKeyTabId(paneKey) + if (!tabId || !tabIds.has(tabId)) { continue } if (!isExplicitAgentStatusFresh(entry, now, AGENT_STATUS_STALE_AFTER_MS)) { @@ -54,8 +54,8 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { if (!entry) { continue } - const parsed = parsePaneKey(entry.paneKey) - if (parsed && tabIds.has(parsed.tabId)) { + const tabId = getPaneKeyTabId(entry.paneKey) + if (tabId && tabIds.has(tabId)) { perm = true } } @@ -104,3 +104,18 @@ export function useWorktreeActivityStatus(worktreeId: string): WorktreeStatus { ] ) } + +function getPaneKeyTabId(paneKey: string): string | null { + const parsed = parsePaneKey(paneKey) + if (parsed) { + return parsed.tabId + } + + // Why: restored snapshots and older test fixtures can still carry the + // pre-stable-pane-id `tabId:numericPaneId` key; status only needs tab scope. + const sepIdx = paneKey.indexOf(':') + if (sepIdx <= 0 || sepIdx !== paneKey.lastIndexOf(':') || sepIdx === paneKey.length - 1) { + return null + } + return paneKey.slice(0, sepIdx) +} diff --git a/src/renderer/src/runtime/runtime-git-client.test.ts b/src/renderer/src/runtime/runtime-git-client.test.ts index 8e46cd718d3..ccb6c248bdd 100644 --- a/src/renderer/src/runtime/runtime-git-client.test.ts +++ b/src/renderer/src/runtime/runtime-git-client.test.ts @@ -6,6 +6,7 @@ import { commitRuntimeGit, generateRuntimeCommitMessage, getRuntimeGitDiff, + getRuntimeGitHistory, getRuntimeGitStatus, pushRuntimeGit } from './runtime-git-client' @@ -17,6 +18,7 @@ import { clearRuntimeCompatibilityCacheForTests } from './runtime-rpc-client' const gitStatus = vi.fn() const gitDiff = vi.fn() +const gitHistory = vi.fn() const gitBulkStage = vi.fn() const gitBulkDiscard = vi.fn() const gitCommit = vi.fn() @@ -31,6 +33,7 @@ beforeEach(() => { clearRuntimeCompatibilityCacheForTests() gitStatus.mockReset() gitDiff.mockReset() + gitHistory.mockReset() gitBulkStage.mockReset() gitBulkDiscard.mockReset() gitCommit.mockReset() @@ -48,6 +51,7 @@ beforeEach(() => { git: { status: gitStatus, diff: gitDiff, + history: gitHistory, bulkStage: gitBulkStage, bulkDiscard: gitBulkDiscard, commit: gitCommit, @@ -107,6 +111,34 @@ describe('runtime git client', () => { }) }) + it('uses local git IPC for history when no remote runtime is active', async () => { + gitHistory.mockResolvedValue({ + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit: 50 + }) + + await getRuntimeGitHistory( + { + settings: { activeRuntimeEnvironmentId: null }, + worktreeId: 'wt-1', + worktreePath: '/repo', + connectionId: 'ssh-1' + }, + { limit: 25, baseRef: 'origin/main' } + ) + + expect(gitHistory).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: 'ssh-1', + limit: 25, + baseRef: 'origin/main' + }) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + it('routes status and diffs through the active runtime environment', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-1', @@ -128,6 +160,14 @@ describe('runtime git client', () => { }, { filePath: 'src/a.ts', staged: false, compareAgainstHead: true } ) + await getRuntimeGitHistory( + { + settings: { activeRuntimeEnvironmentId: 'env-1' }, + worktreeId: 'wt-1', + worktreePath: '/repo' + }, + { limit: 50, baseRef: 'origin/main' } + ) expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(1, { selector: 'env-1', @@ -146,6 +186,12 @@ describe('runtime git client', () => { }, timeoutMs: 15_000 }) + expect(runtimeEnvironmentCall).toHaveBeenNthCalledWith(3, { + selector: 'env-1', + method: 'git.history', + params: { worktree: 'wt-1', limit: 50, baseRef: 'origin/main' }, + timeoutMs: 15_000 + }) }) it('forwards includeIgnored through the active runtime environment', async () => { diff --git a/src/renderer/src/runtime/runtime-git-client.ts b/src/renderer/src/runtime/runtime-git-client.ts index c20f90ee07d..d11f78191d6 100644 --- a/src/renderer/src/runtime/runtime-git-client.ts +++ b/src/renderer/src/runtime/runtime-git-client.ts @@ -3,6 +3,7 @@ runtime-aware routing so source-control callers have one typed boundary instead of reimplementing local-vs-environment branching per operation. */ import type { GitBranchCompareResult, + GitCommitCompareResult, GitConflictOperation, GitDiffResult, GitPushTarget, @@ -10,6 +11,7 @@ import type { GitUpstreamStatus, GlobalSettings } from '../../../shared/types' +import type { GitHistoryOptions, GitHistoryResult } from '../../../shared/git-history' import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' export type RuntimeGenerateCommitMessageResult = @@ -76,6 +78,26 @@ export async function getRuntimeGitStatus( ) } +export async function getRuntimeGitHistory( + context: RuntimeGitContext, + options: GitHistoryOptions = {} +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.history({ + worktreePath: context.worktreePath, + connectionId: context.connectionId, + ...options + }) + } + return callRuntimeRpc( + target, + 'git.history', + { worktree: context.worktreeId, ...options }, + { timeoutMs: 15_000 } + ) +} + export async function getRuntimeGitConflictOperation( context: RuntimeGitContext ): Promise { @@ -136,6 +158,26 @@ export async function getRuntimeGitBranchCompare( ) } +export async function getRuntimeGitCommitCompare( + context: RuntimeGitContext, + commitId: string +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.commitCompare({ + worktreePath: context.worktreePath, + commitId, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.commitCompare', + { worktree: context.worktreeId, commitId }, + { timeoutMs: 15_000 } + ) +} + export async function getRuntimeGitUpstreamStatus( context: RuntimeGitContext ): Promise { @@ -226,6 +268,34 @@ export async function getRuntimeGitBranchDiff( ) } +export async function getRuntimeGitCommitDiff( + context: RuntimeGitContext, + args: { + commitOid: string + parentOid?: string | null + filePath: string + oldPath?: string + } +): Promise { + const target = getActiveRuntimeTarget(context.settings) + if (target.kind === 'local' || !context.worktreeId) { + return window.api.git.commitDiff({ + worktreePath: context.worktreePath, + commitOid: args.commitOid, + parentOid: args.parentOid, + filePath: args.filePath, + oldPath: args.oldPath, + connectionId: context.connectionId + }) + } + return callRuntimeRpc( + target, + 'git.commitDiff', + { worktree: context.worktreeId, ...args }, + { timeoutMs: 15_000 } + ) +} + export async function commitRuntimeGit( context: RuntimeGitContext, message: string diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 19f96cfecf5..ed4841e8c6a 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -10,6 +10,7 @@ import { detectLanguage } from '@/lib/language-detect' import type { GitBranchChangeEntry, GitBranchCompareSummary, + GitCommitCompareSummary, GitConflictKind, GitConflictOperation, GitConflictResolutionStatus, @@ -42,8 +43,10 @@ export type DiffSource = | 'unstaged' | 'staged' | 'branch' + | 'commit' | 'combined-uncommitted' | 'combined-branch' + | 'combined-commit' export type BranchCompareSnapshot = Pick< GitBranchCompareSummary, @@ -52,6 +55,24 @@ export type BranchCompareSnapshot = Pick< compareVersion: string } +export type CommitCompareSnapshot = Pick< + GitCommitCompareSummary, + 'commitOid' | 'parentOid' | 'compareRef' | 'baseRef' +> & { + compareVersion: string + subject?: string +} + +type BranchCompareLike = Pick< + GitBranchCompareSummary, + 'baseRef' | 'baseOid' | 'compareRef' | 'headOid' | 'mergeBase' +> + +type CommitCompareLike = Pick< + GitCommitCompareSummary, + 'commitOid' | 'parentOid' | 'compareRef' | 'baseRef' +> + type CombinedDiffAlternate = { source: 'combined-uncommitted' | 'combined-branch' branchCompare?: BranchCompareSnapshot @@ -119,10 +140,12 @@ export type OpenFile = { markdownPreviewAnchor?: string diffSource?: DiffSource branchCompare?: BranchCompareSnapshot + commitCompare?: CommitCompareSnapshot branchOldPath?: string combinedAlternate?: CombinedDiffAlternate combinedAreaFilter?: string // filter combined diff to a specific area (e.g. 'staged', 'unstaged', 'untracked') branchEntriesSnapshot?: GitBranchChangeEntry[] + commitEntriesSnapshot?: GitBranchChangeEntry[] /** Why: snapshot uncommitted entries at tab-open time so a subsequent commit * does not yank entries out from under the combined diff, which would rebuild * all sections and lose loaded content + scroll position. */ @@ -280,7 +303,14 @@ export type EditorSlice = { worktreeId: string, worktreePath: string, entry: GitBranchChangeEntry, - compare: GitBranchCompareSummary, + compare: BranchCompareLike, + language: string + ) => void + openCommitDiff: ( + worktreeId: string, + worktreePath: string, + entry: GitBranchChangeEntry, + compare: CommitCompareLike, language: string ) => void openAllDiffs: ( @@ -307,6 +337,13 @@ export type EditorSlice = { compare: GitBranchCompareSummary, alternate?: CombinedDiffAlternate ) => void + openCommitAllDiffs: ( + worktreeId: string, + worktreePath: string, + compare: GitCommitCompareSummary, + entries: GitBranchChangeEntry[], + subject?: string + ) => void // Cursor line tracking per file editorCursorLine: Record @@ -745,6 +782,7 @@ export const createEditorSlice: StateCreator = (s existing.mode !== file.mode || existing.diffSource !== file.diffSource || existing.branchCompare?.compareVersion !== file.branchCompare?.compareVersion || + existing.commitCompare?.compareVersion !== file.commitCompare?.compareVersion || existing.conflict?.kind !== file.conflict?.kind || existing.conflict?.conflictKind !== file.conflict?.conflictKind || existing.conflict?.conflictStatus !== file.conflict?.conflictStatus || @@ -769,9 +807,11 @@ export const createEditorSlice: StateCreator = (s mode: file.mode, diffSource: file.diffSource, branchCompare: file.branchCompare, + commitCompare: file.commitCompare, branchOldPath: file.branchOldPath, combinedAlternate: file.combinedAlternate, combinedAreaFilter: file.combinedAreaFilter, + commitEntriesSnapshot: file.commitEntriesSnapshot, conflict: file.conflict, skippedConflicts: file.skippedConflicts, conflictReview: file.conflictReview, @@ -1574,6 +1614,59 @@ export const createEditorSlice: StateCreator = (s void openWorkspaceEditorItem(get(), id, worktreeId, entry.path, 'diff') }, + openCommitDiff: (worktreeId, worktreePath, entry, compare, language) => { + const commitCompare = toCommitCompareSnapshot(compare) + const id = `${worktreeId}::diff::commit::${commitCompare.compareVersion}::${entry.path}` + set((s) => { + const existing = s.openFiles.find((f) => f.id === id) + if (existing) { + return { + openFiles: s.openFiles.map((f) => + f.id === id + ? { + ...f, + mode: 'diff' as const, + diffSource: 'commit' as const, + commitCompare, + branchOldPath: entry.oldPath, + conflict: undefined, + skippedConflicts: undefined, + conflictReview: undefined + } + : f + ), + activeFileId: id, + activeTabType: 'editor', + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, + activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + } + } + const newFile: OpenFile = { + id, + filePath: joinPath(worktreePath, entry.path), + relativePath: entry.path, + worktreeId, + language, + isDirty: false, + mode: 'diff', + diffSource: 'commit', + commitCompare, + branchOldPath: entry.oldPath, + conflict: undefined, + skippedConflicts: undefined, + conflictReview: undefined + } + return { + openFiles: [...s.openFiles, newFile], + activeFileId: id, + activeTabType: 'editor', + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, + activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + } + }) + void openWorkspaceEditorItem(get(), id, worktreeId, entry.path, 'diff') + }, + openAllDiffs: (worktreeId, worktreePath, alternate, areaFilter) => { const id = areaFilter ? `${worktreeId}::all-diffs::uncommitted::${areaFilter}` @@ -1846,6 +1939,62 @@ export const createEditorSlice: StateCreator = (s ) }, + openCommitAllDiffs: (worktreeId, worktreePath, compare, entries, subject) => { + const commitCompare = toCommitCompareSnapshot(compare, subject) + const id = `${worktreeId}::all-diffs::commit::${commitCompare.commitOid}` + const label = subject + ? `Commit ${commitCompare.compareRef}: ${subject}` + : `Commit ${commitCompare.compareRef}` + set((s) => { + const existing = s.openFiles.find((f) => f.id === id) + if (existing) { + return { + openFiles: s.openFiles.map((f) => + f.id === id + ? { + ...f, + relativePath: label, + commitCompare, + commitEntriesSnapshot: entries, + conflict: undefined, + skippedConflicts: undefined, + conflictReview: undefined + } + : f + ), + activeFileId: id, + activeTabType: 'editor', + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, + activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + } + } + + const newFile: OpenFile = { + id, + filePath: worktreePath, + relativePath: label, + worktreeId, + language: 'plaintext', + isDirty: false, + mode: 'diff', + diffSource: 'combined-commit', + commitCompare, + commitEntriesSnapshot: entries, + conflict: undefined, + skippedConflicts: undefined, + conflictReview: undefined + } + return { + openFiles: [...s.openFiles, newFile], + activeFileId: id, + activeTabType: 'editor', + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id }, + activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' } + } + }) + void openWorkspaceEditorItem(get(), id, worktreeId, label, 'diff') + }, + // Cursor line tracking editorCursorLine: {}, setEditorCursorLine: (fileId, line) => @@ -2570,7 +2719,7 @@ export const createEditorSlice: StateCreator = (s }) function getCompareVersion( - compare: Pick + compare: Pick ): string { return [ compare.baseOid ?? 'no-base', @@ -2579,7 +2728,7 @@ function getCompareVersion( ].join(':') } -function toBranchCompareSnapshot(compare: GitBranchCompareSummary): BranchCompareSnapshot { +function toBranchCompareSnapshot(compare: BranchCompareLike): BranchCompareSnapshot { return { baseRef: compare.baseRef, baseOid: compare.baseOid, @@ -2590,6 +2739,22 @@ function toBranchCompareSnapshot(compare: GitBranchCompareSummary): BranchCompar } } +function toCommitCompareSnapshot( + compare: CommitCompareLike, + subject?: string +): CommitCompareSnapshot { + return { + commitOid: compare.commitOid, + parentOid: compare.parentOid, + compareRef: compare.compareRef, + baseRef: compare.baseRef, + compareVersion: `${compare.parentOid ?? 'empty-tree'}:${compare.commitOid}`, + subject: + subject ?? + ('subject' in compare && typeof compare.subject === 'string' ? compare.subject : undefined) + } +} + function toOpenConflictMetadata(entry: GitStatusEntry): OpenConflictMetadata | undefined { if (!entry.conflictKind || !entry.conflictStatus || !entry.conflictStatusSource) { return undefined diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index ba3c73f2dc8..f51f9a02470 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -484,6 +484,10 @@ function createGitApi(): NonNullable['git']> { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.status', { worktree: worktree.id, includeIgnored }) }, + history: async ({ worktreePath, limit, baseRef }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.history', { worktree: worktree.id, limit, baseRef }) + }, conflictOperation: async ({ worktreePath }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.conflictOperation', { worktree: worktree.id }) @@ -501,6 +505,10 @@ function createGitApi(): NonNullable['git']> { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.branchCompare', { worktree: worktree.id, baseRef }) }, + commitCompare: async ({ worktreePath, commitId }) => { + const worktree = await resolveRuntimeWorktreeByPath(worktreePath) + return callRuntimeResult('git.commitCompare', { worktree: worktree.id, commitId }) + }, upstreamStatus: async ({ worktreePath }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.upstreamStatus', { worktree: worktree.id }) @@ -526,6 +534,16 @@ function createGitApi(): NonNullable['git']> { oldPath }) }, + commitDiff: async ({ worktreePath, filePath, commitOid, parentOid, oldPath }) => { + const file = await resolveRuntimeFilePath(filePath, worktreePath) + return callRuntimeResult('git.commitDiff', { + worktree: file.worktree.id, + filePath: file.relativePath, + commitOid, + parentOid, + oldPath + }) + }, commit: async ({ worktreePath, message }) => { const worktree = await resolveRuntimeWorktreeByPath(worktreePath) return callRuntimeResult('git.commit', { worktree: worktree.id, message }) diff --git a/src/shared/git-history-graph.test.ts b/src/shared/git-history-graph.test.ts new file mode 100644 index 00000000000..46d727ba7c0 --- /dev/null +++ b/src/shared/git-history-graph.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest' +import type { GitHistoryItem, GitHistoryItemRef } from './git-history' +import { + GIT_HISTORY_BASE_REF_COLOR, + GIT_HISTORY_LANE_COLORS, + GIT_HISTORY_REF_COLOR, + GIT_HISTORY_REMOTE_REF_COLOR +} from './git-history' +import { + GIT_HISTORY_INCOMING_CHANGES_ID, + GIT_HISTORY_OUTGOING_CHANGES_ID, + buildDefaultGitHistoryColorMap, + buildGitHistoryViewModels, + getGitHistoryMergeParentLaneIndex +} from './git-history-graph' + +function item( + id: string, + parentIds: string[], + references: GitHistoryItemRef[] = [] +): GitHistoryItem { + return { + id, + parentIds, + subject: id, + message: id, + displayId: id, + references + } +} + +function branch(name: string, revision: string): GitHistoryItemRef { + return { + id: `refs/heads/${name}`, + name, + revision, + category: 'branches' + } +} + +function remote(name: string, revision: string): GitHistoryItemRef { + return { + id: `refs/remotes/${name}`, + name, + revision, + category: 'remote branches' + } +} + +describe('git history graph model', () => { + it('preserves the current branch lane through linear history', () => { + const currentRef = branch('main', 'A') + const viewModels = buildGitHistoryViewModels( + [item('A', ['B'], [currentRef]), item('B', ['C']), item('C', [])], + buildDefaultGitHistoryColorMap({ currentRef }), + currentRef + ) + + expect(viewModels.map((viewModel) => viewModel.kind)).toEqual(['HEAD', 'node', 'node']) + expect(viewModels[0]!.inputSwimlanes).toEqual([]) + expect(viewModels[0]!.outputSwimlanes).toEqual([{ id: 'B', color: GIT_HISTORY_REF_COLOR }]) + expect(viewModels[1]!.inputSwimlanes).toEqual([{ id: 'B', color: GIT_HISTORY_REF_COLOR }]) + expect(viewModels[1]!.outputSwimlanes).toEqual([{ id: 'C', color: GIT_HISTORY_REF_COLOR }]) + expect(viewModels[0]!.historyItem.references?.[0]?.color).toBe(GIT_HISTORY_REF_COLOR) + }) + + it('allocates a side lane for a merge parent', () => { + const currentRef = branch('feature', 'M') + const viewModels = buildGitHistoryViewModels( + [item('M', ['A', 'B'], [currentRef]), item('A', ['C']), item('B', ['C']), item('C', [])], + buildDefaultGitHistoryColorMap({ currentRef }), + currentRef + ) + + expect(viewModels[0]!.kind).toBe('HEAD') + expect(viewModels[0]!.outputSwimlanes).toEqual([ + { id: 'A', color: GIT_HISTORY_REF_COLOR }, + { id: 'B', color: GIT_HISTORY_LANE_COLORS[0] } + ]) + expect(getGitHistoryMergeParentLaneIndex(viewModels[0]!, 'B')).toBe(1) + }) + + it('inserts VS Code-style incoming and outgoing boundary rows at the merge base', () => { + const currentRef = branch('feature', 'A') + const remoteRef = remote('origin/feature', 'R') + const viewModels = buildGitHistoryViewModels( + [ + item('A', ['B'], [currentRef]), + item('R', ['B'], [remoteRef]), + item('B', ['C']), + item('C', []) + ], + buildDefaultGitHistoryColorMap({ currentRef, remoteRef }), + currentRef, + remoteRef, + undefined, + true, + true, + 'B' + ) + + expect(viewModels.map((viewModel) => viewModel.kind)).toEqual([ + 'outgoing-changes', + 'HEAD', + 'node', + 'incoming-changes', + 'node', + 'node' + ]) + expect(viewModels[0]!.historyItem.id).toBe(GIT_HISTORY_OUTGOING_CHANGES_ID) + expect(viewModels[3]!.historyItem.id).toBe(GIT_HISTORY_INCOMING_CHANGES_ID) + expect(viewModels[3]!.inputSwimlanes).toContainEqual({ + id: GIT_HISTORY_INCOMING_CHANGES_ID, + color: GIT_HISTORY_REMOTE_REF_COLOR + }) + }) + + it('assigns stable colors to current, remote, and base refs', () => { + const currentRef = branch('feature', 'A') + const remoteRef = remote('origin/feature', 'R') + const baseRef = remote('origin/main', 'B') + + const colorMap = buildDefaultGitHistoryColorMap({ currentRef, remoteRef, baseRef }) + + expect(colorMap.get(currentRef.id)).toBe(GIT_HISTORY_REF_COLOR) + expect(colorMap.get(remoteRef.id)).toBe(GIT_HISTORY_REMOTE_REF_COLOR) + expect(colorMap.get(baseRef.id)).toBe(GIT_HISTORY_BASE_REF_COLOR) + }) +}) diff --git a/src/shared/git-history-graph.ts b/src/shared/git-history-graph.ts new file mode 100644 index 00000000000..5262cf2a9f9 --- /dev/null +++ b/src/shared/git-history-graph.ts @@ -0,0 +1,311 @@ +import type { GitHistoryGraphColorId, GitHistoryItem, GitHistoryItemRef } from './git-history' +import { + GIT_HISTORY_BASE_REF_COLOR, + GIT_HISTORY_LANE_COLORS, + GIT_HISTORY_REF_COLOR, + GIT_HISTORY_REMOTE_REF_COLOR +} from './git-history' + +export const GIT_HISTORY_INCOMING_CHANGES_ID = 'git-history-incoming-changes' +export const GIT_HISTORY_OUTGOING_CHANGES_ID = 'git-history-outgoing-changes' + +export type GitHistoryGraphNode = { + id: string + color: GitHistoryGraphColorId +} + +export type GitHistoryItemViewModel = { + historyItem: GitHistoryItem + inputSwimlanes: GitHistoryGraphNode[] + outputSwimlanes: GitHistoryGraphNode[] + kind: 'HEAD' | 'node' | 'incoming-changes' | 'outgoing-changes' +} + +function rotate(index: number, length: number): number { + return ((index % length) + length) % length +} + +function cloneNode(node: GitHistoryGraphNode): GitHistoryGraphNode { + return { id: node.id, color: node.color } +} + +function findLastIndex(items: readonly T[], predicate: (item: T) => boolean): number { + for (let index = items.length - 1; index >= 0; index -= 1) { + if (predicate(items[index] as T)) { + return index + } + } + return -1 +} + +function findLastNodeIndex(nodes: readonly GitHistoryGraphNode[], id: string): number { + return findLastIndex(nodes, (node) => node.id === id) +} + +function getLabelColorIdentifier( + historyItem: GitHistoryItem, + colorMap: Map +): GitHistoryGraphColorId | undefined { + if (historyItem.id === GIT_HISTORY_INCOMING_CHANGES_ID) { + return GIT_HISTORY_REMOTE_REF_COLOR + } + if (historyItem.id === GIT_HISTORY_OUTGOING_CHANGES_ID) { + return GIT_HISTORY_REF_COLOR + } + for (const ref of historyItem.references ?? []) { + const color = colorMap.get(ref.id) + if (color !== undefined) { + return color + } + } + return undefined +} + +export function compareGitHistoryRefs( + ref1: GitHistoryItemRef, + ref2: GitHistoryItemRef, + currentRef?: GitHistoryItemRef, + remoteRef?: GitHistoryItemRef, + baseRef?: GitHistoryItemRef +): number { + const order = (ref: GitHistoryItemRef): number => { + if (ref.id === currentRef?.id) { + return 1 + } + if (ref.id === remoteRef?.id) { + return 2 + } + if (ref.id === baseRef?.id) { + return 3 + } + if (ref.color !== undefined) { + return 4 + } + return 99 + } + + return order(ref1) - order(ref2) +} + +function addIncomingOutgoingChangesHistoryItems( + viewModels: GitHistoryItemViewModel[], + currentRef?: GitHistoryItemRef, + remoteRef?: GitHistoryItemRef, + addIncomingChanges?: boolean, + addOutgoingChanges?: boolean, + mergeBase?: string +): void { + if (currentRef?.revision === remoteRef?.revision || !mergeBase) { + return + } + + if (addIncomingChanges && remoteRef && remoteRef.revision !== mergeBase) { + const beforeHistoryItemIndex = findLastIndex(viewModels, (viewModel) => + viewModel.outputSwimlanes.some((node) => node.id === mergeBase) + ) + const afterHistoryItemIndex = viewModels.findIndex( + (viewModel) => viewModel.historyItem.id === mergeBase + ) + + if (beforeHistoryItemIndex !== -1 && afterHistoryItemIndex !== -1) { + const before = viewModels[beforeHistoryItemIndex] as GitHistoryItemViewModel + const incomingChangeMerged = + before.historyItem.parentIds.length === 2 && + before.historyItem.parentIds.includes(mergeBase) + + if (!incomingChangeMerged) { + viewModels[beforeHistoryItemIndex] = { + ...before, + inputSwimlanes: before.inputSwimlanes.map((node) => + node.id === mergeBase && node.color === GIT_HISTORY_REMOTE_REF_COLOR + ? { ...node, id: GIT_HISTORY_INCOMING_CHANGES_ID } + : node + ), + outputSwimlanes: before.outputSwimlanes.map((node) => + node.id === mergeBase && node.color === GIT_HISTORY_REMOTE_REF_COLOR + ? { ...node, id: GIT_HISTORY_INCOMING_CHANGES_ID } + : node + ) + } + + const displayIdLength = viewModels[0]?.historyItem.displayId?.length ?? 0 + const incomingChangesHistoryItem: GitHistoryItem = { + id: GIT_HISTORY_INCOMING_CHANGES_ID, + displayId: '0'.repeat(displayIdLength), + parentIds: [mergeBase], + author: remoteRef.name, + subject: 'Incoming Changes', + message: '' + } + + viewModels.splice(afterHistoryItemIndex, 0, { + historyItem: incomingChangesHistoryItem, + kind: 'incoming-changes', + inputSwimlanes: viewModels[beforeHistoryItemIndex]!.outputSwimlanes.map(cloneNode), + outputSwimlanes: viewModels[afterHistoryItemIndex]!.inputSwimlanes.map(cloneNode) + }) + } + } + } + + if (addOutgoingChanges && currentRef?.revision && currentRef.revision !== mergeBase) { + const currentRefIndex = viewModels.findIndex( + (viewModel) => viewModel.kind === 'HEAD' && viewModel.historyItem.id === currentRef.revision + ) + if (currentRefIndex === -1) { + return + } + + const displayIdLength = viewModels[0]?.historyItem.displayId?.length ?? 0 + const outgoingChangesHistoryItem: GitHistoryItem = { + id: GIT_HISTORY_OUTGOING_CHANGES_ID, + displayId: '0'.repeat(displayIdLength), + parentIds: [currentRef.revision], + author: currentRef.name, + subject: 'Outgoing Changes', + message: '' + } + + const inputSwimlanes = viewModels[currentRefIndex]!.inputSwimlanes.map(cloneNode) + const outputSwimlanes = inputSwimlanes.concat({ + id: currentRef.revision, + color: GIT_HISTORY_REF_COLOR + }) + + viewModels.splice(currentRefIndex, 0, { + historyItem: outgoingChangesHistoryItem, + kind: 'outgoing-changes', + inputSwimlanes, + outputSwimlanes + }) + + viewModels[currentRefIndex + 1]!.inputSwimlanes.push({ + id: currentRef.revision, + color: GIT_HISTORY_REF_COLOR + }) + } +} + +export function buildGitHistoryViewModels( + historyItems: GitHistoryItem[], + colorMap = new Map(), + currentRef?: GitHistoryItemRef, + remoteRef?: GitHistoryItemRef, + baseRef?: GitHistoryItemRef, + addIncomingChanges?: boolean, + addOutgoingChanges?: boolean, + mergeBase?: string +): GitHistoryItemViewModel[] { + let colorIndex = -1 + const viewModels: GitHistoryItemViewModel[] = [] + + for (const historyItem of historyItems) { + const kind = historyItem.id === currentRef?.revision ? 'HEAD' : 'node' + const inputSwimlanes = (viewModels.at(-1)?.outputSwimlanes ?? []).map(cloneNode) + const outputSwimlanes: GitHistoryGraphNode[] = [] + let firstParentAdded = false + + if (historyItem.parentIds.length > 0) { + for (const node of inputSwimlanes) { + if (node.id === historyItem.id) { + if (!firstParentAdded) { + outputSwimlanes.push({ + id: historyItem.parentIds[0]!, + color: getLabelColorIdentifier(historyItem, colorMap) ?? node.color + }) + firstParentAdded = true + } + continue + } + outputSwimlanes.push(cloneNode(node)) + } + } + + for (let index = firstParentAdded ? 1 : 0; index < historyItem.parentIds.length; index += 1) { + let colorIdentifier: GitHistoryGraphColorId | undefined + if (index === 0) { + colorIdentifier = getLabelColorIdentifier(historyItem, colorMap) + } else { + const parent = historyItems.find((item) => item.id === historyItem.parentIds[index]) + colorIdentifier = parent ? getLabelColorIdentifier(parent, colorMap) : undefined + } + + if (!colorIdentifier) { + colorIndex = rotate(colorIndex + 1, GIT_HISTORY_LANE_COLORS.length) + colorIdentifier = GIT_HISTORY_LANE_COLORS[colorIndex]! + } + + outputSwimlanes.push({ + id: historyItem.parentIds[index]!, + color: colorIdentifier + }) + } + + const references = (historyItem.references ?? []) + .map((ref) => { + let color = colorMap.get(ref.id) + if (colorMap.has(ref.id) && color === undefined) { + const inputIndex = inputSwimlanes.findIndex((node) => node.id === historyItem.id) + const circleIndex = inputIndex !== -1 ? inputIndex : inputSwimlanes.length + color = + circleIndex < outputSwimlanes.length + ? outputSwimlanes[circleIndex]!.color + : circleIndex < inputSwimlanes.length + ? inputSwimlanes[circleIndex]!.color + : GIT_HISTORY_REF_COLOR + } + return { ...ref, color } + }) + .sort((ref1, ref2) => compareGitHistoryRefs(ref1, ref2, currentRef, remoteRef, baseRef)) + + viewModels.push({ + historyItem: { ...historyItem, references }, + kind, + inputSwimlanes, + outputSwimlanes + }) + } + + addIncomingOutgoingChangesHistoryItems( + viewModels, + currentRef, + remoteRef, + addIncomingChanges, + addOutgoingChanges, + mergeBase + ) + + return viewModels +} + +export function getGitHistoryItemLaneIndex(viewModel: GitHistoryItemViewModel): number { + const inputIndex = viewModel.inputSwimlanes.findIndex( + (node) => node.id === viewModel.historyItem.id + ) + return inputIndex !== -1 ? inputIndex : viewModel.inputSwimlanes.length +} + +export function getGitHistoryMergeParentLaneIndex( + viewModel: GitHistoryItemViewModel, + parentId: string +): number { + return findLastNodeIndex(viewModel.outputSwimlanes, parentId) +} + +export function buildDefaultGitHistoryColorMap(input: { + currentRef?: GitHistoryItemRef + remoteRef?: GitHistoryItemRef + baseRef?: GitHistoryItemRef +}): Map { + const colorMap = new Map() + if (input.currentRef) { + colorMap.set(input.currentRef.id, GIT_HISTORY_REF_COLOR) + } + if (input.remoteRef) { + colorMap.set(input.remoteRef.id, GIT_HISTORY_REMOTE_REF_COLOR) + } + if (input.baseRef) { + colorMap.set(input.baseRef.id, GIT_HISTORY_BASE_REF_COLOR) + } + return colorMap +} diff --git a/src/shared/git-history-log-parser.ts b/src/shared/git-history-log-parser.ts new file mode 100644 index 00000000000..1a9dbc084c4 --- /dev/null +++ b/src/shared/git-history-log-parser.ts @@ -0,0 +1,142 @@ +import type { GitHistoryItem, GitHistoryItemRef } from './git-history-types' + +export const GIT_HISTORY_COMMIT_FORMAT = '%H%n%aN%n%aE%n%at%n%ct%n%P%n%D%n%B' + +export function shortGitHash(hash: string): string { + return hash.slice(0, 7) +} + +function commitSubject(message: string): string { + const firstLine = message.split(/\r?\n/, 1)[0]?.trim() + return firstLine || '(no commit message)' +} + +function parseGitDecorationRefs(raw: string, revision: string): GitHistoryItemRef[] { + if (!raw.trim()) { + return [] + } + + const refs: GitHistoryItemRef[] = [] + for (const part of raw.split(',')) { + const ref = part.trim() + if (!ref || ref === 'HEAD' || /^refs\/remotes\/[^/]+\/HEAD(?:\s|$)/.test(ref)) { + continue + } + + if (ref.startsWith('HEAD -> refs/heads/')) { + refs.push({ + id: ref.slice('HEAD -> '.length), + name: ref.slice('HEAD -> refs/heads/'.length), + revision, + category: 'branches' + }) + continue + } + + if (ref.startsWith('refs/heads/')) { + refs.push({ + id: ref, + name: ref.slice('refs/heads/'.length), + revision, + category: 'branches' + }) + continue + } + + if (ref.startsWith('refs/remotes/')) { + refs.push({ + id: ref, + name: ref.slice('refs/remotes/'.length), + revision, + category: 'remote branches' + }) + continue + } + + if (ref.startsWith('tag: refs/tags/')) { + refs.push({ + id: ref.slice('tag: '.length), + name: ref.slice('tag: refs/tags/'.length), + revision, + category: 'tags' + }) + } + } + + return refs.sort(compareGitHistoryItemRefsByCategory) +} + +export function compareGitHistoryItemRefsByCategory( + ref1: GitHistoryItemRef, + ref2: GitHistoryItemRef +): number { + const order = (ref: GitHistoryItemRef): number => { + if (ref.id.startsWith('refs/heads/')) { + return 1 + } + if (ref.id.startsWith('refs/remotes/')) { + return 2 + } + if (ref.id.startsWith('refs/tags/')) { + return 3 + } + return 99 + } + + const categoryOrder = order(ref1) - order(ref2) + return categoryOrder || ref1.name.localeCompare(ref2.name) +} + +export function parseGitHistoryLog(stdout: string): GitHistoryItem[] { + const items: GitHistoryItem[] = [] + for (const rawRecord of stdout.split('\0')) { + const record = rawRecord.replace(/^\n+/, '') + if (!record.trim()) { + continue + } + + const lines = record.split('\n') + const hash = lines[0]?.trim() ?? '' + if (!/^[0-9a-fA-F]{40,64}$/.test(hash)) { + continue + } + + const authorName = lines[1] ?? '' + const authorEmail = lines[2] ?? '' + const authorDateSeconds = Number.parseInt(lines[3] ?? '', 10) + const parents = (lines[5] ?? '').trim() + const decorations = lines[6] ?? '' + const message = lines.slice(7).join('\n').replace(/\n$/, '') + + items.push({ + id: hash, + parentIds: parents ? parents.split(' ') : [], + subject: commitSubject(message), + message, + author: authorName || undefined, + authorEmail: authorEmail || undefined, + displayId: shortGitHash(hash), + timestamp: Number.isFinite(authorDateSeconds) ? authorDateSeconds * 1000 : undefined, + references: parseGitDecorationRefs(decorations, hash) + }) + } + return items +} + +export function gitHistoryRefFromFullName( + fullName: string | null, + fallbackName: string, + revision: string +): GitHistoryItemRef { + const id = fullName || fallbackName + if (id.startsWith('refs/heads/')) { + return { id, name: id.slice('refs/heads/'.length), revision, category: 'branches' } + } + if (id.startsWith('refs/remotes/')) { + return { id, name: id.slice('refs/remotes/'.length), revision, category: 'remote branches' } + } + if (id.startsWith('refs/tags/')) { + return { id, name: id.slice('refs/tags/'.length), revision, category: 'tags' } + } + return { id, name: fallbackName || shortGitHash(revision), revision, category: 'commits' } +} diff --git a/src/shared/git-history-types.ts b/src/shared/git-history-types.ts new file mode 100644 index 00000000000..4e99d4b2eb2 --- /dev/null +++ b/src/shared/git-history-types.ts @@ -0,0 +1,76 @@ +export type GitHistoryGraphColorId = + | 'git-graph-ref' + | 'git-graph-remote-ref' + | 'git-graph-base-ref' + | 'git-graph-lane-1' + | 'git-graph-lane-2' + | 'git-graph-lane-3' + | 'git-graph-lane-4' + | 'git-graph-lane-5' + +export const GIT_HISTORY_REF_COLOR: GitHistoryGraphColorId = 'git-graph-ref' +export const GIT_HISTORY_REMOTE_REF_COLOR: GitHistoryGraphColorId = 'git-graph-remote-ref' +export const GIT_HISTORY_BASE_REF_COLOR: GitHistoryGraphColorId = 'git-graph-base-ref' + +export const GIT_HISTORY_LANE_COLORS: readonly GitHistoryGraphColorId[] = [ + 'git-graph-lane-1', + 'git-graph-lane-2', + 'git-graph-lane-3', + 'git-graph-lane-4', + 'git-graph-lane-5' +] + +export const GIT_HISTORY_DEFAULT_LIMIT = 50 +export const GIT_HISTORY_MAX_LIMIT = 200 + +export type GitHistoryRefCategory = 'branches' | 'remote branches' | 'tags' | 'commits' + +export type GitHistoryItemRef = { + id: string + name: string + revision?: string + category?: GitHistoryRefCategory + description?: string + color?: GitHistoryGraphColorId +} + +export type GitHistoryItemStatistics = { + files: number + insertions: number + deletions: number +} + +export type GitHistoryItem = { + id: string + parentIds: string[] + subject: string + message: string + displayId?: string + author?: string + authorEmail?: string + timestamp?: number + statistics?: GitHistoryItemStatistics + references?: GitHistoryItemRef[] +} + +export type GitHistoryOptions = { + limit?: number + baseRef?: string | null +} + +export type GitHistoryResult = { + items: GitHistoryItem[] + currentRef?: GitHistoryItemRef + remoteRef?: GitHistoryItemRef + baseRef?: GitHistoryItemRef + mergeBase?: string + hasIncomingChanges: boolean + hasOutgoingChanges: boolean + hasMore: boolean + limit: number +} + +export type GitHistoryExecutor = ( + args: string[], + cwd: string +) => Promise<{ stdout: string; stderr?: string }> diff --git a/src/shared/git-history.test.ts b/src/shared/git-history.test.ts new file mode 100644 index 00000000000..6eb45c34fe7 --- /dev/null +++ b/src/shared/git-history.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it, vi } from 'vitest' +import type { GitHistoryExecutor } from './git-history' +import { + GIT_HISTORY_MAX_LIMIT, + loadGitHistoryFromExecutor, + parseGitHistoryLog +} from './git-history' + +const HEAD_OID = 'a'.repeat(40) +const REMOTE_OID = 'b'.repeat(40) +const BASE_OID = 'c'.repeat(40) + +function logRecord({ + hash, + parents = [], + decorations = '', + message, + author = 'Ada Lovelace', + timestamp = 1_700_000_000 +}: { + hash: string + parents?: string[] + decorations?: string + message: string + author?: string + timestamp?: number +}): string { + return `${[ + hash, + author, + 'ada@example.com', + String(timestamp), + String(timestamp), + parents.join(' '), + decorations, + message + ].join('\n')}\0` +} + +function createHistoryExecutor(limitRecords = 2): { + executor: GitHistoryExecutor + calls: string[][] +} { + const calls: string[][] = [] + const executor = vi.fn(async (args: string[], cwd: string) => { + expect(cwd).toBe('/repo') + calls.push(args) + const command = args[0] + + if (command === 'rev-parse' && args.includes('HEAD^{commit}')) { + return { stdout: `${HEAD_OID}\n` } + } + if (command === 'rev-parse' && args.includes('refs/remotes/origin/feature^{commit}')) { + return { stdout: `${REMOTE_OID}\n` } + } + if (command === 'symbolic-ref') { + return { stdout: 'feature\n' } + } + if (command === 'for-each-ref') { + return { stdout: 'refs/remotes/origin/feature\0origin/feature\n' } + } + if (command === 'merge-base') { + return { stdout: `${BASE_OID}\n` } + } + if (command === 'log') { + return { + stdout: Array.from({ length: limitRecords }, (_, index) => + logRecord({ + hash: + index === 0 + ? HEAD_OID + : index === 1 + ? REMOTE_OID + : (index % 16).toString(16).repeat(40), + parents: [BASE_OID], + decorations: index === 0 ? 'HEAD -> refs/heads/feature' : '', + message: `commit ${index}` + }) + ).join('') + } + } + + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + return { executor, calls } +} + +describe('git history parsing', () => { + it('parses VS Code-compatible git log records with decorations and multiline messages', () => { + const stdout = logRecord({ + hash: HEAD_OID, + parents: [BASE_OID], + decorations: + 'HEAD -> refs/heads/feature, refs/remotes/origin/HEAD -> refs/remotes/origin/feature, refs/remotes/origin/feature, tag: refs/tags/v1.0.0', + message: 'feat: add graph\n\nbody line' + }) + + const [item] = parseGitHistoryLog(stdout) + + expect(item).toMatchObject({ + id: HEAD_OID, + parentIds: [BASE_OID], + subject: 'feat: add graph', + message: 'feat: add graph\n\nbody line', + author: 'Ada Lovelace', + authorEmail: 'ada@example.com', + displayId: HEAD_OID.slice(0, 7) + }) + expect(item?.references?.map((ref) => [ref.id, ref.name, ref.category])).toEqual([ + ['refs/heads/feature', 'feature', 'branches'], + ['refs/remotes/origin/feature', 'origin/feature', 'remote branches'], + ['refs/tags/v1.0.0', 'v1.0.0', 'tags'] + ]) + }) +}) + +describe('git history loader', () => { + it('uses one bounded topo-order log query for the graph data', async () => { + const { executor, calls } = createHistoryExecutor() + + const result = await loadGitHistoryFromExecutor(executor, '/repo', { limit: 50 }) + + const logCall = calls.find((args) => args[0] === 'log') + expect(logCall).toEqual( + expect.arrayContaining([ + `--format=%H%n%aN%n%aE%n%at%n%ct%n%P%n%D%n%B`, + '-z', + '--topo-order', + '--decorate=full', + '-n51', + HEAD_OID, + REMOTE_OID + ]) + ) + expect(calls.filter((args) => args[0] === 'log')).toHaveLength(1) + expect(result.items).toHaveLength(2) + expect(result.hasIncomingChanges).toBe(true) + expect(result.hasOutgoingChanges).toBe(true) + expect(result.mergeBase).toBe(BASE_OID) + }) + + it('clamps oversized limits before shelling out to git log', async () => { + const { executor, calls } = createHistoryExecutor(GIT_HISTORY_MAX_LIMIT + 1) + + const result = await loadGitHistoryFromExecutor(executor, '/repo', { limit: 500 }) + + const logCall = calls.find((args) => args[0] === 'log') + expect(logCall).toContain(`-n${GIT_HISTORY_MAX_LIMIT + 1}`) + expect(result.items).toHaveLength(GIT_HISTORY_MAX_LIMIT) + expect(result.limit).toBe(GIT_HISTORY_MAX_LIMIT) + expect(result.hasMore).toBe(true) + }) + + it('returns an empty result for unborn repositories without running git log', async () => { + const executor = vi.fn(async (args: string[]) => { + if (args[0] === 'rev-parse') { + throw new Error('ambiguous argument HEAD') + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + const result = await loadGitHistoryFromExecutor(executor, '/repo') + + expect(result).toMatchObject({ + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false + }) + expect(executor).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/shared/git-history.ts b/src/shared/git-history.ts new file mode 100644 index 00000000000..456bb7fee4a --- /dev/null +++ b/src/shared/git-history.ts @@ -0,0 +1,235 @@ +import { + GIT_HISTORY_COMMIT_FORMAT, + gitHistoryRefFromFullName, + parseGitHistoryLog, + shortGitHash +} from './git-history-log-parser' +import { + GIT_HISTORY_DEFAULT_LIMIT, + GIT_HISTORY_MAX_LIMIT, + type GitHistoryExecutor, + type GitHistoryItemRef, + type GitHistoryOptions, + type GitHistoryResult +} from './git-history-types' + +export type { + GitHistoryExecutor, + GitHistoryGraphColorId, + GitHistoryItem, + GitHistoryItemRef, + GitHistoryItemStatistics, + GitHistoryOptions, + GitHistoryRefCategory, + GitHistoryResult +} from './git-history-types' +export { + GIT_HISTORY_BASE_REF_COLOR, + GIT_HISTORY_DEFAULT_LIMIT, + GIT_HISTORY_LANE_COLORS, + GIT_HISTORY_MAX_LIMIT, + GIT_HISTORY_REF_COLOR, + GIT_HISTORY_REMOTE_REF_COLOR +} from './git-history-types' +export { compareGitHistoryItemRefsByCategory, parseGitHistoryLog } from './git-history-log-parser' + +function clampHistoryLimit(limit: number | undefined): number { + if (!Number.isFinite(limit)) { + return GIT_HISTORY_DEFAULT_LIMIT + } + return Math.min( + GIT_HISTORY_MAX_LIMIT, + Math.max(1, Math.trunc(limit ?? GIT_HISTORY_DEFAULT_LIMIT)) + ) +} + +async function resolveCommit( + git: GitHistoryExecutor, + cwd: string, + ref: string +): Promise { + if (!ref || ref.startsWith('-')) { + return null + } + try { + const { stdout } = await git( + ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`], + cwd + ) + const oid = stdout.trim() + return oid || null + } catch { + return null + } +} + +async function resolveSymbolicFullName( + git: GitHistoryExecutor, + cwd: string, + ref: string +): Promise { + if (!ref || ref.startsWith('-')) { + return null + } + try { + const { stdout } = await git( + ['rev-parse', '--symbolic-full-name', '--end-of-options', ref], + cwd + ) + return stdout.trim().split(/\r?\n/).find(Boolean) ?? null + } catch { + return null + } +} + +async function resolveCurrentRef( + git: GitHistoryExecutor, + cwd: string, + headOid: string +): Promise<{ currentRef: GitHistoryItemRef; branchName: string | null }> { + try { + const { stdout } = await git(['symbolic-ref', '--quiet', '--short', 'HEAD'], cwd) + const branchName = stdout.trim() + if (branchName) { + return { + branchName, + currentRef: { + id: `refs/heads/${branchName}`, + name: branchName, + revision: headOid, + category: 'branches' + } + } + } + } catch { + // Detached HEAD. + } + + return { + branchName: null, + currentRef: { id: headOid, name: shortGitHash(headOid), revision: headOid, category: 'commits' } + } +} + +async function resolveUpstreamRef( + git: GitHistoryExecutor, + cwd: string, + branchName: string | null +): Promise { + if (!branchName) { + return undefined + } + try { + const { stdout } = await git( + ['for-each-ref', '--format=%(upstream)%00%(upstream:short)', `refs/heads/${branchName}`], + cwd + ) + const [fullName, shortName] = stdout.split('\0') + const upstreamRef = fullName?.trim() + const upstreamShortName = shortName?.trim() + if (!upstreamRef || !upstreamShortName) { + return undefined + } + // Why: %(upstream:objectname) is not portable across Git versions; resolve + // the upstream name first, then ask rev-parse for the commit object. + const oid = await resolveCommit(git, cwd, upstreamRef) + return oid ? gitHistoryRefFromFullName(upstreamRef, upstreamShortName, oid) : undefined + } catch { + return undefined + } +} + +async function resolveNamedRef( + git: GitHistoryExecutor, + cwd: string, + ref: string | null | undefined +): Promise { + const normalized = ref?.trim() + if (!normalized || normalized.startsWith('-')) { + return undefined + } + const [revision, fullName] = await Promise.all([ + resolveCommit(git, cwd, normalized), + resolveSymbolicFullName(git, cwd, normalized) + ]) + return revision ? gitHistoryRefFromFullName(fullName, normalized, revision) : undefined +} + +export async function loadGitHistoryFromExecutor( + git: GitHistoryExecutor, + cwd: string, + options: GitHistoryOptions = {} +): Promise { + const limit = clampHistoryLimit(options.limit) + const headOid = await resolveCommit(git, cwd, 'HEAD') + if (!headOid) { + return { + items: [], + hasIncomingChanges: false, + hasOutgoingChanges: false, + hasMore: false, + limit + } + } + + const { currentRef, branchName } = await resolveCurrentRef(git, cwd, headOid) + const [remoteRef, rawBaseRef] = await Promise.all([ + resolveUpstreamRef(git, cwd, branchName), + resolveNamedRef(git, cwd, options.baseRef) + ]) + + const baseRef = + rawBaseRef && rawBaseRef.id !== remoteRef?.id && rawBaseRef.id !== currentRef.id + ? rawBaseRef + : undefined + + const revisions = Array.from( + new Set( + [currentRef.revision, remoteRef?.revision, baseRef?.revision].filter( + (revision): revision is string => Boolean(revision) + ) + ) + ) + + let mergeBase: string | undefined + if (remoteRef?.revision && currentRef.revision && remoteRef.revision !== currentRef.revision) { + try { + const { stdout } = await git(['merge-base', currentRef.revision, remoteRef.revision], cwd) + mergeBase = stdout.trim() || undefined + } catch { + mergeBase = undefined + } + } + + const { stdout } = await git( + [ + 'log', + `--format=${GIT_HISTORY_COMMIT_FORMAT}`, + '-z', + '--topo-order', + '--decorate=full', + `-n${limit + 1}`, + ...revisions + ], + cwd + ) + const parsed = parseGitHistoryLog(stdout) + const items = parsed.slice(0, limit) + const hasIncomingChanges = + Boolean(remoteRef?.revision && mergeBase) && remoteRef?.revision !== mergeBase + const hasOutgoingChanges = + Boolean(currentRef.revision && remoteRef?.revision && mergeBase) && + currentRef.revision !== mergeBase + + return { + items, + currentRef, + remoteRef, + baseRef, + mergeBase, + hasIncomingChanges, + hasOutgoingChanges, + hasMore: parsed.length > limit, + limit + } +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 07812e23ac1..50c1ae197ac 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2002,6 +2002,8 @@ export type GitBranchChangeEntry = { path: string status: GitBranchChangeStatus oldPath?: string + added?: number + removed?: number } export type GitBranchCompareSummary = { @@ -2021,6 +2023,21 @@ export type GitBranchCompareResult = { entries: GitBranchChangeEntry[] } +export type GitCommitCompareSummary = { + commitOid: string + parentOid: string | null + compareRef: string + baseRef: string + changedFiles: number + status: 'ready' | 'invalid-commit' | 'error' + errorMessage?: string +} + +export type GitCommitCompareResult = { + summary: GitCommitCompareSummary + entries: GitBranchChangeEntry[] +} + export type GitDiffTextResult = { kind: 'text' originalContent: string