feat: support multi-file drag and drop of images (#14832)

This commit is contained in:
Jack Wotherspoon
2025-12-12 12:14:35 -05:00
committed by GitHub
parent 299cc9bebf
commit 1e734d7e60
4 changed files with 308 additions and 7 deletions
+98 -4
View File
@@ -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;
}