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`**
|
- **`/copy`**
|
||||||
- **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse.
|
- **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`**)
|
- **`/directory`** (or **`/dir`**)
|
||||||
- **Description:** Manage workspace directories for multi-directory support.
|
- **Description:** Manage workspace directories for multi-directory support.
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ describe('copyCommand', () => {
|
|||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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);
|
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||||
mockCopyToClipboard.mockRejectedValue('String error');
|
const rejectedValue = 'String error';
|
||||||
|
mockCopyToClipboard.mockRejectedValue(rejectedValue);
|
||||||
|
|
||||||
const result = await copyCommand.action(mockContext, '');
|
const result = await copyCommand.action(mockContext, '');
|
||||||
|
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
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 {
|
return {
|
||||||
type: 'message',
|
type: 'message',
|
||||||
messageType: 'error',
|
messageType: 'error',
|
||||||
content: 'Failed to copy to the clipboard.',
|
content: `Failed to copy to the clipboard. ${message}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -224,7 +224,9 @@ describe('commandUtils', () => {
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (callCount === 0) {
|
if (callCount === 0) {
|
||||||
// First call (xclip) fails
|
// 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);
|
child.emit('close', 1);
|
||||||
callCount++;
|
callCount++;
|
||||||
} else {
|
} 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!';
|
const testText = 'Hello, world!';
|
||||||
let callCount = 0;
|
let callCount = 0;
|
||||||
|
const linuxOptions: SpawnOptions = {
|
||||||
|
stdio: ['pipe', 'inherit', 'pipe'],
|
||||||
|
};
|
||||||
|
|
||||||
mockSpawn.mockImplementation(() => {
|
mockSpawn.mockImplementation(() => {
|
||||||
const child = Object.assign(new EventEmitter(), {
|
const child = Object.assign(new EventEmitter(), {
|
||||||
@@ -264,29 +269,99 @@ describe('commandUtils', () => {
|
|||||||
end: vi.fn(),
|
end: vi.fn(),
|
||||||
}),
|
}),
|
||||||
stderr: new EventEmitter(),
|
stderr: new EventEmitter(),
|
||||||
});
|
}) as MockChildProcess;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (callCount === 0) {
|
if (callCount === 0) {
|
||||||
// First call (xclip) fails
|
// First call (xclip) fails with ENOENT
|
||||||
child.stderr.emit('data', 'xclip command not found');
|
const error = new Error('spawn xclip ENOENT');
|
||||||
|
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||||
|
child.emit('error', error);
|
||||||
child.emit('close', 1);
|
child.emit('close', 1);
|
||||||
callCount++;
|
callCount++;
|
||||||
} else {
|
} else {
|
||||||
// Second call (xsel) fails
|
// Second call (xsel) fails with ENOENT
|
||||||
child.stderr.emit('data', 'xsel command not found');
|
const error = new Error('spawn xsel ENOENT');
|
||||||
|
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||||
|
child.emit('error', error);
|
||||||
child.emit('close', 1);
|
child.emit('close', 1);
|
||||||
}
|
}
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return child as unknown as ReturnType<typeof spawn>;
|
return child as unknown as ReturnType<typeof spawn>;
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
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).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.
|
// If xclip fails for any reason, try xsel as a fallback.
|
||||||
await run('xsel', ['--clipboard', '--input'], linuxOptions);
|
await run('xsel', ['--clipboard', '--input'], linuxOptions);
|
||||||
} catch (fallbackError) {
|
} 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 instanceof Error
|
||||||
? primaryError.message
|
? primaryError.message
|
||||||
: String(primaryError);
|
: String(primaryError);
|
||||||
const fallbackMsg =
|
if (xclipNotFound) {
|
||||||
|
primaryMsg = `xclip not found`;
|
||||||
|
}
|
||||||
|
let fallbackMsg =
|
||||||
fallbackError instanceof Error
|
fallbackError instanceof Error
|
||||||
? fallbackError.message
|
? fallbackError.message
|
||||||
: String(fallbackError);
|
: String(fallbackError);
|
||||||
|
if (xselNotFound) {
|
||||||
|
fallbackMsg = `xsel not found`;
|
||||||
|
}
|
||||||
|
|
||||||
throw new Error(
|
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