refactor: push isValidPath() into parsePastedPaths() (#18664)

This commit is contained in:
Tommaso Sciortino
2026-02-09 13:19:51 -08:00
committed by GitHub
parent 9e41b2cd89
commit 1b98c1f806
15 changed files with 247 additions and 261 deletions
+1 -10
View File
@@ -88,7 +88,6 @@ import { calculatePromptWidths } from './components/InputPrompt.js';
import { useApp, useStdout, useStdin } from 'ink';
import { calculateMainAreaWidth } from './utils/ui-sizing.js';
import ansiEscapes from 'ansi-escapes';
import * as fs from 'node:fs';
import { basename } from 'node:path';
import { computeTerminalTitle } from '../utils/windowTitle.js';
import { useTextBuffer } from './components/shared/text-buffer.js';
@@ -468,14 +467,6 @@ export const AppContainer = (props: AppContainerProps) => {
const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100);
const isValidPath = useCallback((filePath: string): boolean => {
try {
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
} catch (_e) {
return false;
}
}, []);
const getPreferredEditor = useCallback(
() => settings.merged.general.preferredEditor as EditorType,
[settings.merged.general.preferredEditor],
@@ -486,7 +477,7 @@ export const AppContainer = (props: AppContainerProps) => {
viewport: { height: 10, width: inputWidth },
stdin,
setRawMode,
isValidPath,
escapePastedPaths: true,
shellModeActive,
getPreferredEditor,
});
@@ -49,7 +49,6 @@ export function ApiAuthDialog({
width: viewportWidth,
height: 4,
},
isValidPath: () => false, // No path validation needed for API key
inputFilter: (text) =>
text.replace(/[^a-zA-Z0-9_-]/g, '').replace(/[\r\n]/g, ''),
singleLine: true,
@@ -285,7 +285,6 @@ const TextQuestionView: React.FC<TextQuestionViewProps> = ({
initialText: initialAnswer,
viewport: { width: Math.max(1, bufferWidth), height: 1 },
singleLine: true,
isValidPath: () => false,
});
const { text: textValue } = buffer;
@@ -564,7 +563,6 @@ const ChoiceQuestionView: React.FC<ChoiceQuestionViewProps> = ({
initialText: initialCustomText,
viewport: { width: Math.max(1, bufferWidth), height: 1 },
singleLine: true,
isValidPath: () => false,
});
const customOptionText = customBuffer.text;
@@ -70,7 +70,7 @@ export const ConfigExtensionDialog: React.FC<ConfigExtensionDialogProps> = ({
initialText: '',
viewport: { width: 80, height: 1 },
singleLine: true,
isValidPath: () => true,
escapePastedPaths: true,
});
const mounted = useRef(true);
@@ -219,7 +219,6 @@ export function SettingsDialog({
width: viewportWidth,
height: 1,
},
isValidPath: () => false,
singleLine: true,
onChange: (text) => setSearchQuery(text),
});
@@ -19,7 +19,6 @@ describe('text-buffer performance', () => {
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
}),
);
@@ -52,7 +51,6 @@ describe('text-buffer performance', () => {
useTextBuffer({
initialText,
viewport,
isValidPath: () => false,
}),
);
@@ -7,10 +7,14 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import stripAnsi from 'strip-ansi';
import { act } from 'react';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import {
renderHook,
renderHookWithProviders,
} from '../../../test-utils/render.js';
import type {
Viewport,
TextBuffer,
@@ -738,9 +742,7 @@ describe('useTextBuffer', () => {
describe('Initialization', () => {
it('should initialize with empty text and cursor at (0,0) by default', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const state = getBufferState(result);
expect(state.text).toBe('');
expect(state.lines).toEqual(['']);
@@ -756,7 +758,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'hello',
viewport,
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -774,7 +775,6 @@ describe('useTextBuffer', () => {
initialText: 'hello\nworld',
initialCursorOffset: 7, // Should be at 'o' in 'world'
viewport,
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -793,7 +793,6 @@ describe('useTextBuffer', () => {
initialText: 'The quick brown fox jumps over the lazy dog.',
initialCursorOffset: 2, // After '好'
viewport: { width: 15, height: 4 },
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -810,7 +809,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'The quick brown fox jumps over the lazy dog.',
viewport: { width: 15, height: 4 },
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -830,7 +828,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: '123456789012345ABCDEFG', // 4 chars, 12 bytes
viewport: { width: 15, height: 2 },
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -846,7 +843,6 @@ describe('useTextBuffer', () => {
initialText: '你好世界', // 4 chars, 12 bytes
initialCursorOffset: 2, // After '好'
viewport: { width: 5, height: 2 },
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -861,9 +857,7 @@ describe('useTextBuffer', () => {
describe('Basic Editing', () => {
it('insert: should insert a character and update cursor', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => result.current.insert('a'));
let state = getBufferState(result);
expect(state.text).toBe('a');
@@ -882,7 +876,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'abc',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('right'));
@@ -893,9 +886,7 @@ describe('useTextBuffer', () => {
});
it('insert: should use placeholder for large text paste', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
const state = getBufferState(result);
@@ -906,9 +897,7 @@ describe('useTextBuffer', () => {
});
it('insert: should NOT use placeholder for large text if NOT a paste', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: false }));
const state = getBufferState(result);
@@ -916,9 +905,7 @@ describe('useTextBuffer', () => {
});
it('insert: should clean up pastedContent when placeholder is deleted', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
@@ -931,9 +918,7 @@ describe('useTextBuffer', () => {
});
it('insert: should clean up pastedContent when placeholder is removed via atomic backspace', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeText = '1\n2\n3\n4\n5\n6';
act(() => result.current.insert(largeText, { paste: true }));
expect(result.current.pastedContent['[Pasted Text: 6 lines]']).toBe(
@@ -955,7 +940,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'ab',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end')); // cursor at [0,2]
@@ -974,7 +958,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'a\nb',
viewport,
isValidPath: () => false,
}),
);
act(() => {
@@ -1002,7 +985,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'a\nb',
viewport,
isValidPath: () => false,
}),
);
// cursor at [0,0]
@@ -1022,36 +1004,49 @@ describe('useTextBuffer', () => {
});
describe('Drag and Drop File Paths', () => {
let tempDir: string;
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gemini-cli-test-'));
});
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
it('should prepend @ to a valid file path on insert', () => {
const filePath = path.join(tempDir, 'file.txt');
fs.writeFileSync(filePath, '');
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => true }),
useTextBuffer({ viewport, escapePastedPaths: true }),
);
const filePath = '/path/to/a/valid/file.txt';
act(() => result.current.insert(filePath, { paste: true }));
expect(getBufferState(result).text).toBe(`@${filePath} `);
});
it('should not prepend @ to an invalid file path on insert', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const notAPath = 'this is just some long text';
const { result } = renderHook(() => useTextBuffer({ viewport }));
const notAPath = path.join(tempDir, 'non_existent.txt');
act(() => result.current.insert(notAPath, { paste: true }));
expect(getBufferState(result).text).toBe(notAPath);
});
it('should handle quoted paths', () => {
const filePath = path.join(tempDir, 'file.txt');
fs.writeFileSync(filePath, '');
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => true }),
useTextBuffer({ viewport, escapePastedPaths: true }),
);
const filePath = "'/path/to/a/valid/file.txt'";
act(() => result.current.insert(filePath, { paste: true }));
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt `);
const quotedPath = `'${filePath}'`;
act(() => result.current.insert(quotedPath, { paste: true }));
expect(getBufferState(result).text).toBe(`@${filePath} `);
});
it('should not prepend @ to short text that is not a path', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => true }),
useTextBuffer({ viewport, escapePastedPaths: true }),
);
const shortText = 'ab';
act(() => result.current.insert(shortText, { paste: true }));
@@ -1059,43 +1054,51 @@ 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 file1 = path.join(tempDir, 'file1.txt');
const file2 = path.join(tempDir, 'file2.txt');
fs.writeFileSync(file1, '');
fs.writeFileSync(file2, '');
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: (p) => validPaths.has(p) }),
useTextBuffer({ viewport, escapePastedPaths: true }),
);
const filePaths = '/path/to/file1.txt /path/to/file2.txt';
const filePaths = `${file1} ${file2}`;
act(() => result.current.insert(filePaths, { paste: true }));
expect(getBufferState(result).text).toBe(
'@/path/to/file1.txt @/path/to/file2.txt ',
);
expect(getBufferState(result).text).toBe(`@${file1} @${file2} `);
});
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 file1 = path.join(tempDir, 'my file.txt');
const file2 = path.join(tempDir, 'other.txt');
fs.writeFileSync(file1, '');
fs.writeFileSync(file2, '');
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: (p) => validPaths.has(p) }),
useTextBuffer({ viewport, escapePastedPaths: true }),
);
const filePaths = '/path/to/my\\ file.txt /other/path.txt';
// Construct escaped path string: "/path/to/my\ file.txt /path/to/other.txt"
const escapedFile1 = file1.replace(/ /g, '\\ ');
const filePaths = `${escapedFile1} ${file2}`;
act(() => result.current.insert(filePaths, { paste: true }));
expect(getBufferState(result).text).toBe(
'@/path/to/my\\ file.txt @/other/path.txt ',
);
expect(getBufferState(result).text).toBe(`@${escapedFile1} @${file2} `);
});
it('should only prepend @ to valid paths in multi-path paste', () => {
const validFile = path.join(tempDir, 'valid.txt');
const invalidFile = path.join(tempDir, 'invalid.jpg');
fs.writeFileSync(validFile, '');
// Do not create invalidFile
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: (p) => p.endsWith('.txt'),
escapePastedPaths: true,
}),
);
const filePaths = '/valid/file.txt /invalid/file.jpg';
const filePaths = `${validFile} ${invalidFile}`;
act(() => result.current.insert(filePaths, { paste: true }));
expect(getBufferState(result).text).toBe(
'@/valid/file.txt /invalid/file.jpg ',
);
expect(getBufferState(result).text).toBe(`@${validFile} ${invalidFile} `);
});
});
@@ -1104,7 +1107,7 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => true,
escapePastedPaths: true,
shellModeActive: true,
}),
);
@@ -1117,7 +1120,7 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => true,
escapePastedPaths: true,
shellModeActive: true,
}),
);
@@ -1130,7 +1133,7 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
shellModeActive: true,
}),
);
@@ -1143,7 +1146,7 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => true,
escapePastedPaths: true,
shellModeActive: true,
}),
);
@@ -1165,7 +1168,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'long line1next line2', // Corrected: was 'long line1next line2'
viewport: { width: 5, height: 4 },
isValidPath: () => false,
}),
);
// Initial cursor [0,0] logical, visual [0,0] ("l" of "long ")
@@ -1192,7 +1194,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: text,
viewport,
isValidPath: () => false,
}),
);
expect(result.current.allVisualLines).toEqual(['abcde', 'xy', '12345']);
@@ -1234,7 +1235,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText,
viewport: { width: 5, height: 5 },
isValidPath: () => false,
}),
);
expect(result.current.allVisualLines).toEqual([
@@ -1263,7 +1263,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'This is a very long line of text.', // 33 chars
viewport: { width: 10, height: 5 },
isValidPath: () => false,
}),
);
const state = getBufferState(result);
@@ -1284,7 +1283,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'l1\nl2\nl3\nl4\nl5',
viewport: { width: 5, height: 3 }, // Can show 3 visual lines
isValidPath: () => false,
}),
);
// Initial: l1, l2, l3 visible. visualScrollRow = 0. visualCursor = [0,0]
@@ -1330,9 +1328,7 @@ describe('useTextBuffer', () => {
describe('Undo/Redo', () => {
it('should undo and redo an insert operation', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => result.current.insert('a'));
expect(getBufferState(result).text).toBe('a');
@@ -1350,7 +1346,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'test',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end'));
@@ -1369,9 +1364,7 @@ describe('useTextBuffer', () => {
describe('Unicode Handling', () => {
it('insert: should correctly handle multi-byte unicode characters', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => result.current.insert('你好'));
const state = getBufferState(result);
expect(state.text).toBe('你好');
@@ -1384,7 +1377,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: '你好',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end')); // cursor at [0,2]
@@ -1404,7 +1396,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: '🐶🐱',
viewport: { width: 5, height: 1 },
isValidPath: () => false,
}),
);
// Initial: visualCursor [0,0]
@@ -1432,7 +1423,6 @@ describe('useTextBuffer', () => {
const { result } = renderHook(() =>
useTextBuffer({
viewport: { width: 10, height: 5 },
isValidPath: () => false,
}),
);
@@ -1484,7 +1474,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: '你好', // 2 chars, width 4
viewport: { width: 10, height: 1 },
isValidPath: () => false,
}),
);
@@ -1510,9 +1499,7 @@ describe('useTextBuffer', () => {
describe('handleInput', () => {
it('should insert printable characters', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput({
name: 'h',
@@ -1539,9 +1526,7 @@ describe('useTextBuffer', () => {
});
it('should handle "Enter" key as newline', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput({
name: 'return',
@@ -1557,9 +1542,7 @@ describe('useTextBuffer', () => {
});
it('should handle Ctrl+J as newline', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput({
name: 'j',
@@ -1575,9 +1558,7 @@ describe('useTextBuffer', () => {
});
it('should do nothing for a tab key press', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput({
name: 'tab',
@@ -1593,9 +1574,7 @@ describe('useTextBuffer', () => {
});
it('should do nothing for a shift tab key press', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput({
name: 'tab',
@@ -1615,7 +1594,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'hello',
viewport,
isValidPath: () => false,
}),
);
expect(getBufferState(result).text).toBe('hello');
@@ -1636,9 +1614,7 @@ describe('useTextBuffer', () => {
});
it('should NOT handle CLEAR_INPUT if buffer is empty', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
let handled = true;
act(() => {
handled = result.current.handleInput({
@@ -1659,7 +1635,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'a',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end'));
@@ -1682,7 +1657,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'abcde',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end')); // cursor at the end
@@ -1726,7 +1700,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'abcde',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end')); // cursor at the end
@@ -1744,7 +1717,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'abcde',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end')); // cursor at the end
@@ -1762,7 +1734,6 @@ describe('useTextBuffer', () => {
useTextBuffer({
initialText: 'ab',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.move('end')); // cursor [0,2]
@@ -1793,9 +1764,7 @@ describe('useTextBuffer', () => {
});
it('should strip ANSI escape codes when pasting text', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
// Simulate pasting by calling handleInput with a string longer than 1 char
act(() => {
@@ -1813,9 +1782,7 @@ describe('useTextBuffer', () => {
});
it('should handle VSCode terminal Shift+Enter as newline', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput({
name: 'return',
@@ -1839,9 +1806,7 @@ It is a long established fact that a reader will be distracted by the readable c
Where does it come from?
Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots in a piece of classical Latin literature from 45 BC, making it over 2000 years old. Richard McClintock, a Latin professor at Hampden-Sydney College in Virginia, looked up one of the more obscure Latin words, consectetur, from a Lore
`;
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
// Simulate pasting the long text multiple times
act(() => {
@@ -1887,7 +1852,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: '@pac',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 1, 0, 4, 'packages'));
@@ -1901,7 +1865,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'hello\nworld\nagain',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 2, 1, 3, ' new ')); // replace 'llo\nwor' with ' new '
@@ -1915,7 +1878,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'hello world',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 5, 0, 11, '')); // delete ' world'
@@ -1929,7 +1891,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'world',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 0, 0, 0, 'hello '));
@@ -1943,7 +1904,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'hello',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 5, 0, 5, ' world'));
@@ -1957,7 +1917,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'old text',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 0, 0, 8, 'new text'));
@@ -1971,7 +1930,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'hello *** world',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 6, 0, 9, '你好'));
@@ -1985,7 +1943,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'test',
viewport,
isValidPath: () => false,
}),
);
act(() => {
@@ -2005,7 +1962,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'first\nsecond\nthird',
viewport,
isValidPath: () => false,
}),
);
act(() => result.current.replaceRange(0, 2, 2, 3, 'X')); // Replace 'rst\nsecond\nthi'
@@ -2019,7 +1975,6 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'one two three',
viewport,
isValidPath: () => false,
}),
);
// Replace "two" with "new\nline"
@@ -2063,9 +2018,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
desc: 'pasted text with ANSI',
},
])('should strip $desc from input', ({ input, expected }) => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
act(() => {
result.current.handleInput(createInput(input));
});
@@ -2073,9 +2026,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
});
it('should not strip standard characters or newlines', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const validText = 'Hello World\nThis is a test.';
act(() => {
result.current.handleInput(createInput(validText));
@@ -2084,9 +2035,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
});
it('should sanitize large text (>5000 chars) and strip unsafe characters', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const unsafeChars = '\x07\x08\x0B\x0C';
const largeTextWithUnsafe =
'safe text'.repeat(600) + unsafeChars + 'more safe text';
@@ -2115,9 +2064,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
});
it('should sanitize large ANSI text (>5000 chars) and strip escape codes', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const largeTextWithAnsi =
'\x1B[31m' +
'red text'.repeat(800) +
@@ -2149,9 +2096,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
});
it('should not strip popular emojis', () => {
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath: () => false }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
const emojis = '🐍🐳🦀🦄';
act(() => {
result.current.handleInput({
@@ -2173,7 +2118,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
inputFilter: (text) => text.replace(/[^0-9]/g, ''),
}),
);
@@ -2186,7 +2131,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
inputFilter: (text) => text.replace(/[^0-9]/g, ''),
}),
);
@@ -2199,7 +2144,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
inputFilter: (text) => text.toUpperCase(),
}),
);
@@ -2212,7 +2157,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
inputFilter: (text) => text, // Allow everything including newlines
}),
);
@@ -2227,7 +2172,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
inputFilter: (text) => text.replace(/\n/g, ''), // Filter out newlines
}),
);
@@ -2260,11 +2205,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
describe('Memoization', () => {
it('should keep action references stable across re-renders', () => {
// We pass a stable `isValidPath` so that callbacks that depend on it
// are not recreated on every render.
const isValidPath = () => false;
const { result, rerender } = renderHook(() =>
useTextBuffer({ viewport, isValidPath }),
useTextBuffer({ viewport }),
);
const initialInsert = result.current.insert;
@@ -2281,10 +2223,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
});
it('should have memoized actions that operate on the latest state', () => {
const isValidPath = () => false;
const { result } = renderHook(() =>
useTextBuffer({ viewport, isValidPath }),
);
const { result } = renderHook(() => useTextBuffer({ viewport }));
// Store a reference to the memoized insert function.
const memoizedInsert = result.current.insert;
@@ -2310,7 +2249,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
singleLine: true,
}),
);
@@ -2325,7 +2264,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
useTextBuffer({
initialText: 'ab',
viewport,
isValidPath: () => false,
singleLine: true,
}),
);
@@ -2341,7 +2280,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
singleLine: true,
}),
);
@@ -2363,7 +2302,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
singleLine: true,
}),
);
@@ -2385,7 +2324,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
const { result } = renderHook(() =>
useTextBuffer({
viewport,
isValidPath: () => false,
singleLine: true,
}),
);
@@ -2841,7 +2780,6 @@ describe('Unicode helper functions', () => {
initialText: '你好世界',
initialCursorOffset: 4, // End of string
viewport,
isValidPath: () => false,
}),
);
@@ -2900,7 +2838,6 @@ describe('Unicode helper functions', () => {
initialText: 'Hello你好World',
initialCursorOffset: 10, // End
viewport,
isValidPath: () => false,
}),
);
@@ -3154,7 +3091,7 @@ describe('Transformation Utilities', () => {
useTextBuffer({
initialText: 'original line',
viewport,
isValidPath: () => true,
escapePastedPaths: true,
}),
);
@@ -3177,7 +3114,7 @@ describe('Transformation Utilities', () => {
initialText:
'a very long line that will wrap when the viewport is small',
viewport: vp,
isValidPath: () => true,
escapePastedPaths: true,
}),
{ initialProps: { vp: viewport } },
);
@@ -3198,7 +3135,7 @@ describe('Transformation Utilities', () => {
useTextBuffer({
initialText: text,
viewport,
isValidPath: () => true,
escapePastedPaths: true,
}),
);
@@ -3231,7 +3168,7 @@ describe('Transformation Utilities', () => {
useTextBuffer({
initialText,
viewport,
isValidPath: () => true,
escapePastedPaths: true,
}),
);
@@ -3265,7 +3202,6 @@ describe('Transformation Utilities', () => {
useTextBuffer({
initialText: placeholder,
viewport: scrollViewport,
isValidPath: () => false,
}),
);
@@ -757,7 +757,7 @@ interface UseTextBufferProps {
stdin?: NodeJS.ReadStream | null; // For external editor
setRawMode?: (mode: boolean) => void; // For external editor
onChange?: (text: string) => void; // Callback for when text changes
isValidPath: (path: string) => boolean;
escapePastedPaths?: boolean;
shellModeActive?: boolean; // Whether the text buffer is in shell mode
inputFilter?: (text: string) => string; // Optional filter for input text
singleLine?: boolean;
@@ -2678,7 +2678,7 @@ export function useTextBuffer({
stdin,
setRawMode,
onChange,
isValidPath,
escapePastedPaths = false,
shellModeActive = false,
inputFilter,
singleLine = false,
@@ -2795,7 +2795,8 @@ export function useTextBuffer({
if (
ch.length >= minLengthToInferAsDragDrop &&
!shellModeActive &&
paste
paste &&
escapePastedPaths
) {
let potentialPath = ch.trim();
const quoteMatch = potentialPath.match(/^'(.*)'$/);
@@ -2805,7 +2806,7 @@ export function useTextBuffer({
potentialPath = potentialPath.trim();
const processed = parsePastedPaths(potentialPath, isValidPath);
const processed = parsePastedPaths(potentialPath);
if (processed) {
textToInsert = processed;
}
@@ -2827,7 +2828,7 @@ export function useTextBuffer({
dispatch({ type: 'insert', payload: currentText, isPaste: paste });
}
},
[isValidPath, shellModeActive],
[shellModeActive, escapePastedPaths],
);
const newline = useCallback((): void => {
@@ -99,7 +99,6 @@ export const TriageIssues = ({
const commentBuffer = useTextBuffer({
initialText: '',
viewport: { width: 80, height: 5 },
isValidPath: () => false,
});
const currentIssue = state.issues[state.currentIndex];
@@ -105,7 +105,6 @@ describe('useCommandCompletion', () => {
initialText: text,
initialCursorOffset: cursorOffset ?? text.length,
viewport: { width: 80, height: 20 },
isValidPath: () => false,
onChange: () => {},
});
}
@@ -24,7 +24,6 @@ describe('useReverseSearchCompletion', () => {
initialText: text,
initialCursorOffset: text.length,
viewport: { width: 80, height: 20 },
isValidPath: () => false,
onChange: () => {},
});
}
+106 -57
View File
@@ -14,8 +14,14 @@ import {
type Mock,
} from 'vitest';
import * as fs from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { spawn, execSync } from 'node:child_process';
import {
createWriteStream,
existsSync,
statSync,
type Stats,
type WriteStream,
} from 'node:fs';
import { spawn, execSync, type ChildProcess } from 'node:child_process';
import EventEmitter from 'node:events';
import { Stream } from 'node:stream';
import * as path from 'node:path';
@@ -24,6 +30,8 @@ import * as path from 'node:path';
vi.mock('node:fs/promises');
vi.mock('node:fs', () => ({
createWriteStream: vi.fn(),
existsSync: vi.fn(),
statSync: vi.fn(),
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
@@ -67,6 +75,12 @@ describe('clipboardUtils', () => {
// Dynamic module instance for stateful functions
let clipboardUtils: ClipboardUtilsModule;
const MOCK_FILE_STATS = {
isFile: () => true,
size: 100,
mtimeMs: Date.now(),
} as unknown as Stats;
beforeEach(async () => {
vi.resetAllMocks();
originalPlatform = process.platform;
@@ -97,9 +111,10 @@ describe('clipboardUtils', () => {
it('should return true when wl-paste shows image type (Wayland)', async () => {
setPlatform('linux');
process.env['XDG_SESSION_TYPE'] = 'wayland';
(execSync as Mock).mockReturnValue(Buffer.from('')); // command -v succeeds
(spawnAsync as Mock).mockResolvedValueOnce({
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
vi.mocked(spawnAsync).mockResolvedValueOnce({
stdout: 'image/png\ntext/plain',
stderr: '',
});
const result = await clipboardUtils.clipboardHasImage();
@@ -115,9 +130,10 @@ describe('clipboardUtils', () => {
it('should return true when xclip shows image type (X11)', async () => {
setPlatform('linux');
process.env['XDG_SESSION_TYPE'] = 'x11';
(execSync as Mock).mockReturnValue(Buffer.from('')); // command -v succeeds
(spawnAsync as Mock).mockResolvedValueOnce({
vi.mocked(execSync).mockReturnValue(Buffer.from('')); // command -v succeeds
vi.mocked(spawnAsync).mockResolvedValueOnce({
stdout: 'image/png\nTARGETS',
stderr: '',
});
const result = await clipboardUtils.clipboardHasImage();
@@ -139,8 +155,8 @@ describe('clipboardUtils', () => {
it('should return false if tool fails', async () => {
setPlatform('linux');
process.env['XDG_SESSION_TYPE'] = 'wayland';
(execSync as Mock).mockReturnValue(Buffer.from(''));
(spawnAsync as Mock).mockRejectedValueOnce(new Error('wl-paste failed'));
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
vi.mocked(spawnAsync).mockRejectedValueOnce(new Error('wl-paste failed'));
const result = await clipboardUtils.clipboardHasImage();
@@ -150,8 +166,11 @@ describe('clipboardUtils', () => {
it('should return false if no image type is found', async () => {
setPlatform('linux');
process.env['XDG_SESSION_TYPE'] = 'wayland';
(execSync as Mock).mockReturnValue(Buffer.from(''));
(spawnAsync as Mock).mockResolvedValueOnce({ stdout: 'text/plain' });
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
vi.mocked(spawnAsync).mockResolvedValueOnce({
stdout: 'text/plain',
stderr: '',
});
const result = await clipboardUtils.clipboardHasImage();
@@ -161,7 +180,7 @@ describe('clipboardUtils', () => {
it('should return false if tool not found', async () => {
setPlatform('linux');
process.env['XDG_SESSION_TYPE'] = 'wayland';
(execSync as Mock).mockImplementation(() => {
vi.mocked(execSync).mockImplementation(() => {
throw new Error('Command not found');
});
@@ -177,8 +196,8 @@ describe('clipboardUtils', () => {
beforeEach(() => {
setPlatform('linux');
(fs.mkdir as Mock).mockResolvedValue(undefined);
(fs.unlink as Mock).mockResolvedValue(undefined);
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
vi.mocked(fs.unlink).mockResolvedValue(undefined);
});
const createMockChildProcess = (
@@ -209,31 +228,36 @@ describe('clipboardUtils', () => {
hasImage = true,
) => {
process.env['XDG_SESSION_TYPE'] = type;
(execSync as Mock).mockReturnValue(Buffer.from(''));
(spawnAsync as Mock).mockResolvedValueOnce({
vi.mocked(execSync).mockReturnValue(Buffer.from(''));
vi.mocked(spawnAsync).mockResolvedValueOnce({
stdout: hasImage ? 'image/png' : 'text/plain',
stderr: '',
});
await clipboardUtils.clipboardHasImage();
(spawnAsync as Mock).mockClear();
(execSync as Mock).mockClear();
vi.mocked(spawnAsync).mockClear();
vi.mocked(execSync).mockClear();
};
it('should save image using wl-paste if detected', async () => {
await primeClipboardTool('wayland');
// Mock fs.stat to return size > 0
(fs.stat as Mock).mockResolvedValue({ size: 100, mtimeMs: Date.now() });
vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS);
// Mock spawn to return a successful process for wl-paste
const mockChild = createMockChildProcess(true, 0);
(spawn as Mock).mockReturnValueOnce(mockChild);
vi.mocked(spawn).mockReturnValueOnce(
mockChild as unknown as ChildProcess,
);
// Mock createWriteStream
const mockStream = new EventEmitter() as EventEmitter & {
writableFinished: boolean;
};
mockStream.writableFinished = false;
(createWriteStream as Mock).mockReturnValue(mockStream);
vi.mocked(createWriteStream).mockReturnValue(
mockStream as unknown as WriteStream,
);
// Use dynamic instance
const promise = clipboardUtils.saveClipboardImage(mockTargetDir);
@@ -254,16 +278,18 @@ describe('clipboardUtils', () => {
await primeClipboardTool('wayland');
// Mock fs.stat to return size > 0
(fs.stat as Mock).mockResolvedValue({ size: 100, mtimeMs: Date.now() });
vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS);
// wl-paste fails (non-zero exit code)
const child1 = createMockChildProcess(true, 1);
(spawn as Mock).mockReturnValueOnce(child1);
vi.mocked(spawn).mockReturnValueOnce(child1 as unknown as ChildProcess);
const mockStream1 = new EventEmitter() as EventEmitter & {
writableFinished: boolean;
};
(createWriteStream as Mock).mockReturnValueOnce(mockStream1);
vi.mocked(createWriteStream).mockReturnValueOnce(
mockStream1 as unknown as WriteStream,
);
const promise = clipboardUtils.saveClipboardImage(mockTargetDir);
@@ -281,18 +307,22 @@ describe('clipboardUtils', () => {
await primeClipboardTool('x11');
// Mock fs.stat to return size > 0
(fs.stat as Mock).mockResolvedValue({ size: 100, mtimeMs: Date.now() });
vi.mocked(fs.stat).mockResolvedValue(MOCK_FILE_STATS);
// Mock spawn to return a successful process for xclip
const mockChild = createMockChildProcess(true, 0);
(spawn as Mock).mockReturnValueOnce(mockChild);
vi.mocked(spawn).mockReturnValueOnce(
mockChild as unknown as ChildProcess,
);
// Mock createWriteStream
const mockStream = new EventEmitter() as EventEmitter & {
writableFinished: boolean;
};
mockStream.writableFinished = false;
(createWriteStream as Mock).mockReturnValue(mockStream);
vi.mocked(createWriteStream).mockReturnValue(
mockStream as unknown as WriteStream,
);
const promise = clipboardUtils.saveClipboardImage(mockTargetDir);
@@ -397,64 +427,71 @@ describe('clipboardUtils', () => {
describe('parsePastedPaths', () => {
it('should return null for empty string', () => {
const result = parsePastedPaths('', () => true);
const result = parsePastedPaths('');
expect(result).toBe(null);
});
it('should add @ prefix to single valid path', () => {
const result = parsePastedPaths('/path/to/file.txt', () => true);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
const result = parsePastedPaths('/path/to/file.txt');
expect(result).toBe('@/path/to/file.txt ');
});
it('should return null for single invalid path', () => {
const result = parsePastedPaths('/path/to/file.txt', () => false);
vi.mocked(existsSync).mockReturnValue(false);
const result = parsePastedPaths('/path/to/file.txt');
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),
vi.mocked(existsSync).mockImplementation((p) =>
validPaths.has(p as string),
);
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
const result = parsePastedPaths('/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', () => {
const result = parsePastedPaths(
'/valid/file.txt /invalid/file.jpg',
(p) => p.endsWith('.txt'),
vi.mocked(existsSync).mockImplementation((p) =>
(p as string).endsWith('.txt'),
);
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
const result = parsePastedPaths('/valid/file.txt /invalid/file.jpg');
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,
);
vi.mocked(existsSync).mockReturnValue(false);
const result = parsePastedPaths('/path/to/file1.txt /path/to/file2.txt');
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),
vi.mocked(existsSync).mockImplementation((p) =>
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', () => {
// 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);
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',
@@ -464,33 +501,45 @@ describe('clipboardUtils', () => {
});
it('should handle single path with unescaped spaces from copy-paste', () => {
const result = parsePastedPaths('/path/to/my file.txt', () => true);
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 Windows path', () => {
const result = parsePastedPaths('C:\\Users\\file.txt', () => true);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
const result = parsePastedPaths('C:\\Users\\file.txt');
expect(result).toBe('@C:\\Users\\file.txt ');
});
it('should handle Windows path with unescaped spaces', () => {
const result = parsePastedPaths('C:\\My Documents\\file.txt', () => true);
vi.mocked(existsSync).mockReturnValue(true);
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
const result = parsePastedPaths('C:\\My Documents\\file.txt');
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),
vi.mocked(existsSync).mockImplementation((p) =>
validPaths.has(p as string),
);
vi.mocked(statSync).mockReturnValue(MOCK_FILE_STATS);
const result = parsePastedPaths('C:\\file1.txt D:\\file2.txt');
expect(result).toBe('@C:\\file1.txt @D:\\file2.txt ');
});
it('should handle Windows UNC path', () => {
const result = parsePastedPaths(
'\\\\server\\share\\file.txt',
() => true,
);
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 ');
});
});
+15 -8
View File
@@ -5,7 +5,7 @@
*/
import * as fs from 'node:fs/promises';
import { createWriteStream } from 'node:fs';
import { createWriteStream, existsSync, statSync } from 'node:fs';
import { execSync, spawn } from 'node:child_process';
import * as path from 'node:path';
import {
@@ -462,20 +462,27 @@ export function splitEscapedPaths(text: string): string[] {
return paths;
}
/**
* Helper to validate if a path exists and is a file.
*/
function isValidFilePath(p: string): boolean {
try {
return existsSync(p) && statSync(p).isFile();
} catch {
return false;
}
}
/**
* 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 {
export function parsePastedPaths(text: string): string | null {
// First, check if the entire text is a single valid path
if (PATH_PREFIX_PATTERN.test(text) && isValidPath(text)) {
if (PATH_PREFIX_PATTERN.test(text) && isValidFilePath(text)) {
return `@${escapePath(text)} `;
}
@@ -492,7 +499,7 @@ export function parsePastedPaths(
return segment;
}
const unescaped = unescapePath(segment);
if (isValidPath(unescaped)) {
if (isValidFilePath(unescaped)) {
anyValidPath = true;
return `@${segment}`;
}