mirror of
https://github.com/QwenLM/qwen-code.git
synced 2025-12-19 09:33:53 +00:00
fix(copyCommand): provide friendlier error messages for /copy command (#6723)
Co-authored-by: Ben Guo <hundunben@gmail.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -40,6 +40,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
|
||||
- **`/copy`**
|
||||
- **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse.
|
||||
- **Note:** This command requires platform-specific clipboard tools to be installed.
|
||||
- On Linux, it requires `xclip` or `xsel`. You can typically install them using your system's package manager.
|
||||
- On macOS, it requires `pbcopy`, and on Windows, it requires `clip`. These tools are typically pre-installed on their respective systems.
|
||||
|
||||
- **`/directory`** (or **`/dir`**)
|
||||
- **Description:** Manage workspace directories for multi-directory support.
|
||||
|
||||
@@ -227,7 +227,7 @@ describe('copyCommand', () => {
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
content: `Failed to copy to the clipboard. ${clipboardError.message}`,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -242,14 +242,15 @@ describe('copyCommand', () => {
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
mockCopyToClipboard.mockRejectedValue('String error');
|
||||
const rejectedValue = 'String error';
|
||||
mockCopyToClipboard.mockRejectedValue(rejectedValue);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
content: `Failed to copy to the clipboard. ${rejectedValue}`,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ export const copyCommand: SlashCommand = {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
content: `Failed to copy to the clipboard. ${message}`,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -224,7 +224,9 @@ describe('commandUtils', () => {
|
||||
setTimeout(() => {
|
||||
if (callCount === 0) {
|
||||
// First call (xclip) fails
|
||||
child.stderr.emit('data', 'xclip not found');
|
||||
const error = new Error('spawn xclip ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
child.emit('error', error);
|
||||
child.emit('close', 1);
|
||||
callCount++;
|
||||
} else {
|
||||
@@ -253,9 +255,12 @@ describe('commandUtils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when both xclip and xsel fail', async () => {
|
||||
it('should throw error when both xclip and xsel are not found', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
let callCount = 0;
|
||||
const linuxOptions: SpawnOptions = {
|
||||
stdio: ['pipe', 'inherit', 'pipe'],
|
||||
};
|
||||
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
@@ -264,29 +269,99 @@ describe('commandUtils', () => {
|
||||
end: vi.fn(),
|
||||
}),
|
||||
stderr: new EventEmitter(),
|
||||
});
|
||||
}) as MockChildProcess;
|
||||
|
||||
setTimeout(() => {
|
||||
if (callCount === 0) {
|
||||
// First call (xclip) fails
|
||||
child.stderr.emit('data', 'xclip command not found');
|
||||
// First call (xclip) fails with ENOENT
|
||||
const error = new Error('spawn xclip ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
child.emit('error', error);
|
||||
child.emit('close', 1);
|
||||
callCount++;
|
||||
} else {
|
||||
// Second call (xsel) fails
|
||||
child.stderr.emit('data', 'xsel command not found');
|
||||
// Second call (xsel) fails with ENOENT
|
||||
const error = new Error('spawn xsel ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
child.emit('error', error);
|
||||
child.emit('close', 1);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return child as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
||||
/All copy commands failed/,
|
||||
'Please ensure xclip or xsel is installed and configured.',
|
||||
);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xclip',
|
||||
['-selection', 'clipboard'],
|
||||
linuxOptions,
|
||||
);
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xsel',
|
||||
['--clipboard', '--input'],
|
||||
linuxOptions,
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit error when xclip or xsel fail with stderr output (command installed)', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
let callCount = 0;
|
||||
const linuxOptions: SpawnOptions = {
|
||||
stdio: ['pipe', 'inherit', 'pipe'],
|
||||
};
|
||||
const errorMsg = "Error: Can't open display:";
|
||||
const exitCode = 1;
|
||||
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}),
|
||||
stderr: new EventEmitter(),
|
||||
}) as MockChildProcess;
|
||||
|
||||
setTimeout(() => {
|
||||
// e.g., cannot connect to X server
|
||||
if (callCount === 0) {
|
||||
child.stderr.emit('data', errorMsg);
|
||||
child.emit('close', exitCode);
|
||||
callCount++;
|
||||
} else {
|
||||
child.stderr.emit('data', errorMsg);
|
||||
child.emit('close', exitCode);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return child as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
const xclipErrorMsg = `'xclip' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`;
|
||||
const xselErrorMsg = `'xsel' exited with code ${exitCode}${errorMsg ? `: ${errorMsg}` : ''}`;
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
||||
`All copy commands failed. "${xclipErrorMsg}", "${xselErrorMsg}". `,
|
||||
);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'xclip',
|
||||
['-selection', 'clipboard'],
|
||||
linuxOptions,
|
||||
);
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'xsel',
|
||||
['--clipboard', '--input'],
|
||||
linuxOptions,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -74,16 +74,35 @@ export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
// If xclip fails for any reason, try xsel as a fallback.
|
||||
await run('xsel', ['--clipboard', '--input'], linuxOptions);
|
||||
} catch (fallbackError) {
|
||||
const primaryMsg =
|
||||
const xclipNotFound =
|
||||
primaryError instanceof Error &&
|
||||
(primaryError as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
const xselNotFound =
|
||||
fallbackError instanceof Error &&
|
||||
(fallbackError as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
if (xclipNotFound && xselNotFound) {
|
||||
throw new Error(
|
||||
'Please ensure xclip or xsel is installed and configured.',
|
||||
);
|
||||
}
|
||||
|
||||
let primaryMsg =
|
||||
primaryError instanceof Error
|
||||
? primaryError.message
|
||||
: String(primaryError);
|
||||
const fallbackMsg =
|
||||
if (xclipNotFound) {
|
||||
primaryMsg = `xclip not found`;
|
||||
}
|
||||
let fallbackMsg =
|
||||
fallbackError instanceof Error
|
||||
? fallbackError.message
|
||||
: String(fallbackError);
|
||||
if (xselNotFound) {
|
||||
fallbackMsg = `xsel not found`;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`,
|
||||
`All copy commands failed. "${primaryMsg}", "${fallbackMsg}". `,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user