This commit is contained in:
Sijie Wang
2025-07-25 15:36:42 -07:00
committed by GitHub
parent aa71438684
commit fbdc8d5ab3
21 changed files with 5324 additions and 191 deletions

View File

@@ -25,6 +25,7 @@ interface FooterProps {
showMemoryUsage?: boolean;
promptTokenCount: number;
nightly: boolean;
vimMode?: string;
}
export const Footer: React.FC<FooterProps> = ({
@@ -39,6 +40,7 @@ export const Footer: React.FC<FooterProps> = ({
showMemoryUsage,
promptTokenCount,
nightly,
vimMode,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
@@ -46,6 +48,7 @@ export const Footer: React.FC<FooterProps> = ({
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
<Box>
{vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Text>

View File

@@ -171,6 +171,7 @@ describe('InputPrompt', () => {
config: {
getProjectRoot: () => path.join('test', 'project'),
getTargetDir: () => path.join('test', 'project', 'src'),
getVimMode: () => false,
} as unknown as Config,
slashCommands: mockSlashCommands,
commandContext: mockCommandContext,
@@ -1076,4 +1077,48 @@ describe('InputPrompt', () => {
unmount();
});
});
describe('vim mode', () => {
it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => {
props.vimModeEnabled = true;
props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it.
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('i');
await wait();
expect(props.vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).not.toHaveBeenCalled();
unmount();
});
it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => {
props.vimModeEnabled = true;
props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it.
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('i');
await wait();
expect(props.vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
it('should call handleInput when vim mode is disabled', async () => {
// Mock vimHandleInput to return false (vim didn't handle the input)
props.vimHandleInput = vi.fn().mockReturnValue(false);
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('i');
await wait();
expect(props.vimHandleInput).toHaveBeenCalled();
expect(mockBuffer.handleInput).toHaveBeenCalled();
unmount();
});
});
});

View File

@@ -39,6 +39,7 @@ export interface InputPromptProps {
suggestionsWidth: number;
shellModeActive: boolean;
setShellModeActive: (value: boolean) => void;
vimHandleInput?: (key: Key) => boolean;
}
export const InputPrompt: React.FC<InputPromptProps> = ({
@@ -55,6 +56,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
vimHandleInput,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
@@ -169,6 +171,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
if (vimHandleInput && vimHandleInput(key)) {
return;
}
if (
key.sequence === '!' &&
buffer.text === '' &&
@@ -347,6 +353,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
shellHistory,
handleClipboardImage,
resetCompletionState,
vimHandleInput,
],
);

View File

@@ -11,6 +11,7 @@ import {
Viewport,
TextBuffer,
offsetToLogicalPos,
logicalPosToOffset,
textBufferReducer,
TextBufferState,
TextBufferAction,
@@ -1341,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
});
});
describe('logicalPosToOffset', () => {
it('should convert row/col position to offset correctly', () => {
const lines = ['hello', 'world', '123'];
// Line 0: "hello" (5 chars)
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
// Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
// Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
});
it('should handle empty lines', () => {
const lines = ['a', '', 'c'];
expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
});
it('should handle single empty line', () => {
const lines = [''];
expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
});
it('should be inverse of offsetToLogicalPos', () => {
const lines = ['hello', 'world', '123'];
const text = lines.join('\n');
// Test round-trip conversion
for (let offset = 0; offset <= text.length; offset++) {
const [row, col] = offsetToLogicalPos(text, offset);
const convertedOffset = logicalPosToOffset(lines, row, col);
expect(convertedOffset).toBe(offset);
}
});
it('should handle out-of-bounds positions', () => {
const lines = ['hello'];
// Beyond end of line
expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
// Beyond array bounds - should clamp to the last line
expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
});
});
describe('textBufferReducer vim operations', () => {
describe('vim_delete_line', () => {
it('should delete a single line including newline in multi-line text', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3'],
cursorRow: 1,
cursorCol: 2,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 2 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
// After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should delete multiple lines when count > 1', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3', 'line4'],
cursorRow: 1,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 2 },
};
const result = textBufferReducer(initialState, action);
// Should delete line2 and line3, leaving line1 and line4
expect(result.lines).toEqual(['line1', 'line4']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should clear single line content when only one line exists', () => {
const initialState: TextBufferState = {
lines: ['only line'],
cursorRow: 0,
cursorCol: 5,
preferredCol: null,
visualLines: [['only line']],
visualScrollRow: 0,
visualCursor: { row: 0, col: 5 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
// Should clear the line content but keep the line
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle deleting the last line properly', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2'],
cursorRow: 1,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2']],
visualScrollRow: 0,
visualCursor: { row: 1, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
const action: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 1 },
};
const result = textBufferReducer(initialState, action);
// Should delete the last line completely, not leave empty line
expect(result.lines).toEqual(['line1']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
const initialState: TextBufferState = {
lines: ['line1', 'line2', 'line3', 'line4'],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
visualScrollRow: 0,
visualCursor: { row: 0, col: 0 },
viewport: { width: 10, height: 5 },
undoStack: [],
redoStack: [],
};
// Delete all 4 lines with 4dd
const deleteAction: TextBufferAction = {
type: 'vim_delete_line',
payload: { count: 4 },
};
const afterDelete = textBufferReducer(initialState, deleteAction);
// After deleting all lines, should have one empty line
expect(afterDelete.lines).toEqual(['']);
expect(afterDelete.cursorRow).toBe(0);
expect(afterDelete.cursorCol).toBe(0);
// Now paste multiline content - this should work correctly
const pasteAction: TextBufferAction = {
type: 'insert',
payload: 'new1\nnew2\nnew3\nnew4',
};
const afterPaste = textBufferReducer(afterDelete, pasteAction);
// All lines including the first one should be present
expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
expect(afterPaste.cursorRow).toBe(3);
expect(afterPaste.cursorCol).toBe(4);
});
});
});

View File

@@ -13,6 +13,7 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@google/gemini-cli-core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
import { handleVimAction, VimAction } from './vim-buffer-actions.js';
export type Direction =
| 'left'
@@ -32,6 +33,283 @@ function isWordChar(ch: string | undefined): boolean {
return !/[\s,.;!?]/.test(ch);
}
// Vim-specific word boundary functions
export const findNextWordStart = (
text: string,
currentOffset: number,
): number => {
let i = currentOffset;
if (i >= text.length) return i;
const currentChar = text[i];
// Skip current word/sequence based on character type
if (/\w/.test(currentChar)) {
// Skip current word characters
while (i < text.length && /\w/.test(text[i])) {
i++;
}
} else if (!/\s/.test(currentChar)) {
// Skip current non-word, non-whitespace characters (like "/", ".", etc.)
while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) {
i++;
}
}
// Skip whitespace
while (i < text.length && /\s/.test(text[i])) {
i++;
}
// If we reached the end of text and there's no next word,
// vim behavior for dw is to delete to the end of the current word
if (i >= text.length) {
// Go back to find the end of the last word
let endOfLastWord = text.length - 1;
while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
endOfLastWord--;
}
// For dw on last word, return position AFTER the last character to delete entire word
return Math.max(currentOffset + 1, endOfLastWord + 1);
}
return i;
};
export const findPrevWordStart = (
text: string,
currentOffset: number,
): number => {
let i = currentOffset;
// If at beginning of text, return current position
if (i <= 0) {
return currentOffset;
}
// Move back one character to start searching
i--;
// Skip whitespace moving backwards
while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) {
i--;
}
if (i < 0) {
return 0; // Reached beginning of text
}
const charAtI = text[i];
if (/\w/.test(charAtI)) {
// We're in a word, move to its beginning
while (i >= 0 && /\w/.test(text[i])) {
i--;
}
return i + 1; // Return first character of word
} else {
// We're in punctuation, move to its beginning
while (
i >= 0 &&
!/\w/.test(text[i]) &&
text[i] !== ' ' &&
text[i] !== '\t' &&
text[i] !== '\n'
) {
i--;
}
return i + 1; // Return first character of punctuation sequence
}
};
export const findWordEnd = (text: string, currentOffset: number): number => {
let i = currentOffset;
// If we're already at the end of a word, advance to next word
if (
i < text.length &&
/\w/.test(text[i]) &&
(i + 1 >= text.length || !/\w/.test(text[i + 1]))
) {
// We're at the end of a word, move forward to find next word
i++;
// Skip whitespace/punctuation to find next word
while (i < text.length && !/\w/.test(text[i])) {
i++;
}
}
// If we're not on a word character, find the next word
if (i < text.length && !/\w/.test(text[i])) {
while (i < text.length && !/\w/.test(text[i])) {
i++;
}
}
// Move to end of current word
while (i < text.length && /\w/.test(text[i])) {
i++;
}
// Move back one to be on the last character of the word
return Math.max(currentOffset, i - 1);
};
// Helper functions for vim operations
export const getOffsetFromPosition = (
row: number,
col: number,
lines: string[],
): number => {
let offset = 0;
for (let i = 0; i < row; i++) {
offset += lines[i].length + 1; // +1 for newline
}
offset += col;
return offset;
};
export const getPositionFromOffsets = (
startOffset: number,
endOffset: number,
lines: string[],
) => {
let offset = 0;
let startRow = 0;
let startCol = 0;
let endRow = 0;
let endCol = 0;
// Find start position
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length + 1; // +1 for newline
if (offset + lineLength > startOffset) {
startRow = i;
startCol = startOffset - offset;
break;
}
offset += lineLength;
}
// Find end position
offset = 0;
for (let i = 0; i < lines.length; i++) {
const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line
if (offset + lineLength >= endOffset) {
endRow = i;
endCol = endOffset - offset;
break;
}
offset += lineLength;
}
return { startRow, startCol, endRow, endCol };
};
export const getLineRangeOffsets = (
startRow: number,
lineCount: number,
lines: string[],
) => {
let startOffset = 0;
// Calculate start offset
for (let i = 0; i < startRow; i++) {
startOffset += lines[i].length + 1; // +1 for newline
}
// Calculate end offset
let endOffset = startOffset;
for (let i = 0; i < lineCount; i++) {
const lineIndex = startRow + i;
if (lineIndex < lines.length) {
endOffset += lines[lineIndex].length;
if (lineIndex < lines.length - 1) {
endOffset += 1; // +1 for newline
}
}
}
return { startOffset, endOffset };
};
export const replaceRangeInternal = (
state: TextBufferState,
startRow: number,
startCol: number,
endRow: number,
endCol: number,
text: string,
): TextBufferState => {
const currentLine = (row: number) => state.lines[row] || '';
const currentLineLen = (row: number) => cpLen(currentLine(row));
const clamp = (value: number, min: number, max: number) =>
Math.min(Math.max(value, min), max);
if (
startRow > endRow ||
(startRow === endRow && startCol > endCol) ||
startRow < 0 ||
startCol < 0 ||
endRow >= state.lines.length ||
(endRow < state.lines.length && endCol > currentLineLen(endRow))
) {
return state; // Invalid range
}
const newLines = [...state.lines];
const sCol = clamp(startCol, 0, currentLineLen(startRow));
const eCol = clamp(endCol, 0, currentLineLen(endRow));
const prefix = cpSlice(currentLine(startRow), 0, sCol);
const suffix = cpSlice(currentLine(endRow), eCol);
const normalisedReplacement = text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
const replacementParts = normalisedReplacement.split('\n');
// Replace the content
if (startRow === endRow) {
newLines[startRow] = prefix + normalisedReplacement + suffix;
} else {
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// Single line of replacement text, but spanning multiple original lines
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else {
// Multi-line replacement text
const lastLine = replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(
startRow,
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
}
const finalCursorRow = startRow + replacementParts.length - 1;
const finalCursorCol =
(replacementParts.length > 1 ? 0 : sCol) +
cpLen(replacementParts[replacementParts.length - 1]);
return {
...state,
lines: newLines,
cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1),
cursorCol: Math.max(
0,
Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')),
),
preferredCol: null,
};
};
/**
* Strip characters that can break terminal rendering.
*
@@ -158,6 +436,33 @@ export function offsetToLogicalPos(
return [row, col];
}
/**
* Converts logical row/col position to absolute text offset
* Inverse operation of offsetToLogicalPos
*/
export function logicalPosToOffset(
lines: string[],
row: number,
col: number,
): number {
let offset = 0;
// Clamp row to valid range
const actualRow = Math.min(row, lines.length - 1);
// Add lengths of all lines before the target row
for (let i = 0; i < actualRow; i++) {
offset += cpLen(lines[i]) + 1; // +1 for newline
}
// Add column offset within the target row
if (actualRow >= 0 && actualRow < lines.length) {
offset += Math.min(col, cpLen(lines[actualRow]));
}
return offset;
}
// Helper to calculate visual lines and map cursor positions
function calculateVisualLayout(
logicalLines: string[],
@@ -376,7 +681,7 @@ function calculateVisualLayout(
// --- Start of reducer logic ---
interface TextBufferState {
export interface TextBufferState {
lines: string[];
cursorRow: number;
cursorCol: number;
@@ -390,7 +695,20 @@ interface TextBufferState {
const historyLimit = 100;
type TextBufferAction =
export const pushUndo = (currentState: TextBufferState): TextBufferState => {
const snapshot = {
lines: [...currentState.lines],
cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol,
};
const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) {
newStack.shift();
}
return { ...currentState, undoStack: newStack, redoStack: [] };
};
export type TextBufferAction =
| { type: 'set_text'; payload: string; pushToUndo?: boolean }
| { type: 'insert'; payload: string }
| { type: 'backspace' }
@@ -419,24 +737,49 @@ type TextBufferAction =
}
| { type: 'move_to_offset'; payload: { offset: number } }
| { type: 'create_undo_snapshot' }
| { type: 'set_viewport_width'; payload: number };
| { type: 'set_viewport_width'; payload: number }
| { type: 'vim_delete_word_forward'; payload: { count: number } }
| { type: 'vim_delete_word_backward'; payload: { count: number } }
| { type: 'vim_delete_word_end'; payload: { count: number } }
| { type: 'vim_change_word_forward'; payload: { count: number } }
| { type: 'vim_change_word_backward'; payload: { count: number } }
| { type: 'vim_change_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_change_movement';
payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
}
// New vim actions for stateless command handling
| { type: 'vim_move_left'; payload: { count: number } }
| { type: 'vim_move_right'; payload: { count: number } }
| { type: 'vim_move_up'; payload: { count: number } }
| { type: 'vim_move_down'; payload: { count: number } }
| { type: 'vim_move_word_forward'; payload: { count: number } }
| { type: 'vim_move_word_backward'; payload: { count: number } }
| { type: 'vim_move_word_end'; payload: { count: number } }
| { type: 'vim_delete_char'; payload: { count: number } }
| { type: 'vim_insert_at_cursor' }
| { type: 'vim_append_at_cursor' }
| { type: 'vim_open_line_below' }
| { type: 'vim_open_line_above' }
| { type: 'vim_append_at_line_end' }
| { type: 'vim_insert_at_line_start' }
| { type: 'vim_move_to_line_start' }
| { type: 'vim_move_to_line_end' }
| { type: 'vim_move_to_first_nonwhitespace' }
| { type: 'vim_move_to_first_line' }
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line'; payload: { lineNumber: number } }
| { type: 'vim_escape_insert_mode' };
export function textBufferReducer(
state: TextBufferState,
action: TextBufferAction,
): TextBufferState {
const pushUndo = (currentState: TextBufferState): TextBufferState => {
const snapshot = {
lines: [...currentState.lines],
cursorRow: currentState.cursorRow,
cursorCol: currentState.cursorCol,
};
const newStack = [...currentState.undoStack, snapshot];
if (newStack.length > historyLimit) {
newStack.shift();
}
return { ...currentState, undoStack: newStack, redoStack: [] };
};
const pushUndoLocal = pushUndo;
const currentLine = (r: number): string => state.lines[r] ?? '';
const currentLineLen = (r: number): number => cpLen(currentLine(r));
@@ -445,7 +788,7 @@ export function textBufferReducer(
case 'set_text': {
let nextState = state;
if (action.pushToUndo !== false) {
nextState = pushUndo(state);
nextState = pushUndoLocal(state);
}
const newContentLines = action.payload
.replace(/\r\n?/g, '\n')
@@ -462,7 +805,7 @@ export function textBufferReducer(
}
case 'insert': {
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
@@ -504,7 +847,7 @@ export function textBufferReducer(
}
case 'backspace': {
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
@@ -700,14 +1043,14 @@ export function textBufferReducer(
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) +
cpSlice(lineContent, cursorCol + 1);
return { ...nextState, lines: newLines, preferredCol: null };
} else if (cursorRow < lines.length - 1) {
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
@@ -722,7 +1065,7 @@ export function textBufferReducer(
if (cursorCol === 0 && cursorRow === 0) return state;
if (cursorCol === 0) {
// Act as a backspace
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const prevLineContent = currentLine(cursorRow - 1);
const currentLineContentVal = currentLine(cursorRow);
const newCol = cpLen(prevLineContent);
@@ -737,7 +1080,7 @@ export function textBufferReducer(
preferredCol: null,
};
}
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const arr = toCodePoints(lineContent);
let start = cursorCol;
@@ -773,14 +1116,14 @@ export function textBufferReducer(
return state;
if (cursorCol >= arr.length) {
// Act as a delete
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
return { ...nextState, lines: newLines, preferredCol: null };
}
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
let end = cursorCol;
while (end < arr.length && !isWordChar(arr[end])) end++;
while (end < arr.length && isWordChar(arr[end])) end++;
@@ -794,13 +1137,13 @@ export function textBufferReducer(
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return { ...nextState, lines: newLines };
} else if (cursorRow < lines.length - 1) {
// Act as a delete
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
@@ -813,7 +1156,7 @@ export function textBufferReducer(
case 'kill_line_left': {
const { cursorRow, cursorCol } = state;
if (cursorCol > 0) {
const nextState = pushUndo(state);
const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
@@ -863,66 +1206,15 @@ export function textBufferReducer(
case 'replace_range': {
const { startRow, startCol, endRow, endCol, text } = action.payload;
if (
startRow > endRow ||
(startRow === endRow && startCol > endCol) ||
startRow < 0 ||
startCol < 0 ||
endRow >= state.lines.length ||
(endRow < state.lines.length && endCol > currentLineLen(endRow))
) {
return state; // Invalid range
}
const nextState = pushUndo(state);
const newLines = [...nextState.lines];
const sCol = clamp(startCol, 0, currentLineLen(startRow));
const eCol = clamp(endCol, 0, currentLineLen(endRow));
const prefix = cpSlice(currentLine(startRow), 0, sCol);
const suffix = cpSlice(currentLine(endRow), eCol);
const normalisedReplacement = text
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n');
const replacementParts = normalisedReplacement.split('\n');
// Replace the content
if (startRow === endRow) {
newLines[startRow] = prefix + normalisedReplacement + suffix;
} else {
const firstLine = prefix + replacementParts[0];
if (replacementParts.length === 1) {
// Single line of replacement text, but spanning multiple original lines
newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
} else {
// Multi-line replacement text
const lastLine =
replacementParts[replacementParts.length - 1] + suffix;
const middleLines = replacementParts.slice(1, -1);
newLines.splice(
startRow,
endRow - startRow + 1,
firstLine,
...middleLines,
lastLine,
);
}
}
const finalCursorRow = startRow + replacementParts.length - 1;
const finalCursorCol =
(replacementParts.length > 1 ? 0 : sCol) +
cpLen(replacementParts[replacementParts.length - 1]);
return {
...nextState,
lines: newLines,
cursorRow: finalCursorRow,
cursorCol: finalCursorCol,
preferredCol: null,
};
const nextState = pushUndoLocal(state);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
text,
);
}
case 'move_to_offset': {
@@ -940,9 +1232,44 @@ export function textBufferReducer(
}
case 'create_undo_snapshot': {
return pushUndo(state);
return pushUndoLocal(state);
}
// Vim-specific operations
case 'vim_delete_word_forward':
case 'vim_delete_word_backward':
case 'vim_delete_word_end':
case 'vim_change_word_forward':
case 'vim_change_word_backward':
case 'vim_change_word_end':
case 'vim_delete_line':
case 'vim_change_line':
case 'vim_delete_to_end_of_line':
case 'vim_change_to_end_of_line':
case 'vim_change_movement':
case 'vim_move_left':
case 'vim_move_right':
case 'vim_move_up':
case 'vim_move_down':
case 'vim_move_word_forward':
case 'vim_move_word_backward':
case 'vim_move_word_end':
case 'vim_delete_char':
case 'vim_insert_at_cursor':
case 'vim_append_at_cursor':
case 'vim_open_line_below':
case 'vim_open_line_above':
case 'vim_append_at_line_end':
case 'vim_insert_at_line_start':
case 'vim_move_to_line_start':
case 'vim_move_to_line_end':
case 'vim_move_to_first_nonwhitespace':
case 'vim_move_to_first_line':
case 'vim_move_to_last_line':
case 'vim_move_to_line':
case 'vim_escape_insert_mode':
return handleVimAction(state, action as VimAction);
default: {
const exhaustiveCheck: never = action;
console.error(`Unknown action encountered: ${exhaustiveCheck}`);
@@ -1110,6 +1437,139 @@ export function useTextBuffer({
dispatch({ type: 'kill_line_left' });
}, []);
// Vim-specific operations
const vimDeleteWordForward = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_word_forward', payload: { count } });
}, []);
const vimDeleteWordBackward = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_word_backward', payload: { count } });
}, []);
const vimDeleteWordEnd = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_word_end', payload: { count } });
}, []);
const vimChangeWordForward = useCallback((count: number): void => {
dispatch({ type: 'vim_change_word_forward', payload: { count } });
}, []);
const vimChangeWordBackward = useCallback((count: number): void => {
dispatch({ type: 'vim_change_word_backward', payload: { count } });
}, []);
const vimChangeWordEnd = useCallback((count: number): void => {
dispatch({ type: 'vim_change_word_end', payload: { count } });
}, []);
const vimDeleteLine = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_line', payload: { count } });
}, []);
const vimChangeLine = useCallback((count: number): void => {
dispatch({ type: 'vim_change_line', payload: { count } });
}, []);
const vimDeleteToEndOfLine = useCallback((): void => {
dispatch({ type: 'vim_delete_to_end_of_line' });
}, []);
const vimChangeToEndOfLine = useCallback((): void => {
dispatch({ type: 'vim_change_to_end_of_line' });
}, []);
const vimChangeMovement = useCallback(
(movement: 'h' | 'j' | 'k' | 'l', count: number): void => {
dispatch({ type: 'vim_change_movement', payload: { movement, count } });
},
[],
);
// New vim navigation and operation methods
const vimMoveLeft = useCallback((count: number): void => {
dispatch({ type: 'vim_move_left', payload: { count } });
}, []);
const vimMoveRight = useCallback((count: number): void => {
dispatch({ type: 'vim_move_right', payload: { count } });
}, []);
const vimMoveUp = useCallback((count: number): void => {
dispatch({ type: 'vim_move_up', payload: { count } });
}, []);
const vimMoveDown = useCallback((count: number): void => {
dispatch({ type: 'vim_move_down', payload: { count } });
}, []);
const vimMoveWordForward = useCallback((count: number): void => {
dispatch({ type: 'vim_move_word_forward', payload: { count } });
}, []);
const vimMoveWordBackward = useCallback((count: number): void => {
dispatch({ type: 'vim_move_word_backward', payload: { count } });
}, []);
const vimMoveWordEnd = useCallback((count: number): void => {
dispatch({ type: 'vim_move_word_end', payload: { count } });
}, []);
const vimDeleteChar = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_char', payload: { count } });
}, []);
const vimInsertAtCursor = useCallback((): void => {
dispatch({ type: 'vim_insert_at_cursor' });
}, []);
const vimAppendAtCursor = useCallback((): void => {
dispatch({ type: 'vim_append_at_cursor' });
}, []);
const vimOpenLineBelow = useCallback((): void => {
dispatch({ type: 'vim_open_line_below' });
}, []);
const vimOpenLineAbove = useCallback((): void => {
dispatch({ type: 'vim_open_line_above' });
}, []);
const vimAppendAtLineEnd = useCallback((): void => {
dispatch({ type: 'vim_append_at_line_end' });
}, []);
const vimInsertAtLineStart = useCallback((): void => {
dispatch({ type: 'vim_insert_at_line_start' });
}, []);
const vimMoveToLineStart = useCallback((): void => {
dispatch({ type: 'vim_move_to_line_start' });
}, []);
const vimMoveToLineEnd = useCallback((): void => {
dispatch({ type: 'vim_move_to_line_end' });
}, []);
const vimMoveToFirstNonWhitespace = useCallback((): void => {
dispatch({ type: 'vim_move_to_first_nonwhitespace' });
}, []);
const vimMoveToFirstLine = useCallback((): void => {
dispatch({ type: 'vim_move_to_first_line' });
}, []);
const vimMoveToLastLine = useCallback((): void => {
dispatch({ type: 'vim_move_to_last_line' });
}, []);
const vimMoveToLine = useCallback((lineNumber: number): void => {
dispatch({ type: 'vim_move_to_line', payload: { lineNumber } });
}, []);
const vimEscapeInsertMode = useCallback((): void => {
dispatch({ type: 'vim_escape_insert_mode' });
}, []);
const openInExternalEditor = useCallback(
async (opts: { editor?: string } = {}): Promise<void> => {
const editor =
@@ -1273,6 +1733,39 @@ export function useTextBuffer({
killLineLeft,
handleInput,
openInExternalEditor,
// Vim-specific operations
vimDeleteWordForward,
vimDeleteWordBackward,
vimDeleteWordEnd,
vimChangeWordForward,
vimChangeWordBackward,
vimChangeWordEnd,
vimDeleteLine,
vimChangeLine,
vimDeleteToEndOfLine,
vimChangeToEndOfLine,
vimChangeMovement,
vimMoveLeft,
vimMoveRight,
vimMoveUp,
vimMoveDown,
vimMoveWordForward,
vimMoveWordBackward,
vimMoveWordEnd,
vimDeleteChar,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
vimOpenLineAbove,
vimAppendAtLineEnd,
vimInsertAtLineStart,
vimMoveToLineStart,
vimMoveToLineEnd,
vimMoveToFirstNonWhitespace,
vimMoveToFirstLine,
vimMoveToLastLine,
vimMoveToLine,
vimEscapeInsertMode,
};
return returnValue;
}
@@ -1387,4 +1880,134 @@ export interface TextBuffer {
replacementText: string,
) => void;
moveToOffset(offset: number): void;
// Vim-specific operations
/**
* Delete N words forward from cursor position (vim 'dw' command)
*/
vimDeleteWordForward: (count: number) => void;
/**
* Delete N words backward from cursor position (vim 'db' command)
*/
vimDeleteWordBackward: (count: number) => void;
/**
* Delete to end of N words from cursor position (vim 'de' command)
*/
vimDeleteWordEnd: (count: number) => void;
/**
* Change N words forward from cursor position (vim 'cw' command)
*/
vimChangeWordForward: (count: number) => void;
/**
* Change N words backward from cursor position (vim 'cb' command)
*/
vimChangeWordBackward: (count: number) => void;
/**
* Change to end of N words from cursor position (vim 'ce' command)
*/
vimChangeWordEnd: (count: number) => void;
/**
* Delete N lines from cursor position (vim 'dd' command)
*/
vimDeleteLine: (count: number) => void;
/**
* Change N lines from cursor position (vim 'cc' command)
*/
vimChangeLine: (count: number) => void;
/**
* Delete from cursor to end of line (vim 'D' command)
*/
vimDeleteToEndOfLine: () => void;
/**
* Change from cursor to end of line (vim 'C' command)
*/
vimChangeToEndOfLine: () => void;
/**
* Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
*/
vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void;
/**
* Move cursor left N times (vim 'h' command)
*/
vimMoveLeft: (count: number) => void;
/**
* Move cursor right N times (vim 'l' command)
*/
vimMoveRight: (count: number) => void;
/**
* Move cursor up N times (vim 'k' command)
*/
vimMoveUp: (count: number) => void;
/**
* Move cursor down N times (vim 'j' command)
*/
vimMoveDown: (count: number) => void;
/**
* Move cursor forward N words (vim 'w' command)
*/
vimMoveWordForward: (count: number) => void;
/**
* Move cursor backward N words (vim 'b' command)
*/
vimMoveWordBackward: (count: number) => void;
/**
* Move cursor to end of Nth word (vim 'e' command)
*/
vimMoveWordEnd: (count: number) => void;
/**
* Delete N characters at cursor (vim 'x' command)
*/
vimDeleteChar: (count: number) => void;
/**
* Enter insert mode at cursor (vim 'i' command)
*/
vimInsertAtCursor: () => void;
/**
* Enter insert mode after cursor (vim 'a' command)
*/
vimAppendAtCursor: () => void;
/**
* Open new line below and enter insert mode (vim 'o' command)
*/
vimOpenLineBelow: () => void;
/**
* Open new line above and enter insert mode (vim 'O' command)
*/
vimOpenLineAbove: () => void;
/**
* Move to end of line and enter insert mode (vim 'A' command)
*/
vimAppendAtLineEnd: () => void;
/**
* Move to first non-whitespace and enter insert mode (vim 'I' command)
*/
vimInsertAtLineStart: () => void;
/**
* Move cursor to beginning of line (vim '0' command)
*/
vimMoveToLineStart: () => void;
/**
* Move cursor to end of line (vim '$' command)
*/
vimMoveToLineEnd: () => void;
/**
* Move cursor to first non-whitespace character (vim '^' command)
*/
vimMoveToFirstNonWhitespace: () => void;
/**
* Move cursor to first line (vim 'gg' command)
*/
vimMoveToFirstLine: () => void;
/**
* Move cursor to last line (vim 'G' command)
*/
vimMoveToLastLine: () => void;
/**
* Move cursor to specific line number (vim '[N]G' command)
*/
vimMoveToLine: (lineNumber: number) => void;
/**
* Handle escape from insert mode (moves cursor left if not at line start)
*/
vimEscapeInsertMode: () => void;
}

View File

@@ -0,0 +1,796 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { handleVimAction } from './vim-buffer-actions.js';
import type { TextBufferState } from './text-buffer.js';
// Helper to create test state
const createTestState = (
lines: string[] = ['hello world'],
cursorRow = 0,
cursorCol = 0,
): TextBufferState => ({
lines,
cursorRow,
cursorCol,
preferredCol: null,
undoStack: [],
redoStack: [],
clipboard: null,
selectionAnchor: null,
viewportWidth: 80,
});
describe('vim-buffer-actions', () => {
describe('Movement commands', () => {
describe('vim_move_left', () => {
it('should move cursor left by count', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_move_left' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(2);
expect(result.preferredCol).toBeNull();
});
it('should not move past beginning of line', () => {
const state = createTestState(['hello'], 0, 2);
const action = {
type: 'vim_move_left' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0);
});
it('should wrap to previous line when at beginning', () => {
const state = createTestState(['line1', 'line2'], 1, 0);
const action = {
type: 'vim_move_left' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
});
it('should handle multiple line wrapping', () => {
const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
const action = {
type: 'vim_move_left' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
});
it('should correctly handle h/l movement between lines', () => {
// Start at end of first line at 'd' (position 10)
let state = createTestState(['hello world', 'foo bar'], 0, 10);
// Move right - should go to beginning of next line
state = handleVimAction(state, {
type: 'vim_move_right' as const,
payload: { count: 1 },
});
expect(state.cursorRow).toBe(1);
expect(state.cursorCol).toBe(0); // Should be on 'f'
// Move left - should go back to end of previous line on 'd'
state = handleVimAction(state, {
type: 'vim_move_left' as const,
payload: { count: 1 },
});
expect(state.cursorRow).toBe(0);
expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
});
});
describe('vim_move_right', () => {
it('should move cursor right by count', () => {
const state = createTestState(['hello world'], 0, 2);
const action = {
type: 'vim_move_right' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(5);
});
it('should not move past last character of line', () => {
const state = createTestState(['hello'], 0, 3);
const action = {
type: 'vim_move_right' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(4); // Last character of 'hello'
});
it('should wrap to next line when at end', () => {
const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
const action = {
type: 'vim_move_right' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_move_up', () => {
it('should move cursor up by count', () => {
const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(3);
});
it('should not move past first line', () => {
const state = createTestState(['line1', 'line2'], 1, 3);
const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
});
it('should adjust column for shorter lines', () => {
const state = createTestState(['short', 'very long line'], 1, 10);
const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(5); // End of 'short'
});
});
describe('vim_move_down', () => {
it('should move cursor down by count', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_move_down' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(2);
});
it('should not move past last line', () => {
const state = createTestState(['line1', 'line2'], 0, 2);
const action = {
type: 'vim_move_down' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1);
});
});
describe('vim_move_word_forward', () => {
it('should move to start of next word', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(6); // Start of 'world'
});
it('should handle multiple words', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(12); // Start of 'test'
});
it('should handle punctuation correctly', () => {
const state = createTestState(['hello, world!'], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(5); // Start of ','
});
});
describe('vim_move_word_backward', () => {
it('should move to start of previous word', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_move_word_backward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(6); // Start of 'world'
});
it('should handle multiple words', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_move_word_backward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0); // Start of 'hello'
});
});
describe('vim_move_word_end', () => {
it('should move to end of current word', () => {
const state = createTestState(['hello world'], 0, 0);
const action = {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(4); // End of 'hello'
});
it('should move to end of next word if already at word end', () => {
const state = createTestState(['hello world'], 0, 4);
const action = {
type: 'vim_move_word_end' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(10); // End of 'world'
});
});
describe('Position commands', () => {
it('vim_move_to_line_start should move to column 0', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_move_to_line_start' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_line_end should move to last character', () => {
const state = createTestState(['hello world'], 0, 0);
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(10); // Last character of 'hello world'
});
it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
const state = createTestState([' hello world'], 0, 0);
const action = { type: 'vim_move_to_first_nonwhitespace' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(3); // Position of 'h'
});
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 };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_last_line should move to last row', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
const action = { type: 'vim_move_to_last_line' as const };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_line should move to specific line', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
const action = {
type: 'vim_move_to_line' as const,
payload: { lineNumber: 2 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1); // 0-indexed
expect(result.cursorCol).toBe(0);
});
it('vim_move_to_line should clamp to valid range', () => {
const state = createTestState(['line1', 'line2'], 0, 0);
const action = {
type: 'vim_move_to_line' as const,
payload: { lineNumber: 10 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(1); // Last line
});
});
});
describe('Edit commands', () => {
describe('vim_delete_char', () => {
it('should delete single character', () => {
const state = createTestState(['hello'], 0, 1);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hllo');
expect(result.cursorCol).toBe(1);
});
it('should delete multiple characters', () => {
const state = createTestState(['hello'], 0, 1);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 3 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('ho');
expect(result.cursorCol).toBe(1);
});
it('should not delete past end of line', () => {
const state = createTestState(['hello'], 0, 3);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 5 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hel');
expect(result.cursorCol).toBe(3);
});
it('should do nothing at end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
});
describe('vim_delete_word_forward', () => {
it('should delete from cursor to next word start', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_delete_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
it('should delete multiple words', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_delete_word_forward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
it('should delete to end if no more words', () => {
const state = createTestState(['hello world'], 0, 6);
const action = {
type: 'vim_delete_word_forward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello ');
expect(result.cursorCol).toBe(6);
});
});
describe('vim_delete_word_backward', () => {
it('should delete from cursor to previous word start', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_delete_word_backward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello test');
expect(result.cursorCol).toBe(6);
});
it('should delete multiple words backward', () => {
const state = createTestState(['hello world test'], 0, 12);
const action = {
type: 'vim_delete_word_backward' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('test');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_line', () => {
it('should delete current line', () => {
const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
const action = {
type: 'vim_delete_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines).toEqual(['line1', 'line3']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
it('should delete multiple lines', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_delete_line' as const,
payload: { count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines).toEqual(['line3']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should leave empty line when deleting all lines', () => {
const state = createTestState(['only line'], 0, 0);
const action = {
type: 'vim_delete_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines).toEqual(['']);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
});
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 result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(5);
});
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 result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hello');
});
});
});
describe('Insert mode commands', () => {
describe('vim_insert_at_cursor', () => {
it('should not change cursor position', () => {
const state = createTestState(['hello'], 0, 2);
const action = { type: 'vim_insert_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
});
});
describe('vim_append_at_cursor', () => {
it('should move cursor right by one', () => {
const state = createTestState(['hello'], 0, 2);
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(3);
});
it('should not move past end of line', () => {
const state = createTestState(['hello'], 0, 5);
const action = { type: 'vim_append_at_cursor' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(5);
});
});
describe('vim_append_at_line_end', () => {
it('should move cursor to end of line', () => {
const state = createTestState(['hello world'], 0, 3);
const action = { type: 'vim_append_at_line_end' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(11);
});
});
describe('vim_insert_at_line_start', () => {
it('should move to first non-whitespace character', () => {
const state = createTestState([' hello world'], 0, 5);
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(2);
});
it('should move to column 0 for line with only whitespace', () => {
const state = createTestState([' '], 0, 1);
const action = { type: 'vim_insert_at_line_start' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(3);
});
});
describe('vim_open_line_below', () => {
it('should insert newline at end of current line', () => {
const state = createTestState(['hello world'], 0, 5);
const action = { type: 'vim_open_line_below' as const };
const result = handleVimAction(state, action);
// The implementation inserts newline at end of current line and cursor moves to column 0
expect(result.lines[0]).toBe('hello world\n');
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
});
});
describe('vim_open_line_above', () => {
it('should insert newline before current line', () => {
const state = createTestState(['hello', 'world'], 1, 2);
const action = { type: 'vim_open_line_above' as const };
const result = handleVimAction(state, action);
// The implementation inserts newline at beginning of current line
expect(result.lines).toEqual(['hello', '\nworld']);
expect(result.cursorRow).toBe(1);
expect(result.cursorCol).toBe(0);
});
});
describe('vim_escape_insert_mode', () => {
it('should move cursor left', () => {
const state = createTestState(['hello'], 0, 3);
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(2);
});
it('should not move past beginning of line', () => {
const state = createTestState(['hello'], 0, 0);
const action = { type: 'vim_escape_insert_mode' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0);
});
});
});
describe('Change commands', () => {
describe('vim_change_word_forward', () => {
it('should delete from cursor to next word start', () => {
const state = createTestState(['hello world test'], 0, 0);
const action = {
type: 'vim_change_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('world test');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_change_line', () => {
it('should delete entire line content', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_line' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_change_movement', () => {
it('should change characters to the left', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'h', count: 2 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hel world');
expect(result.cursorCol).toBe(3);
});
it('should change characters to the right', () => {
const state = createTestState(['hello world'], 0, 5);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'l', count: 3 },
};
const result = handleVimAction(state, action);
expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
expect(result.cursorCol).toBe(5);
});
it('should change multiple lines down', () => {
const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
const action = {
type: 'vim_change_movement' as const,
payload: { movement: 'j', count: 2 },
};
const result = handleVimAction(state, action);
// 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
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(2);
});
});
});
describe('Edge cases', () => {
it('should handle empty text', () => {
const state = createTestState([''], 0, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.cursorRow).toBe(0);
expect(result.cursorCol).toBe(0);
});
it('should handle single character line', () => {
const state = createTestState(['a'], 0, 0);
const action = { type: 'vim_move_to_line_end' as const };
const result = handleVimAction(state, action);
expect(result.cursorCol).toBe(0); // Should be last character position
});
it('should handle empty lines in multi-line text', () => {
const state = createTestState(['line1', '', 'line3'], 1, 0);
const action = {
type: 'vim_move_word_forward' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
// Should move to next line with content
expect(result.cursorRow).toBe(2);
expect(result.cursorCol).toBe(0);
});
it('should preserve undo stack in operations', () => {
const state = createTestState(['hello'], 0, 0);
state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
const action = {
type: 'vim_delete_char' as const,
payload: { count: 1 },
};
const result = handleVimAction(state, action);
expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
});
});
});

View File

@@ -0,0 +1,887 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {
TextBufferState,
TextBufferAction,
findNextWordStart,
findPrevWordStart,
findWordEnd,
getOffsetFromPosition,
getPositionFromOffsets,
getLineRangeOffsets,
replaceRangeInternal,
pushUndo,
} from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
export type VimAction = Extract<
TextBufferAction,
| { type: 'vim_delete_word_forward' }
| { type: 'vim_delete_word_backward' }
| { type: 'vim_delete_word_end' }
| { type: 'vim_change_word_forward' }
| { type: 'vim_change_word_backward' }
| { type: 'vim_change_word_end' }
| { type: 'vim_delete_line' }
| { type: 'vim_change_line' }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_change_movement' }
| { type: 'vim_move_left' }
| { type: 'vim_move_right' }
| { type: 'vim_move_up' }
| { type: 'vim_move_down' }
| { type: 'vim_move_word_forward' }
| { type: 'vim_move_word_backward' }
| { type: 'vim_move_word_end' }
| { type: 'vim_delete_char' }
| { type: 'vim_insert_at_cursor' }
| { type: 'vim_append_at_cursor' }
| { type: 'vim_open_line_below' }
| { type: 'vim_open_line_above' }
| { type: 'vim_append_at_line_end' }
| { type: 'vim_insert_at_line_start' }
| { type: 'vim_move_to_line_start' }
| { type: 'vim_move_to_line_end' }
| { type: 'vim_move_to_first_nonwhitespace' }
| { type: 'vim_move_to_first_line' }
| { type: 'vim_move_to_last_line' }
| { type: 'vim_move_to_line' }
| { type: 'vim_escape_insert_mode' }
>;
export function handleVimAction(
state: TextBufferState,
action: VimAction,
): TextBufferState {
const { lines, cursorRow, cursorCol } = state;
// Cache text join to avoid repeated calculations for word operations
let text: string | null = null;
const getText = () => text ?? (text = lines.join('\n'));
switch (action.type) {
case 'vim_delete_word_forward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let endOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset);
if (nextWordOffset > searchOffset) {
searchOffset = nextWordOffset;
endOffset = nextWordOffset;
} else {
// If no next word, delete to end of current word
const wordEndOffset = findWordEnd(getText(), searchOffset);
endOffset = Math.min(wordEndOffset + 1, getText().length);
break;
}
}
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_word_backward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let startOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
if (prevWordOffset < searchOffset) {
searchOffset = prevWordOffset;
startOffset = prevWordOffset;
} else {
break;
}
}
if (startOffset < currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
const newState = replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
// Cursor is already at the correct position after deletion
return newState;
}
return state;
}
case 'vim_delete_word_end': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let offset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset);
if (wordEndOffset >= offset) {
endOffset = wordEndOffset + 1; // Include the character at word end
// For next iteration, move to start of next word
if (i < count - 1) {
const nextWordStart = findNextWordStart(
getText(),
wordEndOffset + 1,
);
offset = nextWordStart;
if (nextWordStart <= wordEndOffset) {
break; // No more words
}
}
} else {
break;
}
}
endOffset = Math.min(endOffset, getText().length);
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_forward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let searchOffset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), searchOffset);
if (nextWordOffset > searchOffset) {
searchOffset = nextWordOffset;
endOffset = nextWordOffset;
} else {
// If no next word, change to end of current word
const wordEndOffset = findWordEnd(getText(), searchOffset);
endOffset = Math.min(wordEndOffset + 1, getText().length);
break;
}
}
if (endOffset > currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
currentOffset,
endOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_backward': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let startOffset = currentOffset;
let searchOffset = currentOffset;
for (let i = 0; i < count; i++) {
const prevWordOffset = findPrevWordStart(getText(), searchOffset);
if (prevWordOffset < searchOffset) {
searchOffset = prevWordOffset;
startOffset = prevWordOffset;
} else {
break;
}
}
if (startOffset < currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
startOffset,
currentOffset,
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_word_end': {
const { count } = action.payload;
const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
let offset = currentOffset;
let endOffset = currentOffset;
for (let i = 0; i < count; i++) {
const wordEndOffset = findWordEnd(getText(), offset);
if (wordEndOffset >= offset) {
endOffset = wordEndOffset + 1; // Include the character at word end
// For next iteration, move to start of next word
if (i < count - 1) {
const nextWordStart = findNextWordStart(
getText(),
wordEndOffset + 1,
);
offset = nextWordStart;
if (nextWordStart <= wordEndOffset) {
break; // No more words
}
}
} else {
break;
}
}
endOffset = Math.min(endOffset, getText().length);
if (endOffset !== currentOffset) {
const nextState = pushUndo(state);
const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
Math.min(currentOffset, endOffset),
Math.max(currentOffset, endOffset),
nextState.lines,
);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_line': {
const { count } = action.payload;
if (lines.length === 0) return state;
const linesToDelete = Math.min(count, lines.length - cursorRow);
const totalLines = lines.length;
if (totalLines === 1 || linesToDelete >= totalLines) {
// If there's only one line, or we're deleting all remaining lines,
// clear the content but keep one empty line (text editors should never be completely empty)
const nextState = pushUndo(state);
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = pushUndo(state);
const newLines = [...nextState.lines];
newLines.splice(cursorRow, linesToDelete);
// Adjust cursor position
const newCursorRow = Math.min(cursorRow, newLines.length - 1);
const newCursorCol = 0; // Vim places cursor at beginning of line after dd
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: newCursorCol,
preferredCol: null,
};
}
case 'vim_change_line': {
const { count } = action.payload;
if (lines.length === 0) return state;
const linesToChange = Math.min(count, lines.length - cursorRow);
const nextState = 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,
'',
);
}
case 'vim_delete_to_end_of_line': {
const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
currentLine.length,
'',
);
}
return state;
}
case 'vim_change_to_end_of_line': {
const currentLine = lines[cursorRow] || '';
if (cursorCol < currentLine.length) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
currentLine.length,
'',
);
}
return state;
}
case 'vim_change_movement': {
const { movement, count } = action.payload;
const totalLines = lines.length;
switch (movement) {
case 'h': {
// Left
// Change N characters to the left
const startCol = Math.max(0, cursorCol - count);
return replaceRangeInternal(
pushUndo(state),
cursorRow,
startCol,
cursorRow,
cursorCol,
'',
);
}
case 'j': {
// Down
const linesToChange = Math.min(count, totalLines - cursorRow);
if (linesToChange > 0) {
if (totalLines === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
pushUndo(state),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const nextState = 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 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(
pushUndo(state),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const startRow = Math.max(0, cursorRow - count + 1);
const linesToChange = cursorRow - startRow + 1;
const nextState = 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,
cursorCol: 0,
};
}
}
return state;
}
case 'l': {
// Right
// Change N characters to the right
return replaceRangeInternal(
pushUndo(state),
cursorRow,
cursorCol,
cursorRow,
Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
'',
);
}
default:
return state;
}
}
case 'vim_move_left': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
let newRow = cursorRow;
let newCol = cursorCol;
for (let i = 0; i < count; i++) {
if (newCol > 0) {
newCol--;
} else if (newRow > 0) {
// Move to end of previous line
newRow--;
const prevLine = lines[newRow] || '';
const prevLineLength = cpLen(prevLine);
// Position on last character, or column 0 for empty lines
newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
}
}
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_right': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
let newRow = cursorRow;
let newCol = cursorCol;
for (let i = 0; i < count; i++) {
const currentLine = lines[newRow] || '';
const lineLength = cpLen(currentLine);
// Don't move past the last character of the line
// For empty lines, stay at column 0; for non-empty lines, don't go past last character
if (lineLength === 0) {
// Empty line - try to move to next line
if (newRow < lines.length - 1) {
newRow++;
newCol = 0;
}
} else if (newCol < lineLength - 1) {
newCol++;
} else if (newRow < lines.length - 1) {
// At end of line - move to beginning of next line
newRow++;
newCol = 0;
}
}
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_up': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const newRow = Math.max(0, cursorRow - count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_down': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const newRow = Math.min(lines.length - 1, cursorRow + count);
const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
return {
...state,
cursorRow: newRow,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_move_word_forward': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
for (let i = 0; i < count; i++) {
const nextWordOffset = findNextWordStart(getText(), offset);
if (nextWordOffset > offset) {
offset = nextWordOffset;
} else {
// No more words to move to
break;
}
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
preferredCol: null,
};
}
case 'vim_move_word_backward': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
for (let i = 0; i < count; i++) {
offset = findPrevWordStart(getText(), offset);
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
preferredCol: null,
};
}
case 'vim_move_word_end': {
const { count } = action.payload;
let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
for (let i = 0; i < count; i++) {
offset = findWordEnd(getText(), offset);
}
const { startRow, startCol } = getPositionFromOffsets(
offset,
offset,
lines,
);
return {
...state,
cursorRow: startRow,
cursorCol: startCol,
preferredCol: null,
};
}
case 'vim_delete_char': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;
const currentLine = lines[cursorRow] || '';
const lineLength = cpLen(currentLine);
if (cursorCol < lineLength) {
const deleteCount = Math.min(count, lineLength - cursorCol);
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cursorCol + deleteCount,
'',
);
}
return state;
}
case 'vim_insert_at_cursor': {
// Just return state - mode change is handled elsewhere
return state;
}
case 'vim_append_at_cursor': {
const { cursorRow, cursorCol, lines } = state;
const currentLine = lines[cursorRow] || '';
const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
return {
...state,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_open_line_below': {
const { cursorRow, lines } = state;
const nextState = pushUndo(state);
// Insert newline at end of current line
const endOfLine = cpLen(lines[cursorRow] || '');
return replaceRangeInternal(
nextState,
cursorRow,
endOfLine,
cursorRow,
endOfLine,
'\n',
);
}
case 'vim_open_line_above': {
const { cursorRow } = state;
const nextState = pushUndo(state);
// Insert newline at beginning of current line
const resultState = replaceRangeInternal(
nextState,
cursorRow,
0,
cursorRow,
0,
'\n',
);
// Move cursor to the new line above
return {
...resultState,
cursorRow,
cursorCol: 0,
};
}
case 'vim_append_at_line_end': {
const { cursorRow, lines } = state;
const lineLength = cpLen(lines[cursorRow] || '');
return {
...state,
cursorCol: lineLength,
preferredCol: null,
};
}
case 'vim_insert_at_line_start': {
const { cursorRow, lines } = state;
const currentLine = lines[cursorRow] || '';
let col = 0;
// Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++;
}
return {
...state,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_to_line_start': {
return {
...state,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_move_to_line_end': {
const { cursorRow, lines } = state;
const lineLength = cpLen(lines[cursorRow] || '');
return {
...state,
cursorCol: lineLength > 0 ? lineLength - 1 : 0,
preferredCol: null,
};
}
case 'vim_move_to_first_nonwhitespace': {
const { cursorRow, lines } = state;
const currentLine = lines[cursorRow] || '';
let col = 0;
// Find first non-whitespace character using proper Unicode handling
const lineCodePoints = [...currentLine]; // Proper Unicode iteration
while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
col++;
}
return {
...state,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_to_first_line': {
return {
...state,
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_move_to_last_line': {
const { lines } = state;
const lastRow = lines.length - 1;
return {
...state,
cursorRow: lastRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_move_to_line': {
const { lineNumber } = action.payload;
const { lines } = state;
const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
return {
...state,
cursorRow: targetRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_escape_insert_mode': {
// Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
const { cursorCol } = state;
const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
return {
...state,
cursorCol: newCol,
preferredCol: null,
};
}
default: {
// This should never happen if TypeScript is working correctly
const _exhaustiveCheck: never = action;
return state;
}
}
}