Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
53 changes: 9 additions & 44 deletions packages/cli/src/ui/hooks/useLaunchEditor.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,23 @@
import { useCallback } from 'react';
import { useStdin } from 'ink';
import type { EditorType } from '@qwen-code/qwen-code-core';
import {
editorCommands,
commandExists as coreCommandExists,
} from '@qwen-code/qwen-code-core';
import { getEditorExecutable } from '@qwen-code/qwen-code-core';
import { spawnSync } from 'child_process';
import { useSettings } from '../contexts/SettingsContext.js';

/**
* Cache for command existence checks to avoid repeated execSync calls.
*/
const commandExistsCache = new Map<string, boolean>();

/**
* Check if a command exists in the system with caching.
* Results are cached to improve performance in test environments.
*/
function commandExists(cmd: string): boolean {
if (commandExistsCache.has(cmd)) {
return commandExistsCache.get(cmd)!;
}

const exists = coreCommandExists(cmd);
commandExistsCache.set(cmd, exists);
return exists;
}
/**
* Get the actual executable command for an editor type.
*/
function getExecutableCommand(editorType: EditorType): string {
const commandConfig = editorCommands[editorType];
const commands =
process.platform === 'win32' ? commandConfig.win32 : commandConfig.default;

const availableCommand = commands.find((cmd) => commandExists(cmd));

if (!availableCommand) {
throw new Error(
`No available editor command found for ${editorType}. ` +
`Tried: ${commands.join(', ')}. ` +
`Please install one of these editors or set a different preferredEditor in settings.`,
);
}

return availableCommand;
}

/**
* Determines the editor command to use based on user preferences and platform.
*/
function getEditorCommand(preferredEditor?: EditorType): string {
if (preferredEditor) {
return getExecutableCommand(preferredEditor);
const execCmd = getEditorExecutable(preferredEditor);
if (!execCmd) {
throw new Error(
`No available editor found for ${preferredEditor}. ` +
`Please install a supported editor or set a different preferredEditor in settings.`,
);
}
return execCmd;
}

// Platform-specific defaults with UI preference for macOS
Expand Down
238 changes: 235 additions & 3 deletions packages/core/src/utils/editor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ import {
type EditorType,
} from './editor.js';
import { execSync, spawn, spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';

vi.mock('child_process', () => ({
execSync: vi.fn(),
spawn: vi.fn(),
spawnSync: vi.fn(() => ({ error: null, status: 0 })),
}));

vi.mock('fs', () => ({
existsSync: vi.fn(),
}));

const originalPlatform = process.platform;

describe('editor utils', () => {
Expand Down Expand Up @@ -171,7 +176,6 @@ describe('editor utils', () => {
win32Commands: ['windsurf'],
},
{ editor: 'cursor', commands: ['cursor'], win32Commands: ['cursor'] },
{ editor: 'zed', commands: ['zed', 'zeditor'], win32Commands: ['zed'] },
{ editor: 'trae', commands: ['trae'], win32Commands: ['trae'] },
];

Expand Down Expand Up @@ -314,6 +318,57 @@ describe('editor utils', () => {
const command = getDiffCommand('old.txt', 'new.txt', 'foobar');
expect(command).toBeNull();
});

// Zed-specific tests (Zed is handled specially for macOS app detection)
describe('Zed', () => {
it('should use CLI command "zed" when it exists on Linux', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
(execSync as Mock).mockReturnValue(Buffer.from('/usr/bin/zed'));
const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).toEqual({
command: 'zed',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});

it('should use CLI command "zeditor" when "zed" does not exist on Linux', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
(execSync as Mock)
.mockImplementationOnce(() => {
throw new Error(); // zed not found
})
.mockReturnValueOnce(Buffer.from('/usr/bin/zeditor')); // zeditor found

const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).toEqual({
command: 'zeditor',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});

it('should return null on Linux when no CLI commands exist', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // all commands not found
});
(existsSync as Mock).mockReturnValue(false);

const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).toBeNull();
});

it('should use CLI command "zed" on Windows when it exists', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
(execSync as Mock).mockReturnValue(
Buffer.from('C:\\Program Files\\Zed\\zed.exe'),
);
const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).toEqual({
command: 'zed',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});
});
});

describe('openDiff', () => {
Expand All @@ -322,7 +377,6 @@ describe('editor utils', () => {
'vscodium',
'windsurf',
'cursor',
'zed',
'trae',
];

Expand Down Expand Up @@ -377,6 +431,67 @@ describe('editor utils', () => {
});
}

// Zed-specific openDiff tests
describe('Zed', () => {
it('should call spawn for zed on macOS with CLI', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed'));
(existsSync as Mock).mockReturnValue(false);

const mockSpawnOn = vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
});
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });

await openDiff('old.txt', 'new.txt', 'zed', () => {});
expect(spawn).toHaveBeenCalledWith(
'zed',
['--wait', '--diff', 'old.txt', 'new.txt'],
{
stdio: 'inherit',
shell: false,
},
);
});

