mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-12 21:03:05 -07:00
fix(vim): vim support that feels (more) complete (#18755)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
@@ -9,9 +9,9 @@ available combinations.
|
|||||||
#### Basic Controls
|
#### Basic Controls
|
||||||
|
|
||||||
| Action | Keys |
|
| Action | Keys |
|
||||||
| --------------------------------------------------------------- | ---------- |
|
| --------------------------------------------------------------- | --------------------- |
|
||||||
| Confirm the current selection or choice. | `Enter` |
|
| Confirm the current selection or choice. | `Enter` |
|
||||||
| Dismiss dialogs or cancel the current focus. | `Esc` |
|
| Dismiss dialogs or cancel the current focus. | `Esc`<br />`Ctrl + [` |
|
||||||
| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` |
|
| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` |
|
||||||
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
|
| Exit the CLI when the input buffer is empty. | `Ctrl + D` |
|
||||||
|
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export type KeyBindingConfig = {
|
|||||||
export const defaultKeyBindings: KeyBindingConfig = {
|
export const defaultKeyBindings: KeyBindingConfig = {
|
||||||
// Basic Controls
|
// Basic Controls
|
||||||
[Command.RETURN]: [{ key: 'return' }],
|
[Command.RETURN]: [{ key: 'return' }],
|
||||||
[Command.ESCAPE]: [{ key: 'escape' }],
|
[Command.ESCAPE]: [{ key: 'escape' }, { key: '[', ctrl: true }],
|
||||||
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
||||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||||
|
|
||||||
|
|||||||
@@ -1657,8 +1657,9 @@ export type TextBufferAction =
|
|||||||
| { type: 'vim_change_big_word_end'; payload: { count: number } }
|
| { type: 'vim_change_big_word_end'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_line'; payload: { count: number } }
|
| { type: 'vim_delete_line'; payload: { count: number } }
|
||||||
| { type: 'vim_change_line'; payload: { count: number } }
|
| { type: 'vim_change_line'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_to_end_of_line' }
|
| { type: 'vim_delete_to_end_of_line'; payload: { count: number } }
|
||||||
| { type: 'vim_change_to_end_of_line' }
|
| { type: 'vim_delete_to_start_of_line' }
|
||||||
|
| { type: 'vim_change_to_end_of_line'; payload: { count: number } }
|
||||||
| {
|
| {
|
||||||
type: 'vim_change_movement';
|
type: 'vim_change_movement';
|
||||||
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
|
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_last_line' }
|
||||||
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
|
||||||
| { type: 'vim_escape_insert_mode' }
|
| { 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';
|
type: 'toggle_paste_expansion';
|
||||||
payload: { id: string; row: number; col: number };
|
payload: { id: string; row: number; col: number };
|
||||||
@@ -2437,6 +2443,7 @@ function textBufferReducerLogic(
|
|||||||
case 'vim_delete_line':
|
case 'vim_delete_line':
|
||||||
case 'vim_change_line':
|
case 'vim_change_line':
|
||||||
case 'vim_delete_to_end_of_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_to_end_of_line':
|
||||||
case 'vim_change_movement':
|
case 'vim_change_movement':
|
||||||
case 'vim_move_left':
|
case 'vim_move_left':
|
||||||
@@ -2463,6 +2470,11 @@ function textBufferReducerLogic(
|
|||||||
case 'vim_move_to_last_line':
|
case 'vim_move_to_last_line':
|
||||||
case 'vim_move_to_line':
|
case 'vim_move_to_line':
|
||||||
case 'vim_escape_insert_mode':
|
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);
|
return handleVimAction(state, action as VimAction);
|
||||||
|
|
||||||
case 'toggle_paste_expansion': {
|
case 'toggle_paste_expansion': {
|
||||||
@@ -2945,12 +2957,36 @@ export function useTextBuffer({
|
|||||||
dispatch({ type: 'vim_change_line', payload: { count } });
|
dispatch({ type: 'vim_change_line', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const vimDeleteToEndOfLine = useCallback((): void => {
|
const vimDeleteToEndOfLine = useCallback((count: number = 1): void => {
|
||||||
dispatch({ type: 'vim_delete_to_end_of_line' });
|
dispatch({ type: 'vim_delete_to_end_of_line', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const vimChangeToEndOfLine = useCallback((): void => {
|
const vimDeleteToStartOfLine = useCallback((): void => {
|
||||||
dispatch({ type: 'vim_change_to_end_of_line' });
|
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(
|
const vimChangeMovement = useCallback(
|
||||||
@@ -3510,7 +3546,13 @@ export function useTextBuffer({
|
|||||||
vimDeleteLine,
|
vimDeleteLine,
|
||||||
vimChangeLine,
|
vimChangeLine,
|
||||||
vimDeleteToEndOfLine,
|
vimDeleteToEndOfLine,
|
||||||
|
vimDeleteToStartOfLine,
|
||||||
vimChangeToEndOfLine,
|
vimChangeToEndOfLine,
|
||||||
|
vimDeleteToFirstNonWhitespace,
|
||||||
|
vimChangeToStartOfLine,
|
||||||
|
vimChangeToFirstNonWhitespace,
|
||||||
|
vimDeleteToFirstLine,
|
||||||
|
vimDeleteToLastLine,
|
||||||
vimChangeMovement,
|
vimChangeMovement,
|
||||||
vimMoveLeft,
|
vimMoveLeft,
|
||||||
vimMoveRight,
|
vimMoveRight,
|
||||||
@@ -3592,7 +3634,13 @@ export function useTextBuffer({
|
|||||||
vimDeleteLine,
|
vimDeleteLine,
|
||||||
vimChangeLine,
|
vimChangeLine,
|
||||||
vimDeleteToEndOfLine,
|
vimDeleteToEndOfLine,
|
||||||
|
vimDeleteToStartOfLine,
|
||||||
vimChangeToEndOfLine,
|
vimChangeToEndOfLine,
|
||||||
|
vimDeleteToFirstNonWhitespace,
|
||||||
|
vimChangeToStartOfLine,
|
||||||
|
vimChangeToFirstNonWhitespace,
|
||||||
|
vimDeleteToFirstLine,
|
||||||
|
vimDeleteToLastLine,
|
||||||
vimChangeMovement,
|
vimChangeMovement,
|
||||||
vimMoveLeft,
|
vimMoveLeft,
|
||||||
vimMoveRight,
|
vimMoveRight,
|
||||||
@@ -3832,12 +3880,38 @@ export interface TextBuffer {
|
|||||||
vimChangeLine: (count: number) => void;
|
vimChangeLine: (count: number) => void;
|
||||||
/**
|
/**
|
||||||
* Delete from cursor to end of line (vim 'D' command)
|
* 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)
|
* 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)
|
* 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'
|
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', () => {
|
it('vim_move_to_first_line should move to row 0', () => {
|
||||||
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
|
const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
|
||||||
const action = { type: 'vim_move_to_first_line' as const };
|
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', () => {
|
describe('vim_delete_to_end_of_line', () => {
|
||||||
it('should delete from cursor to end of line', () => {
|
it('should delete from cursor to end of line', () => {
|
||||||
const state = createTestState(['hello world'], 0, 5);
|
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);
|
const result = handleVimAction(state, action);
|
||||||
expect(result).toHaveOnlyValidCharacters();
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
@@ -735,12 +756,401 @@ describe('vim-buffer-actions', () => {
|
|||||||
|
|
||||||
it('should do nothing at end of line', () => {
|
it('should do nothing at end of line', () => {
|
||||||
const state = createTestState(['hello'], 0, 5);
|
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);
|
const result = handleVimAction(state, action);
|
||||||
expect(result).toHaveOnlyValidCharacters();
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
expect(result.lines[0]).toBe('hello');
|
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);
|
const result = handleVimAction(state, action);
|
||||||
expect(result).toHaveOnlyValidCharacters();
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
// The movement 'j' with count 2 changes 2 lines starting from cursor row
|
// In VIM, 2cj deletes current line + 2 lines below = 3 lines total
|
||||||
// Since we're at cursor position 2, it changes lines starting from current row
|
// Since there are exactly 3 lines, all are deleted
|
||||||
expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
|
expect(result.lines).toEqual(['']);
|
||||||
expect(result.cursorRow).toBe(0);
|
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_delete_line' }
|
||||||
| { type: 'vim_change_line' }
|
| { type: 'vim_change_line' }
|
||||||
| { type: 'vim_delete_to_end_of_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_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_change_movement' }
|
||||||
| { type: 'vim_move_left' }
|
| { type: 'vim_move_left' }
|
||||||
| { type: 'vim_move_right' }
|
| { type: 'vim_move_right' }
|
||||||
@@ -387,7 +393,12 @@ export function handleVimAction(
|
|||||||
|
|
||||||
case 'vim_delete_to_end_of_line':
|
case 'vim_delete_to_end_of_line':
|
||||||
case 'vim_change_to_end_of_line': {
|
case 'vim_change_to_end_of_line': {
|
||||||
|
const { count } = action.payload;
|
||||||
const currentLine = lines[cursorRow] || '';
|
const currentLine = lines[cursorRow] || '';
|
||||||
|
const totalLines = lines.length;
|
||||||
|
|
||||||
|
if (count === 1) {
|
||||||
|
// Single line: delete from cursor to end of current line
|
||||||
if (cursorCol < cpLen(currentLine)) {
|
if (cursorCol < cpLen(currentLine)) {
|
||||||
const nextState = detachExpandedPaste(pushUndo(state));
|
const nextState = detachExpandedPaste(pushUndo(state));
|
||||||
return replaceRangeInternal(
|
return replaceRangeInternal(
|
||||||
@@ -400,6 +411,233 @@ export function handleVimAction(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return state;
|
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,
|
||||||
|
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': {
|
case 'vim_change_movement': {
|
||||||
@@ -422,88 +660,65 @@ export function handleVimAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'j': {
|
case 'j': {
|
||||||
// Down
|
// Down - delete/change current line + count lines below
|
||||||
const linesToChange = Math.min(count, totalLines - cursorRow);
|
const linesToChange = Math.min(count + 1, totalLines - cursorRow);
|
||||||
if (linesToChange > 0) {
|
if (linesToChange > 0) {
|
||||||
if (totalLines === 1) {
|
if (linesToChange >= totalLines) {
|
||||||
const currentLine = state.lines[0] || '';
|
// Deleting all lines - keep one empty line
|
||||||
return replaceRangeInternal(
|
|
||||||
detachExpandedPaste(pushUndo(state)),
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
0,
|
|
||||||
cpLen(currentLine),
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const nextState = detachExpandedPaste(pushUndo(state));
|
const nextState = detachExpandedPaste(pushUndo(state));
|
||||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
return {
|
||||||
cursorRow,
|
...nextState,
|
||||||
linesToChange,
|
lines: [''],
|
||||||
nextState.lines,
|
cursorRow: 0,
|
||||||
);
|
cursorCol: 0,
|
||||||
const { startRow, startCol, endRow, endCol } =
|
preferredCol: null,
|
||||||
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
|
};
|
||||||
return replaceRangeInternal(
|
|
||||||
nextState,
|
|
||||||
startRow,
|
|
||||||
startCol,
|
|
||||||
endRow,
|
|
||||||
endCol,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'k': {
|
case 'k': {
|
||||||
// Up
|
// Up - delete/change current line + count lines above
|
||||||
const upLines = Math.min(count, cursorRow + 1);
|
const startRow = Math.max(0, cursorRow - count);
|
||||||
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;
|
const linesToChange = cursorRow - startRow + 1;
|
||||||
|
|
||||||
|
if (linesToChange > 0) {
|
||||||
|
if (linesToChange >= totalLines) {
|
||||||
|
// Deleting all lines - keep one empty line
|
||||||
const nextState = detachExpandedPaste(pushUndo(state));
|
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 {
|
return {
|
||||||
...resultState,
|
...nextState,
|
||||||
cursorRow: startRow,
|
lines: [''],
|
||||||
|
cursorRow: 0,
|
||||||
cursorCol: 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;
|
return state;
|
||||||
}
|
}
|
||||||
@@ -910,6 +1125,11 @@ export function handleVimAction(
|
|||||||
col++;
|
col++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If line is all whitespace or empty, ^ goes to column 0 (standard Vim behavior)
|
||||||
|
if (col >= lineCodePoints.length) {
|
||||||
|
col = 0;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...state,
|
...state,
|
||||||
cursorCol: col,
|
cursorCol: col,
|
||||||
|
|||||||
@@ -1708,6 +1708,7 @@ describe('useVim hook', () => {
|
|||||||
cursorRow: 0,
|
cursorRow: 0,
|
||||||
cursorCol: 6,
|
cursorCol: 6,
|
||||||
actionType: 'vim_delete_to_end_of_line' as const,
|
actionType: 'vim_delete_to_end_of_line' as const,
|
||||||
|
count: 1,
|
||||||
expectedLines: ['hello '],
|
expectedLines: ['hello '],
|
||||||
expectedCursorRow: 0,
|
expectedCursorRow: 0,
|
||||||
expectedCursorCol: 6,
|
expectedCursorCol: 6,
|
||||||
@@ -1719,6 +1720,7 @@ describe('useVim hook', () => {
|
|||||||
cursorRow: 0,
|
cursorRow: 0,
|
||||||
cursorCol: 11,
|
cursorCol: 11,
|
||||||
actionType: 'vim_delete_to_end_of_line' as const,
|
actionType: 'vim_delete_to_end_of_line' as const,
|
||||||
|
count: 1,
|
||||||
expectedLines: ['hello world'],
|
expectedLines: ['hello world'],
|
||||||
expectedCursorRow: 0,
|
expectedCursorRow: 0,
|
||||||
expectedCursorCol: 11,
|
expectedCursorCol: 11,
|
||||||
@@ -1730,6 +1732,7 @@ describe('useVim hook', () => {
|
|||||||
cursorRow: 0,
|
cursorRow: 0,
|
||||||
cursorCol: 6,
|
cursorCol: 6,
|
||||||
actionType: 'vim_change_to_end_of_line' as const,
|
actionType: 'vim_change_to_end_of_line' as const,
|
||||||
|
count: 1,
|
||||||
expectedLines: ['hello '],
|
expectedLines: ['hello '],
|
||||||
expectedCursorRow: 0,
|
expectedCursorRow: 0,
|
||||||
expectedCursorCol: 6,
|
expectedCursorCol: 6,
|
||||||
@@ -1741,6 +1744,7 @@ describe('useVim hook', () => {
|
|||||||
cursorRow: 0,
|
cursorRow: 0,
|
||||||
cursorCol: 0,
|
cursorCol: 0,
|
||||||
actionType: 'vim_change_to_end_of_line' as const,
|
actionType: 'vim_change_to_end_of_line' as const,
|
||||||
|
count: 1,
|
||||||
expectedLines: [''],
|
expectedLines: [''],
|
||||||
expectedCursorRow: 0,
|
expectedCursorRow: 0,
|
||||||
expectedCursorCol: 0,
|
expectedCursorCol: 0,
|
||||||
|
|||||||
@@ -44,19 +44,33 @@ const CMD_TYPES = {
|
|||||||
UP: 'ck',
|
UP: 'ck',
|
||||||
RIGHT: 'cl',
|
RIGHT: 'cl',
|
||||||
},
|
},
|
||||||
|
DELETE_MOVEMENT: {
|
||||||
|
LEFT: 'dh',
|
||||||
|
DOWN: 'dj',
|
||||||
|
UP: 'dk',
|
||||||
|
RIGHT: 'dl',
|
||||||
|
},
|
||||||
|
DELETE_TO_SOL: 'd0',
|
||||||
|
DELETE_TO_FIRST_NONWS: 'd^',
|
||||||
|
CHANGE_TO_SOL: 'c0',
|
||||||
|
CHANGE_TO_FIRST_NONWS: 'c^',
|
||||||
|
DELETE_TO_FIRST_LINE: 'dgg',
|
||||||
|
DELETE_TO_LAST_LINE: 'dG',
|
||||||
|
CHANGE_TO_FIRST_LINE: 'cgg',
|
||||||
|
CHANGE_TO_LAST_LINE: 'cG',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Helper function to clear pending state
|
// Helper function to clear pending state
|
||||||
const createClearPendingState = () => ({
|
const createClearPendingState = () => ({
|
||||||
count: 0,
|
count: 0,
|
||||||
pendingOperator: null as 'g' | 'd' | 'c' | null,
|
pendingOperator: null as 'g' | 'd' | 'c' | 'dg' | 'cg' | null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// State and action types for useReducer
|
// State and action types for useReducer
|
||||||
type VimState = {
|
type VimState = {
|
||||||
mode: VimMode;
|
mode: VimMode;
|
||||||
count: number;
|
count: number;
|
||||||
pendingOperator: 'g' | 'd' | 'c' | null;
|
pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
|
||||||
lastCommand: { type: string; count: number } | null;
|
lastCommand: { type: string; count: number } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -65,7 +79,10 @@ type VimAction =
|
|||||||
| { type: 'SET_COUNT'; count: number }
|
| { type: 'SET_COUNT'; count: number }
|
||||||
| { type: 'INCREMENT_COUNT'; digit: number }
|
| { type: 'INCREMENT_COUNT'; digit: number }
|
||||||
| { type: 'CLEAR_COUNT' }
|
| { type: 'CLEAR_COUNT' }
|
||||||
| { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null }
|
| {
|
||||||
|
type: 'SET_PENDING_OPERATOR';
|
||||||
|
operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
|
||||||
|
}
|
||||||
| {
|
| {
|
||||||
type: 'SET_LAST_COMMAND';
|
type: 'SET_LAST_COMMAND';
|
||||||
command: { type: string; count: number } | null;
|
command: { type: string; count: number } | null;
|
||||||
@@ -279,12 +296,73 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case CMD_TYPES.DELETE_TO_EOL: {
|
case CMD_TYPES.DELETE_TO_EOL: {
|
||||||
buffer.vimDeleteToEndOfLine();
|
buffer.vimDeleteToEndOfLine(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_TO_SOL: {
|
||||||
|
buffer.vimDeleteToStartOfLine();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_MOVEMENT.LEFT:
|
||||||
|
case CMD_TYPES.DELETE_MOVEMENT.DOWN:
|
||||||
|
case CMD_TYPES.DELETE_MOVEMENT.UP:
|
||||||
|
case CMD_TYPES.DELETE_MOVEMENT.RIGHT: {
|
||||||
|
const movementMap: Record<string, 'h' | 'j' | 'k' | 'l'> = {
|
||||||
|
[CMD_TYPES.DELETE_MOVEMENT.LEFT]: 'h',
|
||||||
|
[CMD_TYPES.DELETE_MOVEMENT.DOWN]: 'j',
|
||||||
|
[CMD_TYPES.DELETE_MOVEMENT.UP]: 'k',
|
||||||
|
[CMD_TYPES.DELETE_MOVEMENT.RIGHT]: 'l',
|
||||||
|
};
|
||||||
|
const movementType = movementMap[cmdType];
|
||||||
|
if (movementType) {
|
||||||
|
buffer.vimChangeMovement(movementType, count);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case CMD_TYPES.CHANGE_TO_EOL: {
|
case CMD_TYPES.CHANGE_TO_EOL: {
|
||||||
buffer.vimChangeToEndOfLine();
|
buffer.vimChangeToEndOfLine(count);
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_TO_FIRST_NONWS: {
|
||||||
|
buffer.vimDeleteToFirstNonWhitespace();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_TO_SOL: {
|
||||||
|
buffer.vimChangeToStartOfLine();
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_TO_FIRST_NONWS: {
|
||||||
|
buffer.vimChangeToFirstNonWhitespace();
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_TO_FIRST_LINE: {
|
||||||
|
buffer.vimDeleteToFirstLine(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_TO_LAST_LINE: {
|
||||||
|
buffer.vimDeleteToLastLine(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_TO_FIRST_LINE: {
|
||||||
|
buffer.vimDeleteToFirstLine(count);
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_TO_LAST_LINE: {
|
||||||
|
buffer.vimDeleteToLastLine(count);
|
||||||
updateMode('INSERT');
|
updateMode('INSERT');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -324,6 +402,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
return false; // Let InputPrompt handle completion
|
return false; // Let InputPrompt handle completion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let InputPrompt handle Ctrl+U (kill line left) and Ctrl+K (kill line right)
|
||||||
|
if (
|
||||||
|
normalizedKey.ctrl &&
|
||||||
|
(normalizedKey.name === 'u' || normalizedKey.name === 'k')
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Let InputPrompt handle Ctrl+V for clipboard image pasting
|
// Let InputPrompt handle Ctrl+V for clipboard image pasting
|
||||||
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
|
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
|
||||||
return false; // Let InputPrompt handle clipboard functionality
|
return false; // Let InputPrompt handle clipboard functionality
|
||||||
@@ -403,6 +489,37 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
[getCurrentCount, dispatch, buffer, updateMode],
|
[getCurrentCount, dispatch, buffer, updateMode],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles delete movement commands (dh, dj, dk, dl)
|
||||||
|
* @param movement - The movement direction
|
||||||
|
* @returns boolean indicating if command was handled
|
||||||
|
*/
|
||||||
|
const handleDeleteMovement = useCallback(
|
||||||
|
(movement: 'h' | 'j' | 'k' | 'l'): boolean => {
|
||||||
|
const count = getCurrentCount();
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
// Note: vimChangeMovement performs the same deletion operation as what we need.
|
||||||
|
// The only difference between 'change' and 'delete' is that 'change' enters
|
||||||
|
// INSERT mode after deletion, which is handled here (we simply don't call updateMode).
|
||||||
|
buffer.vimChangeMovement(movement, count);
|
||||||
|
|
||||||
|
const cmdTypeMap = {
|
||||||
|
h: CMD_TYPES.DELETE_MOVEMENT.LEFT,
|
||||||
|
j: CMD_TYPES.DELETE_MOVEMENT.DOWN,
|
||||||
|
k: CMD_TYPES.DELETE_MOVEMENT.UP,
|
||||||
|
l: CMD_TYPES.DELETE_MOVEMENT.RIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: cmdTypeMap[movement], count },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[getCurrentCount, dispatch, buffer],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles operator-motion commands (dw/cw, db/cb, de/ce)
|
* Handles operator-motion commands (dw/cw, db/cb, de/ce)
|
||||||
* @param operator - The operator type ('d' for delete, 'c' for change)
|
* @param operator - The operator type ('d' for delete, 'c' for change)
|
||||||
@@ -510,7 +627,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
|
|
||||||
switch (normalizedKey.sequence) {
|
switch (normalizedKey.sequence) {
|
||||||
case 'h': {
|
case 'h': {
|
||||||
// Check if this is part of a change command (ch)
|
// Check if this is part of a delete or change command (dh/ch)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('h');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('h');
|
return handleChangeMovement('h');
|
||||||
}
|
}
|
||||||
@@ -522,7 +642,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'j': {
|
case 'j': {
|
||||||
// Check if this is part of a change command (cj)
|
// Check if this is part of a delete or change command (dj/cj)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('j');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('j');
|
return handleChangeMovement('j');
|
||||||
}
|
}
|
||||||
@@ -534,7 +657,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'k': {
|
case 'k': {
|
||||||
// Check if this is part of a change command (ck)
|
// Check if this is part of a delete or change command (dk/ck)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('k');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('k');
|
return handleChangeMovement('k');
|
||||||
}
|
}
|
||||||
@@ -546,7 +672,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'l': {
|
case 'l': {
|
||||||
// Check if this is part of a change command (cl)
|
// Check if this is part of a delete or change command (dl/cl)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('l');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('l');
|
return handleChangeMovement('l');
|
||||||
}
|
}
|
||||||
@@ -691,6 +820,30 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case '0': {
|
case '0': {
|
||||||
|
// Check if this is part of a delete command (d0)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
buffer.vimDeleteToStartOfLine();
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: CMD_TYPES.DELETE_TO_SOL, count: 1 },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if this is part of a change command (c0)
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
buffer.vimChangeToStartOfLine();
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: CMD_TYPES.CHANGE_TO_SOL, count: 1 },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
updateMode('INSERT');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Move to start of line
|
// Move to start of line
|
||||||
buffer.vimMoveToLineStart();
|
buffer.vimMoveToLineStart();
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
@@ -698,13 +851,64 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case '$': {
|
case '$': {
|
||||||
// Move to end of line
|
// Check if this is part of a delete command (d$)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
buffer.vimDeleteToEndOfLine(repeatCount);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: CMD_TYPES.DELETE_TO_EOL, count: repeatCount },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if this is part of a change command (c$)
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
buffer.vimChangeToEndOfLine(repeatCount);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: repeatCount },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
updateMode('INSERT');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to end of line (with count, move down count-1 lines first)
|
||||||
|
if (repeatCount > 1) {
|
||||||
|
buffer.vimMoveDown(repeatCount - 1);
|
||||||
|
}
|
||||||
buffer.vimMoveToLineEnd();
|
buffer.vimMoveToLineEnd();
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
case '^': {
|
case '^': {
|
||||||
|
// Check if this is part of a delete command (d^)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
buffer.vimDeleteToFirstNonWhitespace();
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: CMD_TYPES.DELETE_TO_FIRST_NONWS, count: 1 },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if this is part of a change command (c^)
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
buffer.vimChangeToFirstNonWhitespace();
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: { type: CMD_TYPES.CHANGE_TO_FIRST_NONWS, count: 1 },
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
updateMode('INSERT');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Move to first non-whitespace character
|
// Move to first non-whitespace character
|
||||||
buffer.vimMoveToFirstNonWhitespace();
|
buffer.vimMoveToFirstNonWhitespace();
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
@@ -712,19 +916,94 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'g': {
|
case 'g': {
|
||||||
if (state.pendingOperator === 'g') {
|
if (state.pendingOperator === 'd') {
|
||||||
// Second 'g' - go to first line (gg command)
|
// 'dg' - need another 'g' for 'dgg' command
|
||||||
buffer.vimMoveToFirstLine();
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'dg' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
// 'cg' - need another 'g' for 'cgg' command
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'cg' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'dg') {
|
||||||
|
// 'dgg' command - delete from first line (or line N) to current line
|
||||||
|
// Pass state.count directly (0 means first line, N means line N)
|
||||||
|
buffer.vimDeleteToFirstLine(state.count);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: {
|
||||||
|
type: CMD_TYPES.DELETE_TO_FIRST_LINE,
|
||||||
|
count: state.count,
|
||||||
|
},
|
||||||
|
});
|
||||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'cg') {
|
||||||
|
// 'cgg' command - change from first line (or line N) to current line
|
||||||
|
buffer.vimDeleteToFirstLine(state.count);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: {
|
||||||
|
type: CMD_TYPES.CHANGE_TO_FIRST_LINE,
|
||||||
|
count: state.count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
updateMode('INSERT');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'g') {
|
||||||
|
// Second 'g' - go to line N (gg command), or first line if no count
|
||||||
|
if (state.count > 0) {
|
||||||
|
buffer.vimMoveToLine(state.count);
|
||||||
} else {
|
} else {
|
||||||
// First 'g' - wait for second g
|
buffer.vimMoveToFirstLine();
|
||||||
|
}
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
} else {
|
||||||
|
// First 'g' - wait for second g (don't clear count yet)
|
||||||
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
|
||||||
}
|
}
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'G': {
|
case 'G': {
|
||||||
|
// Check if this is part of a delete command (dG)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
// Pass state.count directly (0 means last line, N means line N)
|
||||||
|
buffer.vimDeleteToLastLine(state.count);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: {
|
||||||
|
type: CMD_TYPES.DELETE_TO_LAST_LINE,
|
||||||
|
count: state.count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Check if this is part of a change command (cG)
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
buffer.vimDeleteToLastLine(state.count);
|
||||||
|
dispatch({
|
||||||
|
type: 'SET_LAST_COMMAND',
|
||||||
|
command: {
|
||||||
|
type: CMD_TYPES.CHANGE_TO_LAST_LINE,
|
||||||
|
count: state.count,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
|
||||||
|
updateMode('INSERT');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.count > 0) {
|
if (state.count > 0) {
|
||||||
// Go to specific line number (1-based) when a count was provided
|
// Go to specific line number (1-based) when a count was provided
|
||||||
buffer.vimMoveToLine(state.count);
|
buffer.vimMoveToLine(state.count);
|
||||||
@@ -789,34 +1068,44 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
case 'D': {
|
case 'D': {
|
||||||
// Delete from cursor to end of line (equivalent to d$)
|
// Delete from cursor to end of line (with count, delete to end of N lines)
|
||||||
executeCommand(CMD_TYPES.DELETE_TO_EOL, 1);
|
executeCommand(CMD_TYPES.DELETE_TO_EOL, repeatCount);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SET_LAST_COMMAND',
|
type: 'SET_LAST_COMMAND',
|
||||||
command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 },
|
command: { type: CMD_TYPES.DELETE_TO_EOL, count: repeatCount },
|
||||||
});
|
});
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'C': {
|
case 'C': {
|
||||||
// Change from cursor to end of line (equivalent to c$)
|
// Change from cursor to end of line (with count, change to end of N lines)
|
||||||
executeCommand(CMD_TYPES.CHANGE_TO_EOL, 1);
|
executeCommand(CMD_TYPES.CHANGE_TO_EOL, repeatCount);
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SET_LAST_COMMAND',
|
type: 'SET_LAST_COMMAND',
|
||||||
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 },
|
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: repeatCount },
|
||||||
});
|
});
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'u': {
|
||||||
|
// Undo last change
|
||||||
|
for (let i = 0; i < repeatCount; i++) {
|
||||||
|
buffer.undo();
|
||||||
|
}
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case '.': {
|
case '.': {
|
||||||
// Repeat last command
|
// Repeat last command (use current count if provided, otherwise use original count)
|
||||||
if (state.lastCommand) {
|
if (state.lastCommand) {
|
||||||
const cmdData = state.lastCommand;
|
const cmdData = state.lastCommand;
|
||||||
|
const count = state.count > 0 ? state.count : cmdData.count;
|
||||||
|
|
||||||
// All repeatable commands are now handled by executeCommand
|
// All repeatable commands are now handled by executeCommand
|
||||||
executeCommand(cmdData.type, cmdData.count);
|
executeCommand(cmdData.type, count);
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch({ type: 'CLEAR_COUNT' });
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
@@ -827,6 +1116,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
// Check for arrow keys (they have different sequences but known names)
|
// Check for arrow keys (they have different sequences but known names)
|
||||||
if (normalizedKey.name === 'left') {
|
if (normalizedKey.name === 'left') {
|
||||||
// Left arrow - same as 'h'
|
// Left arrow - same as 'h'
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('h');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('h');
|
return handleChangeMovement('h');
|
||||||
}
|
}
|
||||||
@@ -839,6 +1131,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
|
|
||||||
if (normalizedKey.name === 'down') {
|
if (normalizedKey.name === 'down') {
|
||||||
// Down arrow - same as 'j'
|
// Down arrow - same as 'j'
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('j');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('j');
|
return handleChangeMovement('j');
|
||||||
}
|
}
|
||||||
@@ -851,6 +1146,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
|
|
||||||
if (normalizedKey.name === 'up') {
|
if (normalizedKey.name === 'up') {
|
||||||
// Up arrow - same as 'k'
|
// Up arrow - same as 'k'
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('k');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('k');
|
return handleChangeMovement('k');
|
||||||
}
|
}
|
||||||
@@ -863,6 +1161,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
|
|
||||||
if (normalizedKey.name === 'right') {
|
if (normalizedKey.name === 'right') {
|
||||||
// Right arrow - same as 'l'
|
// Right arrow - same as 'l'
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleDeleteMovement('l');
|
||||||
|
}
|
||||||
if (state.pendingOperator === 'c') {
|
if (state.pendingOperator === 'c') {
|
||||||
return handleChangeMovement('l');
|
return handleChangeMovement('l');
|
||||||
}
|
}
|
||||||
@@ -895,6 +1196,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
dispatch,
|
dispatch,
|
||||||
getCurrentCount,
|
getCurrentCount,
|
||||||
handleChangeMovement,
|
handleChangeMovement,
|
||||||
|
handleDeleteMovement,
|
||||||
handleOperatorMotion,
|
handleOperatorMotion,
|
||||||
buffer,
|
buffer,
|
||||||
executeCommand,
|
executeCommand,
|
||||||
|
|||||||
Reference in New Issue
Block a user