Skip to content

Commit 161d52b

Browse files
authored
Name selection writes instead of inferring a selection's identity (#1323)
2 parents b0343a3 + a7ebd17 commit 161d52b

10 files changed

Lines changed: 156 additions & 37 deletions

‎docs/specs/mouse-and-clipboard.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,9 @@ Source of truth: `lib/src/lib/terminal-mouse-router.ts`.
7272

7373
### 3.4 Selection Follows Content
7474

75-
A selection is anchored to the characters under it, not to screen coordinates: stored in absolute buffer rows (scrollback + viewport).
75+
**Must anchor selections to characters in absolute buffer rows** (scrollback + viewport).
7676

77-
- **Pure scroll** — vertical translation with no character changes — carries the selection along; coordinate math only, no matching.
77+
- **Must follow vertical scrolling and older scrollback trims** using coordinate math, never matching. **Must cancel when selected rows are trimmed**, even if identical text replaces them.
7878
- **Must cancel a finalized selection on the next xterm render when its extracted selected text changes**; repaints elsewhere are irrelevant. **Never add a partial-match or content-tracking heuristic** (§9.1).
7979
- **Terminal resize** carries a finalized linewise selection Dormouse owns, in the normal buffer, through xterm's reflow (rationale). Its editor stays open in the same format and same-labeled scope, else As selected; per-break edits drop unless the width held.
8080
- **Must cancel if an edge's line was trimmed or its cells no longer read as the selected text** (rationale).
@@ -180,7 +180,7 @@ Every break is a mark that a click cycles keep → space → none; a scope or fo
180180
- **Never show while its Wall travels**, its pane hidden, or another pane zoomed over it.
181181
- **Never take focus**; only an actual scrollbar press keeps its default (rationale). Presses inside it count as inside its pane (`anchoredTarget`); its `mousedown` and `contextmenu` never reach the pane.
182182
- **Esc**, a click outside the editor, a content change or a resize it cannot follow (§3.4), a confirmed copy, or **any input the terminal receives** — typing, a paste, Pocket's input bar — dismisses it and cancels the selection. Source of truth: `writeUserInput` in `lib/src/lib/terminal-lifecycle.ts`, pinned by `lib/src/lib/terminal-lifecycle.selection.test.ts`.
183-
- **Must confirm only after a successful clipboard write, and only for the selection copied**; the selection then clears, however it moved meanwhile (rationale).
183+
- **Must confirm only after a successful clipboard write, and only for the selection copied**; the selection then clears, even if its edges moved or reflow ran while the write was pending (rationale).
184184
- **Must leave empty copies idle without writing.** **Must say a failed write failed**, keeping the selection for a retry.
185185
- **Must write through the adapter's native `writeClipboardText` where it has one, and never fall back from it** (rationale). **Pocket's adapter must have none**: its copies belong on the phone. Without one, the write takes the Clipboard API, and `execCommand('copy')` where that is missing or refuses (rationale).
186186
- **Must open the clipboard-failure report when a write fails every way it may take**, unless its caller opts out. **Its details never carry the copied text**; they go to the tracking issue the report links.

‎lib/src/lib/copy-editor.test.ts‎

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,17 @@ describe('copySelection', () => {
175175
}
176176
});
177177

