From 7fdd52c932ef6a9268ad7ea88f9a2c08c4dee25e Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 9 Dec 2025 10:26:57 -0500 Subject: [PATCH 1/7] feat: support multi-file drag and drop --- .../ui/components/shared/text-buffer.test.ts | 36 ++ .../src/ui/components/shared/text-buffer.ts | 20 +- .../cli/src/ui/utils/clipboardUtils.test.ts | 315 ++++++++++++++++++ packages/cli/src/ui/utils/clipboardUtils.ts | 217 +++++++++++- 4 files changed, 586 insertions(+), 2 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 72bb567fffc..bd31ef2929d 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,42 @@ 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', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + 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', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + 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 e9a19652bc6..01c050bf100 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -17,6 +17,7 @@ import { stripUnsafeCharacters, getCachedStringWidth, } from '../../utils/textUtils.js'; +import { splitEscapedPaths } 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,7 +1676,24 @@ export function useTextBuffer({ } potentialPath = potentialPath.trim(); - if (isValidPath(unescapePath(potentialPath))) { + + // Check for multiple space-separated paths + const segments = splitEscapedPaths(potentialPath); + if (segments.length > 1) { + // Multiple paths - validate each and add @ prefix to valid ones + let anyValidPath = false; + const processedPaths = segments.map((segment) => { + const unescaped = unescapePath(segment); + if (isValidPath(unescaped)) { + anyValidPath = true; + return `@${segment}`; + } + return segment; + }); + if (anyValidPath) { + ch = processedPaths.join(' ') + ' '; + } + } else if (isValidPath(unescapePath(potentialPath))) { ch = `@${potentialPath} `; } } diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 30258889edb..2939e778c8e 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -9,6 +9,11 @@ import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, + getImagePathFromText, + looksLikeImagePath, + splitEscapedPaths, + looksLikeMultipleImagePaths, + getMultipleImagePathsFromText, } from './clipboardUtils.js'; describe('clipboardUtils', () => { @@ -73,4 +78,314 @@ describe('clipboardUtils', () => { await expect(cleanupOldClipboardImages('.')).resolves.not.toThrow(); }); }); + + describe('getImagePathFromText', () => { + it('should return null for non-path strings', async () => { + expect(await getImagePathFromText('hello world')).toBe(null); + expect(await getImagePathFromText('not a path')).toBe(null); + expect(await getImagePathFromText('')).toBe(null); + }); + + it('should return null for non-image file paths', async () => { + expect(await getImagePathFromText('/path/to/file.txt')).toBe(null); + expect(await getImagePathFromText('./script.js')).toBe(null); + expect(await getImagePathFromText('~/document.pdf')).toBe(null); + }); + + it('should return null for non-existent image paths', async () => { + expect(await getImagePathFromText('/nonexistent/image.png')).toBe(null); + expect(await getImagePathFromText('./fake/photo.jpg')).toBe(null); + expect(await getImagePathFromText('~/missing/image.heic')).toBe(null); + }); + + it('should recognize various image extensions', async () => { + // These should return null because files don't exist, + // but they should pass the extension check + // Based on Gemini API supported formats: PNG, JPEG, WEBP, HEIC, HEIF + const extensions = ['.png', '.jpg', '.jpeg', '.webp', '.heic', '.heif']; + for (const ext of extensions) { + const result = await getImagePathFromText(`/fake/image${ext}`); + // Should return null because file doesn't exist, not because extension is wrong + expect(result).toBe(null); + } + }); + + it('should handle paths starting with different prefixes', async () => { + // All should return null because files don't exist + expect(await getImagePathFromText('/absolute/path/image.png')).toBe(null); + expect(await getImagePathFromText('./relative/path/image.png')).toBe( + null, + ); + expect(await getImagePathFromText('~/home/path/image.png')).toBe(null); + }); + + it('should return null for non-image extensions', async () => { + const result = await getImagePathFromText('./package.json'); + expect(result).toBe(null); + }); + + it('should trim whitespace from input', async () => { + expect(await getImagePathFromText(' /path/to/image.png ')).toBe(null); + expect(await getImagePathFromText('\n/path/to/image.png\n')).toBe(null); + }); + + it('should handle @ prefix from drag-and-drop', async () => { + // Drag-and-drop in Gemini CLI adds @ prefix + expect(await getImagePathFromText('@/nonexistent/image.png')).toBe(null); + expect(await getImagePathFromText('@./fake/photo.jpg')).toBe(null); + expect(await getImagePathFromText('@~/missing/image.heic')).toBe(null); + }); + + it('should handle escaped spaces from drag-and-drop', async () => { + // Drag-and-drop escapes spaces as "\ " + expect(await getImagePathFromText('@/path/to/my\\ image.png')).toBe(null); + expect( + await getImagePathFromText( + '@/Users/test/Screenshot\\ 2025-12-06\\ at\\ 6.31.06\\ PM.png', + ), + ).toBe(null); + }); + + it('should reject non-image files even with @ prefix', async () => { + expect(await getImagePathFromText('@/path/to/file.txt')).toBe(null); + expect(await getImagePathFromText('@./script.js')).toBe(null); + }); + }); + + describe('looksLikeImagePath', () => { + it('should return false for non-path strings', () => { + expect(looksLikeImagePath('hello world')).toBe(false); + expect(looksLikeImagePath('not a path')).toBe(false); + expect(looksLikeImagePath('')).toBe(false); + }); + + it('should return false for non-image file paths', () => { + expect(looksLikeImagePath('/path/to/file.txt')).toBe(false); + expect(looksLikeImagePath('./script.js')).toBe(false); + expect(looksLikeImagePath('~/document.pdf')).toBe(false); + }); + + it('should return true for paths with image extensions', () => { + // Based on Gemini API supported formats: PNG, JPEG, WEBP, HEIC, HEIF + expect(looksLikeImagePath('/path/to/image.png')).toBe(true); + expect(looksLikeImagePath('./photo.jpg')).toBe(true); + expect(looksLikeImagePath('/file.webp')).toBe(true); + expect(looksLikeImagePath('/file.jpeg')).toBe(true); + expect(looksLikeImagePath('/file.heic')).toBe(true); + expect(looksLikeImagePath('/file.heif')).toBe(true); + }); + + it('should return false for unsupported image formats', () => { + // GIF, TIFF, BMP are NOT supported by Gemini API + expect(looksLikeImagePath('~/screenshot.gif')).toBe(false); + expect(looksLikeImagePath('/file.bmp')).toBe(false); + expect(looksLikeImagePath('/file.tiff')).toBe(false); + }); + + it('should return true for @ prefixed image paths', () => { + expect(looksLikeImagePath('@/path/to/image.png')).toBe(true); + expect(looksLikeImagePath('@./photo.jpg')).toBe(true); + expect(looksLikeImagePath('@~/screenshot.heic')).toBe(true); + }); + + it('should return true for paths with escaped spaces', () => { + expect(looksLikeImagePath('@/path/to/my\\ image.png')).toBe(true); + expect( + looksLikeImagePath( + '@/Users/test/Screenshot\\ 2025-12-06\\ at\\ 6.31.06\\ PM.png', + ), + ).toBe(true); + }); + + it('should be synchronous and fast for normal text', () => { + // This test ensures the function is suitable for use in synchronous code paths + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + looksLikeImagePath('hello world this is normal text'); + looksLikeImagePath('const x = 5; function foo() {}'); + looksLikeImagePath('https://example.com/image.png'); + } + const duration = performance.now() - start; + // Should complete 3000 calls in well under 100ms + expect(duration).toBeLessThan(100); + }); + }); + + 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('looksLikeMultipleImagePaths', () => { + it('should return true for single image path', () => { + expect(looksLikeMultipleImagePaths('/path/image.png')).toBe(true); + }); + + it('should return true for multiple image paths', () => { + expect(looksLikeMultipleImagePaths('/img1.png /img2.jpg')).toBe(true); + }); + + it('should return true for @ prefixed paths', () => { + expect(looksLikeMultipleImagePaths('@/img1.png /img2.png')).toBe(true); + }); + + it('should return true if any path has image extension', () => { + // Mixed image and non-image - should return true because .png is present + expect(looksLikeMultipleImagePaths('/img.png /file.txt')).toBe(true); + }); + + it('should return false for non-path text', () => { + expect(looksLikeMultipleImagePaths('hello world')).toBe(false); + }); + + it('should return false for paths without image extensions', () => { + expect(looksLikeMultipleImagePaths('/file.txt /doc.pdf')).toBe(false); + }); + + it('should return false for empty string', () => { + expect(looksLikeMultipleImagePaths('')).toBe(false); + }); + + it('should handle paths with escaped spaces', () => { + expect( + looksLikeMultipleImagePaths('/my\\ image.png /other\\ pic.jpg'), + ).toBe(true); + }); + + it('should be fast for normal text', () => { + const start = performance.now(); + for (let i = 0; i < 1000; i++) { + looksLikeMultipleImagePaths('hello world this is normal text'); + looksLikeMultipleImagePaths('const x = 5; function foo() {}'); + } + const duration = performance.now() - start; + expect(duration).toBeLessThan(100); + }); + }); + + describe('getMultipleImagePathsFromText', () => { + it('should return empty arrays for non-path text', async () => { + const result = await getMultipleImagePathsFromText('hello world'); + expect(result.validPaths).toEqual([]); + expect(result.invalidSegments).toEqual([]); + }); + + it('should put non-existent image paths in invalidSegments', async () => { + const result = await getMultipleImagePathsFromText( + '/nonexistent/image.png', + ); + expect(result.validPaths).toEqual([]); + expect(result.invalidSegments).toEqual(['/nonexistent/image.png']); + }); + + it('should put non-image extensions in invalidSegments', async () => { + const result = await getMultipleImagePathsFromText('/path/to/file.txt'); + expect(result.validPaths).toEqual([]); + expect(result.invalidSegments).toEqual(['/path/to/file.txt']); + }); + + it('should handle multiple non-existent paths', async () => { + const result = await getMultipleImagePathsFromText( + '/fake1.png /fake2.jpg', + ); + expect(result.validPaths).toEqual([]); + expect(result.invalidSegments).toEqual(['/fake1.png', '/fake2.jpg']); + }); + + it('should handle mix of image and non-image paths', async () => { + const result = await getMultipleImagePathsFromText( + '/fake.png /file.txt /another.jpg', + ); + expect(result.validPaths).toEqual([]); + // .png and .jpg are images (but don't exist), .txt is not an image + expect(result.invalidSegments).toContain('/file.txt'); + expect(result.invalidSegments).toContain('/fake.png'); + expect(result.invalidSegments).toContain('/another.jpg'); + }); + + it('should strip @ prefix from first path', async () => { + const result = await getMultipleImagePathsFromText( + '@/fake1.png /fake2.png', + ); + // Both should fail because files don't exist + expect(result.validPaths).toEqual([]); + // The @ should be stripped from the first segment + expect(result.invalidSegments).toEqual(['/fake1.png', '/fake2.png']); + }); + + it('should handle paths with escaped spaces', async () => { + const result = await getMultipleImagePathsFromText( + '/my\\ image.png /other\\ pic.jpg', + ); + expect(result.validPaths).toEqual([]); + // Original escaped segments should be preserved in invalidSegments + expect(result.invalidSegments).toEqual([ + '/my\\ image.png', + '/other\\ pic.jpg', + ]); + }); + + it('should return empty for empty string', async () => { + const result = await getMultipleImagePathsFromText(''); + expect(result.validPaths).toEqual([]); + expect(result.invalidSegments).toEqual([]); + }); + }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index b4760ca7221..cc074a22a52 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -5,8 +5,9 @@ */ import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; import * as path from 'node:path'; -import { debugLogger, spawnAsync } from '@google/gemini-cli-core'; +import { debugLogger, spawnAsync, unescapePath } from '@google/gemini-cli-core'; /** * Supported image file extensions based on Gemini API. @@ -150,3 +151,217 @@ export async function cleanupOldClipboardImages( // Ignore errors in cleanup } } + +/** + * Parses text that might be an image file path. + * Handles paths with @ prefix and escaped spaces (e.g., @/path/to/file\ name.png) + * @param text The text to check (typically pasted content) + * @returns The resolved absolute path if it looks like an image path, null otherwise + */ +function parseImagePath(text: string): string | null { + let trimmed = text.trim(); + + // Remove @ prefix if present (drag-and-drop in Gemini CLI adds @) + if (trimmed.startsWith('@')) { + trimmed = trimmed.slice(1); + } + + // Check if it looks like a file path (starts with / or ~ or .) + if (!trimmed.match(/^[/~.]/)) { + return null; + } + + // Unescape spaces (drag-and-drop escapes spaces as "\ ") + const unescapedPath = trimmed.replace(/\\ /g, ' '); + + // Check if it has an image extension + const lowerPath = unescapedPath.toLowerCase(); + const hasImageExtension = IMAGE_EXTENSIONS.some((ext) => + lowerPath.endsWith(ext), + ); + if (!hasImageExtension) { + return null; + } + + // Resolve the path (handle ~ for home directory) + let absolutePath = unescapedPath; + if (unescapedPath.startsWith('~')) { + absolutePath = path.join(os.homedir(), unescapedPath.slice(1)); + } else if (!path.isAbsolute(unescapedPath)) { + absolutePath = path.resolve(unescapedPath); + } + + return absolutePath; +} + +/** + * Synchronously checks if text looks like an image file path (without verifying existence). + * Use this for fast rejection of non-image text before doing async file checks. + * @param text The text to check + * @returns true if the text could be an image path based on format and extension + */ +export function looksLikeImagePath(text: string): boolean { + return parseImagePath(text) !== null; +} + +/** + * Checks if a string looks like an image file path and the file exists. + * Used for detecting drag-and-drop image files in the terminal. + * Handles paths with @ prefix and escaped spaces (e.g., @/path/to/file\ name.png) + * @param text The text to check (typically pasted content) + * @returns The absolute path if valid image file, null otherwise + */ +export async function getImagePathFromText( + text: string, +): Promise { + const absolutePath = parseImagePath(text); + if (!absolutePath) { + return null; + } + + // Check if file exists + try { + await fs.access(absolutePath); + return absolutePath; + } catch { + return null; + } +} + +/** + * 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; +} + +/** + * Synchronously checks if text could contain one or more image file paths. + * Use this for fast rejection of non-image text before doing async file checks. + * + * @param text The text to check + * @returns true if text could be image path(s) based on format and extension + */ +export function looksLikeMultipleImagePaths(text: string): boolean { + let trimmed = text.trim(); + + // Remove @ prefix if present (drag-and-drop adds @) + if (trimmed.startsWith('@')) { + trimmed = trimmed.slice(1); + } + + // Must start with a path prefix + if (!trimmed.match(/^[/~.]/)) { + return false; + } + + // Must contain at least one image extension + const lower = trimmed.toLowerCase(); + return IMAGE_EXTENSIONS.some((ext) => lower.includes(ext)); +} + +/** + * Validates multiple image paths from drag-and-drop text, checking file existence. + * Returns valid paths for images that exist, and original segments for invalid ones. + * + * @param text The pasted text (potentially space-separated paths) + * @returns Object with validPaths (absolute paths to existing images) and + * invalidSegments (original escaped text for non-images or missing files) + */ +export async function getMultipleImagePathsFromText(text: string): Promise<{ + validPaths: string[]; + invalidSegments: string[]; +}> { + let trimmed = text.trim(); + + // Remove @ prefix from first path if present + if (trimmed.startsWith('@')) { + trimmed = trimmed.slice(1); + } + + // Quick check: if text doesn't look like path(s), return empty + if (!trimmed.match(/^[/~.]/)) { + return { validPaths: [], invalidSegments: [] }; + } + + const segments = splitEscapedPaths(trimmed); + const validPaths: string[] = []; + const invalidSegments: string[] = []; + + // Process segments in parallel for performance + const results = await Promise.all( + segments.map(async (segment) => { + // Use core's unescapePath for consistency with rest of codebase + const unescaped = unescapePath(segment); + const lower = unescaped.toLowerCase(); + + // Non-image extension → keep as raw text + if (!IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))) { + return { type: 'invalid' as const, segment }; + } + + // Resolve to absolute path + let absolute = unescaped; + if (unescaped.startsWith('~')) { + absolute = path.join(os.homedir(), unescaped.slice(1)); + } else if (!path.isAbsolute(unescaped)) { + absolute = path.resolve(unescaped); + } + + // Check file existence + try { + await fs.access(absolute); + return { type: 'valid' as const, path: absolute }; + } catch { + return { type: 'invalid' as const, segment }; + } + }), + ); + + // Collect results maintaining order + for (const result of results) { + if (result.type === 'valid') { + validPaths.push(result.path); + } else { + invalidSegments.push(result.segment); + } + } + + return { validPaths, invalidSegments }; +} From f610819d3610ba6b4ede11f08cbce52c9ef799f3 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 9 Dec 2025 11:39:46 -0500 Subject: [PATCH 2/7] chore: review comments --- packages/cli/pr-multi-file-drag-drop.md | 25 ++++++ .../src/ui/components/shared/text-buffer.ts | 25 ++---- .../cli/src/ui/utils/clipboardUtils.test.ts | 82 +++++++++++++------ packages/cli/src/ui/utils/clipboardUtils.ts | 53 ++++++++++-- 4 files changed, 133 insertions(+), 52 deletions(-) create mode 100644 packages/cli/pr-multi-file-drag-drop.md diff --git a/packages/cli/pr-multi-file-drag-drop.md b/packages/cli/pr-multi-file-drag-drop.md new file mode 100644 index 00000000000..48e6a48fda2 --- /dev/null +++ b/packages/cli/pr-multi-file-drag-drop.md @@ -0,0 +1,25 @@ +## Summary + +Adds support for dragging and dropping multiple files into the terminal at once, +automatically adding `@` prefix to each valid path. + +## Changes + +- Add `splitEscapedPaths` utility to parse space-separated paths while + preserving escaped spaces in filenames +- Add path validation utilities (`looksLikeImagePath`, `getImagePathFromText`, + `looksLikeMultipleImagePaths`, `getMultipleImagePathsFromText`) +- Update `text-buffer.ts` to handle multiple paths in drag-and-drop operations +- Add comprehensive tests for all new functionality + +## Behavior + +**Before:** Dragging and dropping multiple files → inserted as +`/path/img1.png /path/img2.png` + +**After:** Dragging and dropping multiple files → becomes +`@/path/img1.png @/path/img2.png` + +- Only valid paths get the `@` prefix +- Escaped spaces in filenames are preserved (e.g., `/my\ image.png`) +- Normal text input is unaffected diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 01c050bf100..fd3961d313d 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,7 +17,7 @@ import { stripUnsafeCharacters, getCachedStringWidth, } from '../../utils/textUtils.js'; -import { splitEscapedPaths } from '../../utils/clipboardUtils.js'; +import { processPastedPaths } 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'; @@ -1677,24 +1677,9 @@ export function useTextBuffer({ potentialPath = potentialPath.trim(); - // Check for multiple space-separated paths - const segments = splitEscapedPaths(potentialPath); - if (segments.length > 1) { - // Multiple paths - validate each and add @ prefix to valid ones - let anyValidPath = false; - const processedPaths = segments.map((segment) => { - const unescaped = unescapePath(segment); - if (isValidPath(unescaped)) { - anyValidPath = true; - return `@${segment}`; - } - return segment; - }); - if (anyValidPath) { - ch = processedPaths.join(' ') + ' '; - } - } else if (isValidPath(unescapePath(potentialPath))) { - ch = `@${potentialPath} `; + const processed = processPastedPaths(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 2939e778c8e..a83b5455b7d 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -14,6 +14,7 @@ import { splitEscapedPaths, looksLikeMultipleImagePaths, getMultipleImagePathsFromText, + processPastedPaths, } from './clipboardUtils.js'; describe('clipboardUtils', () => { @@ -196,19 +197,6 @@ describe('clipboardUtils', () => { ), ).toBe(true); }); - - it('should be synchronous and fast for normal text', () => { - // This test ensures the function is suitable for use in synchronous code paths - const start = performance.now(); - for (let i = 0; i < 1000; i++) { - looksLikeImagePath('hello world this is normal text'); - looksLikeImagePath('const x = 5; function foo() {}'); - looksLikeImagePath('https://example.com/image.png'); - } - const duration = performance.now() - start; - // Should complete 3000 calls in well under 100ms - expect(duration).toBeLessThan(100); - }); }); describe('splitEscapedPaths', () => { @@ -308,16 +296,6 @@ describe('clipboardUtils', () => { looksLikeMultipleImagePaths('/my\\ image.png /other\\ pic.jpg'), ).toBe(true); }); - - it('should be fast for normal text', () => { - const start = performance.now(); - for (let i = 0; i < 1000; i++) { - looksLikeMultipleImagePaths('hello world this is normal text'); - looksLikeMultipleImagePaths('const x = 5; function foo() {}'); - } - const duration = performance.now() - start; - expect(duration).toBeLessThan(100); - }); }); describe('getMultipleImagePathsFromText', () => { @@ -388,4 +366,62 @@ describe('clipboardUtils', () => { expect(result.invalidSegments).toEqual([]); }); }); + + describe('processPastedPaths', () => { + it('should return null for empty string', () => { + const result = processPastedPaths('', () => true); + expect(result).toBe(null); + }); + + it('should add @ prefix to single valid path', () => { + const result = processPastedPaths('/path/to/file.txt', () => true); + expect(result).toBe('@/path/to/file.txt '); + }); + + it('should return null for single invalid path', () => { + const result = processPastedPaths('/path/to/file.txt', () => false); + expect(result).toBe(null); + }); + + it('should add @ prefix to all valid paths', () => { + const result = processPastedPaths( + '/path/to/file1.txt /path/to/file2.txt', + () => true, + ); + expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt '); + }); + + it('should only add @ prefix to valid paths', () => { + const result = processPastedPaths( + '/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 = processPastedPaths( + '/path/to/file1.txt /path/to/file2.txt', + () => false, + ); + expect(result).toBe(null); + }); + + it('should handle paths with escaped spaces', () => { + const result = processPastedPaths( + '/path/to/my\\ file.txt /other/path.txt', + () => true, + ); + expect(result).toBe('@/path/to/my\\ file.txt @/other/path.txt '); + }); + + it('should unescape paths before validation', () => { + const validatedPaths: string[] = []; + processPastedPaths('/my\\ file.txt /other.txt', (p) => { + validatedPaths.push(p); + return true; + }); + expect(validatedPaths).toEqual(['/my file.txt', '/other.txt']); + }); + }); }); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index cc074a22a52..0a158bea441 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -22,6 +22,9 @@ export const IMAGE_EXTENSIONS = [ '.heif', ]; +/** Matches strings that start with a path prefix (/, ~, or .) */ +const PATH_PREFIX_PATTERN = /^[/~.]/; + /** * Checks if the system clipboard contains an image (macOS only for now) * @returns true if clipboard contains an image @@ -102,16 +105,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); } } @@ -147,8 +152,9 @@ export async function cleanupOldClipboardImages( } } } - } catch { + } catch (e) { // Ignore errors in cleanup + debugLogger.debug('Failed to clean up old clipboard images:', e); } } @@ -166,13 +172,12 @@ function parseImagePath(text: string): string | null { trimmed = trimmed.slice(1); } - // Check if it looks like a file path (starts with / or ~ or .) - if (!trimmed.match(/^[/~.]/)) { + // Must start with a path prefix + if (!PATH_PREFIX_PATTERN.test(trimmed)) { return null; } - // Unescape spaces (drag-and-drop escapes spaces as "\ ") - const unescapedPath = trimmed.replace(/\\ /g, ' '); + const unescapedPath = unescapePath(trimmed); // Check if it has an image extension const lowerPath = unescapedPath.toLowerCase(); @@ -287,7 +292,7 @@ export function looksLikeMultipleImagePaths(text: string): boolean { } // Must start with a path prefix - if (!trimmed.match(/^[/~.]/)) { + if (!PATH_PREFIX_PATTERN.test(trimmed)) { return false; } @@ -316,7 +321,7 @@ export async function getMultipleImagePathsFromText(text: string): Promise<{ } // Quick check: if text doesn't look like path(s), return empty - if (!trimmed.match(/^[/~.]/)) { + if (!PATH_PREFIX_PATTERN.test(trimmed)) { return { validPaths: [], invalidSegments: [] }; } @@ -365,3 +370,33 @@ export async function getMultipleImagePathsFromText(text: string): Promise<{ return { validPaths, invalidSegments }; } + +/** + * 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 processPastedPaths( + text: string, + isValidPath: (path: string) => boolean, +): string | null { + const segments = splitEscapedPaths(text); + if (segments.length === 0) { + return null; + } + + let anyValidPath = false; + const processedPaths = segments.map((segment) => { + const unescaped = unescapePath(segment); + if (isValidPath(unescaped)) { + anyValidPath = true; + return `@${segment}`; + } + return segment; + }); + + return anyValidPath ? processedPaths.join(' ') + ' ' : null; +} From 5f09e497e1621c88c89df75a7059a7d6e44ac6bf Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 9 Dec 2025 12:05:40 -0500 Subject: [PATCH 3/7] chore: update for Windows support --- packages/cli/src/ui/utils/clipboardUtils.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 0a158bea441..c19ee794c04 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -22,8 +22,8 @@ export const IMAGE_EXTENSIONS = [ '.heif', ]; -/** Matches strings that start with a path prefix (/, ~, or .) */ -const PATH_PREFIX_PATTERN = /^[/~.]/; +/** Matches strings that start with a path prefix (/, ~, ., or Windows drive letter) */ +const PATH_PREFIX_PATTERN = /^([/~.]|[a-zA-Z]:)/; /** * Checks if the system clipboard contains an image (macOS only for now) @@ -390,6 +390,10 @@ export function processPastedPaths( 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; From 7e1b5b24b80d7b6ad421929bb570483ec10efe33 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Tue, 9 Dec 2025 12:07:44 -0500 Subject: [PATCH 4/7] chore: remove md --- packages/cli/pr-multi-file-drag-drop.md | 25 ------------------------- 1 file changed, 25 deletions(-) delete mode 100644 packages/cli/pr-multi-file-drag-drop.md diff --git a/packages/cli/pr-multi-file-drag-drop.md b/packages/cli/pr-multi-file-drag-drop.md deleted file mode 100644 index 48e6a48fda2..00000000000 --- a/packages/cli/pr-multi-file-drag-drop.md +++ /dev/null @@ -1,25 +0,0 @@ -## Summary - -Adds support for dragging and dropping multiple files into the terminal at once, -automatically adding `@` prefix to each valid path. - -## Changes - -- Add `splitEscapedPaths` utility to parse space-separated paths while - preserving escaped spaces in filenames -- Add path validation utilities (`looksLikeImagePath`, `getImagePathFromText`, - `looksLikeMultipleImagePaths`, `getMultipleImagePathsFromText`) -- Update `text-buffer.ts` to handle multiple paths in drag-and-drop operations -- Add comprehensive tests for all new functionality - -## Behavior - -**Before:** Dragging and dropping multiple files → inserted as -`/path/img1.png /path/img2.png` - -**After:** Dragging and dropping multiple files → becomes -`@/path/img1.png @/path/img2.png` - -- Only valid paths get the `@` prefix -- Escaped spaces in filenames are preserved (e.g., `/my\ image.png`) -- Normal text input is unaffected From d8d29c9b7657f2d73d2435d41a5ad5590b2b60cd Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 10 Dec 2025 09:54:04 -0500 Subject: [PATCH 5/7] chore: more robust windows support --- .../ui/components/shared/text-buffer.test.ts | 8 ++- .../cli/src/ui/utils/clipboardUtils.test.ts | 53 +++++++++++++++++-- packages/cli/src/ui/utils/clipboardUtils.ts | 17 ++++-- 3 files changed, 69 insertions(+), 9 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 bd31ef2929d..f26608aabe4 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -639,8 +639,10 @@ describe('useTextBuffer', () => { }); 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: () => true }), + useTextBuffer({ viewport, isValidPath: (p) => validPaths.has(p) }), ); const filePaths = '/path/to/file1.txt /path/to/file2.txt'; act(() => result.current.insert(filePaths, { paste: true })); @@ -650,8 +652,10 @@ describe('useTextBuffer', () => { }); 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: () => true }), + useTextBuffer({ viewport, isValidPath: (p) => validPaths.has(p) }), ); const filePaths = '/path/to/my\\ file.txt /other/path.txt'; act(() => result.current.insert(filePaths, { paste: true })); diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index a83b5455b7d..63bbf7b10e0 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -384,9 +384,11 @@ describe('clipboardUtils', () => { }); 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 = processPastedPaths( '/path/to/file1.txt /path/to/file2.txt', - () => true, + (p) => validPaths.has(p), ); expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt '); }); @@ -408,20 +410,63 @@ describe('clipboardUtils', () => { }); 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 = processPastedPaths( '/path/to/my\\ file.txt /other/path.txt', - () => true, + (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[] = []; processPastedPaths('/my\\ file.txt /other.txt', (p) => { validatedPaths.push(p); - return true; + return validPaths.has(p); }); - expect(validatedPaths).toEqual(['/my file.txt', '/other.txt']); + // 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 = processPastedPaths('/path/to/my file.txt', () => true); + expect(result).toBe('@/path/to/my\\ file.txt '); + }); + + it('should handle Windows path', () => { + const result = processPastedPaths('C:\\Users\\file.txt', () => true); + expect(result).toBe('@C:\\Users\\file.txt '); + }); + + it('should handle Windows path with unescaped spaces', () => { + const result = processPastedPaths( + '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 = processPastedPaths('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 = processPastedPaths( + '\\\\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 c19ee794c04..4330fd61875 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -7,7 +7,12 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; -import { debugLogger, spawnAsync, unescapePath } from '@google/gemini-cli-core'; +import { + debugLogger, + spawnAsync, + unescapePath, + escapePath, +} from '@google/gemini-cli-core'; /** * Supported image file extensions based on Gemini API. @@ -22,8 +27,8 @@ export const IMAGE_EXTENSIONS = [ '.heif', ]; -/** Matches strings that start with a path prefix (/, ~, ., or Windows drive letter) */ -const PATH_PREFIX_PATTERN = /^([/~.]|[a-zA-Z]:)/; +/** 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) @@ -383,6 +388,12 @@ export function processPastedPaths( 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; From a5a501e315b7a504ffccd641ae2fc889b2b05ef2 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 10 Dec 2025 10:45:58 -0500 Subject: [PATCH 6/7] chore: cleanup dead code --- .../cli/src/ui/utils/clipboardUtils.test.ts | 229 ------------------ packages/cli/src/ui/utils/clipboardUtils.ts | 171 ------------- 2 files changed, 400 deletions(-) diff --git a/packages/cli/src/ui/utils/clipboardUtils.test.ts b/packages/cli/src/ui/utils/clipboardUtils.test.ts index 63bbf7b10e0..11a74e873f9 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -9,11 +9,7 @@ import { clipboardHasImage, saveClipboardImage, cleanupOldClipboardImages, - getImagePathFromText, - looksLikeImagePath, splitEscapedPaths, - looksLikeMultipleImagePaths, - getMultipleImagePathsFromText, processPastedPaths, } from './clipboardUtils.js'; @@ -80,125 +76,6 @@ describe('clipboardUtils', () => { }); }); - describe('getImagePathFromText', () => { - it('should return null for non-path strings', async () => { - expect(await getImagePathFromText('hello world')).toBe(null); - expect(await getImagePathFromText('not a path')).toBe(null); - expect(await getImagePathFromText('')).toBe(null); - }); - - it('should return null for non-image file paths', async () => { - expect(await getImagePathFromText('/path/to/file.txt')).toBe(null); - expect(await getImagePathFromText('./script.js')).toBe(null); - expect(await getImagePathFromText('~/document.pdf')).toBe(null); - }); - - it('should return null for non-existent image paths', async () => { - expect(await getImagePathFromText('/nonexistent/image.png')).toBe(null); - expect(await getImagePathFromText('./fake/photo.jpg')).toBe(null); - expect(await getImagePathFromText('~/missing/image.heic')).toBe(null); - }); - - it('should recognize various image extensions', async () => { - // These should return null because files don't exist, - // but they should pass the extension check - // Based on Gemini API supported formats: PNG, JPEG, WEBP, HEIC, HEIF - const extensions = ['.png', '.jpg', '.jpeg', '.webp', '.heic', '.heif']; - for (const ext of extensions) { - const result = await getImagePathFromText(`/fake/image${ext}`); - // Should return null because file doesn't exist, not because extension is wrong - expect(result).toBe(null); - } - }); - - it('should handle paths starting with different prefixes', async () => { - // All should return null because files don't exist - expect(await getImagePathFromText('/absolute/path/image.png')).toBe(null); - expect(await getImagePathFromText('./relative/path/image.png')).toBe( - null, - ); - expect(await getImagePathFromText('~/home/path/image.png')).toBe(null); - }); - - it('should return null for non-image extensions', async () => { - const result = await getImagePathFromText('./package.json'); - expect(result).toBe(null); - }); - - it('should trim whitespace from input', async () => { - expect(await getImagePathFromText(' /path/to/image.png ')).toBe(null); - expect(await getImagePathFromText('\n/path/to/image.png\n')).toBe(null); - }); - - it('should handle @ prefix from drag-and-drop', async () => { - // Drag-and-drop in Gemini CLI adds @ prefix - expect(await getImagePathFromText('@/nonexistent/image.png')).toBe(null); - expect(await getImagePathFromText('@./fake/photo.jpg')).toBe(null); - expect(await getImagePathFromText('@~/missing/image.heic')).toBe(null); - }); - - it('should handle escaped spaces from drag-and-drop', async () => { - // Drag-and-drop escapes spaces as "\ " - expect(await getImagePathFromText('@/path/to/my\\ image.png')).toBe(null); - expect( - await getImagePathFromText( - '@/Users/test/Screenshot\\ 2025-12-06\\ at\\ 6.31.06\\ PM.png', - ), - ).toBe(null); - }); - - it('should reject non-image files even with @ prefix', async () => { - expect(await getImagePathFromText('@/path/to/file.txt')).toBe(null); - expect(await getImagePathFromText('@./script.js')).toBe(null); - }); - }); - - describe('looksLikeImagePath', () => { - it('should return false for non-path strings', () => { - expect(looksLikeImagePath('hello world')).toBe(false); - expect(looksLikeImagePath('not a path')).toBe(false); - expect(looksLikeImagePath('')).toBe(false); - }); - - it('should return false for non-image file paths', () => { - expect(looksLikeImagePath('/path/to/file.txt')).toBe(false); - expect(looksLikeImagePath('./script.js')).toBe(false); - expect(looksLikeImagePath('~/document.pdf')).toBe(false); - }); - - it('should return true for paths with image extensions', () => { - // Based on Gemini API supported formats: PNG, JPEG, WEBP, HEIC, HEIF - expect(looksLikeImagePath('/path/to/image.png')).toBe(true); - expect(looksLikeImagePath('./photo.jpg')).toBe(true); - expect(looksLikeImagePath('/file.webp')).toBe(true); - expect(looksLikeImagePath('/file.jpeg')).toBe(true); - expect(looksLikeImagePath('/file.heic')).toBe(true); - expect(looksLikeImagePath('/file.heif')).toBe(true); - }); - - it('should return false for unsupported image formats', () => { - // GIF, TIFF, BMP are NOT supported by Gemini API - expect(looksLikeImagePath('~/screenshot.gif')).toBe(false); - expect(looksLikeImagePath('/file.bmp')).toBe(false); - expect(looksLikeImagePath('/file.tiff')).toBe(false); - }); - - it('should return true for @ prefixed image paths', () => { - expect(looksLikeImagePath('@/path/to/image.png')).toBe(true); - expect(looksLikeImagePath('@./photo.jpg')).toBe(true); - expect(looksLikeImagePath('@~/screenshot.heic')).toBe(true); - }); - - it('should return true for paths with escaped spaces', () => { - expect(looksLikeImagePath('@/path/to/my\\ image.png')).toBe(true); - expect( - looksLikeImagePath( - '@/Users/test/Screenshot\\ 2025-12-06\\ at\\ 6.31.06\\ PM.png', - ), - ).toBe(true); - }); - }); - describe('splitEscapedPaths', () => { it('should return single path when no spaces', () => { expect(splitEscapedPaths('/path/to/image.png')).toEqual([ @@ -261,112 +138,6 @@ describe('clipboardUtils', () => { }); }); - describe('looksLikeMultipleImagePaths', () => { - it('should return true for single image path', () => { - expect(looksLikeMultipleImagePaths('/path/image.png')).toBe(true); - }); - - it('should return true for multiple image paths', () => { - expect(looksLikeMultipleImagePaths('/img1.png /img2.jpg')).toBe(true); - }); - - it('should return true for @ prefixed paths', () => { - expect(looksLikeMultipleImagePaths('@/img1.png /img2.png')).toBe(true); - }); - - it('should return true if any path has image extension', () => { - // Mixed image and non-image - should return true because .png is present - expect(looksLikeMultipleImagePaths('/img.png /file.txt')).toBe(true); - }); - - it('should return false for non-path text', () => { - expect(looksLikeMultipleImagePaths('hello world')).toBe(false); - }); - - it('should return false for paths without image extensions', () => { - expect(looksLikeMultipleImagePaths('/file.txt /doc.pdf')).toBe(false); - }); - - it('should return false for empty string', () => { - expect(looksLikeMultipleImagePaths('')).toBe(false); - }); - - it('should handle paths with escaped spaces', () => { - expect( - looksLikeMultipleImagePaths('/my\\ image.png /other\\ pic.jpg'), - ).toBe(true); - }); - }); - - describe('getMultipleImagePathsFromText', () => { - it('should return empty arrays for non-path text', async () => { - const result = await getMultipleImagePathsFromText('hello world'); - expect(result.validPaths).toEqual([]); - expect(result.invalidSegments).toEqual([]); - }); - - it('should put non-existent image paths in invalidSegments', async () => { - const result = await getMultipleImagePathsFromText( - '/nonexistent/image.png', - ); - expect(result.validPaths).toEqual([]); - expect(result.invalidSegments).toEqual(['/nonexistent/image.png']); - }); - - it('should put non-image extensions in invalidSegments', async () => { - const result = await getMultipleImagePathsFromText('/path/to/file.txt'); - expect(result.validPaths).toEqual([]); - expect(result.invalidSegments).toEqual(['/path/to/file.txt']); - }); - - it('should handle multiple non-existent paths', async () => { - const result = await getMultipleImagePathsFromText( - '/fake1.png /fake2.jpg', - ); - expect(result.validPaths).toEqual([]); - expect(result.invalidSegments).toEqual(['/fake1.png', '/fake2.jpg']); - }); - - it('should handle mix of image and non-image paths', async () => { - const result = await getMultipleImagePathsFromText( - '/fake.png /file.txt /another.jpg', - ); - expect(result.validPaths).toEqual([]); - // .png and .jpg are images (but don't exist), .txt is not an image - expect(result.invalidSegments).toContain('/file.txt'); - expect(result.invalidSegments).toContain('/fake.png'); - expect(result.invalidSegments).toContain('/another.jpg'); - }); - - it('should strip @ prefix from first path', async () => { - const result = await getMultipleImagePathsFromText( - '@/fake1.png /fake2.png', - ); - // Both should fail because files don't exist - expect(result.validPaths).toEqual([]); - // The @ should be stripped from the first segment - expect(result.invalidSegments).toEqual(['/fake1.png', '/fake2.png']); - }); - - it('should handle paths with escaped spaces', async () => { - const result = await getMultipleImagePathsFromText( - '/my\\ image.png /other\\ pic.jpg', - ); - expect(result.validPaths).toEqual([]); - // Original escaped segments should be preserved in invalidSegments - expect(result.invalidSegments).toEqual([ - '/my\\ image.png', - '/other\\ pic.jpg', - ]); - }); - - it('should return empty for empty string', async () => { - const result = await getMultipleImagePathsFromText(''); - expect(result.validPaths).toEqual([]); - expect(result.invalidSegments).toEqual([]); - }); - }); - describe('processPastedPaths', () => { it('should return null for empty string', () => { const result = processPastedPaths('', () => true); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 4330fd61875..1ab462b2f0d 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -5,7 +5,6 @@ */ import * as fs from 'node:fs/promises'; -import * as os from 'node:os'; import * as path from 'node:path'; import { debugLogger, @@ -163,81 +162,6 @@ export async function cleanupOldClipboardImages( } } -/** - * Parses text that might be an image file path. - * Handles paths with @ prefix and escaped spaces (e.g., @/path/to/file\ name.png) - * @param text The text to check (typically pasted content) - * @returns The resolved absolute path if it looks like an image path, null otherwise - */ -function parseImagePath(text: string): string | null { - let trimmed = text.trim(); - - // Remove @ prefix if present (drag-and-drop in Gemini CLI adds @) - if (trimmed.startsWith('@')) { - trimmed = trimmed.slice(1); - } - - // Must start with a path prefix - if (!PATH_PREFIX_PATTERN.test(trimmed)) { - return null; - } - - const unescapedPath = unescapePath(trimmed); - - // Check if it has an image extension - const lowerPath = unescapedPath.toLowerCase(); - const hasImageExtension = IMAGE_EXTENSIONS.some((ext) => - lowerPath.endsWith(ext), - ); - if (!hasImageExtension) { - return null; - } - - // Resolve the path (handle ~ for home directory) - let absolutePath = unescapedPath; - if (unescapedPath.startsWith('~')) { - absolutePath = path.join(os.homedir(), unescapedPath.slice(1)); - } else if (!path.isAbsolute(unescapedPath)) { - absolutePath = path.resolve(unescapedPath); - } - - return absolutePath; -} - -/** - * Synchronously checks if text looks like an image file path (without verifying existence). - * Use this for fast rejection of non-image text before doing async file checks. - * @param text The text to check - * @returns true if the text could be an image path based on format and extension - */ -export function looksLikeImagePath(text: string): boolean { - return parseImagePath(text) !== null; -} - -/** - * Checks if a string looks like an image file path and the file exists. - * Used for detecting drag-and-drop image files in the terminal. - * Handles paths with @ prefix and escaped spaces (e.g., @/path/to/file\ name.png) - * @param text The text to check (typically pasted content) - * @returns The absolute path if valid image file, null otherwise - */ -export async function getImagePathFromText( - text: string, -): Promise { - const absolutePath = parseImagePath(text); - if (!absolutePath) { - return null; - } - - // Check if file exists - try { - await fs.access(absolutePath); - return absolutePath; - } catch { - return null; - } -} - /** * Splits text into individual path segments, respecting escaped spaces. * Unescaped spaces act as separators between paths, while "\ " is preserved @@ -281,101 +205,6 @@ export function splitEscapedPaths(text: string): string[] { return paths; } -/** - * Synchronously checks if text could contain one or more image file paths. - * Use this for fast rejection of non-image text before doing async file checks. - * - * @param text The text to check - * @returns true if text could be image path(s) based on format and extension - */ -export function looksLikeMultipleImagePaths(text: string): boolean { - let trimmed = text.trim(); - - // Remove @ prefix if present (drag-and-drop adds @) - if (trimmed.startsWith('@')) { - trimmed = trimmed.slice(1); - } - - // Must start with a path prefix - if (!PATH_PREFIX_PATTERN.test(trimmed)) { - return false; - } - - // Must contain at least one image extension - const lower = trimmed.toLowerCase(); - return IMAGE_EXTENSIONS.some((ext) => lower.includes(ext)); -} - -/** - * Validates multiple image paths from drag-and-drop text, checking file existence. - * Returns valid paths for images that exist, and original segments for invalid ones. - * - * @param text The pasted text (potentially space-separated paths) - * @returns Object with validPaths (absolute paths to existing images) and - * invalidSegments (original escaped text for non-images or missing files) - */ -export async function getMultipleImagePathsFromText(text: string): Promise<{ - validPaths: string[]; - invalidSegments: string[]; -}> { - let trimmed = text.trim(); - - // Remove @ prefix from first path if present - if (trimmed.startsWith('@')) { - trimmed = trimmed.slice(1); - } - - // Quick check: if text doesn't look like path(s), return empty - if (!PATH_PREFIX_PATTERN.test(trimmed)) { - return { validPaths: [], invalidSegments: [] }; - } - - const segments = splitEscapedPaths(trimmed); - const validPaths: string[] = []; - const invalidSegments: string[] = []; - - // Process segments in parallel for performance - const results = await Promise.all( - segments.map(async (segment) => { - // Use core's unescapePath for consistency with rest of codebase - const unescaped = unescapePath(segment); - const lower = unescaped.toLowerCase(); - - // Non-image extension → keep as raw text - if (!IMAGE_EXTENSIONS.some((ext) => lower.endsWith(ext))) { - return { type: 'invalid' as const, segment }; - } - - // Resolve to absolute path - let absolute = unescaped; - if (unescaped.startsWith('~')) { - absolute = path.join(os.homedir(), unescaped.slice(1)); - } else if (!path.isAbsolute(unescaped)) { - absolute = path.resolve(unescaped); - } - - // Check file existence - try { - await fs.access(absolute); - return { type: 'valid' as const, path: absolute }; - } catch { - return { type: 'invalid' as const, segment }; - } - }), - ); - - // Collect results maintaining order - for (const result of results) { - if (result.type === 'valid') { - validPaths.push(result.path); - } else { - invalidSegments.push(result.segment); - } - } - - return { validPaths, invalidSegments }; -} - /** * Processes pasted text containing file paths, adding @ prefix to valid paths. * Handles both single and multiple space-separated paths. From 9357e6cce37e9317267ba4b3ba831d6061369164 Mon Sep 17 00:00:00 2001 From: Jack Wotherspoon Date: Wed, 10 Dec 2025 15:23:16 -0500 Subject: [PATCH 7/7] chore: rename to parsePastedPaths --- .../src/ui/components/shared/text-buffer.ts | 4 +-- .../cli/src/ui/utils/clipboardUtils.test.ts | 33 +++++++++---------- packages/cli/src/ui/utils/clipboardUtils.ts | 2 +- 3 files changed, 18 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index fd3961d313d..99cfc7e7d43 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -17,7 +17,7 @@ import { stripUnsafeCharacters, getCachedStringWidth, } from '../../utils/textUtils.js'; -import { processPastedPaths } from '../../utils/clipboardUtils.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'; @@ -1677,7 +1677,7 @@ export function useTextBuffer({ potentialPath = potentialPath.trim(); - const processed = processPastedPaths(potentialPath, isValidPath); + 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 11a74e873f9..bff3d2a6ec7 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.test.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.test.ts @@ -10,7 +10,7 @@ import { saveClipboardImage, cleanupOldClipboardImages, splitEscapedPaths, - processPastedPaths, + parsePastedPaths, } from './clipboardUtils.js'; describe('clipboardUtils', () => { @@ -138,26 +138,26 @@ describe('clipboardUtils', () => { }); }); - describe('processPastedPaths', () => { + describe('parsePastedPaths', () => { it('should return null for empty string', () => { - const result = processPastedPaths('', () => true); + const result = parsePastedPaths('', () => true); expect(result).toBe(null); }); it('should add @ prefix to single valid path', () => { - const result = processPastedPaths('/path/to/file.txt', () => true); + 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 = processPastedPaths('/path/to/file.txt', () => false); + 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 = processPastedPaths( + const result = parsePastedPaths( '/path/to/file1.txt /path/to/file2.txt', (p) => validPaths.has(p), ); @@ -165,7 +165,7 @@ describe('clipboardUtils', () => { }); it('should only add @ prefix to valid paths', () => { - const result = processPastedPaths( + const result = parsePastedPaths( '/valid/file.txt /invalid/file.jpg', (p) => p.endsWith('.txt'), ); @@ -173,7 +173,7 @@ describe('clipboardUtils', () => { }); it('should return null if no paths are valid', () => { - const result = processPastedPaths( + const result = parsePastedPaths( '/path/to/file1.txt /path/to/file2.txt', () => false, ); @@ -183,7 +183,7 @@ describe('clipboardUtils', () => { 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 = processPastedPaths( + const result = parsePastedPaths( '/path/to/my\\ file.txt /other/path.txt', (p) => validPaths.has(p), ); @@ -194,7 +194,7 @@ describe('clipboardUtils', () => { // Use Set to model reality: individual paths exist, combined string doesn't const validPaths = new Set(['/my file.txt', '/other.txt']); const validatedPaths: string[] = []; - processPastedPaths('/my\\ file.txt /other.txt', (p) => { + parsePastedPaths('/my\\ file.txt /other.txt', (p) => { validatedPaths.push(p); return validPaths.has(p); }); @@ -207,33 +207,30 @@ describe('clipboardUtils', () => { }); it('should handle single path with unescaped spaces from copy-paste', () => { - const result = processPastedPaths('/path/to/my file.txt', () => true); + const result = parsePastedPaths('/path/to/my file.txt', () => true); expect(result).toBe('@/path/to/my\\ file.txt '); }); it('should handle Windows path', () => { - const result = processPastedPaths('C:\\Users\\file.txt', () => true); + 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 = processPastedPaths( - 'C:\\My Documents\\file.txt', - () => true, - ); + 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 = processPastedPaths('C:\\file1.txt D:\\file2.txt', (p) => + 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 = processPastedPaths( + const result = parsePastedPaths( '\\\\server\\share\\file.txt', () => true, ); diff --git a/packages/cli/src/ui/utils/clipboardUtils.ts b/packages/cli/src/ui/utils/clipboardUtils.ts index 1ab462b2f0d..91a657aca02 100644 --- a/packages/cli/src/ui/utils/clipboardUtils.ts +++ b/packages/cli/src/ui/utils/clipboardUtils.ts @@ -213,7 +213,7 @@ export function splitEscapedPaths(text: string): string[] { * @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 processPastedPaths( +export function parsePastedPaths( text: string, isValidPath: (path: string) => boolean, ): string | null {