Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 22 additions & 10 deletions packages/cli/src/ui/components/shared/text-buffer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
getTransformedImagePath,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
import { escapePath } from '@google/gemini-cli-core';

const defaultVisualLayout: VisualLayout = {
visualLines: [''],
Expand Down Expand Up @@ -1077,14 +1078,16 @@ describe('useTextBuffer', () => {
useTextBuffer({ viewport, escapePastedPaths: true }),
);
// 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 }));
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 invalidFile = path.join(tempDir, 'invalid.jpg');
fs.writeFileSync(validFile, '');
Expand All @@ -1098,7 +1101,7 @@ describe('useTextBuffer', () => {
);
const filePaths = `${validFile} ${invalidFile}`;
act(() => result.current.insert(filePaths, { paste: true }));
expect(getBufferState(result).text).toBe(`@${validFile} ${invalidFile} `);
expect(getBufferState(result).text).toBe(`${validFile} ${invalidFile}`);
});
});

Expand Down Expand Up @@ -2869,12 +2872,26 @@ describe('Unicode helper functions', () => {
});
});

const mockPlatform = (platform: string) => {
vi.stubGlobal(
'process',
Object.create(process, {
platform: {
get: () => platform,
},
}),
);
};

describe('Transformation Utilities', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

describe('getTransformedImagePath', () => {
beforeEach(() => mockPlatform('linux'));

it('should transform a simple image path', () => {
expect(getTransformedImagePath('@test.png')).toBe('[Image test.png]');
});
Expand Down Expand Up @@ -2905,11 +2922,6 @@ describe('Transformation Utilities', () => {
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', () => {
const input = '@path/to/my\\ file.png';
expect(getTransformedImagePath(input)).toBe('[Image my file.png]');
Expand Down
10 changes: 1 addition & 9 deletions packages/cli/src/ui/components/shared/text-buffer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2802,15 +2802,7 @@ export function useTextBuffer({
paste &&
escapePastedPaths
) {
let potentialPath = ch.trim();
const quoteMatch = potentialPath.match(/^'(.*)'$/);
if (quoteMatch) {
potentialPath = quoteMatch[1];
}

potentialPath = potentialPath.trim();

const processed = parsePastedPaths(potentialPath);
const processed = parsePastedPaths(ch.trim());
if (processed) {
textToInsert = processed;
}
Expand Down
41 changes: 41 additions & 0 deletions packages/cli/src/ui/hooks/atCommandProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe('handleAtCommand', () => {
afterEach(async () => {
abortController.abort();
await fsPromises.rm(testRootDir, { recursive: true, force: true });
vi.unstubAllGlobals();
});

it('should pass through query if no @ command is present', async () => {
Expand Down Expand Up @@ -319,6 +320,46 @@ describe('handleAtCommand', () => {
);
}, 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 () => {
const nnbsp = '\u202F';
const fileContent = 'NNBSP file content.';
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/ui/hooks/atCommandProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ const REF_CONTENT_FOOTER = `\n${REFERENCE_CONTENT_END}`;
* Regex source for the path/command part of an @ reference.
* It uses strict ASCII whitespace delimiters to allow Unicode characters like NNBSP in filenames.
*
* 1. \\. matches any escaped character (e.g., \ ).
* 2. [^ \t\n\r,;!?()\[\]{}.] matches any character that is NOT a delimiter and NOT a period.
* 3. \.(?!$|[ \t\n\r]) matches a period ONLY if it is NOT followed by whitespace or end-of-string.
* 1. "(?:[^"]*)" matches a double-quoted string (for Windows paths with spaces).
* 2. \\. matches any escaped character (e.g., \ ).
* 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 =
'(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r]))+';
'(?:(?:"(?:[^"]*)")|(?:\\\\.|[^ \\t\\n\\r,;!?()\\[\\]{}.]|\\.(?!$|[ \\t\\n\\r])))+';

interface HandleAtCommandParams {
query: string;
Expand Down Expand Up @@ -85,8 +86,8 @@ function parseAllAtCommands(query: string): AtCommandPart[] {
});
}

// unescapePath expects the @ symbol to be present, and will handle it.
const atPath = unescapePath(fullMatch);
// We strip the @ before unescaping so that unescapePath can handle quoted paths correctly on Windows.
const atPath = '@' + unescapePath(fullMatch.substring(1));
parts.push({ type: 'atPath', content: atPath });

lastIndex = matchIndex + fullMatch.length;
Expand Down
Loading
Loading