178+
it.each([true, false])('reports a pending copy after nudging the same selection (copied: %s)', async copied => {
179+
let complete!: (result: boolean) => void;
180+
vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise(resolve => { complete = resolve; }));
181+
select({ endRow: 2, endCol: 11 });
182+
const pending = copySelection(ID);
183+
nudgeCopyEdge(ID, 'end', 1);
184+
complete(copied);
185+
await pending;
186+
expect(getMouseSelectionState(ID)).toMatchObject({ selection: { endCol: 16 }, copyOutcome: copied ? 'copied' : 'failed' });
187+
});
188+
178189
it('says a failed write failed, for that selection', async () => {
179190
vi.mocked(writeTextToClipboard).mockResolvedValue(false);
180191
select({});
@@ -209,13 +220,19 @@ describe('copySelection', () => {
209220
});
210221

211222
it.each([true, false])('neither flashes nor fails a newer selection when an earlier copy finishes (copied: %s)', async (copied) => {
212-
let complete!: (copied: boolean) => void;
213-
vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise((resolve) => { complete = resolve; }));
214-
select({});
215-
const pending = copySelection(ID);
216-
beginDrag(ID, { row: 9, col: 1, altKey: false, startedInScrollback: false });
217-
complete(copied);
218-
await pending;
219-
expect(getMouseSelectionState(ID)).toMatchObject({ copyOutcome: null, selection: { startRow: 9 } });
223+
const newer = [
224+
() => beginDrag(ID, { row: 9, col: 1, altKey: false, startedInScrollback: false }),
225+
() => select({ startRow: 9, endRow: 9 }),
226+
];
227+
for (const replace of newer) {
228+
let complete!: (copied: boolean) => void;
229+
vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise((resolve) => { complete = resolve; }));
230+
select({});
231+
const pending = copySelection(ID);
232+
replace();
233+
complete(copied);
234+
await pending;
235+
expect(getMouseSelectionState(ID)).toMatchObject({ copyOutcome: null, selection: { startRow: 9 } });
236+
}
220237
});
221238
});

‎lib/src/lib/copy-editor.ts‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,23 +37,23 @@ export function openCopyEditor(id: string, terminal: Terminal | undefined = regi
3737
if (terminal && sel && !sel.dragging) setCopyEditor(id, editorFor(terminalCopyBuffer(terminal), spanOfSelection(sel), 'auto'));
3838
}
3939

40-
/** Move the selection to `span`, where a resize's reflow put its cells, and
41-
* rebuild its editor from the terminal (spec §3.4). Writes nothing when
42-
* neither the span nor the editor's width changed. */
40+
/** Move the selection to `span`, where a scroll, trim, or resize's reflow put
41+
* its cells, and rebuild its editor from the terminal (spec §3.4). Writes
42+
* nothing when neither the span nor the editor's width changed. */
4343
export function followCopySelection(id: string, terminal: Terminal, span: Span): void {
4444
const { selection, copyEditor: editor } = getMouseSelectionState(id);
4545
if (!selection) return;
4646
const sameWidth = editor?.buffer.cols === terminal.cols;
4747
if (spanEquals(span, spanOfSelection(selection)) && (!editor || sameWidth)) return;
4848
const moved = selectionOfSpan(span, selection);
4949
if (!editor) {
50-
setSelection(id, moved);
50+
setSelection(id, moved, null, 'follow');
5151
return;
5252
}
5353
const next = editorFor(terminalCopyBuffer(terminal), span, editor.format);
5454
const label = editor.scopes[editor.scope].label;
5555
const scope = next.scopes.findIndex((s) => s.label === label);
56-
setSelection(id, moved, { ...next, scope: Math.max(0, scope), overrides: scope >= 0 && sameWidth ? editor.overrides : {} });
56+
setSelection(id, moved, { ...next, scope: Math.max(0, scope), overrides: scope >= 0 && sameWidth ? editor.overrides : {} }, 'follow');
5757
}
5858

5959
export type FormatRenderings = Partial<Record<EditorFormat, Rendering>>;
@@ -163,5 +163,5 @@ export function nudgeCopyEdge(id: string, edge: 'start' | 'end', dir: 1 | -1): v
163163
? { ...span, start: nudge(buf, span.start, dir, 'start') }
164164
: { ...span, end: nudge(buf, span.end, dir, 'end') };
165165
if (spanEquals(moved, span) || comparePos(moved.start, moved.end) > 0) return;
166-
setSelection(id, selectionOfSpan(moved, sel), editorFor(buf, moved, copyEditor.format));
166+
setSelection(id, selectionOfSpan(moved, sel), editorFor(buf, moved, copyEditor.format), 'edit');
167167
}

