feat: Added /copy command for copying output to clipboard with new Command Service approach (#3706)
Co-authored-by: Abhi <43648792+abhipatel12@users.noreply.github.com> Co-authored-by: N. Taylor Mullen <ntaylormullen@google.com>
This commit is contained in:
parent
a01b1219a3
commit
8f85ac7de0
|
@ -28,6 +28,9 @@ Slash commands provide meta-level control over the CLI itself.
|
|||
- **`/compress`**
|
||||
- **Description:** Replace the entire chat context with a summary. This saves on tokens used for future tasks while retaining a high level summary of what has happened.
|
||||
|
||||
- **`/copy`**
|
||||
- **Description:** Copies the last output produced by Gemini CLI to your clipboard, for easy sharing or reuse.
|
||||
|
||||
- **`/editor`**
|
||||
- **Description:** Open a dialog for selecting supported editors.
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { type SlashCommand } from '../ui/commands/types.js';
|
|||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { chatCommand } from '../ui/commands/chatCommand.js';
|
||||
|
@ -51,6 +52,9 @@ vi.mock('../ui/commands/authCommand.js', () => ({
|
|||
vi.mock('../ui/commands/themeCommand.js', () => ({
|
||||
themeCommand: { name: 'theme', description: 'Mock Theme' },
|
||||
}));
|
||||
vi.mock('../ui/commands/copyCommand.js', () => ({
|
||||
copyCommand: { name: 'copy', description: 'Mock Copy' },
|
||||
}));
|
||||
vi.mock('../ui/commands/privacyCommand.js', () => ({
|
||||
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
|
||||
}));
|
||||
|
@ -89,7 +93,7 @@ vi.mock('../ui/commands/restoreCommand.js', () => ({
|
|||
}));
|
||||
|
||||
describe('CommandService', () => {
|
||||
const subCommandLen = 18;
|
||||
const subCommandLen = 19;
|
||||
let mockConfig: Mocked<Config>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
@ -132,6 +136,7 @@ describe('CommandService', () => {
|
|||
expect(commandNames).toContain('memory');
|
||||
expect(commandNames).toContain('help');
|
||||
expect(commandNames).toContain('clear');
|
||||
expect(commandNames).toContain('copy');
|
||||
expect(commandNames).toContain('compress');
|
||||
expect(commandNames).toContain('corgi');
|
||||
expect(commandNames).toContain('docs');
|
||||
|
@ -205,6 +210,7 @@ describe('CommandService', () => {
|
|||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
copyCommand,
|
||||
compressCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
|
|
|
@ -9,6 +9,7 @@ import { SlashCommand } from '../ui/commands/types.js';
|
|||
import { memoryCommand } from '../ui/commands/memoryCommand.js';
|
||||
import { helpCommand } from '../ui/commands/helpCommand.js';
|
||||
import { clearCommand } from '../ui/commands/clearCommand.js';
|
||||
import { copyCommand } from '../ui/commands/copyCommand.js';
|
||||
import { corgiCommand } from '../ui/commands/corgiCommand.js';
|
||||
import { docsCommand } from '../ui/commands/docsCommand.js';
|
||||
import { mcpCommand } from '../ui/commands/mcpCommand.js';
|
||||
|
@ -36,6 +37,7 @@ const loadBuiltInCommands = async (
|
|||
bugCommand,
|
||||
chatCommand,
|
||||
clearCommand,
|
||||
copyCommand,
|
||||
compressCommand,
|
||||
corgiCommand,
|
||||
docsCommand,
|
||||
|
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { copyCommand } from './copyCommand.js';
|
||||
import { type CommandContext } from './types.js';
|
||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
|
||||
vi.mock('../utils/commandUtils.js', () => ({
|
||||
copyToClipboard: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('copyCommand', () => {
|
||||
let mockContext: CommandContext;
|
||||
let mockCopyToClipboard: Mock;
|
||||
let mockGetChat: Mock;
|
||||
let mockGetHistory: Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockCopyToClipboard = vi.mocked(copyToClipboard);
|
||||
mockGetChat = vi.fn();
|
||||
mockGetHistory = vi.fn();
|
||||
|
||||
mockContext = createMockCommandContext({
|
||||
services: {
|
||||
config: {
|
||||
getGeminiClient: () => ({
|
||||
getChat: mockGetChat,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
mockGetChat.mockReturnValue({
|
||||
getHistory: mockGetHistory,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message when no history is available', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetChat.mockReturnValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return info message when history is empty', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
mockGetHistory.mockReturnValue([]);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return info message when no AI messages are found in history', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithUserOnly = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithUserOnly);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should copy last AI message to clipboard successfully', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithAiMessage = [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'Hello' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Hi there! How can I help you?' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith(
|
||||
'Hi there! How can I help you?',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle multiple text parts in AI message', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithMultipleParts = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Part 1: ' }, { text: 'Part 2: ' }, { text: 'Part 3' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithMultipleParts);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Part 1: Part 2: Part 3');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter out non-text parts', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithMixedParts = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [
|
||||
{ text: 'Text part' },
|
||||
{ image: 'base64data' }, // Non-text part
|
||||
{ text: ' more text' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithMixedParts);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Text part more text');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should get the last AI message when multiple AI messages exist', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithMultipleAiMessages = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'First AI response' }],
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: 'User message' }],
|
||||
},
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'Second AI response' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithMultipleAiMessages);
|
||||
mockCopyToClipboard.mockResolvedValue(undefined);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(mockCopyToClipboard).toHaveBeenCalledWith('Second AI response');
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle clipboard copy error', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithAiMessage = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'AI response' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
const clipboardError = new Error('Clipboard access denied');
|
||||
mockCopyToClipboard.mockRejectedValue(clipboardError);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle non-Error clipboard errors', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithAiMessage = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ text: 'AI response' }],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithAiMessage);
|
||||
mockCopyToClipboard.mockRejectedValue('String error');
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return info message when no text parts found in AI message', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const historyWithEmptyParts = [
|
||||
{
|
||||
role: 'model',
|
||||
parts: [{ image: 'base64data' }], // No text parts
|
||||
},
|
||||
];
|
||||
|
||||
mockGetHistory.mockReturnValue(historyWithEmptyParts);
|
||||
|
||||
const result = await copyCommand.action(mockContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last AI output contains no text to copy.',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle unavailable config service', async () => {
|
||||
if (!copyCommand.action) throw new Error('Command has no action');
|
||||
|
||||
const nullConfigContext = createMockCommandContext({
|
||||
services: { config: null },
|
||||
});
|
||||
|
||||
const result = await copyCommand.action(nullConfigContext, '');
|
||||
|
||||
expect(result).toEqual({
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
});
|
||||
|
||||
expect(mockCopyToClipboard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
|
@ -0,0 +1,62 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { copyToClipboard } from '../utils/commandUtils.js';
|
||||
import { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
|
||||
export const copyCommand: SlashCommand = {
|
||||
name: 'copy',
|
||||
description: 'Copy the last result or code snippet to clipboard',
|
||||
action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
|
||||
const chat = await context.services.config?.getGeminiClient()?.getChat();
|
||||
const history = chat?.getHistory();
|
||||
|
||||
// Get the last message from the AI (model role)
|
||||
const lastAiMessage = history
|
||||
? history.filter((item) => item.role === 'model').pop()
|
||||
: undefined;
|
||||
|
||||
if (!lastAiMessage) {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'No output in history',
|
||||
};
|
||||
}
|
||||
// Extract text from the parts
|
||||
const lastAiOutput = lastAiMessage.parts
|
||||
?.filter((part) => part.text)
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
|
||||
if (lastAiOutput) {
|
||||
try {
|
||||
await copyToClipboard(lastAiOutput);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last output copied to the clipboard',
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.debug(message);
|
||||
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'error',
|
||||
content: 'Failed to copy to the clipboard.',
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: 'info',
|
||||
content: 'Last AI output contains no text to copy.',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
|
@ -0,0 +1,345 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
|
||||
import { spawn } from 'child_process';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
isAtCommand,
|
||||
isSlashCommand,
|
||||
copyToClipboard,
|
||||
} from './commandUtils.js';
|
||||
|
||||
// Mock child_process
|
||||
vi.mock('child_process');
|
||||
|
||||
// Mock process.platform for platform-specific tests
|
||||
const mockProcess = vi.hoisted(() => ({
|
||||
platform: 'darwin',
|
||||
}));
|
||||
|
||||
vi.stubGlobal('process', {
|
||||
...process,
|
||||
get platform() {
|
||||
return mockProcess.platform;
|
||||
},
|
||||
});
|
||||
|
||||
interface MockChildProcess extends EventEmitter {
|
||||
stdin: EventEmitter & {
|
||||
write: Mock;
|
||||
end: Mock;
|
||||
};
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
|
||||
describe('commandUtils', () => {
|
||||
let mockSpawn: Mock;
|
||||
let mockChild: MockChildProcess;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
// Dynamically import and set up spawn mock
|
||||
const { spawn } = await import('child_process');
|
||||
mockSpawn = spawn as Mock;
|
||||
|
||||
// Create mock child process with stdout/stderr emitters
|
||||
mockChild = Object.assign(new EventEmitter(), {
|
||||
stdin: Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}),
|
||||
stderr: new EventEmitter(),
|
||||
}) as MockChildProcess;
|
||||
|
||||
mockSpawn.mockReturnValue(mockChild as unknown as ReturnType<typeof spawn>);
|
||||
});
|
||||
|
||||
describe('isAtCommand', () => {
|
||||
it('should return true when query starts with @', () => {
|
||||
expect(isAtCommand('@file')).toBe(true);
|
||||
expect(isAtCommand('@path/to/file')).toBe(true);
|
||||
expect(isAtCommand('@')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when query contains @ preceded by whitespace', () => {
|
||||
expect(isAtCommand('hello @file')).toBe(true);
|
||||
expect(isAtCommand('some text @path/to/file')).toBe(true);
|
||||
expect(isAtCommand(' @file')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when query does not start with @ and has no spaced @', () => {
|
||||
expect(isAtCommand('file')).toBe(false);
|
||||
expect(isAtCommand('hello')).toBe(false);
|
||||
expect(isAtCommand('')).toBe(false);
|
||||
expect(isAtCommand('email@domain.com')).toBe(false);
|
||||
expect(isAtCommand('user@host')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when @ is not preceded by whitespace', () => {
|
||||
expect(isAtCommand('hello@file')).toBe(false);
|
||||
expect(isAtCommand('text@path')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSlashCommand', () => {
|
||||
it('should return true when query starts with /', () => {
|
||||
expect(isSlashCommand('/help')).toBe(true);
|
||||
expect(isSlashCommand('/memory show')).toBe(true);
|
||||
expect(isSlashCommand('/clear')).toBe(true);
|
||||
expect(isSlashCommand('/')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when query does not start with /', () => {
|
||||
expect(isSlashCommand('help')).toBe(false);
|
||||
expect(isSlashCommand('memory show')).toBe(false);
|
||||
expect(isSlashCommand('')).toBe(false);
|
||||
expect(isSlashCommand('path/to/file')).toBe(false);
|
||||
expect(isSlashCommand(' /help')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('copyToClipboard', () => {
|
||||
describe('on macOS (darwin)', () => {
|
||||
beforeEach(() => {
|
||||
mockProcess.platform = 'darwin';
|
||||
});
|
||||
|
||||
it('should successfully copy text to clipboard using pbcopy', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
// Simulate successful execution
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 0);
|
||||
}, 0);
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('pbcopy', []);
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
|
||||
expect(mockChild.stdin.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle pbcopy command failure', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
// Simulate command failure
|
||||
setTimeout(() => {
|
||||
mockChild.stderr.emit('data', 'Command not found');
|
||||
mockChild.emit('close', 1);
|
||||
}, 0);
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
||||
"'pbcopy' exited with code 1: Command not found",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle spawn error', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.emit('error', new Error('spawn error'));
|
||||
}, 0);
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow('spawn error');
|
||||
});
|
||||
|
||||
it('should handle stdin write error', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.stdin.emit('error', new Error('stdin error'));
|
||||
}, 0);
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow('stdin error');
|
||||
});
|
||||
});
|
||||
|
||||
describe('on Windows (win32)', () => {
|
||||
beforeEach(() => {
|
||||
mockProcess.platform = 'win32';
|
||||
});
|
||||
|
||||
it('should successfully copy text to clipboard using clip', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 0);
|
||||
}, 0);
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('clip', []);
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
|
||||
expect(mockChild.stdin.end).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('on Linux', () => {
|
||||
beforeEach(() => {
|
||||
mockProcess.platform = 'linux';
|
||||
});
|
||||
|
||||
it('should successfully copy text to clipboard using xclip', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 0);
|
||||
}, 0);
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledWith('xclip', [
|
||||
'-selection',
|
||||
'clipboard',
|
||||
]);
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith(testText);
|
||||
expect(mockChild.stdin.end).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fallback to xsel when xclip fails', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
let callCount = 0;
|
||||
|
||||
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(() => {
|
||||
if (callCount === 0) {
|
||||
// First call (xclip) fails
|
||||
child.stderr.emit('data', 'xclip not found');
|
||||
child.emit('close', 1);
|
||||
callCount++;
|
||||
} else {
|
||||
// Second call (xsel) succeeds
|
||||
child.emit('close', 0);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return child as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
await copyToClipboard(testText);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(1, 'xclip', [
|
||||
'-selection',
|
||||
'clipboard',
|
||||
]);
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(2, 'xsel', [
|
||||
'--clipboard',
|
||||
'--input',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should throw error when both xclip and xsel fail', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
let callCount = 0;
|
||||
|
||||
mockSpawn.mockImplementation(() => {
|
||||
const child = Object.assign(new EventEmitter(), {
|
||||
stdin: Object.assign(new EventEmitter(), {
|
||||
write: vi.fn(),
|
||||
end: vi.fn(),
|
||||
}),
|
||||
stderr: new EventEmitter(),
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (callCount === 0) {
|
||||
// First call (xclip) fails
|
||||
child.stderr.emit('data', 'xclip command not found');
|
||||
child.emit('close', 1);
|
||||
callCount++;
|
||||
} else {
|
||||
// Second call (xsel) fails
|
||||
child.stderr.emit('data', 'xsel command not found');
|
||||
child.emit('close', 1);
|
||||
}
|
||||
}, 0);
|
||||
|
||||
return child as unknown as ReturnType<typeof spawn>;
|
||||
});
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
||||
/All copy commands failed/,
|
||||
);
|
||||
|
||||
expect(mockSpawn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('on unsupported platform', () => {
|
||||
beforeEach(() => {
|
||||
mockProcess.platform = 'unsupported';
|
||||
});
|
||||
|
||||
it('should throw error for unsupported platform', async () => {
|
||||
await expect(copyToClipboard('test')).rejects.toThrow(
|
||||
'Unsupported platform: unsupported',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
beforeEach(() => {
|
||||
mockProcess.platform = 'darwin';
|
||||
});
|
||||
|
||||
it('should handle command exit without stderr', async () => {
|
||||
const testText = 'Hello, world!';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 1);
|
||||
}, 0);
|
||||
|
||||
await expect(copyToClipboard(testText)).rejects.toThrow(
|
||||
"'pbcopy' exited with code 1",
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty text', async () => {
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 0);
|
||||
}, 0);
|
||||
|
||||
await copyToClipboard('');
|
||||
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith('');
|
||||
});
|
||||
|
||||
it('should handle multiline text', async () => {
|
||||
const multilineText = 'Line 1\nLine 2\nLine 3';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 0);
|
||||
}, 0);
|
||||
|
||||
await copyToClipboard(multilineText);
|
||||
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith(multilineText);
|
||||
});
|
||||
|
||||
it('should handle special characters', async () => {
|
||||
const specialText = 'Special chars: !@#$%^&*()_+-=[]{}|;:,.<>?';
|
||||
|
||||
setTimeout(() => {
|
||||
mockChild.emit('close', 0);
|
||||
}, 0);
|
||||
|
||||
await copyToClipboard(specialText);
|
||||
|
||||
expect(mockChild.stdin.write).toHaveBeenCalledWith(specialText);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -4,6 +4,8 @@
|
|||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
/**
|
||||
* Checks if a query string potentially represents an '@' command.
|
||||
* It triggers if the query starts with '@' or contains '@' preceded by whitespace
|
||||
|
@ -24,3 +26,57 @@ export const isAtCommand = (query: string): boolean =>
|
|||
* @returns True if the query looks like an '/' command, false otherwise.
|
||||
*/
|
||||
export const isSlashCommand = (query: string): boolean => query.startsWith('/');
|
||||
|
||||
//Copies a string snippet to the clipboard for different platforms
|
||||
export const copyToClipboard = async (text: string): Promise<void> => {
|
||||
const run = (cmd: string, args: string[]) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const child = spawn(cmd, args);
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => (stderr += chunk.toString()));
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
if (code === 0) return resolve();
|
||||
const errorMsg = stderr.trim();
|
||||
reject(
|
||||
new Error(
|
||||
`'${cmd}' exited with code ${code}${errorMsg ? `: ${errorMsg}` : ''}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
child.stdin.on('error', reject);
|
||||
child.stdin.write(text);
|
||||
child.stdin.end();
|
||||
});
|
||||
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return run('clip', []);
|
||||
case 'darwin':
|
||||
return run('pbcopy', []);
|
||||
case 'linux':
|
||||
try {
|
||||
await run('xclip', ['-selection', 'clipboard']);
|
||||
} catch (primaryError) {
|
||||
try {
|
||||
// If xclip fails for any reason, try xsel as a fallback.
|
||||
await run('xsel', ['--clipboard', '--input']);
|
||||
} catch (fallbackError) {
|
||||
const primaryMsg =
|
||||
primaryError instanceof Error
|
||||
? primaryError.message
|
||||
: String(primaryError);
|
||||
const fallbackMsg =
|
||||
fallbackError instanceof Error
|
||||
? fallbackError.message
|
||||
: String(fallbackError);
|
||||
throw new Error(
|
||||
`All copy commands failed. xclip: "${primaryMsg}", xsel: "${fallbackMsg}". Please ensure xclip or xsel is installed and configured.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${process.platform}`);
|
||||
}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue