mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
feat(tui): treat paste markers as atomic segments in editor (#2111)
* feat(tui): treat paste markers as atomic segments in editor Paste markers like `[paste #1 +123 lines]` are now treated as single atomic units for cursor movement, word navigation, deletion, and wrapping. Only markers with valid paste IDs (present in the editor's pastes Map) are treated atomically. * fix(tui): word-wrap oversized atomic segments in editor
This commit is contained in:
@@ -7,7 +7,75 @@ import { UndoStack } from "../undo-stack.js";
|
||||
import { getSegmenter, isPunctuationChar, isWhitespaceChar, truncateToWidth, visibleWidth } from "../utils.js";
|
||||
import { SelectList, type SelectListTheme } from "./select-list.js";
|
||||
|
||||
const segmenter = getSegmenter();
|
||||
const baseSegmenter = getSegmenter();
|
||||
|
||||
/** Regex matching paste markers like `[paste #1 +123 lines]` or `[paste #2 1234 chars]`. */
|
||||
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
||||
|
||||
/** Non-global version for single-segment testing. */
|
||||
const PASTE_MARKER_SINGLE = /^\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]$/;
|
||||
|
||||
/** Check if a segment is a paste marker (i.e. was merged by segmentWithMarkers). */
|
||||
function isPasteMarker(segment: string): boolean {
|
||||
return segment.length >= 10 && PASTE_MARKER_SINGLE.test(segment);
|
||||
}
|
||||
|
||||
/**
|
||||
* A segmenter that wraps Intl.Segmenter and merges graphemes that fall
|
||||
* within paste markers into single atomic segments. This makes cursor
|
||||
* movement, deletion, word-wrap, etc. treat paste markers as single units.
|
||||
*
|
||||
* Only markers whose numeric ID exists in `validIds` are merged.
|
||||
*/
|
||||
function segmentWithMarkers(text: string, validIds: Set<number>): Iterable<Intl.SegmentData> {
|
||||
// Fast path: no paste markers in the text or no valid IDs.
|
||||
if (validIds.size === 0 || !text.includes("[paste #")) {
|
||||
return baseSegmenter.segment(text);
|
||||
}
|
||||
|
||||
// Find all marker spans with valid IDs.
|
||||
const markers: Array<{ start: number; end: number }> = [];
|
||||
for (const m of text.matchAll(PASTE_MARKER_REGEX)) {
|
||||
const id = Number.parseInt(m[1]!, 10);
|
||||
if (!validIds.has(id)) continue;
|
||||
markers.push({ start: m.index, end: m.index + m[0].length });
|
||||
}
|
||||
if (markers.length === 0) {
|
||||
return baseSegmenter.segment(text);
|
||||
}
|
||||
|
||||
// Build merged segment list.
|
||||
const baseSegments = baseSegmenter.segment(text);
|
||||
const result: Intl.SegmentData[] = [];
|
||||
let markerIdx = 0;
|
||||
|
||||
for (const seg of baseSegments) {
|
||||
// Skip past markers that are entirely before this segment.
|
||||
while (markerIdx < markers.length && markers[markerIdx]!.end <= seg.index) {
|
||||
markerIdx++;
|
||||
}
|
||||
|
||||
const marker = markerIdx < markers.length ? markers[markerIdx]! : null;
|
||||
|
||||
if (marker && seg.index >= marker.start && seg.index < marker.end) {
|
||||
// This segment falls inside a marker.
|
||||
// If this is the first segment of the marker, emit a merged segment.
|
||||
if (seg.index === marker.start) {
|
||||
const markerText = text.slice(marker.start, marker.end);
|
||||
result.push({
|
||||
segment: markerText,
|
||||
index: marker.start,
|
||||
input: text,
|
||||
});
|
||||
}
|
||||
// Otherwise skip (already merged into the first segment).
|
||||
} else {
|
||||
result.push(seg);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a chunk of text for word-wrap layout.
|
||||
@@ -26,9 +94,11 @@ export interface TextChunk {
|
||||
*
|
||||
* @param line - The text line to wrap
|
||||
* @param maxWidth - Maximum visible width per chunk
|
||||
* @param preSegmented - Optional pre-segmented graphemes (e.g. with paste-marker awareness).
|
||||
* When omitted the default Intl.Segmenter is used.
|
||||
* @returns Array of chunks with text and position information
|
||||
*/
|
||||
export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
||||
export function wordWrapLine(line: string, maxWidth: number, preSegmented?: Intl.SegmentData[]): TextChunk[] {
|
||||
if (!line || maxWidth <= 0) {
|
||||
return [{ text: "", startIndex: 0, endIndex: 0 }];
|
||||
}
|
||||
@@ -39,7 +109,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
||||
}
|
||||
|
||||
const chunks: TextChunk[] = [];
|
||||
const segments = [...segmenter.segment(line)];
|
||||
const segments = preSegmented ?? [...baseSegmenter.segment(line)];
|
||||
|
||||
let currentWidth = 0;
|
||||
let chunkStart = 0;
|
||||
@@ -54,7 +124,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
||||
const grapheme = seg.segment;
|
||||
const gWidth = visibleWidth(grapheme);
|
||||
const charIndex = seg.index;
|
||||
const isWs = isWhitespaceChar(grapheme);
|
||||
const isWs = !isPasteMarker(grapheme) && isWhitespaceChar(grapheme);
|
||||
|
||||
// Overflow check before advancing.
|
||||
if (currentWidth + gWidth > maxWidth) {
|
||||
@@ -77,6 +147,24 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
||||
wrapOppIndex = -1;
|
||||
}
|
||||
|
||||
if (gWidth > maxWidth) {
|
||||
// Single atomic segment wider than maxWidth (e.g. paste marker
|
||||
// in a narrow terminal). Re-wrap it at grapheme granularity.
|
||||
|
||||
// The segment remains logically atomic for cursor
|
||||
// movement / editing — the split is purely visual for word-wrap layout.
|
||||
const subChunks = wordWrapLine(grapheme, maxWidth);
|
||||
for (let j = 0; j < subChunks.length - 1; j++) {
|
||||
const sc = subChunks[j]!;
|
||||
chunks.push({ text: sc.text, startIndex: charIndex + sc.startIndex, endIndex: charIndex + sc.endIndex });
|
||||
}
|
||||
const last = subChunks[subChunks.length - 1]!;
|
||||
chunkStart = charIndex + last.startIndex;
|
||||
currentWidth = visibleWidth(last.text);
|
||||
wrapOppIndex = -1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Advance.
|
||||
currentWidth += gWidth;
|
||||
|
||||
@@ -84,7 +172,7 @@ export function wordWrapLine(line: string, maxWidth: number): TextChunk[] {
|
||||
// Multiple spaces join (no break between them); the break point is
|
||||
// after the last space before the next word.
|
||||
const next = segments[i + 1];
|
||||
if (isWs && next && !isWhitespaceChar(next.segment)) {
|
||||
if (isWs && next && (isPasteMarker(next.segment) || !isWhitespaceChar(next.segment))) {
|
||||
wrapOppIndex = next.index;
|
||||
wrapOppWidth = currentWidth;
|
||||
}
|
||||
@@ -188,6 +276,16 @@ export class Editor implements Component, Focusable {
|
||||
this.autocompleteMaxVisible = Number.isFinite(maxVisible) ? Math.max(3, Math.min(20, Math.floor(maxVisible))) : 5;
|
||||
}
|
||||
|
||||
/** Set of currently valid paste IDs, for marker-aware segmentation. */
|
||||
private validPasteIds(): Set<number> {
|
||||
return new Set(this.pastes.keys());
|
||||
}
|
||||
|
||||
/** Segment text with paste-marker awareness, only merging markers with valid IDs. */
|
||||
private segment(text: string): Iterable<Intl.SegmentData> {
|
||||
return segmentWithMarkers(text, this.validPasteIds());
|
||||
}
|
||||
|
||||
getPaddingX(): number {
|
||||
return this.paddingX;
|
||||
}
|
||||
@@ -364,7 +462,7 @@ export class Editor implements Component, Focusable {
|
||||
if (after.length > 0) {
|
||||
// Cursor is on a character (grapheme) - replace it with highlighted version
|
||||
// Get the first grapheme from 'after'
|
||||
const afterGraphemes = [...segmenter.segment(after)];
|
||||
const afterGraphemes = [...this.segment(after)];
|
||||
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
||||
const restAfter = after.slice(firstGrapheme.length);
|
||||
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
||||
@@ -742,7 +840,7 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
} else {
|
||||
// Line needs wrapping - use word-aware wrapping
|
||||
const chunks = wordWrapLine(line, contentWidth);
|
||||
const chunks = wordWrapLine(line, contentWidth, [...this.segment(line)]);
|
||||
|
||||
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
|
||||
const chunk = chunks[chunkIndex];
|
||||
@@ -1084,7 +1182,7 @@ export class Editor implements Component, Focusable {
|
||||
const beforeCursor = line.slice(0, this.state.cursorCol);
|
||||
|
||||
// Find the last grapheme in the text before cursor
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
const graphemes = [...this.segment(beforeCursor)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
|
||||
|
||||
@@ -1175,6 +1273,20 @@ export class Editor implements Component, Focusable {
|
||||
const targetCol = targetVL.startCol + moveToVisualCol;
|
||||
const logicalLine = this.state.lines[targetVL.logicalLine] || "";
|
||||
this.state.cursorCol = Math.min(targetCol, logicalLine.length);
|
||||
|
||||
// Snap cursor to atomic segment boundary (e.g. paste markers)
|
||||
// so the cursor never lands in the middle of a multi-grapheme unit.
|
||||
// Single-grapheme segments don't need snapping.
|
||||
const segments = [...this.segment(logicalLine)];
|
||||
for (const seg of segments) {
|
||||
if (seg.index > this.state.cursorCol) break;
|
||||
if (seg.segment.length <= 1) continue;
|
||||
if (this.state.cursorCol < seg.index + seg.segment.length) {
|
||||
// jump to the start of the segment when moving up, to the end when moving down.
|
||||
this.state.cursorCol = currentVisualLine > targetVisualLine ? seg.index : seg.index + seg.segment.length;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1409,7 +1521,7 @@ export class Editor implements Component, Focusable {
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
|
||||
// Find the first grapheme at cursor
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
const graphemes = [...this.segment(afterCursor)];
|
||||
const firstGrapheme = graphemes[0];
|
||||
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
|
||||
|
||||
@@ -1466,7 +1578,7 @@ export class Editor implements Component, Focusable {
|
||||
visualLines.push({ logicalLine: i, startCol: 0, length: line.length });
|
||||
} else {
|
||||
// Line needs wrapping - use word-aware wrapping
|
||||
const chunks = wordWrapLine(line, width);
|
||||
const chunks = wordWrapLine(line, width, [...this.segment(line)]);
|
||||
for (const chunk of chunks) {
|
||||
visualLines.push({
|
||||
logicalLine: i,
|
||||
@@ -1524,7 +1636,7 @@ export class Editor implements Component, Focusable {
|
||||
// Moving right - move by one grapheme (handles emojis, combining characters, etc.)
|
||||
if (this.state.cursorCol < currentLine.length) {
|
||||
const afterCursor = currentLine.slice(this.state.cursorCol);
|
||||
const graphemes = [...segmenter.segment(afterCursor)];
|
||||
const graphemes = [...this.segment(afterCursor)];
|
||||
const firstGrapheme = graphemes[0];
|
||||
this.setCursorCol(this.state.cursorCol + (firstGrapheme ? firstGrapheme.segment.length : 1));
|
||||
} else if (this.state.cursorLine < this.state.lines.length - 1) {
|
||||
@@ -1542,7 +1654,7 @@ export class Editor implements Component, Focusable {
|
||||
// Moving left - move by one grapheme (handles emojis, combining characters, etc.)
|
||||
if (this.state.cursorCol > 0) {
|
||||
const beforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const graphemes = [...segmenter.segment(beforeCursor)];
|
||||
const graphemes = [...this.segment(beforeCursor)];
|
||||
const lastGrapheme = graphemes[graphemes.length - 1];
|
||||
this.setCursorCol(this.state.cursorCol - (lastGrapheme ? lastGrapheme.segment.length : 1));
|
||||
} else if (this.state.cursorLine > 0) {
|
||||
@@ -1586,19 +1698,30 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const graphemes = [...segmenter.segment(textBeforeCursor)];
|
||||
const graphemes = [...this.segment(textBeforeCursor)];
|
||||
let newCol = this.state.cursorCol;
|
||||
|
||||
// Skip trailing whitespace
|
||||
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
||||
while (
|
||||
graphemes.length > 0 &&
|
||||
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "") &&
|
||||
isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")
|
||||
) {
|
||||
newCol -= graphemes.pop()?.segment.length || 0;
|
||||
}
|
||||
|
||||
if (graphemes.length > 0) {
|
||||
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
|
||||
if (isPunctuationChar(lastGrapheme)) {
|
||||
if (isPasteMarker(lastGrapheme)) {
|
||||
// Paste marker is a single atomic word
|
||||
newCol -= graphemes.pop()?.segment.length || 0;
|
||||
} else if (isPunctuationChar(lastGrapheme)) {
|
||||
// Skip punctuation run
|
||||
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
|
||||
while (
|
||||
graphemes.length > 0 &&
|
||||
isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
||||
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")
|
||||
) {
|
||||
newCol -= graphemes.pop()?.segment.length || 0;
|
||||
}
|
||||
} else {
|
||||
@@ -1606,7 +1729,8 @@ export class Editor implements Component, Focusable {
|
||||
while (
|
||||
graphemes.length > 0 &&
|
||||
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
||||
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
|
||||
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "") &&
|
||||
!isPasteMarker(graphemes[graphemes.length - 1]?.segment || "")
|
||||
) {
|
||||
newCol -= graphemes.pop()?.segment.length || 0;
|
||||
}
|
||||
@@ -1801,28 +1925,36 @@ export class Editor implements Component, Focusable {
|
||||
}
|
||||
|
||||
const textAfterCursor = currentLine.slice(this.state.cursorCol);
|
||||
const segments = segmenter.segment(textAfterCursor);
|
||||
const segments = this.segment(textAfterCursor);
|
||||
const iterator = segments[Symbol.iterator]();
|
||||
let next = iterator.next();
|
||||
let newCol = this.state.cursorCol;
|
||||
|
||||
// Skip leading whitespace
|
||||
while (!next.done && isWhitespaceChar(next.value.segment)) {
|
||||
while (!next.done && !isPasteMarker(next.value.segment) && isWhitespaceChar(next.value.segment)) {
|
||||
newCol += next.value.segment.length;
|
||||
next = iterator.next();
|
||||
}
|
||||
|
||||
if (!next.done) {
|
||||
const firstGrapheme = next.value.segment;
|
||||
if (isPunctuationChar(firstGrapheme)) {
|
||||
if (isPasteMarker(firstGrapheme)) {
|
||||
// Paste marker is a single atomic word
|
||||
newCol += firstGrapheme.length;
|
||||
} else if (isPunctuationChar(firstGrapheme)) {
|
||||
// Skip punctuation run
|
||||
while (!next.done && isPunctuationChar(next.value.segment)) {
|
||||
while (!next.done && isPunctuationChar(next.value.segment) && !isPasteMarker(next.value.segment)) {
|
||||
newCol += next.value.segment.length;
|
||||
next = iterator.next();
|
||||
}
|
||||
} else {
|
||||
// Skip word run
|
||||
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
|
||||
while (
|
||||
!next.done &&
|
||||
!isWhitespaceChar(next.value.segment) &&
|
||||
!isPunctuationChar(next.value.segment) &&
|
||||
!isPasteMarker(next.value.segment)
|
||||
) {
|
||||
newCol += next.value.segment.length;
|
||||
next = iterator.next();
|
||||
}
|
||||
|
||||
@@ -892,6 +892,128 @@ describe("Editor component", () => {
|
||||
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
|
||||
assert.strictEqual(reconstructed, line);
|
||||
});
|
||||
|
||||
it("splits oversized atomic segment across multiple chunks", () => {
|
||||
// Simulate a paste marker wider than maxWidth by passing pre-segmented data
|
||||
const marker = "[paste #1 +20 lines]"; // 21 chars
|
||||
const line = `A${marker}B`;
|
||||
const segments: Intl.SegmentData[] = [
|
||||
{ segment: "A", index: 0, input: line },
|
||||
{ segment: marker, index: 1, input: line },
|
||||
{ segment: "B", index: 1 + marker.length, input: line },
|
||||
];
|
||||
|
||||
const chunks = wordWrapLine(line, 10, segments);
|
||||
|
||||
// Every chunk must fit within maxWidth
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(
|
||||
visibleWidth(chunk.text) <= 10,
|
||||
`chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`,
|
||||
);
|
||||
}
|
||||
|
||||
// Verify no content is lost
|
||||
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
|
||||
assert.strictEqual(reconstructed, line);
|
||||
});
|
||||
|
||||
it("splits oversized atomic segment at start of line", () => {
|
||||
const marker = "[paste #1 +20 lines]"; // 21 chars
|
||||
const line = `${marker}B`;
|
||||
const segments: Intl.SegmentData[] = [
|
||||
{ segment: marker, index: 0, input: line },
|
||||
{ segment: "B", index: marker.length, input: line },
|
||||
];
|
||||
|
||||
const chunks = wordWrapLine(line, 10, segments);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(visibleWidth(chunk.text) <= 10);
|
||||
}
|
||||
// "B" ends up on the last line (either alone or with the marker tail)
|
||||
assert.strictEqual(chunks[chunks.length - 1]!.text.includes("B"), true);
|
||||
|
||||
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
|
||||
assert.strictEqual(reconstructed, line);
|
||||
});
|
||||
|
||||
it("splits oversized atomic segment at end of line", () => {
|
||||
const marker = "[paste #1 +20 lines]"; // 21 chars
|
||||
const line = `A${marker}`;
|
||||
const segments: Intl.SegmentData[] = [
|
||||
{ segment: "A", index: 0, input: line },
|
||||
{ segment: marker, index: 1, input: line },
|
||||
];
|
||||
|
||||
const chunks = wordWrapLine(line, 10, segments);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(visibleWidth(chunk.text) <= 10);
|
||||
}
|
||||
assert.strictEqual(chunks[0]!.text, "A");
|
||||
|
||||
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
|
||||
assert.strictEqual(reconstructed, line);
|
||||
});
|
||||
|
||||
it("splits consecutive oversized atomic segments", () => {
|
||||
const m1 = "[paste #1 +20 lines]"; // 21 chars
|
||||
const m2 = "[paste #2 +30 lines]"; // 21 chars
|
||||
const line = `${m1}${m2}`;
|
||||
const segments: Intl.SegmentData[] = [
|
||||
{ segment: m1, index: 0, input: line },
|
||||
{ segment: m2, index: m1.length, input: line },
|
||||
];
|
||||
|
||||
const chunks = wordWrapLine(line, 10, segments);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(
|
||||
visibleWidth(chunk.text) <= 10,
|
||||
`chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`,
|
||||
);
|
||||
}
|
||||
|
||||
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
|
||||
assert.strictEqual(reconstructed, line);
|
||||
});
|
||||
|
||||
it("wraps normally after oversized atomic segment", () => {
|
||||
const marker = "[paste #1 +20 lines]"; // 21 chars
|
||||
const line = `${marker} hello world`;
|
||||
const segments: Intl.SegmentData[] = [
|
||||
{ segment: marker, index: 0, input: line },
|
||||
{ segment: " ", index: marker.length, input: line },
|
||||
{ segment: "h", index: marker.length + 1, input: line },
|
||||
{ segment: "e", index: marker.length + 2, input: line },
|
||||
{ segment: "l", index: marker.length + 3, input: line },
|
||||
{ segment: "l", index: marker.length + 4, input: line },
|
||||
{ segment: "o", index: marker.length + 5, input: line },
|
||||
{ segment: " ", index: marker.length + 6, input: line },
|
||||
{ segment: "w", index: marker.length + 7, input: line },
|
||||
{ segment: "o", index: marker.length + 8, input: line },
|
||||
{ segment: "r", index: marker.length + 9, input: line },
|
||||
{ segment: "l", index: marker.length + 10, input: line },
|
||||
{ segment: "d", index: marker.length + 11, input: line },
|
||||
];
|
||||
|
||||
const chunks = wordWrapLine(line, 10, segments);
|
||||
|
||||
// All chunks must fit
|
||||
for (const chunk of chunks) {
|
||||
assert.ok(
|
||||
visibleWidth(chunk.text) <= 10,
|
||||
`chunk "${chunk.text}" has visible width ${visibleWidth(chunk.text)}, expected <= 10`,
|
||||
);
|
||||
}
|
||||
|
||||
// Last chunk should contain "world" (normal wrapping resumes)
|
||||
assert.strictEqual(chunks[chunks.length - 1]!.text, "world");
|
||||
|
||||
const reconstructed = chunks.map((c) => line.slice(c.startIndex, c.endIndex)).join("");
|
||||
assert.strictEqual(reconstructed, line);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Kill ring", () => {
|
||||
@@ -2989,4 +3111,282 @@ describe("Editor component", () => {
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 15 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Paste marker atomic behavior", () => {
|
||||
/** Helper: simulate a large paste that creates a marker */
|
||||
function pasteWithMarker(editor: Editor): string {
|
||||
const bigContent = "line\n".repeat(20).trimEnd(); // 20 lines
|
||||
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
|
||||
// The editor replaces large pastes with a marker like "[paste #1 +20 lines]"
|
||||
return editor.getText();
|
||||
}
|
||||
|
||||
it("creates a paste marker for large pastes", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
const text = pasteWithMarker(editor);
|
||||
assert.match(text, /\[paste #\d+ \+\d+ lines\]/);
|
||||
});
|
||||
|
||||
it("treats paste marker as single unit for right arrow", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.handleInput("A");
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput("B");
|
||||
// Text: "A[paste #1 +20 lines]B", cursor at end
|
||||
|
||||
// Go to start
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
|
||||
|
||||
// Right arrow: should move past "A"
|
||||
editor.handleInput("\x1b[C");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
|
||||
|
||||
// Right arrow: should skip the entire marker
|
||||
editor.handleInput("\x1b[C");
|
||||
const marker = editor.getText().match(/\[paste #\d+ \+\d+ lines\]/)![0];
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length });
|
||||
|
||||
// Right arrow: should move past "B"
|
||||
editor.handleInput("\x1b[C");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length + 1 });
|
||||
});
|
||||
|
||||
it("treats paste marker as single unit for left arrow", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.handleInput("A");
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput("B");
|
||||
// Cursor at end
|
||||
|
||||
// Left arrow: past "B"
|
||||
editor.handleInput("\x1b[D");
|
||||
const text = editor.getText();
|
||||
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0];
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length });
|
||||
|
||||
// Left arrow: skip the entire marker
|
||||
editor.handleInput("\x1b[D");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
|
||||
|
||||
// Left arrow: past "A"
|
||||
editor.handleInput("\x1b[D");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 });
|
||||
});
|
||||
|
||||
it("treats paste marker as single unit for backspace", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.handleInput("A");
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput("B");
|
||||
|
||||
const text = editor.getText();
|
||||
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0];
|
||||
|
||||
// Position cursor right after the marker (before "B")
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
// Move past "A" and the marker
|
||||
editor.handleInput("\x1b[C"); // past "A"
|
||||
editor.handleInput("\x1b[C"); // past marker
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 + marker.length });
|
||||
|
||||
// Backspace: should delete the entire marker at once
|
||||
editor.handleInput("\x7f");
|
||||
assert.strictEqual(editor.getText(), "AB");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
|
||||
});
|
||||
|
||||
it("treats paste marker as single unit for forward delete", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.handleInput("A");
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput("B");
|
||||
|
||||
// Position cursor on "A" (col 0) then move right once to be just before marker
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
editor.handleInput("\x1b[C"); // past "A", now at col 1 (start of marker)
|
||||
|
||||
// Forward delete: should delete the entire marker at once
|
||||
editor.handleInput("\x1b[3~"); // Delete key
|
||||
assert.strictEqual(editor.getText(), "AB");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
|
||||
});
|
||||
|
||||
it("treats paste marker as single unit for word movement", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.handleInput("X");
|
||||
editor.handleInput(" ");
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput(" ");
|
||||
editor.handleInput("Y");
|
||||
// Text: "X [paste #1 +20 lines] Y"
|
||||
|
||||
const text = editor.getText();
|
||||
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/)![0];
|
||||
|
||||
// Go to start
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
|
||||
// Ctrl+Right: skip "X"
|
||||
editor.handleInput("\x1b[1;5C");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 });
|
||||
|
||||
// Ctrl+Right: skip whitespace + marker (marker treated as single non-ws, non-punct unit)
|
||||
editor.handleInput("\x1b[1;5C");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 2 + marker.length });
|
||||
});
|
||||
|
||||
it("undo restores marker after backspace deletion", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
editor.handleInput("A");
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput("B");
|
||||
|
||||
const textBefore = editor.getText();
|
||||
|
||||
// Position after marker
|
||||
editor.handleInput("\x01");
|
||||
editor.handleInput("\x1b[C"); // past A
|
||||
editor.handleInput("\x1b[C"); // past marker
|
||||
|
||||
// Delete marker
|
||||
editor.handleInput("\x7f");
|
||||
assert.strictEqual(editor.getText(), "AB");
|
||||
|
||||
// Undo
|
||||
editor.handleInput("\x1b[45;5u");
|
||||
assert.strictEqual(editor.getText(), textBefore);
|
||||
});
|
||||
|
||||
it("handles multiple paste markers in same line", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
pasteWithMarker(editor);
|
||||
editor.handleInput(" ");
|
||||
pasteWithMarker(editor);
|
||||
|
||||
const text = editor.getText();
|
||||
const markers = [...text.matchAll(/\[paste #\d+ \+\d+ lines\]/g)];
|
||||
assert.strictEqual(markers.length, 2);
|
||||
|
||||
// Go to start
|
||||
editor.handleInput("\x01");
|
||||
|
||||
// Right arrow: should skip first marker atomically
|
||||
editor.handleInput("\x1b[C");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length });
|
||||
|
||||
// Right arrow: past space
|
||||
editor.handleInput("\x1b[C");
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: markers[0]![0].length + 1 });
|
||||
|
||||
// Right arrow: should skip second marker atomically
|
||||
editor.handleInput("\x1b[C");
|
||||
assert.deepStrictEqual(editor.getCursor(), {
|
||||
line: 0,
|
||||
col: markers[0]![0].length + 1 + markers[1]![0].length,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat manually typed marker-like text as atomic (no valid paste ID)", () => {
|
||||
const editor = new Editor(createTestTUI(), defaultEditorTheme);
|
||||
// Type text that matches the pattern but was typed manually (no paste entry)
|
||||
const fakeMarker = "[paste #99 +5 lines]";
|
||||
for (const ch of fakeMarker) editor.handleInput(ch);
|
||||
|
||||
assert.strictEqual(editor.getText(), fakeMarker);
|
||||
|
||||
// No paste with ID 99 exists, so the marker is NOT treated atomically.
|
||||
// Right arrow should move one grapheme at a time.
|
||||
editor.handleInput("\x01"); // Ctrl+A
|
||||
editor.handleInput("\x1b[C"); // Right
|
||||
assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 1 }); // Just past "["
|
||||
});
|
||||
|
||||
it("does not crash when paste marker is wider than terminal width", () => {
|
||||
// Reproduce: terminal width 8, paste marker "[paste #1 +47 lines]" (21 chars)
|
||||
const tui = createTestTUI();
|
||||
const editor = new Editor(tui, defaultEditorTheme);
|
||||
const bigContent = "line\n".repeat(47).trimEnd();
|
||||
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
|
||||
|
||||
const text = editor.getText();
|
||||
const marker = text.match(/\[paste #\d+ \+\d+ lines\]/);
|
||||
assert.ok(marker, "paste marker should be created");
|
||||
assert.ok(visibleWidth(marker[0]) > 8, "marker should be wider than render width");
|
||||
|
||||
// Render at very narrow width - should not throw
|
||||
const lines = editor.render(8);
|
||||
// Every rendered line must fit within the width (marker is split)
|
||||
for (const line of lines) {
|
||||
assert.ok(
|
||||
visibleWidth(line) <= 8,
|
||||
`line exceeds width 8: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not crash when text + paste marker exceeds terminal width with cursor on marker", () => {
|
||||
// Reproduce: terminal width 54, text "b".repeat(35) + "[paste #1 +27 lines]" + "bbbb"
|
||||
// Cursor lands on the paste marker after word-wrap, causing the rendered line
|
||||
// to be 55 visible chars (1 over the width).
|
||||
const tui = createTestTUI();
|
||||
const editor = new Editor(tui, defaultEditorTheme);
|
||||
|
||||
// Type 35 'b' characters
|
||||
for (let i = 0; i < 35; i++) editor.handleInput("b");
|
||||
|
||||
// Paste 27 lines
|
||||
const bigContent = "line\n".repeat(27).trimEnd();
|
||||
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
|
||||
|
||||
// Type a few more characters
|
||||
for (let i = 0; i < 4; i++) editor.handleInput("b");
|
||||
|
||||
// Move cursor left to land on the paste marker
|
||||
editor.handleInput("\x1b[D"); // past last 'b'
|
||||
editor.handleInput("\x1b[D"); // past last 'b'
|
||||
editor.handleInput("\x1b[D"); // past last 'b'
|
||||
editor.handleInput("\x1b[D"); // past last 'b'
|
||||
editor.handleInput("\x1b[D"); // now on the paste marker
|
||||
|
||||
// Render at width 54 - should not throw
|
||||
const renderWidth = 54;
|
||||
const lines = editor.render(renderWidth);
|
||||
for (const line of lines) {
|
||||
assert.ok(
|
||||
visibleWidth(line) <= renderWidth,
|
||||
`line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("wordWrapLine re-checks overflow after backtracking to wrap opportunity", () => {
|
||||
// Reproduce crash #2: " " + "b".repeat(35) + atomic_marker(20 chars) + "bbbb"
|
||||
// layoutWidth=53. After wrapping at the space, the remaining 35 b's + marker = 55
|
||||
// must trigger a second force-break instead of silently overflowing.
|
||||
const tui = createTestTUI();
|
||||
const editor = new Editor(tui, defaultEditorTheme);
|
||||
|
||||
// Type a space, then 35 b's
|
||||
editor.handleInput(" ");
|
||||
for (let i = 0; i < 35; i++) editor.handleInput("b");
|
||||
|
||||
// Paste 27 lines to create marker
|
||||
const bigContent = "line\n".repeat(27).trimEnd();
|
||||
editor.handleInput(`\x1b[200~${bigContent}\x1b[201~`);
|
||||
|
||||
// Type trailing chars
|
||||
for (let i = 0; i < 4; i++) editor.handleInput("b");
|
||||
|
||||
// Render at width 54 (contentWidth=54, layoutWidth=53 with paddingX=0)
|
||||
const renderWidth = 54;
|
||||
const lines = editor.render(renderWidth);
|
||||
for (const line of lines) {
|
||||
assert.ok(
|
||||
visibleWidth(line) <= renderWidth,
|
||||
`line exceeds width ${renderWidth}: visible=${visibleWidth(line)} text=${JSON.stringify(line)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user