feat: Add clipboard image paste support for macOS (#1580)
Co-authored-by: Jacob Richman <jacob314@gmail.com> Co-authored-by: Scott Densmore <scottdensmore@mac.com>
This commit is contained in:
parent
c4ea17692f
commit
c9e194ec6a
|
@ -6,6 +6,8 @@
|
||||||
.gemini/
|
.gemini/
|
||||||
!gemini/config.yaml
|
!gemini/config.yaml
|
||||||
|
|
||||||
|
# Note: .gemini-clipboard/ is NOT in gitignore so Gemini can access pasted images
|
||||||
|
|
||||||
# Dependency directory
|
# Dependency directory
|
||||||
node_modules
|
node_modules
|
||||||
bower_components
|
bower_components
|
||||||
|
|
|
@ -13,11 +13,13 @@ import { vi } from 'vitest';
|
||||||
import { useShellHistory } from '../hooks/useShellHistory.js';
|
import { useShellHistory } from '../hooks/useShellHistory.js';
|
||||||
import { useCompletion } from '../hooks/useCompletion.js';
|
import { useCompletion } from '../hooks/useCompletion.js';
|
||||||
import { useInputHistory } from '../hooks/useInputHistory.js';
|
import { useInputHistory } from '../hooks/useInputHistory.js';
|
||||||
|
import * as clipboardUtils from '../utils/clipboardUtils.js';
|
||||||
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
|
||||||
|
|
||||||
vi.mock('../hooks/useShellHistory.js');
|
vi.mock('../hooks/useShellHistory.js');
|
||||||
vi.mock('../hooks/useCompletion.js');
|
vi.mock('../hooks/useCompletion.js');
|
||||||
vi.mock('../hooks/useInputHistory.js');
|
vi.mock('../hooks/useInputHistory.js');
|
||||||
|
vi.mock('../utils/clipboardUtils.js');
|
||||||
|
|
||||||
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
|
type MockedUseShellHistory = ReturnType<typeof useShellHistory>;
|
||||||
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
type MockedUseCompletion = ReturnType<typeof useCompletion>;
|
||||||
|
@ -76,6 +78,7 @@ describe('InputPrompt', () => {
|
||||||
mockBuffer.viewportVisualLines = [newText];
|
mockBuffer.viewportVisualLines = [newText];
|
||||||
mockBuffer.allVisualLines = [newText];
|
mockBuffer.allVisualLines = [newText];
|
||||||
}),
|
}),
|
||||||
|
replaceRangeByOffset: vi.fn(),
|
||||||
viewportVisualLines: [''],
|
viewportVisualLines: [''],
|
||||||
allVisualLines: [''],
|
allVisualLines: [''],
|
||||||
visualCursor: [0, 0],
|
visualCursor: [0, 0],
|
||||||
|
@ -87,7 +90,6 @@ describe('InputPrompt', () => {
|
||||||
killLineLeft: vi.fn(),
|
killLineLeft: vi.fn(),
|
||||||
openInExternalEditor: vi.fn(),
|
openInExternalEditor: vi.fn(),
|
||||||
newline: vi.fn(),
|
newline: vi.fn(),
|
||||||
replaceRangeByOffset: vi.fn(),
|
|
||||||
} as unknown as TextBuffer;
|
} as unknown as TextBuffer;
|
||||||
|
|
||||||
mockShellHistory = {
|
mockShellHistory = {
|
||||||
|
@ -218,6 +220,126 @@ describe('InputPrompt', () => {
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('clipboard image paste', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||||
|
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||||
|
vi.mocked(clipboardUtils.cleanupOldClipboardImages).mockResolvedValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle Ctrl+V when clipboard has an image', async () => {
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||||
|
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||||
|
'/test/.gemini-clipboard/clipboard-123.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Send Ctrl+V
|
||||||
|
stdin.write('\x16'); // Ctrl+V
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||||
|
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalledWith(
|
||||||
|
props.config.getTargetDir(),
|
||||||
|
);
|
||||||
|
expect(clipboardUtils.cleanupOldClipboardImages).toHaveBeenCalledWith(
|
||||||
|
props.config.getTargetDir(),
|
||||||
|
);
|
||||||
|
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not insert anything when clipboard has no image', async () => {
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(false);
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x16'); // Ctrl+V
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(clipboardUtils.clipboardHasImage).toHaveBeenCalled();
|
||||||
|
expect(clipboardUtils.saveClipboardImage).not.toHaveBeenCalled();
|
||||||
|
expect(mockBuffer.setText).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle image save failure gracefully', async () => {
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||||
|
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(null);
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x16'); // Ctrl+V
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(clipboardUtils.saveClipboardImage).toHaveBeenCalled();
|
||||||
|
expect(mockBuffer.setText).not.toHaveBeenCalled();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should insert image path at cursor position with proper spacing', async () => {
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockResolvedValue(true);
|
||||||
|
vi.mocked(clipboardUtils.saveClipboardImage).mockResolvedValue(
|
||||||
|
'/test/.gemini-clipboard/clipboard-456.png',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set initial text and cursor position
|
||||||
|
mockBuffer.text = 'Hello world';
|
||||||
|
mockBuffer.cursor = [0, 5]; // Cursor after "Hello"
|
||||||
|
mockBuffer.lines = ['Hello world'];
|
||||||
|
mockBuffer.replaceRangeByOffset = vi.fn();
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x16'); // Ctrl+V
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
// Should insert at cursor position with spaces
|
||||||
|
expect(mockBuffer.replaceRangeByOffset).toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Get the actual call to see what path was used
|
||||||
|
const actualCall = vi.mocked(mockBuffer.replaceRangeByOffset).mock
|
||||||
|
.calls[0];
|
||||||
|
expect(actualCall[0]).toBe(5); // start offset
|
||||||
|
expect(actualCall[1]).toBe(5); // end offset
|
||||||
|
expect(actualCall[2]).toMatch(
|
||||||
|
/@.*\.gemini-clipboard\/clipboard-456\.png/,
|
||||||
|
); // flexible path match
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors during clipboard operations', async () => {
|
||||||
|
const consoleErrorSpy = vi
|
||||||
|
.spyOn(console, 'error')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
vi.mocked(clipboardUtils.clipboardHasImage).mockRejectedValue(
|
||||||
|
new Error('Clipboard error'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { stdin, unmount } = render(<InputPrompt {...props} />);
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
stdin.write('\x16'); // Ctrl+V
|
||||||
|
await wait();
|
||||||
|
|
||||||
|
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error handling clipboard image:',
|
||||||
|
expect.any(Error),
|
||||||
|
);
|
||||||
|
expect(mockBuffer.setText).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
consoleErrorSpy.mockRestore();
|
||||||
|
unmount();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should complete a partial parent command and add a space', async () => {
|
it('should complete a partial parent command and add a space', async () => {
|
||||||
// SCENARIO: /mem -> Tab
|
// SCENARIO: /mem -> Tab
|
||||||
mockedUseCompletion.mockReturnValue({
|
mockedUseCompletion.mockReturnValue({
|
||||||
|
@ -355,8 +477,6 @@ describe('InputPrompt', () => {
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ADD this test for defensive coverage
|
|
||||||
|
|
||||||
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
it('should not submit on Enter when the buffer is empty or only contains whitespace', async () => {
|
||||||
props.buffer.setText(' '); // Set buffer to whitespace
|
props.buffer.setText(' '); // Set buffer to whitespace
|
||||||
|
|
||||||
|
|
|
@ -19,6 +19,12 @@ import { useKeypress, Key } from '../hooks/useKeypress.js';
|
||||||
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
|
||||||
import { CommandContext, SlashCommand } from '../commands/types.js';
|
import { CommandContext, SlashCommand } from '../commands/types.js';
|
||||||
import { Config } from '@google/gemini-cli-core';
|
import { Config } from '@google/gemini-cli-core';
|
||||||
|
import {
|
||||||
|
clipboardHasImage,
|
||||||
|
saveClipboardImage,
|
||||||
|
cleanupOldClipboardImages,
|
||||||
|
} from '../utils/clipboardUtils.js';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
export interface InputPromptProps {
|
export interface InputPromptProps {
|
||||||
buffer: TextBuffer;
|
buffer: TextBuffer;
|
||||||
|
@ -52,7 +58,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
setShellModeActive,
|
setShellModeActive,
|
||||||
}) => {
|
}) => {
|
||||||
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
|
||||||
|
|
||||||
const completion = useCompletion(
|
const completion = useCompletion(
|
||||||
buffer.text,
|
buffer.text,
|
||||||
config.getTargetDir(),
|
config.getTargetDir(),
|
||||||
|
@ -178,6 +183,54 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
[resetCompletionState, buffer, completionSuggestions, slashCommands],
|
[resetCompletionState, buffer, completionSuggestions, slashCommands],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle clipboard image pasting with Ctrl+V
|
||||||
|
const handleClipboardImage = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
if (await clipboardHasImage()) {
|
||||||
|
const imagePath = await saveClipboardImage(config.getTargetDir());
|
||||||
|
if (imagePath) {
|
||||||
|
// Clean up old images
|
||||||
|
cleanupOldClipboardImages(config.getTargetDir()).catch(() => {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get relative path from current directory
|
||||||
|
const relativePath = path.relative(config.getTargetDir(), imagePath);
|
||||||
|
|
||||||
|
// Insert @path reference at cursor position
|
||||||
|
const insertText = `@${relativePath}`;
|
||||||
|
const currentText = buffer.text;
|
||||||
|
const [row, col] = buffer.cursor;
|
||||||
|
|
||||||
|
// Calculate offset from row/col
|
||||||
|
let offset = 0;
|
||||||
|
for (let i = 0; i < row; i++) {
|
||||||
|
offset += buffer.lines[i].length + 1; // +1 for newline
|
||||||
|
}
|
||||||
|
offset += col;
|
||||||
|
|
||||||
|
// Add spaces around the path if needed
|
||||||
|
let textToInsert = insertText;
|
||||||
|
const charBefore = offset > 0 ? currentText[offset - 1] : '';
|
||||||
|
const charAfter =
|
||||||
|
offset < currentText.length ? currentText[offset] : '';
|
||||||
|
|
||||||
|
if (charBefore && charBefore !== ' ' && charBefore !== '\n') {
|
||||||
|
textToInsert = ' ' + textToInsert;
|
||||||
|
}
|
||||||
|
if (!charAfter || (charAfter !== ' ' && charAfter !== '\n')) {
|
||||||
|
textToInsert = textToInsert + ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert at cursor position
|
||||||
|
buffer.replaceRangeByOffset(offset, offset, textToInsert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling clipboard image:', error);
|
||||||
|
}
|
||||||
|
}, [buffer, config]);
|
||||||
|
|
||||||
const handleInput = useCallback(
|
const handleInput = useCallback(
|
||||||
(key: Key) => {
|
(key: Key) => {
|
||||||
if (!focus) {
|
if (!focus) {
|
||||||
|
@ -315,6 +368,12 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+V for clipboard image paste
|
||||||
|
if (key.ctrl && key.name === 'v') {
|
||||||
|
handleClipboardImage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fallback to the text buffer's default input handling for all other keys
|
// Fallback to the text buffer's default input handling for all other keys
|
||||||
buffer.handleInput(key);
|
buffer.handleInput(key);
|
||||||
},
|
},
|
||||||
|
@ -329,6 +388,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
handleAutocomplete,
|
handleAutocomplete,
|
||||||
handleSubmitAndClear,
|
handleSubmitAndClear,
|
||||||
shellHistory,
|
shellHistory,
|
||||||
|
handleClipboardImage,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -372,6 +432,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
|
|
||||||
if (visualIdxInRenderedSet === cursorVisualRow) {
|
if (visualIdxInRenderedSet === cursorVisualRow) {
|
||||||
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
const relativeVisualColForHighlight = cursorVisualColAbsolute;
|
||||||
|
|
||||||
if (relativeVisualColForHighlight >= 0) {
|
if (relativeVisualColForHighlight >= 0) {
|
||||||
if (relativeVisualColForHighlight < cpLen(display)) {
|
if (relativeVisualColForHighlight < cpLen(display)) {
|
||||||
const charToHighlight =
|
const charToHighlight =
|
||||||
|
|
|
@ -0,0 +1,76 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
clipboardHasImage,
|
||||||
|
saveClipboardImage,
|
||||||
|
cleanupOldClipboardImages,
|
||||||
|
} from './clipboardUtils.js';
|
||||||
|
|
||||||
|
describe('clipboardUtils', () => {
|
||||||
|
describe('clipboardHasImage', () => {
|
||||||
|
it('should return false on non-macOS platforms', async () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
const result = await clipboardHasImage();
|
||||||
|
expect(result).toBe(false);
|
||||||
|
} else {
|
||||||
|
// Skip on macOS as it would require actual clipboard state
|
||||||
|
expect(true).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return boolean on macOS', async () => {
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
const result = await clipboardHasImage();
|
||||||
|
expect(typeof result).toBe('boolean');
|
||||||
|
} else {
|
||||||
|
// Skip on non-macOS
|
||||||
|
expect(true).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('saveClipboardImage', () => {
|
||||||
|
it('should return null on non-macOS platforms', async () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
const result = await saveClipboardImage();
|
||||||
|
expect(result).toBe(null);
|
||||||
|
} else {
|
||||||
|
// Skip on macOS
|
||||||
|
expect(true).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle errors gracefully', async () => {
|
||||||
|
// Test with invalid directory (should not throw)
|
||||||
|
const result = await saveClipboardImage(
|
||||||
|
'/invalid/path/that/does/not/exist',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
// On macOS, might return null due to various errors
|
||||||
|
expect(result === null || typeof result === 'string').toBe(true);
|
||||||
|
} else {
|
||||||
|
// On other platforms, should always return null
|
||||||
|
expect(result).toBe(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cleanupOldClipboardImages', () => {
|
||||||
|
it('should not throw errors', async () => {
|
||||||
|
// Should handle missing directories gracefully
|
||||||
|
await expect(
|
||||||
|
cleanupOldClipboardImages('/path/that/does/not/exist'),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should complete without errors on valid directory', async () => {
|
||||||
|
await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,149 @@
|
||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2025 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { exec } from 'child_process';
|
||||||
|
import { promisify } from 'util';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
const execAsync = promisify(exec);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the system clipboard contains an image (macOS only for now)
|
||||||
|
* @returns true if clipboard contains an image
|
||||||
|
*/
|
||||||
|
export async function clipboardHasImage(): Promise<boolean> {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use osascript to check clipboard type
|
||||||
|
const { stdout } = await execAsync(
|
||||||
|
`osascript -e 'clipboard info' 2>/dev/null | grep -qE "«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»" && echo "true" || echo "false"`,
|
||||||
|
{ shell: '/bin/bash' },
|
||||||
|
);
|
||||||
|
return stdout.trim() === 'true';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the image from clipboard to a temporary file (macOS only for now)
|
||||||
|
* @param targetDir The target directory to create temp files within
|
||||||
|
* @returns The path to the saved image file, or null if no image or error
|
||||||
|
*/
|
||||||
|
export async function saveClipboardImage(
|
||||||
|
targetDir?: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a temporary directory for clipboard images within the target directory
|
||||||
|
// This avoids security restrictions on paths outside the target directory
|
||||||
|
const baseDir = targetDir || process.cwd();
|
||||||
|
const tempDir = path.join(baseDir, '.gemini-clipboard');
|
||||||
|
await fs.mkdir(tempDir, { recursive: true });
|
||||||
|
|
||||||
|
// Generate a unique filename with timestamp
|
||||||
|
const timestamp = new Date().getTime();
|
||||||
|
|
||||||
|
// Try different image formats in order of preference
|
||||||
|
const formats = [
|
||||||
|
{ class: 'PNGf', extension: 'png' },
|
||||||
|
{ class: 'JPEG', extension: 'jpg' },
|
||||||
|
{ class: 'TIFF', extension: 'tiff' },
|
||||||
|
{ class: 'GIFf', extension: 'gif' },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const format of formats) {
|
||||||
|
const tempFilePath = path.join(
|
||||||
|
tempDir,
|
||||||
|
`clipboard-${timestamp}.${format.extension}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Try to save clipboard as this format
|
||||||
|
const script = `
|
||||||
|
try
|
||||||
|
set imageData to the clipboard as «class ${format.class}»
|
||||||
|
set fileRef to open for access POSIX file "${tempFilePath}" with write permission
|
||||||
|
write imageData to fileRef
|
||||||
|
close access fileRef
|
||||||
|
return "success"
|
||||||
|
on error errMsg
|
||||||
|
try
|
||||||
|
close access POSIX file "${tempFilePath}"
|
||||||
|
end try
|
||||||
|
return "error"
|
||||||
|
end try
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { stdout } = await execAsync(`osascript -e '${script}'`);
|
||||||
|
|
||||||
|
if (stdout.trim() === 'success') {
|
||||||
|
// Verify the file was created and has content
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(tempFilePath);
|
||||||
|
if (stats.size > 0) {
|
||||||
|
return tempFilePath;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// File doesn't exist, continue to next format
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up failed attempt
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempFilePath);
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No format worked
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving clipboard image:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans up old temporary clipboard image files
|
||||||
|
* Removes files older than 1 hour
|
||||||
|
* @param targetDir The target directory where temp files are stored
|
||||||
|
*/
|
||||||
|
export async function cleanupOldClipboardImages(
|
||||||
|
targetDir?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const baseDir = targetDir || process.cwd();
|
||||||
|
const tempDir = path.join(baseDir, '.gemini-clipboard');
|
||||||
|
const files = await fs.readdir(tempDir);
|
||||||
|
const oneHourAgo = Date.now() - 60 * 60 * 1000;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (
|
||||||
|
file.startsWith('clipboard-') &&
|
||||||
|
(file.endsWith('.png') ||
|
||||||
|
file.endsWith('.jpg') ||
|
||||||
|
file.endsWith('.tiff') ||
|
||||||
|
file.endsWith('.gif'))
|
||||||
|
) {
|
||||||
|
const filePath = path.join(tempDir, file);
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
if (stats.mtimeMs < oneHourAgo) {
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors in cleanup
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue