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

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