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:
Ali Anari
2026-03-11 11:43:42 -07:00
committed by GitHub
parent df8b399bb4
commit 08e174a05c
6 changed files with 1361 additions and 34 deletions

View File

@@ -66,6 +66,7 @@ const initialState: TextBufferState = {
visualLayout: defaultVisualLayout,
pastedContent: {},
expandedPaste: null,
yankRegister: null,
};
/**

View File

@@ -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;
}

View File

@@ -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);
});
});
});
});

View File

@@ -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);

View File

@@ -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');
});
});
});

View File

@@ -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);