2025-05-13 11:24:04 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* @license
|
|
|
|
|
|
* Copyright 2025 Google LLC
|
|
|
|
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
2025-05-16 13:17:48 -07:00
|
|
|
|
import stripAnsi from 'strip-ansi';
|
2025-05-13 11:24:04 -07:00
|
|
|
|
import { spawnSync } from 'child_process';
|
|
|
|
|
|
import fs from 'fs';
|
|
|
|
|
|
import os from 'os';
|
|
|
|
|
|
import pathMod from 'path';
|
2025-05-13 19:55:31 -07:00
|
|
|
|
import { useState, useCallback, useEffect, useMemo } from 'react';
|
2025-05-16 11:58:37 -07:00
|
|
|
|
import stringWidth from 'string-width';
|
2025-06-08 00:12:53 +02:00
|
|
|
|
import { unescapePath } from '@gemini-cli/core';
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
|
|
|
|
|
export type Direction =
|
|
|
|
|
|
| 'left'
|
|
|
|
|
|
| 'right'
|
|
|
|
|
|
| 'up'
|
|
|
|
|
|
| 'down'
|
|
|
|
|
|
| 'wordLeft'
|
|
|
|
|
|
| 'wordRight'
|
|
|
|
|
|
| 'home'
|
|
|
|
|
|
| 'end';
|
|
|
|
|
|
|
|
|
|
|
|
// Simple helper for word‑wise ops.
|
|
|
|
|
|
function isWordChar(ch: string | undefined): boolean {
|
|
|
|
|
|
if (ch === undefined) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
return !/[\s,.;!?]/.test(ch);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export interface Viewport {
|
|
|
|
|
|
height: number;
|
|
|
|
|
|
width: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function clamp(v: number, min: number, max: number): number {
|
|
|
|
|
|
return v < min ? min : v > max ? max : v;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/*
|
|
|
|
|
|
* -------------------------------------------------------------------------
|
|
|
|
|
|
* Unicode‑aware helpers (work at the code‑point level rather than UTF‑16
|
|
|
|
|
|
* code units so that surrogate‑pair emoji count as one "column".)
|
|
|
|
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
export function toCodePoints(str: string): string[] {
|
2025-05-13 11:24:04 -07:00
|
|
|
|
// [...str] or Array.from both iterate by UTF‑32 code point, handling
|
|
|
|
|
|
// surrogate pairs correctly.
|
|
|
|
|
|
return Array.from(str);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
export function cpLen(str: string): number {
|
2025-05-13 11:24:04 -07:00
|
|
|
|
return toCodePoints(str).length;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
export function cpSlice(str: string, start: number, end?: number): string {
|
2025-05-13 11:24:04 -07:00
|
|
|
|
// Slice by code‑point indices and re‑join.
|
|
|
|
|
|
const arr = toCodePoints(str).slice(start, end);
|
|
|
|
|
|
return arr.join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* -------------------------------------------------------------------------
|
|
|
|
|
|
* Debug helper – enable verbose logging by setting env var TEXTBUFFER_DEBUG=1
|
|
|
|
|
|
* ---------------------------------------------------------------------- */
|
|
|
|
|
|
|
|
|
|
|
|
// Enable verbose logging only when requested via env var.
|
|
|
|
|
|
const DEBUG =
|
|
|
|
|
|
process.env['TEXTBUFFER_DEBUG'] === '1' ||
|
|
|
|
|
|
process.env['TEXTBUFFER_DEBUG'] === 'true';
|
|
|
|
|
|
|
|
|
|
|
|
function dbg(...args: unknown[]): void {
|
|
|
|
|
|
if (DEBUG) {
|
|
|
|
|
|
console.log('[TextBuffer]', ...args);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/* ────────────────────────────────────────────────────────────────────────── */
|
|
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
interface UseTextBufferProps {
|
|
|
|
|
|
initialText?: string;
|
|
|
|
|
|
initialCursorOffset?: number;
|
|
|
|
|
|
viewport: Viewport; // Viewport dimensions needed for scrolling
|
|
|
|
|
|
stdin?: NodeJS.ReadStream | null; // For external editor
|
|
|
|
|
|
setRawMode?: (mode: boolean) => void; // For external editor
|
|
|
|
|
|
onChange?: (text: string) => void; // Callback for when text changes
|
2025-06-07 14:48:56 -07:00
|
|
|
|
isValidPath: (path: string) => boolean;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
}
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
interface UndoHistoryEntry {
|
|
|
|
|
|
lines: string[];
|
|
|
|
|
|
cursorRow: number;
|
|
|
|
|
|
cursorCol: number;
|
|
|
|
|
|
}
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
function calculateInitialCursorPosition(
|
|
|
|
|
|
initialLines: string[],
|
|
|
|
|
|
offset: number,
|
|
|
|
|
|
): [number, number] {
|
|
|
|
|
|
let remainingChars = offset;
|
|
|
|
|
|
let row = 0;
|
|
|
|
|
|
while (row < initialLines.length) {
|
|
|
|
|
|
const lineLength = cpLen(initialLines[row]);
|
|
|
|
|
|
// Add 1 for the newline character (except for the last line)
|
|
|
|
|
|
const totalCharsInLineAndNewline =
|
|
|
|
|
|
lineLength + (row < initialLines.length - 1 ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (remainingChars <= lineLength) {
|
|
|
|
|
|
// Cursor is on this line
|
|
|
|
|
|
return [row, remainingChars];
|
|
|
|
|
|
}
|
|
|
|
|
|
remainingChars -= totalCharsInLineAndNewline;
|
|
|
|
|
|
row++;
|
|
|
|
|
|
}
|
|
|
|
|
|
// Offset is beyond the text, place cursor at the end of the last line
|
|
|
|
|
|
if (initialLines.length > 0) {
|
|
|
|
|
|
const lastRow = initialLines.length - 1;
|
|
|
|
|
|
return [lastRow, cpLen(initialLines[lastRow])];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [0, 0]; // Default for empty text
|
|
|
|
|
|
}
|
2025-05-20 16:50:32 -07:00
|
|
|
|
|
|
|
|
|
|
export function offsetToLogicalPos(
|
|
|
|
|
|
text: string,
|
|
|
|
|
|
offset: number,
|
|
|
|
|
|
): [number, number] {
|
|
|
|
|
|
let row = 0;
|
|
|
|
|
|
let col = 0;
|
|
|
|
|
|
let currentOffset = 0;
|
|
|
|
|
|
|
|
|
|
|
|
if (offset === 0) return [0, 0];
|
|
|
|
|
|
|
|
|
|
|
|
const lines = text.split('\n');
|
|
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
|
|
|
|
const line = lines[i];
|
|
|
|
|
|
const lineLength = cpLen(line);
|
|
|
|
|
|
const lineLengthWithNewline = lineLength + (i < lines.length - 1 ? 1 : 0);
|
|
|
|
|
|
|
|
|
|
|
|
if (offset <= currentOffset + lineLength) {
|
|
|
|
|
|
// Check against lineLength first
|
|
|
|
|
|
row = i;
|
|
|
|
|
|
col = offset - currentOffset;
|
|
|
|
|
|
return [row, col];
|
|
|
|
|
|
} else if (offset <= currentOffset + lineLengthWithNewline) {
|
|
|
|
|
|
// Check if offset is the newline itself
|
|
|
|
|
|
row = i;
|
|
|
|
|
|
col = lineLength; // Position cursor at the end of the current line content
|
|
|
|
|
|
// If the offset IS the newline, and it's not the last line, advance to next line, col 0
|
|
|
|
|
|
if (
|
|
|
|
|
|
offset === currentOffset + lineLengthWithNewline &&
|
|
|
|
|
|
i < lines.length - 1
|
|
|
|
|
|
) {
|
|
|
|
|
|
return [i + 1, 0];
|
|
|
|
|
|
}
|
|
|
|
|
|
return [row, col]; // Otherwise, it's at the end of the current line content
|
|
|
|
|
|
}
|
|
|
|
|
|
currentOffset += lineLengthWithNewline;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If offset is beyond the text length, place cursor at the end of the last line
|
|
|
|
|
|
// or [0,0] if text is empty
|
|
|
|
|
|
if (lines.length > 0) {
|
|
|
|
|
|
row = lines.length - 1;
|
|
|
|
|
|
col = cpLen(lines[row]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
row = 0;
|
|
|
|
|
|
col = 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
return [row, col];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// Helper to calculate visual lines and map cursor positions
|
|
|
|
|
|
function calculateVisualLayout(
|
|
|
|
|
|
logicalLines: string[],
|
|
|
|
|
|
logicalCursor: [number, number],
|
|
|
|
|
|
viewportWidth: number,
|
|
|
|
|
|
): {
|
|
|
|
|
|
visualLines: string[];
|
|
|
|
|
|
visualCursor: [number, number];
|
|
|
|
|
|
logicalToVisualMap: Array<Array<[number, number]>>; // For each logical line, an array of [visualLineIndex, startColInLogical]
|
|
|
|
|
|
visualToLogicalMap: Array<[number, number]>; // For each visual line, its [logicalLineIndex, startColInLogical]
|
|
|
|
|
|
} {
|
|
|
|
|
|
const visualLines: string[] = [];
|
|
|
|
|
|
const logicalToVisualMap: Array<Array<[number, number]>> = [];
|
|
|
|
|
|
const visualToLogicalMap: Array<[number, number]> = [];
|
|
|
|
|
|
let currentVisualCursor: [number, number] = [0, 0];
|
|
|
|
|
|
|
|
|
|
|
|
logicalLines.forEach((logLine, logIndex) => {
|
|
|
|
|
|
logicalToVisualMap[logIndex] = [];
|
|
|
|
|
|
if (logLine.length === 0) {
|
|
|
|
|
|
// Handle empty logical line
|
|
|
|
|
|
logicalToVisualMap[logIndex].push([visualLines.length, 0]);
|
|
|
|
|
|
visualToLogicalMap.push([logIndex, 0]);
|
|
|
|
|
|
visualLines.push('');
|
|
|
|
|
|
if (logIndex === logicalCursor[0] && logicalCursor[1] === 0) {
|
|
|
|
|
|
currentVisualCursor = [visualLines.length - 1, 0];
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Non-empty logical line
|
|
|
|
|
|
let currentPosInLogLine = 0; // Tracks position within the current logical line (code point index)
|
|
|
|
|
|
const codePointsInLogLine = toCodePoints(logLine);
|
|
|
|
|
|
|
|
|
|
|
|
while (currentPosInLogLine < codePointsInLogLine.length) {
|
|
|
|
|
|
let currentChunk = '';
|
|
|
|
|
|
let currentChunkVisualWidth = 0;
|
|
|
|
|
|
let numCodePointsInChunk = 0;
|
|
|
|
|
|
let lastWordBreakPoint = -1; // Index in codePointsInLogLine for word break
|
|
|
|
|
|
let numCodePointsAtLastWordBreak = 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Iterate through code points to build the current visual line (chunk)
|
|
|
|
|
|
for (let i = currentPosInLogLine; i < codePointsInLogLine.length; i++) {
|
|
|
|
|
|
const char = codePointsInLogLine[i];
|
|
|
|
|
|
const charVisualWidth = stringWidth(char);
|
|
|
|
|
|
|
|
|
|
|
|
if (currentChunkVisualWidth + charVisualWidth > viewportWidth) {
|
|
|
|
|
|
// Character would exceed viewport width
|
|
|
|
|
|
if (
|
|
|
|
|
|
lastWordBreakPoint !== -1 &&
|
|
|
|
|
|
numCodePointsAtLastWordBreak > 0 &&
|
|
|
|
|
|
currentPosInLogLine + numCodePointsAtLastWordBreak < i
|
|
|
|
|
|
) {
|
|
|
|
|
|
// We have a valid word break point to use, and it's not the start of the current segment
|
|
|
|
|
|
currentChunk = codePointsInLogLine
|
|
|
|
|
|
.slice(
|
|
|
|
|
|
currentPosInLogLine,
|
|
|
|
|
|
currentPosInLogLine + numCodePointsAtLastWordBreak,
|
|
|
|
|
|
)
|
|
|
|
|
|
.join('');
|
|
|
|
|
|
numCodePointsInChunk = numCodePointsAtLastWordBreak;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// No word break, or word break is at the start of this potential chunk, or word break leads to empty chunk.
|
|
|
|
|
|
// Hard break: take characters up to viewportWidth, or just the current char if it alone is too wide.
|
|
|
|
|
|
if (
|
|
|
|
|
|
numCodePointsInChunk === 0 &&
|
|
|
|
|
|
charVisualWidth > viewportWidth
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Single character is wider than viewport, take it anyway
|
|
|
|
|
|
currentChunk = char;
|
|
|
|
|
|
numCodePointsInChunk = 1;
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
numCodePointsInChunk === 0 &&
|
|
|
|
|
|
charVisualWidth <= viewportWidth
|
|
|
|
|
|
) {
|
|
|
|
|
|
// This case should ideally be caught by the next iteration if the char fits.
|
|
|
|
|
|
// If it doesn't fit (because currentChunkVisualWidth was already > 0 from a previous char that filled the line),
|
|
|
|
|
|
// then numCodePointsInChunk would not be 0.
|
|
|
|
|
|
// This branch means the current char *itself* doesn't fit an empty line, which is handled by the above.
|
|
|
|
|
|
// If we are here, it means the loop should break and the current chunk (which is empty) is finalized.
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
break; // Break from inner loop to finalize this chunk
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
currentChunk += char;
|
|
|
|
|
|
currentChunkVisualWidth += charVisualWidth;
|
|
|
|
|
|
numCodePointsInChunk++;
|
|
|
|
|
|
|
|
|
|
|
|
// Check for word break opportunity (space)
|
|
|
|
|
|
if (char === ' ') {
|
|
|
|
|
|
lastWordBreakPoint = i; // Store code point index of the space
|
|
|
|
|
|
// Store the state *before* adding the space, if we decide to break here.
|
|
|
|
|
|
numCodePointsAtLastWordBreak = numCodePointsInChunk - 1; // Chars *before* the space
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If the inner loop completed without breaking (i.e., remaining text fits)
|
|
|
|
|
|
// or if the loop broke but numCodePointsInChunk is still 0 (e.g. first char too wide for empty line)
|
|
|
|
|
|
if (
|
|
|
|
|
|
numCodePointsInChunk === 0 &&
|
|
|
|
|
|
currentPosInLogLine < codePointsInLogLine.length
|
|
|
|
|
|
) {
|
|
|
|
|
|
// This can happen if the very first character considered for a new visual line is wider than the viewport.
|
|
|
|
|
|
// In this case, we take that single character.
|
|
|
|
|
|
const firstChar = codePointsInLogLine[currentPosInLogLine];
|
|
|
|
|
|
currentChunk = firstChar;
|
|
|
|
|
|
numCodePointsInChunk = 1; // Ensure we advance
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// If after everything, numCodePointsInChunk is still 0 but we haven't processed the whole logical line,
|
|
|
|
|
|
// it implies an issue, like viewportWidth being 0 or less. Avoid infinite loop.
|
|
|
|
|
|
if (
|
|
|
|
|
|
numCodePointsInChunk === 0 &&
|
|
|
|
|
|
currentPosInLogLine < codePointsInLogLine.length
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Force advance by one character to prevent infinite loop if something went wrong
|
|
|
|
|
|
currentChunk = codePointsInLogLine[currentPosInLogLine];
|
|
|
|
|
|
numCodePointsInChunk = 1;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
logicalToVisualMap[logIndex].push([
|
|
|
|
|
|
visualLines.length,
|
|
|
|
|
|
currentPosInLogLine,
|
|
|
|
|
|
]);
|
|
|
|
|
|
visualToLogicalMap.push([logIndex, currentPosInLogLine]);
|
|
|
|
|
|
visualLines.push(currentChunk);
|
|
|
|
|
|
|
|
|
|
|
|
// Cursor mapping logic
|
|
|
|
|
|
// Note: currentPosInLogLine here is the start of the currentChunk within the logical line.
|
|
|
|
|
|
if (logIndex === logicalCursor[0]) {
|
|
|
|
|
|
const cursorLogCol = logicalCursor[1]; // This is a code point index
|
|
|
|
|
|
if (
|
|
|
|
|
|
cursorLogCol >= currentPosInLogLine &&
|
|
|
|
|
|
cursorLogCol < currentPosInLogLine + numCodePointsInChunk // Cursor is within this chunk
|
|
|
|
|
|
) {
|
|
|
|
|
|
currentVisualCursor = [
|
|
|
|
|
|
visualLines.length - 1,
|
|
|
|
|
|
cursorLogCol - currentPosInLogLine, // Visual col is also code point index within visual line
|
|
|
|
|
|
];
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
cursorLogCol === currentPosInLogLine + numCodePointsInChunk &&
|
|
|
|
|
|
numCodePointsInChunk > 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Cursor is exactly at the end of this non-empty chunk
|
|
|
|
|
|
currentVisualCursor = [
|
|
|
|
|
|
visualLines.length - 1,
|
|
|
|
|
|
numCodePointsInChunk,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const logicalStartOfThisChunk = currentPosInLogLine;
|
|
|
|
|
|
currentPosInLogLine += numCodePointsInChunk;
|
|
|
|
|
|
|
|
|
|
|
|
// If the chunk processed did not consume the entire logical line,
|
|
|
|
|
|
// and the character immediately following the chunk is a space,
|
|
|
|
|
|
// advance past this space as it acted as a delimiter for word wrapping.
|
|
|
|
|
|
if (
|
|
|
|
|
|
logicalStartOfThisChunk + numCodePointsInChunk <
|
|
|
|
|
|
codePointsInLogLine.length &&
|
|
|
|
|
|
currentPosInLogLine < codePointsInLogLine.length && // Redundant if previous is true, but safe
|
|
|
|
|
|
codePointsInLogLine[currentPosInLogLine] === ' '
|
|
|
|
|
|
) {
|
|
|
|
|
|
currentPosInLogLine++;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
// After all chunks of a non-empty logical line are processed,
|
|
|
|
|
|
// if the cursor is at the very end of this logical line, update visual cursor.
|
|
|
|
|
|
if (
|
|
|
|
|
|
logIndex === logicalCursor[0] &&
|
|
|
|
|
|
logicalCursor[1] === codePointsInLogLine.length // Cursor at end of logical line
|
|
|
|
|
|
) {
|
|
|
|
|
|
const lastVisualLineIdx = visualLines.length - 1;
|
|
|
|
|
|
if (
|
|
|
|
|
|
lastVisualLineIdx >= 0 &&
|
|
|
|
|
|
visualLines[lastVisualLineIdx] !== undefined
|
|
|
|
|
|
) {
|
|
|
|
|
|
currentVisualCursor = [
|
|
|
|
|
|
lastVisualLineIdx,
|
|
|
|
|
|
cpLen(visualLines[lastVisualLineIdx]), // Cursor at end of last visual line for this logical line
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// If the entire logical text was empty, ensure there's one empty visual line.
|
|
|
|
|
|
if (
|
|
|
|
|
|
logicalLines.length === 0 ||
|
|
|
|
|
|
(logicalLines.length === 1 && logicalLines[0] === '')
|
|
|
|
|
|
) {
|
|
|
|
|
|
if (visualLines.length === 0) {
|
|
|
|
|
|
visualLines.push('');
|
|
|
|
|
|
if (!logicalToVisualMap[0]) logicalToVisualMap[0] = [];
|
|
|
|
|
|
logicalToVisualMap[0].push([0, 0]);
|
|
|
|
|
|
visualToLogicalMap.push([0, 0]);
|
|
|
|
|
|
}
|
|
|
|
|
|
currentVisualCursor = [0, 0];
|
|
|
|
|
|
}
|
|
|
|
|
|
// Handle cursor at the very end of the text (after all processing)
|
|
|
|
|
|
// This case might be covered by the loop end condition now, but kept for safety.
|
|
|
|
|
|
else if (
|
|
|
|
|
|
logicalCursor[0] === logicalLines.length - 1 &&
|
|
|
|
|
|
logicalCursor[1] === cpLen(logicalLines[logicalLines.length - 1]) &&
|
|
|
|
|
|
visualLines.length > 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
const lastVisLineIdx = visualLines.length - 1;
|
|
|
|
|
|
currentVisualCursor = [lastVisLineIdx, cpLen(visualLines[lastVisLineIdx])];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
visualLines,
|
|
|
|
|
|
visualCursor: currentVisualCursor,
|
|
|
|
|
|
logicalToVisualMap,
|
|
|
|
|
|
visualToLogicalMap,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
export function useTextBuffer({
|
|
|
|
|
|
initialText = '',
|
|
|
|
|
|
initialCursorOffset = 0,
|
|
|
|
|
|
viewport,
|
|
|
|
|
|
stdin,
|
|
|
|
|
|
setRawMode,
|
|
|
|
|
|
onChange,
|
2025-06-07 14:48:56 -07:00
|
|
|
|
isValidPath,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
}: UseTextBufferProps): TextBuffer {
|
|
|
|
|
|
const [lines, setLines] = useState<string[]>(() => {
|
|
|
|
|
|
const l = initialText.split('\n');
|
|
|
|
|
|
return l.length === 0 ? [''] : l;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const [[initialCursorRow, initialCursorCol]] = useState(() =>
|
|
|
|
|
|
calculateInitialCursorPosition(lines, initialCursorOffset),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const [cursorRow, setCursorRow] = useState<number>(initialCursorRow);
|
|
|
|
|
|
const [cursorCol, setCursorCol] = useState<number>(initialCursorCol);
|
2025-05-16 11:58:37 -07:00
|
|
|
|
const [preferredCol, setPreferredCol] = useState<number | null>(null); // Visual preferred col
|
2025-05-13 19:55:31 -07:00
|
|
|
|
|
|
|
|
|
|
const [undoStack, setUndoStack] = useState<UndoHistoryEntry[]>([]);
|
|
|
|
|
|
const [redoStack, setRedoStack] = useState<UndoHistoryEntry[]>([]);
|
|
|
|
|
|
const historyLimit = 100;
|
|
|
|
|
|
|
|
|
|
|
|
const [clipboard, setClipboard] = useState<string | null>(null);
|
|
|
|
|
|
const [selectionAnchor, setSelectionAnchor] = useState<
|
|
|
|
|
|
[number, number] | null
|
2025-05-16 11:58:37 -07:00
|
|
|
|
>(null); // Logical selection
|
|
|
|
|
|
|
|
|
|
|
|
// Visual state
|
|
|
|
|
|
const [visualLines, setVisualLines] = useState<string[]>(['']);
|
|
|
|
|
|
const [visualCursor, setVisualCursor] = useState<[number, number]>([0, 0]);
|
|
|
|
|
|
const [visualScrollRow, setVisualScrollRow] = useState<number>(0);
|
|
|
|
|
|
const [logicalToVisualMap, setLogicalToVisualMap] = useState<
|
|
|
|
|
|
Array<Array<[number, number]>>
|
|
|
|
|
|
>([]);
|
|
|
|
|
|
const [visualToLogicalMap, setVisualToLogicalMap] = useState<
|
|
|
|
|
|
Array<[number, number]>
|
|
|
|
|
|
>([]);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
|
|
|
|
|
|
const currentLine = useCallback(
|
|
|
|
|
|
(r: number): string => lines[r] ?? '',
|
|
|
|
|
|
[lines],
|
|
|
|
|
|
);
|
|
|
|
|
|
const currentLineLen = useCallback(
|
|
|
|
|
|
(r: number): number => cpLen(currentLine(r)),
|
|
|
|
|
|
[currentLine],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// Recalculate visual layout whenever logical lines or viewport width changes
|
2025-05-13 19:55:31 -07:00
|
|
|
|
useEffect(() => {
|
2025-05-16 11:58:37 -07:00
|
|
|
|
const layout = calculateVisualLayout(
|
|
|
|
|
|
lines,
|
|
|
|
|
|
[cursorRow, cursorCol],
|
|
|
|
|
|
viewport.width,
|
|
|
|
|
|
);
|
|
|
|
|
|
setVisualLines(layout.visualLines);
|
|
|
|
|
|
setVisualCursor(layout.visualCursor);
|
|
|
|
|
|
setLogicalToVisualMap(layout.logicalToVisualMap);
|
|
|
|
|
|
setVisualToLogicalMap(layout.visualToLogicalMap);
|
|
|
|
|
|
}, [lines, cursorRow, cursorCol, viewport.width]);
|
|
|
|
|
|
|
|
|
|
|
|
// Update visual scroll (vertical)
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const { height } = viewport;
|
|
|
|
|
|
let newVisualScrollRow = visualScrollRow;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (visualCursor[0] < visualScrollRow) {
|
|
|
|
|
|
newVisualScrollRow = visualCursor[0];
|
|
|
|
|
|
} else if (visualCursor[0] >= visualScrollRow + height) {
|
|
|
|
|
|
newVisualScrollRow = visualCursor[0] - height + 1;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
}
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (newVisualScrollRow !== visualScrollRow) {
|
|
|
|
|
|
setVisualScrollRow(newVisualScrollRow);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
}
|
2025-05-16 11:58:37 -07:00
|
|
|
|
}, [visualCursor, visualScrollRow, viewport]);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
|
|
|
|
|
|
const pushUndo = useCallback(() => {
|
|
|
|
|
|
dbg('pushUndo', { cursor: [cursorRow, cursorCol], text: lines.join('\n') });
|
|
|
|
|
|
const snapshot = { lines: [...lines], cursorRow, cursorCol };
|
|
|
|
|
|
setUndoStack((prev) => {
|
|
|
|
|
|
const newStack = [...prev, snapshot];
|
|
|
|
|
|
if (newStack.length > historyLimit) {
|
|
|
|
|
|
newStack.shift();
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
return newStack;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
});
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setRedoStack([]);
|
|
|
|
|
|
}, [lines, cursorRow, cursorCol, historyLimit]);
|
|
|
|
|
|
|
|
|
|
|
|
const _restoreState = useCallback(
|
|
|
|
|
|
(state: UndoHistoryEntry | undefined): boolean => {
|
|
|
|
|
|
if (!state) return false;
|
|
|
|
|
|
setLines(state.lines);
|
|
|
|
|
|
setCursorRow(state.cursorRow);
|
|
|
|
|
|
setCursorCol(state.cursorCol);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
[],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const text = lines.join('\n');
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (onChange) {
|
|
|
|
|
|
onChange(text);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [text, onChange]);
|
|
|
|
|
|
|
|
|
|
|
|
const undo = useCallback((): boolean => {
|
|
|
|
|
|
const state = undoStack[undoStack.length - 1];
|
|
|
|
|
|
if (!state) return false;
|
|
|
|
|
|
|
|
|
|
|
|
setUndoStack((prev) => prev.slice(0, -1));
|
|
|
|
|
|
const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
|
|
|
|
|
|
setRedoStack((prev) => [...prev, currentSnapshot]);
|
|
|
|
|
|
return _restoreState(state);
|
|
|
|
|
|
}, [undoStack, lines, cursorRow, cursorCol, _restoreState]);
|
|
|
|
|
|
|
|
|
|
|
|
const redo = useCallback((): boolean => {
|
|
|
|
|
|
const state = redoStack[redoStack.length - 1];
|
|
|
|
|
|
if (!state) return false;
|
|
|
|
|
|
|
|
|
|
|
|
setRedoStack((prev) => prev.slice(0, -1));
|
|
|
|
|
|
const currentSnapshot = { lines: [...lines], cursorRow, cursorCol };
|
|
|
|
|
|
setUndoStack((prev) => [...prev, currentSnapshot]);
|
|
|
|
|
|
return _restoreState(state);
|
|
|
|
|
|
}, [redoStack, lines, cursorRow, cursorCol, _restoreState]);
|
|
|
|
|
|
|
|
|
|
|
|
const insertStr = useCallback(
|
|
|
|
|
|
(str: string): boolean => {
|
|
|
|
|
|
dbg('insertStr', { str, beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
if (str === '') return false;
|
|
|
|
|
|
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
const normalised = str.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
|
|
|
|
const parts = normalised.split('\n');
|
|
|
|
|
|
|
2025-06-02 14:31:35 -07:00
|
|
|
|
const newLines = [...lines];
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
const before = cpSlice(lineContent, 0, cursorCol);
|
|
|
|
|
|
const after = cpSlice(lineContent, cursorCol);
|
|
|
|
|
|
|
|
|
|
|
|
newLines[cursorRow] = before + parts[0];
|
|
|
|
|
|
|
|
|
|
|
|
if (parts.length > 1) {
|
|
|
|
|
|
// Adjusted condition for inserting multiple lines
|
|
|
|
|
|
const remainingParts = parts.slice(1);
|
|
|
|
|
|
const lastPartOriginal = remainingParts.pop() ?? '';
|
|
|
|
|
|
newLines.splice(cursorRow + 1, 0, ...remainingParts);
|
|
|
|
|
|
newLines.splice(
|
|
|
|
|
|
cursorRow + parts.length - 1,
|
|
|
|
|
|
0,
|
|
|
|
|
|
lastPartOriginal + after,
|
|
|
|
|
|
);
|
|
|
|
|
|
setCursorRow(cursorRow + parts.length - 1);
|
|
|
|
|
|
setCursorCol(cpLen(lastPartOriginal));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setCursorCol(cpLen(before) + cpLen(parts[0]));
|
|
|
|
|
|
}
|
|
|
|
|
|
setLines(newLines);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
2025-06-02 14:31:35 -07:00
|
|
|
|
[pushUndo, cursorRow, cursorCol, lines, currentLine, setPreferredCol],
|
2025-05-13 19:55:31 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const insert = useCallback(
|
|
|
|
|
|
(ch: string): void => {
|
|
|
|
|
|
if (/[\n\r]/.test(ch)) {
|
|
|
|
|
|
insertStr(ch);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
dbg('insert', { ch, beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
pushUndo();
|
2025-06-07 14:48:56 -07:00
|
|
|
|
|
|
|
|
|
|
// Arbitrary threshold to avoid false positives on normal key presses
|
|
|
|
|
|
// while still detecting virtually all reasonable length file paths.
|
|
|
|
|
|
const minLengthToInferAsDragDrop = 3;
|
|
|
|
|
|
if (ch.length >= minLengthToInferAsDragDrop) {
|
|
|
|
|
|
// Possible drag and drop of a file path.
|
|
|
|
|
|
let potentialPath = ch;
|
|
|
|
|
|
if (
|
|
|
|
|
|
potentialPath.length > 2 &&
|
|
|
|
|
|
potentialPath.startsWith("'") &&
|
|
|
|
|
|
potentialPath.endsWith("'")
|
|
|
|
|
|
) {
|
|
|
|
|
|
potentialPath = ch.slice(1, -1);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
potentialPath = potentialPath.trim();
|
|
|
|
|
|
// Be conservative and only add an @ if the path is valid.
|
|
|
|
|
|
if (isValidPath(unescapePath(potentialPath))) {
|
|
|
|
|
|
ch = `@${potentialPath}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
newLines[cursorRow] =
|
|
|
|
|
|
cpSlice(lineContent, 0, cursorCol) +
|
|
|
|
|
|
ch +
|
|
|
|
|
|
cpSlice(lineContent, cursorCol);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
2025-05-16 11:58:37 -07:00
|
|
|
|
setCursorCol((prev) => prev + cpLen(ch)); // Use cpLen for character length
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
},
|
2025-06-07 14:48:56 -07:00
|
|
|
|
[
|
|
|
|
|
|
pushUndo,
|
|
|
|
|
|
cursorRow,
|
|
|
|
|
|
cursorCol,
|
|
|
|
|
|
currentLine,
|
|
|
|
|
|
insertStr,
|
|
|
|
|
|
setPreferredCol,
|
|
|
|
|
|
isValidPath,
|
|
|
|
|
|
],
|
2025-05-13 19:55:31 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const newline = useCallback((): void => {
|
|
|
|
|
|
dbg('newline', { beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
const l = currentLine(cursorRow);
|
|
|
|
|
|
const before = cpSlice(l, 0, cursorCol);
|
|
|
|
|
|
const after = cpSlice(l, cursorCol);
|
|
|
|
|
|
newLines[cursorRow] = before;
|
|
|
|
|
|
newLines.splice(cursorRow + 1, 0, after);
|
|
|
|
|
|
return newLines;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
});
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setCursorRow((prev) => prev + 1);
|
|
|
|
|
|
setCursorCol(0);
|
|
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
|
|
|
|
|
|
|
|
|
|
|
|
const backspace = useCallback((): void => {
|
|
|
|
|
|
dbg('backspace', { beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
if (cursorCol === 0 && cursorRow === 0) return;
|
|
|
|
|
|
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
if (cursorCol > 0) {
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
newLines[cursorRow] =
|
|
|
|
|
|
cpSlice(lineContent, 0, cursorCol - 1) +
|
|
|
|
|
|
cpSlice(lineContent, cursorCol);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
|
|
|
|
|
setCursorCol((prev) => prev - 1);
|
|
|
|
|
|
} else if (cursorRow > 0) {
|
|
|
|
|
|
const prevLineContent = currentLine(cursorRow - 1);
|
|
|
|
|
|
const currentLineContentVal = currentLine(cursorRow);
|
|
|
|
|
|
const newCol = cpLen(prevLineContent);
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow - 1] = prevLineContent + currentLineContentVal;
|
|
|
|
|
|
newLines.splice(cursorRow, 1);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
|
|
|
|
|
setCursorRow((prev) => prev - 1);
|
|
|
|
|
|
setCursorCol(newCol);
|
|
|
|
|
|
}
|
|
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
|
|
|
|
|
|
|
|
|
|
|
|
const del = useCallback((): void => {
|
|
|
|
|
|
dbg('delete', { beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
if (cursorCol < currentLineLen(cursorRow)) {
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow] =
|
|
|
|
|
|
cpSlice(lineContent, 0, cursorCol) +
|
|
|
|
|
|
cpSlice(lineContent, cursorCol + 1);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
|
|
|
|
|
} else if (cursorRow < lines.length - 1) {
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
const nextLineContent = currentLine(cursorRow + 1);
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow] = lineContent + nextLineContent;
|
|
|
|
|
|
newLines.splice(cursorRow + 1, 1);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
// cursor position does not change for del
|
|
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
}, [
|
|
|
|
|
|
pushUndo,
|
|
|
|
|
|
cursorRow,
|
|
|
|
|
|
cursorCol,
|
|
|
|
|
|
currentLine,
|
|
|
|
|
|
currentLineLen,
|
2025-05-14 17:33:37 -07:00
|
|
|
|
lines.length,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const setText = useCallback(
|
2025-05-16 11:58:37 -07:00
|
|
|
|
(newText: string): void => {
|
|
|
|
|
|
dbg('setText', { text: newText });
|
2025-05-13 19:55:31 -07:00
|
|
|
|
pushUndo();
|
2025-05-16 11:58:37 -07:00
|
|
|
|
const newContentLines = newText.replace(/\r\n?/g, '\n').split('\n');
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setLines(newContentLines.length === 0 ? [''] : newContentLines);
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// Set logical cursor to the end of the new text
|
|
|
|
|
|
const lastNewLineIndex = newContentLines.length - 1;
|
|
|
|
|
|
setCursorRow(lastNewLineIndex);
|
|
|
|
|
|
setCursorCol(cpLen(newContentLines[lastNewLineIndex] ?? ''));
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
},
|
|
|
|
|
|
[pushUndo, setPreferredCol],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const replaceRange = useCallback(
|
|
|
|
|
|
(
|
|
|
|
|
|
startRow: number,
|
|
|
|
|
|
startCol: number,
|
|
|
|
|
|
endRow: number,
|
|
|
|
|
|
endCol: number,
|
2025-05-16 11:58:37 -07:00
|
|
|
|
replacementText: string,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
): boolean => {
|
|
|
|
|
|
if (
|
|
|
|
|
|
startRow > endRow ||
|
|
|
|
|
|
(startRow === endRow && startCol > endCol) ||
|
|
|
|
|
|
startRow < 0 ||
|
|
|
|
|
|
startCol < 0 ||
|
2025-05-16 11:58:37 -07:00
|
|
|
|
endRow >= lines.length ||
|
|
|
|
|
|
(endRow < lines.length && endCol > currentLineLen(endRow))
|
2025-05-13 19:55:31 -07:00
|
|
|
|
) {
|
2025-05-16 11:58:37 -07:00
|
|
|
|
console.error('Invalid range provided to replaceRange', {
|
|
|
|
|
|
startRow,
|
|
|
|
|
|
startCol,
|
|
|
|
|
|
endRow,
|
|
|
|
|
|
endCol,
|
|
|
|
|
|
linesLength: lines.length,
|
|
|
|
|
|
endRowLineLength: currentLineLen(endRow),
|
|
|
|
|
|
});
|
2025-05-13 19:55:31 -07:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
dbg('replaceRange', {
|
|
|
|
|
|
start: [startRow, startCol],
|
|
|
|
|
|
end: [endRow, endCol],
|
2025-05-16 11:58:37 -07:00
|
|
|
|
text: replacementText,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
});
|
|
|
|
|
|
pushUndo();
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
const sCol = clamp(startCol, 0, currentLineLen(startRow));
|
|
|
|
|
|
const eCol = clamp(endCol, 0, currentLineLen(endRow));
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
const prefix = cpSlice(currentLine(startRow), 0, sCol);
|
|
|
|
|
|
const suffix = cpSlice(currentLine(endRow), eCol);
|
2025-05-16 11:58:37 -07:00
|
|
|
|
const normalisedReplacement = replacementText
|
|
|
|
|
|
.replace(/\r\n/g, '\n')
|
|
|
|
|
|
.replace(/\r/g, '\n');
|
|
|
|
|
|
const replacementParts = normalisedReplacement.split('\n');
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// Remove lines between startRow and endRow (exclusive of startRow, inclusive of endRow if different)
|
2025-05-13 19:55:31 -07:00
|
|
|
|
if (startRow < endRow) {
|
|
|
|
|
|
newLines.splice(startRow + 1, endRow - startRow);
|
|
|
|
|
|
}
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// Construct the new content for the startRow
|
|
|
|
|
|
newLines[startRow] = prefix + replacementParts[0];
|
|
|
|
|
|
|
|
|
|
|
|
// If replacementText has multiple lines, insert them
|
|
|
|
|
|
if (replacementParts.length > 1) {
|
|
|
|
|
|
const lastReplacementPart = replacementParts.pop() ?? ''; // parts are already split by \n
|
|
|
|
|
|
// Insert middle parts (if any)
|
|
|
|
|
|
if (replacementParts.length > 1) {
|
|
|
|
|
|
// parts[0] is already used
|
|
|
|
|
|
newLines.splice(startRow + 1, 0, ...replacementParts.slice(1));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The line where the last part of the replacement will go
|
|
|
|
|
|
const targetRowForLastPart = startRow + (replacementParts.length - 1); // -1 because parts[0] is on startRow
|
|
|
|
|
|
// If the last part is not the first part (multi-line replacement)
|
|
|
|
|
|
if (
|
|
|
|
|
|
targetRowForLastPart > startRow ||
|
|
|
|
|
|
(replacementParts.length === 1 && lastReplacementPart !== '')
|
|
|
|
|
|
) {
|
|
|
|
|
|
// If the target row for the last part doesn't exist (because it's a new line created by replacement)
|
|
|
|
|
|
// ensure it's created before trying to append suffix.
|
|
|
|
|
|
// This case should be handled by splice if replacementParts.length > 1
|
|
|
|
|
|
// For single line replacement that becomes multi-line due to parts.length > 1 logic, this is tricky.
|
|
|
|
|
|
// Let's assume newLines[targetRowForLastPart] exists due to previous splice or it's newLines[startRow]
|
|
|
|
|
|
if (
|
|
|
|
|
|
newLines[targetRowForLastPart] === undefined &&
|
|
|
|
|
|
targetRowForLastPart === startRow + 1 &&
|
|
|
|
|
|
replacementParts.length === 1
|
|
|
|
|
|
) {
|
|
|
|
|
|
// This implies a single line replacement that became two lines.
|
|
|
|
|
|
// e.g. "abc" replace "b" with "B\nC" -> "aB", "C", "c"
|
|
|
|
|
|
// Here, lastReplacementPart is "C", targetRowForLastPart is startRow + 1
|
|
|
|
|
|
newLines.splice(
|
|
|
|
|
|
targetRowForLastPart,
|
|
|
|
|
|
0,
|
|
|
|
|
|
lastReplacementPart + suffix,
|
|
|
|
|
|
);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newLines[targetRowForLastPart] =
|
|
|
|
|
|
(newLines[targetRowForLastPart] || '') +
|
|
|
|
|
|
lastReplacementPart +
|
|
|
|
|
|
suffix;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Single line in replacementParts, but it was the only part
|
|
|
|
|
|
newLines[startRow] += suffix;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setCursorRow(targetRowForLastPart);
|
|
|
|
|
|
setCursorCol(cpLen(newLines[targetRowForLastPart]) - cpLen(suffix));
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// Single line replacement (replacementParts has only one item)
|
|
|
|
|
|
newLines[startRow] += suffix;
|
|
|
|
|
|
setCursorRow(startRow);
|
|
|
|
|
|
setCursorCol(cpLen(prefix) + cpLen(replacementParts[0]));
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
},
|
|
|
|
|
|
[pushUndo, lines, currentLine, currentLineLen, setPreferredCol],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const deleteWordLeft = useCallback((): void => {
|
|
|
|
|
|
dbg('deleteWordLeft', { beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
if (cursorCol === 0 && cursorRow === 0) return;
|
|
|
|
|
|
if (cursorCol === 0) {
|
|
|
|
|
|
backspace();
|
2025-05-13 11:24:04 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
pushUndo();
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
const arr = toCodePoints(lineContent);
|
|
|
|
|
|
let start = cursorCol;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
let onlySpaces = true;
|
|
|
|
|
|
for (let i = 0; i < start; i++) {
|
|
|
|
|
|
if (isWordChar(arr[i])) {
|
|
|
|
|
|
onlySpaces = false;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (onlySpaces && start > 0) {
|
|
|
|
|
|
start--;
|
|
|
|
|
|
} else {
|
2025-05-13 19:55:31 -07:00
|
|
|
|
while (start > 0 && !isWordChar(arr[start - 1])) start--;
|
|
|
|
|
|
while (start > 0 && isWordChar(arr[start - 1])) start--;
|
|
|
|
|
|
}
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow] =
|
|
|
|
|
|
cpSlice(lineContent, 0, start) + cpSlice(lineContent, cursorCol);
|
|
|
|
|
|
return newLines;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
});
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setCursorCol(start);
|
|
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
}, [pushUndo, cursorRow, cursorCol, currentLine, backspace, setPreferredCol]);
|
|
|
|
|
|
|
|
|
|
|
|
const deleteWordRight = useCallback((): void => {
|
|
|
|
|
|
dbg('deleteWordRight', { beforeCursor: [cursorRow, cursorCol] });
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
const arr = toCodePoints(lineContent);
|
|
|
|
|
|
if (cursorCol >= arr.length && cursorRow === lines.length - 1) return;
|
|
|
|
|
|
if (cursorCol >= arr.length) {
|
|
|
|
|
|
del();
|
2025-05-13 11:24:04 -07:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
pushUndo();
|
|
|
|
|
|
let end = cursorCol;
|
|
|
|
|
|
while (end < arr.length && !isWordChar(arr[end])) end++;
|
|
|
|
|
|
while (end < arr.length && isWordChar(arr[end])) end++;
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow] =
|
|
|
|
|
|
cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, end);
|
|
|
|
|
|
return newLines;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
});
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
}, [
|
|
|
|
|
|
pushUndo,
|
|
|
|
|
|
cursorRow,
|
|
|
|
|
|
cursorCol,
|
|
|
|
|
|
currentLine,
|
|
|
|
|
|
del,
|
2025-05-14 17:33:37 -07:00
|
|
|
|
lines.length,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
2025-05-14 17:33:37 -07:00
|
|
|
|
const killLineRight = useCallback((): void => {
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
if (cursorCol < currentLineLen(cursorRow)) {
|
|
|
|
|
|
// Cursor is before the end of the line's content, delete text to the right
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
|
|
|
|
|
// Cursor position and preferredCol do not change in this case
|
|
|
|
|
|
} else if (
|
|
|
|
|
|
cursorCol === currentLineLen(cursorRow) &&
|
|
|
|
|
|
cursorRow < lines.length - 1
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Cursor is at the end of the line's content (or line is empty),
|
|
|
|
|
|
// and it's not the last line. Delete the newline.
|
|
|
|
|
|
// `del()` handles pushUndo and setPreferredCol.
|
|
|
|
|
|
del();
|
|
|
|
|
|
}
|
|
|
|
|
|
// If cursor is at the end of the line and it's the last line, do nothing.
|
|
|
|
|
|
}, [
|
|
|
|
|
|
pushUndo,
|
|
|
|
|
|
cursorRow,
|
|
|
|
|
|
cursorCol,
|
|
|
|
|
|
currentLine,
|
|
|
|
|
|
currentLineLen,
|
|
|
|
|
|
lines.length,
|
|
|
|
|
|
del,
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
const killLineLeft = useCallback((): void => {
|
|
|
|
|
|
const lineContent = currentLine(cursorRow);
|
|
|
|
|
|
// Only act if the cursor is not at the beginning of the line
|
|
|
|
|
|
if (cursorCol > 0) {
|
|
|
|
|
|
pushUndo();
|
|
|
|
|
|
setLines((prevLines) => {
|
|
|
|
|
|
const newLines = [...prevLines];
|
|
|
|
|
|
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
|
|
|
|
|
|
return newLines;
|
|
|
|
|
|
});
|
|
|
|
|
|
setCursorCol(0);
|
|
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [pushUndo, cursorRow, cursorCol, currentLine, setPreferredCol]);
|
|
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
const move = useCallback(
|
|
|
|
|
|
(dir: Direction): void => {
|
2025-05-16 11:58:37 -07:00
|
|
|
|
let newVisualRow = visualCursor[0];
|
|
|
|
|
|
let newVisualCol = visualCursor[1];
|
2025-05-13 19:55:31 -07:00
|
|
|
|
let newPreferredCol = preferredCol;
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
const currentVisLineLen = cpLen(visualLines[newVisualRow] ?? '');
|
|
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
switch (dir) {
|
|
|
|
|
|
case 'left':
|
|
|
|
|
|
newPreferredCol = null;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (newVisualCol > 0) {
|
|
|
|
|
|
newVisualCol--;
|
|
|
|
|
|
} else if (newVisualRow > 0) {
|
|
|
|
|
|
newVisualRow--;
|
|
|
|
|
|
newVisualCol = cpLen(visualLines[newVisualRow] ?? '');
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
|
|
|
|
|
case 'right':
|
|
|
|
|
|
newPreferredCol = null;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (newVisualCol < currentVisLineLen) {
|
|
|
|
|
|
newVisualCol++;
|
|
|
|
|
|
} else if (newVisualRow < visualLines.length - 1) {
|
|
|
|
|
|
newVisualRow++;
|
|
|
|
|
|
newVisualCol = 0;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'up':
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (newVisualRow > 0) {
|
|
|
|
|
|
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
|
|
|
|
|
newVisualRow--;
|
|
|
|
|
|
newVisualCol = clamp(
|
2025-05-13 19:55:31 -07:00
|
|
|
|
newPreferredCol,
|
|
|
|
|
|
0,
|
2025-05-16 11:58:37 -07:00
|
|
|
|
cpLen(visualLines[newVisualRow] ?? ''),
|
2025-05-13 19:55:31 -07:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
break;
|
|
|
|
|
|
case 'down':
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (newVisualRow < visualLines.length - 1) {
|
|
|
|
|
|
if (newPreferredCol === null) newPreferredCol = newVisualCol;
|
|
|
|
|
|
newVisualRow++;
|
|
|
|
|
|
newVisualCol = clamp(
|
2025-05-13 19:55:31 -07:00
|
|
|
|
newPreferredCol,
|
|
|
|
|
|
0,
|
2025-05-16 11:58:37 -07:00
|
|
|
|
cpLen(visualLines[newVisualRow] ?? ''),
|
2025-05-13 19:55:31 -07:00
|
|
|
|
);
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
|
|
|
|
|
case 'home':
|
|
|
|
|
|
newPreferredCol = null;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
newVisualCol = 0;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
|
|
|
|
|
case 'end':
|
|
|
|
|
|
newPreferredCol = null;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
newVisualCol = currentVisLineLen;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// wordLeft and wordRight might need more sophisticated visual handling
|
|
|
|
|
|
// For now, they operate on the logical line derived from the visual cursor
|
2025-05-13 19:55:31 -07:00
|
|
|
|
case 'wordLeft': {
|
|
|
|
|
|
newPreferredCol = null;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (
|
|
|
|
|
|
visualToLogicalMap.length === 0 ||
|
|
|
|
|
|
logicalToVisualMap.length === 0
|
|
|
|
|
|
)
|
|
|
|
|
|
break;
|
|
|
|
|
|
const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
|
|
|
|
|
|
0, 0,
|
|
|
|
|
|
];
|
|
|
|
|
|
const currentLogCol = logColInitial + newVisualCol;
|
|
|
|
|
|
const lineText = lines[logRow];
|
|
|
|
|
|
const sliceToCursor = cpSlice(lineText, 0, currentLogCol).replace(
|
|
|
|
|
|
/[\s,.;!?]+$/,
|
|
|
|
|
|
'',
|
|
|
|
|
|
);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
let lastIdx = 0;
|
|
|
|
|
|
const regex = /[\s,.;!?]+/g;
|
|
|
|
|
|
let m;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
while ((m = regex.exec(sliceToCursor)) != null) lastIdx = m.index;
|
|
|
|
|
|
const newLogicalCol =
|
|
|
|
|
|
lastIdx === 0 ? 0 : cpLen(sliceToCursor.slice(0, lastIdx)) + 1;
|
|
|
|
|
|
|
|
|
|
|
|
// Map newLogicalCol back to visual
|
|
|
|
|
|
const targetLogicalMapEntries = logicalToVisualMap[logRow];
|
|
|
|
|
|
if (!targetLogicalMapEntries) break;
|
|
|
|
|
|
for (let i = targetLogicalMapEntries.length - 1; i >= 0; i--) {
|
|
|
|
|
|
const [visRow, logStartCol] = targetLogicalMapEntries[i];
|
|
|
|
|
|
if (newLogicalCol >= logStartCol) {
|
|
|
|
|
|
newVisualRow = visRow;
|
|
|
|
|
|
newVisualCol = newLogicalCol - logStartCol;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
case 'wordRight': {
|
|
|
|
|
|
newPreferredCol = null;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (
|
|
|
|
|
|
visualToLogicalMap.length === 0 ||
|
|
|
|
|
|
logicalToVisualMap.length === 0
|
|
|
|
|
|
)
|
|
|
|
|
|
break;
|
|
|
|
|
|
const [logRow, logColInitial] = visualToLogicalMap[newVisualRow] ?? [
|
|
|
|
|
|
0, 0,
|
|
|
|
|
|
];
|
|
|
|
|
|
const currentLogCol = logColInitial + newVisualCol;
|
|
|
|
|
|
const lineText = lines[logRow];
|
2025-05-13 19:55:31 -07:00
|
|
|
|
const regex = /[\s,.;!?]+/g;
|
|
|
|
|
|
let moved = false;
|
|
|
|
|
|
let m;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
let newLogicalCol = currentLineLen(logRow); // Default to end of logical line
|
|
|
|
|
|
|
|
|
|
|
|
while ((m = regex.exec(lineText)) != null) {
|
|
|
|
|
|
const cpIdx = cpLen(lineText.slice(0, m.index));
|
|
|
|
|
|
if (cpIdx > currentLogCol) {
|
|
|
|
|
|
newLogicalCol = cpIdx;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
moved = true;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-16 11:58:37 -07:00
|
|
|
|
if (!moved && currentLogCol < currentLineLen(logRow)) {
|
|
|
|
|
|
// If no word break found after cursor, move to end
|
|
|
|
|
|
newLogicalCol = currentLineLen(logRow);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Map newLogicalCol back to visual
|
|
|
|
|
|
const targetLogicalMapEntries = logicalToVisualMap[logRow];
|
|
|
|
|
|
if (!targetLogicalMapEntries) break;
|
|
|
|
|
|
for (let i = 0; i < targetLogicalMapEntries.length; i++) {
|
|
|
|
|
|
const [visRow, logStartCol] = targetLogicalMapEntries[i];
|
|
|
|
|
|
const nextLogStartCol =
|
|
|
|
|
|
i + 1 < targetLogicalMapEntries.length
|
|
|
|
|
|
? targetLogicalMapEntries[i + 1][1]
|
|
|
|
|
|
: Infinity;
|
|
|
|
|
|
if (
|
|
|
|
|
|
newLogicalCol >= logStartCol &&
|
|
|
|
|
|
newLogicalCol < nextLogStartCol
|
|
|
|
|
|
) {
|
|
|
|
|
|
newVisualRow = visRow;
|
|
|
|
|
|
newVisualCol = newLogicalCol - logStartCol;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (
|
|
|
|
|
|
newLogicalCol === logStartCol &&
|
|
|
|
|
|
i === targetLogicalMapEntries.length - 1 &&
|
|
|
|
|
|
cpLen(visualLines[visRow] ?? '') === 0
|
|
|
|
|
|
) {
|
|
|
|
|
|
// Special case: moving to an empty visual line at the end of a logical line
|
|
|
|
|
|
newVisualRow = visRow;
|
|
|
|
|
|
newVisualCol = 0;
|
|
|
|
|
|
break;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-16 11:58:37 -07:00
|
|
|
|
default:
|
2025-05-13 19:55:31 -07:00
|
|
|
|
break;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-16 11:58:37 -07:00
|
|
|
|
|
|
|
|
|
|
setVisualCursor([newVisualRow, newVisualCol]);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setPreferredCol(newPreferredCol);
|
2025-05-16 11:58:37 -07:00
|
|
|
|
|
|
|
|
|
|
// Update logical cursor based on new visual cursor
|
|
|
|
|
|
if (visualToLogicalMap[newVisualRow]) {
|
|
|
|
|
|
const [logRow, logStartCol] = visualToLogicalMap[newVisualRow];
|
|
|
|
|
|
setCursorRow(logRow);
|
|
|
|
|
|
setCursorCol(
|
|
|
|
|
|
clamp(logStartCol + newVisualCol, 0, currentLineLen(logRow)),
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
dbg('move', {
|
|
|
|
|
|
dir,
|
|
|
|
|
|
visualBefore: visualCursor,
|
|
|
|
|
|
visualAfter: [newVisualRow, newVisualCol],
|
|
|
|
|
|
logicalAfter: [cursorRow, cursorCol],
|
|
|
|
|
|
});
|
2025-05-13 19:55:31 -07:00
|
|
|
|
},
|
|
|
|
|
|
[
|
2025-05-16 11:58:37 -07:00
|
|
|
|
visualCursor,
|
|
|
|
|
|
visualLines,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
preferredCol,
|
|
|
|
|
|
lines,
|
|
|
|
|
|
currentLineLen,
|
2025-05-16 11:58:37 -07:00
|
|
|
|
visualToLogicalMap,
|
|
|
|
|
|
logicalToVisualMap,
|
|
|
|
|
|
cursorCol,
|
|
|
|
|
|
cursorRow,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const openInExternalEditor = useCallback(
|
|
|
|
|
|
async (opts: { editor?: string } = {}): Promise<void> => {
|
|
|
|
|
|
const editor =
|
|
|
|
|
|
opts.editor ??
|
|
|
|
|
|
process.env['VISUAL'] ??
|
|
|
|
|
|
process.env['EDITOR'] ??
|
|
|
|
|
|
(process.platform === 'win32' ? 'notepad' : 'vi');
|
|
|
|
|
|
const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-'));
|
|
|
|
|
|
const filePath = pathMod.join(tmpDir, 'buffer.txt');
|
|
|
|
|
|
fs.writeFileSync(filePath, text, 'utf8');
|
|
|
|
|
|
|
|
|
|
|
|
pushUndo(); // Snapshot before external edit
|
|
|
|
|
|
|
|
|
|
|
|
const wasRaw = stdin?.isRaw ?? false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
setRawMode?.(false);
|
|
|
|
|
|
const { status, error } = spawnSync(editor, [filePath], {
|
|
|
|
|
|
stdio: 'inherit',
|
|
|
|
|
|
});
|
|
|
|
|
|
if (error) throw error;
|
|
|
|
|
|
if (typeof status === 'number' && status !== 0)
|
|
|
|
|
|
throw new Error(`External editor exited with status ${status}`);
|
|
|
|
|
|
|
|
|
|
|
|
let newText = fs.readFileSync(filePath, 'utf8');
|
|
|
|
|
|
newText = newText.replace(/\r\n?/g, '\n');
|
2025-05-16 11:58:37 -07:00
|
|
|
|
setText(newText);
|
2025-05-13 19:55:31 -07:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('[useTextBuffer] external editor error', err);
|
|
|
|
|
|
// TODO(jacobr): potentially revert or handle error state.
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (wasRaw) setRawMode?.(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
fs.unlinkSync(filePath);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* ignore */
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
try {
|
|
|
|
|
|
fs.rmdirSync(tmpDir);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
/* ignore */
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
},
|
2025-05-16 11:58:37 -07:00
|
|
|
|
[text, pushUndo, stdin, setRawMode, setText],
|
2025-05-13 19:55:31 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleInput = useCallback(
|
|
|
|
|
|
(input: string | undefined, key: Record<string, boolean>): boolean => {
|
2025-05-16 11:58:37 -07:00
|
|
|
|
dbg('handleInput', {
|
|
|
|
|
|
input,
|
|
|
|
|
|
key,
|
|
|
|
|
|
cursor: [cursorRow, cursorCol],
|
|
|
|
|
|
visualCursor,
|
|
|
|
|
|
});
|
|
|
|
|
|
const beforeText = text;
|
|
|
|
|
|
const beforeLogicalCursor = [cursorRow, cursorCol];
|
|
|
|
|
|
const beforeVisualCursor = [...visualCursor];
|
2025-05-13 19:55:31 -07:00
|
|
|
|
|
|
|
|
|
|
if (key['escape']) return false;
|
|
|
|
|
|
|
2025-05-20 23:42:40 -07:00
|
|
|
|
if (
|
|
|
|
|
|
key['return'] ||
|
|
|
|
|
|
input === '\r' ||
|
|
|
|
|
|
input === '\n' ||
|
|
|
|
|
|
input === '\\\r' // VSCode terminal represents shift + enter this way
|
|
|
|
|
|
)
|
|
|
|
|
|
newline();
|
2025-05-13 19:55:31 -07:00
|
|
|
|
else if (key['leftArrow'] && !key['meta'] && !key['ctrl'] && !key['alt'])
|
|
|
|
|
|
move('left');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if (key['ctrl'] && input === 'b') move('left');
|
2025-05-13 19:55:31 -07:00
|
|
|
|
else if (key['rightArrow'] && !key['meta'] && !key['ctrl'] && !key['alt'])
|
|
|
|
|
|
move('right');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if (key['ctrl'] && input === 'f') move('right');
|
2025-05-13 19:55:31 -07:00
|
|
|
|
else if (key['upArrow']) move('up');
|
|
|
|
|
|
else if (key['downArrow']) move('down');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if ((key['ctrl'] || key['alt']) && key['leftArrow'])
|
2025-05-13 19:55:31 -07:00
|
|
|
|
move('wordLeft');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if (key['meta'] && input === 'b') move('wordLeft');
|
|
|
|
|
|
else if ((key['ctrl'] || key['alt']) && key['rightArrow'])
|
2025-05-13 19:55:31 -07:00
|
|
|
|
move('wordRight');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if (key['meta'] && input === 'f') move('wordRight');
|
2025-05-13 19:55:31 -07:00
|
|
|
|
else if (key['home']) move('home');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if (key['ctrl'] && input === 'a') move('home');
|
2025-05-13 19:55:31 -07:00
|
|
|
|
else if (key['end']) move('end');
|
2025-05-20 10:12:07 -07:00
|
|
|
|
else if (key['ctrl'] && input === 'e') move('end');
|
2025-05-28 09:59:25 -07:00
|
|
|
|
else if (key['ctrl'] && input === 'w') deleteWordLeft();
|
2025-05-13 19:55:31 -07:00
|
|
|
|
else if (
|
|
|
|
|
|
(key['meta'] || key['ctrl'] || key['alt']) &&
|
|
|
|
|
|
(key['backspace'] || input === '\x7f')
|
|
|
|
|
|
)
|
|
|
|
|
|
deleteWordLeft();
|
|
|
|
|
|
else if ((key['meta'] || key['ctrl'] || key['alt']) && key['delete'])
|
|
|
|
|
|
deleteWordRight();
|
|
|
|
|
|
else if (
|
|
|
|
|
|
key['backspace'] ||
|
|
|
|
|
|
input === '\x7f' ||
|
2025-05-23 22:13:57 -07:00
|
|
|
|
(key['ctrl'] && input === 'h') ||
|
2025-05-13 19:55:31 -07:00
|
|
|
|
(key['delete'] && !key['shift'])
|
|
|
|
|
|
)
|
|
|
|
|
|
backspace();
|
2025-05-23 22:13:57 -07:00
|
|
|
|
else if (key['delete'] || (key['ctrl'] && input === 'd')) del();
|
2025-05-16 13:17:48 -07:00
|
|
|
|
else if (input && !key['ctrl'] && !key['meta']) {
|
|
|
|
|
|
// Heuristic for paste: if input is longer than 1 char (potential paste)
|
|
|
|
|
|
// strip ANSI escape codes.
|
|
|
|
|
|
const cleanedInput = input.length > 1 ? stripAnsi(input) : input;
|
|
|
|
|
|
insert(cleanedInput);
|
|
|
|
|
|
}
|
2025-05-13 19:55:31 -07:00
|
|
|
|
|
|
|
|
|
|
const textChanged = text !== beforeText;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
// After operations, visualCursor might not be immediately updated if the change
|
|
|
|
|
|
// was to `lines`, `cursorRow`, or `cursorCol` which then triggers the useEffect.
|
|
|
|
|
|
// So, for return value, we check logical cursor change.
|
2025-05-13 19:55:31 -07:00
|
|
|
|
const cursorChanged =
|
2025-05-16 11:58:37 -07:00
|
|
|
|
cursorRow !== beforeLogicalCursor[0] ||
|
|
|
|
|
|
cursorCol !== beforeLogicalCursor[1] ||
|
|
|
|
|
|
visualCursor[0] !== beforeVisualCursor[0] ||
|
|
|
|
|
|
visualCursor[1] !== beforeVisualCursor[1];
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
dbg('handleInput:after', {
|
|
|
|
|
|
cursor: [cursorRow, cursorCol],
|
2025-05-16 11:58:37 -07:00
|
|
|
|
visualCursor,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
text,
|
|
|
|
|
|
});
|
|
|
|
|
|
return textChanged || cursorChanged;
|
|
|
|
|
|
},
|
|
|
|
|
|
[
|
|
|
|
|
|
text,
|
|
|
|
|
|
cursorRow,
|
|
|
|
|
|
cursorCol,
|
2025-05-16 11:58:37 -07:00
|
|
|
|
visualCursor,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
newline,
|
|
|
|
|
|
move,
|
|
|
|
|
|
deleteWordLeft,
|
|
|
|
|
|
deleteWordRight,
|
|
|
|
|
|
backspace,
|
|
|
|
|
|
del,
|
|
|
|
|
|
insert,
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
const renderedVisualLines = useMemo(
|
|
|
|
|
|
() => visualLines.slice(visualScrollRow, visualScrollRow + viewport.height),
|
|
|
|
|
|
[visualLines, visualScrollRow, viewport.height],
|
2025-05-13 19:55:31 -07:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-05-20 16:50:32 -07:00
|
|
|
|
const replaceRangeByOffset = useCallback(
|
|
|
|
|
|
(
|
|
|
|
|
|
startOffset: number,
|
|
|
|
|
|
endOffset: number,
|
|
|
|
|
|
replacementText: string,
|
|
|
|
|
|
): boolean => {
|
|
|
|
|
|
dbg('replaceRangeByOffset', { startOffset, endOffset, replacementText });
|
|
|
|
|
|
const [startRow, startCol] = offsetToLogicalPos(text, startOffset);
|
|
|
|
|
|
const [endRow, endCol] = offsetToLogicalPos(text, endOffset);
|
|
|
|
|
|
return replaceRange(startRow, startCol, endRow, endCol, replacementText);
|
|
|
|
|
|
},
|
|
|
|
|
|
[text, replaceRange],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const moveToOffset = useCallback(
|
|
|
|
|
|
(offset: number): void => {
|
|
|
|
|
|
const [newRow, newCol] = offsetToLogicalPos(text, offset);
|
|
|
|
|
|
setCursorRow(newRow);
|
|
|
|
|
|
setCursorCol(newCol);
|
|
|
|
|
|
setPreferredCol(null);
|
|
|
|
|
|
dbg('moveToOffset', { offset, newCursor: [newRow, newCol] });
|
|
|
|
|
|
},
|
|
|
|
|
|
[text, setPreferredCol],
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
const returnValue: TextBuffer = {
|
|
|
|
|
|
lines,
|
|
|
|
|
|
text,
|
|
|
|
|
|
cursor: [cursorRow, cursorCol],
|
|
|
|
|
|
preferredCol,
|
|
|
|
|
|
selectionAnchor,
|
|
|
|
|
|
|
2025-05-16 11:58:37 -07:00
|
|
|
|
allVisualLines: visualLines,
|
|
|
|
|
|
viewportVisualLines: renderedVisualLines,
|
|
|
|
|
|
visualCursor,
|
|
|
|
|
|
visualScrollRow,
|
|
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
setText,
|
|
|
|
|
|
insert,
|
2025-06-02 14:31:35 -07:00
|
|
|
|
insertStr,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
newline,
|
|
|
|
|
|
backspace,
|
|
|
|
|
|
del,
|
|
|
|
|
|
move,
|
|
|
|
|
|
undo,
|
|
|
|
|
|
redo,
|
|
|
|
|
|
replaceRange,
|
2025-05-20 16:50:32 -07:00
|
|
|
|
replaceRangeByOffset,
|
|
|
|
|
|
moveToOffset, // Added here
|
2025-05-13 19:55:31 -07:00
|
|
|
|
deleteWordLeft,
|
|
|
|
|
|
deleteWordRight,
|
2025-05-14 17:33:37 -07:00
|
|
|
|
killLineRight,
|
|
|
|
|
|
killLineLeft,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
handleInput,
|
|
|
|
|
|
openInExternalEditor,
|
|
|
|
|
|
|
|
|
|
|
|
copy: useCallback(() => {
|
|
|
|
|
|
if (!selectionAnchor) return null;
|
|
|
|
|
|
const [ar, ac] = selectionAnchor;
|
|
|
|
|
|
const [br, bc] = [cursorRow, cursorCol];
|
|
|
|
|
|
if (ar === br && ac === bc) return null;
|
|
|
|
|
|
const topBefore = ar < br || (ar === br && ac < bc);
|
|
|
|
|
|
const [sr, sc, er, ec] = topBefore ? [ar, ac, br, bc] : [br, bc, ar, ac];
|
|
|
|
|
|
|
|
|
|
|
|
let selectedTextVal;
|
|
|
|
|
|
if (sr === er) {
|
|
|
|
|
|
selectedTextVal = cpSlice(currentLine(sr), sc, ec);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const parts: string[] = [cpSlice(currentLine(sr), sc)];
|
|
|
|
|
|
for (let r = sr + 1; r < er; r++) parts.push(currentLine(r));
|
|
|
|
|
|
parts.push(cpSlice(currentLine(er), 0, ec));
|
|
|
|
|
|
selectedTextVal = parts.join('\n');
|
|
|
|
|
|
}
|
|
|
|
|
|
setClipboard(selectedTextVal);
|
|
|
|
|
|
return selectedTextVal;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
}, [selectionAnchor, cursorRow, cursorCol, currentLine, setClipboard]),
|
2025-05-13 19:55:31 -07:00
|
|
|
|
paste: useCallback(() => {
|
|
|
|
|
|
if (clipboard === null) return false;
|
|
|
|
|
|
return insertStr(clipboard);
|
|
|
|
|
|
}, [clipboard, insertStr]),
|
|
|
|
|
|
startSelection: useCallback(
|
|
|
|
|
|
() => setSelectionAnchor([cursorRow, cursorCol]),
|
2025-05-16 11:58:37 -07:00
|
|
|
|
[cursorRow, cursorCol, setSelectionAnchor],
|
2025-05-13 19:55:31 -07:00
|
|
|
|
),
|
|
|
|
|
|
};
|
|
|
|
|
|
return returnValue;
|
|
|
|
|
|
}
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
export interface TextBuffer {
|
|
|
|
|
|
// State
|
2025-05-16 11:58:37 -07:00
|
|
|
|
lines: string[]; // Logical lines
|
2025-05-13 19:55:31 -07:00
|
|
|
|
text: string;
|
2025-05-16 11:58:37 -07:00
|
|
|
|
cursor: [number, number]; // Logical cursor [row, col]
|
2025-05-13 11:24:04 -07:00
|
|
|
|
/**
|
2025-05-13 19:55:31 -07:00
|
|
|
|
* When the user moves the caret vertically we try to keep their original
|
|
|
|
|
|
* horizontal column even when passing through shorter lines. We remember
|
|
|
|
|
|
* that *preferred* column in this field while the user is still travelling
|
|
|
|
|
|
* vertically. Any explicit horizontal movement resets the preference.
|
2025-05-13 11:24:04 -07:00
|
|
|
|
*/
|
2025-05-16 11:58:37 -07:00
|
|
|
|
preferredCol: number | null; // Preferred visual column
|
|
|
|
|
|
selectionAnchor: [number, number] | null; // Logical selection anchor
|
|
|
|
|
|
|
|
|
|
|
|
// Visual state (handles wrapping)
|
|
|
|
|
|
allVisualLines: string[]; // All visual lines for the current text and viewport width.
|
|
|
|
|
|
viewportVisualLines: string[]; // The subset of visual lines to be rendered based on visualScrollRow and viewport.height
|
|
|
|
|
|
visualCursor: [number, number]; // Visual cursor [row, col] relative to the start of all visualLines
|
|
|
|
|
|
visualScrollRow: number; // Scroll position for visual lines (index of the first visible visual line)
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
// Actions
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Replaces the entire buffer content with the provided text.
|
|
|
|
|
|
* The operation is undoable.
|
|
|
|
|
|
*/
|
|
|
|
|
|
setText: (text: string) => void;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Insert a single character or string without newlines.
|
|
|
|
|
|
*/
|
|
|
|
|
|
insert: (ch: string) => void;
|
2025-06-02 14:31:35 -07:00
|
|
|
|
insertStr: (str: string) => boolean;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
newline: () => void;
|
|
|
|
|
|
backspace: () => void;
|
|
|
|
|
|
del: () => void;
|
|
|
|
|
|
move: (dir: Direction) => void;
|
|
|
|
|
|
undo: () => boolean;
|
|
|
|
|
|
redo: () => boolean;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Replaces the text within the specified range with new text.
|
|
|
|
|
|
* Handles both single-line and multi-line ranges.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param startRow The starting row index (inclusive).
|
|
|
|
|
|
* @param startCol The starting column index (inclusive, code-point based).
|
|
|
|
|
|
* @param endRow The ending row index (inclusive).
|
|
|
|
|
|
* @param endCol The ending column index (exclusive, code-point based).
|
|
|
|
|
|
* @param text The new text to insert.
|
|
|
|
|
|
* @returns True if the buffer was modified, false otherwise.
|
|
|
|
|
|
*/
|
|
|
|
|
|
replaceRange: (
|
|
|
|
|
|
startRow: number,
|
|
|
|
|
|
startCol: number,
|
|
|
|
|
|
endRow: number,
|
|
|
|
|
|
endCol: number,
|
|
|
|
|
|
text: string,
|
|
|
|
|
|
) => boolean;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Delete the word to the *left* of the caret, mirroring common
|
|
|
|
|
|
* Ctrl/Alt+Backspace behaviour in editors & terminals. Both the adjacent
|
|
|
|
|
|
* whitespace *and* the word characters immediately preceding the caret are
|
|
|
|
|
|
* removed. If the caret is already at column‑0 this becomes a no-op.
|
|
|
|
|
|
*/
|
|
|
|
|
|
deleteWordLeft: () => void;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Delete the word to the *right* of the caret, akin to many editors'
|
|
|
|
|
|
* Ctrl/Alt+Delete shortcut. Removes any whitespace/punctuation that
|
|
|
|
|
|
* follows the caret and the next contiguous run of word characters.
|
|
|
|
|
|
*/
|
|
|
|
|
|
deleteWordRight: () => void;
|
2025-05-14 17:33:37 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* Deletes text from the cursor to the end of the current line.
|
|
|
|
|
|
*/
|
|
|
|
|
|
killLineRight: () => void;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Deletes text from the start of the current line to the cursor.
|
|
|
|
|
|
*/
|
|
|
|
|
|
killLineLeft: () => void;
|
2025-05-13 19:55:31 -07:00
|
|
|
|
/**
|
|
|
|
|
|
* High level "handleInput" – receives what Ink gives us.
|
|
|
|
|
|
*/
|
|
|
|
|
|
handleInput: (
|
2025-05-13 11:24:04 -07:00
|
|
|
|
input: string | undefined,
|
|
|
|
|
|
key: Record<string, boolean>,
|
2025-05-13 19:55:31 -07:00
|
|
|
|
) => boolean;
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Opens the current buffer contents in the user’s preferred terminal text
|
|
|
|
|
|
* editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks
|
|
|
|
|
|
* until the editor exits, then reloads the file and replaces the in‑memory
|
|
|
|
|
|
* buffer with whatever the user saved.
|
|
|
|
|
|
*
|
|
|
|
|
|
* The operation is treated as a single undoable edit – we snapshot the
|
|
|
|
|
|
* previous state *once* before launching the editor so one `undo()` will
|
|
|
|
|
|
* revert the entire change set.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Note: We purposefully rely on the *synchronous* spawn API so that the
|
|
|
|
|
|
* calling process genuinely waits for the editor to close before
|
|
|
|
|
|
* continuing. This mirrors Git’s behaviour and simplifies downstream
|
|
|
|
|
|
* control‑flow (callers can simply `await` the Promise).
|
|
|
|
|
|
*/
|
|
|
|
|
|
openInExternalEditor: (opts?: { editor?: string }) => Promise<void>;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
|
2025-05-13 19:55:31 -07:00
|
|
|
|
// Selection & Clipboard
|
|
|
|
|
|
copy: () => string | null;
|
|
|
|
|
|
paste: () => boolean;
|
|
|
|
|
|
startSelection: () => void;
|
2025-05-20 16:50:32 -07:00
|
|
|
|
replaceRangeByOffset: (
|
|
|
|
|
|
startOffset: number,
|
|
|
|
|
|
endOffset: number,
|
|
|
|
|
|
replacementText: string,
|
|
|
|
|
|
) => boolean;
|
|
|
|
|
|
moveToOffset(offset: number): void;
|
2025-05-13 11:24:04 -07:00
|
|
|
|
}
|