Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions l10n/bundle.l10n.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,6 @@
"remoteBranchDeleted": "Remote branch '{0}/{1}' deleted",
"remoteAdded": "Remote '{0}' added",
"remoteRemoved": "Remote '{0}' removed",
"externalDiffFailed": "Failed to open external diff tool: {0}",
"worktreeRemoved": "Worktree removed"
}
1 change: 1 addition & 0 deletions l10n/bundle.l10n.ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,6 @@
"remoteBranchDeleted": "리모트 브랜치 '{0}/{1}'이(가) 삭제되었습니다",
"remoteAdded": "리모트 '{0}'이(가) 추가되었습니다",
"remoteRemoved": "리모트 '{0}'이(가) 제거되었습니다",
"externalDiffFailed": "외부 diff 도구를 열지 못했습니다: {0}",
"worktreeRemoved": "Worktree가 제거되었습니다"
}
1 change: 1 addition & 0 deletions l10n/bundle.l10n.zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,5 +113,6 @@
"remoteBranchDeleted": "远程分支 '{0}/{1}' 已删除",
"remoteAdded": "远程 '{0}' 已添加",
"remoteRemoved": "远程 '{0}' 已移除",
"externalDiffFailed": "打开外部 diff 工具失败:{0}",
"worktreeRemoved": "工作树已移除"
}
113 changes: 113 additions & 0 deletions src/git/__tests__/git-service.external-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
import { GitService, GitError } from '../git-service';

// `git difftool` hands the comparison to a GUI tool and git stays alive until
// that tool exits. GitService.openExternalDiff must therefore spawn it detached
// and return as soon as the process has launched, never awaiting 'close'.
// Driving the spawned process by hand (same pattern as
// git-service.exec.test.ts) pins the argument construction, the
// first-parent / empty-tree base resolution, and the non-blocking contract.
import * as childProcess from 'child_process';
vi.mock('child_process', () => ({ spawn: vi.fn() }));

function fakeProc() {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
stdin: { write: ReturnType<typeof vi.fn>; end: ReturnType<typeof vi.fn> };
kill: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.stdin = { write: vi.fn(), end: vi.fn() };
proc.kill = vi.fn();
proc.unref = vi.fn();
return proc;
}

const spawnMock = vi.mocked(childProcess.spawn);

describe('GitService.openExternalDiff', () => {
let service: GitService;
let proc: ReturnType<typeof fakeProc>;
let spawnArgs: string[];

beforeEach(() => {
service = new GitService('C:\\repo');
proc = fakeProc();
spawnArgs = [];
spawnMock.mockImplementation((_bin: string, args: readonly string[]) => {
spawnArgs = [...args];
// A real child emits 'spawn' asynchronously, after the caller has
// attached its listeners; emit on a microtask to mirror that.
queueMicrotask(() => proc.emit('spawn'));
return proc as unknown as ReturnType<typeof childProcess.spawn>;
});
});

afterEach(() => {
vi.clearAllMocks();
});

it('spawns git difftool detached against the resolved first parent', async () => {
(service as any).exec = vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('--verify')) return 'parentsha\n';
throw new Error(`unexpected git call: ${args.join(' ')}`);
});

await service.openExternalDiff('abc123', 'assets/logo.bin');

expect(spawnArgs).toEqual([
'-c', 'core.quotePath=false',
'difftool', '--no-prompt', 'parentsha', 'abc123', '--', 'assets/logo.bin',
]);
const opts = spawnMock.mock.calls[0][2] as { detached?: boolean; cwd?: string; stdio?: string };
expect(opts.detached).toBe(true);
expect(opts.cwd).toBe('C:\\repo');
expect(opts.stdio).toBe('ignore');
// The GUI tool may outlive the extension host; the child must be unref'd.
expect(proc.unref).toHaveBeenCalled();
});

it('does not wait for the GUI tool to exit (resolves while the process is still running)', async () => {
(service as any).exec = vi.fn(async () => 'parentsha\n');

// No 'close' is ever emitted by the mock, yet the call must resolve.
await expect(service.openExternalDiff('abc123', 'file.bin')).resolves.toBeUndefined();
expect(proc.unref).toHaveBeenCalledTimes(1);
});

it('falls back to the empty tree for a root commit (no parent)', async () => {
(service as any).exec = vi.fn(async (args: string[]) => {
if (args[0] === 'rev-parse' && args.includes('--show-object-format')) return 'sha1\n';
// The parent probe (`--verify --quiet`) exits non-zero for a root commit.
throw new GitError('', 1, args);
});

await service.openExternalDiff('root123', 'file.bin');

expect(spawnArgs).toContain('4b825dc642cb6eb9a060e54bf8d69288fbee4904');
expect(spawnArgs).toContain('root123');
});

