fix(vim): vim support that feels (more) complete (#18755)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Philippe
2026-02-13 01:22:39 +01:00
committed by GitHub
parent 55ec0f043c
commit 5b4884692b
7 changed files with 1241 additions and 115 deletions
@@ -1657,8 +1657,9 @@ export type TextBufferAction =
| { type: 'vim_change_big_word_end'; payload: { count: number } }
| { type: 'vim_delete_line'; payload: { count: number } }
| { type: 'vim_change_line'; payload: { count: number } }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_delete_to_end_of_line'; payload: { count: number } }
| { type: 'vim_delete_to_start_of_line' }
| { type: 'vim_change_to_end_of_line'; payload: { count: number } }
| {
type: 'vim_change_movement';
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
@@ -1688,6 +1689,11 @@ export type TextBufferAction =
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
| { type: 'vim_escape_insert_mode' }
| { type: 'vim_delete_to_first_nonwhitespace' }
| { type: 'vim_change_to_start_of_line' }
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line'; payload: { count: number } }
| { type: 'vim_delete_to_last_line'; payload: { count: number } }
| {
type: 'toggle_paste_expansion';
payload: { id: string; row: number; col: number };
@@ -2437,6 +2443,7 @@ function textBufferReducerLogic(
case 'vim_delete_line':
case 'vim_change_line':
case 'vim_delete_to_end_of_line':
case 'vim_delete_to_start_of_line':
case 'vim_change_to_end_of_line':
case 'vim_change_movement':
case 'vim_move_left':
@@ -2463,6 +2470,11 @@ function textBufferReducerLogic(
case 'vim_move_to_last_line':
case 'vim_move_to_line':
case 'vim_escape_insert_mode':
case 'vim_delete_to_first_nonwhitespace':
case 'vim_change_to_start_of_line':
case 'vim_change_to_first_nonwhitespace':
case 'vim_delete_to_first_line':
case 'vim_delete_to_last_line':
return handleVimAction(state, action as VimAction);
case 'toggle_paste_expansion': {
@@ -2945,12 +2957,36 @@ export function useTextBuffer({
dispatch({ type: 'vim_change_line', payload: { count } });
}, []);
const vimDeleteToEndOfLine = useCallback((): void => {
dispatch({ type: 'vim_delete_to_end_of_line' });
const vimDeleteToEndOfLine = useCallback((count: number = 1): void => {
dispatch({ type: 'vim_delete_to_end_of_line', payload: { count } });
}, []);
const vimChangeToEndOfLine = useCallback((): void => {
dispatch({ type: 'vim_change_to_end_of_line' });
const vimDeleteToStartOfLine = useCallback((): void => {
dispatch({ type: 'vim_delete_to_start_of_line' });
}, []);
const vimChangeToEndOfLine = useCallback((count: number = 1): void => {
dispatch({ type: 'vim_change_to_end_of_line', payload: { count } });
}, []);
const vimDeleteToFirstNonWhitespace = useCallback((): void => {
dispatch({ type: 'vim_delete_to_first_nonwhitespace' });
}, []);
const vimChangeToStartOfLine = useCallback((): void => {
dispatch({ type: 'vim_change_to_start_of_line' });
}, []);
const vimChangeToFirstNonWhitespace = useCallback((): void => {
dispatch({ type: 'vim_change_to_first_nonwhitespace' });
}, []);
const vimDeleteToFirstLine = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_to_first_line', payload: { count } });
}, []);
const vimDeleteToLastLine = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_to_last_line', payload: { count } });
}, []);
const vimChangeMovement = useCallback(
@@ -3510,7 +3546,13 @@ export function useTextBuffer({
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimDeleteToStartOfLine,
vimChangeToEndOfLine,
vimDeleteToFirstNonWhitespace,
vimChangeToStartOfLine,
vimChangeToFirstNonWhitespace,
vimDeleteToFirstLine,
vimDeleteToLastLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
@@ -3592,7 +3634,13 @@ export function useTextBuffer({
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimDeleteToStartOfLine,
vimChangeToEndOfLine,
vimDeleteToFirstNonWhitespace,
vimChangeToStartOfLine,
vimChangeToFirstNonWhitespace,
vimDeleteToFirstLine,
vimDeleteToLastLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
@@ -3832,12 +3880,38 @@ export interface TextBuffer {
vimChangeLine: (count: number) => void;
/**
* Delete from cursor to end of line (vim 'D' command)
* With count > 1, deletes to end of current line plus (count-1) additional lines
*/
vimDeleteToEndOfLine: () => void;
vimDeleteToEndOfLine: (count?: number) => void;
/**
* Delete from start of line to cursor (vim 'd0' command)
*/
vimDeleteToStartOfLine: () => void;
/**
* Change from cursor to end of line (vim 'C' command)
* With count > 1, changes to end of current line plus (count-1) additional lines
*/
vimChangeToEndOfLine: () => void;
vimChangeToEndOfLine: (count?: number) => void;
/**
* Delete from cursor to first non-whitespace character (vim 'd^' command)
*/
vimDeleteToFirstNonWhitespace: () => void;
/**
* Change from cursor to start of line (vim 'c0' command)
*/
vimChangeToStartOfLine: () => void;
/**
* Change from cursor to first non-whitespace character (vim 'c^' command)
*/
vimChangeToFirstNonWhitespace: () => void;
/**
* Delete from current line to first line (vim 'dgg' command)
*/
vimDeleteToFirstLine: (count: number) => void;
/**
* Delete from current line to last line (vim 'dG' command)
*/
vimDeleteToLastLine: (count: number) => void;
/**
* Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
*/
@@ -469,6 +469,24 @@ describe('vim-buffer-actions', () => {
expect(result.cursorCol).toBe(3); // Position of 'h'
});
it('vim_move_to_first_nonwhitespace should go to column 0 on whitespace-only line', () => {
const state = createTestState([' '], 0, 3);
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_first_nonwhitespace should go to column 0 on empty line', () => {
const state = createTestState([''], 0, 0);
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_first_line should move to row 0', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
const action = { type: 'vim_move_to_first_line' as const };
@@ -725,7 +743,10 @@ describe('vim-buffer-actions', () => {
describe('vim_delete_to_end_of_line', () => {
it('should delete from cursor to end of line', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_delete_to_end_of_line' as const };
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
@@ -735,12 +756,401 @@ describe('vim-buffer-actions', () => {
it('should do nothing at end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = { type: 'vim_delete_to_end_of_line' as const };
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
});
it('should delete to end of line plus additional lines with count > 1', () => {
const state = createTestState(
['line one', 'line two', 'line three'],
0,
5,
);
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 2D at position 5 on "line one" should delete "one" + entire "line two"
expect(result.lines).toEqual(['line ', 'line three']);
expect(result.cursorCol).toBe(5);
});
it('should handle count exceeding available lines', () => {
const state = createTestState(['line one', 'line two'], 0, 5);
const action = {
type: 'vim_delete_to_end_of_line' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Should delete to end of available lines
expect(result.lines).toEqual(['line ']);
});
});
describe('vim_delete_to_first_nonwhitespace', () => {
it('should delete from cursor backwards to first non-whitespace', () => {
const state = createTestState([' hello world'], 0, 10);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete from 'h' (col 4) to cursor (col 10), leaving " world"
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(4);
});
it('should delete from cursor forwards when cursor is in whitespace', () => {
const state = createTestState([' hello'], 0, 2);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete from cursor (col 2) to first non-ws (col 4), leaving " hello"
expect(result.lines[0]).toBe(' hello');
expect(result.cursorCol).toBe(2);
});
it('should do nothing when cursor is at first non-whitespace', () => {
const state = createTestState([' hello'], 0, 4);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' hello');
});
it('should delete to column 0 on whitespace-only line', () => {
const state = createTestState([' '], 0, 2);
const action = { type: 'vim_delete_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// On whitespace-only line, ^ goes to col 0, so d^ deletes cols 0-2
expect(result.lines[0]).toBe(' ');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_first_line', () => {
it('should delete from current line to first line (dgg)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
2,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 0, 1, 2 (current), leaving line4
expect(result.lines).toEqual(['line4']);
expect(result.cursorRow).toBe(0);
});
it('should delete from current line to specified line (d5gg)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
4,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 2 }, // Delete to line 2 (1-based)
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 1-4 (line2 to line5), leaving line1
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
});
it('should keep one empty line when deleting all lines', () => {
const state = createTestState(['line1', 'line2'], 1, 0);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
});
});
describe('vim_delete_to_last_line', () => {
it('should delete from current line to last line (dG)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
1,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 1, 2, 3 (from current to last), leaving line1
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
});
it('should delete from current line to specified line (d3G)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
0,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 3 }, // Delete to line 3 (1-based)
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// Delete lines 0-2 (line1 to line3), leaving line4 and line5
expect(result.lines).toEqual(['line4', 'line5']);
expect(result.cursorRow).toBe(0);
});
it('should keep one empty line when deleting all lines', () => {
const state = createTestState(['line1', 'line2'], 0, 0);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
});
});
describe('vim_change_to_start_of_line', () => {
it('should delete from start of line to cursor (c0)', () => {
const state = createTestState(['hello world'], 0, 6);
const action = { type: 'vim_change_to_start_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('world');
expect(result.cursorCol).toBe(0);
});
it('should do nothing at start of line', () => {
const state = createTestState(['hello'], 0, 0);
const action = { type: 'vim_change_to_start_of_line' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello');
});
});
describe('vim_change_to_first_nonwhitespace', () => {
it('should delete from first non-whitespace to cursor (c^)', () => {
const state = createTestState([' hello world'], 0, 10);
const action = { type: 'vim_change_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(4);
});
it('should delete backwards when cursor before first non-whitespace', () => {
const state = createTestState([' hello'], 0, 2);
const action = { type: 'vim_change_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' hello');
expect(result.cursorCol).toBe(2);
});
it('should handle whitespace-only line', () => {
const state = createTestState([' '], 0, 3);
const action = { type: 'vim_change_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe(' ');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_change_to_end_of_line', () => {
it('should delete from cursor to end of line (C)', () => {
const state = createTestState(['hello world'], 0, 6);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
});
it('should delete multiple lines with count (2C)', () => {
const state = createTestState(['line1 hello', 'line2', 'line3'], 0, 6);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1 ', 'line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(6);
});
it('should delete remaining lines when count exceeds available (3C on 2 lines)', () => {
const state = createTestState(['hello world', 'end'], 0, 6);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['hello ']);
expect(result.cursorCol).toBe(6);
});
it('should handle count at last line', () => {
const state = createTestState(['first', 'last line'], 1, 5);
const action = {
type: 'vim_change_to_end_of_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['first', 'last ']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(5);
});
});
describe('vim_change_to_first_line', () => {
it('should delete from first line to current line (cgg)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
});
it('should delete from line 1 to target line (c3gg)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
0,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line4', 'line5']);
expect(result.cursorRow).toBe(0);
});
it('should handle cursor below target line', () => {
// Cursor on line 4 (index 3), target line 2 (index 1)
// Should delete lines 2-4 (indices 1-3), leaving line1 and line5
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
3,
0,
);
const action = {
type: 'vim_delete_to_first_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line5']);
expect(result.cursorRow).toBe(1);
});
});
describe('vim_change_to_last_line', () => {
it('should delete from current line to last line (cG)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 3);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 0 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
});
it('should delete from cursor to target line (c2G)', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
0,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3', 'line4']);
expect(result.cursorRow).toBe(0);
});
it('should handle cursor above target', () => {
// Cursor on line 2 (index 1), target line 3 (index 2)
// Should delete lines 2-3 (indices 1-2), leaving line1 and line4
const state = createTestState(
['line1', 'line2', 'line3', 'line4'],
1,
0,
);
const action = {
type: 'vim_delete_to_last_line' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line4']);
expect(result.cursorRow).toBe(1);
});
});
});
@@ -922,11 +1332,127 @@ describe('vim-buffer-actions', () => {
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// The movement 'j' with count 2 changes 2 lines starting from cursor row
// Since we're at cursor position 2, it changes lines starting from current row
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
// In VIM, 2cj deletes current line + 2 lines below = 3 lines total
// Since there are exactly 3 lines, all are deleted
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
expect(result.cursorCol).toBe(0);
});
it('should handle Unicode characters in cj (down)', () => {
const state = createTestState(
['hello 🎉 world', 'line2 émoji', 'line3'],
0,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle Unicode characters in ck (up)', () => {
const state = createTestState(
['line1', 'hello 🎉 world', 'line3 émoji'],
2,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'k' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle cj on first line of 2 lines (delete all)', () => {
const state = createTestState(['line1', 'line2'], 0, 0);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle cj on last line (delete only current line)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 0);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line1', 'line2']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should handle ck on first line (delete only current line)', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 0);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'k' as const, count: 1 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
expect(result.lines).toEqual(['line2', 'line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle 2cj from middle line', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
1,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j' as const, count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 2cj from line 1: delete lines 1, 2, 3 (current + 2 below)
expect(result.lines).toEqual(['line1', 'line5']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should handle 2ck from middle line', () => {
const state = createTestState(
['line1', 'line2', 'line3', 'line4', 'line5'],
3,
0,
);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'k' as const, count: 2 },
};
const result = handleVimAction(state, action);
expect(result).toHaveOnlyValidCharacters();
// 2ck from line 3: delete lines 1, 2, 3 (current + 2 above)
expect(result.lines).toEqual(['line1', 'line5']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
});
@@ -39,7 +39,13 @@ export type VimAction = Extract<
| { type: 'vim_delete_line' }
| { type: 'vim_change_line' }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_delete_to_start_of_line' }
| { type: 'vim_delete_to_first_nonwhitespace' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_change_to_start_of_line' }
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line' }
| { type: 'vim_delete_to_last_line' }
| { type: 'vim_change_movement' }
| { type: 'vim_move_left' }
| { type: 'vim_move_right' }
@@ -387,21 +393,253 @@ export function handleVimAction(
case 'vim_delete_to_end_of_line':
case 'vim_change_to_end_of_line': {
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
if (cursorCol < cpLen(currentLine)) {
const totalLines = lines.length;
if (count === 1) {
// Single line: delete from cursor to end of current line
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cpLen(currentLine),
'',
);
}
return state;
} else {
// Multi-line: delete from cursor to end of current line, plus (count-1) entire lines below
// For example, 2D = delete to EOL + delete next line entirely
const linesToDelete = Math.min(count - 1, totalLines - cursorRow - 1);
const endRow = cursorRow + linesToDelete;
if (endRow === cursorRow) {
// No additional lines to delete, just delete to EOL
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cpLen(currentLine),
'',
);
}
return state;
}
// Delete from cursor position to end of endRow (including newlines)
const nextState = detachExpandedPaste(pushUndo(state));
const endLine = lines[endRow] || '';
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
endRow,
cpLen(endLine),
'',
);
}
}
case 'vim_delete_to_start_of_line': {
if (cursorCol > 0) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cpLen(currentLine),
0,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_delete_to_first_nonwhitespace': {
// Delete from cursor to first non-whitespace character (vim 'd^')
const currentLine = lines[cursorRow] || '';
const lineCodePoints = toCodePoints(currentLine);
let firstNonWs = 0;
while (
firstNonWs < lineCodePoints.length &&
/\s/.test(lineCodePoints[firstNonWs])
) {
firstNonWs++;
}
// If line is all whitespace, firstNonWs would be lineCodePoints.length
// In VIM, ^ on whitespace-only line goes to column 0
if (firstNonWs >= lineCodePoints.length) {
firstNonWs = 0;
}
// Delete between cursor and first non-whitespace (whichever direction)
if (cursorCol !== firstNonWs) {
const startCol = Math.min(cursorCol, firstNonWs);
const endCol = Math.max(cursorCol, firstNonWs);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_to_start_of_line': {
// Change from cursor to start of line (vim 'c0')
if (cursorCol > 0) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
0,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_change_to_first_nonwhitespace': {
// Change from cursor to first non-whitespace character (vim 'c^')
const currentLine = lines[cursorRow] || '';
const lineCodePoints = toCodePoints(currentLine);
let firstNonWs = 0;
while (
firstNonWs < lineCodePoints.length &&
/\s/.test(lineCodePoints[firstNonWs])
) {
firstNonWs++;
}
// If line is all whitespace, firstNonWs would be lineCodePoints.length
// In VIM, ^ on whitespace-only line goes to column 0
if (firstNonWs >= lineCodePoints.length) {
firstNonWs = 0;
}
// Change between cursor and first non-whitespace (whichever direction)
if (cursorCol !== firstNonWs) {
const startCol = Math.min(cursorCol, firstNonWs);
const endCol = Math.max(cursorCol, firstNonWs);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_to_first_line': {
// Delete from first line (or line N if count given) to current line (vim 'dgg' or 'd5gg')
// count is the target line number (1-based), or 0 for first line
const { count } = action.payload;
const totalLines = lines.length;
// Determine target row (0-based)
// count=0 means go to first line, count=N means go to line N (1-based)
let targetRow: number;
if (count > 0) {
targetRow = Math.min(count - 1, totalLines - 1);
} else {
targetRow = 0;
}
// Determine the range to delete (from min to max row, inclusive)
const startRow = Math.min(cursorRow, targetRow);
const endRow = Math.max(cursorRow, targetRow);
const linesToDelete = endRow - startRow + 1;
if (linesToDelete >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToDelete);
// Cursor goes to start of the deleted range, clamped to valid bounds
const newCursorRow = Math.min(startRow, newLines.length - 1);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_delete_to_last_line': {
// Delete from current line to last line (vim 'dG') or to line N (vim 'd5G')
// count is the target line number (1-based), or 0 for last line
const { count } = action.payload;
const totalLines = lines.length;
// Determine target row (0-based)
// count=0 means go to last line, count=N means go to line N (1-based)
let targetRow: number;
if (count > 0) {
targetRow = Math.min(count - 1, totalLines - 1);
} else {
targetRow = totalLines - 1;
}
// Determine the range to delete (from min to max row, inclusive)
const startRow = Math.min(cursorRow, targetRow);
const endRow = Math.max(cursorRow, targetRow);
const linesToDelete = endRow - startRow + 1;
if (linesToDelete >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToDelete);
// Move cursor to the start of the deleted range (or last line if needed)
const newCursorRow = Math.min(startRow, newLines.length - 1);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_change_movement': {
const { movement, count } = action.payload;
const totalLines = lines.length;
@@ -422,88 +660,65 @@ export function handleVimAction(
}
case 'j': {
// Down
const linesToChange = Math.min(count, totalLines - cursorRow);
// Down - delete/change current line + count lines below
const linesToChange = Math.min(count + 1, totalLines - cursorRow);
if (linesToChange > 0) {
if (totalLines === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
detachExpandedPaste(pushUndo(state)),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
if (linesToChange >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
linesToChange,
nextState.lines,
);
const { startRow, startCol, endRow, endCol } =
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(cursorRow, linesToChange);
return {
...nextState,
lines: newLines,
cursorRow: Math.min(cursorRow, newLines.length - 1),
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
case 'k': {
// Up
const upLines = Math.min(count, cursorRow + 1);
if (upLines > 0) {
if (state.lines.length === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
detachExpandedPaste(pushUndo(state)),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const startRow = Math.max(0, cursorRow - count + 1);
const linesToChange = cursorRow - startRow + 1;
// Up - delete/change current line + count lines above
const startRow = Math.max(0, cursorRow - count);
const linesToChange = cursorRow - startRow + 1;
if (linesToChange > 0) {
if (linesToChange >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
startRow,
linesToChange,
nextState.lines,
);
const {
startRow: newStartRow,
startCol,
endRow,
endCol,
} = getPositionFromOffsets(
startOffset,
endOffset,
nextState.lines,
);
const resultState = replaceRangeInternal(
nextState,
newStartRow,
startCol,
endRow,
endCol,
'',
);
return {
...resultState,
cursorRow: startRow,
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToChange);
return {
...nextState,
lines: newLines,
cursorRow: Math.min(startRow, newLines.length - 1),
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
@@ -910,6 +1125,11 @@ export function handleVimAction(
col++;
}
// If line is all whitespace or empty, ^ goes to column 0 (standard Vim behavior)
if (col >= lineCodePoints.length) {
col = 0;
}
return {
...state,
cursorCol: col,