@google/gemini-cli 0.1.8-rc.1 → 0.1.9-nightly.20250704.b1fbe7b0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,7 +8,7 @@ import { spawnSync } from 'child_process';
8
8
  import fs from 'fs';
9
9
  import os from 'os';
10
10
  import pathMod from 'path';
11
- import { useState, useCallback, useEffect, useMemo } from 'react';
11
+ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
12
12
  import stringWidth from 'string-width';
13
13
  import { unescapePath } from '@google/gemini-cli-core';
14
14
  import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
@@ -43,17 +43,6 @@ function stripUnsafeCharacters(str) {
43
43
  function clamp(v, min, max) {
44
44
  return v < min ? min : v > max ? max : v;
45
45
  }
46
- /* -------------------------------------------------------------------------
47
- * Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
48
- * ---------------------------------------------------------------------- */
49
- // Enable verbose logging only when requested via env var.
50
- const DEBUG = process.env['TEXTBUFFER_DEBUG'] === '1' ||
51
- process.env['TEXTBUFFER_DEBUG'] === 'true';
52
- function dbg(...args) {
53
- if (DEBUG) {
54
- console.log('[TextBuffer]', ...args);
55
- }
56
- }
57
46
  function calculateInitialCursorPosition(initialLines, offset) {
58
47
  let remainingChars = offset;
59
48
  let row = 0;
@@ -289,36 +278,512 @@ function calculateVisualLayout(logicalLines, logicalCursor, viewportWidth) {
289
278
  visualToLogicalMap,
290
279
  };
291
280
  }
281
+ const historyLimit = 100;
282
+ export function textBufferReducer(state, action) {
283
+ const pushUndo = (currentState) => {
284
+ const snapshot = {
285
+ lines: [...currentState.lines],
286
+ cursorRow: currentState.cursorRow,
287
+ cursorCol: currentState.cursorCol,
288
+ };
289
+ const newStack = [...currentState.undoStack, snapshot];
290
+ if (newStack.length > historyLimit) {
291
+ newStack.shift();
292
+ }
293
+ return { ...currentState, undoStack: newStack, redoStack: [] };
294
+ };
295
+ const currentLine = (r) => state.lines[r] ?? '';
296
+ const currentLineLen = (r) => cpLen(currentLine(r));
297
+ switch (action.type) {
298
+ case 'set_text': {
299
+ let nextState = state;
300
+ if (action.pushToUndo !== false) {
301
+ nextState = pushUndo(state);
302
+ }
303
+ const newContentLines = action.payload
304
+ .replace(/\r\n?/g, '\n')
305
+ .split('\n');
306
+ const lines = newContentLines.length === 0 ? [''] : newContentLines;
307
+ const lastNewLineIndex = lines.length - 1;
308
+ return {
309
+ ...nextState,
310
+ lines,
311
+ cursorRow: lastNewLineIndex,
312
+ cursorCol: cpLen(lines[lastNewLineIndex] ?? ''),
313
+ preferredCol: null,
314
+ };
315
+ }
316
+ case 'insert': {
317
+ const nextState = pushUndo(state);
318
+ const newLines = [...nextState.lines];
319
+ let newCursorRow = nextState.cursorRow;
320
+ let newCursorCol = nextState.cursorCol;
321
+ const currentLine = (r) => newLines[r] ?? '';
322
+ const str = stripUnsafeCharacters(action.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
323
+ const parts = str.split('\n');
324
+ const lineContent = currentLine(newCursorRow);
325
+ const before = cpSlice(lineContent, 0, newCursorCol);
326
+ const after = cpSlice(lineContent, newCursorCol);
327
+ if (parts.length > 1) {
328
+ newLines[newCursorRow] = before + parts[0];
329
+ const remainingParts = parts.slice(1);
330
+ const lastPartOriginal = remainingParts.pop() ?? '';
331
+ newLines.splice(newCursorRow + 1, 0, ...remainingParts);
332
+ newLines.splice(newCursorRow + parts.length - 1, 0, lastPartOriginal + after);
333
+ newCursorRow = newCursorRow + parts.length - 1;
334
+ newCursorCol = cpLen(lastPartOriginal);
335
+ }
336
+ else {
337
+ newLines[newCursorRow] = before + parts[0] + after;
338
+ newCursorCol = cpLen(before) + cpLen(parts[0]);
339
+ }
340
+ return {
341
+ ...nextState,
342
+ lines: newLines,
343
+ cursorRow: newCursorRow,
344
+ cursorCol: newCursorCol,
345
+ preferredCol: null,
346
+ };
347
+ }
348
+ case 'backspace': {
349
+ const nextState = pushUndo(state);
350
+ const newLines = [...nextState.lines];
351
+ let newCursorRow = nextState.cursorRow;
352
+ let newCursorCol = nextState.cursorCol;
353
+ const currentLine = (r) => newLines[r] ?? '';
354
+ if (newCursorCol === 0 && newCursorRow === 0)
355
+ return state;
356
+ if (newCursorCol > 0) {
357
+ const lineContent = currentLine(newCursorRow);
358
+ newLines[newCursorRow] =
359
+ cpSlice(lineContent, 0, newCursorCol - 1) +
360
+ cpSlice(lineContent, newCursorCol);
361
+ newCursorCol--;
362
+ }
363
+ else if (newCursorRow > 0) {
364
+ const prevLineContent = currentLine(newCursorRow - 1);
365
+ const currentLineContentVal = currentLine(newCursorRow);
366
+ const newCol = cpLen(prevLineContent);
367
+ newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
368
+ newLines.splice(newCursorRow, 1);
369
+ newCursorRow--;
370
+ newCursorCol = newCol;
371
+ }
372
+ return {
373
+ ...nextState,
374
+ lines: newLines,
375
+ cursorRow: newCursorRow,
376
+ cursorCol: newCursorCol,
377
+ preferredCol: null,
378
+ };
379
+ }
380
+ case 'set_viewport_width': {
381
+ if (action.payload === state.viewportWidth) {
382
+ return state;
383
+ }
384
+ return { ...state, viewportWidth: action.payload };
385
+ }
386
+ case 'move': {
387
+ const { dir } = action.payload;
388
+ const { lines, cursorRow, cursorCol, viewportWidth } = state;
389
+ const visualLayout = calculateVisualLayout(lines, [cursorRow, cursorCol], viewportWidth);
390
+ const { visualLines, visualCursor, visualToLogicalMap } = visualLayout;
391
+ let newVisualRow = visualCursor[0];
392
+ let newVisualCol = visualCursor[1];
393
+ let newPreferredCol = state.preferredCol;
394
+ const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
395
+ switch (dir) {
396
+ case 'left':
397
+ newPreferredCol = null;
398
+ if (newVisualCol > 0) {
399
+ newVisualCol--;
400
+ }
401
+ else if (newVisualRow > 0) {
402
+ newVisualRow--;
403
+ newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
404
+ }
405
+ break;
406
+ case 'right':
407
+ newPreferredCol = null;
408
+ if (newVisualCol < currentVisLineLen) {
409
+ newVisualCol++;
410
+ }
411
+ else if (newVisualRow < visualLines.length - 1) {
412
+ newVisualRow++;
413
+ newVisualCol = 0;
414
+ }
415
+ break;
416
+ case 'up':
417
+ if (newVisualRow > 0) {
418
+ if (newPreferredCol === null)
419
+ newPreferredCol = newVisualCol;
420
+ newVisualRow--;
421
+ newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
422
+ }
423
+ break;
424
+ case 'down':
425
+ if (newVisualRow < visualLines.length - 1) {
426
+ if (newPreferredCol === null)
427
+ newPreferredCol = newVisualCol;
428
+ newVisualRow++;
429
+ newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
430
+ }
431
+ break;
432
+ case 'home':
433
+ newPreferredCol = null;
434
+ newVisualCol = 0;
435
+ break;
436
+ case 'end':
437
+ newPreferredCol = null;
438
+ newVisualCol = currentVisLineLen;
439
+ break;
440
+ case 'wordLeft': {
441
+ const { cursorRow, cursorCol, lines } = state;
442
+ if (cursorCol === 0 && cursorRow === 0)
443
+ return state;
444
+ let newCursorRow = cursorRow;
445
+ let newCursorCol = cursorCol;
446
+ if (cursorCol === 0) {
447
+ newCursorRow--;
448
+ newCursorCol = cpLen(lines[newCursorRow] ?? '');
449
+ }
450
+ else {
451
+ const lineContent = lines[cursorRow];
452
+ const arr = toCodePoints(lineContent);
453
+ let start = cursorCol;
454
+ let onlySpaces = true;
455
+ for (let i = 0; i < start; i++) {
456
+ if (isWordChar(arr[i])) {
457
+ onlySpaces = false;
458
+ break;
459
+ }
460
+ }
461
+ if (onlySpaces && start > 0) {
462
+ start--;
463
+ }
464
+ else {
465
+ while (start > 0 && !isWordChar(arr[start - 1]))
466
+ start--;
467
+ while (start > 0 && isWordChar(arr[start - 1]))
468
+ start--;
469
+ }
470
+ newCursorCol = start;
471
+ }
472
+ return {
473
+ ...state,
474
+ cursorRow: newCursorRow,
475
+ cursorCol: newCursorCol,
476
+ preferredCol: null,
477
+ };
478
+ }
479
+ case 'wordRight': {
480
+ const { cursorRow, cursorCol, lines } = state;
481
+ if (cursorRow === lines.length - 1 &&
482
+ cursorCol === cpLen(lines[cursorRow] ?? '')) {
483
+ return state;
484
+ }
485
+ let newCursorRow = cursorRow;
486
+ let newCursorCol = cursorCol;
487
+ const lineContent = lines[cursorRow] ?? '';
488
+ const arr = toCodePoints(lineContent);
489
+ if (cursorCol >= arr.length) {
490
+ newCursorRow++;
491
+ newCursorCol = 0;
492
+ }
493
+ else {
494
+ let end = cursorCol;
495
+ while (end < arr.length && !isWordChar(arr[end]))
496
+ end++;
497
+ while (end < arr.length && isWordChar(arr[end]))
498
+ end++;
499
+ newCursorCol = end;
500
+ }
501
+ return {
502
+ ...state,
503
+ cursorRow: newCursorRow,
504
+ cursorCol: newCursorCol,
505
+ preferredCol: null,
506
+ };
507
+ }
508
+ default:
509
+ break;
510
+ }
511
+ if (visualToLogicalMap[newVisualRow]) {
512
+ const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
513
+ return {
514
+ ...state,
515
+ cursorRow: logRow,
516
+ cursorCol: clamp(logStartCol + newVisualCol, 0, cpLen(state.lines[logRow] ?? '')),
517
+ preferredCol: newPreferredCol,
518
+ };
519
+ }
520
+ return state;
521
+ }
522
+ case 'delete': {
523
+ const { cursorRow, cursorCol, lines } = state;
524
+ const lineContent = currentLine(cursorRow);
525
+ if (cursorCol < currentLineLen(cursorRow)) {
526
+ const nextState = pushUndo(state);
527
+ const newLines = [...nextState.lines];
528
+ newLines[cursorRow] =
529
+ cpSlice(lineContent, 0, cursorCol) +
530
+ cpSlice(lineContent, cursorCol + 1);
531
+ return { ...nextState, lines: newLines, preferredCol: null };
532
+ }
533
+ else if (cursorRow < lines.length - 1) {
534
+ const nextState = pushUndo(state);
535
+ const nextLineContent = currentLine(cursorRow + 1);
536
+ const newLines = [...nextState.lines];
537
+ newLines[cursorRow] = lineContent + nextLineContent;
538
+ newLines.splice(cursorRow + 1, 1);
539
+ return { ...nextState, lines: newLines, preferredCol: null };
540
+ }
541
+ return state;
542
+ }
543
+ case 'delete_word_left': {
544
+ const { cursorRow, cursorCol } = state;
545
+ if (cursorCol === 0 && cursorRow === 0)
546
+ return state;
547
+ if (cursorCol === 0) {
548
+ // Act as a backspace
549
+ const nextState = pushUndo(state);
550
+ const prevLineContent = currentLine(cursorRow - 1);
551
+ const currentLineContentVal = currentLine(cursorRow);
552
+ const newCol = cpLen(prevLineContent);
553
+ const newLines = [...nextState.lines];
554
+ newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
555
+ newLines.splice(cursorRow, 1);
556
+ return {
557
+ ...nextState,
558
+ lines: newLines,
559
+ cursorRow: cursorRow - 1,
560
+ cursorCol: newCol,
561
+ preferredCol: null,
562
+ };
563
+ }
564
+ const nextState = pushUndo(state);
565
+ const lineContent = currentLine(cursorRow);
566
+ const arr = toCodePoints(lineContent);
567
+ let start = cursorCol;
568
+ let onlySpaces = true;
569
+ for (let i = 0; i < start; i++) {
570
+ if (isWordChar(arr[i])) {
571
+ onlySpaces = false;
572
+ break;
573
+ }
574
+ }
575
+ if (onlySpaces && start > 0) {
576
+ start--;
577
+ }
578
+ else {
579
+ while (start > 0 && !isWordChar(arr[start - 1]))
580
+ start--;
581
+ while (start > 0 && isWordChar(arr[start - 1]))
582
+ start--;
583
+ }
584
+ const newLines = [...nextState.lines];
585
+ newLines[cursorRow] =
586
+ cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
587
+ return {
588
+ ...nextState,
589
+ lines: newLines,
590
+ cursorCol: start,
591
+ preferredCol: null,
592
+ };
593
+ }
594
+ case 'delete_word_right': {
595
+ const { cursorRow, cursorCol, lines } = state;
596
+ const lineContent = currentLine(cursorRow);
597
+ const arr = toCodePoints(lineContent);
598
+ if (cursorCol >= arr.length && cursorRow === lines.length - 1)
599
+ return state;
600
+ if (cursorCol >= arr.length) {
601
+ // Act as a delete
602
+ const nextState = pushUndo(state);
603
+ const nextLineContent = currentLine(cursorRow + 1);
604
+ const newLines = [...nextState.lines];
605
+ newLines[cursorRow] = lineContent + nextLineContent;
606
+ newLines.splice(cursorRow + 1, 1);
607
+ return { ...nextState, lines: newLines, preferredCol: null };
608
+ }
609
+ const nextState = pushUndo(state);
610
+ let end = cursorCol;
611
+ while (end < arr.length && !isWordChar(arr[end]))
612
+ end++;
613
+ while (end < arr.length && isWordChar(arr[end]))
614
+ end++;
615
+ const newLines = [...nextState.lines];
616
+ newLines[cursorRow] =
617
+ cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
618
+ return { ...nextState, lines: newLines, preferredCol: null };
619
+ }
620
+ case 'kill_line_right': {
621
+ const { cursorRow, cursorCol, lines } = state;
622
+ const lineContent = currentLine(cursorRow);
623
+ if (cursorCol < currentLineLen(cursorRow)) {
624
+ const nextState = pushUndo(state);
625
+ const newLines = [...nextState.lines];
626
+ newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
627
+ return { ...nextState, lines: newLines };
628
+ }
629
+ else if (cursorRow < lines.length - 1) {
630
+ // Act as a delete
631
+ const nextState = pushUndo(state);
632
+ const nextLineContent = currentLine(cursorRow + 1);
633
+ const newLines = [...nextState.lines];
634
+ newLines[cursorRow] = lineContent + nextLineContent;
635
+ newLines.splice(cursorRow + 1, 1);
636
+ return { ...nextState, lines: newLines, preferredCol: null };
637
+ }
638
+ return state;
639
+ }
640
+ case 'kill_line_left': {
641
+ const { cursorRow, cursorCol } = state;
642
+ if (cursorCol > 0) {
643
+ const nextState = pushUndo(state);
644
+ const lineContent = currentLine(cursorRow);
645
+ const newLines = [...nextState.lines];
646
+ newLines[cursorRow] = cpSlice(lineContent, cursorCol);
647
+ return {
648
+ ...nextState,
649
+ lines: newLines,
650
+ cursorCol: 0,
651
+ preferredCol: null,
652
+ };
653
+ }
654
+ return state;
655
+ }
656
+ case 'undo': {
657
+ const stateToRestore = state.undoStack[state.undoStack.length - 1];
658
+ if (!stateToRestore)
659
+ return state;
660
+ const currentSnapshot = {
661
+ lines: [...state.lines],
662
+ cursorRow: state.cursorRow,
663
+ cursorCol: state.cursorCol,
664
+ };
665
+ return {
666
+ ...state,
667
+ ...stateToRestore,
668
+ undoStack: state.undoStack.slice(0, -1),
669
+ redoStack: [...state.redoStack, currentSnapshot],
670
+ };
671
+ }
672
+ case 'redo': {
673
+ const stateToRestore = state.redoStack[state.redoStack.length - 1];
674
+ if (!stateToRestore)
675
+ return state;
676
+ const currentSnapshot = {
677
+ lines: [...state.lines],
678
+ cursorRow: state.cursorRow,
679
+ cursorCol: state.cursorCol,
680
+ };
681
+ return {
682
+ ...state,
683
+ ...stateToRestore,
684
+ redoStack: state.redoStack.slice(0, -1),
685
+ undoStack: [...state.undoStack, currentSnapshot],
686
+ };
687
+ }
688
+ case 'replace_range': {
689
+ const { startRow, startCol, endRow, endCol, text } = action.payload;
690
+ if (startRow > endRow ||
691
+ (startRow === endRow && startCol > endCol) ||
692
+ startRow < 0 ||
693
+ startCol < 0 ||
694
+ endRow >= state.lines.length ||
695
+ (endRow < state.lines.length && endCol > currentLineLen(endRow))) {
696
+ return state; // Invalid range
697
+ }
698
+ const nextState = pushUndo(state);
699
+ const newLines = [...nextState.lines];
700
+ const sCol = clamp(startCol, 0, currentLineLen(startRow));
701
+ const eCol = clamp(endCol, 0, currentLineLen(endRow));
702
+ const prefix = cpSlice(currentLine(startRow), 0, sCol);
703
+ const suffix = cpSlice(currentLine(endRow), eCol);
704
+ const normalisedReplacement = text
705
+ .replace(/\r\n/g, '\n')
706
+ .replace(/\r/g, '\n');
707
+ const replacementParts = normalisedReplacement.split('\n');
708
+ // Replace the content
709
+ if (startRow === endRow) {
710
+ newLines[startRow] = prefix + normalisedReplacement + suffix;
711
+ }
712
+ else {
713
+ const firstLine = prefix + replacementParts[0];
714
+ if (replacementParts.length === 1) {
715
+ // Single line of replacement text, but spanning multiple original lines
716
+ newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
717
+ }
718
+ else {
719
+ // Multi-line replacement text
720
+ const lastLine = replacementParts[replacementParts.length - 1] + suffix;
721
+ const middleLines = replacementParts.slice(1, -1);
722
+ newLines.splice(startRow, endRow - startRow + 1, firstLine, ...middleLines, lastLine);
723
+ }
724
+ }
725
+ const finalCursorRow = startRow + replacementParts.length - 1;
726
+ const finalCursorCol = (replacementParts.length > 1 ? 0 : sCol) +
727
+ cpLen(replacementParts[replacementParts.length - 1]);
728
+ return {
729
+ ...nextState,
730
+ lines: newLines,
731
+ cursorRow: finalCursorRow,
732
+ cursorCol: finalCursorCol,
733
+ preferredCol: null,
734
+ };
735
+ }
736
+ case 'move_to_offset': {
737
+ const { offset } = action.payload;
738
+ const [newRow, newCol] = offsetToLogicalPos(state.lines.join('\n'), offset);
739
+ return {
740
+ ...state,
741
+ cursorRow: newRow,
742
+ cursorCol: newCol,
743
+ preferredCol: null,
744
+ };
745
+ }
746
+ case 'create_undo_snapshot': {
747
+ return pushUndo(state);
748
+ }
749
+ default: {
750
+ const exhaustiveCheck = action;
751
+ console.error(`Unknown action encountered: ${exhaustiveCheck}`);
752
+ return state;
753
+ }
754
+ }
755
+ }
756
+ // --- End of reducer logic ---
292
757
  export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewport, stdin, setRawMode, onChange, isValidPath, }) {
293
- const [lines, setLines] = useState(() => {
294
- const l = initialText.split('\n');
295
- return l.length === 0 ? [''] : l;
296
- });
297
- const [[initialCursorRow, initialCursorCol]] = useState(() => calculateInitialCursorPosition(lines, initialCursorOffset));
298
- const [cursorRow, setCursorRow] = useState(initialCursorRow);
299
- const [cursorCol, setCursorCol] = useState(initialCursorCol);
300
- const [preferredCol, setPreferredCol] = useState(null); // Visual preferred col
301
- const [undoStack, setUndoStack] = useState([]);
302
- const [redoStack, setRedoStack] = useState([]);
303
- const historyLimit = 100;
304
- const [clipboard, setClipboard] = useState(null);
305
- const [selectionAnchor, setSelectionAnchor] = useState(null); // Logical selection
306
- // Visual state
307
- const [visualLines, setVisualLines] = useState(['']);
308
- const [visualCursor, setVisualCursor] = useState([0, 0]);
758
+ const initialState = useMemo(() => {
759
+ const lines = initialText.split('\n');
760
+ const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(lines.length === 0 ? [''] : lines, initialCursorOffset);
761
+ return {
762
+ lines: lines.length === 0 ? [''] : lines,
763
+ cursorRow: initialCursorRow,
764
+ cursorCol: initialCursorCol,
765
+ preferredCol: null,
766
+ undoStack: [],
767
+ redoStack: [],
768
+ clipboard: null,
769
+ selectionAnchor: null,
770
+ viewportWidth: viewport.width,
771
+ };
772
+ }, [initialText, initialCursorOffset, viewport.width]);
773
+ const [state, dispatch] = useReducer(textBufferReducer, initialState);
774
+ const { lines, cursorRow, cursorCol, preferredCol, selectionAnchor } = state;
775
+ const text = useMemo(() => lines.join('\n'), [lines]);
776
+ const visualLayout = useMemo(() => calculateVisualLayout(lines, [cursorRow, cursorCol], state.viewportWidth), [lines, cursorRow, cursorCol, state.viewportWidth]);
777
+ const { visualLines, visualCursor } = visualLayout;
309
778
  const [visualScrollRow, setVisualScrollRow] = useState(0);
310
- const [logicalToVisualMap, setLogicalToVisualMap] = useState([]);
311
- const [visualToLogicalMap, setVisualToLogicalMap] = useState([]);
312
- const currentLine = useCallback((r) => lines[r] ?? '', [lines]);
313
- const currentLineLen = useCallback((r) => cpLen(currentLine(r)), [currentLine]);
314
- // Recalculate visual layout whenever logical lines or viewport width changes
315
779
  useEffect(() => {
316
- const layout = calculateVisualLayout(lines, [cursorRow, cursorCol], viewport.width);
317
- setVisualLines(layout.visualLines);
318
- setVisualCursor(layout.visualCursor);
319
- setLogicalToVisualMap(layout.logicalToVisualMap);
320
- setVisualToLogicalMap(layout.visualToLogicalMap);
321
- }, [lines, cursorRow, cursorCol, viewport.width]);
780
+ if (onChange) {
781
+ onChange(text);
782
+ }
783
+ }, [text, onChange]);
784
+ useEffect(() => {
785
+ dispatch({ type: 'set_viewport_width', payload: viewport.width });
786
+ }, [viewport.width]);
322
787
  // Update visual scroll (vertical)
