From 1e734d7e60ee1a69d9ee2b57c6c32a78aa491ec1 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Fri, 12 Dec 2025 12:14:35 -0500 Subject: [PATCH] feat: support multi-file drag and drop of images (#14832) --- .../ui/components/shared/text-buffer.test.ts | 40 +++++ .../src/ui/components/shared/text-buffer.ts | 9 +- .../cli/src/ui/utils/clipboardUtils.test.ts | 164 ++++++++++++++++++ packages/cli/src/ui/utils/clipboardUtils.ts | 102 ++++++++++- 4 files changed, 308 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 72bb567fff..f26608aabe 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -637,6 +637,46 @@ describe('useTextBuffer', () => { act(() => result.current.insert(shortText, { paste: true })); expect(getBufferState(result).text).toBe(shortText); }); + + it('should prepend @ to multiple valid file paths on insert', () => { + // Use Set to model reality: individual paths exist, combined string doesn't + const validPaths = new Set(['/path/to/file1.txt', '/path/to/file2.txt']); + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: (p) => validPaths.has(p) }), + ); + const filePaths = '/path/to/file1.txt /path/to/file2.txt'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt ', + ); + }); + + it('should handle multiple paths with escaped spaces', () => { + // Use Set to model reality: individual paths exist, combined string doesn't + const validPaths = new Set(['/path/to/my file.txt', '/other/path.txt']); + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: (p) => validPaths.has(p) }), + ); + const filePaths = '/path/to/my\\ file.txt /other/path.txt'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/my\\ file.txt @/other/path.txt ', + ); + }); + + it('should only prepend @ to valid paths in multi-path paste', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p) => p.endsWith('.txt'), + }), + ); + const filePaths = '/valid/file.txt /invalid/file.jpg'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/valid/file.txt /invalid/file.jpg ', + ); + }); }); describe('Shell Mode Behavior', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index e9a19652bc..99cfc7e7d4 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -9,7 +9,7 @@ import fs from 'node:fs'; import os from 'node:os'; import pathMod from 'node:path'; import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; -import { unescapePath, coreEvents, CoreEvent } from '@google/gemini-cli-core'; +import { coreEvents, CoreEvent } from '@google/gemini-cli-core'; import { toCodePoints, cpLen, @@ -17,6 +17,7 @@ import { stripUnsafeCharacters, getCachedStringWidth, } from '../../utils/textUtils.js'; +import { parsePastedPaths } from '../../utils/clipboardUtils.js'; import type { Key } from '../../contexts/KeypressContext.js'; import type { VimAction } from './vim-buffer-actions.js'; import { handleVimAction } from './vim-buffer-actions.js'; @@ -1675,8 +1676,10 @@ export function useTextBuffer({ } potentialPath = potentialPath.trim(); - if (isValidPath(unescapePath(potentialPath))) { - ch = `@${potentialPath} `; + + const processed = parsePastedPaths(potentialPath, isValidPath); + if (processed) { + ch = processed; } } diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889ed..bff3d2a6ec 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -9,6 +9,8 @@ import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, + splitEscapedPaths, + parsePastedPaths, } from './clipboardUtils.js'; describe('clipboardUtils', () => { @@ -73,4 +75,166 @@ describe('clipboardUtils', () => { await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); }); }); + + describe('splitEscapedPaths', () => { + it('should return single path when no spaces', () => { + expect(splitEscapedPaths('/path/to/image.png')).toEqual([ + '/path/to/image.png', + ]); + }); + + it('should split simple space-separated paths', () => { + expect(splitEscapedPaths('/img1.png /img2.png')).toEqual([ + '/img1.png', + '/img2.png', + ]); + }); + + it('should split three paths', () => { + expect(splitEscapedPaths('/a.png /b.jpg /c.heic')).toEqual([ + '/a.png', + '/b.jpg', + '/c.heic', + ]); + }); + + it('should preserve escaped spaces within filenames', () => { + expect(splitEscapedPaths('/my\\ image.png')).toEqual(['/my\\ image.png']); + }); + + it('should handle multiple paths with escaped spaces', () => { + expect(splitEscapedPaths('/my\\ img1.png /my\\ img2.png')).toEqual([ + '/my\\ img1.png', + '/my\\ img2.png', + ]); + }); + + it('should handle path with multiple escaped spaces', () => { + expect(splitEscapedPaths('/path/to/my\\ cool\\ image.png')).toEqual([ + '/path/to/my\\ cool\\ image.png', + ]); + }); + + it('should handle multiple consecutive spaces between paths', () => { + expect(splitEscapedPaths('/img1.png /img2.png')).toEqual([ + '/img1.png', + '/img2.png', + ]); + }); + + it('should handle trailing and leading whitespace', () => { + expect(splitEscapedPaths(' /img1.png /img2.png ')).toEqual([ + '/img1.png', + '/img2.png', + ]); + }); + + it('should return empty array for empty string', () => { + expect(splitEscapedPaths('')).toEqual([]); + }); + + it('should return empty array for whitespace only', () => { + expect(splitEscapedPaths(' ')).toEqual([]); + }); + }); + + describe('parsePastedPaths', () => { + it('should return null for empty string', () => { + const result = parsePastedPaths('', () => true); + expect(result).toBe(null); + }); + + it('should add @ prefix to single valid path', () => { + const result = parsePastedPaths('/path/to/file.txt', () => true); + expect(result).toBe('@/path/to/file.txt '); + }); + + it('should return null for single invalid path', () => { + const result = parsePastedPaths('/path/to/file.txt', () => false); + expect(result).toBe(null); + }); + + it('should add @ prefix to all valid paths', () => { + // Use Set to model reality: individual paths exist, combined string doesn't + const validPaths = new Set(['/path/to/file1.txt', '/path/to/file2.txt']); + const result = parsePastedPaths( + '/path/to/file1.txt /path/to/file2.txt', + (p) => validPaths.has(p), + ); + expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt '); + }); + + it('should only add @ prefix to valid paths', () => { + const result = parsePastedPaths( + '/valid/file.txt /invalid/file.jpg', + (p) => p.endsWith('.txt'), + ); + expect(result).toBe('@/valid/file.txt /invalid/file.jpg '); + }); + + it('should return null if no paths are valid', () => { + const result = parsePastedPaths( + '/path/to/file1.txt /path/to/file2.txt', + () => false, + ); + expect(result).toBe(null); + }); + + it('should handle paths with escaped spaces', () => { + // Use Set to model reality: individual paths exist, combined string doesn't + const validPaths = new Set(['/path/to/my file.txt', '/other/path.txt']); + const result = parsePastedPaths( + '/path/to/my\\ file.txt /other/path.txt', + (p) => validPaths.has(p), + ); + expect(result).toBe('@/path/to/my\\ file.txt @/other/path.txt '); + }); + + it('should unescape paths before validation', () => { + // Use Set to model reality: individual paths exist, combined string doesn't + const validPaths = new Set(['/my file.txt', '/other.txt']); + const validatedPaths: string[] = []; + parsePastedPaths('/my\\ file.txt /other.txt', (p) => { + validatedPaths.push(p); + return validPaths.has(p); + }); + // First checks entire string, then individual unescaped segments + expect(validatedPaths).toEqual([ + '/my\\ file.txt /other.txt', + '/my file.txt', + '/other.txt', + ]); + }); + + it('should handle single path with unescaped spaces from copy-paste', () => { + const result = parsePastedPaths('/path/to/my file.txt', () => true); + expect(result).toBe('@/path/to/my\\ file.txt '); + }); + + it('should handle Windows path', () => { + const result = parsePastedPaths('C:\\Users\\file.txt', () => true); + expect(result).toBe('@C:\\Users\\file.txt '); + }); + + it('should handle Windows path with unescaped spaces', () => { + const result = parsePastedPaths('C:\\My Documents\\file.txt', () => true); + expect(result).toBe('@C:\\My\\ Documents\\file.txt '); + }); + + it('should handle multiple Windows paths', () => { + const validPaths = new Set(['C:\\file1.txt', 'D:\\file2.txt']); + const result = parsePastedPaths('C:\\file1.txt D:\\file2.txt', (p) => + validPaths.has(p), + ); + expect(result).toBe('@C:\\file1.txt @D:\\file2.txt '); + }); + + it('should handle Windows UNC path', () => { + const result = parsePastedPaths( + '\\\\server\\share\\file.txt', + () => true, + ); + expect(result).toBe('@\\\\server\\share\\file.txt '); + }); + }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index b4760ca722..91a657aca0 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -6,7 +6,12 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import { debugLogger, spawnAsync } from '@google/gemini-cli-core'; +import { + debugLogger, + spawnAsync, + unescapePath, + escapePath, +} from '@google/gemini-cli-core'; /** * Supported image file extensions based on Gemini API. @@ -21,6 +26,9 @@ export const IMAGE_EXTENSIONS = [ '.heif', ]; +/** Matches strings that start with a path prefix (/, ~, ., Windows drive letter, or UNC path) */ +const PATH_PREFIX_PATTERN = /^([/~.]|[a-zA-Z]:|\\\\)/; + /** * Checks if the system clipboard contains an image (macOS only for now) * @returns true if clipboard contains an image @@ -101,16 +109,18 @@ export async function saveClipboardImage( if (stats.size > 0) { return tempFilePath; } - } catch { + } catch (e) { // File doesn't exist, continue to next format + debugLogger.debug('Clipboard image file not found:', tempFilePath, e); } } // Clean up failed attempt try { await fs.unlink(tempFilePath); - } catch { + } catch (e) { // Ignore cleanup errors + debugLogger.debug('Failed to clean up temp file:', tempFilePath, e); } } @@ -146,7 +156,91 @@ export async function cleanupOldClipboardImages( } } } - } catch { + } catch (e) { // Ignore errors in cleanup + debugLogger.debug('Failed to clean up old clipboard images:', e); } } + +/** + * Splits text into individual path segments, respecting escaped spaces. + * Unescaped spaces act as separators between paths, while "\ " is preserved + * as part of a filename. + * + * Example: "/img1.png /path/my\ image.png" → ["/img1.png", "/path/my\ image.png"] + * + * @param text The text to split + * @returns Array of path segments (still escaped) + */ +export function splitEscapedPaths(text: string): string[] { + const paths: string[] = []; + let current = ''; + let i = 0; + + while (i < text.length) { + const char = text[i]; + + if (char === '\\' && i + 1 < text.length && text[i + 1] === ' ') { + // Escaped space - part of filename, preserve the escape sequence + current += '\\ '; + i += 2; + } else if (char === ' ') { + // Unescaped space - path separator + if (current.trim()) { + paths.push(current.trim()); + } + current = ''; + i++; + } else { + current += char; + i++; + } + } + + // Don't forget the last segment + if (current.trim()) { + paths.push(current.trim()); + } + + return paths; +} + +/** + * Processes pasted text containing file paths, adding @ prefix to valid paths. + * Handles both single and multiple space-separated paths. + * + * @param text The pasted text (potentially space-separated paths) + * @param isValidPath Function to validate if a path exists/is valid + * @returns Processed string with @ prefixes on valid paths, or null if no valid paths + */ +export function parsePastedPaths( + text: string, + isValidPath: (path: string) => boolean, +): string | null { + // First, check if the entire text is a single valid path + if (PATH_PREFIX_PATTERN.test(text) && isValidPath(text)) { + return `@${escapePath(text)} `; + } + + // Otherwise, try splitting on unescaped spaces + const segments = splitEscapedPaths(text); + if (segments.length === 0) { + return null; + } + + let anyValidPath = false; + const processedPaths = segments.map((segment) => { + // Quick rejection: skip segments that can't be paths + if (!PATH_PREFIX_PATTERN.test(segment)) { + return segment; + } + const unescaped = unescapePath(segment); + if (isValidPath(unescaped)) { + anyValidPath = true; + return `@${segment}`; + } + return segment; + }); + + return anyValidPath ? processedPaths.join(' ') + ' ' : null; +}