summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared/text-buffer.test.ts
diff options
context:
space:
mode:
authorSijie Wang <[email protected]>2025-07-25 15:36:42 -0700
committerGitHub <[email protected]>2025-07-25 22:36:42 +0000
commitfbdc8d5ab3f76aef32af6a8f516d97771c56a7ac (patch)
tree2167cd9ab4c5a1378d466e735bc41e167ea4d904 /packages/cli/src/ui/components/shared/text-buffer.test.ts
parentaa71438684dd0350acf62fc01d1e6244fd4d3f51 (diff)
Vim mode (#3936)
Diffstat (limited to 'packages/cli/src/ui/components/shared/text-buffer.test.ts')
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.test.ts214
1 files changed, 214 insertions, 0 deletions
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
index 4db1ce7b..807c33df 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.test.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -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);
+ });
+ });
+});