323
788
  useEffect(() => {
324
789
  const { height } = viewport;
@@ -333,175 +798,13 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
333
798
  setVisualScrollRow(newVisualScrollRow);
334
799
  }
335
800
  }, [visualCursor, visualScrollRow, viewport]);
336
- const pushUndo = useCallback(() => {
337
- dbg('pushUndo', { cursor: [cursorRow, cursorCol], text: lines.join('\n') });
338
- const snapshot = { lines: [...lines], cursorRow, cursorCol };
339
- setUndoStack((prev) => {
340
- const newStack = [...prev, snapshot];
341
- if (newStack.length > historyLimit) {
342
- newStack.shift();
343
- }
344
- return newStack;
345
- });
346
- setRedoStack([]);
347
- }, [lines, cursorRow, cursorCol, historyLimit]);
348
- const _restoreState = useCallback((state) => {
349
- if (!state)
350
- return false;
351
- setLines(state.lines);
352
- setCursorRow(state.cursorRow);
353
- setCursorCol(state.cursorCol);
354
- return true;
355
- }, []);
356
- const text = lines.join('\n');
357
- useEffect(() => {
358
- if (onChange) {
359
- onChange(text);
360
- }
361
- }, [text, onChange]);
362
- const undo = useCallback(() => {
363
- const state = undoStack[undoStack.length - 1];
364
- if (!state)
365
- return false;
366
- setUndoStack((prev) => prev.slice(0, -1));
367
- const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
368
- setRedoStack((prev) => [...prev, currentSnapshot]);
369
- return _restoreState(state);
370
- }, [undoStack, lines, cursorRow, cursorCol, _restoreState]);
371
- const redo = useCallback(() => {
372
- const state = redoStack[redoStack.length - 1];
373
- if (!state)
374
- return false;
375
- setRedoStack((prev) => prev.slice(0, -1));
376
- const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
377
- setUndoStack((prev) => [...prev, currentSnapshot]);
378
- return _restoreState(state);
379
- }, [redoStack, lines, cursorRow, cursorCol, _restoreState]);
380
- const insertStr = useCallback((str) => {
381
- dbg('insertStr', { str, beforeCursor: [cursorRow, cursorCol] });
382
- if (str === '')
383
- return false;
384
- pushUndo();
385
- let normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
386
- normalised = stripUnsafeCharacters(normalised);
387
- const parts = normalised.split('\n');
388
- const newLines = [...lines];
389
- const lineContent = currentLine(cursorRow);
390
- const before = cpSlice(lineContent, 0, cursorCol);
391
- const after = cpSlice(lineContent, cursorCol);
392
- newLines[cursorRow] = before + parts[0];
393
- if (parts.length > 1) {
394
- // Adjusted condition for inserting multiple lines
395
- const remainingParts = parts.slice(1);
396
- const lastPartOriginal = remainingParts.pop() ?? '';
397
- newLines.splice(cursorRow + 1, 0, ...remainingParts);
398
- newLines.splice(cursorRow + parts.length - 1, 0, lastPartOriginal + after);
399
- setCursorRow(cursorRow + parts.length - 1);
400
- setCursorCol(cpLen(lastPartOriginal));
401
- }
402
- else {
403
- setCursorCol(cpLen(before) + cpLen(parts[0]));
404
- }
405
- setLines(newLines);
406
- setPreferredCol(null);
407
- return true;
408
- }, [pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol]);
409
- const applyOperations = useCallback((ops) => {
410
- if (ops.length === 0)
411
- return;
412
- const expandedOps = [];
413
- for (const op of ops) {
414
- if (op.type === 'insert') {
415
- let currentText = '';
416
- for (const char of toCodePoints(op.payload)) {
417
- if (char.codePointAt(0) === 127) {
418
- // \x7f
419
- if (currentText.length > 0) {
420
- expandedOps.push({ type: 'insert', payload: currentText });
421
- currentText = '';
422
- }
423
- expandedOps.push({ type: 'backspace' });
424
- }
425
- else {
426
- currentText += char;
427
- }
428
- }
429
- if (currentText.length > 0) {
430
- expandedOps.push({ type: 'insert', payload: currentText });
431
- }
432
- }
433
- else {
434
- expandedOps.push(op);
435
- }
436
- }
437
- if (expandedOps.length === 0) {
438
- return;
439
- }
440
- pushUndo(); // Snapshot before applying batch of updates
441
- const newLines = [...lines];
442
- let newCursorRow = cursorRow;
443
- let newCursorCol = cursorCol;
444
- const currentLine = (r) => newLines[r] ?? '';
445
- for (const op of expandedOps) {
446
- if (op.type === 'insert') {
447
- const str = stripUnsafeCharacters(op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
448
- const parts = str.split('\n');
449
- const lineContent = currentLine(newCursorRow);
450
- const before = cpSlice(lineContent, 0, newCursorCol);
451
- const after = cpSlice(lineContent, newCursorCol);
452
- if (parts.length > 1) {
453
- newLines[newCursorRow] = before + parts[0];
454
- const remainingParts = parts.slice(1);
455
- const lastPartOriginal = remainingParts.pop() ?? '';
456
- newLines.splice(newCursorRow + 1, 0, ...remainingParts);
457
- newLines.splice(newCursorRow + parts.length - 1, 0, lastPartOriginal + after);
458
- newCursorRow = newCursorRow + parts.length - 1;
459
- newCursorCol = cpLen(lastPartOriginal);
460
- }
461
- else {
462
- newLines[newCursorRow] = before + parts[0] + after;
463
- newCursorCol = cpLen(before) + cpLen(parts[0]);
464
- }
465
- }
466
- else if (op.type === 'backspace') {
467
- if (newCursorCol === 0 && newCursorRow === 0)
468
- continue;
469
- if (newCursorCol > 0) {
470
- const lineContent = currentLine(newCursorRow);
471
- newLines[newCursorRow] =
472
- cpSlice(lineContent, 0, newCursorCol - 1) +
473
- cpSlice(lineContent, newCursorCol);
474
- newCursorCol--;
475
- }
476
- else if (newCursorRow > 0) {
477
- const prevLineContent = currentLine(newCursorRow - 1);
478
- const currentLineContentVal = currentLine(newCursorRow);
479
- const newCol = cpLen(prevLineContent);
480
- newLines[newCursorRow - 1] =
481
- prevLineContent + currentLineContentVal;
482
- newLines.splice(newCursorRow, 1);
483
- newCursorRow--;
484
- newCursorCol = newCol;
485
- }
486
- }
487
- }
488
- setLines(newLines);
489
- setCursorRow(newCursorRow);
490
- setCursorCol(newCursorCol);
491
- setPreferredCol(null);
492
- }, [lines, cursorRow, cursorCol, pushUndo, setPreferredCol]);
493
801
  const insert = useCallback((ch) => {
494
802
  if (/[\n\r]/.test(ch)) {
495
- insertStr(ch);
803
+ dispatch({ type: 'insert', payload: ch });
496
804
  return;
497
805
  }
498
- dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
499
- ch = stripUnsafeCharacters(ch);
500
- // Arbitrary threshold to avoid false positives on normal key presses
501
- // while still detecting virtually all reasonable length file paths.
502
806
  const minLengthToInferAsDragDrop = 3;
503
807
  if (ch.length >= minLengthToInferAsDragDrop) {
504
- // Possible drag and drop of a file path.
505
808
  let potentialPath = ch;
506
809
  if (potentialPath.length > 2 &&
507
810
  potentialPath.startsWith("'") &&
@@ -509,433 +812,60 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
509
812
  potentialPath = ch.slice(1, -1);
510
813
  }
511
814
  potentialPath = potentialPath.trim();
512
- // Be conservative and only add an @ if the path is valid.
513
815
  if (isValidPath(unescapePath(potentialPath))) {
514
816
  ch = `@${potentialPath}`;
515
817
  }
516
818
  }
517
- applyOperations([{ type: 'insert', payload: ch }]);
518
- }, [applyOperations, cursorRow, cursorCol, isValidPath, insertStr]);
519
- const newline = useCallback(() => {
520
- dbg('newline', { beforeCursor: [cursorRow, cursorCol] });
521
- applyOperations([{ type: 'insert', payload: '\n' }]);
522
- }, [applyOperations, cursorRow, cursorCol]);
523
- const backspace = useCallback(() => {
524
- dbg('backspace', { beforeCursor: [cursorRow, cursorCol] });
525
- if (cursorCol === 0 && cursorRow === 0)
526
- return;
527
- applyOperations([{ type: 'backspace' }]);
528
- }, [applyOperations, cursorRow, cursorCol]);
529
- const del = useCallback(() => {
530
- dbg('delete', { beforeCursor: [cursorRow, cursorCol] });
531
- const lineContent = currentLine(cursorRow);
532
- if (cursorCol < currentLineLen(cursorRow)) {
533
- pushUndo();
534
- setLines((prevLines) => {
535
- const newLines = [...prevLines];
536
- newLines[cursorRow] =
537
- cpSlice(lineContent, 0, cursorCol) +
538
- cpSlice(lineContent, cursorCol + 1);
539
- return newLines;
540
- });
541
- }
542
- else if (cursorRow < lines.length - 1) {
543
- pushUndo();
544
- const nextLineContent = currentLine(cursorRow + 1);
545
- setLines((prevLines) => {
546
- const newLines = [...prevLines];
547
- newLines[cursorRow] = lineContent + nextLineContent;
548
- newLines.splice(cursorRow + 1, 1);
549
- return newLines;
550
- });
551
- }
552
- // cursor position does not change for del
553
- setPreferredCol(null);
554
- }, [
555
- pushUndo,
556
- cursorRow,
557
- cursorCol,
558
- currentLine,
559
- currentLineLen,
560
- lines.length,
561
- setPreferredCol,
562
- ]);
563
- const setText = useCallback((newText) => {
564
- dbg('setText', { text: newText });
565
- pushUndo();
566
- const newContentLines = newText.replace(/\r\n?/g, '\n').split('\n');
567
- setLines(newContentLines.length === 0 ? [''] : newContentLines);
568
- // Set logical cursor to the end of the new text
569
- const lastNewLineIndex = newContentLines.length - 1;
570
- setCursorRow(lastNewLineIndex);
571
- setCursorCol(cpLen(newContentLines[lastNewLineIndex] ?? ''));
572
- setPreferredCol(null);
573
- }, [pushUndo, setPreferredCol]);
574
- const replaceRange = useCallback((startRow, startCol, endRow, endCol, replacementText) => {
575
- if (startRow > endRow ||
576
- (startRow === endRow && startCol > endCol) ||
577
- startRow < 0 ||
578
- startCol < 0 ||
579
- endRow >= lines.length ||
580
- (endRow < lines.length && endCol > currentLineLen(endRow))) {
581
- console.error('Invalid range provided to replaceRange', {
582
- startRow,
583
- startCol,
584
- endRow,
585
- endCol,
586
- linesLength: lines.length,
587
- endRowLineLength: currentLineLen(endRow),
588
- });
589
- return false;
590
- }
591
- dbg('replaceRange', {
592
- start: [startRow, startCol],
593
- end: [endRow, endCol],
594
- text: replacementText,
595
- });
596
- pushUndo();
597
- const sCol = clamp(startCol, 0, currentLineLen(startRow));
598
- const eCol = clamp(endCol, 0, currentLineLen(endRow));
599
- const prefix = cpSlice(currentLine(startRow), 0, sCol);
600
- const suffix = cpSlice(currentLine(endRow), eCol);
601
- const normalisedReplacement = replacementText
602
- .replace(/\r\n/g, '\n')
603
- .replace(/\r/g, '\n');
604
- const replacementParts = normalisedReplacement.split('\n');
605
- setLines((prevLines) => {
606
- const newLines = [...prevLines];
607
- // Remove lines between startRow and endRow (exclusive of startRow, inclusive of endRow if different)
608
- if (startRow < endRow) {
609
- newLines.splice(startRow + 1, endRow - startRow);
610
- }
611
- // Construct the new content for the startRow
612
- newLines[startRow] = prefix + replacementParts[0];
613
- // If replacementText has multiple lines, insert them
614
- if (replacementParts.length > 1) {
615
- const lastReplacementPart = replacementParts.pop() ?? ''; // parts are already split by \n
616
- // Insert middle parts (if any)
617
- if (replacementParts.length > 1) {
618
- // parts[0] is already used
619
- newLines.splice(startRow + 1, 0, ...replacementParts.slice(1));
620
- }
621
- // The line where the last part of the replacement will go
622
- const targetRowForLastPart = startRow + (replacementParts.length - 1); // -1 because parts[0] is on startRow
623
- // If the last part is not the first part (multi-line replacement)
624
- if (targetRowForLastPart > startRow ||
625
- (replacementParts.length === 1 && lastReplacementPart !== '')) {
626
- // If the target row for the last part doesn't exist (because it's a new line created by replacement)
627
- // ensure it's created before trying to append suffix.
628
- // This case should be handled by splice if replacementParts.length > 1
629
- // For single line replacement that becomes multi-line due to parts.length > 1 logic, this is tricky.
630
- // Let's assume newLines[targetRowForLastPart] exists due to previous splice or it's newLines[startRow]
631
- if (newLines[targetRowForLastPart] === undefined &&
632
- targetRowForLastPart === startRow + 1 &&
633
- replacementParts.length === 1) {
634
- // This implies a single line replacement that became two lines.
635
- // e.g. "abc" replace "b" with "B\nC" -> "aB", "C", "c"
636
- // Here, lastReplacementPart is "C", targetRowForLastPart is startRow + 1
637
- newLines.splice(targetRowForLastPart, 0, lastReplacementPart + suffix);
638
- }
639
- else {
640
- newLines[targetRowForLastPart] =
641
- (newLines[targetRowForLastPart] || '') +
642
- lastReplacementPart +
643
- suffix;
644
- }
645
- }
646
- else {
647
- // Single line in replacementParts, but it was the only part
648
- newLines[startRow] += suffix;
819
+ let currentText = '';
820
+ for (const char of toCodePoints(ch)) {
821
+ if (char.codePointAt(0) === 127) {
822
+ if (currentText.length > 0) {
823
+ dispatch({ type: 'insert', payload: currentText });
824
+ currentText = '';
649
825
  }
650
- setCursorRow(targetRowForLastPart);
651
- setCursorCol(cpLen(newLines[targetRowForLastPart]) - cpLen(suffix));
826
+ dispatch({ type: 'backspace' });
652
827
  }
653
828
  else {
654
- // Single line replacement (replacementParts has only one item)
655
- newLines[startRow] += suffix;
656
- setCursorRow(startRow);
657
- setCursorCol(cpLen(prefix) + cpLen(replacementParts[0]));
829
+ currentText += char;
658
830
  }
659
- return newLines;
660
- });
661
- setPreferredCol(null);
662
- return true;
663
- }, [pushUndo, lines, currentLine, currentLineLen, setPreferredCol]);
664
- const deleteWordLeft = useCallback(() => {
665
- dbg('deleteWordLeft', { beforeCursor: [cursorRow, cursorCol] });
666
- if (cursorCol === 0 && cursorRow === 0)
667
- return;
668
- if (cursorCol === 0) {
669
- backspace();
670
- return;
671
831
  }
672
- pushUndo();
673
- const lineContent = currentLine(cursorRow);
674
- const arr = toCodePoints(lineContent);
675
- let start = cursorCol;
676
- let onlySpaces = true;
677
- for (let i = 0; i < start; i++) {
678
- if (isWordChar(arr[i])) {
679
- onlySpaces = false;
680
- break;
681
- }
832
+ if (currentText.length > 0) {
833
+ dispatch({ type: 'insert', payload: currentText });
682
834
  }
683
- if (onlySpaces && start > 0) {
684
- start--;
685
- }
686
- else {
687
- while (start > 0 && !isWordChar(arr[start - 1]))
688
- start--;
689
- while (start > 0 && isWordChar(arr[start - 1]))
690
- start--;
691
- }
692
- setLines((prevLines) => {
693
- const newLines = [...prevLines];
694
- newLines[cursorRow] =
695
- cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
696
- return newLines;
697
- });
698
- setCursorCol(start);
699
- setPreferredCol(null);
700
- }, [pushUndo, cursorRow, cursorCol, currentLine, backspace, setPreferredCol]);
835
+ }, [isValidPath]);
836
+ const newline = useCallback(() => {
837
+ dispatch({ type: 'insert', payload: '\n' });
838
+ }, []);
839
+ const backspace = useCallback(() => {
840
+ dispatch({ type: 'backspace' });
841
+ }, []);
842
+ const del = useCallback(() => {
843
+ dispatch({ type: 'delete' });
844
+ }, []);
845
+ const move = useCallback((dir) => {
846
+ dispatch({ type: 'move', payload: { dir } });
847
+ }, []);
848
+ const undo = useCallback(() => {
849
+ dispatch({ type: 'undo' });
850
+ }, []);
851
+ const redo = useCallback(() => {
852
+ dispatch({ type: 'redo' });
853
+ }, []);
854
+ const setText = useCallback((newText) => {
855
+ dispatch({ type: 'set_text', payload: newText });
856
+ }, []);
857
+ const deleteWordLeft = useCallback(() => {
858
+ dispatch({ type: 'delete_word_left' });
859
+ }, []);
701
860
  const deleteWordRight = useCallback(() => {
702
- dbg('deleteWordRight', { beforeCursor: [cursorRow, cursorCol] });
703
- const lineContent = currentLine(cursorRow);
704
- const arr = toCodePoints(lineContent);
705
- if (cursorCol >= arr.length && cursorRow === lines.length - 1)
706
- return;
707
- if (cursorCol >= arr.length) {
708
- del();
709
- return;
710
- }
711
- pushUndo();
712
- let end = cursorCol;
713
- while (end < arr.length && !isWordChar(arr[end]))
714
- end++;
715
- while (end < arr.length && isWordChar(arr[end]))
716
- end++;
717
- setLines((prevLines) => {
718
- const newLines = [...prevLines];
719
- newLines[cursorRow] =
720
- cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
721
- return newLines;
722
- });
723
- setPreferredCol(null);
724
- }, [
725
- pushUndo,
726
- cursorRow,
727
- cursorCol,
728
- currentLine,
729
- del,
730
- lines.length,
731
- setPreferredCol,
732
- ]);
861
+ dispatch({ type: 'delete_word_right' });
862
+ }, []);
733
863
  const killLineRight = useCallback(() => {
734
- const lineContent = currentLine(cursorRow);
735
- if (cursorCol < currentLineLen(cursorRow)) {
736
- // Cursor is before the end of the line's content, delete text to the right
737
- pushUndo();
738
- setLines((prevLines) => {
739
- const newLines = [...prevLines];
740
- newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
741
- return newLines;
742
- });
743
- // Cursor position and preferredCol do not change in this case
744
- }
745
- else if (cursorCol === currentLineLen(cursorRow) &&
746
- cursorRow < lines.length - 1) {
747
- // Cursor is at the end of the line's content (or line is empty),
748
- // and it's not the last line. Delete the newline.
749
- // `del()` handles pushUndo and setPreferredCol.
750
- del();
751
- }
752
- // If cursor is at the end of the line and it's the last line, do nothing.
753
- }, [
754
- pushUndo,
755
- cursorRow,
756
- cursorCol,
757
- currentLine,
758
- currentLineLen,
759
- lines.length,
760
- del,
761
- ]);
864
+ dispatch({ type: 'kill_line_right' });
865
+ }, []);
762
866
  const killLineLeft = useCallback(() => {
763
- const lineContent = currentLine(cursorRow);
764
- // Only act if the cursor is not at the beginning of the line
765
- if (cursorCol > 0) {
766
- pushUndo();
767
- setLines((prevLines) => {
768
- const newLines = [...prevLines];
769
- newLines[cursorRow] = cpSlice(lineContent, cursorCol);
770
- return newLines;
771
- });
772
- setCursorCol(0);
773
- setPreferredCol(null);
774
- }
775
- }, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
776
- const move = useCallback((dir) => {
777
- let newVisualRow = visualCursor[0];
778
- let newVisualCol = visualCursor[1];
779
- let newPreferredCol = preferredCol;
780
- const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
781
- switch (dir) {
782
- case 'left':
783
- newPreferredCol = null;
784
- if (newVisualCol > 0) {
785
- newVisualCol--;
786
- }
787
- else if (newVisualRow > 0) {
788
- newVisualRow--;
789
- newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
790
- }
791
- break;
792
- case 'right':
793
- newPreferredCol = null;
794
- if (newVisualCol < currentVisLineLen) {
795
- newVisualCol++;
796
- }
797
- else if (newVisualRow < visualLines.length - 1) {
798
- newVisualRow++;
799
- newVisualCol = 0;
800
- }
801
- break;
802
- case 'up':
803
- if (newVisualRow > 0) {
804
- if (newPreferredCol === null)
805
- newPreferredCol = newVisualCol;
806
- newVisualRow--;
807
- newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
808
- }
809
- break;
810
- case 'down':
811
- if (newVisualRow < visualLines.length - 1) {
812
- if (newPreferredCol === null)
813
- newPreferredCol = newVisualCol;
814
- newVisualRow++;
815
- newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
816
- }
817
- break;
818
- case 'home':
819
- newPreferredCol = null;
820
- newVisualCol = 0;
821
- break;
822
- case 'end':
823
- newPreferredCol = null;
824
- newVisualCol = currentVisLineLen;
825
- break;
826
- // wordLeft and wordRight might need more sophisticated visual handling
827
- // For now, they operate on the logical line derived from the visual cursor
828
- case 'wordLeft': {
829
- newPreferredCol = null;
830
- if (visualToLogicalMap.length === 0 ||
831
- logicalToVisualMap.length === 0)
832
- break;
833
- const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
834
- 0, 0,
835
- ];
836
- const currentLogCol = logColInitial + newVisualCol;
837
- const lineText = lines[logRow];
838
- const sliceToCursor = cpSlice(lineText, 0, currentLogCol).replace(/[\s,.;!?]+$/, '');
839
- let lastIdx = 0;
840
- const regex = /[\s,.;!?]+/g;
841
- let m;
842
- while ((m = regex.exec(sliceToCursor)) != null)
843
- lastIdx = m.index;
844
- const newLogicalCol = lastIdx === 0 ? 0 : cpLen(sliceToCursor.slice(0, lastIdx)) + 1;
845
- // Map newLogicalCol back to visual
846
- const targetLogicalMapEntries = logicalToVisualMap[logRow];
847
- if (!targetLogicalMapEntries)
848
- break;
849
- for (let i = targetLogicalMapEntries.length - 1; i >= 0; i--) {
850
- const [visRow, logStartCol] = targetLogicalMapEntries[i];
851
- if (newLogicalCol >= logStartCol) {
852
- newVisualRow = visRow;
853
- newVisualCol = newLogicalCol - logStartCol;
854
- break;
855
- }
856
- }
857
- break;
858
- }
859
- case 'wordRight': {
860
- newPreferredCol = null;
861
- if (visualToLogicalMap.length === 0 ||
862
- logicalToVisualMap.length === 0)
863
- break;
864
- const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
865
- 0, 0,
866
- ];
867
- const currentLogCol = logColInitial + newVisualCol;
868
- const lineText = lines[logRow];
869
- const regex = /[\s,.;!?]+/g;
870
- let moved = false;
871
- let m;
872
- let newLogicalCol = currentLineLen(logRow); // Default to end of logical line
873
- while ((m = regex.exec(lineText)) != null) {
874
- const cpIdx = cpLen(lineText.slice(0, m.index));
875
- if (cpIdx > currentLogCol) {
876
- newLogicalCol = cpIdx;
877
- moved = true;
878
- break;
879
- }
880
- }
881
- if (!moved && currentLogCol < currentLineLen(logRow)) {
882
- // If no word break found after cursor, move to end
883
- newLogicalCol = currentLineLen(logRow);
884
- }
885
- // Map newLogicalCol back to visual
886
- const targetLogicalMapEntries = logicalToVisualMap[logRow];
887
- if (!targetLogicalMapEntries)
888
- break;
889
- for (let i = 0; i < targetLogicalMapEntries.length; i++) {
890
- const [visRow, logStartCol] = targetLogicalMapEntries[i];
891
- const nextLogStartCol = i + 1 < targetLogicalMapEntries.length
892
- ? targetLogicalMapEntries[i + 1][1]
893
- : Infinity;
894
- if (newLogicalCol >= logStartCol &&
895
- newLogicalCol < nextLogStartCol) {
896
- newVisualRow = visRow;
897
- newVisualCol = newLogicalCol - logStartCol;
898
- break;
899
- }
900
- if (newLogicalCol === logStartCol &&
901
- i === targetLogicalMapEntries.length - 1 &&
902
- cpLen(visualLines[visRow] ?? '') === 0) {
903
- // Special case: moving to an empty visual line at the end of a logical line
904
- newVisualRow = visRow;
905
- newVisualCol = 0;
906
- break;
907
- }
908
- }
909
- break;
910
- }
911
- default:
912
- break;
913
- }
914
- setVisualCursor([newVisualRow, newVisualCol]);
915
- setPreferredCol(newPreferredCol);
916
- // Update logical cursor based on new visual cursor
917
- if (visualToLogicalMap[newVisualRow]) {
918
- const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
919
- setCursorRow(logRow);
920
- setCursorCol(clamp(logStartCol + newVisualCol, 0, currentLineLen(logRow)));
921
- }
922
- dbg('move', {
923
- dir,
924
- visualBefore: visualCursor,
925
- visualAfter: [newVisualRow, newVisualCol],
926
- logicalAfter: [cursorRow, cursorCol],
927
- });
928
- }, [
929
- visualCursor,
930
- visualLines,
931
- preferredCol,
932
- lines,
933
- currentLineLen,
934
- visualToLogicalMap,
935
- logicalToVisualMap,
936
- cursorCol,
937
- cursorRow,
938
- ]);
867
+ dispatch({ type: 'kill_line_left' });
868
+ }, []);
939
869
  const openInExternalEditor = useCallback(async (opts = {}) => {
940
870
  const editor = opts.editor ??
941
871
  process.env['VISUAL'] ??
@@ -944,7 +874,7 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
944
874
  const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
945
875
  const filePath = pathMod.join(tmpDir, 'buffer.txt');
946
876
  fs.writeFileSync(filePath, text, 'utf8');
947
- pushUndo(); // Snapshot before external edit
877
+ dispatch({ type: 'create_undo_snapshot' });
948
878
  const wasRaw = stdin?.isRaw ?? false;
949
879
  try {
950
880
  setRawMode?.(false);
@@ -957,11 +887,10 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
957
887
  throw new Error(`External editor exited with status ${status}`);
958
888
  let newText = fs.readFileSync(filePath, 'utf8');
959
889
  newText = newText.replace(/\r\n?/g, '\n');
960
- setText(newText);
890
+ dispatch({ type: 'set_text', payload: newText, pushToUndo: false });
961
891
  }
962
892
  catch (err) {
963
893
  console.error('[useTextBuffer] external editor error', err);
964
- // TODO(jacobr): potentially revert or handle error state.
965
894
  }
966
895
  finally {
967
896
  if (wasRaw)
@@ -979,19 +908,9 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
979
908
  /* ignore */
980
909
  }
981
910
  }
982
- }, [text, pushUndo, stdin, setRawMode, setText]);
911
+ }, [text, stdin, setRawMode]);
983
912
  const handleInput = useCallback((key) => {
984
913
  const { sequence: input } = key;
985
- dbg('handleInput', {
986
- key,
987
- cursor: [cursorRow, cursorCol],
988
- visualCursor,
989
- });
990
- const beforeText = text;
991
- const beforeLogicalCursor = [cursorRow, cursorCol];
992
- const beforeVisualCursor = [...visualCursor];
993
- if (key.name === 'escape')
994
- return false;
995
914
  if (key.name === 'return' ||
996
915
  input === '\r' ||
997
916
  input === '\n' ||
@@ -1042,47 +961,22 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
1042
961
  else if (input && !key.ctrl && !key.meta) {
1043
962
  insert(input);
1044
963
  }
1045
- const textChanged = text !== beforeText;
1046
- // After operations, visualCursor might not be immediately updated if the change
1047
- // was to `lines`, `cursorRow`, or `cursorCol` which then triggers the useEffect.
1048
- // So, for return value, we check logical cursor change.
1049
- const cursorChanged = cursorRow !== beforeLogicalCursor[0] ||
1050
- cursorCol !== beforeLogicalCursor[1] ||
1051
- visualCursor[0] !== beforeVisualCursor[0] ||
1052
- visualCursor[1] !== beforeVisualCursor[1];
1053
- dbg('handleInput:after', {
1054
- cursor: [cursorRow, cursorCol],
1055
- visualCursor,
1056
- text,
1057
- });
1058
- return textChanged || cursorChanged;
1059
- }, [
1060
- text,
1061
- cursorRow,
1062
- cursorCol,
1063
- visualCursor,
1064
- newline,
1065
- move,
1066
- deleteWordLeft,
1067
- deleteWordRight,
1068
- backspace,
1069
- del,
1070
- insert,
1071
- ]);
964
+ }, [newline, move, deleteWordLeft, deleteWordRight, backspace, del, insert]);
1072
965
  const renderedVisualLines = useMemo(() => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height), [visualLines, visualScrollRow, viewport.height]);
