mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-13 21:32:56 -07:00
feat(cli): Add W, B, E Vim motions and operator support (#16209)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
@@ -27,6 +27,9 @@ import {
|
|||||||
textBufferReducer,
|
textBufferReducer,
|
||||||
findWordEndInLine,
|
findWordEndInLine,
|
||||||
findNextWordStartInLine,
|
findNextWordStartInLine,
|
||||||
|
findNextBigWordStartInLine,
|
||||||
|
findPrevBigWordStartInLine,
|
||||||
|
findBigWordEndInLine,
|
||||||
isWordCharStrict,
|
isWordCharStrict,
|
||||||
calculateTransformationsForLine,
|
calculateTransformationsForLine,
|
||||||
calculateTransformedLine,
|
calculateTransformedLine,
|
||||||
@@ -87,6 +90,43 @@ describe('textBufferReducer', () => {
|
|||||||
expect(state).toEqual(initialState);
|
expect(state).toEqual(initialState);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Big Word Navigation Helpers', () => {
|
||||||
|
describe('findNextBigWordStartInLine (W)', () => {
|
||||||
|
it('should skip non-whitespace and then whitespace', () => {
|
||||||
|
expect(findNextBigWordStartInLine('hello world', 0)).toBe(6);
|
||||||
|
expect(findNextBigWordStartInLine('hello.world test', 0)).toBe(12);
|
||||||
|
expect(findNextBigWordStartInLine(' test', 0)).toBe(3);
|
||||||
|
expect(findNextBigWordStartInLine('test ', 0)).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findPrevBigWordStartInLine (B)', () => {
|
||||||
|
it('should skip whitespace backwards then non-whitespace', () => {
|
||||||
|
expect(findPrevBigWordStartInLine('hello world', 6)).toBe(0);
|
||||||
|
expect(findPrevBigWordStartInLine('hello.world test', 12)).toBe(0);
|
||||||
|
expect(findPrevBigWordStartInLine(' test', 3)).toBe(null); // At start of word
|
||||||
|
expect(findPrevBigWordStartInLine(' test', 4)).toBe(3); // Inside word
|
||||||
|
expect(findPrevBigWordStartInLine('test ', 6)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('findBigWordEndInLine (E)', () => {
|
||||||
|
it('should find end of current big word', () => {
|
||||||
|
expect(findBigWordEndInLine('hello world', 0)).toBe(4);
|
||||||
|
expect(findBigWordEndInLine('hello.world test', 0)).toBe(10);
|
||||||
|
expect(findBigWordEndInLine('hello.world test', 11)).toBe(15);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip whitespace if currently on whitespace', () => {
|
||||||
|
expect(findBigWordEndInLine('hello world', 5)).toBe(12);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should find next big word end if at end of current', () => {
|
||||||
|
expect(findBigWordEndInLine('hello world', 4)).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('set_text action', () => {
|
describe('set_text action', () => {
|
||||||
it('should set new text and move cursor to the end', () => {
|
it('should set new text and move cursor to the end', () => {
|
||||||
const action: TextBufferAction = {
|
const action: TextBufferAction = {
|
||||||
|
|||||||
@@ -174,15 +174,21 @@ export const findWordEndInLine = (line: string, col: number): number | null => {
|
|||||||
|
|
||||||
// If we're already at the end of a word (including punctuation sequences), advance to next word
|
// If we're already at the end of a word (including punctuation sequences), advance to next word
|
||||||
// This includes both regular word endings and script boundaries
|
// This includes both regular word endings and script boundaries
|
||||||
|
let nextBaseCharIdx = i + 1;
|
||||||
|
while (
|
||||||
|
nextBaseCharIdx < chars.length &&
|
||||||
|
isCombiningMark(chars[nextBaseCharIdx])
|
||||||
|
) {
|
||||||
|
nextBaseCharIdx++;
|
||||||
|
}
|
||||||
|
|
||||||
const atEndOfWordChar =
|
const atEndOfWordChar =
|
||||||
i < chars.length &&
|
i < chars.length &&
|
||||||
isWordCharWithCombining(chars[i]) &&
|
isWordCharWithCombining(chars[i]) &&
|
||||||
(i + 1 >= chars.length ||
|
(nextBaseCharIdx >= chars.length ||
|
||||||
!isWordCharWithCombining(chars[i + 1]) ||
|
!isWordCharStrict(chars[nextBaseCharIdx]) ||
|
||||||
(isWordCharStrict(chars[i]) &&
|
(isWordCharStrict(chars[i]) &&
|
||||||
i + 1 < chars.length &&
|
isDifferentScript(chars[i], chars[nextBaseCharIdx])));
|
||||||
isWordCharStrict(chars[i + 1]) &&
|
|
||||||
isDifferentScript(chars[i], chars[i + 1])));
|
|
||||||
|
|
||||||
const atEndOfPunctuation =
|
const atEndOfPunctuation =
|
||||||
i < chars.length &&
|
i < chars.length &&
|
||||||
@@ -195,6 +201,10 @@ export const findWordEndInLine = (line: string, col: number): number | null => {
|
|||||||
if (atEndOfWordChar || atEndOfPunctuation) {
|
if (atEndOfWordChar || atEndOfPunctuation) {
|
||||||
// We're at the end of a word or punctuation sequence, move forward to find next word
|
// We're at the end of a word or punctuation sequence, move forward to find next word
|
||||||
i++;
|
i++;
|
||||||
|
// Skip any combining marks that belong to the word we just finished
|
||||||
|
while (i < chars.length && isCombiningMark(chars[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
// Skip whitespace to find next word or punctuation
|
// Skip whitespace to find next word or punctuation
|
||||||
while (i < chars.length && isWhitespace(chars[i])) {
|
while (i < chars.length && isWhitespace(chars[i])) {
|
||||||
i++;
|
i++;
|
||||||
@@ -260,6 +270,91 @@ export const findWordEndInLine = (line: string, col: number): number | null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Find next big word start within a line (W)
|
||||||
|
export const findNextBigWordStartInLine = (
|
||||||
|
line: string,
|
||||||
|
col: number,
|
||||||
|
): number | null => {
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
let i = col;
|
||||||
|
|
||||||
|
if (i >= chars.length) return null;
|
||||||
|
|
||||||
|
// If currently on non-whitespace, skip it
|
||||||
|
if (!isWhitespace(chars[i])) {
|
||||||
|
while (i < chars.length && !isWhitespace(chars[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
while (i < chars.length && isWhitespace(chars[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return i < chars.length ? i : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find previous big word start within a line (B)
|
||||||
|
export const findPrevBigWordStartInLine = (
|
||||||
|
line: string,
|
||||||
|
col: number,
|
||||||
|
): number | null => {
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
let i = col;
|
||||||
|
|
||||||
|
if (i <= 0) return null;
|
||||||
|
|
||||||
|
i--;
|
||||||
|
|
||||||
|
// Skip whitespace moving backwards
|
||||||
|
while (i >= 0 && isWhitespace(chars[i])) {
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i < 0) return null;
|
||||||
|
|
||||||
|
// We're in a big word, move to its beginning
|
||||||
|
while (i >= 0 && !isWhitespace(chars[i])) {
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
return i + 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find big word end within a line (E)
|
||||||
|
export const findBigWordEndInLine = (
|
||||||
|
line: string,
|
||||||
|
col: number,
|
||||||
|
): number | null => {
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
let i = col;
|
||||||
|
|
||||||
|
// If we're already at the end of a big word, advance to next
|
||||||
|
const atEndOfBigWord =
|
||||||
|
i < chars.length &&
|
||||||
|
!isWhitespace(chars[i]) &&
|
||||||
|
(i + 1 >= chars.length || isWhitespace(chars[i + 1]));
|
||||||
|
|
||||||
|
if (atEndOfBigWord) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip whitespace
|
||||||
|
while (i < chars.length && isWhitespace(chars[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to end of current big word
|
||||||
|
if (i < chars.length && !isWhitespace(chars[i])) {
|
||||||
|
while (i < chars.length && !isWhitespace(chars[i])) {
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return i - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Initialize segmenter for word boundary detection
|
// Initialize segmenter for word boundary detection
|
||||||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||||||
|
|
||||||
@@ -322,34 +417,17 @@ export const findNextWordAcrossLines = (
|
|||||||
return { row: cursorRow, col: colInCurrentLine };
|
return { row: cursorRow, col: colInCurrentLine };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let firstEmptyRow: number | null = null;
|
||||||
|
|
||||||
// Search subsequent lines
|
// Search subsequent lines
|
||||||
for (let row = cursorRow + 1; row < lines.length; row++) {
|
for (let row = cursorRow + 1; row < lines.length; row++) {
|
||||||
const line = lines[row] || '';
|
const line = lines[row] || '';
|
||||||
const chars = toCodePoints(line);
|
const chars = toCodePoints(line);
|
||||||
|
|
||||||
// For empty lines, if we haven't found any words yet, return the empty line
|
// For empty lines, if we haven't found any words yet, remember the first empty line
|
||||||
if (chars.length === 0) {
|
if (chars.length === 0) {
|
||||||
// Check if there are any words in remaining lines
|
if (firstEmptyRow === null) {
|
||||||
let hasWordsInLaterLines = false;
|
firstEmptyRow = row;
|
||||||
for (let laterRow = row + 1; laterRow < lines.length; laterRow++) {
|
|
||||||
const laterLine = lines[laterRow] || '';
|
|
||||||
const laterChars = toCodePoints(laterLine);
|
|
||||||
let firstNonWhitespace = 0;
|
|
||||||
while (
|
|
||||||
firstNonWhitespace < laterChars.length &&
|
|
||||||
isWhitespace(laterChars[firstNonWhitespace])
|
|
||||||
) {
|
|
||||||
firstNonWhitespace++;
|
|
||||||
}
|
|
||||||
if (firstNonWhitespace < laterChars.length) {
|
|
||||||
hasWordsInLaterLines = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no words in later lines, return the empty line
|
|
||||||
if (!hasWordsInLaterLines) {
|
|
||||||
return { row, col: 0 };
|
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -376,6 +454,11 @@ export const findNextWordAcrossLines = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If no words in later lines, return the first empty line we found
|
||||||
|
if (firstEmptyRow !== null) {
|
||||||
|
return { row: firstEmptyRow, col: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -418,6 +501,106 @@ export const findPrevWordAcrossLines = (
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Find next big word across lines
|
||||||
|
export const findNextBigWordAcrossLines = (
|
||||||
|
lines: string[],
|
||||||
|
cursorRow: number,
|
||||||
|
cursorCol: number,
|
||||||
|
searchForWordStart: boolean,
|
||||||
|
): { row: number; col: number } | null => {
|
||||||
|
// First try current line
|
||||||
|
const currentLine = lines[cursorRow] || '';
|
||||||
|
const colInCurrentLine = searchForWordStart
|
||||||
|
? findNextBigWordStartInLine(currentLine, cursorCol)
|
||||||
|
: findBigWordEndInLine(currentLine, cursorCol);
|
||||||
|
|
||||||
|
if (colInCurrentLine !== null) {
|
||||||
|
return { row: cursorRow, col: colInCurrentLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstEmptyRow: number | null = null;
|
||||||
|
|
||||||
|
// Search subsequent lines
|
||||||
|
for (let row = cursorRow + 1; row < lines.length; row++) {
|
||||||
|
const line = lines[row] || '';
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
|
||||||
|
// For empty lines, if we haven't found any words yet, remember the first empty line
|
||||||
|
if (chars.length === 0) {
|
||||||
|
if (firstEmptyRow === null) {
|
||||||
|
firstEmptyRow = row;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find first non-whitespace
|
||||||
|
let firstNonWhitespace = 0;
|
||||||
|
while (
|
||||||
|
firstNonWhitespace < chars.length &&
|
||||||
|
isWhitespace(chars[firstNonWhitespace])
|
||||||
|
) {
|
||||||
|
firstNonWhitespace++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstNonWhitespace < chars.length) {
|
||||||
|
// Found a non-whitespace character (start of a big word)
|
||||||
|
if (searchForWordStart) {
|
||||||
|
return { row, col: firstNonWhitespace };
|
||||||
|
} else {
|
||||||
|
const endCol = findBigWordEndInLine(line, firstNonWhitespace);
|
||||||
|
if (endCol !== null) {
|
||||||
|
return { row, col: endCol };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no words in later lines, return the first empty line we found
|
||||||
|
if (firstEmptyRow !== null) {
|
||||||
|
return { row: firstEmptyRow, col: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find previous big word across lines
|
||||||
|
export const findPrevBigWordAcrossLines = (
|
||||||
|
lines: string[],
|
||||||
|
cursorRow: number,
|
||||||
|
cursorCol: number,
|
||||||
|
): { row: number; col: number } | null => {
|
||||||
|
// First try current line
|
||||||
|
const currentLine = lines[cursorRow] || '';
|
||||||
|
const colInCurrentLine = findPrevBigWordStartInLine(currentLine, cursorCol);
|
||||||
|
|
||||||
|
if (colInCurrentLine !== null) {
|
||||||
|
return { row: cursorRow, col: colInCurrentLine };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search previous lines
|
||||||
|
for (let row = cursorRow - 1; row >= 0; row--) {
|
||||||
|
const line = lines[row] || '';
|
||||||
|
const chars = toCodePoints(line);
|
||||||
|
|
||||||
|
if (chars.length === 0) continue;
|
||||||
|
|
||||||
|
// Find last big word start
|
||||||
|
let lastWordStart = chars.length;
|
||||||
|
while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) {
|
||||||
|
lastWordStart--;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastWordStart > 0) {
|
||||||
|
const wordStart = findPrevBigWordStartInLine(line, lastWordStart);
|
||||||
|
if (wordStart !== null) {
|
||||||
|
return { row, col: wordStart };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Helper functions for vim line operations
|
// Helper functions for vim line operations
|
||||||
export const getPositionFromOffsets = (
|
export const getPositionFromOffsets = (
|
||||||
startOffset: number,
|
startOffset: number,
|
||||||
@@ -1454,9 +1637,15 @@ export type TextBufferAction =
|
|||||||
| { type: 'vim_delete_word_forward'; payload: { count: number } }
|
| { type: 'vim_delete_word_forward'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_word_backward'; payload: { count: number } }
|
| { type: 'vim_delete_word_backward'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_word_end'; payload: { count: number } }
|
| { type: 'vim_delete_word_end'; payload: { count: number } }
|
||||||
|
| { type: 'vim_delete_big_word_forward'; payload: { count: number } }
|
||||||
|
| { type: 'vim_delete_big_word_backward'; payload: { count: number } }
|
||||||
|
| { type: 'vim_delete_big_word_end'; payload: { count: number } }
|
||||||
| { type: 'vim_change_word_forward'; payload: { count: number } }
|
| { type: 'vim_change_word_forward'; payload: { count: number } }
|
||||||
| { type: 'vim_change_word_backward'; payload: { count: number } }
|
| { type: 'vim_change_word_backward'; payload: { count: number } }
|
||||||
| { type: 'vim_change_word_end'; payload: { count: number } }
|
| { type: 'vim_change_word_end'; payload: { count: number } }
|
||||||
|
| { type: 'vim_change_big_word_forward'; payload: { count: number } }
|
||||||
|
| { type: 'vim_change_big_word_backward'; payload: { count: number } }
|
||||||
|
| { type: 'vim_change_big_word_end'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_line'; payload: { count: number } }
|
| { type: 'vim_delete_line'; payload: { count: number } }
|
||||||
| { type: 'vim_change_line'; payload: { count: number } }
|
| { type: 'vim_change_line'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_to_end_of_line' }
|
| { type: 'vim_delete_to_end_of_line' }
|
||||||
@@ -1473,6 +1662,9 @@ export type TextBufferAction =
|
|||||||
| { type: 'vim_move_word_forward'; payload: { count: number } }
|
| { type: 'vim_move_word_forward'; payload: { count: number } }
|
||||||
| { type: 'vim_move_word_backward'; payload: { count: number } }
|
| { type: 'vim_move_word_backward'; payload: { count: number } }
|
||||||
| { type: 'vim_move_word_end'; payload: { count: number } }
|
| { type: 'vim_move_word_end'; payload: { count: number } }
|
||||||
|
| { type: 'vim_move_big_word_forward'; payload: { count: number } }
|
||||||
|
| { type: 'vim_move_big_word_backward'; payload: { count: number } }
|
||||||
|
| { type: 'vim_move_big_word_end'; payload: { count: number } }
|
||||||
| { type: 'vim_delete_char'; payload: { count: number } }
|
| { type: 'vim_delete_char'; payload: { count: number } }
|
||||||
| { type: 'vim_insert_at_cursor' }
|
| { type: 'vim_insert_at_cursor' }
|
||||||
| { type: 'vim_append_at_cursor' }
|
| { type: 'vim_append_at_cursor' }
|
||||||
@@ -2207,9 +2399,15 @@ function textBufferReducerLogic(
|
|||||||
case 'vim_delete_word_forward':
|
case 'vim_delete_word_forward':
|
||||||
case 'vim_delete_word_backward':
|
case 'vim_delete_word_backward':
|
||||||
case 'vim_delete_word_end':
|
case 'vim_delete_word_end':
|
||||||
|
case 'vim_delete_big_word_forward':
|
||||||
|
case 'vim_delete_big_word_backward':
|
||||||
|
case 'vim_delete_big_word_end':
|
||||||
case 'vim_change_word_forward':
|
case 'vim_change_word_forward':
|
||||||
case 'vim_change_word_backward':
|
case 'vim_change_word_backward':
|
||||||
case 'vim_change_word_end':
|
case 'vim_change_word_end':
|
||||||
|
case 'vim_change_big_word_forward':
|
||||||
|
case 'vim_change_big_word_backward':
|
||||||
|
case 'vim_change_big_word_end':
|
||||||
case 'vim_delete_line':
|
case 'vim_delete_line':
|
||||||
case 'vim_change_line':
|
case 'vim_change_line':
|
||||||
case 'vim_delete_to_end_of_line':
|
case 'vim_delete_to_end_of_line':
|
||||||
@@ -2222,6 +2420,9 @@ function textBufferReducerLogic(
|
|||||||
case 'vim_move_word_forward':
|
case 'vim_move_word_forward':
|
||||||
case 'vim_move_word_backward':
|
case 'vim_move_word_backward':
|
||||||
case 'vim_move_word_end':
|
case 'vim_move_word_end':
|
||||||
|
case 'vim_move_big_word_forward':
|
||||||
|
case 'vim_move_big_word_backward':
|
||||||
|
case 'vim_move_big_word_end':
|
||||||
case 'vim_delete_char':
|
case 'vim_delete_char':
|
||||||
case 'vim_insert_at_cursor':
|
case 'vim_insert_at_cursor':
|
||||||
case 'vim_append_at_cursor':
|
case 'vim_append_at_cursor':
|
||||||
@@ -2670,6 +2871,18 @@ export function useTextBuffer({
|
|||||||
dispatch({ type: 'vim_delete_word_end', payload: { count } });
|
dispatch({ type: 'vim_delete_word_end', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const vimDeleteBigWordForward = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_delete_big_word_forward', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vimDeleteBigWordBackward = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_delete_big_word_backward', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vimDeleteBigWordEnd = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_delete_big_word_end', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const vimChangeWordForward = useCallback((count: number): void => {
|
const vimChangeWordForward = useCallback((count: number): void => {
|
||||||
dispatch({ type: 'vim_change_word_forward', payload: { count } });
|
dispatch({ type: 'vim_change_word_forward', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2682,6 +2895,18 @@ export function useTextBuffer({
|
|||||||
dispatch({ type: 'vim_change_word_end', payload: { count } });
|
dispatch({ type: 'vim_change_word_end', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const vimChangeBigWordForward = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_change_big_word_forward', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vimChangeBigWordBackward = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_change_big_word_backward', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vimChangeBigWordEnd = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_change_big_word_end', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const vimDeleteLine = useCallback((count: number): void => {
|
const vimDeleteLine = useCallback((count: number): void => {
|
||||||
dispatch({ type: 'vim_delete_line', payload: { count } });
|
dispatch({ type: 'vim_delete_line', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2734,6 +2959,18 @@ export function useTextBuffer({
|
|||||||
dispatch({ type: 'vim_move_word_end', payload: { count } });
|
dispatch({ type: 'vim_move_word_end', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const vimMoveBigWordForward = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_move_big_word_forward', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vimMoveBigWordBackward = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_move_big_word_backward', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const vimMoveBigWordEnd = useCallback((count: number): void => {
|
||||||
|
dispatch({ type: 'vim_move_big_word_end', payload: { count } });
|
||||||
|
}, []);
|
||||||
|
|
||||||
const vimDeleteChar = useCallback((count: number): void => {
|
const vimDeleteChar = useCallback((count: number): void => {
|
||||||
dispatch({ type: 'vim_delete_char', payload: { count } });
|
dispatch({ type: 'vim_delete_char', payload: { count } });
|
||||||
}, []);
|
}, []);
|
||||||
@@ -3230,9 +3467,15 @@ export function useTextBuffer({
|
|||||||
vimDeleteWordForward,
|
vimDeleteWordForward,
|
||||||
vimDeleteWordBackward,
|
vimDeleteWordBackward,
|
||||||
vimDeleteWordEnd,
|
vimDeleteWordEnd,
|
||||||
|
vimDeleteBigWordForward,
|
||||||
|
vimDeleteBigWordBackward,
|
||||||
|
vimDeleteBigWordEnd,
|
||||||
vimChangeWordForward,
|
vimChangeWordForward,
|
||||||
vimChangeWordBackward,
|
vimChangeWordBackward,
|
||||||
vimChangeWordEnd,
|
vimChangeWordEnd,
|
||||||
|
vimChangeBigWordForward,
|
||||||
|
vimChangeBigWordBackward,
|
||||||
|
vimChangeBigWordEnd,
|
||||||
vimDeleteLine,
|
vimDeleteLine,
|
||||||
vimChangeLine,
|
vimChangeLine,
|
||||||
vimDeleteToEndOfLine,
|
vimDeleteToEndOfLine,
|
||||||
@@ -3245,6 +3488,9 @@ export function useTextBuffer({
|
|||||||
vimMoveWordForward,
|
vimMoveWordForward,
|
||||||
vimMoveWordBackward,
|
vimMoveWordBackward,
|
||||||
vimMoveWordEnd,
|
vimMoveWordEnd,
|
||||||
|
vimMoveBigWordForward,
|
||||||
|
vimMoveBigWordBackward,
|
||||||
|
vimMoveBigWordEnd,
|
||||||
vimDeleteChar,
|
vimDeleteChar,
|
||||||
vimInsertAtCursor,
|
vimInsertAtCursor,
|
||||||
vimAppendAtCursor,
|
vimAppendAtCursor,
|
||||||
@@ -3303,9 +3549,15 @@ export function useTextBuffer({
|
|||||||
vimDeleteWordForward,
|
vimDeleteWordForward,
|
||||||
vimDeleteWordBackward,
|
vimDeleteWordBackward,
|
||||||
vimDeleteWordEnd,
|
vimDeleteWordEnd,
|
||||||
|
vimDeleteBigWordForward,
|
||||||
|
vimDeleteBigWordBackward,
|
||||||
|
vimDeleteBigWordEnd,
|
||||||
vimChangeWordForward,
|
vimChangeWordForward,
|
||||||
vimChangeWordBackward,
|
vimChangeWordBackward,
|
||||||
vimChangeWordEnd,
|
vimChangeWordEnd,
|
||||||
|
vimChangeBigWordForward,
|
||||||
|
vimChangeBigWordBackward,
|
||||||
|
vimChangeBigWordEnd,
|
||||||
vimDeleteLine,
|
vimDeleteLine,
|
||||||
vimChangeLine,
|
vimChangeLine,
|
||||||
vimDeleteToEndOfLine,
|
vimDeleteToEndOfLine,
|
||||||
@@ -3318,6 +3570,9 @@ export function useTextBuffer({
|
|||||||
vimMoveWordForward,
|
vimMoveWordForward,
|
||||||
vimMoveWordBackward,
|
vimMoveWordBackward,
|
||||||
vimMoveWordEnd,
|
vimMoveWordEnd,
|
||||||
|
vimMoveBigWordForward,
|
||||||
|
vimMoveBigWordBackward,
|
||||||
|
vimMoveBigWordEnd,
|
||||||
vimDeleteChar,
|
vimDeleteChar,
|
||||||
vimInsertAtCursor,
|
vimInsertAtCursor,
|
||||||
vimAppendAtCursor,
|
vimAppendAtCursor,
|
||||||
@@ -3500,6 +3755,18 @@ export interface TextBuffer {
|
|||||||
* Delete to end of N words from cursor position (vim 'de' command)
|
* Delete to end of N words from cursor position (vim 'de' command)
|
||||||
*/
|
*/
|
||||||
vimDeleteWordEnd: (count: number) => void;
|
vimDeleteWordEnd: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Delete N big words forward from cursor position (vim 'dW' command)
|
||||||
|
*/
|
||||||
|
vimDeleteBigWordForward: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Delete N big words backward from cursor position (vim 'dB' command)
|
||||||
|
*/
|
||||||
|
vimDeleteBigWordBackward: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Delete to end of N big words from cursor position (vim 'dE' command)
|
||||||
|
*/
|
||||||
|
vimDeleteBigWordEnd: (count: number) => void;
|
||||||
/**
|
/**
|
||||||
* Change N words forward from cursor position (vim 'cw' command)
|
* Change N words forward from cursor position (vim 'cw' command)
|
||||||
*/
|
*/
|
||||||
@@ -3512,6 +3779,18 @@ export interface TextBuffer {
|
|||||||
* Change to end of N words from cursor position (vim 'ce' command)
|
* Change to end of N words from cursor position (vim 'ce' command)
|
||||||
*/
|
*/
|
||||||
vimChangeWordEnd: (count: number) => void;
|
vimChangeWordEnd: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Change N big words forward from cursor position (vim 'cW' command)
|
||||||
|
*/
|
||||||
|
vimChangeBigWordForward: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Change N big words backward from cursor position (vim 'cB' command)
|
||||||
|
*/
|
||||||
|
vimChangeBigWordBackward: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Change to end of N big words from cursor position (vim 'cE' command)
|
||||||
|
*/
|
||||||
|
vimChangeBigWordEnd: (count: number) => void;
|
||||||
/**
|
/**
|
||||||
* Delete N lines from cursor position (vim 'dd' command)
|
* Delete N lines from cursor position (vim 'dd' command)
|
||||||
*/
|
*/
|
||||||
@@ -3560,6 +3839,18 @@ export interface TextBuffer {
|
|||||||
* Move cursor to end of Nth word (vim 'e' command)
|
* Move cursor to end of Nth word (vim 'e' command)
|
||||||
*/
|
*/
|
||||||
vimMoveWordEnd: (count: number) => void;
|
vimMoveWordEnd: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Move cursor forward N big words (vim 'W' command)
|
||||||
|
*/
|
||||||
|
vimMoveBigWordForward: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Move cursor backward N big words (vim 'B' command)
|
||||||
|
*/
|
||||||
|
vimMoveBigWordBackward: (count: number) => void;
|
||||||
|
/**
|
||||||
|
* Move cursor to end of Nth big word (vim 'E' command)
|
||||||
|
*/
|
||||||
|
vimMoveBigWordEnd: (count: number) => void;
|
||||||
/**
|
/**
|
||||||
* Delete N characters at cursor (vim 'x' command)
|
* Delete N characters at cursor (vim 'x' command)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -310,6 +310,32 @@ describe('vim-buffer-actions', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('vim_move_big_word_backward', () => {
|
||||||
|
it('should treat punctuation as part of the word (B)', () => {
|
||||||
|
const state = createTestState(['hello.world'], 0, 10);
|
||||||
|
const action = {
|
||||||
|
type: 'vim_move_big_word_backward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(0); // Start of 'hello'
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should skip punctuation when moving back to previous big word', () => {
|
||||||
|
const state = createTestState(['word1, word2'], 0, 7);
|
||||||
|
const action = {
|
||||||
|
type: 'vim_move_big_word_backward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.cursorCol).toBe(0); // Start of 'word1,'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('vim_move_word_end', () => {
|
describe('vim_move_word_end', () => {
|
||||||
it('should move to end of current word', () => {
|
it('should move to end of current word', () => {
|
||||||
const state = createTestState(['hello world'], 0, 0);
|
const state = createTestState(['hello world'], 0, 0);
|
||||||
@@ -584,6 +610,44 @@ describe('vim-buffer-actions', () => {
|
|||||||
expect(result.lines[0]).toBe('hello ');
|
expect(result.lines[0]).toBe('hello ');
|
||||||
expect(result.cursorCol).toBe(6);
|
expect(result.cursorCol).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should delete only the word characters if it is the last word followed by whitespace', () => {
|
||||||
|
const state = createTestState(['foo bar '], 0, 4); // on 'b'
|
||||||
|
const action = {
|
||||||
|
type: 'vim_delete_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.lines[0]).toBe('foo ');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should do nothing if cursor is on whitespace after the last word', () => {
|
||||||
|
const state = createTestState(['foo bar '], 0, 8); // on one of the trailing spaces
|
||||||
|
const action = {
|
||||||
|
type: 'vim_delete_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.lines[0]).toBe('foo bar ');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('vim_delete_big_word_forward', () => {
|
||||||
|
it('should delete only the big word characters if it is the last word followed by whitespace', () => {
|
||||||
|
const state = createTestState(['foo bar.baz '], 0, 4); // on 'b'
|
||||||
|
const action = {
|
||||||
|
type: 'vim_delete_big_word_forward' as const,
|
||||||
|
payload: { count: 1 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = handleVimAction(state, action);
|
||||||
|
expect(result).toHaveOnlyValidCharacters();
|
||||||
|
expect(result.lines[0]).toBe('foo ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('vim_delete_word_backward', () => {
|
describe('vim_delete_word_backward', () => {
|
||||||
|
|||||||
@@ -11,41 +11,31 @@ import {
|
|||||||
replaceRangeInternal,
|
replaceRangeInternal,
|
||||||
pushUndo,
|
pushUndo,
|
||||||
detachExpandedPaste,
|
detachExpandedPaste,
|
||||||
isWordCharStrict,
|
|
||||||
isWordCharWithCombining,
|
|
||||||
isCombiningMark,
|
isCombiningMark,
|
||||||
findNextWordAcrossLines,
|
findNextWordAcrossLines,
|
||||||
findPrevWordAcrossLines,
|
findPrevWordAcrossLines,
|
||||||
|
findNextBigWordAcrossLines,
|
||||||
|
findPrevBigWordAcrossLines,
|
||||||
findWordEndInLine,
|
findWordEndInLine,
|
||||||
|
findBigWordEndInLine,
|
||||||
} from './text-buffer.js';
|
} from './text-buffer.js';
|
||||||
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
|
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
|
||||||
import { assumeExhaustive } from '@google/gemini-cli-core';
|
import { assumeExhaustive } from '@google/gemini-cli-core';
|
||||||
|
|
||||||
// Check if we're at the end of a base word (on the last base character)
|
|
||||||
// Returns true if current position has a base character followed only by combining marks until non-word
|
|
||||||
function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean {
|
|
||||||
if (!isWordCharStrict(lineCodePoints[col])) return false;
|
|
||||||
|
|
||||||
// Look ahead to see if we have only combining marks followed by non-word
|
|
||||||
let i = col + 1;
|
|
||||||
|
|
||||||
// Skip any combining marks
|
|
||||||
while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we hit end of line or non-word character, we were at end of base word
|
|
||||||
return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export type VimAction = Extract<
|
export type VimAction = Extract<
|
||||||
TextBufferAction,
|
TextBufferAction,
|
||||||
| { type: 'vim_delete_word_forward' }
|
| { type: 'vim_delete_word_forward' }
|
||||||
| { type: 'vim_delete_word_backward' }
|
| { type: 'vim_delete_word_backward' }
|
||||||
| { type: 'vim_delete_word_end' }
|
| { type: 'vim_delete_word_end' }
|
||||||
|
| { type: 'vim_delete_big_word_forward' }
|
||||||
|
| { type: 'vim_delete_big_word_backward' }
|
||||||
|
| { type: 'vim_delete_big_word_end' }
|
||||||
| { type: 'vim_change_word_forward' }
|
| { type: 'vim_change_word_forward' }
|
||||||
| { type: 'vim_change_word_backward' }
|
| { type: 'vim_change_word_backward' }
|
||||||
| { type: 'vim_change_word_end' }
|
| { type: 'vim_change_word_end' }
|
||||||
|
| { type: 'vim_change_big_word_forward' }
|
||||||
|
| { type: 'vim_change_big_word_backward' }
|
||||||
|
| { type: 'vim_change_big_word_end' }
|
||||||
| { type: 'vim_delete_line' }
|
| { type: 'vim_delete_line' }
|
||||||
| { type: 'vim_change_line' }
|
| { type: 'vim_change_line' }
|
||||||
| { type: 'vim_delete_to_end_of_line' }
|
| { type: 'vim_delete_to_end_of_line' }
|
||||||
@@ -58,6 +48,9 @@ export type VimAction = Extract<
|
|||||||
| { type: 'vim_move_word_forward' }
|
| { type: 'vim_move_word_forward' }
|
||||||
| { type: 'vim_move_word_backward' }
|
| { type: 'vim_move_word_backward' }
|
||||||
| { type: 'vim_move_word_end' }
|
| { type: 'vim_move_word_end' }
|
||||||
|
| { type: 'vim_move_big_word_forward' }
|
||||||
|
| { type: 'vim_move_big_word_backward' }
|
||||||
|
| { type: 'vim_move_big_word_end' }
|
||||||
| { type: 'vim_delete_char' }
|
| { type: 'vim_delete_char' }
|
||||||
| { type: 'vim_insert_at_cursor' }
|
| { type: 'vim_insert_at_cursor' }
|
||||||
| { type: 'vim_append_at_cursor' }
|
| { type: 'vim_append_at_cursor' }
|
||||||
@@ -93,14 +86,15 @@ export function handleVimAction(
|
|||||||
endRow = nextWord.row;
|
endRow = nextWord.row;
|
||||||
endCol = nextWord.col;
|
endCol = nextWord.col;
|
||||||
} else {
|
} else {
|
||||||
// No more words, delete/change to end of current word or line
|
// No more words. Check if we can delete to the end of the current word.
|
||||||
const currentLine = lines[endRow] || '';
|
const currentLine = lines[endRow] || '';
|
||||||
const wordEnd = findWordEndInLine(currentLine, endCol);
|
const wordEnd = findWordEndInLine(currentLine, endCol);
|
||||||
|
|
||||||
if (wordEnd !== null) {
|
if (wordEnd !== null) {
|
||||||
endCol = wordEnd + 1; // Include the character at word end
|
// Found word end, delete up to (and including) it
|
||||||
} else {
|
endCol = wordEnd + 1;
|
||||||
endCol = cpLen(currentLine);
|
|
||||||
}
|
}
|
||||||
|
// If wordEnd is null, we are likely on trailing whitespace, so do nothing.
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -119,6 +113,48 @@ export function handleVimAction(
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_delete_big_word_forward':
|
||||||
|
case 'vim_change_big_word_forward': {
|
||||||
|
const { count } = action.payload;
|
||||||
|
let endRow = cursorRow;
|
||||||
|
let endCol = cursorCol;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const nextWord = findNextBigWordAcrossLines(
|
||||||
|
lines,
|
||||||
|
endRow,
|
||||||
|
endCol,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (nextWord) {
|
||||||
|
endRow = nextWord.row;
|
||||||
|
endCol = nextWord.col;
|
||||||
|
} else {
|
||||||
|
// No more words. Check if we can delete to the end of the current big word.
|
||||||
|
const currentLine = lines[endRow] || '';
|
||||||
|
const wordEnd = findBigWordEndInLine(currentLine, endCol);
|
||||||
|
|
||||||
|
if (wordEnd !== null) {
|
||||||
|
endCol = wordEnd + 1;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||||
|
const nextState = pushUndo(state);
|
||||||
|
return replaceRangeInternal(
|
||||||
|
nextState,
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
endRow,
|
||||||
|
endCol,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
case 'vim_delete_word_backward':
|
case 'vim_delete_word_backward':
|
||||||
case 'vim_change_word_backward': {
|
case 'vim_change_word_backward': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
@@ -149,6 +185,36 @@ export function handleVimAction(
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_delete_big_word_backward':
|
||||||
|
case 'vim_change_big_word_backward': {
|
||||||
|
const { count } = action.payload;
|
||||||
|
let startRow = cursorRow;
|
||||||
|
let startCol = cursorCol;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const prevWord = findPrevBigWordAcrossLines(lines, startRow, startCol);
|
||||||
|
if (prevWord) {
|
||||||
|
startRow = prevWord.row;
|
||||||
|
startCol = prevWord.col;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startRow !== cursorRow || startCol !== cursorCol) {
|
||||||
|
const nextState = pushUndo(state);
|
||||||
|
return replaceRangeInternal(
|
||||||
|
nextState,
|
||||||
|
startRow,
|
||||||
|
startCol,
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
case 'vim_delete_word_end':
|
case 'vim_delete_word_end':
|
||||||
case 'vim_change_word_end': {
|
case 'vim_change_word_end': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
@@ -202,6 +268,59 @@ export function handleVimAction(
|
|||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_delete_big_word_end':
|
||||||
|
case 'vim_change_big_word_end': {
|
||||||
|
const { count } = action.payload;
|
||||||
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
let endRow = cursorRow;
|
||||||
|
let endCol = cursorCol;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const wordEnd = findNextBigWordAcrossLines(lines, row, col, false);
|
||||||
|
if (wordEnd) {
|
||||||
|
endRow = wordEnd.row;
|
||||||
|
endCol = wordEnd.col + 1; // Include the character at word end
|
||||||
|
// For next iteration, move to start of next word
|
||||||
|
if (i < count - 1) {
|
||||||
|
const nextWord = findNextBigWordAcrossLines(
|
||||||
|
lines,
|
||||||
|
wordEnd.row,
|
||||||
|
wordEnd.col + 1,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (nextWord) {
|
||||||
|
row = nextWord.row;
|
||||||
|
col = nextWord.col;
|
||||||
|
} else {
|
||||||
|
break; // No more words
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure we don't go past the end of the last line
|
||||||
|
if (endRow < lines.length) {
|
||||||
|
const lineLen = cpLen(lines[endRow] || '');
|
||||||
|
endCol = Math.min(endCol, lineLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endRow !== cursorRow || endCol !== cursorCol) {
|
||||||
|
const nextState = pushUndo(state);
|
||||||
|
return replaceRangeInternal(
|
||||||
|
nextState,
|
||||||
|
cursorRow,
|
||||||
|
cursorCol,
|
||||||
|
endRow,
|
||||||
|
endCol,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
case 'vim_delete_line': {
|
case 'vim_delete_line': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
if (lines.length === 0) return state;
|
if (lines.length === 0) return state;
|
||||||
@@ -540,6 +659,30 @@ export function handleVimAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_move_big_word_forward': {
|
||||||
|
const { count } = action.payload;
|
||||||
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const nextWord = findNextBigWordAcrossLines(lines, row, col, true);
|
||||||
|
if (nextWord) {
|
||||||
|
row = nextWord.row;
|
||||||
|
col = nextWord.col;
|
||||||
|
} else {
|
||||||
|
// No more words to move to
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
cursorRow: row,
|
||||||
|
cursorCol: col,
|
||||||
|
preferredCol: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'vim_move_word_backward': {
|
case 'vim_move_word_backward': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
let row = cursorRow;
|
let row = cursorRow;
|
||||||
@@ -563,43 +706,35 @@ export function handleVimAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_move_big_word_backward': {
|
||||||
|
const { count } = action.payload;
|
||||||
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const prevWord = findPrevBigWordAcrossLines(lines, row, col);
|
||||||
|
if (prevWord) {
|
||||||
|
row = prevWord.row;
|
||||||
|
col = prevWord.col;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
cursorRow: row,
|
||||||
|
cursorCol: col,
|
||||||
|
preferredCol: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'vim_move_word_end': {
|
case 'vim_move_word_end': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
let row = cursorRow;
|
let row = cursorRow;
|
||||||
let col = cursorCol;
|
let col = cursorCol;
|
||||||
|
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
// Special handling for the first iteration when we're at end of word
|
|
||||||
if (i === 0) {
|
|
||||||
const currentLine = lines[row] || '';
|
|
||||||
const lineCodePoints = toCodePoints(currentLine);
|
|
||||||
|
|
||||||
// Check if we're at the end of a word (on the last base character)
|
|
||||||
const atEndOfWord =
|
|
||||||
col < lineCodePoints.length &&
|
|
||||||
isWordCharStrict(lineCodePoints[col]) &&
|
|
||||||
(col + 1 >= lineCodePoints.length ||
|
|
||||||
!isWordCharWithCombining(lineCodePoints[col + 1]) ||
|
|
||||||
// Or if we're on a base char followed only by combining marks until non-word
|
|
||||||
(isWordCharStrict(lineCodePoints[col]) &&
|
|
||||||
isAtEndOfBaseWord(lineCodePoints, col)));
|
|
||||||
|
|
||||||
if (atEndOfWord) {
|
|
||||||
// We're already at end of word, find next word end
|
|
||||||
const nextWord = findNextWordAcrossLines(
|
|
||||||
lines,
|
|
||||||
row,
|
|
||||||
col + 1,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (nextWord) {
|
|
||||||
row = nextWord.row;
|
|
||||||
col = nextWord.col;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
|
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
|
||||||
if (wordEnd) {
|
if (wordEnd) {
|
||||||
row = wordEnd.row;
|
row = wordEnd.row;
|
||||||
@@ -617,6 +752,29 @@ export function handleVimAction(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'vim_move_big_word_end': {
|
||||||
|
const { count } = action.payload;
|
||||||
|
let row = cursorRow;
|
||||||
|
let col = cursorCol;
|
||||||
|
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const wordEnd = findNextBigWordAcrossLines(lines, row, col, false);
|
||||||
|
if (wordEnd) {
|
||||||
|
row = wordEnd.row;
|
||||||
|
col = wordEnd.col;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
cursorRow: row,
|
||||||
|
cursorCol: col,
|
||||||
|
preferredCol: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
case 'vim_delete_char': {
|
case 'vim_delete_char': {
|
||||||
const { count } = action.payload;
|
const { count } = action.payload;
|
||||||
const { cursorRow, cursorCol, lines } = state;
|
const { cursorRow, cursorCol, lines } = state;
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ describe('useVim hook', () => {
|
|||||||
vimMoveWordForward: vi.fn(),
|
vimMoveWordForward: vi.fn(),
|
||||||
vimMoveWordBackward: vi.fn(),
|
vimMoveWordBackward: vi.fn(),
|
||||||
vimMoveWordEnd: vi.fn(),
|
vimMoveWordEnd: vi.fn(),
|
||||||
|
vimMoveBigWordForward: vi.fn(),
|
||||||
|
vimMoveBigWordBackward: vi.fn(),
|
||||||
|
vimMoveBigWordEnd: vi.fn(),
|
||||||
|
vimDeleteBigWordForward: vi.fn(),
|
||||||
|
vimDeleteBigWordBackward: vi.fn(),
|
||||||
|
vimDeleteBigWordEnd: vi.fn(),
|
||||||
|
vimChangeBigWordForward: vi.fn(),
|
||||||
|
vimChangeBigWordBackward: vi.fn(),
|
||||||
|
vimChangeBigWordEnd: vi.fn(),
|
||||||
vimDeleteChar: vi.fn(),
|
vimDeleteChar: vi.fn(),
|
||||||
vimInsertAtCursor: vi.fn(),
|
vimInsertAtCursor: vi.fn(),
|
||||||
vimAppendAtCursor: vi.fn().mockImplementation(() => {
|
vimAppendAtCursor: vi.fn().mockImplementation(() => {
|
||||||
@@ -570,6 +579,105 @@ describe('useVim hook', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Big Word movement', () => {
|
||||||
|
it('should handle W (next big word)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello world test');
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'W' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimMoveBigWordForward).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle B (previous big word)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello world test', [0, 6]);
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'B' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimMoveBigWordBackward).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle E (end of big word)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello world test');
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'E' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimMoveBigWordEnd).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dW (delete big word forward)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello.world test', [0, 0]);
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'W' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimDeleteBigWordForward).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle cW (change big word forward)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello.world test', [0, 0]);
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'c' }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'W' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimChangeBigWordForward).toHaveBeenCalledWith(1);
|
||||||
|
expect(result.current.mode).toBe('INSERT');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dB (delete big word backward)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello.world test', [0, 11]);
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'B' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimDeleteBigWordBackward).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle dE (delete big word end)', () => {
|
||||||
|
const testBuffer = createMockBuffer('hello.world test', [0, 0]);
|
||||||
|
const { result } = renderVimHook(testBuffer);
|
||||||
|
exitInsertMode(result);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'd' }));
|
||||||
|
});
|
||||||
|
act(() => {
|
||||||
|
result.current.handleInput(createKey({ sequence: 'E' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(testBuffer.vimDeleteBigWordEnd).toHaveBeenCalledWith(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Disabled vim mode', () => {
|
describe('Disabled vim mode', () => {
|
||||||
it('should not respond to vim commands when disabled', () => {
|
it('should not respond to vim commands when disabled', () => {
|
||||||
mockVimContext.vimEnabled = false;
|
mockVimContext.vimEnabled = false;
|
||||||
|
|||||||
@@ -24,9 +24,15 @@ const CMD_TYPES = {
|
|||||||
DELETE_WORD_FORWARD: 'dw',
|
DELETE_WORD_FORWARD: 'dw',
|
||||||
DELETE_WORD_BACKWARD: 'db',
|
DELETE_WORD_BACKWARD: 'db',
|
||||||
DELETE_WORD_END: 'de',
|
DELETE_WORD_END: 'de',
|
||||||
|
DELETE_BIG_WORD_FORWARD: 'dW',
|
||||||
|
DELETE_BIG_WORD_BACKWARD: 'dB',
|
||||||
|
DELETE_BIG_WORD_END: 'dE',
|
||||||
CHANGE_WORD_FORWARD: 'cw',
|
CHANGE_WORD_FORWARD: 'cw',
|
||||||
CHANGE_WORD_BACKWARD: 'cb',
|
CHANGE_WORD_BACKWARD: 'cb',
|
||||||
CHANGE_WORD_END: 'ce',
|
CHANGE_WORD_END: 'ce',
|
||||||
|
CHANGE_BIG_WORD_FORWARD: 'cW',
|
||||||
|
CHANGE_BIG_WORD_BACKWARD: 'cB',
|
||||||
|
CHANGE_BIG_WORD_END: 'cE',
|
||||||
DELETE_CHAR: 'x',
|
DELETE_CHAR: 'x',
|
||||||
DELETE_LINE: 'dd',
|
DELETE_LINE: 'dd',
|
||||||
CHANGE_LINE: 'cc',
|
CHANGE_LINE: 'cc',
|
||||||
@@ -187,6 +193,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_BIG_WORD_FORWARD: {
|
||||||
|
buffer.vimDeleteBigWordForward(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_BIG_WORD_BACKWARD: {
|
||||||
|
buffer.vimDeleteBigWordBackward(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.DELETE_BIG_WORD_END: {
|
||||||
|
buffer.vimDeleteBigWordEnd(count);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case CMD_TYPES.CHANGE_WORD_FORWARD: {
|
case CMD_TYPES.CHANGE_WORD_FORWARD: {
|
||||||
buffer.vimChangeWordForward(count);
|
buffer.vimChangeWordForward(count);
|
||||||
updateMode('INSERT');
|
updateMode('INSERT');
|
||||||
@@ -205,6 +226,24 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_BIG_WORD_FORWARD: {
|
||||||
|
buffer.vimChangeBigWordForward(count);
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_BIG_WORD_BACKWARD: {
|
||||||
|
buffer.vimChangeBigWordBackward(count);
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case CMD_TYPES.CHANGE_BIG_WORD_END: {
|
||||||
|
buffer.vimChangeBigWordEnd(count);
|
||||||
|
updateMode('INSERT');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case CMD_TYPES.DELETE_CHAR: {
|
case CMD_TYPES.DELETE_CHAR: {
|
||||||
buffer.vimDeleteChar(count);
|
buffer.vimDeleteChar(count);
|
||||||
break;
|
break;
|
||||||
@@ -371,7 +410,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
* @returns boolean indicating if command was handled
|
* @returns boolean indicating if command was handled
|
||||||
*/
|
*/
|
||||||
const handleOperatorMotion = useCallback(
|
const handleOperatorMotion = useCallback(
|
||||||
(operator: 'd' | 'c', motion: 'w' | 'b' | 'e'): boolean => {
|
(
|
||||||
|
operator: 'd' | 'c',
|
||||||
|
motion: 'w' | 'b' | 'e' | 'W' | 'B' | 'E',
|
||||||
|
): boolean => {
|
||||||
const count = getCurrentCount();
|
const count = getCurrentCount();
|
||||||
|
|
||||||
const commandMap = {
|
const commandMap = {
|
||||||
@@ -379,11 +421,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
w: CMD_TYPES.DELETE_WORD_FORWARD,
|
w: CMD_TYPES.DELETE_WORD_FORWARD,
|
||||||
b: CMD_TYPES.DELETE_WORD_BACKWARD,
|
b: CMD_TYPES.DELETE_WORD_BACKWARD,
|
||||||
e: CMD_TYPES.DELETE_WORD_END,
|
e: CMD_TYPES.DELETE_WORD_END,
|
||||||
|
W: CMD_TYPES.DELETE_BIG_WORD_FORWARD,
|
||||||
|
B: CMD_TYPES.DELETE_BIG_WORD_BACKWARD,
|
||||||
|
E: CMD_TYPES.DELETE_BIG_WORD_END,
|
||||||
},
|
},
|
||||||
c: {
|
c: {
|
||||||
w: CMD_TYPES.CHANGE_WORD_FORWARD,
|
w: CMD_TYPES.CHANGE_WORD_FORWARD,
|
||||||
b: CMD_TYPES.CHANGE_WORD_BACKWARD,
|
b: CMD_TYPES.CHANGE_WORD_BACKWARD,
|
||||||
e: CMD_TYPES.CHANGE_WORD_END,
|
e: CMD_TYPES.CHANGE_WORD_END,
|
||||||
|
W: CMD_TYPES.CHANGE_BIG_WORD_FORWARD,
|
||||||
|
B: CMD_TYPES.CHANGE_BIG_WORD_BACKWARD,
|
||||||
|
E: CMD_TYPES.CHANGE_BIG_WORD_END,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -524,6 +572,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'W': {
|
||||||
|
// Check if this is part of a delete or change command (dW/cW)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleOperatorMotion('d', 'W');
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
return handleOperatorMotion('c', 'W');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal big word movement
|
||||||
|
buffer.vimMoveBigWordForward(repeatCount);
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case 'b': {
|
case 'b': {
|
||||||
// Check if this is part of a delete or change command (db/cb)
|
// Check if this is part of a delete or change command (db/cb)
|
||||||
if (state.pendingOperator === 'd') {
|
if (state.pendingOperator === 'd') {
|
||||||
@@ -539,6 +602,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'B': {
|
||||||
|
// Check if this is part of a delete or change command (dB/cB)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleOperatorMotion('d', 'B');
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
return handleOperatorMotion('c', 'B');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal backward big word movement
|
||||||
|
buffer.vimMoveBigWordBackward(repeatCount);
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case 'e': {
|
case 'e': {
|
||||||
// Check if this is part of a delete or change command (de/ce)
|
// Check if this is part of a delete or change command (de/ce)
|
||||||
if (state.pendingOperator === 'd') {
|
if (state.pendingOperator === 'd') {
|
||||||
@@ -554,6 +632,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'E': {
|
||||||
|
// Check if this is part of a delete or change command (dE/cE)
|
||||||
|
if (state.pendingOperator === 'd') {
|
||||||
|
return handleOperatorMotion('d', 'E');
|
||||||
|
}
|
||||||
|
if (state.pendingOperator === 'c') {
|
||||||
|
return handleOperatorMotion('c', 'E');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normal big word end movement
|
||||||
|
buffer.vimMoveBigWordEnd(repeatCount);
|
||||||
|
dispatch({ type: 'CLEAR_COUNT' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
case 'x': {
|
case 'x': {
|
||||||
// Delete character under cursor
|
// Delete character under cursor
|
||||||
buffer.vimDeleteChar(repeatCount);
|
buffer.vimDeleteChar(repeatCount);
|
||||||
|
|||||||
Reference in New Issue
Block a user