mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-20 11:00:40 -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:
@@ -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