Skip to content

Commit 00c2c91

Browse files
committed
fix(tui): keep a repeated kill on the caret hole
The longest common prefix of a repeated span is the other copy, and parking the caret there yanks into the wrong hole. Prefer the span the native caret already sits on, and fall back to that prefix only when a snapped wide cell left the caret on none of them. Refs CL-10644
1 parent db78b83 commit 00c2c91

3 files changed

Lines changed: 106 additions & 6 deletions

File tree

‎src/tui/prompt-kill-ring.test.ts‎

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,27 @@ describe("removedByDelete", () => {
6060
test("a replacement that is not a pure deletion records nothing", () => {
6161
expect(removedByDelete("abcd", "abXd")).toEqual({ text: "", at: 0 });
6262
});
63+
64+
test("a repeated prefix uses the caret hole, not the other copy", () => {
65+
const hole = {
66+
caret: 0,
67+
measure: (text: string) => text.length,
68+
};
69+
expect(removedByDelete("hello hello", "hello", hole)).toEqual({
70+
text: "hello ",
71+
at: 0,
72+
});
73+
expect(removedByDelete("aaa", "aa", hole)).toEqual({ text: "a", at: 0 });
74+
});
75+
76+
test("a caret that sits on no hole keeps the longest common prefix", () => {
77+
expect(
78+
removedByDelete("hello hello", "hello", {
79+
caret: 7,
80+
measure: (text: string) => text.length,
81+
}),
82+
).toEqual({ text: " hello", at: 5 });
83+
});
6384
});
6485

6586
describe("rotateYank", () => {
@@ -192,6 +213,56 @@ describe("prompt kill ring chords", () => {
192213
});
193214
});
194215

216+
test("ctrl+u on a repeated prefix kills the span at the caret", async () => {
217+
await withTestRenderer(async (h) => {
218+
const shell = createAppShell(h.renderer, {
219+
terminal: { columns: 80, rows: 24 },
220+
run: "idle",
221+
});
222+
shell.prompt.value = "hello hello";
223+
shell.prompt.cursorOffset = 6;
224+
h.pressKey("u", { ctrl: true });
225+
expect(shell.prompt.value).toBe("hello");
226+
expect(shell.prompt.cursorOffset).toBe(0);
227+
expect(shell.promptKillRing.entries[0]).toBe("hello ");
228+
h.pressKey("y", { ctrl: true });
229+
expect(shell.prompt.value).toBe("hello hello");
230+
});
231+
});
232+
233+
test("ctrl+u inside a run of the same character leaves the caret at the hole", async () => {
234+
await withTestRenderer(async (h) => {
235+
const shell = createAppShell(h.renderer, {
236+
terminal: { columns: 80, rows: 24 },
237+
run: "idle",
238+
});
239+
shell.prompt.value = "aaa";
240+
shell.prompt.cursorOffset = 1;
241+
h.pressKey("u", { ctrl: true });
242+
expect(shell.prompt.value).toBe("aa");
243+
expect(shell.prompt.cursorOffset).toBe(0);
244+
h.pressKey("x");
245+
expect(shell.prompt.value).toBe("xaa");
246+
});
247+
});
248+
249+
test("ctrl+u on a unique prefix still kills that prefix", async () => {
250+
await withTestRenderer(async (h) => {
251+
const shell = createAppShell(h.renderer, {
252+
terminal: { columns: 80, rows: 24 },
253+
run: "idle",
254+
});
255+
shell.prompt.value = "hello world";
256+
shell.prompt.cursorOffset = 6;
257+
h.pressKey("u", { ctrl: true });
258+
expect(shell.prompt.value).toBe("world");
259+
expect(shell.prompt.cursorOffset).toBe(0);
260+
expect(shell.promptKillRing.entries[0]).toBe("hello ");
261+
h.pressKey("z", { shift: true });
262+
expect(shell.prompt.value).toBe("Zworld");
263+
});
264+
});
265+
195266
test("ctrl+k then ctrl+y on a parked wide glyph leaves it unchanged", async () => {
196267
await withTestRenderer(async (h) => {
197268
const shell = createAppShell(h.renderer, {

‎src/tui/prompt-kill-ring.ts‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,17 @@ export function rotateYank(
123123
* prediction yanks text the buffer never lost. `text` is empty when nothing
124124
* was deleted or the change is not a pure deletion. `at` is the code-unit
125125
* index of the span in `before` (and where it splices back into `after`).
126+
*
127+
* A repeated span has more than one such index. The longest common prefix
128+
* is the rightmost, which is the other copy. `hole` is the native caret in
129+
* `after` (display cells, via `measure`). The span whose splice point lands
130+
* on that caret wins; if the caret sits on none of them, the longest common
131+
* prefix stands so a snapped wide cell can still be parked.
126132
*/
127133
export function removedByDelete(
128134
before: string,
129135
after: string,
136+
hole?: { caret: number; measure: (text: string) => number },
130137
): { text: string; at: number } {
131138
if (after.length > before.length || before === after)
132139
return { text: "", at: 0 };
@@ -146,5 +153,22 @@ export function removedByDelete(
146153
if (after !== `${before.slice(0, at)}${before.slice(beforeEnd)}`) {
147154
return { text: "", at: 0 };
148155
}
149-
return { text: before.slice(at, beforeEnd), at };
156+
const removedLen = beforeEnd - at;
157+
let chosen = at;
158+
if (hole !== undefined && removedLen > 0) {
159+
let min = at;
160+
while (
161+
min > 0 &&
162+
before.charCodeAt(min - 1) === before.charCodeAt(min - 1 + removedLen)
163+
) {
164+
min -= 1;
165+
}
166+
for (let candidate = min; candidate < at; candidate += 1) {
167+
if (hole.measure(after.slice(0, candidate)) === hole.caret) {
168+
chosen = candidate;
169+
break;
170+
}
171+
}
172+
}
173+
return { text: before.slice(chosen, chosen + removedLen), at: chosen };
150174
}

‎src/tui/shell/keys.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,18 @@ function killThenRecord(
318318
const before = shell.prompt.value;
319319
remove(shell.prompt);
320320
const after = shell.prompt.value;
321-
const removal = removedByDelete(before, after);
322-
// deleteRange can leave the caret past a grapheme it snapped off. Yank
321+
const measure = (prefix: string): number =>
322+
shell.prompt.editBuffer.measureText(prefix);
323+
const removal = removedByDelete(before, after, {
324+
caret: shell.prompt.cursorOffset,
325+
measure,
326+
});
327+
// A snapped wide cell leaves the caret off the grapheme it dropped. Yank
323328
// inserts at the caret, so park it where the lost span splices back in.
329+
// When the native caret already sits on that hole, this is a no-op and
330+
// does not slide it onto the other copy of a repeated prefix.
324331
if (removal.text.length > 0) {
325-
shell.prompt.cursorOffset = shell.prompt.editBuffer.measureText(
326-
after.slice(0, removal.at),
327-
);
332+
shell.prompt.cursorOffset = measure(after.slice(0, removal.at));
328333
}
329334
shell.promptKillRing = recordKill(
330335
shell.promptKillRing,

0 commit comments

Comments
 (0)