From 32db5ba0e1b7628fa6714bea8532377641b1af18 Mon Sep 17 00:00:00 2001 From: Sandy Tao Date: Thu, 3 Jul 2025 17:53:17 -0700 Subject: [PATCH] Refactor text-buffer to use reducer (#2652) --- .../ui/components/shared/text-buffer.test.ts | 192 ++- .../src/ui/components/shared/text-buffer.ts | 1446 ++++++++--------- 2 files changed, 842 insertions(+), 796 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(() => { - const l = initialText.split('\n'); - return l.length === 0 ? [''] : l; - }); +// --- Start of reducer logic --- - const [[initialCursorRow, initialCursorCol]] = useState(() => - calculateInitialCursorPosition(lines, initialCursorOffset), - ); +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; +} - const [cursorRow, setCursorRow] = useState(initialCursorRow); - const [cursorCol, setCursorCol] = useState(initialCursorCol); - const [preferredCol, setPreferredCol] = useState(null); // Visual preferred col +const historyLimit = 100; - const [undoStack, setUndoStack] = useState([]); - const [redoStack, setRedoStack] = useState([]); - const historyLimit = 100; - const [opQueue, setOpQueue] = useState([]); - - const [clipboard, setClipboard] = useState(null); - const [selectionAnchor, setSelectionAnchor] = useState< - [number, number] | null - >(null); // Logical selection - - // Visual state - const [visualLines, setVisualLines] = useState(['']); - const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]); - const [visualScrollRow, setVisualScrollRow] = useState(0); - const [logicalToVisualMap, setLogicalToVisualMap] = useState< - Array> - >([]); - 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], - ); - - // 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]); - - // 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; +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]); + | { type: 'move_to_offset'; payload: { offset: number } } + | { type: 'create_undo_snapshot' } + | { type: 'set_viewport_width'; payload: number }; - 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]); - - 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); - } - }, [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]); - }, []); - - useEffect(() => { - if (opQueue.length === 0) return; - - 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); +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(); + } + return { ...currentState, undoStack: newStack, redoStack: [] }; + }; + + const currentLine = (r: number): string => state.lines[r] ?? ''; + const currentLineLen = (r: number): number => cpLen(currentLine(r)); + + 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; - } + case 'insert': { + const nextState = pushUndo(state); + const newLines = [...nextState.lines]; + let newCursorRow = nextState.cursorRow; + let newCursorCol = nextState.cursorCol; - pushUndo(); // Snapshot before applying batch of updates + const currentLine = (r: number) => newLines[r] ?? ''; - const newLines = [...lines]; - let newCursorRow = cursorRow; - let newCursorCol = cursorCol; + 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); - const currentLine = (r: number) => newLines[r] ?? ''; - - 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'); + newCursorRow = newCursorRow + parts.length - 1; + newCursorCol = cpLen(lastPartOriginal); + } else { + newLines[newCursorRow] = before + parts[0] + after; + newCursorCol = cpLen(before) + cpLen(parts[0]); + } + + return { + ...nextState, + lines: newLines, + cursorRow: newCursorRow, + cursorCol: newCursorCol, + preferredCol: null, + }; + } + + case 'backspace': { + const nextState = pushUndo(state); + const newLines = [...nextState.lines]; + let newCursorRow = nextState.cursorRow; + let newCursorCol = nextState.cursorCol; + + const currentLine = (r: number) => newLines[r] ?? ''; + + if (newCursorCol === 0 && newCursorRow === 0) return state; + + if (newCursorCol > 0) { 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; - } + 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; } + + return { + ...nextState, + lines: newLines, + cursorRow: newCursorRow, + cursorCol: newCursorCol, + preferredCol: null, + }; } - 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}`; - } + case 'set_viewport_width': { + if (action.payload === state.viewportWidth) { + return state; } - 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 { ...state, viewportWidth: action.payload }; } - // 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 'move': { + const { dir } = action.payload; + const { lines, cursorRow, cursorCol, viewportWidth } = state; + const visualLayout = calculateVisualLayout( + lines, + [cursorRow, cursorCol], + viewportWidth, + ); + const { visualLines, visualCursor, visualToLogicalMap } = visualLayout; - 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'); - - 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)); - } - - // 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 (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; - } - 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(); - } - // 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]); - - 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; - // 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; + let newCursorRow = cursorRow; + let newCursorCol = cursorCol; + + 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; + } + + 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 } - dbg('move', { - dir, - visualBefore: visualCursor, - visualAfter: [newVisualRow, newVisualCol], - logicalAfter: [cursorRow, cursorCol], - }); - }, - [ - visualCursor, - visualLines, - preferredCol, - lines, - currentLineLen, - visualToLogicalMap, - logicalToVisualMap, - cursorCol, - cursorRow, - ], + 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(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 }); + } + }, + [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 => { 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; - // 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; }