it('should call spawn for zed on macOS with app bundle CLI', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
// Accept any path containing Zed.app
(existsSync as Mock).mockImplementation((path: string) => {
return path.includes('Zed.app');
});

const mockSpawnOn = vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
});
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });

await openDiff('old.txt', 'new.txt', 'zed', () => {});
expect(spawn).toHaveBeenCalled();
// Verify the command uses the CLI tool (not GUI binary)
const call = (spawn as Mock).mock.calls[0];
expect(call[0]).toMatch(/MacOS[\/\\]cli$/);
});

it('should reject if zed is not installed', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
(existsSync as Mock).mockReturnValue(false); // App not found

await openDiff('old.txt', 'new.txt', 'zed', () => {});
// Should complete without throwing (logs error to debugLogger)
});
});

const terminalEditors: EditorType[] = ['vim', 'neovim', 'emacs'];

for (const editor of terminalEditors) {
Expand Down Expand Up @@ -427,7 +542,6 @@ describe('editor utils', () => {
'vscodium',
'windsurf',
'cursor',
'zed',
'trae',
];
for (const editor of guiEditors) {
Expand All @@ -443,6 +557,23 @@ describe('editor utils', () => {
expect(onEditorClose).not.toHaveBeenCalled();
});
}

// Zed-specific onEditorClose tests
it('should not call onEditorClose for zed', async () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed'));
(existsSync as Mock).mockReturnValue(false);

const onEditorClose = vi.fn();
const mockSpawnOn = vi.fn((event, cb) => {
if (event === 'close') {
cb(0);
}
});
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
await openDiff('old.txt', 'new.txt', 'zed', onEditorClose);
expect(onEditorClose).not.toHaveBeenCalled();
});
});
});

Expand Down Expand Up @@ -543,4 +674,105 @@ describe('editor utils', () => {
expect(isEditorAvailable('neovim')).toBe(true);
});
});

describe('Zed macOS app detection', () => {
describe('checkHasEditorType for Zed', () => {
it('should return true on macOS when Zed.app exists even if CLI is not in PATH', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
(existsSync as Mock).mockReturnValue(true); // Zed.app exists
expect(checkHasEditorType('zed')).toBe(true);
});

it('should return false on macOS when Zed.app does not exist and CLI is not in PATH', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
(existsSync as Mock).mockReturnValue(false); // Zed.app does not exist
expect(checkHasEditorType('zed')).toBe(false);
});

it('should return true on macOS when Zed CLI is in PATH', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed'));
expect(checkHasEditorType('zed')).toBe(true);
});

it('should not check for Zed.app on non-macOS platforms', () => {
Object.defineProperty(process, 'platform', { value: 'linux' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
(existsSync as Mock).mockReturnValue(true); // This should be ignored on Linux
expect(checkHasEditorType('zed')).toBe(false);
});
});

describe('getDiffCommand for Zed on macOS', () => {
// Dynamically compute the path based on what join() produces on the mocked platform
const getAppBundleCliPath = () => {
// When platform is darwin, join() produces Unix paths
// Use the correct CLI path: Contents/MacOS/cli (not the GUI binary)
return '/Applications/Zed.app/Contents/MacOS/cli';
};

it('should use app bundle CLI path when CLI is not in PATH', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
// Accept any path containing Zed.app (the CLI check will be for Contents/MacOS/cli)
(existsSync as Mock).mockImplementation((path: string) => {
return path.includes('Zed.app');
});

const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).not.toBeNull();
// Verify the command ends with cli (the CLI tool, not GUI binary zed)
expect(diffCommand!.command).toMatch(/MacOS[\/\\]cli$/);
expect(diffCommand!.args).toEqual(['--wait', '--diff', 'old.txt', 'new.txt']);
});

it('should prefer CLI in PATH over app bundle', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockReturnValue(Buffer.from('/usr/local/bin/zed'));
(existsSync as Mock).mockReturnValue(true); // App also exists

const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).toEqual({
command: 'zed',
args: ['--wait', '--diff', 'old.txt', 'new.txt'],
});
});

it('should return null when Zed is not installed at all', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
(existsSync as Mock).mockReturnValue(false); // App not found

const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).toBeNull();
});

it('should check user Applications folder as fallback', () => {
Object.defineProperty(process, 'platform', { value: 'darwin' });
(execSync as Mock).mockImplementation(() => {
throw new Error(); // CLI not found
});
// Accept any path containing Zed.app
(existsSync as Mock).mockImplementation((path: string) => {
return path.includes('Zed.app');
});

const diffCommand = getDiffCommand('old.txt', 'new.txt', 'zed');
expect(diffCommand).not.toBeNull();
expect(diffCommand!.command).toMatch(/MacOS[\/\\]cli$/);
});
});
});
});
Loading
Loading