diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 7b83f9b8d..d716cf58c 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -180,7 +180,7 @@ Every break is a mark that a click cycles keep → space → none; a scope or fo - **Never show while its Wall travels**, its pane hidden, or another pane zoomed over it. - **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. - **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`. -- **Must confirm only after a successful clipboard write, and only for the selection copied**; the selection then clears, however it moved meanwhile (rationale). +- **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). - **Must leave empty copies idle without writing.** **Must say a failed write failed**, keeping the selection for a retry. - **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). - **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. diff --git a/lib/src/lib/copy-editor.test.ts b/lib/src/lib/copy-editor.test.ts index bccab6f5a..61afca767 100644 --- a/lib/src/lib/copy-editor.test.ts +++ b/lib/src/lib/copy-editor.test.ts @@ -175,6 +175,17 @@ describe('copySelection', () => { } }); + it.each([true, false])('reports a pending copy after nudging the same selection (copied: %s)', async copied => { + let complete!: (result: boolean) => void; + vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise(resolve => { complete = resolve; })); + select({ endRow: 2, endCol: 11 }); + const pending = copySelection(ID); + nudgeCopyEdge(ID, 'end', 1); + complete(copied); + await pending; + expect(getMouseSelectionState(ID)).toMatchObject({ selection: { endCol: 16 }, copyOutcome: copied ? 'copied' : 'failed' }); + }); + it('says a failed write failed, for that selection', async () => { vi.mocked(writeTextToClipboard).mockResolvedValue(false); select({}); diff --git a/lib/src/lib/copy-selection.ts b/lib/src/lib/copy-selection.ts index e7ffe7478..90dbeda17 100644 --- a/lib/src/lib/copy-selection.ts +++ b/lib/src/lib/copy-selection.ts @@ -14,12 +14,12 @@ import { COPY_FLASH_MS, failCopy, flashCopy, getMouseSelectionState, TOUCH_COPY_ * says so instead, for that selection, and keeps it. */ export async function copySelection(terminalId: string, { touch = false }: { touch?: boolean } = {}): Promise { - const { selection, copyEditor, programCopy } = getMouseSelectionState(terminalId); + const { selection, selectionIdentity, copyEditor, programCopy } = getMouseSelectionState(terminalId); if (!selection || !copyEditor) return; const text = editorRendering(copyEditor, programCopy).text; if (!text) return; const copied = await writeTextToClipboard(text); - if (getMouseSelectionState(terminalId).selection !== selection) return; + if (getMouseSelectionState(terminalId).selectionIdentity !== selectionIdentity) return; if (!copied) { failCopy(terminalId); return; diff --git a/lib/src/lib/mouse-selection.test.ts b/lib/src/lib/mouse-selection.test.ts index 029185796..496fd0a89 100644 --- a/lib/src/lib/mouse-selection.test.ts +++ b/lib/src/lib/mouse-selection.test.ts @@ -40,6 +40,7 @@ describe('mouse-selection: default state', () => { bracketedPaste: false, override: 'off', selection: null, + selectionIdentity: null, hintToken: null, copyEditor: null, programCopy: null, diff --git a/lib/src/lib/mouse-selection.ts b/lib/src/lib/mouse-selection.ts index b2e408e96..a2dd46433 100644 --- a/lib/src/lib/mouse-selection.ts +++ b/lib/src/lib/mouse-selection.ts @@ -63,6 +63,8 @@ export interface MouseSelectionState { bracketedPaste: boolean; override: OverrideState; selection: Selection | null; + /** Stable through edge movement and reflow; cleared with the selection. */ + selectionIdentity: object | null; /** The token under a drag's head, in absolute buffer rows. */ hintToken: BufferToken | null; /** Open while non-null: always the editor written with this exact @@ -84,6 +86,7 @@ export const DEFAULT_MOUSE_SELECTION_STATE: MouseSelectionState = Object.freeze( bracketedPaste: false, override: 'off', selection: null, + selectionIdentity: null, hintToken: null, copyEditor: null, programCopy: null, @@ -98,6 +101,7 @@ let cachedSnapshot: Map | null = null; * one cannot leave another's editor, offer, or flash behind. */ function clearSelection(s: MouseSelectionState): void { s.selection = null; + s.selectionIdentity = null; s.copyEditor = null; s.programCopy = null; s.copyOutcome = null; @@ -182,6 +186,7 @@ export function setSelection(id: string, selection: Selection | null, copyEditor else { // A replaced selection keeps an in-flight flash and the drag's hint, but // not the program's offer, so its editor cannot show that offer either. + s.selectionIdentity ??= {}; s.selection = selection; s.programCopy = null; s.copyEditor = selection.dragging || !copyEditor ? null @@ -221,6 +226,7 @@ export function beginDrag( // Clearing the in-flight copy flash too keeps its timer from clearing this // new selection when it fires. clearSelection(s); + s.selectionIdentity = {}; s.selection = { startRow: args.row, startCol: args.col, diff --git a/lib/src/lib/terminal-lifecycle.selection.test.ts b/lib/src/lib/terminal-lifecycle.selection.test.ts index ef36e7c1a..694a78601 100644 --- a/lib/src/lib/terminal-lifecycle.selection.test.ts +++ b/lib/src/lib/terminal-lifecycle.selection.test.ts @@ -29,6 +29,10 @@ vi.mock('./platform', async () => { return { ...actual, getPlatform: () => fakePlatform }; }); +vi.mock('./clipboard', () => ({ writeTextToClipboard: vi.fn() })); +import { writeTextToClipboard } from './clipboard'; +import { copySelection } from './copy-selection'; + import { flipCopyBreak, openCopyEditor, setCopyFormat, setCopyScope } from './copy-editor'; import type { EditorFormat } from './copy-text'; import { finalizedSelection, WRAPPING } from './copy-text-fixtures'; @@ -130,6 +134,25 @@ describe('a terminal resize', () => { } }); + it.each([true, false])('keeps a pending clipboard result through reflow (copied: %s)', async copied => { + const id = 'resize-copy-' + copied; + const terminal = await openEdited(id); + let complete!: (result: boolean) => void; + vi.mocked(writeTextToClipboard).mockImplementation(() => new Promise(resolve => { complete = resolve; })); + vi.useFakeTimers(); + try { + const pending = copySelection(id); + terminal.resize(10, 10); + complete(copied); + await pending; + expect(getMouseSelectionState(id)).toMatchObject({ selection: { endRow: 2, endCol: 1 }, copyOutcome: copied ? 'copied' : 'failed' }); + vi.advanceTimersByTime(1500); + expect(getMouseSelectionState(id).selection === null).toBe(copied); + } finally { + vi.useRealTimers(); + } + }); + it('still cancels a block selection, and one the program owns', async () => { for (const [id, over] of [['resize-5', { shape: 'block' }], ['resize-6', { owner: 'program' }]] as const) { const terminal = await openEdited(id, { over });