mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-29 14:34:55 -07:00
Fix drag and drop escaping (#18965)
This commit is contained in:
committed by
GitHub
parent
00f73b73bc
commit
d82f66973f
@@ -41,6 +41,7 @@ import {
|
|||||||
getTransformedImagePath,
|
getTransformedImagePath,
|
||||||
} from './text-buffer.js';
|
} from './text-buffer.js';
|
||||||
import { cpLen } from '../../utils/textUtils.js';
|
import { cpLen } from '../../utils/textUtils.js';
|
||||||
|
import { escapePath } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
const defaultVisualLayout: VisualLayout = {
|
const defaultVisualLayout: VisualLayout = {
|
||||||
visualLines: [''],
|
visualLines: [''],
|
||||||
@@ -1077,14 +1078,16 @@ describe('useTextBuffer', () => {
|
|||||||
useTextBuffer({ viewport, escapePastedPaths: true }),
|
useTextBuffer({ viewport, escapePastedPaths: true }),
|
||||||
);
|
);
|
||||||
// Construct escaped path string: "/path/to/my\ file.txt /path/to/other.txt"
|
// Construct escaped path string: "/path/to/my\ file.txt /path/to/other.txt"
|
||||||
const escapedFile1 = file1.replace(/ /g, '\\ ');
|
|
||||||
const filePaths = `${escapedFile1} ${file2}`;
|
const filePaths = `${escapePath(file1)} ${file2}`;
|
||||||
|
|
||||||
act(() => result.current.insert(filePaths, { paste: true }));
|
act(() => result.current.insert(filePaths, { paste: true }));
|
||||||
expect(getBufferState(result).text).toBe(`@${escapedFile1} @${file2} `);
|
expect(getBufferState(result).text).toBe(
|
||||||
|
`@${escapePath(file1)} @${file2} `,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only prepend @ to valid paths in multi-path paste', () => {
|
it('should not prepend @ unless all paths are valid', () => {
|
||||||
const validFile = path.join(tempDir, 'valid.txt');
|
const validFile = path.join(tempDir, 'valid.txt');
|
||||||
const invalidFile = path.join(tempDir, 'invalid.jpg');
|
const invalidFile = path.join(tempDir, 'invalid.jpg');
|
||||||
fs.writeFileSync(validFile, '');
|
fs.writeFileSync(validFile, '');
|
||||||
@@ -1098,7 +1101,7 @@ describe('useTextBuffer', () => {
|
|||||||
);
|
);
|
||||||
const filePaths = `${validFile} ${invalidFile}`;
|
const filePaths = `${validFile} ${invalidFile}`;
|
||||||
act(() => result.current.insert(filePaths, { paste: true }));
|
act(() => result.current.insert(filePaths, { paste: true }));
|
||||||
expect(getBufferState(result).text).toBe(`@${validFile} ${invalidFile} `);
|
expect(getBufferState(result).text).toBe(`${validFile} ${invalidFile}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2869,12 +2872,26 @@ describe('Unicode helper functions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockPlatform = (platform: string) => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'process',
|
||||||
|
Object.create(process, {
|
||||||
|
platform: {
|
||||||
|
get: () => platform,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('Transformation Utilities', () => {
|
describe('Transformation Utilities', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getTransformedImagePath', () => {
|
describe('getTransformedImagePath', () => {
|
||||||
|
beforeEach(() => mockPlatform('linux'));
|
||||||
|
|
||||||
it('should transform a simple image path', () => {
|
it('should transform a simple image path', () => {
|
||||||
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
|
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
|
||||||
});
|
});
|
||||||
@@ -2905,11 +2922,6 @@ describe('Transformation Utilities', () => {
|
|||||||
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
|
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Windows-style backslash paths on any platform', () => {
|
|
||||||
const input = '@C:\\Users\\foo\\screenshots\\image2x.png';
|
|
||||||
expect(getTransformedImagePath(input)).toBe('[Image image2x.png]');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle escaped spaces in paths', () => {
|
it('should handle escaped spaces in paths', () => {
|
||||||
const input = '@path/to/my\\ file.png';
|
const input = '@path/to/my\\ file.png';
|
||||||
expect(getTransformedImagePath(input)).toBe('[Image my file.png]');
|
expect(getTransformedImagePath(input)).toBe('[Image my file.png]');
|
||||||
|
|||||||
@@ -2814,15 +2814,7 @@ export function useTextBuffer({
|
|||||||
paste &&
|
paste &&
|
||||||
escapePastedPaths
|
escapePastedPaths
|
||||||
) {
|
) {
|
||||||
let potentialPath = ch.trim();
|
const processed = parsePastedPaths(ch.trim());
|
||||||
const quoteMatch = potentialPath.match(/^'(.*)'$/);
|
|
||||||
if (quoteMatch) {
|
|
||||||
potentialPath = quoteMatch[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
potentialPath = potentialPath.trim();
|
|
||||||
|
|
||||||
const processed = parsePastedPaths(potentialPath);
|
|
||||||
if (processed) {
|
if (processed) {
|
||||||
textToInsert = processed;
|
textToInsert = processed;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,7 @@ describe('handleAtCommand', () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
abortController.abort();
|
abortController.abort();
|
||||||
await fsPromises.rm(testRootDir, { recursive: true, force: true });
|
await fsPromises.rm(testRootDir, { recursive: true, force: true });
|
||||||
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should pass through query if no @ command is present', async () => {
|
it('should pass through query if no @ command is present', async () => {
|
||||||
@@ -319,6 +320,46 @@ describe('handleAtCommand', () => {
|
|||||||
);
|
);
|
||||||
}, 10000);
|
}, 10000);
|
||||||
|
|
||||||
|
it('should correctly handle double-quoted paths with spaces', async () => {
|
||||||
|
// Mock platform to win32 so unescapePath strips quotes
|
||||||
|
vi.stubGlobal(
|
||||||
|
'process',
|
||||||
|
Object.create(process, {
|
||||||
|
platform: {
|
||||||
|
get: () => 'win32',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const fileContent = 'Content of file with spaces';
|
||||||
|
const filePath = await createTestFile(
|
||||||
|
path.join(testRootDir, 'my folder', 'my file.txt'),
|
||||||
|
fileContent,
|
||||||
|
);
|
||||||
|
// On Windows, the user might provide: @"path/to/my file.txt"
|
||||||
|
const query = `@"${filePath}"`;
|
||||||
|
|
||||||
|
const result = await handleAtCommand({
|
||||||
|
query,
|
||||||
|
config: mockConfig,
|
||||||
|
addItem: mockAddItem,
|
||||||
|
onDebugMessage: mockOnDebugMessage,
|
||||||
|
messageId: 126,
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const relativePath = getRelativePath(filePath);
|
||||||
|
expect(result).toEqual({
|
||||||
|
processedQuery: [
|
||||||
|
{ text: `@${relativePath}` },
|
||||||
|
{ text: '\n--- Content from referenced files ---' },
|
||||||
|
{ text: `\nContent from @${relativePath}:\n` },
|
||||||
|
{ text: fileContent },
|
||||||
|
{ text: '\n--- End of content ---' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('should correctly handle file paths with narrow non-breaking space (NNBSP)', async () => {
|
it('should correctly handle file paths with narrow non-breaking space (NNBSP)', async () => {
|
||||||
const nnbsp = '\u202F';
|
const nnbsp = '\u202F';
|
||||||
const fileContent = 'NNBSP file content.';
|
const fileContent = 'NNBSP file content.';
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
|
|||||||
* Regex source for the path/command part of an @ reference.
|
* Regex source for the path/command part of an @ reference.
|
||||||
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
|
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
|
||||||
*
|
*
|
||||||
* 1. \\. matches any escaped character (e.g., \ ).
|
* 1. "(?:[^"]*)" matches a double-quoted string (for Windows paths with spaces).
|
||||||
* 2. [^ \t\n\r,;!?()\[\]{}.] matches any character that is NOT a delimiter and NOT a period.
|
* 2. \\. matches any escaped character (e.g., \ ).
|
||||||
* 3. \.(?!$|[ \t\n\r]) matches a period ONLY if it is NOT followed by whitespace or end-of-string.
|
* 3. [^ \t\n\r,;!?()\[\]{}.] matches any character that is NOT a delimiter and NOT a period.
|
||||||
|
* 4. \.(?!$|[ \t\n\r]) matches a period ONLY if it is NOT followed by whitespace or end-of-string.
|
||||||
*/
|
*/
|
||||||
export const AT_COMMAND_PATH_REGEX_SOURCE =
|
export const AT_COMMAND_PATH_REGEX_SOURCE =
|
||||||
'(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r]))+';
|
'(?:(?:"(?:[^"]*)")|(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r])))+';
|
||||||
|
|
||||||
interface HandleAtCommandParams {
|
interface HandleAtCommandParams {
|
||||||
query: string;
|
query: string;
|
||||||
@@ -85,8 +86,8 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// unescapePath expects the @ symbol to be present, and will handle it.
|
// We strip the @ before unescaping so that unescapePath can handle quoted paths correctly on Windows.
|
||||||
const atPath = unescapePath(fullMatch);
|
const atPath = '@' + unescapePath(fullMatch.substring(1));
|
||||||
parts.push({ type: 'atPath', content: atPath });
|
parts.push({ type: 'atPath', content: atPath });
|
||||||
|
|
||||||
lastIndex = matchIndex + fullMatch.length;
|
lastIndex = matchIndex + fullMatch.length;
|
||||||
|
|||||||
@@ -62,15 +62,25 @@ import { spawnAsync } from '@google/gemini-cli-core';
|
|||||||
// Keep static imports for stateless functions
|
// Keep static imports for stateless functions
|
||||||
import {
|
import {
|
||||||
cleanupOldClipboardImages,
|
cleanupOldClipboardImages,
|
||||||
splitEscapedPaths,
|
splitDragAndDropPaths,
|
||||||
parsePastedPaths,
|
parsePastedPaths,
|
||||||
} from './clipboardUtils.js';
|
} from './clipboardUtils.js';
|
||||||
|
|
||||||
|
const mockPlatform = (platform: string) => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'process',
|
||||||
|
Object.create(process, {
|
||||||
|
platform: {
|
||||||
|
get: () => platform,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// Define the type for the module to use in tests
|
// Define the type for the module to use in tests
|
||||||
type ClipboardUtilsModule = typeof import('./clipboardUtils.js');
|
type ClipboardUtilsModule = typeof import('./clipboardUtils.js');
|
||||||
|
|
||||||
describe('clipboardUtils', () => {
|
describe('clipboardUtils', () => {
|
||||||
let originalPlatform: string;
|
|
||||||
let originalEnv: NodeJS.ProcessEnv;
|
let originalEnv: NodeJS.ProcessEnv;
|
||||||
// Dynamic module instance for stateful functions
|
// Dynamic module instance for stateful functions
|
||||||
let clipboardUtils: ClipboardUtilsModule;
|
let clipboardUtils: ClipboardUtilsModule;
|
||||||
@@ -83,7 +93,6 @@ describe('clipboardUtils', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
originalPlatform = process.platform;
|
|
||||||
originalEnv = process.env;
|
originalEnv = process.env;
|
||||||
process.env = { ...originalEnv };
|
process.env = { ...originalEnv };
|
||||||
|
|
||||||
@@ -94,22 +103,13 @@ describe('clipboardUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
Object.defineProperty(process, 'platform', {
|
vi.unstubAllGlobals();
|
||||||
value: originalPlatform,
|
|
||||||
});
|
|
||||||
process.env = originalEnv;
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
const setPlatform = (platform: string) => {
|
|
||||||
Object.defineProperty(process, 'platform', {
|
|
||||||
value: platform,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('clipboardHasImage (Linux)', () => {
|
describe('clipboardHasImage (Linux)', () => {
|
||||||
it('should return true when wl-paste shows image type (Wayland)', async () => {
|
it('should return true when wl-paste shows image type (Wayland)', async () => {
|
||||||
setPlatform('linux');
|
mockPlatform('linux');
|
||||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||||
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
|
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
|
||||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||||
@@ -128,7 +128,7 @@ describe('clipboardUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return true when xclip shows image type (X11)', async () => {
|
it('should return true when xclip shows image type (X11)', async () => {
|
||||||
setPlatform('linux');
|
mockPlatform('linux');
|
||||||
process.env['XDG_SESSION_TYPE'] = 'x11';
|
process.env['XDG_SESSION_TYPE'] = 'x11';
|
||||||
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
|
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
|
||||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||||
@@ -153,7 +153,7 @@ describe('clipboardUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if tool fails', async () => {
|
it('should return false if tool fails', async () => {
|
||||||
setPlatform('linux');
|
mockPlatform('linux');
|
||||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||||
vi.mocked(spawnAsync).mockRejectedValueOnce(new Error('wl-paste failed'));
|
vi.mocked(spawnAsync).mockRejectedValueOnce(new Error('wl-paste failed'));
|
||||||
@@ -164,7 +164,7 @@ describe('clipboardUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if no image type is found', async () => {
|
it('should return false if no image type is found', async () => {
|
||||||
setPlatform('linux');
|
mockPlatform('linux');
|
||||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||||
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
|
||||||
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
vi.mocked(spawnAsync).mockResolvedValueOnce({
|
||||||
@@ -178,7 +178,7 @@ describe('clipboardUtils', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should return false if tool not found', async () => {
|
it('should return false if tool not found', async () => {
|
||||||
setPlatform('linux');
|
mockPlatform('linux');
|
||||||
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
process.env['XDG_SESSION_TYPE'] = 'wayland';
|
||||||
vi.mocked(execSync).mockImplementation(() => {
|
vi.mocked(execSync).mockImplementation(() => {
|
||||||
throw new Error('Command not found');
|
throw new Error('Command not found');
|
||||||
@@ -195,7 +195,7 @@ describe('clipboardUtils', () => {
|
|||||||
const mockTempDir = path.join('/tmp/global', 'images');
|
const mockTempDir = path.join('/tmp/global', 'images');
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setPlatform('linux');
|
mockPlatform('linux');
|
||||||
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
||||||
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
vi.mocked(fs.unlink).mockResolvedValue(undefined);
|
||||||
});
|
});
|
||||||
@@ -363,65 +363,86 @@ describe('clipboardUtils', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('splitEscapedPaths', () => {
|
describe('splitDragAndDropPaths', () => {
|
||||||
it('should return single path when no spaces', () => {
|
describe('in posix', () => {
|
||||||
expect(splitEscapedPaths('/path/to/image.png')).toEqual([
|
beforeEach(() => mockPlatform('linux'));
|
||||||
'/path/to/image.png',
|
|
||||||
]);
|
it.each([
|
||||||
|
['empty string', '', []],
|
||||||
|
['single path no spaces', '/path/to/image.png', ['/path/to/image.png']],
|
||||||
|
[
|
||||||
|
'simple space-separated paths',
|
||||||
|
'/img1.png /img2.png',
|
||||||
|
['/img1.png', '/img2.png'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'three paths',
|
||||||
|
'/a.png /b.jpg /c.heic',
|
||||||
|
['/a.png', '/b.jpg', '/c.heic'],
|
||||||
|
],
|
||||||
|
['escaped spaces', '/my\\ image.png', ['/my image.png']],
|
||||||
|
[
|
||||||
|
'multiple paths with escaped spaces',
|
||||||
|
'/my\\ img1.png /my\\ img2.png',
|
||||||
|
['/my img1.png', '/my img2.png'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'multiple escaped spaces',
|
||||||
|
'/path/to/my\\ cool\\ image.png',
|
||||||
|
['/path/to/my cool image.png'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'consecutive spaces',
|
||||||
|
'/img1.png /img2.png',
|
||||||
|
['/img1.png', '/img2.png'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trailing/leading whitespace',
|
||||||
|
' /img1.png /img2.png ',
|
||||||
|
['/img1.png', '/img2.png'],
|
||||||
|
],
|
||||||
|
['whitespace only', ' ', []],
|
||||||
|
['quoted path with spaces', '"/my image.png"', ['/my image.png']],
|
||||||
|
[
|
||||||
|
'mixed quoted and unquoted',
|
||||||
|
'"/my img1.png" /my\\ img2.png',
|
||||||
|
['/my img1.png', '/my img2.png'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quoted with escaped quotes',
|
||||||
|
"'/derp/my '\\''cool'\\'' image.png'",
|
||||||
|
["/derp/my 'cool' image.png"],
|
||||||
|
],
|
||||||
|
])('should escape %s', (_, input, expected) => {
|
||||||
|
expect([...splitDragAndDropPaths(input)]).toEqual(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should split simple space-separated paths', () => {
|
describe('in windows', () => {
|
||||||
expect(splitEscapedPaths('/img1.png /img2.png')).toEqual([
|
beforeEach(() => mockPlatform('win32'));
|
||||||
'/img1.png',
|
|
||||||
'/img2.png',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should split three paths', () => {
|
it.each([
|
||||||
expect(splitEscapedPaths('/a.png /b.jpg /c.heic')).toEqual([
|
['double quoted path', '"C:\\my image.png"', ['C:\\my image.png']],
|
||||||
'/a.png',
|
[
|
||||||
'/b.jpg',
|
'multiple double quoted paths',
|
||||||
'/c.heic',
|
'"C:\\img 1.png" "D:\\img 2.png"',
|
||||||
]);
|
['C:\\img 1.png', 'D:\\img 2.png'],
|
||||||
});
|
],
|
||||||
|
['unquoted path', 'C:\\img.png', ['C:\\img.png']],
|
||||||
it('should preserve escaped spaces within filenames', () => {
|
[
|
||||||
expect(splitEscapedPaths('/my\\ image.png')).toEqual(['/my\\ image.png']);
|
'mixed quoted and unquoted',
|
||||||
});
|
'"C:\\img 1.png" D:\\img2.png',
|
||||||
|
['C:\\img 1.png', 'D:\\img2.png'],
|
||||||
it('should handle multiple paths with escaped spaces', () => {
|
],
|
||||||
expect(splitEscapedPaths('/my\\ img1.png /my\\ img2.png')).toEqual([
|
['single quoted path', "'C:\\my image.png'", ['C:\\my image.png']],
|
||||||
'/my\\ img1.png',
|
[
|
||||||
'/my\\ img2.png',
|
'mixed single and double quoted',
|
||||||
]);
|
'"C:\\img 1.png" \'D:\\img 2.png\'',
|
||||||
});
|
['C:\\img 1.png', 'D:\\img 2.png'],
|
||||||
|
],
|
||||||
it('should handle path with multiple escaped spaces', () => {
|
])('should split %s', (_, input, expected) => {
|
||||||
expect(splitEscapedPaths('/path/to/my\\ cool\\ image.png')).toEqual([
|
expect([...splitDragAndDropPaths(input)]).toEqual(expected);
|
||||||
'/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([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -455,14 +476,14 @@ describe('clipboardUtils', () => {
|
|||||||
expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt ');
|
expect(result).toBe('@/path/to/file1.txt @/path/to/file2.txt ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should only add @ prefix to valid paths', () => {
|
it('should return null if any path is invalid', () => {
|
||||||
vi.mocked(existsSync).mockImplementation((p) =>
|
vi.mocked(existsSync).mockImplementation((p) =>
|
||||||
(p as string).endsWith('.txt'),
|
(p as string).endsWith('.txt'),
|
||||||
);
|
);
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
const result = parsePastedPaths('/valid/file.txt /invalid/file.jpg');
|
const result = parsePastedPaths('/valid/file.txt /invalid/file.jpg');
|
||||||
expect(result).toBe('@/valid/file.txt /invalid/file.jpg ');
|
expect(result).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return null if no paths are valid', () => {
|
it('should return null if no paths are valid', () => {
|
||||||
@@ -471,76 +492,110 @@ describe('clipboardUtils', () => {
|
|||||||
expect(result).toBe(null);
|
expect(result).toBe(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle paths with escaped spaces', () => {
|
describe('in posix', () => {
|
||||||
const validPaths = new Set(['/path/to/my file.txt', '/other/path.txt']);
|
beforeEach(() => {
|
||||||
vi.mocked(existsSync).mockImplementation((p) =>
|
mockPlatform('linux');
|
||||||
validPaths.has(p as string),
|
|
||||||
);
|
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
|
||||||
|
|
||||||
const result = parsePastedPaths('/path/to/my\\ file.txt /other/path.txt');
|
|
||||||
expect(result).toBe('@/path/to/my\\ file.txt @/other/path.txt ');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unescape paths before validation', () => {
|
|
||||||
const validPaths = new Set(['/my file.txt', '/other.txt']);
|
|
||||||
const validatedPaths: string[] = [];
|
|
||||||
vi.mocked(existsSync).mockImplementation((p) => {
|
|
||||||
validatedPaths.push(p as string);
|
|
||||||
return validPaths.has(p as string);
|
|
||||||
});
|
});
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
|
||||||
|
|
||||||
parsePastedPaths('/my\\ file.txt /other.txt');
|
it('should handle paths with escaped spaces', () => {
|
||||||
// First checks entire string, then individual unescaped segments
|
const validPaths = new Set(['/path/to/my file.txt', '/other/path.txt']);
|
||||||
expect(validatedPaths).toEqual([
|
vi.mocked(existsSync).mockImplementation((p) =>
|
||||||
'/my\\ file.txt /other.txt',
|
validPaths.has(p as string),
|
||||||
'/my file.txt',
|
);
|
||||||
'/other.txt',
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
]);
|
|
||||||
|
const result = parsePastedPaths(
|
||||||
|
'/path/to/my\\ file.txt /other/path.txt',
|
||||||
|
);
|
||||||
|
expect(result).toBe('@/path/to/my\\ file.txt @/other/path.txt ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should unescape paths before validation', () => {
|
||||||
|
const validPaths = new Set(['/my file.txt', '/other.txt']);
|
||||||
|
const validatedPaths: string[] = [];
|
||||||
|
vi.mocked(existsSync).mockImplementation((p) => {
|
||||||
|
validatedPaths.push(p as string);
|
||||||
|
return validPaths.has(p as string);
|
||||||
|
});
|
||||||
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
|
parsePastedPaths('/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', () => {
|
||||||
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
|
const result = parsePastedPaths('/path/to/my file.txt');
|
||||||
|
expect(result).toBe('@/path/to/my\\ file.txt ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single-quoted with escaped quote', () => {
|
||||||
|
const validPaths = new Set([
|
||||||
|
"/usr/test/my file with 'single quotes'.txt",
|
||||||
|
]);
|
||||||
|
const validatedPaths: string[] = [];
|
||||||
|
vi.mocked(existsSync).mockImplementation((p) => {
|
||||||
|
validatedPaths.push(p as string);
|
||||||
|
return validPaths.has(p as string);
|
||||||
|
});
|
||||||
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
|
const result = parsePastedPaths(
|
||||||
|
"'/usr/test/my file with '\\''single quotes'\\''.txt'",
|
||||||
|
);
|
||||||
|
expect(result).toBe(
|
||||||
|
"@/usr/test/my\\ file\\ with\\ \\'single\\ quotes\\'.txt ",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(validatedPaths).toEqual([
|
||||||
|
"/usr/test/my file with 'single quotes'.txt",
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle single path with unescaped spaces from copy-paste', () => {
|
describe('in windows', () => {
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
beforeEach(() => mockPlatform('win32'));
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
|
||||||
|
|
||||||
const result = parsePastedPaths('/path/to/my file.txt');
|
it('should handle Windows path', () => {
|
||||||
expect(result).toBe('@/path/to/my\\ file.txt ');
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
});
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
it('should handle Windows path', () => {
|
const result = parsePastedPaths('C:\\Users\\file.txt');
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
expect(result).toBe('@C:\\Users\\file.txt ');
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
});
|
||||||
|
|
||||||
const result = parsePastedPaths('C:\\Users\\file.txt');
|
it('should handle Windows path with unescaped spaces', () => {
|
||||||
expect(result).toBe('@C:\\Users\\file.txt ');
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
});
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
it('should handle Windows path with unescaped spaces', () => {
|
const result = parsePastedPaths('C:\\My Documents\\file.txt');
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
expect(result).toBe('@"C:\\My Documents\\file.txt" ');
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
});
|
||||||
|
it('should handle multiple Windows paths', () => {
|
||||||
|
const validPaths = new Set(['C:\\file1.txt', 'D:\\file2.txt']);
|
||||||
|
vi.mocked(existsSync).mockImplementation((p) =>
|
||||||
|
validPaths.has(p as string),
|
||||||
|
);
|
||||||
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
|
|
||||||
const result = parsePastedPaths('C:\\My Documents\\file.txt');
|
const result = parsePastedPaths('C:\\file1.txt D:\\file2.txt');
|
||||||
expect(result).toBe('@C:\\My\\ Documents\\file.txt ');
|
expect(result).toBe('@C:\\file1.txt @D:\\file2.txt ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple Windows paths', () => {
|
it('should handle Windows UNC path', () => {
|
||||||
const validPaths = new Set(['C:\\file1.txt', 'D:\\file2.txt']);
|
vi.mocked(existsSync).mockReturnValue(true);
|
||||||
vi.mocked(existsSync).mockImplementation((p) =>
|
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
||||||
validPaths.has(p as string),
|
|
||||||
);
|
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
|
||||||
|
|
||||||
const result = parsePastedPaths('C:\\file1.txt D:\\file2.txt');
|
const result = parsePastedPaths('\\\\server\\share\\file.txt');
|
||||||
expect(result).toBe('@C:\\file1.txt @D:\\file2.txt ');
|
expect(result).toBe('@\\\\server\\share\\file.txt ');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle Windows UNC path', () => {
|
|
||||||
vi.mocked(existsSync).mockReturnValue(true);
|
|
||||||
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
|
|
||||||
|
|
||||||
const result = parsePastedPaths('\\\\server\\share\\file.txt');
|
|
||||||
expect(result).toBe('@\\\\server\\share\\file.txt ');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import * as path from 'node:path';
|
|||||||
import {
|
import {
|
||||||
debugLogger,
|
debugLogger,
|
||||||
spawnAsync,
|
spawnAsync,
|
||||||
unescapePath,
|
|
||||||
escapePath,
|
escapePath,
|
||||||
Storage,
|
Storage,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
@@ -418,48 +417,77 @@ export async function cleanupOldClipboardImages(
|
|||||||
debugLogger.debug('Failed to clean up old clipboard images:', e);
|
debugLogger.debug('Failed to clean up old clipboard images:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Splits text into individual path segments, respecting escaped spaces.
|
* Splits a pasted text block up into escaped path segements if it's a legal
|
||||||
* Unescaped spaces act as separators between paths, while "\ " is preserved
|
* drag-and-drop string.
|
||||||
* as part of a filename.
|
|
||||||
*
|
*
|
||||||
* Example: "/img1.png /path/my\ image.png" → ["/img1.png", "/path/my\ image.png"]
|
* There are multiple ways drag-and-drop paths might be escaped:
|
||||||
|
* - Bare (only if there are no special chars): /path/to/myfile.png
|
||||||
|
* - Wrapped in double quotes (Windows only): "/path/to/my file~!.png"
|
||||||
|
* - Escaped with backslashes (POSIX only): /path/to/my\ file~!.png
|
||||||
|
* - Wrapped in single quotes: '/path/to/my file~!.png'
|
||||||
*
|
*
|
||||||
* @param text The text to split
|
* When wrapped in single quotes, actual single quotes in the filename are
|
||||||
* @returns Array of path segments (still escaped)
|
* escaped with "'\''". For example: '/path/to/my '\''fancy file'\''.png'
|
||||||
|
*
|
||||||
|
* When wrapped in double quotes, actual double quotes are not an issue becuase
|
||||||
|
* windows doesn't allow them in filenames.
|
||||||
|
*
|
||||||
|
* On all systems, a single drag-and-drop may include both wrapped and bare
|
||||||
|
* paths, so we need to handle both simultaneously.
|
||||||
|
*
|
||||||
|
* @param text
|
||||||
|
* @returns An iterable of escaped paths
|
||||||
*/
|
*/
|
||||||
export function splitEscapedPaths(text: string): string[] {
|
export function* splitDragAndDropPaths(text: string): Generator<string> {
|
||||||
const paths: string[] = [];
|
|
||||||
let current = '';
|
let current = '';
|
||||||
let i = 0;
|
let mode: 'NORMAL' | 'DOUBLE' | 'SINGLE' = 'NORMAL';
|
||||||
|
const isWindows = process.platform === 'win32';
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
while (i < text.length) {
|
while (i < text.length) {
|
||||||
const char = text[i];
|
const char = text[i];
|
||||||
|
|
||||||
if (char === '\\' && i + 1 < text.length && text[i + 1] === ' ') {
|
if (mode === 'NORMAL') {
|
||||||
// Escaped space - part of filename, preserve the escape sequence
|
if (char === ' ') {
|
||||||
current += '\\ ';
|
if (current.length > 0) {
|
||||||
i += 2;
|
yield current;
|
||||||
} else if (char === ' ') {
|
current = '';
|
||||||
// Unescaped space - path separator
|
}
|
||||||
if (current.trim()) {
|
} else if (char === '"') {
|
||||||
paths.push(current.trim());
|
mode = 'DOUBLE';
|
||||||
|
} else if (char === "'") {
|
||||||
|
mode = 'SINGLE';
|
||||||
|
} else if (char === '\\' && !isWindows) {
|
||||||
|
// POSIX escape in normal mode
|
||||||
|
if (i + 1 < text.length) {
|
||||||
|
const next = text[i + 1];
|
||||||
|
current += next;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
} else if (mode === 'DOUBLE') {
|
||||||
|
if (char === '"') {
|
||||||
|
mode = 'NORMAL';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
} else if (mode === 'SINGLE') {
|
||||||
|
if (char === "'") {
|
||||||
|
mode = 'NORMAL';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
}
|
}
|
||||||
current = '';
|
|
||||||
i++;
|
|
||||||
} else {
|
|
||||||
current += char;
|
|
||||||
i++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't forget the last segment
|
if (current.length > 0) {
|
||||||
if (current.trim()) {
|
yield current;
|
||||||
paths.push(current.trim());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return paths;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -467,44 +495,35 @@ export function splitEscapedPaths(text: string): string[] {
|
|||||||
*/
|
*/
|
||||||
function isValidFilePath(p: string): boolean {
|
function isValidFilePath(p: string): boolean {
|
||||||
try {
|
try {
|
||||||
return existsSync(p) && statSync(p).isFile();
|
return PATH_PREFIX_PATTERN.test(p) && existsSync(p) && statSync(p).isFile();
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Processes pasted text containing file paths, adding @ prefix to valid paths.
|
* Processes pasted text containing file paths (like those from drag and drop),
|
||||||
* Handles both single and multiple space-separated paths.
|
* adding @ prefix to valid paths and escaping them in a standard way.
|
||||||
*
|
*
|
||||||
* @param text The pasted text (potentially space-separated paths)
|
* @param text The pasted text
|
||||||
* @returns Processed string with @ prefixes on valid paths, or null if no valid paths
|
* @returns Processed string with @ prefixes or null if any paths are invalid
|
||||||
*/
|
*/
|
||||||
export function parsePastedPaths(text: string): string | null {
|
export function parsePastedPaths(text: string): string | null {
|
||||||
// First, check if the entire text is a single valid path
|
// First, check if the entire text is a single valid path
|
||||||
if (PATH_PREFIX_PATTERN.test(text) && isValidFilePath(text)) {
|
if (isValidFilePath(text)) {
|
||||||
return `@${escapePath(text)} `;
|
return `@${escapePath(text)} `;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, try splitting on unescaped spaces
|
const validPaths = [];
|
||||||
const segments = splitEscapedPaths(text);
|
for (const segment of splitDragAndDropPaths(text)) {
|
||||||
if (segments.length === 0) {
|
if (isValidFilePath(segment)) {
|
||||||
|
validPaths.push(`@${escapePath(segment)}`);
|
||||||
|
} else {
|
||||||
|
return null; // If any segment is invalid, return null for the whole string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validPaths.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
return validPaths.join(' ') + ' ';
|
||||||
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 (isValidFilePath(unescaped)) {
|
|
||||||
anyValidPath = true;
|
|
||||||
return `@${segment}`;
|
|
||||||
}
|
|
||||||
return segment;
|
|
||||||
});
|
|
||||||
|
|
||||||
return anyValidPath ? processedPaths.join(' ') + ' ' : null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { pathToFileURL } from 'node:url';
|
import { pathToFileURL } from 'node:url';
|
||||||
@@ -24,131 +24,118 @@ vi.mock('node:fs', async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockPlatform = (platform: string) => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
'process',
|
||||||
|
Object.create(process, {
|
||||||
|
platform: {
|
||||||
|
get: () => platform,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
describe('escapePath', () => {
|
describe('escapePath', () => {
|
||||||
it.each([
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
['spaces', 'my file.txt', 'my\\ file.txt'],
|
|
||||||
['tabs', 'file\twith\ttabs.txt', 'file\\\twith\\\ttabs.txt'],
|
describe('in posix', () => {
|
||||||
['parentheses', 'file(1).txt', 'file\\(1\\).txt'],
|
beforeEach(() => mockPlatform('linux'));
|
||||||
['square brackets', 'file[backup].txt', 'file\\[backup\\].txt'],
|
|
||||||
['curly braces', 'file{temp}.txt', 'file\\{temp\\}.txt'],
|
it.each([
|
||||||
['semicolons', 'file;name.txt', 'file\\;name.txt'],
|
['spaces', 'my file.txt', 'my\\ file.txt'],
|
||||||
['ampersands', 'file&name.txt', 'file\\&name.txt'],
|
['tabs', 'file\twith\ttabs.txt', 'file\\\twith\\\ttabs.txt'],
|
||||||
['pipes', 'file|name.txt', 'file\\|name.txt'],
|
['parentheses', 'file(1).txt', 'file\\(1\\).txt'],
|
||||||
['asterisks', 'file*.txt', 'file\\*.txt'],
|
['square brackets', 'file[backup].txt', 'file\\[backup\\].txt'],
|
||||||
['question marks', 'file?.txt', 'file\\?.txt'],
|
['curly braces', 'file{temp}.txt', 'file\\{temp\\}.txt'],
|
||||||
['dollar signs', 'file$name.txt', 'file\\$name.txt'],
|
['semicolons', 'file;name.txt', 'file\\;name.txt'],
|
||||||
['backticks', 'file`name.txt', 'file\\`name.txt'],
|
['ampersands', 'file&name.txt', 'file\\&name.txt'],
|
||||||
['single quotes', "file'name.txt", "file\\'name.txt"],
|
['pipes', 'file|name.txt', 'file\\|name.txt'],
|
||||||
['double quotes', 'file"name.txt', 'file\\"name.txt'],
|
['asterisks', 'file*.txt', 'file\\*.txt'],
|
||||||
['hash symbols', 'file#name.txt', 'file\\#name.txt'],
|
['question marks', 'file?.txt', 'file\\?.txt'],
|
||||||
['exclamation marks', 'file!name.txt', 'file\\!name.txt'],
|
['dollar signs', 'file$name.txt', 'file\\$name.txt'],
|
||||||
[
|
['backticks', 'file`name.txt', 'file\\`name.txt'],
|
||||||
'tildes',
|
['single quotes', "file'name.txt", "file\\'name.txt"],
|
||||||
'file~name.txt',
|
['double quotes', 'file"name.txt', 'file\\"name.txt'],
|
||||||
process.platform === 'win32' ? 'file~name.txt' : 'file\\~name.txt',
|
['hash symbols', 'file#name.txt', 'file\\#name.txt'],
|
||||||
],
|
['exclamation marks', 'file!name.txt', 'file\\!name.txt'],
|
||||||
[
|
['tildes', 'file~name.txt', 'file\\~name.txt'],
|
||||||
'less than and greater than signs',
|
[
|
||||||
'file<name>.txt',
|
'less than and greater than signs',
|
||||||
'file\\<name\\>.txt',
|
'file<name>.txt',
|
||||||
],
|
'file\\<name\\>.txt',
|
||||||
])('should escape %s', (_, input, expected) => {
|
],
|
||||||
expect(escapePath(input)).toBe(expected);
|
[
|
||||||
|
'multiple special characters',
|
||||||
|
'my file (backup) [v1.2].txt',
|
||||||
|
'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
|
||||||
|
],
|
||||||
|
['normal file', 'normalfile.txt', 'normalfile.txt'],
|
||||||
|
['normal path', 'path/to/normalfile.txt', 'path/to/normalfile.txt'],
|
||||||
|
[
|
||||||
|
'real world example 1',
|
||||||
|
'My Documents/Project (2024)/file [backup].txt',
|
||||||
|
'My\\ Documents/Project\\ \\(2024\\)/file\\ \\[backup\\].txt',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'real world example 2',
|
||||||
|
'file with $special &chars!.txt',
|
||||||
|
'file\\ with\\ \\$special\\ \\&chars\\!.txt',
|
||||||
|
],
|
||||||
|
['empty string', '', ''],
|
||||||
|
[
|
||||||
|
'all special chars',
|
||||||
|
' ()[]{};&|*?$`\'"#!<>',
|
||||||
|
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\<\\>',
|
||||||
|
],
|
||||||
|
])('should escape %s', (_, input, expected) => {
|
||||||
|
expect(escapePath(input)).toBe(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle multiple special characters', () => {
|
describe('in windows', () => {
|
||||||
expect(escapePath('my file (backup) [v1.2].txt')).toBe(
|
beforeEach(() => mockPlatform('win32'));
|
||||||
'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not double-escape already escaped characters', () => {
|
it.each([
|
||||||
expect(escapePath('my\\ file.txt')).toBe('my\\ file.txt');
|
[
|
||||||
expect(escapePath('file\\(name\\).txt')).toBe('file\\(name\\).txt');
|
'spaces',
|
||||||
});
|
'C:\\path with spaces\\file.txt',
|
||||||
|
'"C:\\path with spaces\\file.txt"',
|
||||||
it('should handle escaped backslashes correctly', () => {
|
],
|
||||||
// Double backslash (escaped backslash) followed by space should escape the space
|
['parentheses', 'file(1).txt', '"file(1).txt"'],
|
||||||
expect(escapePath('path\\\\ file.txt')).toBe('path\\\\\\ file.txt');
|
['special chars', 'file&name.txt', '"file&name.txt"'],
|
||||||
// Triple backslash (escaped backslash + escaping backslash) followed by space should not double-escape
|
['caret', 'file^name.txt', '"file^name.txt"'],
|
||||||
expect(escapePath('path\\\\\\ file.txt')).toBe('path\\\\\\ file.txt');
|
['normal path', 'C:\\path\\to\\file.txt', 'C:\\path\\to\\file.txt'],
|
||||||
// Quadruple backslash (two escaped backslashes) followed by space should escape the space
|
])('should escape %s', (_, input, expected) => {
|
||||||
expect(escapePath('path\\\\\\\\ file.txt')).toBe('path\\\\\\\\\\ file.txt');
|
expect(escapePath(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle complex escaped backslash scenarios', () => {
|
|
||||||
// Escaped backslash before special character that needs escaping
|
|
||||||
expect(escapePath('file\\\\(test).txt')).toBe('file\\\\\\(test\\).txt');
|
|
||||||
// Multiple escaped backslashes
|
|
||||||
expect(escapePath('path\\\\\\\\with space.txt')).toBe(
|
|
||||||
'path\\\\\\\\with\\ space.txt',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle paths without special characters', () => {
|
|
||||||
expect(escapePath('normalfile.txt')).toBe('normalfile.txt');
|
|
||||||
expect(escapePath('path/to/normalfile.txt')).toBe('path/to/normalfile.txt');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle complex real-world examples', () => {
|
|
||||||
expect(escapePath('My Documents/Project (2024)/file [backup].txt')).toBe(
|
|
||||||
'My\\ Documents/Project\\ \\(2024\\)/file\\ \\[backup\\].txt',
|
|
||||||
);
|
|
||||||
expect(escapePath('file with $special &chars!.txt')).toBe(
|
|
||||||
'file\\ with\\ \\$special\\ \\&chars\\!.txt',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle empty strings', () => {
|
|
||||||
expect(escapePath('')).toBe('');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle paths with multiple special characters', () => {
|
|
||||||
expect(escapePath(' ()[]{};&|*?$`\'"#!<>')).toBe(
|
|
||||||
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\<\\>',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle tildes based on platform', () => {
|
|
||||||
const expected = process.platform === 'win32' ? '~' : '\\~';
|
|
||||||
expect(escapePath('~')).toBe(expected);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('unescapePath', () => {
|
describe('unescapePath', () => {
|
||||||
it.each([
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
['spaces', 'my\\ file.txt', 'my file.txt'],
|
|
||||||
['tabs', 'file\\\twith\\\ttabs.txt', 'file\twith\ttabs.txt'],
|
|
||||||
['parentheses', 'file\\(1\\).txt', 'file(1).txt'],
|
|
||||||
['square brackets', 'file\\[backup\\].txt', 'file[backup].txt'],
|
|
||||||
['curly braces', 'file\\{temp\\}.txt', 'file{temp}.txt'],
|
|
||||||
])('should unescape %s', (_, input, expected) => {
|
|
||||||
expect(unescapePath(input)).toBe(expected);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should unescape multiple special characters', () => {
|
describe('in posix', () => {
|
||||||
expect(unescapePath('my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt')).toBe(
|
beforeEach(() => mockPlatform('linux'));
|
||||||
'my file (backup) [v1.2].txt',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should handle paths without escaped characters', () => {
|
it.each([
|
||||||
expect(unescapePath('normalfile.txt')).toBe('normalfile.txt');
|
['spaces', 'my\\ file.txt', 'my file.txt'],
|
||||||
expect(unescapePath('path/to/normalfile.txt')).toBe(
|
['tabs', 'file\\\twith\\\ttabs.txt', 'file\twith\ttabs.txt'],
|
||||||
'path/to/normalfile.txt',
|
['parentheses', 'file\\(1\\).txt', 'file(1).txt'],
|
||||||
);
|
['square brackets', 'file\\[backup\\].txt', 'file[backup].txt'],
|
||||||
});
|
['curly braces', 'file\\{temp\\}.txt', 'file{temp}.txt'],
|
||||||
|
[
|
||||||
|
'multiple special characters',
|
||||||
|
'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
|
||||||
|
'my file (backup) [v1.2].txt',
|
||||||
|
],
|
||||||
|
['normal file', 'normalfile.txt', 'normalfile.txt'],
|
||||||
|
['normal path', 'path/to/normalfile.txt', 'path/to/normalfile.txt'],
|
||||||
|
['empty string', '', ''],
|
||||||
|
])('should unescape %s', (_, input, expected) => {
|
||||||
|
expect(unescapePath(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle all special characters but tilda', () => {
|
it.each([
|
||||||
expect(
|
|
||||||
unescapePath(
|
|
||||||
'\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\<\\>',
|
|
||||||
),
|
|
||||||
).toBe(' ()[]{};&|*?$`\'"#!<>');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should be the inverse of escapePath', () => {
|
|
||||||
const testCases = [
|
|
||||||
'my file.txt',
|
'my file.txt',
|
||||||
'file(1).txt',
|
'file(1).txt',
|
||||||
'file[backup].txt',
|
'file[backup].txt',
|
||||||
@@ -156,29 +143,35 @@ describe('unescapePath', () => {
|
|||||||
'file with $special &chars!.txt',
|
'file with $special &chars!.txt',
|
||||||
' ()[]{};&|*?$`\'"#!~<>',
|
' ()[]{};&|*?$`\'"#!~<>',
|
||||||
'file\twith\ttabs.txt',
|
'file\twith\ttabs.txt',
|
||||||
];
|
])('should unescape escaped %s', (input) => {
|
||||||
|
expect(unescapePath(escapePath(input))).toBe(input);
|
||||||
testCases.forEach((testCase) => {
|
|
||||||
expect(unescapePath(escapePath(testCase))).toBe(testCase);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle empty strings', () => {
|
describe('in windows', () => {
|
||||||
expect(unescapePath('')).toBe('');
|
beforeEach(() => mockPlatform('win32'));
|
||||||
});
|
|
||||||
|
|
||||||
it('should not affect backslashes not followed by special characters', () => {
|
it.each([
|
||||||
expect(unescapePath('file\\name.txt')).toBe('file\\name.txt');
|
[
|
||||||
expect(unescapePath('path\\to\\file.txt')).toBe('path\\to\\file.txt');
|
'quoted path',
|
||||||
});
|
'"C:\\path with spaces\\file.txt"',
|
||||||
|
'C:\\path with spaces\\file.txt',
|
||||||
|
],
|
||||||
|
['unquoted path', 'C:\\path\\to\\file.txt', 'C:\\path\\to\\file.txt'],
|
||||||
|
['partially quoted', '"C:\\path', '"C:\\path'],
|
||||||
|
['empty string', '', ''],
|
||||||
|
])('should unescape %s', (_, input, expected) => {
|
||||||
|
expect(unescapePath(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle escaped backslashes in unescaping', () => {
|
it.each([
|
||||||
// Should correctly unescape when there are escaped backslashes
|
'C:\\path\\to\\file.txt',
|
||||||
expect(unescapePath('path\\\\\\ file.txt')).toBe('path\\\\ file.txt');
|
'C:\\path with spaces\\file.txt',
|
||||||
expect(unescapePath('path\\\\\\\\\\ file.txt')).toBe(
|
'file(1).txt',
|
||||||
'path\\\\\\\\ file.txt',
|
'file&name.txt',
|
||||||
);
|
])('should unescape escaped %s', (input) => {
|
||||||
expect(unescapePath('file\\\\\\(test\\).txt')).toBe('file\\\\(test).txt');
|
expect(unescapePath(escapePath(input))).toBe(input);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -222,19 +215,9 @@ describe('isSubpath', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('isSubpath on Windows', () => {
|
describe('isSubpath on Windows', () => {
|
||||||
const originalPlatform = process.platform;
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeEach(() => mockPlatform('win32'));
|
||||||
Object.defineProperty(process, 'platform', {
|
|
||||||
value: 'win32',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(() => {
|
|
||||||
Object.defineProperty(process, 'platform', {
|
|
||||||
value: originalPlatform,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return true for a direct subpath on Windows', () => {
|
it('should return true for a direct subpath on Windows', () => {
|
||||||
expect(isSubpath('C:\\Users\\Test', 'C:\\Users\\Test\\file.txt')).toBe(
|
expect(isSubpath('C:\\Users\\Test', 'C:\\Users\\Test\\file.txt')).toBe(
|
||||||
|
|||||||
@@ -6,7 +6,6 @@
|
|||||||
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import process from 'node:process';
|
|
||||||
import * as crypto from 'node:crypto';
|
import * as crypto from 'node:crypto';
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -14,15 +13,6 @@ import { fileURLToPath } from 'node:url';
|
|||||||
export const GEMINI_DIR = '.gemini';
|
export const GEMINI_DIR = '.gemini';
|
||||||
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||||
|
|
||||||
/**
|
|
||||||
* Special characters that need to be escaped in file paths for shell compatibility.
|
|
||||||
* Note that windows doesn't escape tilda.
|
|
||||||
*/
|
|
||||||
export const SHELL_SPECIAL_CHARS =
|
|
||||||
process.platform === 'win32'
|
|
||||||
? /[ \t()[\]{};|*?$`'"#&<>!]/
|
|
||||||
: /[ \t()[\]{};|*?$`'"#&<>!~]/;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the home directory.
|
* Returns the home directory.
|
||||||
* If GEMINI_CLI_HOME environment variable is set, it returns its value.
|
* If GEMINI_CLI_HOME environment variable is set, it returns its value.
|
||||||
@@ -280,43 +270,43 @@ export function makeRelative(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escapes special characters in a file path like macOS terminal does.
|
* Escape paths for at-commands.
|
||||||
* Escapes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes,
|
*
|
||||||
* asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters.
|
* - Windows: double quoted if they contain special chars, otherwise bare
|
||||||
|
* - POSIX: backslash-escaped
|
||||||
*/
|
*/
|
||||||
export function escapePath(filePath: string): string {
|
export function escapePath(filePath: string): string {
|
||||||
let result = '';
|
if (process.platform === 'win32') {
|
||||||
for (let i = 0; i < filePath.length; i++) {
|
// Windows: Double quote if it contains space or special chars
|
||||||
const char = filePath[i];
|
if (/[\s()[\]{};|&^$!@%`'~]/.test(filePath)) {
|
||||||
|
return `"${filePath}"`;
|
||||||
// Count consecutive backslashes before this character
|
|
||||||
let backslashCount = 0;
|
|
||||||
for (let j = i - 1; j >= 0 && filePath[j] === '\\'; j--) {
|
|
||||||
backslashCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Character is already escaped if there's an odd number of backslashes before it
|
|
||||||
const isAlreadyEscaped = backslashCount % 2 === 1;
|
|
||||||
|
|
||||||
// Only escape if not already escaped
|
|
||||||
if (!isAlreadyEscaped && SHELL_SPECIAL_CHARS.test(char)) {
|
|
||||||
result += '\\' + char;
|
|
||||||
} else {
|
|
||||||
result += char;
|
|
||||||
}
|
}
|
||||||
|
return filePath;
|
||||||
|
} else {
|
||||||
|
// POSIX: Backslash escape
|
||||||
|
return filePath.replace(/([ \t()[\]{};|*?$`'"#&<>!~\\])/g, '\\$1');
|
||||||
}
|
}
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unescapes special characters in a file path.
|
* Unescapes paths for at-commands.
|
||||||
* Removes backslash escaping from shell metacharacters.
|
*
|
||||||
|
* - Windows: double quoted if they contain special chars, otherwise bare
|
||||||
|
* - POSIX: backslash-escaped
|
||||||
*/
|
*/
|
||||||
export function unescapePath(filePath: string): string {
|
export function unescapePath(filePath: string): string {
|
||||||
return filePath.replace(
|
if (process.platform === 'win32') {
|
||||||
new RegExp(`\\\\([${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`, 'g'),
|
if (
|
||||||
'$1',
|
filePath.length >= 2 &&
|
||||||
);
|
filePath.startsWith('"') &&
|
||||||
|
filePath.endsWith('"')
|
||||||
|
) {
|
||||||
|
return filePath.slice(1, -1);
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
} else {
|
||||||
|
return filePath.replace(/\\(.)/g, '$1');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -345,7 +335,7 @@ export function normalizePath(p: string): string {
|
|||||||
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
* @returns True if childPath is a subpath of parentPath, false otherwise.
|
||||||
*/
|
*/
|
||||||
export function isSubpath(parentPath: string, childPath: string): boolean {
|
export function isSubpath(parentPath: string, childPath: string): boolean {
|
||||||
const isWindows = os.platform() === 'win32';
|
const isWindows = process.platform === 'win32';
|
||||||
const pathModule = isWindows ? path.win32 : path;
|
const pathModule = isWindows ? path.win32 : path;
|
||||||
|
|
||||||
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
// On Windows, path.relative is case-insensitive. On POSIX, it's case-sensitive.
|
||||||
|
|||||||
Reference in New Issue
Block a user