@google/gemini-cli 0.1.9-nightly.250709.c8cf954e → 0.1.9-rc.1
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.
- package/README.md +3 -17
- package/dist/google-gemini-cli-0.1.9-rc.1.tgz +0 -0
- package/dist/index.js +0 -0
- package/dist/package.json +10 -9
- package/dist/src/config/auth.js +5 -6
- package/dist/src/config/auth.js.map +1 -1
- package/dist/src/config/config.d.ts +1 -0
- package/dist/src/config/config.js +47 -57
- package/dist/src/config/config.js.map +1 -1
- package/dist/src/config/extension.d.ts +0 -1
- package/dist/src/config/extension.js +6 -34
- package/dist/src/config/extension.js.map +1 -1
- package/dist/src/config/sandboxConfig.d.ts +1 -1
- package/dist/src/config/sandboxConfig.js +1 -1
- package/dist/src/config/sandboxConfig.js.map +1 -1
- package/dist/src/config/settings.d.ts +0 -2
- package/dist/src/config/settings.js +1 -63
- package/dist/src/config/settings.js.map +1 -1
- package/dist/src/gemini.js +20 -26
- package/dist/src/gemini.js.map +1 -1
- package/dist/src/{ui/commands/types.js → gemini.test.d.ts} +0 -1
- package/dist/src/gemini.test.js +122 -0
- package/dist/src/gemini.test.js.map +1 -0
- package/dist/src/generated/git-commit.d.ts +1 -1
- package/dist/src/generated/git-commit.js +1 -1
- package/dist/src/generated/git-commit.js.map +1 -1
- package/dist/src/ui/App.js +7 -11
- package/dist/src/ui/App.js.map +1 -1
- package/dist/src/ui/App.test.d.ts +6 -0
- package/dist/src/ui/App.test.js +299 -0
- package/dist/src/ui/App.test.js.map +1 -0
- package/dist/src/ui/components/AuthDialog.js +8 -36
- package/dist/src/ui/components/AuthDialog.js.map +1 -1
- package/dist/src/ui/components/AuthDialog.test.d.ts +6 -0
- package/dist/src/ui/components/AuthDialog.test.js +69 -0
- package/dist/src/ui/components/AuthDialog.test.js.map +1 -0
- package/dist/src/ui/components/Help.d.ts +1 -1
- package/dist/src/ui/components/Help.js +2 -3
- package/dist/src/ui/components/Help.js.map +1 -1
- package/dist/src/ui/components/HistoryItemDisplay.test.d.ts +6 -0
- package/dist/src/ui/components/HistoryItemDisplay.test.js +81 -0
- package/dist/src/ui/components/HistoryItemDisplay.test.js.map +1 -0
- package/dist/src/ui/components/InputPrompt.d.ts +1 -2
- package/dist/src/ui/components/InputPrompt.js +157 -106
- package/dist/src/ui/components/InputPrompt.js.map +1 -1
- package/dist/src/ui/components/InputPrompt.test.d.ts +6 -0
- package/dist/src/ui/components/InputPrompt.test.js +154 -0
- package/dist/src/ui/components/InputPrompt.test.js.map +1 -0
- package/dist/src/ui/components/LoadingIndicator.js +1 -2
- package/dist/src/ui/components/LoadingIndicator.js.map +1 -1
- package/dist/src/ui/components/LoadingIndicator.test.d.ts +6 -0
- package/dist/src/ui/components/LoadingIndicator.test.js +141 -0
- package/dist/src/ui/components/LoadingIndicator.test.js.map +1 -0
- package/dist/src/ui/components/ModelStatsDisplay.test.d.ts +6 -0
- package/dist/src/ui/components/ModelStatsDisplay.test.js +217 -0
- package/dist/src/ui/components/ModelStatsDisplay.test.js.map +1 -0
- package/dist/src/ui/components/SessionSummaryDisplay.test.d.ts +6 -0
- package/dist/src/ui/components/SessionSummaryDisplay.test.js +60 -0
- package/dist/src/ui/components/SessionSummaryDisplay.test.js.map +1 -0
- package/dist/src/ui/components/StatsDisplay.test.d.ts +6 -0
- package/dist/src/ui/components/StatsDisplay.test.js +275 -0
- package/dist/src/ui/components/StatsDisplay.test.js.map +1 -0
- package/dist/src/ui/components/ToolStatsDisplay.test.d.ts +6 -0
- package/dist/src/ui/components/ToolStatsDisplay.test.js +160 -0
- package/dist/src/ui/components/ToolStatsDisplay.test.js.map +1 -0
- package/dist/src/ui/components/messages/CompressionMessage.js +1 -1
- package/dist/src/ui/components/messages/DiffRenderer.test.d.ts +6 -0
- package/dist/src/ui/components/messages/DiffRenderer.test.js +239 -0
- package/dist/src/ui/components/messages/DiffRenderer.test.js.map +1 -0
- package/dist/src/ui/components/messages/ToolConfirmationMessage.test.d.ts +6 -0
- package/dist/src/ui/components/messages/ToolConfirmationMessage.test.js +37 -0
- package/dist/src/ui/components/messages/ToolConfirmationMessage.test.js.map +1 -0
- package/dist/src/ui/components/messages/ToolMessage.test.d.ts +6 -0
- package/dist/src/ui/components/messages/ToolMessage.test.js +116 -0
- package/dist/src/ui/components/messages/ToolMessage.test.js.map +1 -0
- package/dist/src/ui/components/shared/MaxSizedBox.js +1 -1
- package/dist/src/ui/components/shared/MaxSizedBox.js.map +1 -1
- package/dist/src/ui/components/shared/MaxSizedBox.test.d.ts +6 -0
- package/dist/src/ui/components/shared/MaxSizedBox.test.js +134 -0
- package/dist/src/ui/components/shared/MaxSizedBox.test.js.map +1 -0
- package/dist/src/ui/components/shared/text-buffer.d.ts +16 -72
- package/dist/src/ui/components/shared/text-buffer.js +676 -564
- package/dist/src/ui/components/shared/text-buffer.js.map +1 -1
- package/dist/src/ui/contexts/SessionContext.d.ts +1 -1
- package/dist/src/ui/contexts/SessionContext.test.d.ts +6 -0
- package/dist/src/ui/contexts/SessionContext.test.js +96 -0
- package/dist/src/ui/contexts/SessionContext.test.js.map +1 -0
- package/dist/src/ui/hooks/atCommandProcessor.js +7 -5
- package/dist/src/ui/hooks/atCommandProcessor.js.map +1 -1
- package/dist/src/ui/hooks/slashCommandProcessor.d.ts +10 -6
- package/dist/src/ui/hooks/slashCommandProcessor.js +124 -235
- package/dist/src/ui/hooks/slashCommandProcessor.js.map +1 -1
- package/dist/src/ui/hooks/useCompletion.d.ts +2 -2
- package/dist/src/ui/hooks/useCompletion.js +56 -106
- package/dist/src/ui/hooks/useCompletion.js.map +1 -1
- package/dist/src/ui/hooks/useGeminiStream.d.ts +2 -2
- package/dist/src/ui/hooks/useGeminiStream.js +18 -12
- package/dist/src/ui/hooks/useGeminiStream.js.map +1 -1
- package/dist/src/ui/hooks/useGeminiStream.test.d.ts +6 -0
- package/dist/src/ui/hooks/useGeminiStream.test.js +775 -0
- package/dist/src/ui/hooks/useGeminiStream.test.js.map +1 -0
- package/dist/src/ui/themes/ansi.js +1 -1
- package/dist/src/ui/themes/ansi.js.map +1 -1
- package/dist/src/ui/types.d.ts +0 -10
- package/dist/src/ui/utils/MarkdownDisplay.js +80 -1
- package/dist/src/ui/utils/MarkdownDisplay.js.map +1 -1
- package/dist/src/ui/utils/MarkdownDisplay.test.d.ts +6 -0
- package/dist/src/ui/utils/MarkdownDisplay.test.js +176 -0
- package/dist/src/ui/utils/MarkdownDisplay.test.js.map +1 -0
- package/dist/src/ui/utils/TableRenderer.js +32 -50
- package/dist/src/ui/utils/TableRenderer.js.map +1 -1
- package/dist/src/utils/sandbox-macos-permissive-proxied.sb +1 -1
- package/dist/src/utils/sandbox-macos-restrictive-proxied.sb +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +10 -9
- package/dist/google-gemini-cli-0.1.9.tgz +0 -0
- package/dist/src/services/CommandService.d.ts +0 -13
- package/dist/src/services/CommandService.js +0 -30
- package/dist/src/services/CommandService.js.map +0 -1
- package/dist/src/ui/commands/clearCommand.d.ts +0 -7
- package/dist/src/ui/commands/clearCommand.js +0 -15
- package/dist/src/ui/commands/clearCommand.js.map +0 -1
- package/dist/src/ui/commands/helpCommand.d.ts +0 -7
- package/dist/src/ui/commands/helpCommand.js +0 -18
- package/dist/src/ui/commands/helpCommand.js.map +0 -1
- package/dist/src/ui/commands/memoryCommand.d.ts +0 -7
- package/dist/src/ui/commands/memoryCommand.js +0 -81
- package/dist/src/ui/commands/memoryCommand.js.map +0 -1
- package/dist/src/ui/commands/types.d.ts +0 -63
- package/dist/src/ui/commands/types.js.map +0 -1
- package/dist/src/ui/utils/InlineMarkdownRenderer.d.ts +0 -16
- package/dist/src/ui/utils/InlineMarkdownRenderer.js +0 -104
- package/dist/src/ui/utils/InlineMarkdownRenderer.js.map +0 -1
- package/dist/src/utils/userStartupWarnings.d.ts +0 -6
- package/dist/src/utils/userStartupWarnings.js +0 -33
- package/dist/src/utils/userStartupWarnings.js.map +0 -1
|
@@ -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
|
|
11
|
+
import { useState, useCallback, useEffect, useMemo } 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,6 +43,17 @@ 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
|
+
}
|
|
46
57
|
function calculateInitialCursorPosition(initialLines, offset) {
|
|
47
58
|
let remainingChars = offset;
|
|
48
59
|
let row = 0;
|
|
@@ -278,512 +289,37 @@ function calculateVisualLayout(logicalLines, logicalCursor, viewportWidth) {
|
|
|
278
289
|
visualToLogicalMap,
|
|
279
290
|
};
|
|
280
291
|
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 ---
|
|
757
|
-
export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewport, stdin, setRawMode, onChange, isValidPath, shellModeActive = false, }) {
|
|
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;
|
|
292
|
+
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 [opQueue, setOpQueue] = useState([]);
|
|
305
|
+
const [clipboard, setClipboard] = useState(null);
|
|
306
|
+
const [selectionAnchor, setSelectionAnchor] = useState(null); // Logical selection
|
|
307
|
+
// Visual state
|
|
308
|
+
const [visualLines, setVisualLines] = useState(['']);
|
|
309
|
+
const [visualCursor, setVisualCursor] = useState([0, 0]);
|
|
778
310
|
const [visualScrollRow, setVisualScrollRow] = useState(0);
|
|
311
|
+
const [logicalToVisualMap, setLogicalToVisualMap] = useState([]);
|
|
312
|
+
const [visualToLogicalMap, setVisualToLogicalMap] = useState([]);
|
|
313
|
+
const currentLine = useCallback((r) => lines[r] ?? '', [lines]);
|
|
314
|
+
const currentLineLen = useCallback((r) => cpLen(currentLine(r)), [currentLine]);
|
|
315
|
+
// Recalculate visual layout whenever logical lines or viewport width changes
|
|
779
316
|
useEffect(() => {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}, [viewport.width]);
|
|
317
|
+
const layout = calculateVisualLayout(lines, [cursorRow, cursorCol], viewport.width);
|
|
318
|
+
setVisualLines(layout.visualLines);
|
|
319
|
+
setVisualCursor(layout.visualCursor);
|
|
320
|
+
setLogicalToVisualMap(layout.logicalToVisualMap);
|
|
321
|
+
setVisualToLogicalMap(layout.visualToLogicalMap);
|
|
322
|
+
}, [lines, cursorRow, cursorCol, viewport.width]);
|
|
787
323
|
// Update visual scroll (vertical)
|
|
788
324
|
useEffect(() => {
|
|
789
325
|
const { height } = viewport;
|
|
@@ -798,13 +334,149 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
798
334
|
setVisualScrollRow(newVisualScrollRow);
|
|
799
335
|
}
|
|
800
336
|
}, [visualCursor, visualScrollRow, viewport]);
|
|
801
|
-
const
|
|
802
|
-
|
|
803
|
-
|
|
337
|
+
const pushUndo = useCallback(() => {
|
|
338
|
+
dbg('pushUndo', { cursor: [cursorRow, cursorCol], text: lines.join('\n') });
|
|
339
|
+
const snapshot = { lines: [...lines], cursorRow, cursorCol };
|
|
340
|
+
setUndoStack((prev) => {
|
|
341
|
+
const newStack = [...prev, snapshot];
|
|
342
|
+
if (newStack.length > historyLimit) {
|
|
343
|
+
newStack.shift();
|
|
344
|
+
}
|
|
345
|
+
return newStack;
|
|
346
|
+
});
|
|
347
|
+
setRedoStack([]);
|
|
348
|
+
}, [lines, cursorRow, cursorCol, historyLimit]);
|
|
349
|
+
const _restoreState = useCallback((state) => {
|
|
350
|
+
if (!state)
|
|
351
|
+
return false;
|
|
352
|
+
setLines(state.lines);
|
|
353
|
+
setCursorRow(state.cursorRow);
|
|
354
|
+
setCursorCol(state.cursorCol);
|
|
355
|
+
return true;
|
|
356
|
+
}, []);
|
|
357
|
+
const text = lines.join('\n');
|
|
358
|
+
useEffect(() => {
|
|
359
|
+
if (onChange) {
|
|
360
|
+
onChange(text);
|
|
361
|
+
}
|
|
362
|
+
}, [text, onChange]);
|
|
363
|
+
const undo = useCallback(() => {
|
|
364
|
+
const state = undoStack[undoStack.length - 1];
|
|
365
|
+
if (!state)
|
|
366
|
+
return false;
|
|
367
|
+
setUndoStack((prev) => prev.slice(0, -1));
|
|
368
|
+
const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
|
|
369
|
+
setRedoStack((prev) => [...prev, currentSnapshot]);
|
|
370
|
+
return _restoreState(state);
|
|
371
|
+
}, [undoStack, lines, cursorRow, cursorCol, _restoreState]);
|
|
372
|
+
const redo = useCallback(() => {
|
|
373
|
+
const state = redoStack[redoStack.length - 1];
|
|
374
|
+
if (!state)
|
|
375
|
+
return false;
|
|
376
|
+
setRedoStack((prev) => prev.slice(0, -1));
|
|
377
|
+
const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
|
|
378
|
+
setUndoStack((prev) => [...prev, currentSnapshot]);
|
|
379
|
+
return _restoreState(state);
|
|
380
|
+
}, [redoStack, lines, cursorRow, cursorCol, _restoreState]);
|
|
381
|
+
const applyOperations = useCallback((ops) => {
|
|
382
|
+
if (ops.length === 0)
|
|
383
|
+
return;
|
|
384
|
+
setOpQueue((prev) => [...prev, ...ops]);
|
|
385
|
+
}, []);
|
|
386
|
+
useEffect(() => {
|
|
387
|
+
if (opQueue.length === 0)
|
|
388
|
+
return;
|
|
389
|
+
const expandedOps = [];
|
|
390
|
+
for (const op of opQueue) {
|
|
391
|
+
if (op.type === 'insert') {
|
|
392
|
+
let currentText = '';
|
|
393
|
+
for (const char of toCodePoints(op.payload)) {
|
|
394
|
+
if (char.codePointAt(0) === 127) {
|
|
395
|
+
// \x7f
|
|
396
|
+
if (currentText.length > 0) {
|
|
397
|
+
expandedOps.push({ type: 'insert', payload: currentText });
|
|
398
|
+
currentText = '';
|
|
399
|
+
}
|
|
400
|
+
expandedOps.push({ type: 'backspace' });
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
currentText += char;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (currentText.length > 0) {
|
|
407
|
+
expandedOps.push({ type: 'insert', payload: currentText });
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
expandedOps.push(op);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (expandedOps.length === 0) {
|
|
415
|
+
setOpQueue([]); // Clear queue even if ops were no-ops
|
|
804
416
|
return;
|
|
805
417
|
}
|
|
418
|
+
pushUndo(); // Snapshot before applying batch of updates
|
|
419
|
+
const newLines = [...lines];
|
|
420
|
+
let newCursorRow = cursorRow;
|
|
421
|
+
let newCursorCol = cursorCol;
|
|
422
|
+
const currentLine = (r) => newLines[r] ?? '';
|
|
423
|
+
for (const op of expandedOps) {
|
|
424
|
+
if (op.type === 'insert') {
|
|
425
|
+
const str = stripUnsafeCharacters(op.payload.replace(/\r\n/g, '\n').replace(/\r/g, '\n'));
|
|
426
|
+
const parts = str.split('\n');
|
|
427
|
+
const lineContent = currentLine(newCursorRow);
|
|
428
|
+
const before = cpSlice(lineContent, 0, newCursorCol);
|
|
429
|
+
const after = cpSlice(lineContent, newCursorCol);
|
|
430
|
+
if (parts.length > 1) {
|
|
431
|
+
newLines[newCursorRow] = before + parts[0];
|
|
432
|
+
const remainingParts = parts.slice(1);
|
|
433
|
+
const lastPartOriginal = remainingParts.pop() ?? '';
|
|
434
|
+
newLines.splice(newCursorRow + 1, 0, ...remainingParts);
|
|
435
|
+
newLines.splice(newCursorRow + parts.length - 1, 0, lastPartOriginal + after);
|
|
436
|
+
newCursorRow = newCursorRow + parts.length - 1;
|
|
437
|
+
newCursorCol = cpLen(lastPartOriginal);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
newLines[newCursorRow] = before + parts[0] + after;
|
|
441
|
+
newCursorCol = cpLen(before) + cpLen(parts[0]);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
else if (op.type === 'backspace') {
|
|
445
|
+
if (newCursorCol === 0 && newCursorRow === 0)
|
|
446
|
+
continue;
|
|
447
|
+
if (newCursorCol > 0) {
|
|
448
|
+
const lineContent = currentLine(newCursorRow);
|
|
449
|
+
newLines[newCursorRow] =
|
|
450
|
+
cpSlice(lineContent, 0, newCursorCol - 1) +
|
|
451
|
+
cpSlice(lineContent, newCursorCol);
|
|
452
|
+
newCursorCol--;
|
|
453
|
+
}
|
|
454
|
+
else if (newCursorRow > 0) {
|
|
455
|
+
const prevLineContent = currentLine(newCursorRow - 1);
|
|
456
|
+
const currentLineContentVal = currentLine(newCursorRow);
|
|
457
|
+
const newCol = cpLen(prevLineContent);
|
|
458
|
+
newLines[newCursorRow - 1] = prevLineContent + currentLineContentVal;
|
|
459
|
+
newLines.splice(newCursorRow, 1);
|
|
460
|
+
newCursorRow--;
|
|
461
|
+
newCursorCol = newCol;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
setLines(newLines);
|
|
466
|
+
setCursorRow(newCursorRow);
|
|
467
|
+
setCursorCol(newCursorCol);
|
|
468
|
+
setPreferredCol(null);
|
|
469
|
+
// Clear the queue after processing
|
|
470
|
+
setOpQueue((prev) => prev.slice(opQueue.length));
|
|
471
|
+
}, [opQueue, lines, cursorRow, cursorCol, pushUndo, setPreferredCol]);
|
|
472
|
+
const insert = useCallback((ch) => {
|
|
473
|
+
dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
|
|
474
|
+
ch = stripUnsafeCharacters(ch);
|
|
475
|
+
// Arbitrary threshold to avoid false positives on normal key presses
|
|
476
|
+
// while still detecting virtually all reasonable length file paths.
|
|
806
477
|
const minLengthToInferAsDragDrop = 3;
|
|
807
|
-
if (ch.length >= minLengthToInferAsDragDrop
|
|
478
|
+
if (ch.length >= minLengthToInferAsDragDrop) {
|
|
479
|
+
// Possible drag and drop of a file path.
|
|
808
480
|
let potentialPath = ch;
|
|
809
481
|
if (potentialPath.length > 2 &&
|
|
810
482
|
potentialPath.startsWith("'") &&
|
|
@@ -812,60 +484,433 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
812
484
|
potentialPath = ch.slice(1, -1);
|
|
813
485
|
}
|
|
814
486
|
potentialPath = potentialPath.trim();
|
|
487
|
+
// Be conservative and only add an @ if the path is valid.
|
|
815
488
|
if (isValidPath(unescapePath(potentialPath))) {
|
|
816
489
|
ch = `@${potentialPath}`;
|
|
817
490
|
}
|
|
818
491
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
492
|
+
applyOperations([{ type: 'insert', payload: ch }]);
|
|
493
|
+
}, [applyOperations, cursorRow, cursorCol, isValidPath]);
|
|
494
|
+
const newline = useCallback(() => {
|
|
495
|
+
dbg('newline', { beforeCursor: [cursorRow, cursorCol] });
|
|
496
|
+
applyOperations([{ type: 'insert', payload: '\n' }]);
|
|
497
|
+
}, [applyOperations, cursorRow, cursorCol]);
|
|
498
|
+
const backspace = useCallback(() => {
|
|
499
|
+
dbg('backspace', { beforeCursor: [cursorRow, cursorCol] });
|
|
500
|
+
if (cursorCol === 0 && cursorRow === 0)
|
|
501
|
+
return;
|
|
502
|
+
applyOperations([{ type: 'backspace' }]);
|
|
503
|
+
}, [applyOperations, cursorRow, cursorCol]);
|
|
504
|
+
const del = useCallback(() => {
|
|
505
|
+
dbg('delete', { beforeCursor: [cursorRow, cursorCol] });
|
|
506
|
+
const lineContent = currentLine(cursorRow);
|
|
507
|
+
if (cursorCol < currentLineLen(cursorRow)) {
|
|
508
|
+
pushUndo();
|
|
509
|
+
setLines((prevLines) => {
|
|
510
|
+
const newLines = [...prevLines];
|
|
511
|
+
newLines[cursorRow] =
|
|
512
|
+
cpSlice(lineContent, 0, cursorCol) +
|
|
513
|
+
cpSlice(lineContent, cursorCol + 1);
|
|
514
|
+
return newLines;
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
else if (cursorRow < lines.length - 1) {
|
|
518
|
+
pushUndo();
|
|
519
|
+
const nextLineContent = currentLine(cursorRow + 1);
|
|
520
|
+
setLines((prevLines) => {
|
|
521
|
+
const newLines = [...prevLines];
|
|
522
|
+
newLines[cursorRow] = lineContent + nextLineContent;
|
|
523
|
+
newLines.splice(cursorRow + 1, 1);
|
|
524
|
+
return newLines;
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
// cursor position does not change for del
|
|
528
|
+
setPreferredCol(null);
|
|
529
|
+
}, [
|
|
530
|
+
pushUndo,
|
|
531
|
+
cursorRow,
|
|
532
|
+
cursorCol,
|
|
533
|
+
currentLine,
|
|
534
|
+
currentLineLen,
|
|
535
|
+
lines.length,
|
|
536
|
+
setPreferredCol,
|
|
537
|
+
]);
|
|
538
|
+
const setText = useCallback((newText) => {
|
|
539
|
+
dbg('setText', { text: newText });
|
|
540
|
+
pushUndo();
|
|
541
|
+
const newContentLines = newText.replace(/\r\n?/g, '\n').split('\n');
|
|
542
|
+
setLines(newContentLines.length === 0 ? [''] : newContentLines);
|
|
543
|
+
// Set logical cursor to the end of the new text
|
|
544
|
+
const lastNewLineIndex = newContentLines.length - 1;
|
|
545
|
+
setCursorRow(lastNewLineIndex);
|
|
546
|
+
setCursorCol(cpLen(newContentLines[lastNewLineIndex] ?? ''));
|
|
547
|
+
setPreferredCol(null);
|
|
548
|
+
}, [pushUndo, setPreferredCol]);
|
|
549
|
+
const replaceRange = useCallback((startRow, startCol, endRow, endCol, replacementText) => {
|
|
550
|
+
if (startRow > endRow ||
|
|
551
|
+
(startRow === endRow && startCol > endCol) ||
|
|
552
|
+
startRow < 0 ||
|
|
553
|
+
startCol < 0 ||
|
|
554
|
+
endRow >= lines.length ||
|
|
555
|
+
(endRow < lines.length && endCol > currentLineLen(endRow))) {
|
|
556
|
+
console.error('Invalid range provided to replaceRange', {
|
|
557
|
+
startRow,
|
|
558
|
+
startCol,
|
|
559
|
+
endRow,
|
|
560
|
+
endCol,
|
|
561
|
+
linesLength: lines.length,
|
|
562
|
+
endRowLineLength: currentLineLen(endRow),
|
|
563
|
+
});
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
dbg('replaceRange', {
|
|
567
|
+
start: [startRow, startCol],
|
|
568
|
+
end: [endRow, endCol],
|
|
569
|
+
text: replacementText,
|
|
570
|
+
});
|
|
571
|
+
pushUndo();
|
|
572
|
+
const sCol = clamp(startCol, 0, currentLineLen(startRow));
|
|
573
|
+
const eCol = clamp(endCol, 0, currentLineLen(endRow));
|
|
574
|
+
const prefix = cpSlice(currentLine(startRow), 0, sCol);
|
|
575
|
+
const suffix = cpSlice(currentLine(endRow), eCol);
|
|
576
|
+
const normalisedReplacement = replacementText
|
|
577
|
+
.replace(/\r\n/g, '\n')
|
|
578
|
+
.replace(/\r/g, '\n');
|
|
579
|
+
const replacementParts = normalisedReplacement.split('\n');
|
|
580
|
+
setLines((prevLines) => {
|
|
581
|
+
const newLines = [...prevLines];
|
|
582
|
+
// Remove lines between startRow and endRow (exclusive of startRow, inclusive of endRow if different)
|
|
583
|
+
if (startRow < endRow) {
|
|
584
|
+
newLines.splice(startRow + 1, endRow - startRow);
|
|
585
|
+
}
|
|
586
|
+
// Construct the new content for the startRow
|
|
587
|
+
newLines[startRow] = prefix + replacementParts[0];
|
|
588
|
+
// If replacementText has multiple lines, insert them
|
|
589
|
+
if (replacementParts.length > 1) {
|
|
590
|
+
const lastReplacementPart = replacementParts.pop() ?? ''; // parts are already split by \n
|
|
591
|
+
// Insert middle parts (if any)
|
|
592
|
+
if (replacementParts.length > 1) {
|
|
593
|
+
// parts[0] is already used
|
|
594
|
+
newLines.splice(startRow + 1, 0, ...replacementParts.slice(1));
|
|
595
|
+
}
|
|
596
|
+
// The line where the last part of the replacement will go
|
|
597
|
+
const targetRowForLastPart = startRow + (replacementParts.length - 1); // -1 because parts[0] is on startRow
|
|
598
|
+
// If the last part is not the first part (multi-line replacement)
|
|
599
|
+
if (targetRowForLastPart > startRow ||
|
|
600
|
+
(replacementParts.length === 1 && lastReplacementPart !== '')) {
|
|
601
|
+
// If the target row for the last part doesn't exist (because it's a new line created by replacement)
|
|
602
|
+
// ensure it's created before trying to append suffix.
|
|
603
|
+
// This case should be handled by splice if replacementParts.length > 1
|
|
604
|
+
// For single line replacement that becomes multi-line due to parts.length > 1 logic, this is tricky.
|
|
605
|
+
// Let's assume newLines[targetRowForLastPart] exists due to previous splice or it's newLines[startRow]
|
|
606
|
+
if (newLines[targetRowForLastPart] === undefined &&
|
|
607
|
+
targetRowForLastPart === startRow + 1 &&
|
|
608
|
+
replacementParts.length === 1) {
|
|
609
|
+
// This implies a single line replacement that became two lines.
|
|
610
|
+
// e.g. "abc" replace "b" with "B\nC" -> "aB", "C", "c"
|
|
611
|
+
// Here, lastReplacementPart is "C", targetRowForLastPart is startRow + 1
|
|
612
|
+
newLines.splice(targetRowForLastPart, 0, lastReplacementPart + suffix);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
newLines[targetRowForLastPart] =
|
|
616
|
+
(newLines[targetRowForLastPart] || '') +
|
|
617
|
+
lastReplacementPart +
|
|
618
|
+
suffix;
|
|
619
|
+
}
|
|
825
620
|
}
|
|
826
|
-
|
|
621
|
+
else {
|
|
622
|
+
// Single line in replacementParts, but it was the only part
|
|
623
|
+
newLines[startRow] += suffix;
|
|
624
|
+
}
|
|
625
|
+
setCursorRow(targetRowForLastPart);
|
|
626
|
+
setCursorCol(cpLen(newLines[targetRowForLastPart]) - cpLen(suffix));
|
|
827
627
|
}
|
|
828
628
|
else {
|
|
829
|
-
|
|
629
|
+
// Single line replacement (replacementParts has only one item)
|
|
630
|
+
newLines[startRow] += suffix;
|
|
631
|
+
setCursorRow(startRow);
|
|
632
|
+
setCursorCol(cpLen(prefix) + cpLen(replacementParts[0]));
|
|
830
633
|
}
|
|
634
|
+
return newLines;
|
|
635
|
+
});
|
|
636
|
+
setPreferredCol(null);
|
|
637
|
+
return true;
|
|
638
|
+
}, [pushUndo, lines, currentLine, currentLineLen, setPreferredCol]);
|
|
639
|
+
const deleteWordLeft = useCallback(() => {
|
|
640
|
+
dbg('deleteWordLeft', { beforeCursor: [cursorRow, cursorCol] });
|
|
641
|
+
if (cursorCol === 0 && cursorRow === 0)
|
|
642
|
+
return;
|
|
643
|
+
if (cursorCol === 0) {
|
|
644
|
+
backspace();
|
|
645
|
+
return;
|
|
831
646
|
}
|
|
832
|
-
|
|
833
|
-
|
|
647
|
+
pushUndo();
|
|
648
|
+
const lineContent = currentLine(cursorRow);
|
|
649
|
+
const arr = toCodePoints(lineContent);
|
|
650
|
+
let start = cursorCol;
|
|
651
|
+
let onlySpaces = true;
|
|
652
|
+
for (let i = 0; i < start; i++) {
|
|
653
|
+
if (isWordChar(arr[i])) {
|
|
654
|
+
onlySpaces = false;
|
|
655
|
+
break;
|
|
656
|
+
}
|
|
834
657
|
}
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
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
|
-
}, []);
|
|
658
|
+
if (onlySpaces && start > 0) {
|
|
659
|
+
start--;
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
while (start > 0 && !isWordChar(arr[start - 1]))
|
|
663
|
+
start--;
|
|
664
|
+
while (start > 0 && isWordChar(arr[start - 1]))
|
|
665
|
+
start--;
|
|
666
|
+
}
|
|
667
|
+
setLines((prevLines) => {
|
|
668
|
+
const newLines = [...prevLines];
|
|
669
|
+
newLines[cursorRow] =
|
|
670
|
+
cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
|
|
671
|
+
return newLines;
|
|
672
|
+
});
|
|
673
|
+
setCursorCol(start);
|
|
674
|
+
setPreferredCol(null);
|
|
675
|
+
}, [pushUndo, cursorRow, cursorCol, currentLine, backspace, setPreferredCol]);
|
|
860
676
|
const deleteWordRight = useCallback(() => {
|
|
861
|
-
|
|
862
|
-
|
|
677
|
+
dbg('deleteWordRight', { beforeCursor: [cursorRow, cursorCol] });
|
|
678
|
+
const lineContent = currentLine(cursorRow);
|
|
679
|
+
const arr = toCodePoints(lineContent);
|
|
680
|
+
if (cursorCol >= arr.length && cursorRow === lines.length - 1)
|
|
681
|
+
return;
|
|
682
|
+
if (cursorCol >= arr.length) {
|
|
683
|
+
del();
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
pushUndo();
|
|
687
|
+
let end = cursorCol;
|
|
688
|
+
while (end < arr.length && !isWordChar(arr[end]))
|
|
689
|
+
end++;
|
|
690
|
+
while (end < arr.length && isWordChar(arr[end]))
|
|
691
|
+
end++;
|
|
692
|
+
setLines((prevLines) => {
|
|
693
|
+
const newLines = [...prevLines];
|
|
694
|
+
newLines[cursorRow] =
|
|
695
|
+
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
|
|
696
|
+
return newLines;
|
|
697
|
+
});
|
|
698
|
+
setPreferredCol(null);
|
|
699
|
+
}, [
|
|
700
|
+
pushUndo,
|
|
701
|
+
cursorRow,
|
|
702
|
+
cursorCol,
|
|
703
|
+
currentLine,
|
|
704
|
+
del,
|
|
705
|
+
lines.length,
|
|
706
|
+
setPreferredCol,
|
|
707
|
+
]);
|
|
863
708
|
const killLineRight = useCallback(() => {
|
|
864
|
-
|
|
865
|
-
|
|
709
|
+
const lineContent = currentLine(cursorRow);
|
|
710
|
+
if (cursorCol < currentLineLen(cursorRow)) {
|
|
711
|
+
// Cursor is before the end of the line's content, delete text to the right
|
|
712
|
+
pushUndo();
|
|
713
|
+
setLines((prevLines) => {
|
|
714
|
+
const newLines = [...prevLines];
|
|
715
|
+
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
|
716
|
+
return newLines;
|
|
717
|
+
});
|
|
718
|
+
// Cursor position and preferredCol do not change in this case
|
|
719
|
+
}
|
|
720
|
+
else if (cursorCol === currentLineLen(cursorRow) &&
|
|
721
|
+
cursorRow < lines.length - 1) {
|
|
722
|
+
// Cursor is at the end of the line's content (or line is empty),
|
|
723
|
+
// and it's not the last line. Delete the newline.
|
|
724
|
+
// `del()` handles pushUndo and setPreferredCol.
|
|
725
|
+
del();
|
|
726
|
+
}
|
|
727
|
+
// If cursor is at the end of the line and it's the last line, do nothing.
|
|
728
|
+
}, [
|
|
729
|
+
pushUndo,
|
|
730
|
+
cursorRow,
|
|
731
|
+
cursorCol,
|
|
732
|
+
currentLine,
|
|
733
|
+
currentLineLen,
|
|
734
|
+
lines.length,
|
|
735
|
+
del,
|
|
736
|
+
]);
|
|
866
737
|
const killLineLeft = useCallback(() => {
|
|
867
|
-
|
|
868
|
-
|
|
738
|
+
const lineContent = currentLine(cursorRow);
|
|
739
|
+
// Only act if the cursor is not at the beginning of the line
|
|
740
|
+
if (cursorCol > 0) {
|
|
741
|
+
pushUndo();
|
|
742
|
+
setLines((prevLines) => {
|
|
743
|
+
const newLines = [...prevLines];
|
|
744
|
+
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
|
|
745
|
+
return newLines;
|
|
746
|
+
});
|
|
747
|
+
setCursorCol(0);
|
|
748
|
+
setPreferredCol(null);
|
|
749
|
+
}
|
|
750
|
+
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
|
|
751
|
+
const move = useCallback((dir) => {
|
|
752
|
+
let newVisualRow = visualCursor[0];
|
|
753
|
+
let newVisualCol = visualCursor[1];
|
|
754
|
+
let newPreferredCol = preferredCol;
|
|
755
|
+
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
|
|
756
|
+
switch (dir) {
|
|
757
|
+
case 'left':
|
|
758
|
+
newPreferredCol = null;
|
|
759
|
+
if (newVisualCol > 0) {
|
|
760
|
+
newVisualCol--;
|
|
761
|
+
}
|
|
762
|
+
else if (newVisualRow > 0) {
|
|
763
|
+
newVisualRow--;
|
|
764
|
+
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
|
|
765
|
+
}
|
|
766
|
+
break;
|
|
767
|
+
case 'right':
|
|
768
|
+
newPreferredCol = null;
|
|
769
|
+
if (newVisualCol < currentVisLineLen) {
|
|
770
|
+
newVisualCol++;
|
|
771
|
+
}
|
|
772
|
+
else if (newVisualRow < visualLines.length - 1) {
|
|
773
|
+
newVisualRow++;
|
|
774
|
+
newVisualCol = 0;
|
|
775
|
+
}
|
|
776
|
+
break;
|
|
777
|
+
case 'up':
|
|
778
|
+
if (newVisualRow > 0) {
|
|
779
|
+
if (newPreferredCol === null)
|
|
780
|
+
newPreferredCol = newVisualCol;
|
|
781
|
+
newVisualRow--;
|
|
782
|
+
newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
|
|
783
|
+
}
|
|
784
|
+
break;
|
|
785
|
+
case 'down':
|
|
786
|
+
if (newVisualRow < visualLines.length - 1) {
|
|
787
|
+
if (newPreferredCol === null)
|
|
788
|
+
newPreferredCol = newVisualCol;
|
|
789
|
+
newVisualRow++;
|
|
790
|
+
newVisualCol = clamp(newPreferredCol, 0, cpLen(visualLines[newVisualRow] ?? ''));
|
|
791
|
+
}
|
|
792
|
+
break;
|
|
793
|
+
case 'home':
|
|
794
|
+
newPreferredCol = null;
|
|
795
|
+
newVisualCol = 0;
|
|
796
|
+
break;
|
|
797
|
+
case 'end':
|
|
798
|
+
newPreferredCol = null;
|
|
799
|
+
newVisualCol = currentVisLineLen;
|
|
800
|
+
break;
|
|
801
|
+
// wordLeft and wordRight might need more sophisticated visual handling
|
|
802
|
+
// For now, they operate on the logical line derived from the visual cursor
|
|
803
|
+
case 'wordLeft': {
|
|
804
|
+
newPreferredCol = null;
|
|
805
|
+
if (visualToLogicalMap.length === 0 ||
|
|
806
|
+
logicalToVisualMap.length === 0)
|
|
807
|
+
break;
|
|
808
|
+
const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
|
|
809
|
+
0, 0,
|
|
810
|
+
];
|
|
811
|
+
const currentLogCol = logColInitial + newVisualCol;
|
|
812
|
+
const lineText = lines[logRow];
|
|
813
|
+
const sliceToCursor = cpSlice(lineText, 0, currentLogCol).replace(/[\s,.;!?]+$/, '');
|
|
814
|
+
let lastIdx = 0;
|
|
815
|
+
const regex = /[\s,.;!?]+/g;
|
|
816
|
+
let m;
|
|
817
|
+
while ((m = regex.exec(sliceToCursor)) != null)
|
|
818
|
+
lastIdx = m.index;
|
|
819
|
+
const newLogicalCol = lastIdx === 0 ? 0 : cpLen(sliceToCursor.slice(0, lastIdx)) + 1;
|
|
820
|
+
// Map newLogicalCol back to visual
|
|
821
|
+
const targetLogicalMapEntries = logicalToVisualMap[logRow];
|
|
822
|
+
if (!targetLogicalMapEntries)
|
|
823
|
+
break;
|
|
824
|
+
for (let i = targetLogicalMapEntries.length - 1; i >= 0; i--) {
|
|
825
|
+
const [visRow, logStartCol] = targetLogicalMapEntries[i];
|
|
826
|
+
if (newLogicalCol >= logStartCol) {
|
|
827
|
+
newVisualRow = visRow;
|
|
828
|
+
newVisualCol = newLogicalCol - logStartCol;
|
|
829
|
+
break;
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
break;
|
|
833
|
+
}
|
|
834
|
+
case 'wordRight': {
|
|
835
|
+
newPreferredCol = null;
|
|
836
|
+
if (visualToLogicalMap.length === 0 ||
|
|
837
|
+
logicalToVisualMap.length === 0)
|
|
838
|
+
break;
|
|
839
|
+
const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
|
|
840
|
+
0, 0,
|
|
841
|
+
];
|
|
842
|
+
const currentLogCol = logColInitial + newVisualCol;
|
|
843
|
+
const lineText = lines[logRow];
|
|
844
|
+
const regex = /[\s,.;!?]+/g;
|
|
845
|
+
let moved = false;
|
|
846
|
+
let m;
|
|
847
|
+
let newLogicalCol = currentLineLen(logRow); // Default to end of logical line
|
|
848
|
+
while ((m = regex.exec(lineText)) != null) {
|
|
849
|
+
const cpIdx = cpLen(lineText.slice(0, m.index));
|
|
850
|
+
if (cpIdx > currentLogCol) {
|
|
851
|
+
newLogicalCol = cpIdx;
|
|
852
|
+
moved = true;
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
if (!moved && currentLogCol < currentLineLen(logRow)) {
|
|
857
|
+
// If no word break found after cursor, move to end
|
|
858
|
+
newLogicalCol = currentLineLen(logRow);
|
|
859
|
+
}
|
|
860
|
+
// Map newLogicalCol back to visual
|
|
861
|
+
const targetLogicalMapEntries = logicalToVisualMap[logRow];
|
|
862
|
+
if (!targetLogicalMapEntries)
|
|
863
|
+
break;
|
|
864
|
+
for (let i = 0; i < targetLogicalMapEntries.length; i++) {
|
|
865
|
+
const [visRow, logStartCol] = targetLogicalMapEntries[i];
|
|
866
|
+
const nextLogStartCol = i + 1 < targetLogicalMapEntries.length
|
|
867
|
+
? targetLogicalMapEntries[i + 1][1]
|
|
868
|
+
: Infinity;
|
|
869
|
+
if (newLogicalCol >= logStartCol &&
|
|
870
|
+
newLogicalCol < nextLogStartCol) {
|
|
871
|
+
newVisualRow = visRow;
|
|
872
|
+
newVisualCol = newLogicalCol - logStartCol;
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
if (newLogicalCol === logStartCol &&
|
|
876
|
+
i === targetLogicalMapEntries.length - 1 &&
|
|
877
|
+
cpLen(visualLines[visRow] ?? '') === 0) {
|
|
878
|
+
// Special case: moving to an empty visual line at the end of a logical line
|
|
879
|
+
newVisualRow = visRow;
|
|
880
|
+
newVisualCol = 0;
|
|
881
|
+
break;
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
break;
|
|
885
|
+
}
|
|
886
|
+
default:
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
setVisualCursor([newVisualRow, newVisualCol]);
|
|
890
|
+
setPreferredCol(newPreferredCol);
|
|
891
|
+
// Update logical cursor based on new visual cursor
|
|
892
|
+
if (visualToLogicalMap[newVisualRow]) {
|
|
893
|
+
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
|
894
|
+
setCursorRow(logRow);
|
|
895
|
+
setCursorCol(clamp(logStartCol + newVisualCol, 0, currentLineLen(logRow)));
|
|
896
|
+
}
|
|
897
|
+
dbg('move', {
|
|
898
|
+
dir,
|
|
899
|
+
visualBefore: visualCursor,
|
|
900
|
+
visualAfter: [newVisualRow, newVisualCol],
|
|
901
|
+
logicalAfter: [cursorRow, cursorCol],
|
|
902
|
+
});
|
|
903
|
+
}, [
|
|
904
|
+
visualCursor,
|
|
905
|
+
visualLines,
|
|
906
|
+
preferredCol,
|
|
907
|
+
lines,
|
|
908
|
+
currentLineLen,
|
|
909
|
+
visualToLogicalMap,
|
|
910
|
+
logicalToVisualMap,
|
|
911
|
+
cursorCol,
|
|
912
|
+
cursorRow,
|
|
913
|
+
]);
|
|
869
914
|
const openInExternalEditor = useCallback(async (opts = {}) => {
|
|
870
915
|
const editor = opts.editor ??
|
|
871
916
|
process.env['VISUAL'] ??
|
|
@@ -874,7 +919,7 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
874
919
|
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
|
|
875
920
|
const filePath = pathMod.join(tmpDir, 'buffer.txt');
|
|
876
921
|
fs.writeFileSync(filePath, text, 'utf8');
|
|
877
|
-
|
|
922
|
+
pushUndo(); // Snapshot before external edit
|
|
878
923
|
const wasRaw = stdin?.isRaw ?? false;
|
|
879
924
|
try {
|
|
880
925
|
setRawMode?.(false);
|
|
@@ -887,10 +932,11 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
887
932
|
throw new Error(`External editor exited with status ${status}`);
|
|
888
933
|
let newText = fs.readFileSync(filePath, 'utf8');
|
|
889
934
|
newText = newText.replace(/\r\n?/g, '\n');
|
|
890
|
-
|
|
935
|
+
setText(newText);
|
|
891
936
|
}
|
|
892
937
|
catch (err) {
|
|
893
938
|
console.error('[useTextBuffer] external editor error', err);
|
|
939
|
+
// TODO(jacobr): potentially revert or handle error state.
|
|
894
940
|
}
|
|
895
941
|
finally {
|
|
896
942
|
if (wasRaw)
|
|
@@ -908,9 +954,19 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
908
954
|
/* ignore */
|
|
909
955
|
}
|
|
910
956
|
}
|
|
911
|
-
}, [text, stdin, setRawMode]);
|
|
957
|
+
}, [text, pushUndo, stdin, setRawMode, setText]);
|
|
912
958
|
const handleInput = useCallback((key) => {
|
|
913
959
|
const { sequence: input } = key;
|
|
960
|
+
dbg('handleInput', {
|
|
961
|
+
key,
|
|
962
|
+
cursor: [cursorRow, cursorCol],
|
|
963
|
+
visualCursor,
|
|
964
|
+
});
|
|
965
|
+
const beforeText = text;
|
|
966
|
+
const beforeLogicalCursor = [cursorRow, cursorCol];
|
|
967
|
+
const beforeVisualCursor = [...visualCursor];
|
|
968
|
+
if (key.name === 'escape')
|
|
969
|
+
return false;
|
|
914
970
|
if (key.name === 'return' ||
|
|
915
971
|
input === '\r' ||
|
|
916
972
|
input === '\n' ||
|
|
@@ -961,22 +1017,47 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
961
1017
|
else if (input && !key.ctrl && !key.meta) {
|
|
962
1018
|
insert(input);
|
|
963
1019
|
}
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1020
|
+
const textChanged = text !== beforeText;
|
|
1021
|
+
// After operations, visualCursor might not be immediately updated if the change
|
|
1022
|
+
// was to `lines`, `cursorRow`, or `cursorCol` which then triggers the useEffect.
|
|
1023
|
+
// So, for return value, we check logical cursor change.
|
|
1024
|
+
const cursorChanged = cursorRow !== beforeLogicalCursor[0] ||
|
|
1025
|
+
cursorCol !== beforeLogicalCursor[1] ||
|
|
1026
|
+
visualCursor[0] !== beforeVisualCursor[0] ||
|
|
1027
|
+
visualCursor[1] !== beforeVisualCursor[1];
|
|
1028
|
+
dbg('handleInput:after', {
|
|
1029
|
+
cursor: [cursorRow, cursorCol],
|
|
1030
|
+
visualCursor,
|
|
1031
|
+
text,
|
|
970
1032
|
});
|
|
971
|
-
|
|
1033
|
+
return textChanged || cursorChanged;
|
|
1034
|
+
}, [
|
|
1035
|
+
text,
|
|
1036
|
+
cursorRow,
|
|
1037
|
+
cursorCol,
|
|
1038
|
+
visualCursor,
|
|
1039
|
+
newline,
|
|
1040
|
+
move,
|
|
1041
|
+
deleteWordLeft,
|
|
1042
|
+
deleteWordRight,
|
|
1043
|
+
backspace,
|
|
1044
|
+
del,
|
|
1045
|
+
insert,
|
|
1046
|
+
]);
|
|
1047
|
+
const renderedVisualLines = useMemo(() => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height), [visualLines, visualScrollRow, viewport.height]);
|
|
972
1048
|
const replaceRangeByOffset = useCallback((startOffset, endOffset, replacementText) => {
|
|
1049
|
+
dbg('replaceRangeByOffset', { startOffset, endOffset, replacementText });
|
|
973
1050
|
const [startRow, startCol] = offsetToLogicalPos(text, startOffset);
|
|
974
1051
|
const [endRow, endCol] = offsetToLogicalPos(text, endOffset);
|
|
975
|
-
replaceRange(startRow, startCol, endRow, endCol, replacementText);
|
|
1052
|
+
return replaceRange(startRow, startCol, endRow, endCol, replacementText);
|
|
976
1053
|
}, [text, replaceRange]);
|
|
977
1054
|
const moveToOffset = useCallback((offset) => {
|
|
978
|
-
|
|
979
|
-
|
|
1055
|
+
const [newRow, newCol] = offsetToLogicalPos(text, offset);
|
|
1056
|
+
setCursorRow(newRow);
|
|
1057
|
+
setCursorCol(newCol);
|
|
1058
|
+
setPreferredCol(null);
|
|
1059
|
+
dbg('moveToOffset', { offset, newCursor: [newRow, newCol] });
|
|
1060
|
+
}, [text, setPreferredCol]);
|
|
980
1061
|
const returnValue = {
|
|
981
1062
|
lines,
|
|
982
1063
|
text,
|
|
@@ -997,13 +1078,44 @@ export function useTextBuffer({ initialText = '', initialCursorOffset = 0, viewp
|
|
|
997
1078
|
redo,
|
|
998
1079
|
replaceRange,
|
|
999
1080
|
replaceRangeByOffset,
|
|
1000
|
-
moveToOffset,
|
|
1081
|
+
moveToOffset, // Added here
|
|
1001
1082
|
deleteWordLeft,
|
|
1002
1083
|
deleteWordRight,
|
|
1003
1084
|
killLineRight,
|
|
1004
1085
|
killLineLeft,
|
|
1005
1086
|
handleInput,
|
|
1006
1087
|
openInExternalEditor,
|
|
1088
|
+
applyOperations,
|
|
1089
|
+
copy: useCallback(() => {
|
|
1090
|
+
if (!selectionAnchor)
|
|
1091
|
+
return null;
|
|
1092
|
+
const [ar, ac] = selectionAnchor;
|
|
1093
|
+
const [br, bc] = [cursorRow, cursorCol];
|
|
1094
|
+
if (ar === br && ac === bc)
|
|
1095
|
+
return null;
|
|
1096
|
+
const topBefore = ar < br || (ar === br && ac < bc);
|
|
1097
|
+
const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
|
|
1098
|
+
let selectedTextVal;
|
|
1099
|
+
if (sr === er) {
|
|
1100
|
+
selectedTextVal = cpSlice(currentLine(sr), sc, ec);
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
const parts = [cpSlice(currentLine(sr), sc)];
|
|
1104
|
+
for (let r = sr + 1; r < er; r++)
|
|
1105
|
+
parts.push(currentLine(r));
|
|
1106
|
+
parts.push(cpSlice(currentLine(er), 0, ec));
|
|
1107
|
+
selectedTextVal = parts.join('\n');
|
|
1108
|
+
}
|
|
1109
|
+
setClipboard(selectedTextVal);
|
|
1110
|
+
return selectedTextVal;
|
|
1111
|
+
}, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
|
|
1112
|
+
paste: useCallback(() => {
|
|
1113
|
+
if (clipboard === null)
|
|
1114
|
+
return false;
|
|
1115
|
+
applyOperations([{ type: 'insert', payload: clipboard }]);
|
|
1116
|
+
return true;
|
|
1117
|
+
}, [clipboard, applyOperations]),
|
|
1118
|
+
startSelection: useCallback(() => setSelectionAnchor([cursorRow, cursorCol]), [cursorRow, cursorCol, setSelectionAnchor]),
|
|
1007
1119
|
};
|
|
1008
1120
|
return returnValue;
|
|
1009
1121
|
}
|