mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-14 08:01:02 -07:00
feat(ui): add vim yank/paste (y/p/P) with unnamed register (#22026)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
@@ -66,6 +66,7 @@ const initialState: TextBufferState = {
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
expandedPaste: null,
|
||||
yankRegister: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1568,6 +1568,7 @@ export interface TextBufferState {
|
||||
visualLayout: VisualLayout;
|
||||
pastedContent: Record<string, string>;
|
||||
expandedPaste: ExpandedPasteInfo | null;
|
||||
yankRegister: { text: string; linewise: boolean } | null;
|
||||
}
|
||||
|
||||
const historyLimit = 100;
|
||||
@@ -1722,6 +1723,14 @@ export type TextBufferAction =
|
||||
type: 'vim_delete_to_char_backward';
|
||||
payload: { char: string; count: number; till: boolean };
|
||||
}
|
||||
| { type: 'vim_yank_line'; payload: { count: number } }
|
||||
| { type: 'vim_yank_word_forward'; payload: { count: number } }
|
||||
| { type: 'vim_yank_big_word_forward'; payload: { count: number } }
|
||||
| { type: 'vim_yank_word_end'; payload: { count: number } }
|
||||
| { type: 'vim_yank_big_word_end'; payload: { count: number } }
|
||||
| { type: 'vim_yank_to_end_of_line'; payload: { count: number } }
|
||||
| { type: 'vim_paste_after'; payload: { count: number } }
|
||||
| { type: 'vim_paste_before'; payload: { count: number } }
|
||||
| {
|
||||
type: 'toggle_paste_expansion';
|
||||
payload: { id: string; row: number; col: number };
|
||||
@@ -2510,6 +2519,14 @@ function textBufferReducerLogic(
|
||||
case 'vim_find_char_backward':
|
||||
case 'vim_delete_to_char_forward':
|
||||
case 'vim_delete_to_char_backward':
|
||||
case 'vim_yank_line':
|
||||
case 'vim_yank_word_forward':
|
||||
case 'vim_yank_big_word_forward':
|
||||
case 'vim_yank_word_end':
|
||||
case 'vim_yank_big_word_end':
|
||||
case 'vim_yank_to_end_of_line':
|
||||
case 'vim_paste_after':
|
||||
case 'vim_paste_before':
|
||||
return handleVimAction(state, action as VimAction);
|
||||
|
||||
case 'toggle_paste_expansion': {
|
||||
@@ -2765,6 +2782,7 @@ export function useTextBuffer({
|
||||
visualLayout,
|
||||
pastedContent: {},
|
||||
expandedPaste: null,
|
||||
yankRegister: null,
|
||||
};
|
||||
}, [initialText, initialCursorOffset, viewport.width, viewport.height]);
|
||||
|
||||
@@ -3173,6 +3191,38 @@ export function useTextBuffer({
|
||||
dispatch({ type: 'vim_escape_insert_mode' });
|
||||
}, []);
|
||||
|
||||
const vimYankLine = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_yank_line', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimYankWordForward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_yank_word_forward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimYankBigWordForward = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_yank_big_word_forward', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimYankWordEnd = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_yank_word_end', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimYankBigWordEnd = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_yank_big_word_end', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimYankToEndOfLine = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_yank_to_end_of_line', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimPasteAfter = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_paste_after', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const vimPasteBefore = useCallback((count: number): void => {
|
||||
dispatch({ type: 'vim_paste_before', payload: { count } });
|
||||
}, []);
|
||||
|
||||
const openInExternalEditor = useCallback(async (): Promise<void> => {
|
||||
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
|
||||
const filePath = pathMod.join(tmpDir, 'buffer.txt');
|
||||
@@ -3640,6 +3690,14 @@ export function useTextBuffer({
|
||||
vimMoveToLastLine,
|
||||
vimMoveToLine,
|
||||
vimEscapeInsertMode,
|
||||
vimYankLine,
|
||||
vimYankWordForward,
|
||||
vimYankBigWordForward,
|
||||
vimYankWordEnd,
|
||||
vimYankBigWordEnd,
|
||||
vimYankToEndOfLine,
|
||||
vimPasteAfter,
|
||||
vimPasteBefore,
|
||||
}),
|
||||
[
|
||||
lines,
|
||||
@@ -3735,6 +3793,14 @@ export function useTextBuffer({
|
||||
vimMoveToLastLine,
|
||||
vimMoveToLine,
|
||||
vimEscapeInsertMode,
|
||||
vimYankLine,
|
||||
vimYankWordForward,
|
||||
vimYankBigWordForward,
|
||||
vimYankWordEnd,
|
||||
vimYankBigWordEnd,
|
||||
vimYankToEndOfLine,
|
||||
vimPasteAfter,
|
||||
vimPasteBefore,
|
||||
],
|
||||
);
|
||||
return returnValue;
|
||||
@@ -4095,4 +4161,20 @@ export interface TextBuffer {
|
||||
* Handle escape from insert mode (moves cursor left if not at line start)
|
||||
*/
|
||||
vimEscapeInsertMode: () => void;
|
||||
/** Yank N lines into the unnamed register (vim 'yy' / 'Nyy') */
|
||||
vimYankLine: (count: number) => void;
|
||||
/** Yank forward N words into the unnamed register (vim 'yw') */
|
||||
vimYankWordForward: (count: number) => void;
|
||||
/** Yank forward N big words into the unnamed register (vim 'yW') */
|
||||
vimYankBigWordForward: (count: number) => void;
|
||||
/** Yank to end of N words into the unnamed register (vim 'ye') */
|
||||
vimYankWordEnd: (count: number) => void;
|
||||
/** Yank to end of N big words into the unnamed register (vim 'yE') */
|
||||
vimYankBigWordEnd: (count: number) => void;
|
||||
/** Yank from cursor to end of line into the unnamed register (vim 'y$') */
|
||||
vimYankToEndOfLine: (count: number) => void;
|
||||
/** Paste the unnamed register after cursor (vim 'p') */
|
||||
vimPasteAfter: (count: number) => void;
|
||||
/** Paste the unnamed register before cursor (vim 'P') */
|
||||
vimPasteBefore: (count: number) => void;
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ const createTestState = (
|
||||
visualLayout: defaultVisualLayout,
|
||||
pastedContent: {},
|
||||
expandedPaste: null,
|
||||
yankRegister: null,
|
||||
});
|
||||
|
||||
describe('vim-buffer-actions', () => {
|
||||
@@ -2227,4 +2228,442 @@ describe('vim-buffer-actions', () => {
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim yank and paste', () => {
|
||||
describe('vim_yank_line (yy)', () => {
|
||||
it('should yank current line into register as linewise', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_line' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'hello world',
|
||||
linewise: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify the buffer or cursor position', () => {
|
||||
const state = createTestState(['hello world'], 0, 3);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_line' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.lines).toEqual(['hello world']);
|
||||
expect(result.cursorRow).toBe(0);
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should yank multiple lines with count', () => {
|
||||
const state = createTestState(['line1', 'line2', 'line3'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_line' as const,
|
||||
payload: { count: 2 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'line1\nline2',
|
||||
linewise: true,
|
||||
});
|
||||
expect(result.lines).toEqual(['line1', 'line2', 'line3']);
|
||||
});
|
||||
|
||||
it('should clamp count to available lines', () => {
|
||||
const state = createTestState(['only'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_line' as const,
|
||||
payload: { count: 99 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'only', linewise: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_yank_word_forward (yw)', () => {
|
||||
it('should yank from cursor to start of next word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'hello ',
|
||||
linewise: false,
|
||||
});
|
||||
expect(result.lines).toEqual(['hello world']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_yank_big_word_forward (yW)', () => {
|
||||
it('should yank from cursor to start of next big word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_big_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'hello ',
|
||||
linewise: false,
|
||||
});
|
||||
expect(result.lines).toEqual(['hello world']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_yank_word_end (ye)', () => {
|
||||
it('should yank from cursor to end of current word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'hello', linewise: false });
|
||||
expect(result.lines).toEqual(['hello world']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_yank_big_word_end (yE)', () => {
|
||||
it('should yank from cursor to end of current big word', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_big_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'hello', linewise: false });
|
||||
expect(result.lines).toEqual(['hello world']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_yank_to_end_of_line (y$)', () => {
|
||||
it('should yank from cursor to end of line', () => {
|
||||
const state = createTestState(['hello world'], 0, 6);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_to_end_of_line' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'world', linewise: false });
|
||||
expect(result.lines).toEqual(['hello world']);
|
||||
});
|
||||
|
||||
it('should do nothing when cursor is at end of line', () => {
|
||||
const state = createTestState(['hello'], 0, 5);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_yank_to_end_of_line' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete operations populate yankRegister', () => {
|
||||
it('should populate register on x (vim_delete_char)', () => {
|
||||
const state = createTestState(['hello'], 0, 1);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'e', linewise: false });
|
||||
expect(result.lines[0]).toBe('hllo');
|
||||
});
|
||||
|
||||
it('should populate register on X (vim_delete_char_before)', () => {
|
||||
// cursor at col 2 ('l'); X deletes the char before = col 1 ('e')
|
||||
const state = createTestState(['hello'], 0, 2);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_char_before' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'e', linewise: false });
|
||||
expect(result.lines[0]).toBe('hllo');
|
||||
});
|
||||
|
||||
it('should populate register on dd (vim_delete_line) as linewise', () => {
|
||||
const state = createTestState(['hello', 'world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'hello', linewise: true });
|
||||
expect(result.lines).toEqual(['world']);
|
||||
});
|
||||
|
||||
it('should populate register on 2dd with multiple lines', () => {
|
||||
const state = createTestState(['one', 'two', 'three'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_line' as const,
|
||||
payload: { count: 2 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'one\ntwo',
|
||||
linewise: true,
|
||||
});
|
||||
expect(result.lines).toEqual(['three']);
|
||||
});
|
||||
|
||||
it('should populate register on dw (vim_delete_word_forward)', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'hello ',
|
||||
linewise: false,
|
||||
});
|
||||
expect(result.lines[0]).toBe('world');
|
||||
});
|
||||
|
||||
it('should populate register on dW (vim_delete_big_word_forward)', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_big_word_forward' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'hello ',
|
||||
linewise: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should populate register on de (vim_delete_word_end)', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'hello', linewise: false });
|
||||
});
|
||||
|
||||
it('should populate register on dE (vim_delete_big_word_end)', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_big_word_end' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'hello', linewise: false });
|
||||
});
|
||||
|
||||
it('should populate register on D (vim_delete_to_end_of_line)', () => {
|
||||
const state = createTestState(['hello world'], 0, 6);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_to_end_of_line' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'world', linewise: false });
|
||||
expect(result.lines[0]).toBe('hello ');
|
||||
});
|
||||
|
||||
it('should populate register on df (vim_delete_to_char_forward, inclusive)', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_to_char_forward' as const,
|
||||
payload: { char: 'o', count: 1, till: false },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'hello', linewise: false });
|
||||
});
|
||||
|
||||
it('should populate register on dt (vim_delete_to_char_forward, till)', () => {
|
||||
const state = createTestState(['hello world'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_to_char_forward' as const,
|
||||
payload: { char: 'o', count: 1, till: true },
|
||||
});
|
||||
// dt stops before 'o', so deletes 'hell'
|
||||
expect(result.yankRegister).toEqual({ text: 'hell', linewise: false });
|
||||
});
|
||||
|
||||
it('should populate register on dF (vim_delete_to_char_backward, inclusive)', () => {
|
||||
// cursor at 7 ('o' in world), dFo finds 'o' at col 4, deletes [4, 8)
|
||||
const state = createTestState(['hello world'], 0, 7);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_to_char_backward' as const,
|
||||
payload: { char: 'o', count: 1, till: false },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: 'o wo', linewise: false });
|
||||
});
|
||||
|
||||
it('should populate register on dT (vim_delete_to_char_backward, till)', () => {
|
||||
// cursor at 7 ('o' in world), dTo finds 'o' at col 4, deletes [5, 8) = ' wo'
|
||||
const state = createTestState(['hello world'], 0, 7);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_to_char_backward' as const,
|
||||
payload: { char: 'o', count: 1, till: true },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({ text: ' wo', linewise: false });
|
||||
});
|
||||
|
||||
it('should preserve existing register when delete finds nothing to delete', () => {
|
||||
const state = {
|
||||
...createTestState(['hello'], 0, 5),
|
||||
yankRegister: { text: 'preserved', linewise: false },
|
||||
};
|
||||
// x at end-of-line does nothing
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_delete_char' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.yankRegister).toEqual({
|
||||
text: 'preserved',
|
||||
linewise: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_paste_after (p)', () => {
|
||||
it('should paste charwise text after cursor and land on last pasted char', () => {
|
||||
const state = {
|
||||
...createTestState(['abc'], 0, 1),
|
||||
yankRegister: { text: 'XY', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines[0]).toBe('abXYc');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should paste charwise at end of line when cursor is on last char', () => {
|
||||
const state = {
|
||||
...createTestState(['ab'], 0, 1),
|
||||
yankRegister: { text: 'Z', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines[0]).toBe('abZ');
|
||||
expect(result.cursorCol).toBe(2);
|
||||
});
|
||||
|
||||
it('should paste linewise below current row', () => {
|
||||
const state = {
|
||||
...createTestState(['hello', 'world'], 0, 0),
|
||||
yankRegister: { text: 'inserted', linewise: true },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines).toEqual(['hello', 'inserted', 'world']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should do nothing when register is empty', () => {
|
||||
const state = createTestState(['hello'], 0, 0);
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result.lines).toEqual(['hello']);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should paste charwise text count times', () => {
|
||||
const state = {
|
||||
...createTestState(['abc'], 0, 1),
|
||||
yankRegister: { text: 'X', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 2 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines[0]).toBe('abXXc');
|
||||
});
|
||||
|
||||
it('should paste linewise count times', () => {
|
||||
const state = {
|
||||
...createTestState(['hello', 'world'], 0, 0),
|
||||
yankRegister: { text: 'foo', linewise: true },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 2 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines).toEqual(['hello', 'foo', 'foo', 'world']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
});
|
||||
|
||||
it('should land cursor on last char when pasting multiline charwise text', () => {
|
||||
// Simulates yanking across a line boundary and pasting charwise.
|
||||
// Cursor must land on the last pasted char, not a large out-of-bounds column.
|
||||
const state = {
|
||||
...createTestState(['ab', 'cd'], 0, 1),
|
||||
yankRegister: { text: 'b\nc', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should land cursor correctly for count > 1 multiline charwise paste', () => {
|
||||
const state = {
|
||||
...createTestState(['ab', 'cd'], 0, 0),
|
||||
yankRegister: { text: 'x\ny', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_after' as const,
|
||||
payload: { count: 2 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
// cursor should be on the last char of the last pasted copy, not off-screen
|
||||
expect(result.cursorCol).toBeLessThanOrEqual(
|
||||
result.lines[result.cursorRow].length - 1,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('vim_paste_before (P)', () => {
|
||||
it('should paste charwise text before cursor and land on last pasted char', () => {
|
||||
const state = {
|
||||
...createTestState(['abc'], 0, 2),
|
||||
yankRegister: { text: 'XY', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_before' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines[0]).toBe('abXYc');
|
||||
expect(result.cursorCol).toBe(3);
|
||||
});
|
||||
|
||||
it('should land cursor on last char when pasting multiline charwise text', () => {
|
||||
const state = {
|
||||
...createTestState(['ab', 'cd'], 0, 1),
|
||||
yankRegister: { text: 'b\nc', linewise: false },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_before' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.cursorCol).toBeLessThanOrEqual(
|
||||
result.lines[result.cursorRow].length - 1,
|
||||
);
|
||||
});
|
||||
|
||||
it('should paste linewise above current row', () => {
|
||||
const state = {
|
||||
...createTestState(['hello', 'world'], 1, 0),
|
||||
yankRegister: { text: 'inserted', linewise: true },
|
||||
};
|
||||
const result = handleVimAction(state, {
|
||||
type: 'vim_paste_before' as const,
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(result).toHaveOnlyValidCharacters();
|
||||
expect(result.lines).toEqual(['hello', 'inserted', 'world']);
|
||||
expect(result.cursorRow).toBe(1);
|
||||
expect(result.cursorCol).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,14 @@ export type VimAction = Extract<
|
||||
| { type: 'vim_move_to_last_line' }
|
||||
| { type: 'vim_move_to_line' }
|
||||
| { type: 'vim_escape_insert_mode' }
|
||||
| { type: 'vim_yank_line' }
|
||||
| { type: 'vim_yank_word_forward' }
|
||||
| { type: 'vim_yank_big_word_forward' }
|
||||
| { type: 'vim_yank_word_end' }
|
||||
| { type: 'vim_yank_big_word_end' }
|
||||
| { type: 'vim_yank_to_end_of_line' }
|
||||
| { type: 'vim_paste_after' }
|
||||
| { type: 'vim_paste_before' }
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -123,6 +131,36 @@ function clampNormalCursor(state: TextBufferState): TextBufferState {
|
||||
return { ...state, cursorCol: maxCol };
|
||||
}
|
||||
|
||||
/** Extract the text that will be removed by a delete/yank operation. */
|
||||
function extractRange(
|
||||
lines: string[],
|
||||
startRow: number,
|
||||
startCol: number,
|
||||
endRow: number,
|
||||
endCol: number,
|
||||
): string {
|
||||
if (startRow === endRow) {
|
||||
return toCodePoints(lines[startRow] || '')
|
||||
.slice(startCol, endCol)
|
||||
.join('');
|
||||
}
|
||||
const parts: string[] = [];
|
||||
parts.push(
|
||||
toCodePoints(lines[startRow] || '')
|
||||
.slice(startCol)
|
||||
.join(''),
|
||||
);
|
||||
for (let r = startRow + 1; r < endRow; r++) {
|
||||
parts.push(lines[r] || '');
|
||||
}
|
||||
parts.push(
|
||||
toCodePoints(lines[endRow] || '')
|
||||
.slice(0, endCol)
|
||||
.join(''),
|
||||
);
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
export function handleVimAction(
|
||||
state: TextBufferState,
|
||||
action: VimAction,
|
||||
@@ -156,6 +194,13 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -165,9 +210,13 @@ export function handleVimAction(
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return action.type === 'vim_delete_word_forward'
|
||||
? clampNormalCursor(newState)
|
||||
: newState;
|
||||
if (action.type === 'vim_delete_word_forward') {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -201,6 +250,13 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
const nextState = pushUndo(state);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -210,9 +266,13 @@ export function handleVimAction(
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return action.type === 'vim_delete_big_word_forward'
|
||||
? clampNormalCursor(newState)
|
||||
: newState;
|
||||
if (action.type === 'vim_delete_big_word_forward') {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -317,6 +377,13 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -326,9 +393,13 @@ export function handleVimAction(
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return action.type === 'vim_delete_word_end'
|
||||
? clampNormalCursor(newState)
|
||||
: newState;
|
||||
if (action.type === 'vim_delete_word_end') {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -373,6 +444,13 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
const nextState = pushUndo(state);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -382,9 +460,13 @@ export function handleVimAction(
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return action.type === 'vim_delete_big_word_end'
|
||||
? clampNormalCursor(newState)
|
||||
: newState;
|
||||
if (action.type === 'vim_delete_big_word_end') {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -395,6 +477,9 @@ export function handleVimAction(
|
||||
|
||||
const linesToDelete = Math.min(count, lines.length - cursorRow);
|
||||
const totalLines = lines.length;
|
||||
const yankedText = lines
|
||||
.slice(cursorRow, cursorRow + linesToDelete)
|
||||
.join('\n');
|
||||
|
||||
if (totalLines === 1 || linesToDelete >= totalLines) {
|
||||
// If there's only one line, or we're deleting all remaining lines,
|
||||
@@ -406,6 +491,7 @@ export function handleVimAction(
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
yankRegister: { text: yankedText, linewise: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -423,6 +509,7 @@ export function handleVimAction(
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: newCursorCol,
|
||||
preferredCol: null,
|
||||
yankRegister: { text: yankedText, linewise: true },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -463,6 +550,13 @@ export function handleVimAction(
|
||||
if (count === 1) {
|
||||
// Single line: delete from cursor to end of current line
|
||||
if (cursorCol < cpLen(currentLine)) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cpLen(currentLine),
|
||||
);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -472,7 +566,13 @@ export function handleVimAction(
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
return isDelete ? clampNormalCursor(newState) : newState;
|
||||
if (isDelete) {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
} else {
|
||||
@@ -484,6 +584,13 @@ export function handleVimAction(
|
||||
if (endRow === cursorRow) {
|
||||
// No additional lines to delete, just delete to EOL
|
||||
if (cursorCol < cpLen(currentLine)) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cpLen(currentLine),
|
||||
);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -493,14 +600,27 @@ export function handleVimAction(
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
return isDelete ? clampNormalCursor(newState) : newState;
|
||||
if (isDelete) {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Delete from cursor position to end of endRow (including newlines)
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const endLine = lines[endRow] || '';
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
cpLen(endLine),
|
||||
);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
@@ -509,7 +629,13 @@ export function handleVimAction(
|
||||
cpLen(endLine),
|
||||
'',
|
||||
);
|
||||
return isDelete ? clampNormalCursor(newState) : newState;
|
||||
if (isDelete) {
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return newState;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1064,6 +1190,9 @@ export function handleVimAction(
|
||||
|
||||
if (cursorCol < lineLength) {
|
||||
const deleteCount = Math.min(count, lineLength - cursorCol);
|
||||
const deletedText = toCodePoints(currentLine)
|
||||
.slice(cursorCol, cursorCol + deleteCount)
|
||||
.join('');
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -1073,7 +1202,10 @@ export function handleVimAction(
|
||||
cursorCol + deleteCount,
|
||||
'',
|
||||
);
|
||||
return clampNormalCursor(newState);
|
||||
return {
|
||||
...clampNormalCursor(newState),
|
||||
yankRegister: { text: deletedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -1254,8 +1386,11 @@ export function handleVimAction(
|
||||
const { count } = action.payload;
|
||||
if (cursorCol > 0) {
|
||||
const deleteStart = Math.max(0, cursorCol - count);
|
||||
const deletedText = toCodePoints(lines[cursorRow] || '')
|
||||
.slice(deleteStart, cursorCol)
|
||||
.join('');
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
deleteStart,
|
||||
@@ -1263,6 +1398,10 @@ export function handleVimAction(
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...newState,
|
||||
yankRegister: { text: deletedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -1328,17 +1467,21 @@ export function handleVimAction(
|
||||
);
|
||||
if (found === -1) return state;
|
||||
const endCol = till ? found : found + 1;
|
||||
const yankedText = lineCodePoints.slice(cursorCol, endCol).join('');
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return clampNormalCursor(
|
||||
replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
endCol,
|
||||
'',
|
||||
return {
|
||||
...clampNormalCursor(
|
||||
replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
endCol,
|
||||
'',
|
||||
),
|
||||
),
|
||||
);
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_delete_to_char_backward': {
|
||||
@@ -1355,6 +1498,7 @@ export function handleVimAction(
|
||||
const startCol = till ? found + 1 : found;
|
||||
const endCol = cursorCol + 1; // inclusive: cursor char is part of the deletion
|
||||
if (startCol >= endCol) return state;
|
||||
const yankedText = lineCodePoints.slice(startCol, endCol).join('');
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
@@ -1364,11 +1508,14 @@ export function handleVimAction(
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return clampNormalCursor({
|
||||
...resultState,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
});
|
||||
return {
|
||||
...clampNormalCursor({
|
||||
...resultState,
|
||||
cursorCol: startCol,
|
||||
preferredCol: null,
|
||||
}),
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_find_char_forward': {
|
||||
@@ -1401,6 +1548,298 @@ export function handleVimAction(
|
||||
return { ...state, cursorCol: newCol, preferredCol: null };
|
||||
}
|
||||
|
||||
case 'vim_yank_line': {
|
||||
const { count } = action.payload;
|
||||
const linesToYank = Math.min(count, lines.length - cursorRow);
|
||||
const text = lines.slice(cursorRow, cursorRow + linesToYank).join('\n');
|
||||
return { ...state, yankRegister: { text, linewise: true } };
|
||||
}
|
||||
|
||||
case 'vim_yank_word_forward': {
|
||||
const { count } = action.payload;
|
||||
let endRow = cursorRow;
|
||||
let endCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWord = findNextWordAcrossLines(lines, endRow, endCol, true);
|
||||
if (nextWord) {
|
||||
endRow = nextWord.row;
|
||||
endCol = nextWord.col;
|
||||
} else {
|
||||
const currentLine = lines[endRow] || '';
|
||||
const wordEnd = findWordEndInLine(currentLine, endCol);
|
||||
if (wordEnd !== null) {
|
||||
endCol = wordEnd + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_yank_big_word_forward': {
|
||||
const { count } = action.payload;
|
||||
let endRow = cursorRow;
|
||||
let endCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const nextWord = findNextBigWordAcrossLines(
|
||||
lines,
|
||||
endRow,
|
||||
endCol,
|
||||
true,
|
||||
);
|
||||
if (nextWord) {
|
||||
endRow = nextWord.row;
|
||||
endCol = nextWord.col;
|
||||
} else {
|
||||
const currentLine = lines[endRow] || '';
|
||||
const wordEnd = findBigWordEndInLine(currentLine, endCol);
|
||||
if (wordEnd !== null) {
|
||||
endCol = wordEnd + 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_yank_word_end': {
|
||||
const { count } = action.payload;
|
||||
let row = cursorRow;
|
||||
let col = cursorCol;
|
||||
let endRow = cursorRow;
|
||||
let endCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
|
||||
if (wordEnd) {
|
||||
endRow = wordEnd.row;
|
||||
endCol = wordEnd.col + 1;
|
||||
if (i < count - 1) {
|
||||
const nextWord = findNextWordAcrossLines(
|
||||
lines,
|
||||
wordEnd.row,
|
||||
wordEnd.col + 1,
|
||||
true,
|
||||
);
|
||||
if (nextWord) {
|
||||
row = nextWord.row;
|
||||
col = nextWord.col;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endRow < lines.length) {
|
||||
endCol = Math.min(endCol, cpLen(lines[endRow] || ''));
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_yank_big_word_end': {
|
||||
const { count } = action.payload;
|
||||
let row = cursorRow;
|
||||
let col = cursorCol;
|
||||
let endRow = cursorRow;
|
||||
let endCol = cursorCol;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const wordEnd = findNextBigWordAcrossLines(lines, row, col, false);
|
||||
if (wordEnd) {
|
||||
endRow = wordEnd.row;
|
||||
endCol = wordEnd.col + 1;
|
||||
if (i < count - 1) {
|
||||
const nextWord = findNextBigWordAcrossLines(
|
||||
lines,
|
||||
wordEnd.row,
|
||||
wordEnd.col + 1,
|
||||
true,
|
||||
);
|
||||
if (nextWord) {
|
||||
row = nextWord.row;
|
||||
col = nextWord.col;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (endRow < lines.length) {
|
||||
endCol = Math.min(endCol, cpLen(lines[endRow] || ''));
|
||||
}
|
||||
|
||||
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||
const yankedText = extractRange(
|
||||
lines,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
endCol,
|
||||
);
|
||||
return {
|
||||
...state,
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_yank_to_end_of_line': {
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineLen = cpLen(currentLine);
|
||||
if (cursorCol < lineLen) {
|
||||
const yankedText = toCodePoints(currentLine).slice(cursorCol).join('');
|
||||
return {
|
||||
...state,
|
||||
yankRegister: { text: yankedText, linewise: false },
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_paste_after': {
|
||||
const { count } = action.payload;
|
||||
const reg = state.yankRegister;
|
||||
if (!reg) return state;
|
||||
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
|
||||
if (reg.linewise) {
|
||||
// Insert lines BELOW cursorRow
|
||||
const pasteText = (reg.text + '\n').repeat(count).slice(0, -1); // N copies, no trailing newline
|
||||
const pasteLines = pasteText.split('\n');
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow + 1, 0, ...pasteLines);
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: cursorRow + 1,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
} else {
|
||||
// Insert after cursor (at cursorCol + 1)
|
||||
const currentLine = nextState.lines[cursorRow] || '';
|
||||
const lineLen = cpLen(currentLine);
|
||||
const insertCol = Math.min(cursorCol + 1, lineLen);
|
||||
const pasteText = reg.text.repeat(count);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
insertCol,
|
||||
cursorRow,
|
||||
insertCol,
|
||||
pasteText,
|
||||
);
|
||||
// replaceRangeInternal leaves cursorCol one past the last inserted char;
|
||||
// step back by 1 to land on the last pasted character.
|
||||
const pasteLength = pasteText.length;
|
||||
return clampNormalCursor({
|
||||
...newState,
|
||||
cursorCol: Math.max(
|
||||
0,
|
||||
newState.cursorCol - (pasteLength > 0 ? 1 : 0),
|
||||
),
|
||||
preferredCol: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
case 'vim_paste_before': {
|
||||
const { count } = action.payload;
|
||||
const reg = state.yankRegister;
|
||||
if (!reg) return state;
|
||||
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
|
||||
if (reg.linewise) {
|
||||
// Insert lines ABOVE cursorRow
|
||||
const pasteText = (reg.text + '\n').repeat(count).slice(0, -1);
|
||||
const pasteLines = pasteText.split('\n');
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, 0, ...pasteLines);
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
} else {
|
||||
// Insert at cursorCol (not +1)
|
||||
const pasteText = reg.text.repeat(count);
|
||||
const newState = replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
pasteText,
|
||||
);
|
||||
// replaceRangeInternal leaves cursorCol one past the last inserted char;
|
||||
// step back by 1 to land on the last pasted character.
|
||||
const pasteLength = pasteText.length;
|
||||
return clampNormalCursor({
|
||||
...newState,
|
||||
cursorCol: Math.max(
|
||||
0,
|
||||
newState.cursorCol - (pasteLength > 0 ? 1 : 0),
|
||||
),
|
||||
preferredCol: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
// This should never happen if TypeScript is working correctly
|
||||
assumeExhaustive(action);
|
||||
|
||||
@@ -77,6 +77,7 @@ const createMockTextBufferState = (
|
||||
},
|
||||
pastedContent: {},
|
||||
expandedPaste: null,
|
||||
yankRegister: null,
|
||||
...partial,
|
||||
};
|
||||
};
|
||||
@@ -206,6 +207,14 @@ describe('useVim hook', () => {
|
||||
cursorState.pos = [row, col - 1];
|
||||
}
|
||||
}),
|
||||
vimYankLine: vi.fn(),
|
||||
vimYankWordForward: vi.fn(),
|
||||
vimYankBigWordForward: vi.fn(),
|
||||
vimYankWordEnd: vi.fn(),
|
||||
vimYankBigWordEnd: vi.fn(),
|
||||
vimYankToEndOfLine: vi.fn(),
|
||||
vimPasteAfter: vi.fn(),
|
||||
vimPasteBefore: vi.fn(),
|
||||
// Additional properties for transformations
|
||||
transformedToLogicalMaps: lines.map(() => []),
|
||||
visualToTransformedMap: [],
|
||||
@@ -2387,4 +2396,205 @@ describe('useVim hook', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Yank and paste (y/p/P)', () => {
|
||||
it('should handle yy (yank line)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankLine).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle 2yy (yank 2 lines)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '2' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankLine).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should handle Y (yank to end of line, equivalent to y$)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'Y' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankToEndOfLine).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle yw (yank word forward)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'w' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankWordForward).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle yW (yank big word forward)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'W' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankBigWordForward).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle ye (yank to end of word)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'e' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankWordEnd).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle yE (yank to end of big word)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'E' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankBigWordEnd).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle y$ (yank to end of line)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'y' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '$' }));
|
||||
});
|
||||
expect(mockBuffer.vimYankToEndOfLine).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle p (paste after)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'p' }));
|
||||
});
|
||||
expect(mockBuffer.vimPasteAfter).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should handle 2p (paste after, count 2)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: '2' }));
|
||||
});
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'p' }));
|
||||
});
|
||||
expect(mockBuffer.vimPasteAfter).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('should handle P (paste before)', () => {
|
||||
const { result } = renderVimHook();
|
||||
exitInsertMode(result);
|
||||
act(() => {
|
||||
result.current.handleInput(createKey({ sequence: 'P' }));
|
||||
});
|
||||
expect(mockBuffer.vimPasteBefore).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
// Integration tests using actual textBufferReducer to verify full state changes
|
||||
it('should duplicate a line below with yy then p', () => {
|
||||
const initialState = createMockTextBufferState({
|
||||
lines: ['hello', 'world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
});
|
||||
// Simulate yy action
|
||||
let state = textBufferReducer(initialState, {
|
||||
type: 'vim_yank_line',
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.yankRegister).toEqual({ text: 'hello', linewise: true });
|
||||
expect(state.lines).toEqual(['hello', 'world']); // unchanged
|
||||
|
||||
// Simulate p action
|
||||
state = textBufferReducer(state, {
|
||||
type: 'vim_paste_after',
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.lines).toEqual(['hello', 'hello', 'world']);
|
||||
expect(state.cursorRow).toBe(1);
|
||||
expect(state.cursorCol).toBe(0);
|
||||
});
|
||||
|
||||
it('should paste a yanked word after cursor with yw then p', () => {
|
||||
const initialState = createMockTextBufferState({
|
||||
lines: ['hello world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
});
|
||||
// Simulate yw action
|
||||
let state = textBufferReducer(initialState, {
|
||||
type: 'vim_yank_word_forward',
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.yankRegister).toEqual({ text: 'hello ', linewise: false });
|
||||
expect(state.lines).toEqual(['hello world']); // unchanged
|
||||
|
||||
// Move cursor to col 6 (start of 'world') and paste
|
||||
state = { ...state, cursorCol: 6 };
|
||||
state = textBufferReducer(state, {
|
||||
type: 'vim_paste_after',
|
||||
payload: { count: 1 },
|
||||
});
|
||||
// 'hello world' with paste after col 6 (between 'w' and 'o')
|
||||
// insert 'hello ' at col 7, result: 'hello whello orld'
|
||||
expect(state.lines[0]).toContain('hello ');
|
||||
});
|
||||
|
||||
it('should move a word forward with dw then p', () => {
|
||||
const initialState = createMockTextBufferState({
|
||||
lines: ['hello world'],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
});
|
||||
// Simulate dw (delete word, populates register)
|
||||
let state = textBufferReducer(initialState, {
|
||||
type: 'vim_delete_word_forward',
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.yankRegister).toEqual({ text: 'hello ', linewise: false });
|
||||
expect(state.lines[0]).toBe('world');
|
||||
|
||||
// Paste at end of 'world' (after last char)
|
||||
state = { ...state, cursorCol: 4 };
|
||||
state = textBufferReducer(state, {
|
||||
type: 'vim_paste_after',
|
||||
payload: { count: 1 },
|
||||
});
|
||||
expect(state.lines[0]).toContain('hello');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,14 @@ const CMD_TYPES = {
|
||||
DELETE_TO_LAST_LINE: 'dG',
|
||||
CHANGE_TO_FIRST_LINE: 'cgg',
|
||||
CHANGE_TO_LAST_LINE: 'cG',
|
||||
YANK_LINE: 'yy',
|
||||
YANK_WORD_FORWARD: 'yw',
|
||||
YANK_BIG_WORD_FORWARD: 'yW',
|
||||
YANK_WORD_END: 'ye',
|
||||
YANK_BIG_WORD_END: 'yE',
|
||||
YANK_TO_EOL: 'y$',
|
||||
PASTE_AFTER: 'p',
|
||||
PASTE_BEFORE: 'P',
|
||||
} as const;
|
||||
|
||||
type PendingFindOp = {
|
||||
@@ -80,7 +88,7 @@ const createClearPendingState = () => ({
|
||||
type VimState = {
|
||||
mode: VimMode;
|
||||
count: number;
|
||||
pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
|
||||
pendingOperator: 'g' | 'd' | 'c' | 'y' | 'dg' | 'cg' | null;
|
||||
pendingFindOp: PendingFindOp | undefined;
|
||||
lastCommand: { type: string; count: number; char?: string } | null;
|
||||
lastFind: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
|
||||
@@ -93,7 +101,7 @@ type VimAction =
|
||||
| { type: 'CLEAR_COUNT' }
|
||||
| {
|
||||
type: 'SET_PENDING_OPERATOR';
|
||||
operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
|
||||
operator: 'g' | 'd' | 'c' | 'y' | 'dg' | 'cg' | null;
|
||||
}
|
||||
| { type: 'SET_PENDING_FIND_OP'; pendingFindOp: PendingFindOp | undefined }
|
||||
| {
|
||||
@@ -408,6 +416,46 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.YANK_LINE: {
|
||||
buffer.vimYankLine(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.YANK_WORD_FORWARD: {
|
||||
buffer.vimYankWordForward(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.YANK_BIG_WORD_FORWARD: {
|
||||
buffer.vimYankBigWordForward(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.YANK_WORD_END: {
|
||||
buffer.vimYankWordEnd(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.YANK_BIG_WORD_END: {
|
||||
buffer.vimYankBigWordEnd(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.YANK_TO_EOL: {
|
||||
buffer.vimYankToEndOfLine(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.PASTE_AFTER: {
|
||||
buffer.vimPasteAfter(count);
|
||||
break;
|
||||
}
|
||||
|
||||
case CMD_TYPES.PASTE_BEFORE: {
|
||||
buffer.vimPasteBefore(count);
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
@@ -776,6 +824,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'w');
|
||||
}
|
||||
if (state.pendingOperator === 'y') {
|
||||
const count = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.YANK_WORD_FORWARD, count);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_WORD_FORWARD, count },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normal word movement
|
||||
buffer.vimMoveWordForward(repeatCount);
|
||||
@@ -791,6 +850,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'W');
|
||||
}
|
||||
if (state.pendingOperator === 'y') {
|
||||
const count = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.YANK_BIG_WORD_FORWARD, count);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_BIG_WORD_FORWARD, count },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normal big word movement
|
||||
buffer.vimMoveBigWordForward(repeatCount);
|
||||
@@ -836,6 +906,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'e');
|
||||
}
|
||||
if (state.pendingOperator === 'y') {
|
||||
const count = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.YANK_WORD_END, count);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_WORD_END, count },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normal word end movement
|
||||
buffer.vimMoveWordEnd(repeatCount);
|
||||
@@ -851,6 +932,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
if (state.pendingOperator === 'c') {
|
||||
return handleOperatorMotion('c', 'E');
|
||||
}
|
||||
if (state.pendingOperator === 'y') {
|
||||
const count = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.YANK_BIG_WORD_END, count);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_BIG_WORD_END, count },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Normal big word end movement
|
||||
buffer.vimMoveBigWordEnd(repeatCount);
|
||||
@@ -1027,6 +1119,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
updateMode('INSERT');
|
||||
return true;
|
||||
}
|
||||
// Check if this is part of a yank command (y$)
|
||||
if (state.pendingOperator === 'y') {
|
||||
executeCommand(CMD_TYPES.YANK_TO_EOL, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_TO_EOL, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
return true;
|
||||
}
|
||||
|
||||
// Move to end of line (with count, move down count-1 lines first)
|
||||
if (repeatCount > 1) {
|
||||
@@ -1220,6 +1323,59 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'y': {
|
||||
if (state.pendingOperator === 'y') {
|
||||
// Second 'y' - yank N lines (yy command)
|
||||
const repeatCount = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.YANK_LINE, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_LINE, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||
} else if (state.pendingOperator === null) {
|
||||
// First 'y' - wait for motion
|
||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'y' });
|
||||
} else {
|
||||
// Another operator is pending; clear it
|
||||
dispatch({ type: 'CLEAR_PENDING_STATES' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'Y': {
|
||||
// Y yanks from cursor to end of line (equivalent to y$)
|
||||
const repeatCount = getCurrentCount();
|
||||
executeCommand(CMD_TYPES.YANK_TO_EOL, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.YANK_TO_EOL, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'p': {
|
||||
executeCommand(CMD_TYPES.PASTE_AFTER, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.PASTE_AFTER, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'P': {
|
||||
executeCommand(CMD_TYPES.PASTE_BEFORE, repeatCount);
|
||||
dispatch({
|
||||
type: 'SET_LAST_COMMAND',
|
||||
command: { type: CMD_TYPES.PASTE_BEFORE, count: repeatCount },
|
||||
});
|
||||
dispatch({ type: 'CLEAR_COUNT' });
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'D': {
|
||||
// Delete from cursor to end of line (with count, delete to end of N lines)
|
||||
executeCommand(CMD_TYPES.DELETE_TO_EOL, repeatCount);
|
||||
|
||||
Reference in New Issue
Block a user