3f348dbb
fix: let an explicit copy revoke a queued osc 52 write
a73x 2026-08-18 12:27
Commit message
web/mux.js
| Old | New | ||
|---|---|---|---|
| @@ -643,6 +643,15 @@ class Tile { | |||
| 643 | const selection = this.selection; | 643 | const selection = this.selection; |
| 644 | if (!this.zoomed || !selection || selection.text === null) return; | 644 | if (!this.zoomed || !selection || selection.text === null) return; |
| 645 | this.clearSelectionFeedback(); | 645 | this.clearSelectionFeedback(); |
| 646 | // An explicit copy is the newest statement of what the human wants on | ||
| 647 | // the clipboard, so it revokes the automatic lane rather than racing | ||
| 648 | // it. A write already handed to the browser genuinely cannot be | ||
| 649 | // cancelled — but the RE-DISPATCH that write performs when it settles | ||
| 650 | // is ours to start, and starting it would put terminal text on the | ||
| 651 | // clipboard after the gesture that asked for something else. Retiring | ||
| 652 | // the version and the queued value together is what suppresses it. | ||
| 653 | this.clipboardVersion++; | ||
| 654 | this.pendingClipboard = null; | ||
| 646 | const version = ++this.selectionCopyVersion; | 655 | const version = ++this.selectionCopyVersion; |
| 647 | this.renderCopyUi('selection', 'copy-request', 'Copy'); | 656 | this.renderCopyUi('selection', 'copy-request', 'Copy'); |
| 648 | let succeeded = false; | 657 | let succeeded = false; |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -2066,6 +2066,37 @@ async function verifySelectionShell(shell, html) { | |||
| 2066 | check('older OSC52 settlement cannot overwrite selection-copy success UI', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); | 2066 | check('older OSC52 settlement cannot overwrite selection-copy success UI', `${clipboardRace.tile.copyButton.className}|${clipboardRace.tile.copyButton.textContent}`, 'copy-request on|Copied'); |
| 2067 | check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null); | 2067 | check('older OSC52 still completes its serialized pending state', clipboardRace.tile.pendingClipboard, null); |
| 2068 | 2068 | ||
| 2069 | // The re-dispatch at the end of tryClipboardWrite is ours to start, and | ||
| 2070 | // an explicit user copy is the newest statement of intent: a queued OSC 52 | ||
| 2071 | // must not reach the system clipboard after the gesture that asked for | ||
| 2072 | // something else. | ||
| 2073 | const revoked = makeTile(); | ||
| 2074 | revoked.tile.pendingClipboard = 'first OSC52'; | ||
| 2075 | revoked.tile.clipboardVersion = 1; | ||
| 2076 | revoked.tile.copyUiOwner = 'clipboard'; | ||
| 2077 | const firstOsc = deferred(); | ||
| 2078 | const revokedCalls = []; | ||
| 2079 | h.navigator.clipboard = { writeText(text) { | ||
| 2080 | revokedCalls.push(text); | ||
| 2081 | return text === 'first OSC52' ? firstOsc.promise : Promise.resolve(); | ||
| 2082 | } }; | ||
| 2083 | const revokedRun = revoked.tile.tryClipboardWrite(1, true); | ||
| 2084 | // A second OSC 52 lands while the first write is still with the browser, | ||
| 2085 | // so it is queued in the one pending slot rather than started. | ||
| 2086 | revoked.tile.pendingClipboard = 'second OSC52'; | ||
| 2087 | revoked.tile.clipboardVersion++; | ||
| 2088 | check('queued OSC52 is not yet handed to the browser', revokedCalls.join('|'), 'first OSC52'); | ||
| 2089 | revoked.tile.selection = authoritativeSelection('what the user asked for'); | ||
| 2090 | h.setZoomedTile(revoked.tile); | ||
| 2091 | h.document.dispatchEvent('keydown', keyEvent({ key: 'c', ctrlKey: true })); | ||
| 2092 | await flushPromises(); | ||
| 2093 | firstOsc.resolve(); | ||
| 2094 | await revokedRun; | ||
| 2095 | await flushPromises(); | ||
| 2096 | check('explicit copy revokes the queued OSC52 write', revokedCalls.join('|'), 'first OSC52|what the user asked for'); | ||
| 2097 | check('explicit copy drops the queued OSC52 text', revoked.tile.pendingClipboard, null); | ||
| 2098 | check('revoked OSC52 leaves the copy control to the selection', revoked.tile.copyUiOwner, 'selection'); | ||
| 2099 | |||
| 2069 | const oldTimerRace = makeTile(); | 2100 | const oldTimerRace = makeTile(); |
| 2070 | oldTimerRace.tile.pendingClipboard = 'prior copied OSC52'; | 2101 | oldTimerRace.tile.pendingClipboard = 'prior copied OSC52'; |
| 2071 | oldTimerRace.tile.clipboardVersion = 1; | 2102 | oldTimerRace.tile.clipboardVersion = 1; |