buroventures-harald-code
Version:
Harald Code - AI-powered coding assistant CLI
565 lines • 24.7 kB
JavaScript
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { findNextWordStart, findPrevWordStart, findWordEnd, getOffsetFromPosition, getPositionFromOffsets, getLineRangeOffsets, replaceRangeInternal, pushUndo, } from './text-buffer.js';
import { cpLen } from '../../utils/textUtils.js';
export function handleVimAction(state, action) {
const { lines, cursorRow, cursorCol } = state;
// Cache text join to avoid repeated calculations for word operations
let text = 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 = action;
return state;
}
}
}
//# sourceMappingURL=vim-buffer-actions.js.map