it('rejects when the process cannot be spawned (git missing)', async () => {
(service as any).exec = vi.fn(async () => 'parentsha\n');
spawnMock.mockImplementationOnce((_bin: string, args: readonly string[]) => {
spawnArgs = [...args];
queueMicrotask(() => proc.emit('error', new Error('spawn git ENOENT')));
return proc as unknown as ReturnType<typeof childProcess.spawn>;
});

await expect(service.openExternalDiff('abc123', 'file.bin')).rejects.toThrow('ENOENT');
expect(proc.unref).not.toHaveBeenCalled();
});

it('rejects unsafe refs and paths before spawning', async () => {
await expect(service.openExternalDiff('--upload-pack=evil', 'file.bin')).rejects.toThrow();
await expect(service.openExternalDiff('abc123', '../escape.bin')).rejects.toThrow();
await expect(service.openExternalDiff('abc123', '-f')).rejects.toThrow();
expect(spawnMock).not.toHaveBeenCalled();
});
});
51 changes: 51 additions & 0 deletions src/git/git-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,57 @@ export class GitService {
return GitService.EMPTY_TREE[format] ?? GitService.EMPTY_TREE.sha1;
}

/**
* Hand a single file's change in a commit to git's configured external diff
* tool (#83) — the built-in editor can't render binary files. The base is the
* same one the built-in diff uses ({@link resolveDiffBaseRef}: first parent,
* empty tree for a root commit).
*
* `git difftool` opens a GUI and stays alive until the user closes it, so
* the process is spawned detached and this method resolves as soon as the
* launch succeeds — callers must not wait for the tool to exit. Spawn
* failures reject so the caller can report them.
*/
async openExternalDiff(hash: string, file: string): Promise<void> {
this.assertSafeRef(hash, 'openExternalDiff');
this.assertSafePath(file, 'openExternalDiff');

const base = await this.resolveDiffBaseRef(hash);
this.assertSafeRef(base, 'openExternalDiff');

// `--no-prompt` keeps git from stopping to ask which tool to use; the
// user's diff.tool / GIT_EXTERNAL_DIFF configuration decides the tool. A
// user without a difftool configured gets git's own fallback.
const args = ['difftool', '--no-prompt', base, hash, '--', file];

const proc = spawn(getGitBinaryPath(), ['-c', 'core.quotePath=false', ...args], {
cwd: this.repoPath,
env: { ...process.env, ...this.extraEnv, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C', GIT_MERGE_AUTOEDIT: 'no', GIT_EDITOR: 'true', EDITOR: 'true' },
// Detach and discard stdio: neither the extension host nor VS Code's
// shutdown may be held open by a GUI the user leaves running, and an
// ignored stdio pipe means no data event can keep the loop alive.
detached: true,
stdio: 'ignore',
windowsHide: true,
});

await new Promise<void>((resolve, reject) => {
let launched = false;
proc.once('spawn', () => { launched = true; resolve(); });
proc.on('error', (err: Error) => {
if (launched) {
// Nothing awaits the process after launch; surface late failures
// (git exiting non-zero once the tool closes) as a warning.
this.warn(`external difftool failed: ${err.message}`);
return;
}
reject(new GitError(err.message, null, args));
});
});

proc.unref();
}

/**
* Whether `file` exists at `ref` (`ref === ''` checks the index, `:<path>`).
* Used to pick a diff side's base: a file that's absent at a ref — added or
Expand Down
17 changes: 17 additions & 0 deletions src/panels/MainPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,23 @@ export class MainPanel {
}
break;
}
case 'openExternalDiff': {
// Hand one changed file to git's configured difftool (#83) — binary
// files in particular can't be shown in VS Code's text diff. The
// service spawns the GUI detached and returns once it has launched,
// so the only failure to report here is an invalid path or a failed
// launch (e.g. git missing).
try {
const file = this.assertSafeArgPath(message.payload.file, 'openExternalDiff');
this.resolveRepoRelativePath(file, 'openExternalDiff');
await this.gitService.openExternalDiff(message.payload.hash, file);
} catch (err) {
vscode.window.showErrorMessage(
vscode.l10n.t('externalDiffFailed', err instanceof Error ? err.message : String(err)),
);
}
break;
}
case 'openFile': {
const fullPath = this.resolveRepoRelativePath(message.payload.file, 'openFile');
const fileUri = vscode.Uri.file(fullPath);
Expand Down
16 changes: 16 additions & 0 deletions src/panels/__tests__/MainPanel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const H = vi.hoisted(() => {
stashPop: vi.fn(async () => {}),
showCommitDiff: vi.fn(async () => []),
showCommitFiles: vi.fn(async () => []),
openExternalDiff: vi.fn(async () => {}),
resolveDiffBaseRef: vi.fn(async () => 'parentsha'),
getEmptyTreeRef: vi.fn(async () => '4b825dc642cb6eb9a060e54bf8d69288fbee4904'),
fileExistsAtRef: vi.fn(async () => true),
Expand Down Expand Up @@ -125,6 +126,7 @@ beforeEach(() => {
H.git.getConflictFiles.mockResolvedValue([]);
H.git.getRemoteUrl.mockResolvedValue('');
H.git.showCommitDiff.mockResolvedValue([]);
H.git.openExternalDiff.mockResolvedValue(undefined);
H.git.fileExistsAtRef.mockResolvedValue(true);
H.git.getEmptyTreeRef.mockResolvedValue('4b825dc642cb6eb9a060e54bf8d69288fbee4904');
H.repos = [{ path: '/repo', name: 'repo', type: 'root' }];
Expand Down Expand Up @@ -233,6 +235,20 @@ describe('MainPanel message routing', () => {
expect(rightRef).toBe('2222222');
});

it('openExternalDiff launches the difftool for the commit file', async () => {
await dispatch({ type: 'openExternalDiff', payload: { hash: 'h1', file: 'assets/logo.bin' } });
expect(H.git.openExternalDiff).toHaveBeenCalledWith('h1', 'assets/logo.bin');
});

it('openExternalDiff shows an error notification when the launch fails', async () => {
const vscode = await import('vscode');
H.git.openExternalDiff.mockRejectedValueOnce(new Error('spawn git ENOENT'));

await dispatch({ type: 'openExternalDiff', payload: { hash: 'h1', file: 'logo.bin' } });

expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('externalDiffFailed');
});

it('revealInExplorer resolves the repo path and runs revealFileInOS', async () => {
const vscode = await import('vscode');

Expand Down
1 change: 1 addition & 0 deletions src/utils/message-bus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export type WebviewMessage =
| { type: 'addRemote'; payload: { name: string; url: string } }
| { type: 'removeRemote'; payload: { name: string } }
| { type: 'openDiff'; payload: { file: string; commitHash?: string; ref1?: string; ref2?: string; staged?: boolean } }
| { type: 'openExternalDiff'; payload: { hash: string; file: string } }
| { type: 'openFile'; payload: { file: string } }
| { type: 'revealInExplorer'; payload: { file: string } }
| { type: 'copyFilePath'; payload: { file: string } }
Expand Down
11 changes: 11 additions & 0 deletions webview-ui/src/components/commit/CommitDetails.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -956,6 +956,17 @@
},
});

// External diff tool — the escape hatch for binary files the
// built-in text diff can't render (#83). Committed view only:
// the action needs a commit hash to diff against its parent.
if (commit) {
const hash = commit.hash;
items.push({
label: t('file.openExternalDiff'),
action: () => { vscode.postMessage({ type: 'openExternalDiff', payload: { hash, file: node.path } }); fileContextMenu = null; },
});
}

// Reverse this file's change against the working tree.
if (commit && canReverseInThisView) {
const hash = commit.hash;
Expand Down
19 changes: 19 additions & 0 deletions webview-ui/src/components/commit/__tests__/CommitDetails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,25 @@ describe('CommitDetails — file context menu actions', () => {
});
});

it('"Open with external diff tool" posts openExternalDiff with hash and file', async () => {
const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) });
deliverCommitDiff('h1', [{ path: 'assets/logo.bin', status: 'M' }]);
await openMenu(container);
const item = Array.from(document.querySelectorAll<HTMLButtonElement>('.context-menu button, .menu-item, [role="menuitem"]'))
.find(b => /external diff/i.test(b.textContent ?? ''))!;
expect(item).not.toBeUndefined();
globalThis.__postedMessages = [];
await fireEvent.click(item);
const req = globalThis.__postedMessages.find(
(m) => (m.data as { type?: string }).type === 'openExternalDiff'
);
expect(req).toBeDefined();
expect((req!.data as { payload: unknown }).payload).toMatchObject({
hash: 'h1',
file: 'assets/logo.bin',
});
});

it('LFS unlocked file shows Lock action and posts lfsLock', async () => {
const { container } = render(CommitDetails, { commit: commit({ hash: 'h1' }) });
deliverCommitDiff('h1', [{ path: 'a.bin', status: 'M' }]);
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/lib/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,6 +633,7 @@ export const en: Record<string, string> = {
// File context menu
'file.open': 'Open File',
'file.openChanges': 'Open Changes',
'file.openExternalDiff': 'Open with External Diff Tool',
'file.revealInExplorer': 'Reveal in File Explorer',
'file.copyPath': 'Copy Path',
'file.copyRelativePath': 'Copy Relative Path',
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/lib/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ export const ko: Record<string, string> = {
'lfs.locked': '{owner}가 잠금',
'file.open': '파일 열기',
'file.openChanges': '변경 내용 열기',
'file.openExternalDiff': '외부 Diff 도구로 열기',
'file.revealInExplorer': '파일 탐색기에 표시',
'file.copyPath': '경로 복사',
'file.copyRelativePath': '상대 경로 복사',
Expand Down
1 change: 1 addition & 0 deletions webview-ui/src/lib/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -634,6 +634,7 @@ export const zh: Record<string, string> = {
'lfs.locked': '由 {owner} 锁定',
'file.open': '打开文件',
'file.openChanges': '打开更改',
'file.openExternalDiff': '使用外部 Diff 工具打开',
'file.revealInExplorer': '在文件资源管理器中显示',
'file.copyPath': '复制路径',
'file.copyRelativePath': '复制相对路径',
Expand Down