summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components/shared')
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.test.ts192
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts1426
2 files changed, 832 insertions, 786 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 5ea52ba4..7f180dae 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.test.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -11,8 +11,150 @@ import {
Viewport,
TextBuffer,
offsetToLogicalPos,
+ textBufferReducer,
+ TextBufferState,
+ TextBufferAction,
} from './text-buffer.js';
+const initialState: TextBufferState = {
+ lines: [''],
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+};
+
+describe('textBufferReducer', () => {
+ it('should return the initial state if state is undefined', () => {
+ const action = { type: 'unknown_action' } as unknown as TextBufferAction;
+ const state = textBufferReducer(initialState, action);
+ expect(state).toEqual(initialState);
+ });
+
+ describe('set_text action', () => {
+ it('should set new text and move cursor to the end', () => {
+ const action: TextBufferAction = {
+ type: 'set_text',
+ payload: 'hello\nworld',
+ };
+ const state = textBufferReducer(initialState, action);
+ expect(state.lines).toEqual(['hello', 'world']);
+ expect(state.cursorRow).toBe(1);
+ expect(state.cursorCol).toBe(5);
+ expect(state.undoStack.length).toBe(1);
+ });
+
+ it('should not create an undo snapshot if pushToUndo is false', () => {
+ const action: TextBufferAction = {
+ type: 'set_text',
+ payload: 'no undo',
+ pushToUndo: false,
+ };
+ const state = textBufferReducer(initialState, action);
+ expect(state.lines).toEqual(['no undo']);
+ expect(state.undoStack.length).toBe(0);
+ });
+ });
+
+ describe('insert action', () => {
+ it('should insert a character', () => {
+ const action: TextBufferAction = { type: 'insert', payload: 'a' };
+ const state = textBufferReducer(initialState, action);
+ expect(state.lines).toEqual(['a']);
+ expect(state.cursorCol).toBe(1);
+ });
+
+ it('should insert a newline', () => {
+ const stateWithText = { ...initialState, lines: ['hello'] };
+ const action: TextBufferAction = { type: 'insert', payload: '\n' };
+ const state = textBufferReducer(stateWithText, action);
+ expect(state.lines).toEqual(['', 'hello']);
+ expect(state.cursorRow).toBe(1);
+ expect(state.cursorCol).toBe(0);
+ });
+ });
+
+ describe('backspace action', () => {
+ it('should remove a character', () => {
+ const stateWithText: TextBufferState = {
+ ...initialState,
+ lines: ['a'],
+ cursorRow: 0,
+ cursorCol: 1,
+ };
+ const action: TextBufferAction = { type: 'backspace' };
+ const state = textBufferReducer(stateWithText, action);
+ expect(state.lines).toEqual(['']);
+ expect(state.cursorCol).toBe(0);
+ });
+
+ it('should join lines if at the beginning of a line', () => {
+ const stateWithText: TextBufferState = {
+ ...initialState,
+ lines: ['hello', 'world'],
+ cursorRow: 1,
+ cursorCol: 0,
+ };
+ const action: TextBufferAction = { type: 'backspace' };
+ const state = textBufferReducer(stateWithText, action);
+ expect(state.lines).toEqual(['helloworld']);
+ expect(state.cursorRow).toBe(0);
+ expect(state.cursorCol).toBe(5);
+ });
+ });
+
+ describe('undo/redo actions', () => {
+ it('should undo and redo a change', () => {
+ // 1. Insert text
+ const insertAction: TextBufferAction = {
+ type: 'insert',
+ payload: 'test',
+ };
+ const stateAfterInsert = textBufferReducer(initialState, insertAction);
+ expect(stateAfterInsert.lines).toEqual(['test']);
+ expect(stateAfterInsert.undoStack.length).toBe(1);
+
+ // 2. Undo
+ const undoAction: TextBufferAction = { type: 'undo' };
+ const stateAfterUndo = textBufferReducer(stateAfterInsert, undoAction);
+ expect(stateAfterUndo.lines).toEqual(['']);
+ expect(stateAfterUndo.undoStack.length).toBe(0);
+ expect(stateAfterUndo.redoStack.length).toBe(1);
+
+ // 3. Redo
+ const redoAction: TextBufferAction = { type: 'redo' };
+ const stateAfterRedo = textBufferReducer(stateAfterUndo, redoAction);
+ expect(stateAfterRedo.lines).toEqual(['test']);
+ expect(stateAfterRedo.undoStack.length).toBe(1);
+ expect(stateAfterRedo.redoStack.length).toBe(0);
+ });
+ });
+
+ describe('create_undo_snapshot action', () => {
+ it('should create a snapshot without changing state', () => {
+ const stateWithText: TextBufferState = {
+ ...initialState,
+ lines: ['hello'],
+ cursorRow: 0,
+ cursorCol: 5,
+ };
+ const action: TextBufferAction = { type: 'create_undo_snapshot' };
+ const state = textBufferReducer(stateWithText, action);
+
+ expect(state.lines).toEqual(['hello']);
+ expect(state.cursorRow).toBe(0);
+ expect(state.cursorCol).toBe(5);
+ expect(state.undoStack.length).toBe(1);
+ expect(state.undoStack[0].lines).toEqual(['hello']);
+ expect(state.undoStack[0].cursorRow).toBe(0);
+ expect(state.undoStack[0].cursorCol).toBe(5);
+ });
+ });
+});
+
// Helper to get the state from the hook
const getBufferState = (result: { current: TextBuffer }) => ({
text: result.current.text,
@@ -644,11 +786,27 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).cursor).toEqual([0, 5]);
act(() => {
- result.current.applyOperations([
- { type: 'backspace' },
- { type: 'backspace' },
- { type: 'backspace' },
- ]);
+ result.current.handleInput({
+ name: 'backspace',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ sequence: '\x7f',
+ });
+ result.current.handleInput({
+ name: 'backspace',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ sequence: '\x7f',
+ });
+ result.current.handleInput({
+ name: 'backspace',
+ ctrl: false,
+ meta: false,
+ shift: false,
+ sequence: '\x7f',
+ });
});
expect(getBufferState(result).text).toBe('ab');
expect(getBufferState(result).cursor).toEqual([0, 2]);
@@ -666,9 +824,7 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).cursor).toEqual([0, 5]);
act(() => {
- result.current.applyOperations([
- { type: 'insert', payload: '\x7f\x7f\x7f' },
- ]);
+ result.current.insert('\x7f\x7f\x7f');
});
expect(getBufferState(result).text).toBe('ab');
expect(getBufferState(result).cursor).toEqual([0, 2]);
@@ -686,9 +842,7 @@ describe('useTextBuffer', () => {
expect(getBufferState(result).cursor).toEqual([0, 5]);
act(() => {
- result.current.applyOperations([
- { type: 'insert', payload: '\x7fI\x7f\x7fNEW' },
- ]);
+ result.current.insert('\x7fI\x7f\x7fNEW');
});
expect(getBufferState(result).text).toBe('abcNEW');
expect(getBufferState(result).cursor).toEqual([0, 6]);
@@ -774,11 +928,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
// Simulate pasting the long text multiple times
act(() => {
- result.current.applyOperations([
- { type: 'insert', payload: longText },
- { type: 'insert', payload: longText },
- { type: 'insert', payload: longText },
- ]);
+ result.current.insert(longText);
+ result.current.insert(longText);
+ result.current.insert(longText);
});
const state = getBufferState(result);
@@ -909,17 +1061,15 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots
isValidPath: () => false,
}),
);
- let success = true;
act(() => {
- success = result.current.replaceRange(0, 5, 0, 3, 'fail'); // startCol > endCol in same line
+ result.current.replaceRange(0, 5, 0, 3, 'fail'); // startCol > endCol in same line
});
- expect(success).toBe(false);
+
expect(getBufferState(result).text).toBe('test');
act(() => {
- success = result.current.replaceRange(1, 0, 0, 0, 'fail'); // startRow > endRow
+ result.current.replaceRange(1, 0, 0, 0, 'fail'); // startRow > endRow
});
- expect(success).toBe(false);
expect(getBufferState(result).text).toBe('test');
});
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 7767fd2d..0283e059 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -9,7 +9,7 @@ import { spawnSync } from 'child_process';
import fs from 'fs';
import os from 'os';
import pathMod from 'path';
-import { useState, useCallback, useEffect, useMemo } from 'react';
+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';
@@ -24,13 +24,6 @@ export type Direction =
| 'home'
| 'end';
-// TODO(jacob314): refactor so all edit operations to be part of this list.
-// This makes it robust for clients to apply multiple edit operations without
-// having to carefully reason about how React manages state.
-type UpdateOperation =
- | { type: 'insert'; payload: string }
- | { type: 'backspace' };
-
// Simple helper for word‑wise ops.
function isWordChar(ch: string | undefined): boolean {
if (ch === undefined) {
@@ -70,21 +63,6 @@ function clamp(v: number, min: number, max: number): number {
return v < min ? min : v > max ? max : v;
}
-/* -------------------------------------------------------------------------
- * Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
- * ---------------------------------------------------------------------- */
-
-// Enable verbose logging only when requested via env var.
-const DEBUG =
- process.env['TEXTBUFFER_DEBUG'] === '1' ||
- process.env['TEXTBUFFER_DEBUG'] === 'true';
-
-function dbg(...args: unknown[]): void {
- if (DEBUG) {
- console.log('[TextBuffer]', ...args);
- }
-}
-
/* ────────────────────────────────────────────────────────────────────────── */
interface UseTextBufferProps {
@@ -395,561 +373,190 @@ function calculateVisualLayout(
};
}
-export function useTextBuffer({
- initialText = '',
- initialCursorOffset = 0,
- viewport,
- stdin,
- setRawMode,
- onChange,
- isValidPath,
-}: UseTextBufferProps): TextBuffer {
- const [lines, setLines] = useState<string[]>(() => {
- const l = initialText.split('\n');
- return l.length === 0 ? [''] : l;
- });
-
- const [[initialCursorRow, initialCursorCol]] = useState(() =>
- calculateInitialCursorPosition(lines, initialCursorOffset),
- );
-
- const [cursorRow, setCursorRow] = useState<number>(initialCursorRow);
- const [cursorCol, setCursorCol] = useState<number>(initialCursorCol);
- const [preferredCol, setPreferredCol] = useState<number | null>(null); // Visual preferred col
-
- const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
- const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
- const historyLimit = 100;
- const [opQueue, setOpQueue] = useState<UpdateOperation[]>([]);
-
- const [clipboard, setClipboard] = useState<string | null>(null);
- const [selectionAnchor, setSelectionAnchor] = useState<
- [number, number] | null
- >(null); // Logical selection
-
- // Visual state
- const [visualLines, setVisualLines] = useState<string[]>(['']);
- const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]);
- const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
- const [logicalToVisualMap, setLogicalToVisualMap] = useState<
- Array<Array<[number, number]>>
- >([]);
- const [visualToLogicalMap, setVisualToLogicalMap] = useState<
- Array<[number, number]>
- >([]);
-
- const currentLine = useCallback(
- (r: number): string => lines[r] ?? '',
- [lines],
- );
- const currentLineLen = useCallback(
- (r: number): number => cpLen(currentLine(r)),
- [currentLine],
- );
+// --- Start of reducer logic ---
- // Recalculate visual layout whenever logical lines or viewport width changes
- useEffect(() => {
- const layout = calculateVisualLayout(
- lines,
- [cursorRow, cursorCol],
- viewport.width,
- );
- setVisualLines(layout.visualLines);
- setVisualCursor(layout.visualCursor);
- setLogicalToVisualMap(layout.logicalToVisualMap);
- setVisualToLogicalMap(layout.visualToLogicalMap);
- }, [lines, cursorRow, cursorCol, viewport.width]);
+interface TextBufferState {
+ lines: string[];
+ cursorRow: number;
+ cursorCol: number;
+ preferredCol: number | null; // This is visual preferred col
+ undoStack: UndoHistoryEntry[];
+ redoStack: UndoHistoryEntry[];
+ clipboard: string | null;
+ selectionAnchor: [number, number] | null;
+ viewportWidth: number;
+}
- // Update visual scroll (vertical)
- useEffect(() => {
- const { height } = viewport;
- let newVisualScrollRow = visualScrollRow;
+const historyLimit = 100;
- if (visualCursor[0] < visualScrollRow) {
- newVisualScrollRow = visualCursor[0];
- } else if (visualCursor[0] >= visualScrollRow + height) {
- newVisualScrollRow = visualCursor[0] - height + 1;
+type TextBufferAction =
+ | { type: 'set_text'; payload: string; pushToUndo?: boolean }
+ | { type: 'insert'; payload: string }
+ | { type: 'backspace' }
+ | {
+ type: 'move';
+ payload: {
+ dir: Direction;
+ };
}
- if (newVisualScrollRow !== visualScrollRow) {
- setVisualScrollRow(newVisualScrollRow);
+ | { type: 'delete' }
+ | { type: 'delete_word_left' }
+ | { type: 'delete_word_right' }
+ | { type: 'kill_line_right' }
+ | { type: 'kill_line_left' }
+ | { type: 'undo' }
+ | { type: 'redo' }
+ | {
+ type: 'replace_range';
+ payload: {
+ startRow: number;
+ startCol: number;
+ endRow: number;
+ endCol: number;
+ text: string;
+ };
}
- }, [visualCursor, visualScrollRow, viewport]);
-
- const pushUndo = useCallback(() => {
- dbg('pushUndo', { cursor: [cursorRow, cursorCol], text: lines.join('\n') });
- const snapshot = { lines: [...lines], cursorRow, cursorCol };
- setUndoStack((prev) => {
- const newStack = [...prev, snapshot];
- if (newStack.length > historyLimit) {
- newStack.shift();
- }
- return newStack;
- });
- setRedoStack([]);
- }, [lines, cursorRow, cursorCol, historyLimit]);
+ | { type: 'move_to_offset'; payload: { offset: number } }
+ | { type: 'create_undo_snapshot' }
+ | { type: 'set_viewport_width'; payload: number };
- const _restoreState = useCallback(
- (state: UndoHistoryEntry | undefined): boolean => {
- if (!state) return false;
- setLines(state.lines);
- setCursorRow(state.cursorRow);
- setCursorCol(state.cursorCol);
- return true;
- },
- [],
- );
-
- const text = lines.join('\n');
-
- useEffect(() => {
- if (onChange) {
- onChange(text);
+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();
}
- }, [text, onChange]);
-
- const undo = useCallback((): boolean => {
- const state = undoStack[undoStack.length - 1];
- if (!state) return false;
-
- setUndoStack((prev) => prev.slice(0, -1));
- const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
- setRedoStack((prev) => [...prev, currentSnapshot]);
- return _restoreState(state);
- }, [undoStack, lines, cursorRow, cursorCol, _restoreState]);
-
- const redo = useCallback((): boolean => {
- const state = redoStack[redoStack.length - 1];
- if (!state) return false;
-
- setRedoStack((prev) => prev.slice(0, -1));
- const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
- setUndoStack((prev) => [...prev, currentSnapshot]);
- return _restoreState(state);
- }, [redoStack, lines, cursorRow, cursorCol, _restoreState]);
-
- const applyOperations = useCallback((ops: UpdateOperation[]) => {
- if (ops.length === 0) return;
- setOpQueue((prev) => [...prev, ...ops]);
- }, []);
+ return { ...currentState, undoStack: newStack, redoStack: [] };
+ };
- useEffect(() => {
- if (opQueue.length === 0) return;
+ const currentLine = (r: number): string => state.lines[r] ?? '';
+ const currentLineLen = (r: number): number => cpLen(currentLine(r));
- const expandedOps: UpdateOperation[] = [];
- for (const op of opQueue) {
- if (op.type === 'insert') {
- let currentText = '';
- for (const char of toCodePoints(op.payload)) {
- if (char.codePointAt(0) === 127) {
- // \x7f
- if (currentText.length > 0) {
- expandedOps.push({ type: 'insert', payload: currentText });
- currentText = '';
- }
- expandedOps.push({ type: 'backspace' });
- } else {
- currentText += char;
- }
- }
- if (currentText.length > 0) {
- expandedOps.push({ type: 'insert', payload: currentText });
- }
- } else {
- expandedOps.push(op);
+ switch (action.type) {
+ case 'set_text': {
+ let nextState = state;
+ if (action.pushToUndo !== false) {
+ nextState = pushUndo(state);
}
+ const newContentLines = action.payload
+ .replace(/\r\n?/g, '\n')
+ .split('\n');
+ const lines = newContentLines.length === 0 ? [''] : newContentLines;
+ const lastNewLineIndex = lines.length - 1;
+ return {
+ ...nextState,
+ lines,
+ cursorRow: lastNewLineIndex,
+ cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
+ preferredCol: null,
+ };
}
- if (expandedOps.length === 0) {
- setOpQueue([]); // Clear queue even if ops were no-ops
- return;
- }
-
- pushUndo(); // Snapshot before applying batch of updates
+ case 'insert': {
+ const nextState = pushUndo(state);
+ const newLines = [...nextState.lines];
+ let newCursorRow = nextState.cursorRow;
+ let newCursorCol = nextState.cursorCol;
- const newLines = [...lines];
- let newCursorRow = cursorRow;
- let newCursorCol = cursorCol;
+ const currentLine = (r: number) => newLines[r] ?? '';
- const currentLine = (r: number) => newLines[r] ?? '';
+ const str = stripUnsafeCharacters(
+ action.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
+ );
+ const parts = str.split('\n');
+ const lineContent = currentLine(newCursorRow);
+ const before = cpSlice(lineContent, 0, newCursorCol);
+ const after = cpSlice(lineContent, newCursorCol);
- for (const op of expandedOps) {
- if (op.type === 'insert') {
- const str = stripUnsafeCharacters(
- op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'),
+ if (parts.length > 1) {
+ newLines[newCursorRow] = before + parts[0];
+ const remainingParts = parts.slice(1);
+ const lastPartOriginal = remainingParts.pop() ?? '';
+ newLines.splice(newCursorRow + 1, 0, ...remainingParts);
+ newLines.splice(
+ newCursorRow + parts.length - 1,
+ 0,
+ lastPartOriginal + after,
);
- const parts = str.split('\n');
- const lineContent = currentLine(newCursorRow);
- const before = cpSlice(lineContent, 0, newCursorCol);
- const after = cpSlice(lineContent, newCursorCol);
-
- if (parts.length > 1) {
- newLines[newCursorRow] = before + parts[0];
- const remainingParts = parts.slice(1);
- const lastPartOriginal = remainingParts.pop() ?? '';
- newLines.splice(newCursorRow + 1, 0, ...remainingParts);
- newLines.splice(
- newCursorRow + parts.length - 1,
- 0,
- lastPartOriginal + after,
- );
- newCursorRow = newCursorRow + parts.length - 1;
- newCursorCol = cpLen(lastPartOriginal);
- } else {
- newLines[newCursorRow] = before + parts[0] + after;
-
- newCursorCol = cpLen(before) + cpLen(parts[0]);
- }
- } else if (op.type === 'backspace') {
- if (newCursorCol === 0 && newCursorRow === 0) continue;
-
- if (newCursorCol > 0) {
- const lineContent = currentLine(newCursorRow);
- newLines[newCursorRow] =
- cpSlice(lineContent, 0, newCursorCol - 1) +
- cpSlice(lineContent, newCursorCol);
- newCursorCol--;
- } else if (newCursorRow > 0) {
- const prevLineContent = currentLine(newCursorRow - 1);
- const currentLineContentVal = currentLine(newCursorRow);
- const newCol = cpLen(prevLineContent);
- newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
- newLines.splice(newCursorRow, 1);
- newCursorRow--;
- newCursorCol = newCol;
- }
- }
- }
-
- setLines(newLines);
- setCursorRow(newCursorRow);
- setCursorCol(newCursorCol);
- setPreferredCol(null);
-
- // Clear the queue after processing
- setOpQueue((prev) => prev.slice(opQueue.length));
- }, [opQueue, lines, cursorRow, cursorCol, pushUndo, setPreferredCol]);
-
- const insert = useCallback(
- (ch: string): void => {
- dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
-
- ch = stripUnsafeCharacters(ch);
-
- // Arbitrary threshold to avoid false positives on normal key presses
- // while still detecting virtually all reasonable length file paths.
- const minLengthToInferAsDragDrop = 3;
- if (ch.length >= minLengthToInferAsDragDrop) {
- // Possible drag and drop of a file path.
- let potentialPath = ch;
- if (
- potentialPath.length > 2 &&
- potentialPath.startsWith("'") &&
- potentialPath.endsWith("'")
- ) {
- potentialPath = ch.slice(1, -1);
- }
-
- potentialPath = potentialPath.trim();
- // Be conservative and only add an @ if the path is valid.
- if (isValidPath(unescapePath(potentialPath))) {
- ch = `@${potentialPath}`;
- }
+ newCursorRow = newCursorRow + parts.length - 1;
+ newCursorCol = cpLen(lastPartOriginal);
+ } else {
+ newLines[newCursorRow] = before + parts[0] + after;
+ newCursorCol = cpLen(before) + cpLen(parts[0]);
}
- applyOperations([{ type: 'insert', payload: ch }]);
- },
- [applyOperations, cursorRow, cursorCol, isValidPath],
- );
-
- const newline = useCallback((): void => {
- dbg('newline', { beforeCursor: [cursorRow, cursorCol] });
- applyOperations([{ type: 'insert', payload: '\n' }]);
- }, [applyOperations, cursorRow, cursorCol]);
-
- const backspace = useCallback((): void => {
- dbg('backspace', { beforeCursor: [cursorRow, cursorCol] });
- if (cursorCol === 0 && cursorRow === 0) return;
- applyOperations([{ type: 'backspace' }]);
- }, [applyOperations, cursorRow, cursorCol]);
- const del = useCallback((): void => {
- dbg('delete', { beforeCursor: [cursorRow, cursorCol] });
- const lineContent = currentLine(cursorRow);
- if (cursorCol < currentLineLen(cursorRow)) {
- pushUndo();
- setLines((prevLines) => {
- const newLines = [...prevLines];
- newLines[cursorRow] =
- cpSlice(lineContent, 0, cursorCol) +
- cpSlice(lineContent, cursorCol + 1);
- return newLines;
- });
- } else if (cursorRow < lines.length - 1) {
- pushUndo();
- const nextLineContent = currentLine(cursorRow + 1);
- setLines((prevLines) => {
- const newLines = [...prevLines];
- newLines[cursorRow] = lineContent + nextLineContent;
- newLines.splice(cursorRow + 1, 1);
- return newLines;
- });
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ };
}
- // cursor position does not change for del
- setPreferredCol(null);
- }, [
- pushUndo,
- cursorRow,
- cursorCol,
- currentLine,
- currentLineLen,
- lines.length,
- setPreferredCol,
- ]);
- const setText = useCallback(
- (newText: string): void => {
- dbg('setText', { text: newText });
- pushUndo();
- const newContentLines = newText.replace(/\r\n?/g, '\n').split('\n');
- setLines(newContentLines.length === 0 ? [''] : newContentLines);
- // Set logical cursor to the end of the new text
- const lastNewLineIndex = newContentLines.length - 1;
- setCursorRow(lastNewLineIndex);
- setCursorCol(cpLen(newContentLines[lastNewLineIndex] ?? ''));
- setPreferredCol(null);
- },
- [pushUndo, setPreferredCol],
- );
+ case 'backspace': {
+ const nextState = pushUndo(state);
+ const newLines = [...nextState.lines];
+ let newCursorRow = nextState.cursorRow;
+ let newCursorCol = nextState.cursorCol;
- const replaceRange = useCallback(
- (
- startRow: number,
- startCol: number,
- endRow: number,
- endCol: number,
- replacementText: string,
- ): boolean => {
- if (
- startRow > endRow ||
- (startRow === endRow && startCol > endCol) ||
- startRow < 0 ||
- startCol < 0 ||
- endRow >= lines.length ||
- (endRow < lines.length && endCol > currentLineLen(endRow))
- ) {
- console.error('Invalid range provided to replaceRange', {
- startRow,
- startCol,
- endRow,
- endCol,
- linesLength: lines.length,
- endRowLineLength: currentLineLen(endRow),
- });
- return false;
- }
- dbg('replaceRange', {
- start: [startRow, startCol],
- end: [endRow, endCol],
- text: replacementText,
- });
- pushUndo();
-
- 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 = replacementText
- .replace(/\r\n/g, '\n')
- .replace(/\r/g, '\n');
- const replacementParts = normalisedReplacement.split('\n');
+ const currentLine = (r: number) => newLines[r] ?? '';
- setLines((prevLines) => {
- const newLines = [...prevLines];
- // Remove lines between startRow and endRow (exclusive of startRow, inclusive of endRow if different)
- if (startRow < endRow) {
- newLines.splice(startRow + 1, endRow - startRow);
- }
-
- // Construct the new content for the startRow
- newLines[startRow] = prefix + replacementParts[0];
-
- // If replacementText has multiple lines, insert them
- if (replacementParts.length > 1) {
- const lastReplacementPart = replacementParts.pop() ?? ''; // parts are already split by \n
- // Insert middle parts (if any)
- if (replacementParts.length > 1) {
- // parts[0] is already used
- newLines.splice(startRow + 1, 0, ...replacementParts.slice(1));
- }
+ if (newCursorCol === 0 && newCursorRow === 0) return state;
- // The line where the last part of the replacement will go
- const targetRowForLastPart = startRow + (replacementParts.length - 1); // -1 because parts[0] is on startRow
- // If the last part is not the first part (multi-line replacement)
- if (
- targetRowForLastPart > startRow ||
- (replacementParts.length === 1 && lastReplacementPart !== '')
- ) {
- // If the target row for the last part doesn't exist (because it's a new line created by replacement)
- // ensure it's created before trying to append suffix.
- // This case should be handled by splice if replacementParts.length > 1
- // For single line replacement that becomes multi-line due to parts.length > 1 logic, this is tricky.
- // Let's assume newLines[targetRowForLastPart] exists due to previous splice or it's newLines[startRow]
- if (
- newLines[targetRowForLastPart] === undefined &&
- targetRowForLastPart === startRow + 1 &&
- replacementParts.length === 1
- ) {
- // This implies a single line replacement that became two lines.
- // e.g. "abc" replace "b" with "B\nC" -> "aB", "C", "c"
- // Here, lastReplacementPart is "C", targetRowForLastPart is startRow + 1
- newLines.splice(
- targetRowForLastPart,
- 0,
- lastReplacementPart + suffix,
- );
- } else {
- newLines[targetRowForLastPart] =
- (newLines[targetRowForLastPart] || '') +
- lastReplacementPart +
- suffix;
- }
- } else {
- // Single line in replacementParts, but it was the only part
- newLines[startRow] += suffix;
- }
-
- setCursorRow(targetRowForLastPart);
- setCursorCol(cpLen(newLines[targetRowForLastPart]) - cpLen(suffix));
- } else {
- // Single line replacement (replacementParts has only one item)
- newLines[startRow] += suffix;
- setCursorRow(startRow);
- setCursorCol(cpLen(prefix) + cpLen(replacementParts[0]));
- }
- return newLines;
- });
-
- setPreferredCol(null);
- return true;
- },
- [pushUndo, lines, currentLine, currentLineLen, setPreferredCol],
- );
-
- const deleteWordLeft = useCallback((): void => {
- dbg('deleteWordLeft', { beforeCursor: [cursorRow, cursorCol] });
- if (cursorCol === 0 && cursorRow === 0) return;
- if (cursorCol === 0) {
- backspace();
- return;
- }
- pushUndo();
- const lineContent = currentLine(cursorRow);
- const arr = toCodePoints(lineContent);
- let start = cursorCol;
- let onlySpaces = true;
- for (let i = 0; i < start; i++) {
- if (isWordChar(arr[i])) {
- onlySpaces = false;
- break;
+ if (newCursorCol > 0) {
+ const lineContent = currentLine(newCursorRow);
+ newLines[newCursorRow] =
+ cpSlice(lineContent, 0, newCursorCol - 1) +
+ cpSlice(lineContent, newCursorCol);
+ newCursorCol--;
+ } else if (newCursorRow > 0) {
+ const prevLineContent = currentLine(newCursorRow - 1);
+ const currentLineContentVal = currentLine(newCursorRow);
+ const newCol = cpLen(prevLineContent);
+ newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
+ newLines.splice(newCursorRow, 1);
+ newCursorRow--;
+ newCursorCol = newCol;
}
- }
- if (onlySpaces && start > 0) {
- start--;
- } else {
- while (start > 0 && !isWordChar(arr[start - 1])) start--;
- while (start > 0 && isWordChar(arr[start - 1])) start--;
- }
- setLines((prevLines) => {
- const newLines = [...prevLines];
- newLines[cursorRow] =
- cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
- return newLines;
- });
- setCursorCol(start);
- setPreferredCol(null);
- }, [pushUndo, cursorRow, cursorCol, currentLine, backspace, setPreferredCol]);
- const deleteWordRight = useCallback((): void => {
- dbg('deleteWordRight', { beforeCursor: [cursorRow, cursorCol] });
- const lineContent = currentLine(cursorRow);
- const arr = toCodePoints(lineContent);
- if (cursorCol >= arr.length && cursorRow === lines.length - 1) return;
- if (cursorCol >= arr.length) {
- del();
- return;
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ };
}
- pushUndo();
- let end = cursorCol;
- while (end < arr.length && !isWordChar(arr[end])) end++;
- while (end < arr.length && isWordChar(arr[end])) end++;
- setLines((prevLines) => {
- const newLines = [...prevLines];
- newLines[cursorRow] =
- cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
- return newLines;
- });
- setPreferredCol(null);
- }, [
- pushUndo,
- cursorRow,
- cursorCol,
- currentLine,
- del,
- lines.length,
- setPreferredCol,
- ]);
- const killLineRight = useCallback((): void => {
- const lineContent = currentLine(cursorRow);
- if (cursorCol < currentLineLen(cursorRow)) {
- // Cursor is before the end of the line's content, delete text to the right
- pushUndo();
- setLines((prevLines) => {
- const newLines = [...prevLines];
- newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
- return newLines;
- });
- // Cursor position and preferredCol do not change in this case
- } else if (
- cursorCol === currentLineLen(cursorRow) &&
- cursorRow < lines.length - 1
- ) {
- // Cursor is at the end of the line's content (or line is empty),
- // and it's not the last line. Delete the newline.
- // `del()` handles pushUndo and setPreferredCol.
- del();
+ case 'set_viewport_width': {
+ if (action.payload === state.viewportWidth) {
+ return state;
+ }
+ return { ...state, viewportWidth: action.payload };
}
- // If cursor is at the end of the line and it's the last line, do nothing.
- }, [
- pushUndo,
- cursorRow,
- cursorCol,
- currentLine,
- currentLineLen,
- lines.length,
- del,
- ]);
- const killLineLeft = useCallback((): void => {
- const lineContent = currentLine(cursorRow);
- // Only act if the cursor is not at the beginning of the line
- if (cursorCol > 0) {
- pushUndo();
- setLines((prevLines) => {
- const newLines = [...prevLines];
- newLines[cursorRow] = cpSlice(lineContent, cursorCol);
- return newLines;
- });
- setCursorCol(0);
- setPreferredCol(null);
- }
- }, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
+ case 'move': {
+ const { dir } = action.payload;
+ const { lines, cursorRow, cursorCol, viewportWidth } = state;
+ const visualLayout = calculateVisualLayout(
+ lines,
+ [cursorRow, cursorCol],
+ viewportWidth,
+ );
+ const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
- const move = useCallback(
- (dir: Direction): void => {
let newVisualRow = visualCursor[0];
let newVisualCol = visualCursor[1];
- let newPreferredCol = preferredCol;
+ let newPreferredCol = state.preferredCol;
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
@@ -1002,140 +609,504 @@ export function useTextBuffer({
newPreferredCol = null;
newVisualCol = currentVisLineLen;
break;
- // wordLeft and wordRight might need more sophisticated visual handling
- // For now, they operate on the logical line derived from the visual cursor
case 'wordLeft': {
- newPreferredCol = null;
- if (
- visualToLogicalMap.length === 0 ||
- logicalToVisualMap.length === 0
- )
- break;
- const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
- 0, 0,
- ];
- const currentLogCol = logColInitial + newVisualCol;
- const lineText = lines[logRow];
- const sliceToCursor = cpSlice(lineText, 0, currentLogCol).replace(
- /[\s,.;!?]+$/,
- '',
- );
- let lastIdx = 0;
- const regex = /[\s,.;!?]+/g;
- let m;
- while ((m = regex.exec(sliceToCursor)) != null) lastIdx = m.index;
- const newLogicalCol =
- lastIdx === 0 ? 0 : cpLen(sliceToCursor.slice(0, lastIdx)) + 1;
+ const { cursorRow, cursorCol, lines } = state;
+ if (cursorCol === 0 && cursorRow === 0) return state;
+
+ let newCursorRow = cursorRow;
+ let newCursorCol = cursorCol;
- // Map newLogicalCol back to visual
- const targetLogicalMapEntries = logicalToVisualMap[logRow];
- if (!targetLogicalMapEntries) break;
- for (let i = targetLogicalMapEntries.length - 1; i >= 0; i--) {
- const [visRow, logStartCol] = targetLogicalMapEntries[i];
- if (newLogicalCol >= logStartCol) {
- newVisualRow = visRow;
- newVisualCol = newLogicalCol - logStartCol;
- break;
+ if (cursorCol === 0) {
+ newCursorRow--;
+ newCursorCol = cpLen(lines[newCursorRow] ?? '');
+ } else {
+ const lineContent = lines[cursorRow];
+ const arr = toCodePoints(lineContent);
+ let start = cursorCol;
+ let onlySpaces = true;
+ for (let i = 0; i < start; i++) {
+ if (isWordChar(arr[i])) {
+ onlySpaces = false;
+ break;
+ }
+ }
+ if (onlySpaces && start > 0) {
+ start--;
+ } else {
+ while (start > 0 && !isWordChar(arr[start - 1])) start--;
+ while (start > 0 && isWordChar(arr[start - 1])) start--;
}
+ newCursorCol = start;
}
- break;
+ return {
+ ...state,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ };
}
case 'wordRight': {
- newPreferredCol = null;
+ const { cursorRow, cursorCol, lines } = state;
if (
- visualToLogicalMap.length === 0 ||
- logicalToVisualMap.length === 0
- )
- break;
- const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
- 0, 0,
- ];
- const currentLogCol = logColInitial + newVisualCol;
- const lineText = lines[logRow];
- const regex = /[\s,.;!?]+/g;
- let moved = false;
- let m;
- let newLogicalCol = currentLineLen(logRow); // Default to end of logical line
-
- while ((m = regex.exec(lineText)) != null) {
- const cpIdx = cpLen(lineText.slice(0, m.index));
- if (cpIdx > currentLogCol) {
- newLogicalCol = cpIdx;
- moved = true;
- break;
- }
- }
- if (!moved && currentLogCol < currentLineLen(logRow)) {
- // If no word break found after cursor, move to end
- newLogicalCol = currentLineLen(logRow);
+ cursorRow === lines.length - 1 &&
+ cursorCol === cpLen(lines[cursorRow] ?? '')
+ ) {
+ return state;
}
- // Map newLogicalCol back to visual
- const targetLogicalMapEntries = logicalToVisualMap[logRow];
- if (!targetLogicalMapEntries) break;
- for (let i = 0; i < targetLogicalMapEntries.length; i++) {
- const [visRow, logStartCol] = targetLogicalMapEntries[i];
- const nextLogStartCol =
- i + 1 < targetLogicalMapEntries.length
- ? targetLogicalMapEntries[i + 1][1]
- : Infinity;
- if (
- newLogicalCol >= logStartCol &&
- newLogicalCol < nextLogStartCol
- ) {
- newVisualRow = visRow;
- newVisualCol = newLogicalCol - logStartCol;
- break;
- }
- if (
- newLogicalCol === logStartCol &&
- i === targetLogicalMapEntries.length - 1 &&
- cpLen(visualLines[visRow] ?? '') === 0
- ) {
- // Special case: moving to an empty visual line at the end of a logical line
- newVisualRow = visRow;
- newVisualCol = 0;
- break;
- }
+ let newCursorRow = cursorRow;
+ let newCursorCol = cursorCol;
+ const lineContent = lines[cursorRow] ?? '';
+ const arr = toCodePoints(lineContent);
+
+ if (cursorCol >= arr.length) {
+ newCursorRow++;
+ newCursorCol = 0;
+ } else {
+ let end = cursorCol;
+ while (end < arr.length && !isWordChar(arr[end])) end++;
+ while (end < arr.length && isWordChar(arr[end])) end++;
+ newCursorCol = end;
}
- break;
+ return {
+ ...state,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ };
}
default:
break;
}
- setVisualCursor([newVisualRow, newVisualCol]);
- setPreferredCol(newPreferredCol);
-
- // Update logical cursor based on new visual cursor
if (visualToLogicalMap[newVisualRow]) {
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
- setCursorRow(logRow);
- setCursorCol(
- clamp(logStartCol + newVisualCol, 0, currentLineLen(logRow)),
- );
+ return {
+ ...state,
+ cursorRow: logRow,
+ cursorCol: clamp(
+ logStartCol + newVisualCol,
+ 0,
+ cpLen(state.lines[logRow] ?? ''),
+ ),
+ preferredCol: newPreferredCol,
+ };
}
+ return state;
+ }
- dbg('move', {
- dir,
- visualBefore: visualCursor,
- visualAfter: [newVisualRow, newVisualCol],
- logicalAfter: [cursorRow, cursorCol],
- });
+ case 'delete': {
+ const { cursorRow, cursorCol, lines } = state;
+ const lineContent = currentLine(cursorRow);
+ if (cursorCol < currentLineLen(cursorRow)) {
+ const nextState = pushUndo(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 nextLineContent = currentLine(cursorRow + 1);
+ const newLines = [...nextState.lines];
+ newLines[cursorRow] = lineContent + nextLineContent;
+ newLines.splice(cursorRow + 1, 1);
+ return { ...nextState, lines: newLines, preferredCol: null };
+ }
+ return state;
+ }
+
+ case 'delete_word_left': {
+ const { cursorRow, cursorCol } = state;
+ if (cursorCol === 0 && cursorRow === 0) return state;
+ if (cursorCol === 0) {
+ // Act as a backspace
+ const nextState = pushUndo(state);
+ const prevLineContent = currentLine(cursorRow - 1);
+ const currentLineContentVal = currentLine(cursorRow);
+ const newCol = cpLen(prevLineContent);
+ const newLines = [...nextState.lines];
+ newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
+ newLines.splice(cursorRow, 1);
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorRow: cursorRow - 1,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+ const nextState = pushUndo(state);
+ const lineContent = currentLine(cursorRow);
+ const arr = toCodePoints(lineContent);
+ let start = cursorCol;
+ let onlySpaces = true;
+ for (let i = 0; i < start; i++) {
+ if (isWordChar(arr[i])) {
+ onlySpaces = false;
+ break;
+ }
+ }
+ if (onlySpaces && start > 0) {
+ start--;
+ } else {
+ while (start > 0 && !isWordChar(arr[start - 1])) start--;
+ while (start > 0 && isWordChar(arr[start - 1])) start--;
+ }
+ const newLines = [...nextState.lines];
+ newLines[cursorRow] =
+ cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorCol: start,
+ preferredCol: null,
+ };
+ }
+
+ case 'delete_word_right': {
+ const { cursorRow, cursorCol, lines } = state;
+ const lineContent = currentLine(cursorRow);
+ const arr = toCodePoints(lineContent);
+ if (cursorCol >= arr.length && cursorRow === lines.length - 1)
+ return state;
+ if (cursorCol >= arr.length) {
+ // Act as a delete
+ const nextState = pushUndo(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);
+ let end = cursorCol;
+ while (end < arr.length && !isWordChar(arr[end])) end++;
+ while (end < arr.length && isWordChar(arr[end])) end++;
+ const newLines = [...nextState.lines];
+ newLines[cursorRow] =
+ cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
+ return { ...nextState, lines: newLines, preferredCol: null };
+ }
+
+ case 'kill_line_right': {
+ const { cursorRow, cursorCol, lines } = state;
+ const lineContent = currentLine(cursorRow);
+ if (cursorCol < currentLineLen(cursorRow)) {
+ const nextState = pushUndo(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 nextLineContent = currentLine(cursorRow + 1);
+ const newLines = [...nextState.lines];
+ newLines[cursorRow] = lineContent + nextLineContent;
+ newLines.splice(cursorRow + 1, 1);
+ return { ...nextState, lines: newLines, preferredCol: null };
+ }
+ return state;
+ }
+
+ case 'kill_line_left': {
+ const { cursorRow, cursorCol } = state;
+ if (cursorCol > 0) {
+ const nextState = pushUndo(state);
+ const lineContent = currentLine(cursorRow);
+ const newLines = [...nextState.lines];
+ newLines[cursorRow] = cpSlice(lineContent, cursorCol);
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorCol: 0,
+ preferredCol: null,
+ };
+ }
+ return state;
+ }
+
+ case 'undo': {
+ const stateToRestore = state.undoStack[state.undoStack.length - 1];
+ if (!stateToRestore) return state;
+
+ const currentSnapshot = {
+ lines: [...state.lines],
+ cursorRow: state.cursorRow,
+ cursorCol: state.cursorCol,
+ };
+ return {
+ ...state,
+ ...stateToRestore,
+ undoStack: state.undoStack.slice(0, -1),
+ redoStack: [...state.redoStack, currentSnapshot],
+ };
+ }
+
+ case 'redo': {
+ const stateToRestore = state.redoStack[state.redoStack.length - 1];
+ if (!stateToRestore) return state;
+
+ const currentSnapshot = {
+ lines: [...state.lines],
+ cursorRow: state.cursorRow,
+ cursorCol: state.cursorCol,
+ };
+ return {
+ ...state,
+ ...stateToRestore,
+ redoStack: state.redoStack.slice(0, -1),
+ undoStack: [...state.undoStack, currentSnapshot],
+ };
+ }
+
+ 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,
+ };
+ }
+
+ case 'move_to_offset': {
+ const { offset } = action.payload;
+ const [newRow, newCol] = offsetToLogicalPos(
+ state.lines.join('\n'),
+ offset,
+ );
+ return {
+ ...state,
+ cursorRow: newRow,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'create_undo_snapshot': {
+ return pushUndo(state);
+ }
+
+ default: {
+ const exhaustiveCheck: never = action;
+ console.error(`Unknown action encountered: ${exhaustiveCheck}`);
+ return state;
+ }
+ }
+}
+
+// --- End of reducer logic ---
+
+export function useTextBuffer({
+ initialText = '',
+ initialCursorOffset = 0,
+ viewport,
+ stdin,
+ setRawMode,
+ onChange,
+ isValidPath,
+}: UseTextBufferProps): TextBuffer {
+ const initialState = useMemo((): TextBufferState => {
+ const lines = initialText.split('\n');
+ const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(
+ lines.length === 0 ? [''] : lines,
+ initialCursorOffset,
+ );
+ return {
+ lines: lines.length === 0 ? [''] : lines,
+ cursorRow: initialCursorRow,
+ cursorCol: initialCursorCol,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ viewportWidth: viewport.width,
+ };
+ }, [initialText, initialCursorOffset, viewport.width]);
+
+ const [state, dispatch] = useReducer(textBufferReducer, initialState);
+ const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
+
+ const text = useMemo(() => lines.join('\n'), [lines]);
+
+ const visualLayout = useMemo(
+ () =>
+ calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth),
+ [lines, cursorRow, cursorCol, state.viewportWidth],
+ );
+
+ const { visualLines, visualCursor } = visualLayout;
+
+ const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
+
+ useEffect(() => {
+ if (onChange) {
+ onChange(text);
+ }
+ }, [text, onChange]);
+
+ useEffect(() => {
+ dispatch({ type: 'set_viewport_width', payload: viewport.width });
+ }, [viewport.width]);
+
+ // Update visual scroll (vertical)
+ useEffect(() => {
+ const { height } = viewport;
+ let newVisualScrollRow = visualScrollRow;
+
+ if (visualCursor[0] < visualScrollRow) {
+ newVisualScrollRow = visualCursor[0];
+ } else if (visualCursor[0] >= visualScrollRow + height) {
+ newVisualScrollRow = visualCursor[0] - height + 1;
+ }
+ if (newVisualScrollRow !== visualScrollRow) {
+ setVisualScrollRow(newVisualScrollRow);
+ }
+ }, [visualCursor, visualScrollRow, viewport]);
+
+ const insert = useCallback(
+ (ch: string): void => {
+ if (/[\n\r]/.test(ch)) {
+ dispatch({ type: 'insert', payload: ch });
+ return;
+ }
+
+ const minLengthToInferAsDragDrop = 3;
+ if (ch.length >= minLengthToInferAsDragDrop) {
+ let potentialPath = ch;
+ if (
+ potentialPath.length > 2 &&
+ potentialPath.startsWith("'") &&
+ potentialPath.endsWith("'")
+ ) {
+ potentialPath = ch.slice(1, -1);
+ }
+
+ potentialPath = potentialPath.trim();
+ if (isValidPath(unescapePath(potentialPath))) {
+ ch = `@${potentialPath}`;
+ }
+ }
+
+ let currentText = '';
+ for (const char of toCodePoints(ch)) {
+ if (char.codePointAt(0) === 127) {
+ if (currentText.length > 0) {
+ dispatch({ type: 'insert', payload: currentText });
+ currentText = '';
+ }
+ dispatch({ type: 'backspace' });
+ } else {
+ currentText += char;
+ }
+ }
+ if (currentText.length > 0) {
+ dispatch({ type: 'insert', payload: currentText });
+ }
},
- [
- visualCursor,
- visualLines,
- preferredCol,
- lines,
- currentLineLen,
- visualToLogicalMap,
- logicalToVisualMap,
- cursorCol,
- cursorRow,
- ],
+ [isValidPath],
);
+ const newline = useCallback((): void => {
+ dispatch({ type: 'insert', payload: '\n' });
+ }, []);
+
+ const backspace = useCallback((): void => {
+ dispatch({ type: 'backspace' });
+ }, []);
+
+ const del = useCallback((): void => {
+ dispatch({ type: 'delete' });
+ }, []);
+
+ const move = useCallback((dir: Direction): void => {
+ dispatch({ type: 'move', payload: { dir } });
+ }, []);
+
+ const undo = useCallback((): void => {
+ dispatch({ type: 'undo' });
+ }, []);
+
+ const redo = useCallback((): void => {
+ dispatch({ type: 'redo' });
+ }, []);
+
+ const setText = useCallback((newText: string): void => {
+ dispatch({ type: 'set_text', payload: newText });
+ }, []);
+
+ const deleteWordLeft = useCallback((): void => {
+ dispatch({ type: 'delete_word_left' });
+ }, []);
+
+ const deleteWordRight = useCallback((): void => {
+ dispatch({ type: 'delete_word_right' });
+ }, []);
+
+ const killLineRight = useCallback((): void => {
+ dispatch({ type: 'kill_line_right' });
+ }, []);
+
+ const killLineLeft = useCallback((): void => {
+ dispatch({ type: 'kill_line_left' });
+ }, []);
+
const openInExternalEditor = useCallback(
async (opts: { editor?: string } = {}): Promise<void> => {
const editor =
@@ -1147,7 +1118,7 @@ export function useTextBuffer({
const filePath = pathMod.join(tmpDir, 'buffer.txt');
fs.writeFileSync(filePath, text, 'utf8');
- pushUndo(); // Snapshot before external edit
+ dispatch({ type: 'create_undo_snapshot' });
const wasRaw = stdin?.isRaw ?? false;
try {
@@ -1161,10 +1132,9 @@ export function useTextBuffer({
let newText = fs.readFileSync(filePath, 'utf8');
newText = newText.replace(/\r\n?/g, '\n');
- setText(newText);
+ dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
} catch (err) {
console.error('[useTextBuffer] external editor error', err);
- // TODO(jacobr): potentially revert or handle error state.
} finally {
if (wasRaw) setRawMode?.(true);
try {
@@ -1179,7 +1149,7 @@ export function useTextBuffer({
}
}
},
- [text, pushUndo, stdin, setRawMode, setText],
+ [text, stdin, setRawMode],
);
const handleInput = useCallback(
@@ -1190,18 +1160,8 @@ export function useTextBuffer({
shift: boolean;
paste: boolean;
sequence: string;
- }): boolean => {
+ }): void => {
const { sequence: input } = key;
- dbg('handleInput', {
- key,
- cursor: [cursorRow, cursorCol],
- visualCursor,
- });
- const beforeText = text;
- const beforeLogicalCursor = [cursorRow, cursorCol];
- const beforeVisualCursor = [...visualCursor];
-
- if (key.name === 'escape') return false;
if (
key.name === 'return' ||
@@ -1243,37 +1203,8 @@ export function useTextBuffer({
else if (input && !key.ctrl && !key.meta) {
insert(input);
}
-
- const textChanged = text !== beforeText;
- // After operations, visualCursor might not be immediately updated if the change
- // was to `lines`, `cursorRow`, or `cursorCol` which then triggers the useEffect.
- // So, for return value, we check logical cursor change.
- const cursorChanged =
- cursorRow !== beforeLogicalCursor[0] ||
- cursorCol !== beforeLogicalCursor[1] ||
- visualCursor[0] !== beforeVisualCursor[0] ||
- visualCursor[1] !== beforeVisualCursor[1];
-
- dbg('handleInput:after', {
- cursor: [cursorRow, cursorCol],
- visualCursor,
- text,
- });
- return textChanged || cursorChanged;
},
- [
- text,
- cursorRow,
- cursorCol,
- visualCursor,
- newline,
- move,
- deleteWordLeft,
- deleteWordRight,
- backspace,
- del,
- insert,
- ],
+ [newline, move, deleteWordLeft, deleteWordRight, backspace, del, insert],
);
const renderedVisualLines = useMemo(
@@ -1281,30 +1212,34 @@ export function useTextBuffer({
[visualLines, visualScrollRow, viewport.height],
);
- const replaceRangeByOffset = useCallback(
+ const replaceRange = useCallback(
(
- startOffset: number,
- endOffset: number,
- replacementText: string,
- ): boolean => {
- dbg('replaceRangeByOffset', { startOffset, endOffset, replacementText });
+ startRow: number,
+ startCol: number,
+ endRow: number,
+ endCol: number,
+ text: string,
+ ): void => {
+ dispatch({
+ type: 'replace_range',
+ payload: { startRow, startCol, endRow, endCol, text },
+ });
+ },
+ [],
+ );
+
+ const replaceRangeByOffset = useCallback(
+ (startOffset: number, endOffset: number, replacementText: string): void => {
const [startRow, startCol] = offsetToLogicalPos(text, startOffset);
const [endRow, endCol] = offsetToLogicalPos(text, endOffset);
- return replaceRange(startRow, startCol, endRow, endCol, replacementText);
+ replaceRange(startRow, startCol, endRow, endCol, replacementText);
},
[text, replaceRange],
);
- const moveToOffset = useCallback(
- (offset: number): void => {
- const [newRow, newCol] = offsetToLogicalPos(text, offset);
- setCursorRow(newRow);
- setCursorCol(newCol);
- setPreferredCol(null);
- dbg('moveToOffset', { offset, newCursor: [newRow, newCol] });
- },
- [text, setPreferredCol],
- );
+ const moveToOffset = useCallback((offset: number): void => {
+ dispatch({ type: 'move_to_offset', payload: { offset } });
+ }, []);
const returnValue: TextBuffer = {
lines,
@@ -1328,45 +1263,13 @@ export function useTextBuffer({
redo,
replaceRange,
replaceRangeByOffset,
- moveToOffset, // Added here
+ moveToOffset,
deleteWordLeft,
deleteWordRight,
killLineRight,
killLineLeft,
handleInput,
openInExternalEditor,
-
- applyOperations,
-
- copy: useCallback(() => {
- if (!selectionAnchor) return null;
- const [ar, ac] = selectionAnchor;
- const [br, bc] = [cursorRow, cursorCol];
- if (ar === br && ac === bc) return null;
- const topBefore = ar < br || (ar === br && ac < bc);
- const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
-
- let selectedTextVal;
- if (sr === er) {
- selectedTextVal = cpSlice(currentLine(sr), sc, ec);
- } else {
- const parts: string[] = [cpSlice(currentLine(sr), sc)];
- for (let r = sr + 1; r < er; r++) parts.push(currentLine(r));
- parts.push(cpSlice(currentLine(er), 0, ec));
- selectedTextVal = parts.join('\n');
- }
- setClipboard(selectedTextVal);
- return selectedTextVal;
- }, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
- paste: useCallback(() => {
- if (clipboard === null) return false;
- applyOperations([{ type: 'insert', payload: clipboard }]);
- return true;
- }, [clipboard, applyOperations]),
- startSelection: useCallback(
- () => setSelectionAnchor([cursorRow, cursorCol]),
- [cursorRow, cursorCol, setSelectionAnchor],
- ),
};
return returnValue;
}
@@ -1406,8 +1309,8 @@ export interface TextBuffer {
backspace: () => void;
del: () => void;
move: (dir: Direction) => void;
- undo: () => boolean;
- redo: () => boolean;
+ undo: () => void;
+ redo: () => void;
/**
* Replaces the text within the specified range with new text.
* Handles both single-line and multi-line ranges.
@@ -1425,7 +1328,7 @@ export interface TextBuffer {
endRow: number,
endCol: number,
text: string,
- ) => boolean;
+ ) => void;
/**
* Delete the word to the *left* of the caret, mirroring common
* Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
@@ -1457,7 +1360,7 @@ export interface TextBuffer {
shift: boolean;
paste: boolean;
sequence: string;
- }) => boolean;
+ }) => void;
/**
* Opens the current buffer contents in the user's preferred terminal text
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
@@ -1475,17 +1378,10 @@ export interface TextBuffer {
*/
openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;
- // Selection & Clipboard
- copy: () => string | null;
- paste: () => boolean;
- startSelection: () => void;
replaceRangeByOffset: (
startOffset: number,
endOffset: number,
replacementText: string,
- ) => boolean;
+ ) => void;
moveToOffset(offset: number): void;
-
- // Batch updates
- applyOperations: (ops: UpdateOperation[]) => void;
}