‎lib/src/lib/copy-selection.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,12 +14,12 @@ import { COPY_FLASH_MS, failCopy, flashCopy, getMouseSelectionState, TOUCH_COPY_
1414
* says so instead, for that selection, and keeps it.
1515
*/
1616
export async function copySelection(terminalId: string, { touch = false }: { touch?: boolean } = {}): Promise<void> {
17-
const { selection, copyEditor, programCopy } = getMouseSelectionState(terminalId);
17+
const { selection, selectionEpoch, copyEditor, programCopy } = getMouseSelectionState(terminalId);
1818
if (!selection || !copyEditor) return;
1919
const text = editorRendering(copyEditor, programCopy).text;
2020
if (!text) return;
2121
const copied = await writeTextToClipboard(text);
22-
if (getMouseSelectionState(terminalId).selection !== selection) return;
22+
if (getMouseSelectionState(terminalId).selectionEpoch !== selectionEpoch) return;
2323
if (!copied) {
2424
failCopy(terminalId);
2525
return;

‎lib/src/lib/mouse-selection.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ describe('mouse-selection: default state', () => {
4040
bracketedPaste: false,
4141
override: 'off',
4242
selection: null,
43+
selectionEpoch: 0,
4344
hintToken: null,
4445
copyEditor: null,
4546
programCopy: null,

‎lib/src/lib/mouse-selection.ts‎

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ export interface MouseSelectionState {
6363
bracketedPaste: boolean;
6464
override: OverrideState;
6565
selection: Selection | null;
66+
/** Bumped when a selection ends or is replaced; held through an edit or
67+
* follow ({@link SelectionWrite}). */
68+
selectionEpoch: number;
6669
/** The token under a drag's head, in absolute buffer rows. */
6770
hintToken: BufferToken | null;
6871
/** Open while non-null: always the editor written with this exact
@@ -84,6 +87,7 @@ export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze(
8487
bracketedPaste: false,
8588
override: 'off',
8689
selection: null,
90+
selectionEpoch: 0,
8791
hintToken: null,
8892
copyEditor: null,
8993
programCopy: null,
@@ -98,6 +102,7 @@ let cachedSnapshot: Map<string, MouseSelectionState> | null = null;
98102
* one cannot leave another's editor, offer, or flash behind. */
99103
function clearSelection(s: MouseSelectionState): void {
100104
s.selection = null;
105+
s.selectionEpoch++;
101106
s.copyEditor = null;
102107
s.programCopy = null;
103108
s.copyOutcome = null;
@@ -170,22 +175,27 @@ export function setOverride(id: string, override: OverrideState): void {
170175
notify();
171176
}
172177

178+
/** How a write relates to the selection before it: `replace` starts another,
179+
* `edit` moves its edges, and `follow` carries the same cells through a
180+
* scroll, trim, or reflow, so only it keeps the program's offer. */
181+
export type SelectionWrite = 'replace' | 'edit' | 'follow';
182+
173183
/**
174184
* Replace the selection and, in the same write, the copy editor over it: none
175185
* unless one is given for a finalized selection, so an editor never outlives
176186
* the selection it was opened for.
177187
*/
178-
export function setSelection(id: string, selection: Selection | null, copyEditor: CopyEditorState | null = null): void {
188+
export function setSelection(id: string, selection: Selection | null, copyEditor: CopyEditorState | null = null, write: SelectionWrite = 'replace'): void {
179189
const s = ensure(id);
180190
if (s.selection === null && selection === null) return;
181191
if (selection === null) clearSelection(s);
182192
else {
183-
// A replaced selection keeps an in-flight flash and the drag's hint, but
184-
// not the program's offer, so its editor cannot show that offer either.
193+
// Every write keeps the flash and drag hint.
194+
if (write === 'replace') s.selectionEpoch++;
185195
s.selection = selection;
186-
s.programCopy = null;
196+
if (write !== 'follow') s.programCopy = null;
187197
s.copyEditor = selection.dragging || !copyEditor ? null
188-
: copyEditor.format === 'program' ? { ...copyEditor, format: 'auto', overrides: {} } : copyEditor;
198+
: copyEditor.format === 'program' && s.programCopy === null ? { ...copyEditor, format: 'auto', overrides: {} } : copyEditor;
189199
}
190200
notify();
191201
}

‎lib/src/lib/selection-reflow.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,13 @@ function locateEdge(buffer: IBuffer, edge: EdgeAnchor): GridPos | null {
6767
}
6868
}
6969

70+
/** A marker on absolute buffer row `row`; `registerMarker` counts from the
71+
* cursor's row. */
72+
export function markRow(terminal: Terminal, row: number): IMarker {
73+
const buffer = terminal.buffer.active;
74+
return terminal.registerMarker(row - (buffer.baseY + buffer.cursorY));
75+
}
76+
7077
/** Anchor a finalized linewise selection Dormouse owns; null for anything a
7178
* resize cancels. The alternate buffer never reflows, and its program
7279
* redraws on resize. */
@@ -78,11 +85,7 @@ export function anchorSelection(terminal: Terminal, sel: Selection): ReflowAncho
7885
const start = lineOffset(buffer, from, 'start');
7986
const end = lineOffset(buffer, to, 'end');
8087
if (text === null || !start || !end) return null;
81-
const mark = ({ first, offset }: { first: number; offset: number }): EdgeAnchor => ({
82-
// `registerMarker` counts from the cursor's row.
83-
marker: terminal.registerMarker(first - (buffer.baseY + buffer.cursorY)),
84-
offset,
85-
});
88+
const mark = ({ first, offset }: { first: number; offset: number }): EdgeAnchor => ({ marker: markRow(terminal, first), offset });
8689
const anchor: Anchor = {
8790
start: mark(start),
8891
end: mark(end),

‎lib/src/lib/selection-watch.test.ts‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// @vitest-environment jsdom
22
import { afterEach, describe, expect, it } from 'vitest';
33
import { Terminal } from '@xterm/xterm';
4+
import { openCopyEditor, setCopyFormat } from './copy-editor';
45
import { finalizedSelection } from './copy-text-fixtures';
5-
import { __resetMouseSelectionForTests, getMouseSelectionState, setSelection } from './mouse-selection';
6+
import { __resetMouseSelectionForTests, getMouseSelectionState, offerProgramCopy, setSelection } from './mouse-selection';
67
import { watchSelection } from './selection-watch';
78

89
/** Cancel-on-change, compared on each render (docs/specs/mouse-and-clipboard.md
@@ -37,9 +38,53 @@ describe('watchSelection', () => {
3738
watch.dispose();
3839
});
3940

41+
it.each([
42+
['linewise', {}],
43+
['block', { shape: 'block' }],
44+
['program', { owner: 'program' }],
45+
] as const)('carries a %s selection when older scrollback is trimmed', async (kind, over) => {
46+
const id = 'trim-' + kind;
47+
const terminal = new Terminal({ cols: 20, rows: 3, scrollback: 3, allowProposedApi: true });
48+
live.push(terminal);
49+
await write(terminal, 'old0\r\nold1\r\nkeep A\r\nkeep B\r\ntail0\r\ntail1');
50+
const watch = watchSelection(id, terminal);
51+
setSelection(id, finalizedSelection({ startRow: 2, endRow: 3, endCol: 5, ...over }));
52+
openCopyEditor(id, terminal);
53+
if (kind === 'program') {
54+
offerProgramCopy(id, 'source text');
55+
setCopyFormat(id, 'program');
56+
}
57+
const markers = terminal.markers.map((m) => m.id);
58+
await write(terminal, '\r\ntail2');
59+
watch.onRender();
60+
expect(getMouseSelectionState(id).selection).toMatchObject({ startRow: 1, endRow: 2, endCol: 5 });
61+
// Following the trim reuses the markers that moved with it.
62+
expect(terminal.markers.map((m) => m.id)).toEqual(markers);
63+
expect(getMouseSelectionState(id).copyEditor).not.toBeNull();
64+
if (kind === 'program') {
65+
expect(getMouseSelectionState(id)).toMatchObject({ programCopy: 'source text', copyEditor: { format: 'program' } });
66+
}
67+
await write(terminal, '\r\ntail3\r\ntail4');
68+
watch.onRender();
69+
expect(getMouseSelectionState(id).selection).toBeNull();
70+
watch.dispose();
71+
});
72+
73+
it('cancels trimmed selected rows even when their replacements have identical text', async () => {
74+
const terminal = new Terminal({ cols: 20, rows: 3, scrollback: 1, allowProposedApi: true });
75+
live.push(terminal);
76+
await write(terminal, 'same\r\nsame\r\nsame\r\nsame');
77+
const watch = watchSelection('trim-identical', terminal);
78+
setSelection('trim-identical', finalizedSelection({ endCol: 3 }));
79+
await write(terminal, '\r\nsame');
80+
watch.onRender();
81+
expect(getMouseSelectionState('trim-identical').selection).toBeNull();
82+
watch.dispose();
83+
});
84+
4085
it('releases its anchor and stops watching once disposed', async () => {
4186
const { terminal, watch } = await watched('w-2');
42-
expect(terminal.markers).toHaveLength(2);
87+
expect(terminal.markers).toHaveLength(3);
4388
watch.dispose();
4489
expect(terminal.markers).toHaveLength(0);
4590
setSelection('w-2', finalizedSelection({ endCol: 2 }));

‎lib/src/lib/selection-watch.ts‎

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { Terminal } from '@xterm/xterm';
1+
import type { IMarker, Terminal } from '@xterm/xterm';
22
import { followCopySelection } from './copy-editor';
3+
import { spanOfSelection } from './copy-text';
34
import { getMouseSelectionState, setSelection, subscribeToMouseSelection, type Selection } from './mouse-selection';
4-
import { anchorSelection, followReflow, type ReflowAnchor } from './selection-reflow';
5+
import { anchorSelection, followReflow, markRow, type ReflowAnchor } from './selection-reflow';
56
import { extractSelectionText } from './selection-text';
67

78
// A pane's selection held to the characters under it
@@ -22,17 +23,35 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch {
2223
let baseline: string | null = null;
2324
/** Its edges, carried through a resize's reflow. */
2425
let anchor: ReflowAnchor | null = null;
26+
// A full normal buffer trims its oldest rows as output arrives. One marker
27+
// on the earlier edge distinguishes translation from replacement text.
28+
let scrollMarker: IMarker | null = null;
29+
/** While {@link onRender} follows a trim: the text matched and every marker
30+
* moved with it, so its write needs no fresh readings. */
31+
let following = false;
2532
const unsubscribe = subscribeToMouseSelection(() => {
2633
const sel = getMouseSelectionState(id).selection;
2734
if (sel === selection) return;
2835
selection = sel;
36+
if (following) return;
2937
baseline = sel && !sel.dragging ? extractSelectionText(terminal, sel) : null;
3038
anchor?.dispose();
3139
anchor = sel && anchorSelection(terminal, sel);
40+
scrollMarker?.dispose();
41+
scrollMarker = sel && !sel.dragging && terminal.buffer.active.type === 'normal'
42+
? markRow(terminal, Math.min(sel.startRow, sel.endRow)) : null;
3243
});
3344
return {
3445
onRender() {
35-
if (selection && baseline !== null && extractSelectionText(terminal, selection) !== baseline) setSelection(id, null);
46+
if (!selection || baseline === null) return;
47+
if (scrollMarker?.isDisposed) { setSelection(id, null); return; }
48+
const shift = scrollMarker ? scrollMarker.line - Math.min(selection.startRow, selection.endRow) : 0;
49+
const moved = shift ? { ...selection, startRow: selection.startRow + shift, endRow: selection.endRow + shift } : selection;
50+
if (extractSelectionText(terminal, moved) !== baseline) setSelection(id, null);
51+
else if (moved !== selection) {
52+
following = true;
53+
try { followCopySelection(id, terminal, spanOfSelection(moved)); } finally { following = false; }
54+
}
3655
},
3756
onResize() {
3857
const span = anchor && followReflow(terminal, anchor);
@@ -42,6 +61,7 @@ export function watchSelection(id: string, terminal: Terminal): SelectionWatch {
4261
dispose() {
4362
unsubscribe();
4463
anchor?.dispose();
64+
scrollMarker?.dispose();
4565
},
4666
};
4767
}

0 commit comments

Comments
 (0)