966
+ const replaceRange = useCallback((startRow, startCol, endRow, endCol, text) => {
967
+ dispatch({
968
+ type: 'replace_range',
969
+ payload: { startRow, startCol, endRow, endCol, text },
970
+ });
971
+ }, []);
1073
972
  const replaceRangeByOffset = useCallback((startOffset, endOffset, replacementText) => {
1074
- dbg('replaceRangeByOffset', { startOffset, endOffset, replacementText });
1075
973
  const [startRow, startCol] = offsetToLogicalPos(text, startOffset);
1076
974
  const [endRow, endCol] = offsetToLogicalPos(text, endOffset);
1077
- return replaceRange(startRow, startCol, endRow, endCol, replacementText);
975
+ replaceRange(startRow, startCol, endRow, endCol, replacementText);
1078
976
  }, [text, replaceRange]);
1079
977
  const moveToOffset = useCallback((offset) => {
1080
- const [newRow, newCol] = offsetToLogicalPos(text, offset);
1081
- setCursorRow(newRow);
1082
- setCursorCol(newCol);
1083
- setPreferredCol(null);
1084
- dbg('moveToOffset', { offset, newCursor: [newRow, newCol] });
1085
- }, [text, setPreferredCol]);
978
+ dispatch({ type: 'move_to_offset', payload: { offset } });
979
+ }, []);
1086
980
  const returnValue = {
1087
981
  lines,
1088
982
  text,
@@ -1103,43 +997,13 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
1103
997
  redo,
1104
998
  replaceRange,
1105
999
  replaceRangeByOffset,
1106
- moveToOffset, // Added here
1000
+ moveToOffset,
1107
1001
  deleteWordLeft,
1108
1002
  deleteWordRight,
1109
1003
  killLineRight,
1110
1004
  killLineLeft,
1111
1005
  handleInput,
1112
1006
  openInExternalEditor,
1113
- applyOperations,
1114
- copy: useCallback(() => {
1115
- if (!selectionAnchor)
1116
- return null;
1117
- const [ar, ac] = selectionAnchor;
1118
- const [br, bc] = [cursorRow, cursorCol];
1119
- if (ar === br && ac === bc)
1120
- return null;
1121
- const topBefore = ar < br || (ar === br && ac < bc);
1122
- const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
1123
- let selectedTextVal;
1124
- if (sr === er) {
1125
- selectedTextVal = cpSlice(currentLine(sr), sc, ec);
1126
- }
1127
- else {
1128
- const parts = [cpSlice(currentLine(sr), sc)];
1129
- for (let r = sr + 1; r < er; r++)
1130
- parts.push(currentLine(r));
1131
- parts.push(cpSlice(currentLine(er), 0, ec));
1132
- selectedTextVal = parts.join('\n');
1133
- }
1134
- setClipboard(selectedTextVal);
1135
- return selectedTextVal;
1136
- }, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
1137
- paste: useCallback(() => {
1138
- if (clipboard === null)
1139
- return false;
1140
- return insertStr(clipboard);
1141
- }, [clipboard, insertStr]),
1142
- startSelection: useCallback(() => setSelectionAnchor([cursorRow, cursorCol]), [cursorRow, cursorCol, setSelectionAnchor]),
1143
1007
  };
1144
1008
  return returnValue;
1145
1009
  }