mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 16:10:59 -07:00
feat: support multi-file drag and drop of images (#14832)
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user