Skip to content

Commit e84e3e5

Browse files
committed
feat(input): atomic placeholder delete on backspace/deleteForward
Backspacing at the right edge of a paste placeholder used to chip off the closing bracket, leaving a fragment that wouldn't re-expand on submit. Detect a complete placeholder hugging the cursor and delete it as one atomic unit, garbage-collecting the side-map entry at the same time. deleteForward gets the symmetric treatment for the left edge.
1 parent 733de65 commit e84e3e5

2 files changed

Lines changed: 125 additions & 0 deletions

File tree

src/ui/input-state.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,3 +316,70 @@ describe("insertPaste + expandPastes", () => {
316316
expect(after).toEqual(before);
317317
});
318318
});
319+
320+
describe("atomic placeholder deletion", () => {
321+
it("backspace at the right edge of a placeholder removes the whole placeholder", () => {
322+
let s = insertPaste(initialInputState(), "abc\ndef\nghi");
323+
const before = s.buffer; // "[Pasted #1 · 3 lines]"
324+
s = backspace(s);
325+
expect(s.buffer).toBe("");
326+
expect(s.cursor).toBe(0);
327+
expect(s.pastedContents[1]).toBeUndefined();
328+
// Sanity: prior state actually held the placeholder
329+
expect(before).toBe("[Pasted #1 · 3 lines]");
330+
});
331+
332+
it("backspace garbage-collects the side entry for the deleted placeholder", () => {
333+
let s = insertPaste(initialInputState(), "x".repeat(500));
334+
expect(Object.keys(s.pastedContents)).toHaveLength(1);
335+
s = backspace(s);
336+
expect(Object.keys(s.pastedContents)).toHaveLength(0);
337+
});
338+
339+
it("backspace deletes only the adjacent placeholder, leaving siblings intact", () => {
340+
let s = insertPaste(initialInputState(), "first one");
341+
for (const ch of " then ") s = insertChar(s, ch);
342+
s = insertPaste(s, "second one is longer than the first paste here");
343+
// Cursor is at the end, right after the second placeholder.
344+
s = backspace(s);
345+
// Second placeholder removed; first remains intact.
346+
expect(s.buffer).toBe("[Pasted #1 · 9 chars] then ");
347+
expect(s.pastedContents[1]).toBeDefined();
348+
expect(s.pastedContents[2]).toBeUndefined();
349+
});
350+
351+
it("deleteForward at the left edge of a placeholder removes the whole placeholder", () => {
352+
let s = insertPaste(initialInputState(), "foo\nbar");
353+
s = moveStart(s);
354+
s = deleteForward(s);
355+
expect(s.buffer).toBe("");
356+
expect(s.pastedContents[1]).toBeUndefined();
357+
});
358+
359+
it("backspace in the middle of a placeholder still chips one char (non-edge)", () => {
360+
// Cursor inside the placeholder, not at the right edge — fall back
361+
// to normal char-by-char behavior. The placeholder is now broken
362+
// but expandPastes will leave the fragment as-is on submit.
363+
let s = insertPaste(initialInputState(), "hello");
364+
s = moveLeft(s); // cursor now between "]" and end? no, one before end
365+
s = backspace(s);
366+
// We removed one char from the middle, breaking the placeholder.
367+
expect(s.buffer.length).toBe(s.buffer.length);
368+
expect(s.buffer).not.toBe("[Pasted #1 · 5 chars]"); // broken
369+
expect(s.pastedContents[1]).toBeDefined(); // entry NOT garbage-collected
370+
});
371+
372+
it("backspace at a position where the buffer looks like a placeholder but isn't ours just chips a char", () => {
373+
// User literally typed [Pasted #999 · 5 chars] — we have no id 999,
374+
// but we still detect placeholder shape via the regex. Decision:
375+
// trust the shape, garbage-collect won't find an entry to remove
376+
// (no-op on the side map), but the visible delete is atomic.
377+
// That's defensible: shape matches user intent.
378+
let s = initialInputState();
379+
for (const ch of "[Pasted #999 · 5 chars]") s = insertChar(s, ch);
380+
s = backspace(s);
381+
expect(s.buffer).toBe("");
382+
// pastedContents was empty — nothing to drop.
383+
expect(s.pastedContents).toEqual({});
384+
});
385+
});

