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

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Philippe
2026-02-13 01:22:39 +01:00
committed by GitHub
parent 55ec0f043c
commit 5b4884692b
7 changed files with 1241 additions and 115 deletions

View File

@@ -39,7 +39,13 @@ export type VimAction = Extract<
| { type: 'vim_delete_line' }
| { type: 'vim_change_line' }
| { type: 'vim_delete_to_end_of_line' }
| { type: 'vim_delete_to_start_of_line' }
| { type: 'vim_delete_to_first_nonwhitespace' }
| { type: 'vim_change_to_end_of_line' }
| { type: 'vim_change_to_start_of_line' }
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line' }
| { type: 'vim_delete_to_last_line' }
| { type: 'vim_change_movement' }
| { type: 'vim_move_left' }
| { type: 'vim_move_right' }
@@ -387,21 +393,253 @@ export function handleVimAction(
case 'vim_delete_to_end_of_line':
case 'vim_change_to_end_of_line': {
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
if (cursorCol < cpLen(currentLine)) {
const totalLines = lines.length;
if (count === 1) {
// Single line: delete from cursor to end of current line
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cpLen(currentLine),
'',
);
}
return state;
} else {
// Multi-line: delete from cursor to end of current line, plus (count-1) entire lines below
// For example, 2D = delete to EOL + delete next line entirely
const linesToDelete = Math.min(count - 1, totalLines - cursorRow - 1);
const endRow = cursorRow + linesToDelete;
if (endRow === cursorRow) {
// No additional lines to delete, just delete to EOL
if (cursorCol < cpLen(currentLine)) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cpLen(currentLine),
'',
);
}
return state;
}
// Delete from cursor position to end of endRow (including newlines)
const nextState = detachExpandedPaste(pushUndo(state));
const endLine = lines[endRow] || '';
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
endRow,
cpLen(endLine),
'',
);
}
}
case 'vim_delete_to_start_of_line': {
if (cursorCol > 0) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cpLen(currentLine),
0,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_delete_to_first_nonwhitespace': {
// Delete from cursor to first non-whitespace character (vim 'd^')
const currentLine = lines[cursorRow] || '';
const lineCodePoints = toCodePoints(currentLine);
let firstNonWs = 0;
while (
firstNonWs < lineCodePoints.length &&
/\s/.test(lineCodePoints[firstNonWs])
) {
firstNonWs++;
}
// If line is all whitespace, firstNonWs would be lineCodePoints.length
// In VIM, ^ on whitespace-only line goes to column 0
if (firstNonWs >= lineCodePoints.length) {
firstNonWs = 0;
}
// Delete between cursor and first non-whitespace (whichever direction)
if (cursorCol !== firstNonWs) {
const startCol = Math.min(cursorCol, firstNonWs);
const endCol = Math.max(cursorCol, firstNonWs);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
}
return state;
}
case 'vim_change_to_start_of_line': {
// Change from cursor to start of line (vim 'c0')
if (cursorCol > 0) {
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
0,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_change_to_first_nonwhitespace': {
// Change from cursor to first non-whitespace character (vim 'c^')
const currentLine = lines[cursorRow] || '';
const lineCodePoints = toCodePoints(currentLine);
let firstNonWs = 0;
while (
firstNonWs < lineCodePoints.length &&
/\s/.test(lineCodePoints[firstNonWs])
) {
firstNonWs++;
}
// If line is all whitespace, firstNonWs would be lineCodePoints.length
// In VIM, ^ on whitespace-only line goes to column 0
if (firstNonWs >= lineCodePoints.length) {
firstNonWs = 0;
}
// Change between cursor and first non-whitespace (whichever direction)
if (cursorCol !== firstNonWs) {
const startCol = Math.min(cursorCol, firstNonWs);
const endCol = Math.max(cursorCol, firstNonWs);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_to_first_line': {
// Delete from first line (or line N if count given) to current line (vim 'dgg' or 'd5gg')
// count is the target line number (1-based), or 0 for first line
const { count } = action.payload;
const totalLines = lines.length;
// Determine target row (0-based)
// count=0 means go to first line, count=N means go to line N (1-based)
let targetRow: number;
if (count > 0) {
targetRow = Math.min(count - 1, totalLines - 1);
} else {
targetRow = 0;
}
// Determine the range to delete (from min to max row, inclusive)
const startRow = Math.min(cursorRow, targetRow);
const endRow = Math.max(cursorRow, targetRow);
const linesToDelete = endRow - startRow + 1;
if (linesToDelete >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToDelete);
// Cursor goes to start of the deleted range, clamped to valid bounds
const newCursorRow = Math.min(startRow, newLines.length - 1);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_delete_to_last_line': {
// Delete from current line to last line (vim 'dG') or to line N (vim 'd5G')
// count is the target line number (1-based), or 0 for last line
const { count } = action.payload;
const totalLines = lines.length;
// Determine target row (0-based)
// count=0 means go to last line, count=N means go to line N (1-based)
let targetRow: number;
if (count > 0) {
targetRow = Math.min(count - 1, totalLines - 1);
} else {
targetRow = totalLines - 1;
}
// Determine the range to delete (from min to max row, inclusive)
const startRow = Math.min(cursorRow, targetRow);
const endRow = Math.max(cursorRow, targetRow);
const linesToDelete = endRow - startRow + 1;
if (linesToDelete >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToDelete);
// Move cursor to the start of the deleted range (or last line if needed)
const newCursorRow = Math.min(startRow, newLines.length - 1);
return {
...nextState,
lines: newLines,
cursorRow: newCursorRow,
cursorCol: 0,
preferredCol: null,
};
}
case 'vim_change_movement': {
const { movement, count } = action.payload;
const totalLines = lines.length;
@@ -422,88 +660,65 @@ export function handleVimAction(
}
case 'j': {
// Down
const linesToChange = Math.min(count, totalLines - cursorRow);
// Down - delete/change current line + count lines below
const linesToChange = Math.min(count + 1, totalLines - cursorRow);
if (linesToChange > 0) {
if (totalLines === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
detachExpandedPaste(pushUndo(state)),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
if (linesToChange >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
cursorRow,
linesToChange,
nextState.lines,
);
const { startRow, startCol, endRow, endCol } =
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
return replaceRangeInternal(
nextState,
startRow,
startCol,
endRow,
endCol,
'',
);
return {
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(cursorRow, linesToChange);
return {
...nextState,
lines: newLines,
cursorRow: Math.min(cursorRow, newLines.length - 1),
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
case 'k': {
// Up
const upLines = Math.min(count, cursorRow + 1);
if (upLines > 0) {
if (state.lines.length === 1) {
const currentLine = state.lines[0] || '';
return replaceRangeInternal(
detachExpandedPaste(pushUndo(state)),
0,
0,
0,
cpLen(currentLine),
'',
);
} else {
const startRow = Math.max(0, cursorRow - count + 1);
const linesToChange = cursorRow - startRow + 1;
// Up - delete/change current line + count lines above
const startRow = Math.max(0, cursorRow - count);
const linesToChange = cursorRow - startRow + 1;
if (linesToChange > 0) {
if (linesToChange >= totalLines) {
// Deleting all lines - keep one empty line
const nextState = detachExpandedPaste(pushUndo(state));
const { startOffset, endOffset } = getLineRangeOffsets(
startRow,
linesToChange,
nextState.lines,
);
const {
startRow: newStartRow,
startCol,
endRow,
endCol,
} = getPositionFromOffsets(
startOffset,
endOffset,
nextState.lines,
);
const resultState = replaceRangeInternal(
nextState,
newStartRow,
startCol,
endRow,
endCol,
'',
);
return {
...resultState,
cursorRow: startRow,
...nextState,
lines: [''],
cursorRow: 0,
cursorCol: 0,
preferredCol: null,
};
}
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines.splice(startRow, linesToChange);
return {
...nextState,
lines: newLines,
cursorRow: Math.min(startRow, newLines.length - 1),
cursorCol: 0,
preferredCol: null,
};
}
return state;
}
@@ -910,6 +1125,11 @@ export function handleVimAction(
col++;
}
// If line is all whitespace or empty, ^ goes to column 0 (standard Vim behavior)
if (col >= lineCodePoints.length) {
col = 0;
}
return {
...state,
cursorCol: col,