Auto insert @ when dragging and dropping files. (#812)
This commit is contained in:
parent
18d6a11c04
commit
ab44824e07
|
@ -4,6 +4,7 @@
|
||||||
* SPDX-License-Identifier: Apache-2.0
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'fs';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { Text, Box, useInput, useStdin } from 'ink';
|
import { Text, Box, useInput, useStdin } from 'ink';
|
||||||
import { Colors } from '../colors.js';
|
import { Colors } from '../colors.js';
|
||||||
|
@ -58,11 +59,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
||||||
|
|
||||||
const { stdin, setRawMode } = useStdin();
|
const { stdin, setRawMode } = useStdin();
|
||||||
|
|
||||||
|
const isValidPath = useCallback((filePath: string): boolean => {
|
||||||
|
try {
|
||||||
|
return fs.existsSync(filePath) && fs.statSync(filePath).isFile();
|
||||||
|
} catch (_e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const buffer = useTextBuffer({
|
const buffer = useTextBuffer({
|
||||||
initialText: '',
|
initialText: '',
|
||||||
viewport: { height, width: effectiveWidth },
|
viewport: { height, width: effectiveWidth },
|
||||||
stdin,
|
stdin,
|
||||||
setRawMode,
|
setRawMode,
|
||||||
|
isValidPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
const completion = useCompletion(
|
const completion = useCompletion(
|
||||||
|
|
|
@ -34,7 +34,9 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
describe('Initialization', () => {
|
describe('Initialization', () => {
|
||||||
it('should initialize with empty text and cursor at (0,0) by default', () => {
|
it('should initialize with empty text and cursor at (0,0) by default', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
expect(state.text).toBe('');
|
expect(state.text).toBe('');
|
||||||
expect(state.lines).toEqual(['']);
|
expect(state.lines).toEqual(['']);
|
||||||
|
@ -47,7 +49,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('should initialize with provided initialText', () => {
|
it('should initialize with provided initialText', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'hello', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'hello',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
expect(state.text).toBe('hello');
|
expect(state.text).toBe('hello');
|
||||||
|
@ -64,6 +70,7 @@ describe('useTextBuffer', () => {
|
||||||
initialText: 'hello\nworld',
|
initialText: 'hello\nworld',
|
||||||
initialCursorOffset: 7, // Should be at 'o' in 'world'
|
initialCursorOffset: 7, // Should be at 'o' in 'world'
|
||||||
viewport,
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -82,6 +89,7 @@ describe('useTextBuffer', () => {
|
||||||
initialText: 'The quick brown fox jumps over the lazy dog.',
|
initialText: 'The quick brown fox jumps over the lazy dog.',
|
||||||
initialCursorOffset: 2, // After '好'
|
initialCursorOffset: 2, // After '好'
|
||||||
viewport: { width: 15, height: 4 },
|
viewport: { width: 15, height: 4 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -98,6 +106,7 @@ describe('useTextBuffer', () => {
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
initialText: 'The quick brown fox jumps over the lazy dog.',
|
initialText: 'The quick brown fox jumps over the lazy dog.',
|
||||||
viewport: { width: 15, height: 4 },
|
viewport: { width: 15, height: 4 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -117,6 +126,7 @@ describe('useTextBuffer', () => {
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
initialText: '123456789012345ABCDEFG', // 4 chars, 12 bytes
|
initialText: '123456789012345ABCDEFG', // 4 chars, 12 bytes
|
||||||
viewport: { width: 15, height: 2 },
|
viewport: { width: 15, height: 2 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -132,6 +142,7 @@ describe('useTextBuffer', () => {
|
||||||
initialText: '你好世界', // 4 chars, 12 bytes
|
initialText: '你好世界', // 4 chars, 12 bytes
|
||||||
initialCursorOffset: 2, // After '好'
|
initialCursorOffset: 2, // After '好'
|
||||||
viewport: { width: 5, height: 2 },
|
viewport: { width: 5, height: 2 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -146,7 +157,9 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
describe('Basic Editing', () => {
|
describe('Basic Editing', () => {
|
||||||
it('insert: should insert a character and update cursor', () => {
|
it('insert: should insert a character and update cursor', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
act(() => result.current.insert('a'));
|
act(() => result.current.insert('a'));
|
||||||
let state = getBufferState(result);
|
let state = getBufferState(result);
|
||||||
expect(state.text).toBe('a');
|
expect(state.text).toBe('a');
|
||||||
|
@ -162,7 +175,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('newline: should create a new line and move cursor', () => {
|
it('newline: should create a new line and move cursor', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'ab', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'ab',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.move('end')); // cursor at [0,2]
|
act(() => result.current.move('end')); // cursor at [0,2]
|
||||||
act(() => result.current.newline());
|
act(() => result.current.newline());
|
||||||
|
@ -177,7 +194,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('backspace: should delete char to the left or merge lines', () => {
|
it('backspace: should delete char to the left or merge lines', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'a\nb', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'a\nb',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => {
|
act(() => {
|
||||||
result.current.move('down');
|
result.current.move('down');
|
||||||
|
@ -201,7 +222,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('del: should delete char to the right or merge lines', () => {
|
it('del: should delete char to the right or merge lines', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'a\nb', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'a\nb',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
// cursor at [0,0]
|
// cursor at [0,0]
|
||||||
act(() => result.current.del()); // delete 'a'
|
act(() => result.current.del()); // delete 'a'
|
||||||
|
@ -219,6 +244,44 @@ describe('useTextBuffer', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Drag and Drop File Paths', () => {
|
||||||
|
it('should prepend @ to a valid file path on insert', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||||
|
);
|
||||||
|
const filePath = '/path/to/a/valid/file.txt';
|
||||||
|
act(() => result.current.insert(filePath));
|
||||||
|
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';
|
||||||
|
act(() => result.current.insert(notAPath));
|
||||||
|
expect(getBufferState(result).text).toBe(notAPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle quoted paths', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||||
|
);
|
||||||
|
const filePath = "'/path/to/a/valid/file.txt'";
|
||||||
|
act(() => result.current.insert(filePath));
|
||||||
|
expect(getBufferState(result).text).toBe(`@/path/to/a/valid/file.txt`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not prepend @ to short text that is not a path', () => {
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => true }),
|
||||||
|
);
|
||||||
|
const shortText = 'ab';
|
||||||
|
act(() => result.current.insert(shortText));
|
||||||
|
expect(getBufferState(result).text).toBe(shortText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Cursor Movement', () => {
|
describe('Cursor Movement', () => {
|
||||||
it('move: left/right should work within and across visual lines (due to wrapping)', () => {
|
it('move: left/right should work within and across visual lines (due to wrapping)', () => {
|
||||||
// Text: "long line1next line2" (20 chars)
|
// Text: "long line1next line2" (20 chars)
|
||||||
|
@ -231,6 +294,7 @@ describe('useTextBuffer', () => {
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
initialText: 'long line1next line2', // Corrected: was 'long line1next line2'
|
initialText: 'long line1next line2', // Corrected: was 'long line1next line2'
|
||||||
viewport: { width: 5, height: 4 },
|
viewport: { width: 5, height: 4 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Initial cursor [0,0] logical, visual [0,0] ("l" of "long ")
|
// Initial cursor [0,0] logical, visual [0,0] ("l" of "long ")
|
||||||
|
@ -254,7 +318,11 @@ describe('useTextBuffer', () => {
|
||||||
it('move: up/down should preserve preferred visual column', () => {
|
it('move: up/down should preserve preferred visual column', () => {
|
||||||
const text = 'abcde\nxy\n12345';
|
const text = 'abcde\nxy\n12345';
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: text, viewport }),
|
useTextBuffer({
|
||||||
|
initialText: text,
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(result.current.allVisualLines).toEqual(['abcde', 'xy', '12345']);
|
expect(result.current.allVisualLines).toEqual(['abcde', 'xy', '12345']);
|
||||||
// Place cursor at the end of "abcde" -> logical [0,5]
|
// Place cursor at the end of "abcde" -> logical [0,5]
|
||||||
|
@ -292,7 +360,11 @@ describe('useTextBuffer', () => {
|
||||||
it('move: home/end should go to visual line start/end', () => {
|
it('move: home/end should go to visual line start/end', () => {
|
||||||
const initialText = 'line one\nsecond line';
|
const initialText = 'line one\nsecond line';
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText, viewport: { width: 5, height: 5 } }),
|
useTextBuffer({
|
||||||
|
initialText,
|
||||||
|
viewport: { width: 5, height: 5 },
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
expect(result.current.allVisualLines).toEqual([
|
expect(result.current.allVisualLines).toEqual([
|
||||||
'line',
|
'line',
|
||||||
|
@ -320,6 +392,7 @@ describe('useTextBuffer', () => {
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
initialText: 'This is a very long line of text.', // 33 chars
|
initialText: 'This is a very long line of text.', // 33 chars
|
||||||
viewport: { width: 10, height: 5 },
|
viewport: { width: 10, height: 5 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -340,6 +413,7 @@ describe('useTextBuffer', () => {
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
initialText: 'l1\nl2\nl3\nl4\nl5',
|
initialText: 'l1\nl2\nl3\nl4\nl5',
|
||||||
viewport: { width: 5, height: 3 }, // Can show 3 visual lines
|
viewport: { width: 5, height: 3 }, // Can show 3 visual lines
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Initial: l1, l2, l3 visible. visualScrollRow = 0. visualCursor = [0,0]
|
// Initial: l1, l2, l3 visible. visualScrollRow = 0. visualCursor = [0,0]
|
||||||
|
@ -385,7 +459,9 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
describe('Undo/Redo', () => {
|
describe('Undo/Redo', () => {
|
||||||
it('should undo and redo an insert operation', () => {
|
it('should undo and redo an insert operation', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
act(() => result.current.insert('a'));
|
act(() => result.current.insert('a'));
|
||||||
expect(getBufferState(result).text).toBe('a');
|
expect(getBufferState(result).text).toBe('a');
|
||||||
|
|
||||||
|
@ -400,7 +476,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('should undo and redo a newline operation', () => {
|
it('should undo and redo a newline operation', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'test', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'test',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.move('end'));
|
act(() => result.current.move('end'));
|
||||||
act(() => result.current.newline());
|
act(() => result.current.newline());
|
||||||
|
@ -418,7 +498,9 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
describe('Unicode Handling', () => {
|
describe('Unicode Handling', () => {
|
||||||
it('insert: should correctly handle multi-byte unicode characters', () => {
|
it('insert: should correctly handle multi-byte unicode characters', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
act(() => result.current.insert('你好'));
|
act(() => result.current.insert('你好'));
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
expect(state.text).toBe('你好');
|
expect(state.text).toBe('你好');
|
||||||
|
@ -428,7 +510,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('backspace: should correctly delete multi-byte unicode characters', () => {
|
it('backspace: should correctly delete multi-byte unicode characters', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: '你好', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: '你好',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.move('end')); // cursor at [0,2]
|
act(() => result.current.move('end')); // cursor at [0,2]
|
||||||
act(() => result.current.backspace()); // delete '好'
|
act(() => result.current.backspace()); // delete '好'
|
||||||
|
@ -447,6 +533,7 @@ describe('useTextBuffer', () => {
|
||||||
useTextBuffer({
|
useTextBuffer({
|
||||||
initialText: '🐶🐱',
|
initialText: '🐶🐱',
|
||||||
viewport: { width: 5, height: 1 },
|
viewport: { width: 5, height: 1 },
|
||||||
|
isValidPath: () => false,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
// Initial: visualCursor [0,0]
|
// Initial: visualCursor [0,0]
|
||||||
|
@ -469,21 +556,29 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
describe('handleInput', () => {
|
describe('handleInput', () => {
|
||||||
it('should insert printable characters', () => {
|
it('should insert printable characters', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
act(() => result.current.handleInput('h', {}));
|
act(() => result.current.handleInput('h', {}));
|
||||||
act(() => result.current.handleInput('i', {}));
|
act(() => result.current.handleInput('i', {}));
|
||||||
expect(getBufferState(result).text).toBe('hi');
|
expect(getBufferState(result).text).toBe('hi');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle "Enter" key as newline', () => {
|
it('should handle "Enter" key as newline', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
act(() => result.current.handleInput(undefined, { return: true }));
|
act(() => result.current.handleInput(undefined, { return: true }));
|
||||||
expect(getBufferState(result).lines).toEqual(['', '']);
|
expect(getBufferState(result).lines).toEqual(['', '']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle "Backspace" key', () => {
|
it('should handle "Backspace" key', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'a', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'a',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.move('end'));
|
act(() => result.current.move('end'));
|
||||||
act(() => result.current.handleInput(undefined, { backspace: true }));
|
act(() => result.current.handleInput(undefined, { backspace: true }));
|
||||||
|
@ -492,7 +587,11 @@ describe('useTextBuffer', () => {
|
||||||
|
|
||||||
it('should handle arrow keys for movement', () => {
|
it('should handle arrow keys for movement', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'ab', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'ab',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.move('end')); // cursor [0,2]
|
act(() => result.current.move('end')); // cursor [0,2]
|
||||||
act(() => result.current.handleInput(undefined, { leftArrow: true })); // cursor [0,1]
|
act(() => result.current.handleInput(undefined, { leftArrow: true })); // cursor [0,1]
|
||||||
|
@ -502,7 +601,9 @@ describe('useTextBuffer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should strip ANSI escape codes when pasting text', () => {
|
it('should strip ANSI escape codes when pasting text', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
|
const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m';
|
||||||
// Simulate pasting by calling handleInput with a string longer than 1 char
|
// Simulate pasting by calling handleInput with a string longer than 1 char
|
||||||
act(() => result.current.handleInput(textWithAnsi, {}));
|
act(() => result.current.handleInput(textWithAnsi, {}));
|
||||||
|
@ -510,13 +611,14 @@ describe('useTextBuffer', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle VSCode terminal Shift+Enter as newline', () => {
|
it('should handle VSCode terminal Shift+Enter as newline', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
const { result } = renderHook(() =>
|
||||||
|
useTextBuffer({ viewport, isValidPath: () => false }),
|
||||||
|
);
|
||||||
act(() => result.current.handleInput('\r', {})); // Simulates Shift+Enter in VSCode terminal
|
act(() => result.current.handleInput('\r', {})); // Simulates Shift+Enter in VSCode terminal
|
||||||
expect(getBufferState(result).lines).toEqual(['', '']);
|
expect(getBufferState(result).lines).toEqual(['', '']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should correctly handle repeated pasting of long text', () => {
|
it('should correctly handle repeated pasting of long text', () => {
|
||||||
const { result } = renderHook(() => useTextBuffer({ viewport }));
|
|
||||||
const longText = `not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
|
const longText = `not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of Letraset sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.
|
||||||
|
|
||||||
Why do we use it?
|
Why do we use it?
|
||||||
|
@ -525,6 +627,9 @@ It is a long established fact that a reader will be distracted by the readable c
|
||||||
Where does it come from?
|
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
|
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 }),
|
||||||
|
);
|
||||||
|
|
||||||
// Simulate pasting the long text multiple times
|
// Simulate pasting the long text multiple times
|
||||||
act(() => result.current.insertStr(longText));
|
act(() => result.current.insertStr(longText));
|
||||||
|
@ -555,7 +660,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
describe('replaceRange', () => {
|
describe('replaceRange', () => {
|
||||||
it('should replace a single-line range with single-line text', () => {
|
it('should replace a single-line range with single-line text', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: '@pac', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: '@pac',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 1, 0, 4, 'packages'));
|
act(() => result.current.replaceRange(0, 1, 0, 4, 'packages'));
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -565,7 +674,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should replace a multi-line range with single-line text', () => {
|
it('should replace a multi-line range with single-line text', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'hello\nworld\nagain', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'hello\nworld\nagain',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 2, 1, 3, ' new ')); // replace 'llo\nwor' with ' new '
|
act(() => result.current.replaceRange(0, 2, 1, 3, ' new ')); // replace 'llo\nwor' with ' new '
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -575,7 +688,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should delete a range when replacing with an empty string', () => {
|
it('should delete a range when replacing with an empty string', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'hello world', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'hello world',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 5, 0, 11, '')); // delete ' world'
|
act(() => result.current.replaceRange(0, 5, 0, 11, '')); // delete ' world'
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -585,7 +702,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should handle replacing at the beginning of the text', () => {
|
it('should handle replacing at the beginning of the text', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'world', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'world',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 0, 0, 0, 'hello '));
|
act(() => result.current.replaceRange(0, 0, 0, 0, 'hello '));
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -595,7 +716,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should handle replacing at the end of the text', () => {
|
it('should handle replacing at the end of the text', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'hello', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'hello',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 5, 0, 5, ' world'));
|
act(() => result.current.replaceRange(0, 5, 0, 5, ' world'));
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -605,7 +730,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should handle replacing the entire buffer content', () => {
|
it('should handle replacing the entire buffer content', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'old text', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'old text',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 0, 0, 8, 'new text'));
|
act(() => result.current.replaceRange(0, 0, 0, 8, 'new text'));
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -615,7 +744,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should correctly replace with unicode characters', () => {
|
it('should correctly replace with unicode characters', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'hello *** world', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'hello *** world',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 6, 0, 9, '你好'));
|
act(() => result.current.replaceRange(0, 6, 0, 9, '你好'));
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
@ -625,7 +758,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('should handle invalid range by returning false and not changing text', () => {
|
it('should handle invalid range by returning false and not changing text', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'test', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'test',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
let success = true;
|
let success = true;
|
||||||
act(() => {
|
act(() => {
|
||||||
|
@ -643,7 +780,11 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
|
||||||
|
|
||||||
it('replaceRange: multiple lines with a single character', () => {
|
it('replaceRange: multiple lines with a single character', () => {
|
||||||
const { result } = renderHook(() =>
|
const { result } = renderHook(() =>
|
||||||
useTextBuffer({ initialText: 'first\nsecond\nthird', viewport }),
|
useTextBuffer({
|
||||||
|
initialText: 'first\nsecond\nthird',
|
||||||
|
viewport,
|
||||||
|
isValidPath: () => false,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
act(() => result.current.replaceRange(0, 2, 2, 3, 'X')); // Replace 'rst\nsecond\nthi'
|
act(() => result.current.replaceRange(0, 2, 2, 3, 'X')); // Replace 'rst\nsecond\nthi'
|
||||||
const state = getBufferState(result);
|
const state = getBufferState(result);
|
||||||
|
|
|
@ -11,6 +11,7 @@ import os from 'os';
|
||||||
import pathMod from 'path';
|
import pathMod from 'path';
|
||||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||||
import stringWidth from 'string-width';
|
import stringWidth from 'string-width';
|
||||||
|
import { unescapePath } from '@gemini-code/core';
|
||||||
|
|
||||||
export type Direction =
|
export type Direction =
|
||||||
| 'left'
|
| 'left'
|
||||||
|
@ -85,6 +86,7 @@ interface UseTextBufferProps {
|
||||||
stdin?: NodeJS.ReadStream | null; // For external editor
|
stdin?: NodeJS.ReadStream | null; // For external editor
|
||||||
setRawMode?: (mode: boolean) => void; // For external editor
|
setRawMode?: (mode: boolean) => void; // For external editor
|
||||||
onChange?: (text: string) => void; // Callback for when text changes
|
onChange?: (text: string) => void; // Callback for when text changes
|
||||||
|
isValidPath: (path: string) => boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UndoHistoryEntry {
|
interface UndoHistoryEntry {
|
||||||
|
@ -392,6 +394,7 @@ export function useTextBuffer({
|
||||||
stdin,
|
stdin,
|
||||||
setRawMode,
|
setRawMode,
|
||||||
onChange,
|
onChange,
|
||||||
|
isValidPath,
|
||||||
}: UseTextBufferProps): TextBuffer {
|
}: UseTextBufferProps): TextBuffer {
|
||||||
const [lines, setLines] = useState<string[]>(() => {
|
const [lines, setLines] = useState<string[]>(() => {
|
||||||
const l = initialText.split('\n');
|
const l = initialText.split('\n');
|
||||||
|
@ -561,6 +564,28 @@ export function useTextBuffer({
|
||||||
}
|
}
|
||||||
dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
|
dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
|
||||||
pushUndo();
|
pushUndo();
|
||||||
|
|
||||||
|
// Arbitrary threshold to avoid false positives on normal key presses
|
||||||
|
// while still detecting virtually all reasonable length file paths.
|
||||||
|
const minLengthToInferAsDragDrop = 3;
|
||||||
|
if (ch.length >= minLengthToInferAsDragDrop) {
|
||||||
|
// Possible drag and drop of a file path.
|
||||||
|
let potentialPath = ch;
|
||||||
|
if (
|
||||||
|
potentialPath.length > 2 &&
|
||||||
|
potentialPath.startsWith("'") &&
|
||||||
|
potentialPath.endsWith("'")
|
||||||
|
) {
|
||||||
|
potentialPath = ch.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
potentialPath = potentialPath.trim();
|
||||||
|
// Be conservative and only add an @ if the path is valid.
|
||||||
|
if (isValidPath(unescapePath(potentialPath))) {
|
||||||
|
ch = `@${potentialPath}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLines((prevLines) => {
|
setLines((prevLines) => {
|
||||||
const newLines = [...prevLines];
|
const newLines = [...prevLines];
|
||||||
const lineContent = currentLine(cursorRow);
|
const lineContent = currentLine(cursorRow);
|
||||||
|
@ -573,7 +598,15 @@ export function useTextBuffer({
|
||||||
setCursorCol((prev) => prev + cpLen(ch)); // Use cpLen for character length
|
setCursorCol((prev) => prev + cpLen(ch)); // Use cpLen for character length
|
||||||
setPreferredCol(null);
|
setPreferredCol(null);
|
||||||
},
|
},
|
||||||
[pushUndo, cursorRow, cursorCol, currentLine, insertStr, setPreferredCol],
|
[
|
||||||
|
pushUndo,
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
currentLine,
|
||||||
|
insertStr,
|
||||||
|
setPreferredCol,
|
||||||
|
isValidPath,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const newline = useCallback((): void => {
|
const newline = useCallback((): void => {
|
||||||
|
|
Loading…
Reference in New Issue