src/ui/input-state.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,39 @@ export function looksLikePaste(input: string): boolean {
6262
}
6363

6464
const PASTE_PLACEHOLDER_RE = /\[Pasted #(\d+) · \d+ (?:lines|chars)\]/g;
65+
/** Anchored variants used to detect a placeholder hugging the cursor on either side. */
66+
const PASTE_PLACEHOLDER_RE_END = /\[Pasted #(\d+) · \d+ (?:lines|chars)\]$/;
67+
const PASTE_PLACEHOLDER_RE_START = /^\[Pasted #(\d+) · \d+ (?:lines|chars)\]/;
68+
69+
/**
70+
* If the buffer slice ending at `pos` finishes with a complete placeholder,
71+
* return its start offset and id. Used by backspace to delete a whole
72+
* placeholder atomically instead of chipping at the closing bracket.
73+
*/
74+
function placeholderEndingAt(buffer: string, pos: number): { start: number; id: number } | undefined {
75+
const before = buffer.slice(0, pos);
76+
const m = before.match(PASTE_PLACEHOLDER_RE_END);
77+
if (!m) return undefined;
78+
return { start: pos - m[0].length, id: Number.parseInt(m[1], 10) };
79+
}
80+
81+
/**
82+
* If the buffer slice starting at `pos` begins with a complete placeholder,
83+
* return its end offset and id. Used by deleteForward.
84+
*/
85+
function placeholderStartingAt(buffer: string, pos: number): { end: number; id: number } | undefined {
86+
const after = buffer.slice(pos);
87+
const m = after.match(PASTE_PLACEHOLDER_RE_START);
88+
if (!m) return undefined;
89+
return { end: pos + m[0].length, id: Number.parseInt(m[1], 10) };
90+
}
91+
92+
function dropPasteEntry(map: Record<number, PastedContent>, id: number): Record<number, PastedContent> {
93+
if (!(id in map)) return map;
94+
const next = { ...map };
95+
delete next[id];
96+
return next;
97+
}
6598

6699
/**
67100
* Render the placeholder shown in the visible buffer when text is pasted.
@@ -136,6 +169,20 @@ export function insertChar(state: InputState, ch: string): InputState {
136169

137170
export function backspace(state: InputState): InputState {
138171
if (state.cursor === 0) return state;
172+
// Atomic placeholder delete: backspacing at the right edge of a paste
173+
// placeholder removes the whole placeholder instead of breaking off
174+
// the closing bracket and leaving a fragment that won't re-expand.
175+
const ph = placeholderEndingAt(state.buffer, state.cursor);
176+
if (ph) {
177+
return {
178+
...state,
179+
buffer: state.buffer.slice(0, ph.start) + state.buffer.slice(state.cursor),
180+
cursor: ph.start,
181+
pastedContents: dropPasteEntry(state.pastedContents, ph.id),
182+
undoStack: pushUndo(state),
183+
lastAction: "delete",
184+
};
185+
}
139186
return {
140187
...state,
141188
buffer: state.buffer.slice(0, state.cursor - 1) + state.buffer.slice(state.cursor),
@@ -147,6 +194,17 @@ export function backspace(state: InputState): InputState {
147194

148195
export function deleteForward(state: InputState): InputState {
149196
if (state.cursor >= state.buffer.length) return state;
197+
// Atomic placeholder delete on forward-delete from the left edge.
198+
const ph = placeholderStartingAt(state.buffer, state.cursor);
199+
if (ph) {
200+
return {
201+
...state,
202+
buffer: state.buffer.slice(0, state.cursor) + state.buffer.slice(ph.end),
203+
pastedContents: dropPasteEntry(state.pastedContents, ph.id),
204+
undoStack: pushUndo(state),
205+
lastAction: "delete",
206+
};
207+
}
150208
return {
151209
...state,
152210
buffer: state.buffer.slice(0, state.cursor) + state.buffer.slice(state.cursor + 1),

0 commit comments

Comments
 (0)