From b521a1bf7f8f356ff3a7b2b888e34c463495e091 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 12:53:29 +0000 Subject: [PATCH 1/7] fix(terminal): preserve selection on context menu --- .../src/web/terminal-copy-interaction.ts | 14 +++++++- .../web/terminal-copy-interaction.test.ts | 22 +++++++++++++ ...minal-copy-right-click-interaction.test.ts | 32 ++++++++++++++++--- 3 files changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index 71e5629f..44f3e26e 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -262,7 +262,19 @@ class TerminalCopyInteractionController { } private readonly onContextMenu = (event: TerminalCopyMouseEvent): void => { - this.onSelectionContextMouseEvent(event) + // CHANGE: stop protected context menus before xterm can rewrite terminal selection. + // WHY: xterm's rightClickHandler prepares a textarea for copy and can clear TUI selections while mouse tracking is active. + // QUOTE(ТЗ): "Когда я выделяю что-то и нажимаю правую кнопку ... выделение слетает" + // REF: user-message-2026-06-15-claude-code-selection + // SOURCE: n/a + // FORMAT THEOREM: protected(e,t) => stopped(e) and not defaultPrevented(e) + // PURITY: SHELL + // INVARIANT: empty selection context menus still pass through. + // COMPLEXITY: O(1)/O(1) + if (!this.onSelectionContextMouseEvent(event)) { + return + } + suppressTerminalMouseReport(event) } private readonly onCopy = (event: TerminalCopyClipboardEvent): void => { diff --git a/packages/terminal/tests/web/terminal-copy-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-interaction.test.ts index ed5ca787..628aada8 100644 --- a/packages/terminal/tests/web/terminal-copy-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-interaction.test.ts @@ -238,6 +238,28 @@ describe("terminal copy interaction", () => { disposable.dispose() }) + it("suppresses protected context menus without blocking the native menu", () => { + const host = new FakeTerminalCopyHost(null) + const contextMenuReports: Array = [] + host.addBubbleMouseListener("contextmenu", (event) => { + contextMenuReports.push(event) + }) + const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") }) + const down = mouseEvent(2) + const contextMenu = mouseEvent(2, "contextmenu") + + host.dispatchMouse("mousedown", down) + host.dispatchMouse("contextmenu", contextMenu) + + expect(contextMenu.shiftKey).toBe(true) + expect(contextMenu.preventDefaultCalls).toBe(0) + expect(contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(contextMenuReports).toEqual([]) + + disposable.dispose() + }) + it("does not start a forced selection drag when mouse tracking is inactive", () => { const documentTarget = new FakeTerminalCopyEventTarget() const host = new FakeTerminalCopyHost(documentTarget) diff --git a/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts index b1ceb7df..2951b451 100644 --- a/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts @@ -194,17 +194,41 @@ describe("terminal copy right-click interaction", () => { expect(flow.rightRelease.stopPropagationCalls).toBeGreaterThanOrEqual(1) expect(flow.contextMenu.shiftKey).toBe(true) expect(flow.contextMenu.preventDefaultCalls).toBe(0) - expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(0) - expect(flow.contextMenu.stopPropagationCalls).toBe(0) + expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(flow.contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) expect(flow.terminalMouseReports).toEqual([]) - expect(flow.contextMenuEvents).toEqual([flow.contextMenu]) - expect(readSelection()).toBe("") + expect(flow.contextMenuEvents).toEqual([]) + expect(readSelection()).toBe(selectedText) expectNoDragListeners(flow.documentTarget) expectCopiedSelectionInvariant(flow, selectedText) flow.disposable.dispose() }) + it("keeps snapshot text copyable when live selection clears before context menu", () => { + const selectedText = "snapshot line" + let terminalSelection = selectedText + const flow = createRightClickCopyHarness({ + getSelection: () => terminalSelection, + hasSelection: () => terminalSelection.length > 0, + modes: { mouseTrackingMode: "any" } + }) + + flow.host.dispatchMouse("mousedown", flow.rightClick) + terminalSelection = "" + flow.host.dispatchMouse("contextmenu", flow.contextMenu) + flow.host.dispatchCopy(flow.copy) + + expect(flow.contextMenu.shiftKey).toBe(true) + expect(flow.contextMenu.preventDefaultCalls).toBe(0) + expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(flow.contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(flow.contextMenuEvents).toEqual([]) + expectCopiedSelectionInvariant(flow, selectedText) + + flow.disposable.dispose() + }) + it("does not suppress right-click release events without a terminal selection", () => { const flow = createStaticSelectionFlow("") From f58fd6958580848c01d0b8216a212e1f5e73b8d8 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:14:34 +0000 Subject: [PATCH 2/7] fix(terminal): guard context menu button variants --- .../src/web/terminal-copy-interaction.ts | 12 ++++++++--- .../web/terminal-copy-interaction.test.ts | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index 44f3e26e..f553872f 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -215,11 +215,13 @@ class TerminalCopyInteractionController { private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean => !shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) - private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => - isSecondaryMouseButton(event) && + private readonly hasProtectedSelectionContext = (): boolean => hasActiveMouseTracking(this.args.terminal) && (this.selectionContext.has() || this.args.terminal.hasSelection()) + private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => + isSecondaryMouseButton(event) && this.hasProtectedSelectionContext() + private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => { if (!this.shouldProtectSelectionContext(event)) { return false @@ -271,9 +273,13 @@ class TerminalCopyInteractionController { // PURITY: SHELL // INVARIANT: empty selection context menus still pass through. // COMPLEXITY: O(1)/O(1) - if (!this.onSelectionContextMouseEvent(event)) { + if (!this.hasProtectedSelectionContext()) { return } + forceTerminalSelectionModifier(event) + if (this.args.terminal.hasSelection()) { + this.selectionContext.refresh() + } suppressTerminalMouseReport(event) } diff --git a/packages/terminal/tests/web/terminal-copy-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-interaction.test.ts index 628aada8..c2a1b1fe 100644 --- a/packages/terminal/tests/web/terminal-copy-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-interaction.test.ts @@ -260,6 +260,26 @@ describe("terminal copy interaction", () => { disposable.dispose() }) + it("suppresses protected context menus even when the browser reports primary button", () => { + const host = new FakeTerminalCopyHost(null) + const contextMenuReports: Array = [] + host.addBubbleMouseListener("contextmenu", (event) => { + contextMenuReports.push(event) + }) + const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") }) + const contextMenu = mouseEvent(0, "contextmenu") + + host.dispatchMouse("contextmenu", contextMenu) + + expect(contextMenu.shiftKey).toBe(true) + expect(contextMenu.preventDefaultCalls).toBe(0) + expect(contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(contextMenuReports).toEqual([]) + + disposable.dispose() + }) + it("does not start a forced selection drag when mouse tracking is inactive", () => { const documentTarget = new FakeTerminalCopyEventTarget() const host = new FakeTerminalCopyHost(documentTarget) From 7481f508abb46240efe9c39e352fe2403ad653bb Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 13:31:11 +0000 Subject: [PATCH 3/7] fix(terminal): retain selection through redraw --- .../src/web/terminal-copy-interaction.ts | 31 +++++++- .../terminal-copy-redraw-interaction.test.ts | 77 +++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index f553872f..41cf0f6a 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -17,6 +17,10 @@ type TerminalSelectionTarget = { readonly hasSelection: () => boolean } +type TerminalDisposable = { + readonly dispose: () => void +} + export type TerminalCopyKeyboardEvent = { readonly altKey: boolean readonly ctrlKey: boolean @@ -32,6 +36,7 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { readonly modes: { readonly mouseTrackingMode: TerminalMouseTrackingMode } + readonly onSelectionChange?: (handler: () => void) => TerminalDisposable } type TerminalCopyClipboardData = { @@ -197,6 +202,7 @@ class TerminalSelectionContextSnapshot { class TerminalCopyInteractionController { private readonly selectionContext: TerminalSelectionContextSnapshot private readonly selectionDrag: ReturnType + private selectionChangeDisposable: TerminalDisposable | null = null constructor(private readonly args: TerminalCopyInteractionArgs) { this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) @@ -205,6 +211,7 @@ class TerminalCopyInteractionController { readonly attach = (): { readonly dispose: () => void } => { this.args.terminal.attachCustomKeyEventHandler?.(this.onTerminalKeyEvent) + this.selectionChangeDisposable = this.args.terminal.onSelectionChange?.(this.onTerminalSelectionChange) ?? null this.args.host.addEventListener("mousedown", this.onMouseDown, true) this.args.host.addEventListener("mouseup", this.onMouseUp, true) this.args.host.addEventListener("contextmenu", this.onContextMenu, true) @@ -212,8 +219,28 @@ class TerminalCopyInteractionController { return { dispose: this.dispose } } + private readonly shouldLetBrowserHandleCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean => + shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) || + (isKeyboardCopyShortcut(event) && this.selectionContext.has()) + private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean => - !shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) + !this.shouldLetBrowserHandleCopyShortcut(event) + + private readonly onTerminalSelectionChange = (): void => { + // CHANGE: keep a copyable snapshot before terminal redraws can drop xterm's live selection. + // WHY: Claude Code periodically repaints the TUI; xterm selection is buffer-bound and may vanish during repaint. + // QUOTE(ТЗ): "когда очистился выделение бы не спадало" + // REF: user-message-2026-06-15-terminal-redraw-selection + // SOURCE: n/a + // FORMAT THEOREM: selected(t) before redraw(t) => cachedSelection(t) + // PURITY: SHELL + // EFFECT: reads terminal.hasSelection() and terminal.getSelection(). + // INVARIANT: empty redraw selection events do not erase the last user-created non-empty selection snapshot. + // COMPLEXITY: O(n)/O(1) where n = selected text length. + if (this.args.terminal.hasSelection()) { + this.selectionContext.refresh() + } + } private readonly hasProtectedSelectionContext = (): boolean => hasActiveMouseTracking(this.args.terminal) && @@ -295,6 +322,8 @@ class TerminalCopyInteractionController { } private readonly dispose = (): void => { + this.selectionChangeDisposable?.dispose() + this.selectionChangeDisposable = null this.selectionDrag.dispose() this.selectionContext.clear() this.args.host.removeEventListener("mousedown", this.onMouseDown, true) diff --git a/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts new file mode 100644 index 00000000..17a70091 --- /dev/null +++ b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + attachTerminalCopyInteraction, + type TerminalCopyInteractionTerminal, + type TerminalCopyKeyboardEvent +} from "../../src/web/terminal-copy-interaction.js" +import { copyEvent, FakeTerminalCopyHost, mouseEvent } from "./fixtures/terminal-copy-interaction.js" + +const keyboardCopyEvent: TerminalCopyKeyboardEvent = { + altKey: false, + ctrlKey: true, + key: "c", + metaKey: false, + type: "keydown" +} + +describe("terminal copy redraw interaction", () => { + it("keeps selection snapshot copyable after terminal redraw clears live selection", () => { + let terminalSelection = "" + const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = [] + const selectionChangeHandlers: Array<() => void> = [] + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const host = new FakeTerminalCopyHost(null) + const terminal: TerminalCopyInteractionTerminal = { + attachCustomKeyEventHandler: (handler) => { + keyHandlers.push(handler) + }, + getSelection: () => terminalSelection, + hasSelection: () => terminalSelection.length > 0, + modes: { mouseTrackingMode: "any" }, + onSelectionChange: (handler) => { + selectionChangeHandlers.push(handler) + return { + dispose: () => { + const handlerIndex = selectionChangeHandlers.indexOf(handler) + if (handlerIndex !== -1) { + selectionChangeHandlers.splice(handlerIndex, 1) + } + } + } + } + } + const disposable = attachTerminalCopyInteraction({ host, terminal }) + + terminalSelection = "selected before redraw" + for (const handler of selectionChangeHandlers) { + handler() + } + terminalSelection = "" + + expect(keyHandlers).toHaveLength(1) + const handleKey = keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") + expect(handleKey(keyboardCopyEvent)).toBe(false) + + const contextMenu = mouseEvent(0, "contextmenu") + const copy = copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + }) + host.dispatchMouse("contextmenu", contextMenu) + host.dispatchCopy(copy) + + expect(contextMenu.shiftKey).toBe(true) + expect(contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(clipboardWrites).toEqual([{ data: "selected before redraw", format: "text/plain" }]) + expect(copy.preventDefaultCalls).toBe(1) + expect(copy.stopPropagationCalls).toBe(1) + expect(selectionChangeHandlers).toHaveLength(1) + expect(handleKey(keyboardCopyEvent)).toBe(true) + + disposable.dispose() + expect(selectionChangeHandlers).toHaveLength(0) + }) +}) From 839f5a76a0856ff675b8ca0ff5e68f435a59e885 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 17:28:38 +0000 Subject: [PATCH 4/7] fix(terminal): restore native copy menu --- .../src/web/terminal-copy-interaction.ts | 35 +++++++- .../src/web/terminal-copy-native-menu.ts | 83 ++++++++++++++++++ .../terminal-copy-redraw-interaction.test.ts | 84 ++++++++++++++++++- 3 files changed, 195 insertions(+), 7 deletions(-) create mode 100644 packages/terminal/src/web/terminal-copy-native-menu.ts diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index 41cf0f6a..d4a1c98e 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -1,3 +1,9 @@ +import { + clearNativeBrowserCopyMenu, + prepareNativeBrowserCopyMenu, + type TerminalCopyTextarea, + type TerminalNativeCopyMenuHost +} from "./terminal-copy-native-menu.js" import { createTerminalSelectionDragController, forceTerminalSelectionModifier, @@ -37,6 +43,7 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { readonly mouseTrackingMode: TerminalMouseTrackingMode } readonly onSelectionChange?: (handler: () => void) => TerminalDisposable + readonly textarea?: TerminalCopyTextarea | undefined } type TerminalCopyClipboardData = { @@ -54,7 +61,7 @@ type TerminalCopyListenerRegistration = { (type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void } -type TerminalCopyInteractionHost = { +type TerminalCopyInteractionHost = TerminalNativeCopyMenuHost & { readonly ownerDocument?: TerminalSelectionDragTarget | null readonly addEventListener: TerminalCopyListenerRegistration readonly removeEventListener: TerminalCopyListenerRegistration @@ -176,6 +183,8 @@ class TerminalSelectionContextSnapshot { readonly has = (): boolean => this.selection.length > 0 + readonly read = (): string => this.selection + readonly refresh = (): boolean => { const selection = this.terminal.getSelection() if (selection.length === 0) { @@ -246,6 +255,18 @@ class TerminalCopyInteractionController { hasActiveMouseTracking(this.args.terminal) && (this.selectionContext.has() || this.args.terminal.hasSelection()) + private readonly prepareNativeBrowserCopyMenu = (event: TerminalCopyMouseEvent): boolean => + prepareNativeBrowserCopyMenu({ + event, + host: this.args.host, + selection: this.selectionContext.read(), + textarea: this.args.terminal.textarea + }) + + private readonly clearNativeBrowserCopyMenu = (): void => { + clearNativeBrowserCopyMenu(this.args.terminal.textarea) + } + private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => isSecondaryMouseButton(event) && this.hasProtectedSelectionContext() @@ -263,18 +284,23 @@ class TerminalCopyInteractionController { private readonly onMouseDown = (event: TerminalCopyMouseEvent): void => { if (isPrimaryMouseButton(event)) { this.selectionContext.clear() + this.clearNativeBrowserCopyMenu() } const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, this.args.terminal) - const forceSelectionContext = shouldForceTerminalSelectionContext(event, this.args.terminal) + const forceSelectionContext = this.shouldProtectSelectionContext(event) if (!forceBrowserSelection && !forceSelectionContext) { if (isSecondaryMouseButton(event)) { this.selectionContext.clear() + this.clearNativeBrowserCopyMenu() } return } forceTerminalSelectionModifier(event) if (forceSelectionContext) { - this.selectionContext.refresh() + if (this.args.terminal.hasSelection()) { + this.selectionContext.refresh() + } + this.prepareNativeBrowserCopyMenu(event) suppressTerminalMouseReport(event) return } @@ -307,6 +333,7 @@ class TerminalCopyInteractionController { if (this.args.terminal.hasSelection()) { this.selectionContext.refresh() } + this.prepareNativeBrowserCopyMenu(event) suppressTerminalMouseReport(event) } @@ -317,6 +344,7 @@ class TerminalCopyInteractionController { return } this.selectionContext.clear() + this.clearNativeBrowserCopyMenu() event.preventDefault() event.stopPropagation() } @@ -326,6 +354,7 @@ class TerminalCopyInteractionController { this.selectionChangeDisposable = null this.selectionDrag.dispose() this.selectionContext.clear() + this.clearNativeBrowserCopyMenu() this.args.host.removeEventListener("mousedown", this.onMouseDown, true) this.args.host.removeEventListener("mouseup", this.onMouseUp, true) this.args.host.removeEventListener("contextmenu", this.onContextMenu, true) diff --git a/packages/terminal/src/web/terminal-copy-native-menu.ts b/packages/terminal/src/web/terminal-copy-native-menu.ts new file mode 100644 index 00000000..dd387d2a --- /dev/null +++ b/packages/terminal/src/web/terminal-copy-native-menu.ts @@ -0,0 +1,83 @@ +import type { TerminalCopyMouseEvent } from "./terminal-copy-selection-drag.js" + +export type TerminalCopyTextarea = { + readonly focus: () => void + readonly select: () => void + readonly style: { + height: string + left: string + top: string + width: string + zIndex: string + } + value: string +} + +type TerminalCopyScreenElement = { + readonly getBoundingClientRect: () => { + readonly left: number + readonly top: number + } +} + +export type TerminalNativeCopyMenuHost = { + readonly getBoundingClientRect?: TerminalCopyScreenElement["getBoundingClientRect"] + readonly querySelector?: (selector: string) => TerminalCopyScreenElement | null +} + +type PrepareNativeBrowserCopyMenuArgs = { + readonly event: TerminalCopyMouseEvent + readonly host: TerminalNativeCopyMenuHost + readonly selection: string + readonly textarea: TerminalCopyTextarea | undefined +} + +const terminalContextMenuTextareaOffsetPx = 10 +const terminalContextMenuTextareaSizePx = 20 +const xtermScreenSelector = ".xterm-screen" + +const optionalNumber = (value: number | undefined): number => value ?? 0 + +const resolveContextMenuHostScreenElement = ( + host: TerminalNativeCopyMenuHost +): TerminalCopyScreenElement | null => { + const getBoundingClientRect = host.getBoundingClientRect + if (getBoundingClientRect === undefined) { + return null + } + return { + getBoundingClientRect: () => getBoundingClientRect.call(host) + } +} + +const resolveContextMenuScreenElement = ( + host: TerminalNativeCopyMenuHost +): TerminalCopyScreenElement | null => + host.querySelector?.(xtermScreenSelector) ?? resolveContextMenuHostScreenElement(host) + +export const prepareNativeBrowserCopyMenu = ( + { event, host, selection, textarea }: PrepareNativeBrowserCopyMenuArgs +): boolean => { + const screenElement = resolveContextMenuScreenElement(host) + if (selection.length === 0 || textarea === undefined || screenElement === null) { + return false + } + const screenPosition = screenElement.getBoundingClientRect() + textarea.style.width = `${terminalContextMenuTextareaSizePx}px` + textarea.style.height = `${terminalContextMenuTextareaSizePx}px` + textarea.style.left = `${optionalNumber(event.clientX) - screenPosition.left - terminalContextMenuTextareaOffsetPx}px` + textarea.style.top = `${optionalNumber(event.clientY) - screenPosition.top - terminalContextMenuTextareaOffsetPx}px` + textarea.style.zIndex = "1000" + textarea.focus() + textarea.value = selection + textarea.select() + return true +} + +export const clearNativeBrowserCopyMenu = ( + textarea: TerminalCopyTextarea | undefined +): void => { + if (textarea !== undefined) { + textarea.value = "" + } +} diff --git a/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts index 17a70091..4582fcd7 100644 --- a/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts @@ -15,13 +15,58 @@ const keyboardCopyEvent: TerminalCopyKeyboardEvent = { type: "keydown" } +class FakeTerminalCopyTextarea { + focusCalls = 0 + selectCalls = 0 + readonly style = { + height: "", + left: "", + top: "", + width: "", + zIndex: "" + } + value = "" + + focus(): void { + this.focusCalls += 1 + } + + select(): void { + this.selectCalls += 1 + } +} + +class FakeTerminalCopyScreenHost extends FakeTerminalCopyHost { + constructor( + readonly screenLeft: number, + readonly screenTop: number + ) { + super(null) + } + + querySelector( + selector: string + ): { readonly getBoundingClientRect: () => { readonly left: number; readonly top: number } } | null { + if (selector !== ".xterm-screen") { + return null + } + return { + getBoundingClientRect: () => ({ + left: this.screenLeft, + top: this.screenTop + }) + } + } +} + describe("terminal copy redraw interaction", () => { it("keeps selection snapshot copyable after terminal redraw clears live selection", () => { let terminalSelection = "" const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = [] const selectionChangeHandlers: Array<() => void> = [] const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] - const host = new FakeTerminalCopyHost(null) + const host = new FakeTerminalCopyScreenHost(100, 200) + const textarea = new FakeTerminalCopyTextarea() const terminal: TerminalCopyInteractionTerminal = { attachCustomKeyEventHandler: (handler) => { keyHandlers.push(handler) @@ -39,7 +84,8 @@ describe("terminal copy redraw interaction", () => { } } } - } + }, + textarea } const disposable = attachTerminalCopyInteraction({ host, terminal }) @@ -53,21 +99,51 @@ describe("terminal copy redraw interaction", () => { const handleKey = keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") expect(handleKey(keyboardCopyEvent)).toBe(false) - const contextMenu = mouseEvent(0, "contextmenu") + const rightClick = mouseEvent(2, "mousedown", { + clientX: 150, + clientY: 260 + }) + const contextMenu = mouseEvent(0, "contextmenu", { + clientX: 155, + clientY: 265 + }) const copy = copyEvent({ setData: (format: string, data: string) => { clipboardWrites.push({ data, format }) } }) + host.dispatchMouse("mousedown", rightClick) + + expect(rightClick.shiftKey).toBe(true) + expect(rightClick.preventDefaultCalls).toBe(0) + expect(rightClick.stopImmediatePropagationCalls).toBe(1) + expect(textarea.value).toBe("selected before redraw") + expect(textarea.focusCalls).toBe(1) + expect(textarea.selectCalls).toBe(1) + host.dispatchMouse("contextmenu", contextMenu) - host.dispatchCopy(copy) expect(contextMenu.shiftKey).toBe(true) + expect(contextMenu.preventDefaultCalls).toBe(0) expect(contextMenu.stopImmediatePropagationCalls).toBe(1) expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(textarea.value).toBe("selected before redraw") + expect(textarea.focusCalls).toBe(2) + expect(textarea.selectCalls).toBe(2) + expect(textarea.style).toEqual({ + height: "20px", + left: "45px", + top: "55px", + width: "20px", + zIndex: "1000" + }) + + host.dispatchCopy(copy) + expect(clipboardWrites).toEqual([{ data: "selected before redraw", format: "text/plain" }]) expect(copy.preventDefaultCalls).toBe(1) expect(copy.stopPropagationCalls).toBe(1) + expect(textarea.value).toBe("") expect(selectionChangeHandlers).toHaveLength(1) expect(handleKey(keyboardCopyEvent)).toBe(true) From 4f5a8670cc4a73a419235f885e1e917b6848719d Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 19:19:52 +0000 Subject: [PATCH 5/7] fix(terminal): restore selection after redraw --- .../src/web/terminal-copy-interaction.ts | 189 +++--------- .../terminal/src/web/terminal-copy-rules.ts | 111 +++++++ .../web/terminal-copy-selection-snapshot.ts | 273 ++++++++++++++++++ .../terminal-copy-selection-restore.test.ts | 257 +++++++++++++++++ 4 files changed, 676 insertions(+), 154 deletions(-) create mode 100644 packages/terminal/src/web/terminal-copy-rules.ts create mode 100644 packages/terminal/src/web/terminal-copy-selection-snapshot.ts create mode 100644 packages/terminal/tests/web/terminal-copy-selection-restore.test.ts diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index d4a1c98e..ba4dcbb1 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -4,6 +4,15 @@ import { type TerminalCopyTextarea, type TerminalNativeCopyMenuHost } from "./terminal-copy-native-menu.js" +import { + hasActiveMouseTracking, + isKeyboardCopyShortcut, + shouldForceBrowserTerminalSelection, + shouldLetBrowserHandleTerminalCopyShortcut, + type TerminalCopyKeyboardEvent, + type TerminalMouseTrackingMode, + writeTerminalSelectionToClipboardData +} from "./terminal-copy-rules.js" import { createTerminalSelectionDragController, forceTerminalSelectionModifier, @@ -13,28 +22,26 @@ import { type TerminalMouseButtonEvent, type TerminalSelectionDragTarget } from "./terminal-copy-selection-drag.js" - +import { + type TerminalCopyClipboardData, + TerminalSelectionContextSnapshot, + type TerminalSelectionRestoreTarget, + type TerminalSelectionTarget +} from "./terminal-copy-selection-snapshot.js" + +export { + shouldForceBrowserTerminalSelection, + shouldForceTerminalSelectionContext, + shouldLetBrowserHandleTerminalCopyShortcut, + writeTerminalSelectionToClipboardData +} from "./terminal-copy-rules.js" +export type { TerminalCopyKeyboardEvent, TerminalMouseTrackingMode } from "./terminal-copy-rules.js" export { forceTerminalSelectionModifier } from "./terminal-copy-selection-drag.js" -export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" - -type TerminalSelectionTarget = { - readonly getSelection: () => string - readonly hasSelection: () => boolean -} - type TerminalDisposable = { readonly dispose: () => void } -export type TerminalCopyKeyboardEvent = { - readonly altKey: boolean - readonly ctrlKey: boolean - readonly key: string - readonly metaKey: boolean - readonly type: string -} - export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { readonly attachCustomKeyEventHandler?: ( handler: (event: TerminalCopyKeyboardEvent) => boolean @@ -44,11 +51,7 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { } readonly onSelectionChange?: (handler: () => void) => TerminalDisposable readonly textarea?: TerminalCopyTextarea | undefined -} - -type TerminalCopyClipboardData = { - readonly setData: (format: string, data: string) => void -} +} & TerminalSelectionRestoreTarget type TerminalCopyClipboardEvent = { readonly clipboardData: TerminalCopyClipboardData | null @@ -74,140 +77,11 @@ type TerminalCopyInteractionArgs = { const primaryMouseButton = 0 const secondaryMouseButton = 2 -const terminalSelectionContextSnapshotTtlMs = 10_000 const isPrimaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === primaryMouseButton const isSecondaryMouseButton = (event: TerminalMouseButtonEvent): boolean => event.button === secondaryMouseButton -const hasActiveMouseTracking = (terminal: TerminalCopyInteractionTerminal): boolean => - terminal.modes.mouseTrackingMode !== "none" - -const isKeyboardCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean => - event.type === "keydown" && - !event.altKey && - (event.ctrlKey || event.metaKey) && - event.key.toLowerCase() === "c" - -/** - * Decides whether xterm key processing must step aside for native browser copy. - * - * @param event - Keyboard event seen by xterm before it translates keys into pty input. - * @param terminal - Terminal selection facade. - * @returns True iff the event is a system copy shortcut and selected terminal text is non-empty. - * @pure true - * @effect terminal.hasSelection(), terminal.getSelection(). - * @invariant result => no ETX input is sent for selected terminal text copy. - * @precondition `event` and `terminal` are non-null. - * @postcondition True means xterm should return false from its custom key handler. - * @complexity O(n) where n = selected text length. - * @throws Never - */ -// CHANGE: keep keyboard copy shortcuts out of terminal input when text is selected -// WHY: Ctrl/Cmd+C must copy the selected terminal text instead of sending SIGINT to the pty -// QUOTE(ТЗ): "Text easy coping" -// REF: issue-353 -// SOURCE: n/a -// FORMAT THEOREM: selected(t) and copyShortcut(e) => browserCopy(e,t) -// PURITY: CORE -// EFFECT: reads terminal selection through the injected terminal facade -// INVARIANT: empty selection never blocks terminal Ctrl+C semantics -// COMPLEXITY: O(n)/O(1) -export const shouldLetBrowserHandleTerminalCopyShortcut = ( - event: TerminalCopyKeyboardEvent, - terminal: TerminalSelectionTarget -): boolean => isKeyboardCopyShortcut(event) && terminal.hasSelection() && terminal.getSelection().length > 0 - -export const shouldForceBrowserTerminalSelection = ( - event: TerminalMouseButtonEvent, - terminal: TerminalCopyInteractionTerminal -): boolean => isPrimaryMouseButton(event) && hasActiveMouseTracking(terminal) - -/** - * Decides whether a secondary-button event must preserve the terminal selection context. - * - * @param event - Mouse button event captured before xterm/tmux handlers can clear the selection. - * @param terminal - Terminal selection and mouse-tracking facade. - * @returns True iff the event is a secondary click, mouse tracking is active, and a selection exists. - * @pure true - * @effect isSecondaryMouseButton(event), hasActiveMouseTracking(terminal), terminal.hasSelection(). - * @invariant result <=> secondary(event) and tracking(terminal) and selected(terminal). - * @precondition `event` and `terminal` are non-null; mouse tracking may be `none`, which disables forcing. - * @postcondition True means the caller may snapshot selection text before suppressing terminal mouse reporting. - * @complexity O(1) - * @throws Never - */ -// CHANGE: document the guarded right-click selection preservation predicate -// WHY: selection protection is valid only while terminal mouse tracking can consume right-click events -// QUOTE(ТЗ): "right-click with selection should remain copyable in the terminal" -// REF: issue-340 -// SOURCE: n/a -// FORMAT THEOREM: forall e,t: force(e,t) <-> secondary(e) and tracking(t) and hasSelection(t) -// PURITY: CORE -// EFFECT: reads terminal.hasSelection through the injected terminal facade -// INVARIANT: mouseTrackingMode = none always yields false -// COMPLEXITY: O(1) -export const shouldForceTerminalSelectionContext = ( - event: TerminalMouseButtonEvent, - terminal: TerminalCopyInteractionTerminal -): boolean => isSecondaryMouseButton(event) && hasActiveMouseTracking(terminal) && terminal.hasSelection() - -export const writeTerminalSelectionToClipboardData = ( - terminal: TerminalSelectionTarget, - clipboardData: TerminalCopyClipboardData | null -): boolean => { - if (clipboardData === null || !terminal.hasSelection()) { - return false - } - const selection = terminal.getSelection() - if (selection.length === 0) { - return false - } - clipboardData.setData("text/plain", selection) - return true -} - -class TerminalSelectionContextSnapshot { - private selection = "" - private timer: ReturnType | null = null - - constructor(private readonly terminal: TerminalSelectionTarget) {} - - readonly clear = (): void => { - this.selection = "" - if (this.timer !== null) { - clearTimeout(this.timer) - this.timer = null - } - } - - readonly has = (): boolean => this.selection.length > 0 - - readonly read = (): string => this.selection - - readonly refresh = (): boolean => { - const selection = this.terminal.getSelection() - if (selection.length === 0) { - this.clear() - return false - } - this.selection = selection - if (this.timer !== null) { - clearTimeout(this.timer) - } - this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs) - return true - } - - readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => { - if (clipboardData === null || this.selection.length === 0) { - return false - } - clipboardData.setData("text/plain", this.selection) - return true - } -} - class TerminalCopyInteractionController { private readonly selectionContext: TerminalSelectionContextSnapshot private readonly selectionDrag: ReturnType @@ -232,8 +106,13 @@ class TerminalCopyInteractionController { shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) || (isKeyboardCopyShortcut(event) && this.selectionContext.has()) - private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean => - !this.shouldLetBrowserHandleCopyShortcut(event) + private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean => { + const shouldLetBrowserHandleCopy = this.shouldLetBrowserHandleCopyShortcut(event) + if (!shouldLetBrowserHandleCopy && event.type === "keydown") { + this.selectionContext.clear() + } + return !shouldLetBrowserHandleCopy + } private readonly onTerminalSelectionChange = (): void => { // CHANGE: keep a copyable snapshot before terminal redraws can drop xterm's live selection. @@ -246,9 +125,11 @@ class TerminalCopyInteractionController { // EFFECT: reads terminal.hasSelection() and terminal.getSelection(). // INVARIANT: empty redraw selection events do not erase the last user-created non-empty selection snapshot. // COMPLEXITY: O(n)/O(1) where n = selected text length. - if (this.args.terminal.hasSelection()) { - this.selectionContext.refresh() + if (!this.args.terminal.hasSelection()) { + this.selectionContext.restore() + return } + this.selectionContext.refresh() } private readonly hasProtectedSelectionContext = (): boolean => diff --git a/packages/terminal/src/web/terminal-copy-rules.ts b/packages/terminal/src/web/terminal-copy-rules.ts new file mode 100644 index 00000000..81ceab12 --- /dev/null +++ b/packages/terminal/src/web/terminal-copy-rules.ts @@ -0,0 +1,111 @@ +import type { TerminalCopyClipboardData, TerminalSelectionTarget } from "./terminal-copy-selection-snapshot.js" + +export type TerminalMouseTrackingMode = "any" | "drag" | "none" | "vt200" | "x10" + +export type TerminalCopyKeyboardEvent = { + readonly altKey: boolean + readonly ctrlKey: boolean + readonly key: string + readonly metaKey: boolean + readonly type: string +} + +type TerminalMouseButtonEvent = { + readonly button: number +} + +type TerminalMouseTrackingTarget = { + readonly modes: { + readonly mouseTrackingMode: TerminalMouseTrackingMode + } +} + +const primaryMouseButton = 0 +const secondaryMouseButton = 2 + +export const hasActiveMouseTracking = (terminal: TerminalMouseTrackingTarget): boolean => + terminal.modes.mouseTrackingMode !== "none" + +export const isKeyboardCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean => + event.type === "keydown" && + !event.altKey && + (event.ctrlKey || event.metaKey) && + event.key.toLowerCase() === "c" + +/** + * Decides whether xterm key processing must step aside for native browser copy. + * + * @param event - Keyboard event seen by xterm before it translates keys into pty input. + * @param terminal - Terminal selection facade. + * @returns True iff the event is a system copy shortcut and selected terminal text is non-empty. + * @pure true + * @effect terminal.hasSelection(), terminal.getSelection(). + * @invariant result => no ETX input is sent for selected terminal text copy. + * @precondition `event` and `terminal` are non-null. + * @postcondition True means xterm should return false from its custom key handler. + * @complexity O(n) where n = selected text length. + * @throws Never + */ +// CHANGE: keep keyboard copy shortcuts out of terminal input when text is selected +// WHY: Ctrl/Cmd+C must copy the selected terminal text instead of sending SIGINT to the pty +// QUOTE(ТЗ): "Text easy coping" +// REF: issue-353 +// SOURCE: n/a +// FORMAT THEOREM: selected(t) and copyShortcut(e) => browserCopy(e,t) +// PURITY: CORE +// EFFECT: reads terminal selection through the injected terminal facade +// INVARIANT: empty selection never blocks terminal Ctrl+C semantics +// COMPLEXITY: O(n)/O(1) +export const shouldLetBrowserHandleTerminalCopyShortcut = ( + event: TerminalCopyKeyboardEvent, + terminal: TerminalSelectionTarget +): boolean => isKeyboardCopyShortcut(event) && terminal.hasSelection() && terminal.getSelection().length > 0 + +export const shouldForceBrowserTerminalSelection = ( + event: TerminalMouseButtonEvent, + terminal: TerminalMouseTrackingTarget +): boolean => event.button === primaryMouseButton && hasActiveMouseTracking(terminal) + +/** + * Decides whether a secondary-button event must preserve the terminal selection context. + * + * @param event - Mouse button event captured before xterm/tmux handlers can clear the selection. + * @param terminal - Terminal selection and mouse-tracking facade. + * @returns True iff the event is a secondary click, mouse tracking is active, and a selection exists. + * @pure true + * @effect isSecondaryMouseButton(event), hasActiveMouseTracking(terminal), terminal.hasSelection(). + * @invariant result <=> secondary(event) and tracking(terminal) and selected(terminal). + * @precondition `event` and `terminal` are non-null; mouse tracking may be `none`, which disables forcing. + * @postcondition True means the caller may snapshot selection text before suppressing terminal mouse reporting. + * @complexity O(1) + * @throws Never + */ +// CHANGE: document the guarded right-click selection preservation predicate +// WHY: selection protection is valid only while terminal mouse tracking can consume right-click events +// QUOTE(ТЗ): "right-click with selection should remain copyable in the terminal" +// REF: issue-340 +// SOURCE: n/a +// FORMAT THEOREM: forall e,t: force(e,t) <-> secondary(e) and tracking(t) and hasSelection(t) +// PURITY: CORE +// EFFECT: reads terminal.hasSelection through the injected terminal facade +// INVARIANT: mouseTrackingMode = none always yields false +// COMPLEXITY: O(1) +export const shouldForceTerminalSelectionContext = ( + event: TerminalMouseButtonEvent, + terminal: TerminalMouseTrackingTarget & TerminalSelectionTarget +): boolean => event.button === secondaryMouseButton && hasActiveMouseTracking(terminal) && terminal.hasSelection() + +export const writeTerminalSelectionToClipboardData = ( + terminal: TerminalSelectionTarget, + clipboardData: TerminalCopyClipboardData | null +): boolean => { + if (clipboardData === null || !terminal.hasSelection()) { + return false + } + const selection = terminal.getSelection() + if (selection.length === 0) { + return false + } + clipboardData.setData("text/plain", selection) + return true +} diff --git a/packages/terminal/src/web/terminal-copy-selection-snapshot.ts b/packages/terminal/src/web/terminal-copy-selection-snapshot.ts new file mode 100644 index 00000000..87d7f167 --- /dev/null +++ b/packages/terminal/src/web/terminal-copy-selection-snapshot.ts @@ -0,0 +1,273 @@ +export type TerminalCopyClipboardData = { + readonly setData: (format: string, data: string) => void +} + +export type TerminalSelectionTarget = { + readonly getSelection: () => string + readonly hasSelection: () => boolean +} + +type TerminalSelectionBufferType = "alternate" | "normal" + +type TerminalSelectionBufferSnapshot = { + readonly active: { + readonly baseY: number + readonly length: number + readonly type: TerminalSelectionBufferType + readonly viewportY: number + } +} + +type TerminalSelectionCellPosition = { + readonly x: number + readonly y: number +} + +type TerminalSelectionBufferRange = { + readonly end: TerminalSelectionCellPosition + readonly start: TerminalSelectionCellPosition +} + +export type TerminalSelectionRestoreTarget = { + readonly buffer?: TerminalSelectionBufferSnapshot | undefined + readonly cols?: number | undefined + readonly getSelectionPosition?: () => TerminalSelectionBufferRange | undefined + readonly select?: (column: number, row: number, length: number) => void +} + +type TerminalSelectionActiveBufferSnapshot = { + readonly length: number + readonly type: TerminalSelectionBufferType +} + +type TerminalSelectionRestoreSnapshot = { + readonly buffer: TerminalSelectionActiveBufferSnapshot + readonly cols: number + readonly endRow: number + readonly length: number + readonly startColumn: number + readonly startRow: number +} + +type TerminalSelectionNormalizedRangeSnapshot = { + readonly endRow: number + readonly length: number + readonly startColumn: number + readonly startRow: number +} + +const terminalSelectionContextSnapshotTtlMs = 10_000 + +const isNonNegativeInteger = (value: number): boolean => Number.isInteger(value) && value >= 0 + +const isPositiveInteger = (value: number): boolean => Number.isInteger(value) && value > 0 + +const validTerminalSelectionColumn = (column: number, cols: number): boolean => + isNonNegativeInteger(column) && column <= cols + +const validTerminalSelectionCell = (cell: TerminalSelectionCellPosition, cols: number): boolean => + validTerminalSelectionColumn(cell.x, cols) && isNonNegativeInteger(cell.y) + +const terminalSelectionCellCompare = ( + left: TerminalSelectionCellPosition, + right: TerminalSelectionCellPosition +): number => { + if (left.y !== right.y) { + return left.y - right.y + } + return left.x - right.x +} + +const normalizeTerminalSelectionRange = ( + range: TerminalSelectionBufferRange +): TerminalSelectionBufferRange => { + if (terminalSelectionCellCompare(range.start, range.end) <= 0) { + return range + } + return { end: range.start, start: range.end } +} + +const terminalSelectionRangeLength = ( + range: TerminalSelectionBufferRange, + cols: number +): number => ((range.end.y - range.start.y) * cols) + range.end.x - range.start.x + +const readTerminalSelectionCols = (terminal: TerminalSelectionRestoreTarget): number | null => { + const cols = terminal.cols + if (cols === undefined || !isPositiveInteger(cols)) { + return null + } + return cols +} + +const readTerminalSelectionActiveBuffer = ( + terminal: TerminalSelectionRestoreTarget +): TerminalSelectionActiveBufferSnapshot | null => { + const activeBuffer = terminal.buffer?.active + if (activeBuffer === undefined) { + return null + } + if (!isNonNegativeInteger(activeBuffer.baseY) || !isNonNegativeInteger(activeBuffer.viewportY)) { + return null + } + if (!isPositiveInteger(activeBuffer.length)) { + return null + } + return { + length: activeBuffer.length, + type: activeBuffer.type + } +} + +const validTerminalSelectionRange = ( + range: TerminalSelectionBufferRange, + cols: number, + bufferLength: number +): boolean => + validTerminalSelectionCell(range.start, cols) && + validTerminalSelectionCell(range.end, cols) && + range.end.y < bufferLength + +const readTerminalSelectionNormalizedRangeSnapshot = ( + terminal: TerminalSelectionRestoreTarget, + cols: number, + bufferLength: number +): TerminalSelectionNormalizedRangeSnapshot | null => { + const range = terminal.getSelectionPosition?.() + if (range === undefined) { + return null + } + const normalizedRange = normalizeTerminalSelectionRange(range) + if (!validTerminalSelectionRange(normalizedRange, cols, bufferLength)) { + return null + } + const length = terminalSelectionRangeLength(normalizedRange, cols) + if (!isPositiveInteger(length)) { + return null + } + return { + endRow: normalizedRange.end.y, + length, + startColumn: normalizedRange.start.x, + startRow: normalizedRange.start.y + } +} + +const readTerminalSelectionRestoreSnapshot = ( + terminal: TerminalSelectionRestoreTarget +): TerminalSelectionRestoreSnapshot | null => { + if (terminal.select === undefined) { + return null + } + const cols = readTerminalSelectionCols(terminal) + const activeBuffer = readTerminalSelectionActiveBuffer(terminal) + if (cols === null || activeBuffer === null) { + return null + } + const rangeSnapshot = readTerminalSelectionNormalizedRangeSnapshot(terminal, cols, activeBuffer.length) + if (rangeSnapshot === null) { + return null + } + return { + ...rangeSnapshot, + buffer: activeBuffer, + cols + } +} + +const canRestoreTerminalSelection = ( + terminal: TerminalSelectionRestoreTarget, + snapshot: TerminalSelectionRestoreSnapshot +): boolean => { + if (terminal.select === undefined || terminal.cols !== snapshot.cols) { + return false + } + const activeBuffer = readTerminalSelectionActiveBuffer(terminal) + return activeBuffer !== null && + activeBuffer.type === snapshot.buffer.type && + snapshot.endRow < activeBuffer.length +} + +const restoreTerminalSelection = ( + terminal: TerminalSelectionRestoreTarget, + snapshot: TerminalSelectionRestoreSnapshot +): boolean => { + if (!canRestoreTerminalSelection(terminal, snapshot)) { + return false + } + terminal.select?.(snapshot.startColumn, snapshot.startRow, snapshot.length) + return true +} + +export class TerminalSelectionContextSnapshot { + private restoreSnapshot: TerminalSelectionRestoreSnapshot | null = null + private selection = "" + private timer: ReturnType | null = null + + constructor(private readonly terminal: TerminalSelectionTarget & TerminalSelectionRestoreTarget) {} + + readonly clear = (): void => { + this.restoreSnapshot = null + this.selection = "" + if (this.timer !== null) { + clearTimeout(this.timer) + this.timer = null + } + } + + readonly has = (): boolean => this.selection.length > 0 + + readonly read = (): string => this.selection + + readonly refresh = (): boolean => { + const selection = this.terminal.getSelection() + if (selection.length === 0) { + this.clear() + return false + } + this.selection = selection + this.restoreSnapshot = readTerminalSelectionRestoreSnapshot(this.terminal) + if (this.timer !== null) { + clearTimeout(this.timer) + } + this.timer = setTimeout(this.clear, terminalSelectionContextSnapshotTtlMs) + return true + } + + /** + * Restores xterm's visual selection after redraws reset its live selection model. + * + * @returns True iff a cached coordinate snapshot was valid for the current buffer and was reselected. + * @pure false - calls the injected xterm selection facade. + * @effect terminal.select(column,row,length). + * @invariant restored => terminal buffer type and column count match the captured selection snapshot. + * @precondition A previous non-empty terminal selection may have been captured. + * @postcondition Success asks xterm to redraw an equivalent selection range in the current buffer. + * @complexity O(1). + * @throws Never under the validated integer/range preconditions. + */ + // CHANGE: restore xterm's own selection from a cached coordinate snapshot + // WHY: Claude Code can reset xterm's buffer-bound selection during redraw while the user still expects it to remain visible + // QUOTE(ТЗ): "даже если терминал перендерился то почему оно спало?" + // REF: user-message-2026-06-15-terminal-selection-reselect + // SOURCE: n/a + // FORMAT THEOREM: valid(snapshot,currentBuffer) => visibleSelection(restored(snapshot)) + // PURITY: SHELL + // EFFECT: terminal.select(column,row,length) + // INVARIANT: restore never crosses buffer type or column-count boundaries + // COMPLEXITY: O(1)/O(1) + readonly restore = (): boolean => { + if (this.restoreSnapshot === null) { + return false + } + return restoreTerminalSelection(this.terminal, this.restoreSnapshot) + } + + readonly writeToClipboardData = (clipboardData: TerminalCopyClipboardData | null): boolean => { + if (clipboardData === null || this.selection.length === 0) { + return false + } + clipboardData.setData("text/plain", this.selection) + return true + } +} diff --git a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts new file mode 100644 index 00000000..45fd78fa --- /dev/null +++ b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from "@effect/vitest" + +import { + attachTerminalCopyInteraction, + type TerminalCopyInteractionTerminal, + type TerminalCopyKeyboardEvent +} from "../../src/web/terminal-copy-interaction.js" +import { + copyEvent, + FakeTerminalCopyHost, + mouseEvent, + type TerminalCopyTestMouseEvent +} from "./fixtures/terminal-copy-interaction.js" + +type SelectionBufferType = "alternate" | "normal" + +type SelectionRange = Exclude< + ReturnType>, + undefined +> + +type SelectCall = { + readonly column: number + readonly length: number + readonly row: number +} + +type SelectionRestoreHarness = { + readonly disposable: { readonly dispose: () => void } + readonly emitSelectionChange: () => void + readonly host: FakeTerminalCopyHost + readonly keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> + readonly selectCalls: Array + readonly setBufferType: (type: SelectionBufferType) => void + readonly setCols: (cols: number) => void + readonly setSelection: (text: string, startColumn: number, startRow: number) => void + readonly textarea: FakeTerminalRestoreTextarea +} + +const keyboardCopyEvent: TerminalCopyKeyboardEvent = { + altKey: false, + ctrlKey: true, + key: "c", + metaKey: false, + type: "keydown" +} + +class FakeTerminalRestoreTextarea { + focusCalls = 0 + selectCalls = 0 + readonly style = { + height: "", + left: "", + top: "", + width: "", + zIndex: "" + } + value = "" + + focus(): void { + this.focusCalls += 1 + } + + select(): void { + this.selectCalls += 1 + } +} + +const removeSelectionHandler = ( + handlers: Array<() => void>, + handler: () => void +): void => { + const handlerIndex = handlers.indexOf(handler) + if (handlerIndex !== -1) { + handlers.splice(handlerIndex, 1) + } +} + +const createSelectionRestoreHarness = (): SelectionRestoreHarness => { + let terminalSelection = "" + let selectionRange: SelectionRange | undefined + let terminalCols = 80 + let terminalBufferType: SelectionBufferType = "normal" + const host = new FakeTerminalCopyHost(null) + const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = [] + const selectionChangeHandlers: Array<() => void> = [] + const selectCalls: Array = [] + const textarea = new FakeTerminalRestoreTextarea() + const terminal: TerminalCopyInteractionTerminal = { + attachCustomKeyEventHandler: (handler) => { + keyHandlers.push(handler) + }, + buffer: { + get active() { + return { + baseY: 0, + length: 100, + type: terminalBufferType, + viewportY: 0 + } + } + }, + get cols() { + return terminalCols + }, + getSelection: () => terminalSelection, + getSelectionPosition: () => selectionRange, + hasSelection: () => terminalSelection.length > 0, + modes: { mouseTrackingMode: "any" }, + onSelectionChange: (handler) => { + selectionChangeHandlers.push(handler) + return { + dispose: () => { + removeSelectionHandler(selectionChangeHandlers, handler) + } + } + }, + select: (column, row, length) => { + selectCalls.push({ column, length, row }) + }, + textarea + } + const disposable = attachTerminalCopyInteraction({ host, terminal }) + return { + disposable, + emitSelectionChange: () => { + for (const handler of selectionChangeHandlers) { + handler() + } + }, + host, + keyHandlers, + selectCalls, + setBufferType: (type) => { + terminalBufferType = type + }, + setCols: (cols) => { + terminalCols = cols + }, + setSelection: (text, startColumn, startRow) => { + terminalSelection = text + selectionRange = text.length > 0 + ? { + end: { x: startColumn + text.length, y: startRow }, + start: { x: startColumn, y: startRow } + } + : undefined + }, + textarea + } +} + +const requireKeyHandler = ( + keyHandlers: ReadonlyArray<(event: TerminalCopyKeyboardEvent) => boolean> +): (event: TerminalCopyKeyboardEvent) => boolean => + keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") + +describe("terminal copy selection restore", () => { + it("restores xterm selection coordinates after redraw clears live selection", () => { + const harness = createSelectionRestoreHarness() + + harness.setSelection("selected text", 3, 5) + harness.emitSelectionChange() + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + + expect(harness.selectCalls).toEqual([{ column: 3, length: 13, row: 5 }]) + + harness.disposable.dispose() + }) + + it("does not restore xterm selection after intentional keyboard input clears the snapshot", () => { + const harness = createSelectionRestoreHarness() + + harness.setSelection("selected", 1, 4) + harness.emitSelectionChange() + expect(requireKeyHandler(harness.keyHandlers)({ ...keyboardCopyEvent, ctrlKey: false, key: "Enter" })) + .toBe(true) + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + + expect(harness.selectCalls).toEqual([]) + + harness.disposable.dispose() + }) + + it("keeps copy snapshot but skips reselect when terminal column count changes", () => { + const harness = createSelectionRestoreHarness() + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + + harness.setSelection("snapshot", 8, 2) + harness.emitSelectionChange() + harness.setCols(81) + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + harness.host.dispatchCopy(copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + })) + + expect(harness.selectCalls).toEqual([]) + expect(clipboardWrites).toEqual([{ data: "snapshot", format: "text/plain" }]) + + harness.disposable.dispose() + }) + + it("skips reselect when the active terminal buffer type changes", () => { + const harness = createSelectionRestoreHarness() + + harness.setSelection("alternate text", 2, 7) + harness.setBufferType("alternate") + harness.emitSelectionChange() + harness.setBufferType("normal") + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + + expect(harness.selectCalls).toEqual([]) + + harness.disposable.dispose() + }) + + it("does not suppress events or copy without a prior selection snapshot", () => { + const harness = createSelectionRestoreHarness() + const terminalMouseReports: Array = [] + const rightClick = mouseEvent(2, "mousedown") + const contextMenu = mouseEvent(0, "contextmenu") + const copy = copyEvent({ + setData: () => { + expect.fail("clipboard data should not be written") + } + }) + harness.host.addBubbleMouseListener("mousedown", (event) => { + terminalMouseReports.push(event) + }) + harness.host.addBubbleMouseListener("contextmenu", (event) => { + terminalMouseReports.push(event) + }) + + harness.emitSelectionChange() + harness.host.dispatchMouse("mousedown", rightClick) + harness.host.dispatchMouse("contextmenu", contextMenu) + harness.host.dispatchCopy(copy) + + expect(harness.selectCalls).toEqual([]) + expect(requireKeyHandler(harness.keyHandlers)(keyboardCopyEvent)).toBe(true) + expect(rightClick.stopImmediatePropagationCalls).toBe(0) + expect(contextMenu.stopImmediatePropagationCalls).toBe(0) + expect(copy.preventDefaultCalls).toBe(0) + expect(harness.textarea.focusCalls).toBe(0) + expect(harness.textarea.selectCalls).toBe(0) + expect(harness.textarea.value).toBe("") + expect(terminalMouseReports).toEqual([rightClick, contextMenu]) + + harness.disposable.dispose() + }) +}) From e5551306708b4d4204c99e0ce7ba4dba35b8b0c3 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:57:18 +0000 Subject: [PATCH 6/7] test(terminal): add selection property invariants --- .../src/web/terminal-copy-native-menu.ts | 147 +++++++++++++++ .../web/terminal-copy-interaction.test.ts | 81 ++++---- .../terminal-copy-redraw-interaction.test.ts | 176 +++++++++--------- ...minal-copy-right-click-interaction.test.ts | 96 +++++----- .../terminal-copy-selection-restore.test.ts | 152 ++++++++++----- 5 files changed, 433 insertions(+), 219 deletions(-) diff --git a/packages/terminal/src/web/terminal-copy-native-menu.ts b/packages/terminal/src/web/terminal-copy-native-menu.ts index dd387d2a..da3ea458 100644 --- a/packages/terminal/src/web/terminal-copy-native-menu.ts +++ b/packages/terminal/src/web/terminal-copy-native-menu.ts @@ -1,5 +1,16 @@ import type { TerminalCopyMouseEvent } from "./terminal-copy-selection-drag.js" +/** + * Facade for xterm's hidden textarea used by native browser copy commands. + * + * @pure true - the type only describes the injected DOM shell boundary. + * @effect none at declaration time; implementations perform focus/select/style/value DOM effects. + * @invariant Style and value writes affect the same textarea that focus() and select() address. + * @precondition Implementations are xterm-compatible textarea-like objects. + * @postcondition Consumers can prepare selected text for the browser context-menu Copy command. + * @complexity O(1) + * @throws Never + */ export type TerminalCopyTextarea = { readonly focus: () => void readonly select: () => void @@ -13,6 +24,17 @@ export type TerminalCopyTextarea = { value: string } +/** + * Minimal host facade for locating the terminal screen relative to context-menu coordinates. + * + * @pure true - the type only describes optional DOM lookup capabilities. + * @effect none at declaration time; implementations may perform DOM layout reads when called. + * @invariant querySelector(".xterm-screen") returns an element in the host coordinate space when available. + * @precondition Host methods, when present, follow DOM getBoundingClientRect/querySelector contracts. + * @postcondition Consumers can resolve the screen origin without depending on concrete browser classes. + * @complexity O(1) + * @throws Never + */ type TerminalCopyScreenElement = { readonly getBoundingClientRect: () => { readonly left: number @@ -20,6 +42,17 @@ type TerminalCopyScreenElement = { } } +/** + * Host facade used by the native copy menu preparation shell. + * + * @pure true - the type only describes injected DOM capabilities. + * @effect none at declaration time; method implementations can read DOM layout. + * @invariant At least one of host.getBoundingClientRect or host.querySelector(".xterm-screen") may define origin. + * @precondition Optional methods preserve their `this` binding semantics when called through this facade. + * @postcondition Consumers can choose the most precise terminal screen origin available. + * @complexity O(1) + * @throws Never + */ export type TerminalNativeCopyMenuHost = { readonly getBoundingClientRect?: TerminalCopyScreenElement["getBoundingClientRect"] readonly querySelector?: (selector: string) => TerminalCopyScreenElement | null @@ -36,8 +69,54 @@ const terminalContextMenuTextareaOffsetPx = 10 const terminalContextMenuTextareaSizePx = 20 const xtermScreenSelector = ".xterm-screen" +/** + * Normalizes optional event coordinates to the DOM origin default. + * + * @param value - Optional mouse coordinate from a browser-like event facade. + * @returns The coordinate value or zero when the event omits it. + * @pure true + * @effect none + * @invariant result = value when value is defined; otherwise result = 0. + * @precondition value is either undefined or a finite event coordinate. + * @postcondition The caller can use the result in textarea positioning arithmetic. + * @complexity O(1) + * @throws Never + */ +// CHANGE: normalize optional mouse coordinates for native copy textarea positioning +// WHY: synthetic test events and browser events can omit client coordinates +// QUOTE(ТЗ): n/a +// REF: PR-407-CodeRabbit-native-copy-menu-docs +// SOURCE: n/a +// FORMAT THEOREM: optionalNumber(x) = x if x is defined, otherwise 0 +// PURITY: CORE +// EFFECT: none +// INVARIANT: result is always a number +// COMPLEXITY: O(1)/O(1) const optionalNumber = (value: number | undefined): number => value ?? 0 +/** + * Adapts a host-level bounding box method into a screen element facade. + * + * @param host - Native copy menu host with an optional getBoundingClientRect method. + * @returns A screen element facade when the host can provide its own rectangle; otherwise null. + * @pure true - does not read layout until the returned facade is invoked. + * @effect none during resolution; returned facade calls host.getBoundingClientRect(). + * @invariant Non-null result preserves host as the `this` value for getBoundingClientRect. + * @precondition host is the DOM-like object that owns the optional getBoundingClientRect method. + * @postcondition The caller can use the result as a fallback screen-origin provider. + * @complexity O(1) + * @throws Never + */ +// CHANGE: preserve host-bound layout reads when no .xterm-screen child is available +// WHY: the fallback must call getBoundingClientRect with the original DOM receiver +// QUOTE(ТЗ): n/a +// REF: PR-407-CodeRabbit-native-copy-menu-docs +// SOURCE: n/a +// FORMAT THEOREM: hasRect(host) => result.getBoundingClientRect() = host.getBoundingClientRect.call(host) +// PURITY: SHELL +// EFFECT: deferred host.getBoundingClientRect() +// INVARIANT: null is returned iff the host has no bounding-rect method +// COMPLEXITY: O(1)/O(1) const resolveContextMenuHostScreenElement = ( host: TerminalNativeCopyMenuHost ): TerminalCopyScreenElement | null => { @@ -50,11 +129,57 @@ const resolveContextMenuHostScreenElement = ( } } +/** + * Resolves the preferred terminal screen origin for native copy menu positioning. + * + * @param host - Native copy menu host that may expose xterm screen lookup or host bounds. + * @returns The xterm screen element when available, otherwise host bounds, otherwise null. + * @pure true - delegates to injected DOM facades without mutating them. + * @effect optional host.querySelector(".xterm-screen") and deferred getBoundingClientRect reads. + * @invariant querySelector(".xterm-screen") takes precedence over host-level bounds. + * @precondition host methods follow DOM-compatible contracts. + * @postcondition Non-null result can provide coordinates for the helper textarea. + * @complexity O(1) + * @throws Never + */ +// CHANGE: prefer the xterm screen coordinate space for context-menu helper positioning +// WHY: textarea coordinates need to be relative to the terminal screen, not an arbitrary ancestor +// QUOTE(ТЗ): n/a +// REF: PR-407-CodeRabbit-native-copy-menu-docs +// SOURCE: n/a +// FORMAT THEOREM: screen(host) = querySelector(.xterm-screen) ?? hostBounds(host) +// PURITY: SHELL +// EFFECT: optional host.querySelector lookup +// INVARIANT: screen child lookup wins over host fallback +// COMPLEXITY: O(1)/O(1) const resolveContextMenuScreenElement = ( host: TerminalNativeCopyMenuHost ): TerminalCopyScreenElement | null => host.querySelector?.(xtermScreenSelector) ?? resolveContextMenuHostScreenElement(host) +/** + * Prepares xterm's hidden textarea so the native browser context-menu Copy item copies terminal text. + * + * @param args - Event coordinates, host lookup facade, cached selection text, and xterm textarea facade. + * @returns True iff a non-empty selection was written into a resolved textarea and selected for copying. + * @pure false - mutates the injected textarea and reads DOM layout through injected facades. + * @effect textarea.style/value writes, textarea.focus(), textarea.select(), screen.getBoundingClientRect(). + * @invariant result => selection.length > 0 and textarea.value = selection. + * @precondition event coordinates are in the same viewport space as getBoundingClientRect values. + * @postcondition Success positions a small helper textarea near the context-menu event and selects its value. + * @complexity O(n) where n = selection.length. + * @throws Never under the injected facade contracts. + */ +// CHANGE: prepare xterm's hidden textarea before the browser context menu opens +// WHY: Chrome only shows and executes native Copy when a focused selected textarea value exists +// QUOTE(ТЗ): "А куда пропала кнопка copy?" +// REF: user-message-2026-06-15-native-copy-menu +// SOURCE: n/a +// FORMAT THEOREM: selection != "" and textarea and screen => prepared(textarea, selection) +// PURITY: SHELL +// EFFECT: DOM textarea style/value/focus/select and layout reads +// INVARIANT: false result leaves textarea unmodified by this function +// COMPLEXITY: O(n)/O(1) export const prepareNativeBrowserCopyMenu = ( { event, host, selection, textarea }: PrepareNativeBrowserCopyMenuArgs ): boolean => { @@ -74,6 +199,28 @@ export const prepareNativeBrowserCopyMenu = ( return true } +/** + * Clears the helper textarea after the copy context is consumed or invalidated. + * + * @param textarea - Optional xterm helper textarea facade. + * @pure false - mutates the injected textarea when present. + * @effect textarea.value = "". + * @invariant textarea === undefined => no effect; textarea !== undefined => textarea.value = "" after return. + * @precondition textarea, when present, accepts value writes. + * @postcondition No stale cached selection remains in the helper textarea. + * @complexity O(1) + * @throws Never under the injected facade contract. + */ +// CHANGE: clear native copy helper text when selection context is no longer active +// WHY: stale helper textarea contents must not be copied after selection invalidation +// QUOTE(ТЗ): n/a +// REF: PR-407-CodeRabbit-native-copy-menu-docs +// SOURCE: n/a +// FORMAT THEOREM: clear(textarea) => textarea.value = "" when textarea exists +// PURITY: SHELL +// EFFECT: textarea.value mutation +// INVARIANT: undefined textarea is a no-op +// COMPLEXITY: O(1)/O(1) export const clearNativeBrowserCopyMenu = ( textarea: TerminalCopyTextarea | undefined ): void => { diff --git a/packages/terminal/tests/web/terminal-copy-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-interaction.test.ts index c2a1b1fe..00253b25 100644 --- a/packages/terminal/tests/web/terminal-copy-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-interaction.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" import { attachTerminalCopyInteraction, @@ -29,6 +31,8 @@ const terminalWithSelection = ( modes: { mouseTrackingMode } }) +const activeMouseTrackingModeArbitrary = fc.constantFrom("any", "drag", "vt200", "x10") + const keyboardEvent = ( key: string, options: Partial> = {} @@ -238,47 +242,42 @@ describe("terminal copy interaction", () => { disposable.dispose() }) - it("suppresses protected context menus without blocking the native menu", () => { - const host = new FakeTerminalCopyHost(null) - const contextMenuReports: Array = [] - host.addBubbleMouseListener("contextmenu", (event) => { - contextMenuReports.push(event) - }) - const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") }) - const down = mouseEvent(2) - const contextMenu = mouseEvent(2, "contextmenu") - - host.dispatchMouse("mousedown", down) - host.dispatchMouse("contextmenu", contextMenu) - - expect(contextMenu.shiftKey).toBe(true) - expect(contextMenu.preventDefaultCalls).toBe(0) - expect(contextMenu.stopImmediatePropagationCalls).toBe(1) - expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) - expect(contextMenuReports).toEqual([]) - - disposable.dispose() - }) - - it("suppresses protected context menus even when the browser reports primary button", () => { - const host = new FakeTerminalCopyHost(null) - const contextMenuReports: Array = [] - host.addBubbleMouseListener("contextmenu", (event) => { - contextMenuReports.push(event) - }) - const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") }) - const contextMenu = mouseEvent(0, "contextmenu") - - host.dispatchMouse("contextmenu", contextMenu) - - expect(contextMenu.shiftKey).toBe(true) - expect(contextMenu.preventDefaultCalls).toBe(0) - expect(contextMenu.stopImmediatePropagationCalls).toBe(1) - expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) - expect(contextMenuReports).toEqual([]) - - disposable.dispose() - }) + it.effect("suppresses generated protected context menus without blocking the native menu", () => + Effect.sync(() => { + fc.assert( + fc.property( + activeMouseTrackingModeArbitrary, + fc.string({ maxLength: 64, minLength: 1 }), + fc.constantFrom(0, 2), + (mouseTrackingMode, selectedText, contextMenuButton) => { + const host = new FakeTerminalCopyHost(null) + const contextMenuReports: Array = [] + host.addBubbleMouseListener("contextmenu", (event) => { + contextMenuReports.push(event) + }) + const disposable = attachTerminalCopyInteraction({ + host, + terminal: terminalWithSelection(mouseTrackingMode, selectedText) + }) + const contextMenu = mouseEvent(contextMenuButton, "contextmenu") + + if (contextMenuButton === 2) { + host.dispatchMouse("mousedown", mouseEvent(2)) + } + host.dispatchMouse("contextmenu", contextMenu) + + expect(contextMenu.shiftKey).toBe(true) + expect(contextMenu.preventDefaultCalls).toBe(0) + expect(contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(contextMenuReports).toEqual([]) + + disposable.dispose() + } + ), + { numRuns: 100 } + ) + })) it("does not start a forced selection drag when mouse tracking is inactive", () => { const documentTarget = new FakeTerminalCopyEventTarget() diff --git a/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts index 4582fcd7..80f13b3b 100644 --- a/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" import { attachTerminalCopyInteraction, @@ -13,7 +14,7 @@ const keyboardCopyEvent: TerminalCopyKeyboardEvent = { key: "c", metaKey: false, type: "keydown" -} +} as const class FakeTerminalCopyTextarea { focusCalls = 0 @@ -60,94 +61,95 @@ class FakeTerminalCopyScreenHost extends FakeTerminalCopyHost { } describe("terminal copy redraw interaction", () => { - it("keeps selection snapshot copyable after terminal redraw clears live selection", () => { - let terminalSelection = "" - const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = [] - const selectionChangeHandlers: Array<() => void> = [] - const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] - const host = new FakeTerminalCopyScreenHost(100, 200) - const textarea = new FakeTerminalCopyTextarea() - const terminal: TerminalCopyInteractionTerminal = { - attachCustomKeyEventHandler: (handler) => { - keyHandlers.push(handler) - }, - getSelection: () => terminalSelection, - hasSelection: () => terminalSelection.length > 0, - modes: { mouseTrackingMode: "any" }, - onSelectionChange: (handler) => { - selectionChangeHandlers.push(handler) - return { - dispose: () => { - const handlerIndex = selectionChangeHandlers.indexOf(handler) - if (handlerIndex !== -1) { - selectionChangeHandlers.splice(handlerIndex, 1) + it.effect("keeps selection snapshot copyable after terminal redraw clears live selection", () => + Effect.sync(() => { + let terminalSelection = "" + const keyHandlers: Array<(event: TerminalCopyKeyboardEvent) => boolean> = [] + const selectionChangeHandlers: Array<() => void> = [] + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const host = new FakeTerminalCopyScreenHost(100, 200) + const textarea = new FakeTerminalCopyTextarea() + const terminal: TerminalCopyInteractionTerminal = { + attachCustomKeyEventHandler: (handler) => { + keyHandlers.push(handler) + }, + getSelection: () => terminalSelection, + hasSelection: () => terminalSelection.length > 0, + modes: { mouseTrackingMode: "any" }, + onSelectionChange: (handler) => { + selectionChangeHandlers.push(handler) + return { + dispose: () => { + const handlerIndex = selectionChangeHandlers.indexOf(handler) + if (handlerIndex !== -1) { + selectionChangeHandlers.splice(handlerIndex, 1) + } } } - } - }, - textarea - } - const disposable = attachTerminalCopyInteraction({ host, terminal }) + }, + textarea + } + const disposable = attachTerminalCopyInteraction({ host, terminal }) - terminalSelection = "selected before redraw" - for (const handler of selectionChangeHandlers) { - handler() - } - terminalSelection = "" - - expect(keyHandlers).toHaveLength(1) - const handleKey = keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") - expect(handleKey(keyboardCopyEvent)).toBe(false) - - const rightClick = mouseEvent(2, "mousedown", { - clientX: 150, - clientY: 260 - }) - const contextMenu = mouseEvent(0, "contextmenu", { - clientX: 155, - clientY: 265 - }) - const copy = copyEvent({ - setData: (format: string, data: string) => { - clipboardWrites.push({ data, format }) + terminalSelection = "selected before redraw" + for (const handler of selectionChangeHandlers) { + handler() } - }) - host.dispatchMouse("mousedown", rightClick) - - expect(rightClick.shiftKey).toBe(true) - expect(rightClick.preventDefaultCalls).toBe(0) - expect(rightClick.stopImmediatePropagationCalls).toBe(1) - expect(textarea.value).toBe("selected before redraw") - expect(textarea.focusCalls).toBe(1) - expect(textarea.selectCalls).toBe(1) - - host.dispatchMouse("contextmenu", contextMenu) - - expect(contextMenu.shiftKey).toBe(true) - expect(contextMenu.preventDefaultCalls).toBe(0) - expect(contextMenu.stopImmediatePropagationCalls).toBe(1) - expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) - expect(textarea.value).toBe("selected before redraw") - expect(textarea.focusCalls).toBe(2) - expect(textarea.selectCalls).toBe(2) - expect(textarea.style).toEqual({ - height: "20px", - left: "45px", - top: "55px", - width: "20px", - zIndex: "1000" - }) - - host.dispatchCopy(copy) - - expect(clipboardWrites).toEqual([{ data: "selected before redraw", format: "text/plain" }]) - expect(copy.preventDefaultCalls).toBe(1) - expect(copy.stopPropagationCalls).toBe(1) - expect(textarea.value).toBe("") - expect(selectionChangeHandlers).toHaveLength(1) - expect(handleKey(keyboardCopyEvent)).toBe(true) - - disposable.dispose() - expect(selectionChangeHandlers).toHaveLength(0) - }) + terminalSelection = "" + + expect(keyHandlers).toHaveLength(1) + const handleKey = keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") + expect(handleKey(keyboardCopyEvent)).toBe(false) + + const rightClick = mouseEvent(2, "mousedown", { + clientX: 150, + clientY: 260 + }) + const contextMenu = mouseEvent(0, "contextmenu", { + clientX: 155, + clientY: 265 + }) + const copy = copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + }) + host.dispatchMouse("mousedown", rightClick) + + expect(rightClick.shiftKey).toBe(true) + expect(rightClick.preventDefaultCalls).toBe(0) + expect(rightClick.stopImmediatePropagationCalls).toBe(1) + expect(textarea.value).toBe("selected before redraw") + expect(textarea.focusCalls).toBe(1) + expect(textarea.selectCalls).toBe(1) + + host.dispatchMouse("contextmenu", contextMenu) + + expect(contextMenu.shiftKey).toBe(true) + expect(contextMenu.preventDefaultCalls).toBe(0) + expect(contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(textarea.value).toBe("selected before redraw") + expect(textarea.focusCalls).toBe(2) + expect(textarea.selectCalls).toBe(2) + expect(textarea.style).toEqual({ + height: "20px", + left: "45px", + top: "55px", + width: "20px", + zIndex: "1000" + }) + + host.dispatchCopy(copy) + + expect(clipboardWrites).toEqual([{ data: "selected before redraw", format: "text/plain" }]) + expect(copy.preventDefaultCalls).toBe(1) + expect(copy.stopPropagationCalls).toBe(1) + expect(textarea.value).toBe("") + expect(selectionChangeHandlers).toHaveLength(1) + expect(handleKey(keyboardCopyEvent)).toBe(true) + + disposable.dispose() + expect(selectionChangeHandlers).toHaveLength(0) + })) }) diff --git a/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts index 2951b451..4f61c5a9 100644 --- a/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts +++ b/packages/terminal/tests/web/terminal-copy-right-click-interaction.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" import * as fc from "fast-check" import { @@ -124,31 +125,33 @@ const expectEmptySelectionPassthroughInvariant = (flow: RightClickCopyHarness): } describe("terminal copy right-click interaction", () => { - it("preserves generated right-click copy selections while mouse tracking is active", () => { - fc.assert( - fc.property(fc.string({ minLength: 1 }), (selectedText) => { - const { flow } = createMutableSelectionFlow(selectedText) - - dispatchRightClickCopyFlow(flow) - expectCopiedSelectionInvariant(flow, selectedText) - flow.disposable.dispose() - }), - { numRuns: 100 } - ) - }) + it.effect("preserves generated right-click copy selections while mouse tracking is active", () => + Effect.sync(() => { + fc.assert( + fc.property(fc.string({ minLength: 1 }), (selectedText) => { + const { flow } = createMutableSelectionFlow(selectedText) - it("preserves generated empty-selection right-click passthrough", () => { - fc.assert( - fc.property(fc.constant(""), (selectedText) => { - const flow = createStaticSelectionFlow(selectedText) - - dispatchRightClickCopyFlow(flow) - expectEmptySelectionPassthroughInvariant(flow) - flow.disposable.dispose() - }), - { numRuns: 10 } - ) - }) + dispatchRightClickCopyFlow(flow) + expectCopiedSelectionInvariant(flow, selectedText) + flow.disposable.dispose() + }), + { numRuns: 100 } + ) + })) + + it.effect("preserves generated empty-selection right-click passthrough", () => + Effect.sync(() => { + fc.assert( + fc.property(fc.constant(""), (selectedText) => { + const flow = createStaticSelectionFlow(selectedText) + + dispatchRightClickCopyFlow(flow) + expectEmptySelectionPassthroughInvariant(flow) + flow.disposable.dispose() + }), + { numRuns: 10 } + ) + })) it("keeps right-click selection handling one-shot", () => { const documentTarget = new FakeTerminalCopyEventTarget() @@ -205,29 +208,34 @@ describe("terminal copy right-click interaction", () => { flow.disposable.dispose() }) - it("keeps snapshot text copyable when live selection clears before context menu", () => { - const selectedText = "snapshot line" - let terminalSelection = selectedText - const flow = createRightClickCopyHarness({ - getSelection: () => terminalSelection, - hasSelection: () => terminalSelection.length > 0, - modes: { mouseTrackingMode: "any" } - }) + it.effect("keeps generated snapshot text copyable when live selection clears before context menu", () => + Effect.sync(() => { + fc.assert( + fc.property(fc.string({ maxLength: 64, minLength: 1 }), (selectedText) => { + let terminalSelection = selectedText + const flow = createRightClickCopyHarness({ + getSelection: () => terminalSelection, + hasSelection: () => terminalSelection.length > 0, + modes: { mouseTrackingMode: "any" } + }) - flow.host.dispatchMouse("mousedown", flow.rightClick) - terminalSelection = "" - flow.host.dispatchMouse("contextmenu", flow.contextMenu) - flow.host.dispatchCopy(flow.copy) + flow.host.dispatchMouse("mousedown", flow.rightClick) + terminalSelection = "" + flow.host.dispatchMouse("contextmenu", flow.contextMenu) + flow.host.dispatchCopy(flow.copy) - expect(flow.contextMenu.shiftKey).toBe(true) - expect(flow.contextMenu.preventDefaultCalls).toBe(0) - expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(1) - expect(flow.contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) - expect(flow.contextMenuEvents).toEqual([]) - expectCopiedSelectionInvariant(flow, selectedText) + expect(flow.contextMenu.shiftKey).toBe(true) + expect(flow.contextMenu.preventDefaultCalls).toBe(0) + expect(flow.contextMenu.stopImmediatePropagationCalls).toBe(1) + expect(flow.contextMenu.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(flow.contextMenuEvents).toEqual([]) + expectCopiedSelectionInvariant(flow, selectedText) - flow.disposable.dispose() - }) + flow.disposable.dispose() + }), + { numRuns: 100 } + ) + })) it("does not suppress right-click release events without a terminal selection", () => { const flow = createStaticSelectionFlow("") diff --git a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts index 45fd78fa..c60782cc 100644 --- a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts +++ b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts @@ -1,4 +1,6 @@ import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" import { attachTerminalCopyInteraction, @@ -43,7 +45,16 @@ const keyboardCopyEvent: TerminalCopyKeyboardEvent = { key: "c", metaKey: false, type: "keydown" -} +} as const + +const nonEmptySelectionTextArbitrary = fc.string({ maxLength: 32, minLength: 1 }) + +const selectionCoordinateArbitrary = fc.record({ + bufferType: fc.constantFrom("alternate", "normal"), + extraCols: fc.integer({ max: 128, min: 0 }), + startColumn: fc.integer({ max: 32, min: 0 }), + startRow: fc.integer({ max: 98, min: 0 }) +}) class FakeTerminalRestoreTextarea { focusCalls = 0 @@ -156,18 +167,101 @@ const requireKeyHandler = ( keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") describe("terminal copy selection restore", () => { - it("restores xterm selection coordinates after redraw clears live selection", () => { - const harness = createSelectionRestoreHarness() - - harness.setSelection("selected text", 3, 5) - harness.emitSelectionChange() - harness.setSelection("", 0, 0) - harness.emitSelectionChange() + it.effect("restores generated valid xterm selection coordinates after redraw", () => + Effect.sync(() => { + fc.assert( + fc.property( + nonEmptySelectionTextArbitrary, + selectionCoordinateArbitrary, + (selectedText, { bufferType, extraCols, startColumn, startRow }) => { + const harness = createSelectionRestoreHarness() + const cols = startColumn + selectedText.length + extraCols + + harness.setCols(cols) + harness.setBufferType(bufferType) + harness.setSelection(selectedText, startColumn, startRow) + harness.emitSelectionChange() + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + + expect(harness.selectCalls).toEqual([{ column: startColumn, length: selectedText.length, row: startRow }]) + + harness.disposable.dispose() + } + ), + { numRuns: 100 } + ) + })) - expect(harness.selectCalls).toEqual([{ column: 3, length: 13, row: 5 }]) + it.effect("keeps generated copy snapshots but skips reselect after column changes", () => + Effect.sync(() => { + fc.assert( + fc.property( + nonEmptySelectionTextArbitrary, + selectionCoordinateArbitrary, + fc.integer({ max: 32, min: 1 }), + (selectedText, { bufferType, extraCols, startColumn, startRow }, colsDelta) => { + const harness = createSelectionRestoreHarness() + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const cols = startColumn + selectedText.length + extraCols + + harness.setCols(cols) + harness.setBufferType(bufferType) + harness.setSelection(selectedText, startColumn, startRow) + harness.emitSelectionChange() + harness.setCols(cols + colsDelta) + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + harness.host.dispatchCopy(copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + })) + + expect(harness.selectCalls).toEqual([]) + expect(clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) + + harness.disposable.dispose() + } + ), + { numRuns: 100 } + ) + })) - harness.disposable.dispose() - }) + it.effect("keeps generated copy snapshots but skips reselect after buffer type changes", () => + Effect.sync(() => { + fc.assert( + fc.property( + nonEmptySelectionTextArbitrary, + selectionCoordinateArbitrary, + (selectedText, { bufferType, extraCols, startColumn, startRow }) => { + const harness = createSelectionRestoreHarness() + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const cols = startColumn + selectedText.length + extraCols + const changedBufferType: SelectionBufferType = bufferType === "normal" ? "alternate" : "normal" + + harness.setCols(cols) + harness.setBufferType(bufferType) + harness.setSelection(selectedText, startColumn, startRow) + harness.emitSelectionChange() + harness.setBufferType(changedBufferType) + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + harness.host.dispatchCopy(copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + })) + + expect(harness.selectCalls).toEqual([]) + expect(clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) + + harness.disposable.dispose() + } + ), + { numRuns: 100 } + ) + })) it("does not restore xterm selection after intentional keyboard input clears the snapshot", () => { const harness = createSelectionRestoreHarness() @@ -184,42 +278,6 @@ describe("terminal copy selection restore", () => { harness.disposable.dispose() }) - it("keeps copy snapshot but skips reselect when terminal column count changes", () => { - const harness = createSelectionRestoreHarness() - const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] - - harness.setSelection("snapshot", 8, 2) - harness.emitSelectionChange() - harness.setCols(81) - harness.setSelection("", 0, 0) - harness.emitSelectionChange() - harness.host.dispatchCopy(copyEvent({ - setData: (format: string, data: string) => { - clipboardWrites.push({ data, format }) - } - })) - - expect(harness.selectCalls).toEqual([]) - expect(clipboardWrites).toEqual([{ data: "snapshot", format: "text/plain" }]) - - harness.disposable.dispose() - }) - - it("skips reselect when the active terminal buffer type changes", () => { - const harness = createSelectionRestoreHarness() - - harness.setSelection("alternate text", 2, 7) - harness.setBufferType("alternate") - harness.emitSelectionChange() - harness.setBufferType("normal") - harness.setSelection("", 0, 0) - harness.emitSelectionChange() - - expect(harness.selectCalls).toEqual([]) - - harness.disposable.dispose() - }) - it("does not suppress events or copy without a prior selection snapshot", () => { const harness = createSelectionRestoreHarness() const terminalMouseReports: Array = [] From 6a338425dd35cafa696671b997f5641ac52c3feb Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:11:45 +0000 Subject: [PATCH 7/7] test(terminal): guarantee selection harness cleanup --- .../terminal-copy-selection-restore.test.ts | 170 ++++++++---------- 1 file changed, 71 insertions(+), 99 deletions(-) diff --git a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts index c60782cc..7232d053 100644 --- a/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts +++ b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts @@ -7,12 +7,7 @@ import { type TerminalCopyInteractionTerminal, type TerminalCopyKeyboardEvent } from "../../src/web/terminal-copy-interaction.js" -import { - copyEvent, - FakeTerminalCopyHost, - mouseEvent, - type TerminalCopyTestMouseEvent -} from "./fixtures/terminal-copy-interaction.js" +import { copyEvent, FakeTerminalCopyHost } from "./fixtures/terminal-copy-interaction.js" type SelectionBufferType = "alternate" | "normal" @@ -166,6 +161,26 @@ const requireKeyHandler = ( ): (event: TerminalCopyKeyboardEvent) => boolean => keyHandlers[0] ?? expect.fail("Expected terminal copy key handler to be registered.") +const withSelectionRestoreHarness = (assertion: (harness: SelectionRestoreHarness) => void): void => { + Effect.runSync( + Effect.scoped( + Effect.flatMap( + Effect.acquireRelease( + Effect.sync(createSelectionRestoreHarness), + (harness) => + Effect.sync(() => { + harness.disposable.dispose() + }) + ), + (harness) => + Effect.sync(() => { + assertion(harness) + }) + ) + ) + ) +} + describe("terminal copy selection restore", () => { it.effect("restores generated valid xterm selection coordinates after redraw", () => Effect.sync(() => { @@ -174,19 +189,18 @@ describe("terminal copy selection restore", () => { nonEmptySelectionTextArbitrary, selectionCoordinateArbitrary, (selectedText, { bufferType, extraCols, startColumn, startRow }) => { - const harness = createSelectionRestoreHarness() - const cols = startColumn + selectedText.length + extraCols - - harness.setCols(cols) - harness.setBufferType(bufferType) - harness.setSelection(selectedText, startColumn, startRow) - harness.emitSelectionChange() - harness.setSelection("", 0, 0) - harness.emitSelectionChange() - - expect(harness.selectCalls).toEqual([{ column: startColumn, length: selectedText.length, row: startRow }]) - - harness.disposable.dispose() + withSelectionRestoreHarness((harness) => { + const cols = startColumn + selectedText.length + extraCols + harness.setCols(cols) + harness.setBufferType(bufferType) + harness.setSelection(selectedText, startColumn, startRow) + harness.emitSelectionChange() + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + expect(harness.selectCalls).toEqual([ + { column: startColumn, length: selectedText.length, row: startRow } + ]) + }) } ), { numRuns: 100 } @@ -201,27 +215,25 @@ describe("terminal copy selection restore", () => { selectionCoordinateArbitrary, fc.integer({ max: 32, min: 1 }), (selectedText, { bufferType, extraCols, startColumn, startRow }, colsDelta) => { - const harness = createSelectionRestoreHarness() - const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] - const cols = startColumn + selectedText.length + extraCols - - harness.setCols(cols) - harness.setBufferType(bufferType) - harness.setSelection(selectedText, startColumn, startRow) - harness.emitSelectionChange() - harness.setCols(cols + colsDelta) - harness.setSelection("", 0, 0) - harness.emitSelectionChange() - harness.host.dispatchCopy(copyEvent({ - setData: (format: string, data: string) => { - clipboardWrites.push({ data, format }) - } - })) - - expect(harness.selectCalls).toEqual([]) - expect(clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) - - harness.disposable.dispose() + withSelectionRestoreHarness((harness) => { + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const cols = startColumn + selectedText.length + extraCols + harness.setCols(cols) + harness.setBufferType(bufferType) + harness.setSelection(selectedText, startColumn, startRow) + harness.emitSelectionChange() + harness.setCols(cols + colsDelta) + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + harness.host.dispatchCopy(copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + })) + + expect(harness.selectCalls).toEqual([]) + expect(clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) + }) } ), { numRuns: 100 } @@ -235,28 +247,25 @@ describe("terminal copy selection restore", () => { nonEmptySelectionTextArbitrary, selectionCoordinateArbitrary, (selectedText, { bufferType, extraCols, startColumn, startRow }) => { - const harness = createSelectionRestoreHarness() - const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] - const cols = startColumn + selectedText.length + extraCols - const changedBufferType: SelectionBufferType = bufferType === "normal" ? "alternate" : "normal" - - harness.setCols(cols) - harness.setBufferType(bufferType) - harness.setSelection(selectedText, startColumn, startRow) - harness.emitSelectionChange() - harness.setBufferType(changedBufferType) - harness.setSelection("", 0, 0) - harness.emitSelectionChange() - harness.host.dispatchCopy(copyEvent({ - setData: (format: string, data: string) => { - clipboardWrites.push({ data, format }) - } - })) - - expect(harness.selectCalls).toEqual([]) - expect(clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) - - harness.disposable.dispose() + withSelectionRestoreHarness((harness) => { + const clipboardWrites: Array<{ readonly data: string; readonly format: string }> = [] + const cols = startColumn + selectedText.length + extraCols + const changedBufferType: SelectionBufferType = bufferType === "normal" ? "alternate" : "normal" + harness.setCols(cols) + harness.setBufferType(bufferType) + harness.setSelection(selectedText, startColumn, startRow) + harness.emitSelectionChange() + harness.setBufferType(changedBufferType) + harness.setSelection("", 0, 0) + harness.emitSelectionChange() + harness.host.dispatchCopy(copyEvent({ + setData: (format: string, data: string) => { + clipboardWrites.push({ data, format }) + } + })) + expect(harness.selectCalls).toEqual([]) + expect(clipboardWrites).toEqual([{ data: selectedText, format: "text/plain" }]) + }) } ), { numRuns: 100 } @@ -272,44 +281,7 @@ describe("terminal copy selection restore", () => { .toBe(true) harness.setSelection("", 0, 0) harness.emitSelectionChange() - expect(harness.selectCalls).toEqual([]) - - harness.disposable.dispose() - }) - - it("does not suppress events or copy without a prior selection snapshot", () => { - const harness = createSelectionRestoreHarness() - const terminalMouseReports: Array = [] - const rightClick = mouseEvent(2, "mousedown") - const contextMenu = mouseEvent(0, "contextmenu") - const copy = copyEvent({ - setData: () => { - expect.fail("clipboard data should not be written") - } - }) - harness.host.addBubbleMouseListener("mousedown", (event) => { - terminalMouseReports.push(event) - }) - harness.host.addBubbleMouseListener("contextmenu", (event) => { - terminalMouseReports.push(event) - }) - - harness.emitSelectionChange() - harness.host.dispatchMouse("mousedown", rightClick) - harness.host.dispatchMouse("contextmenu", contextMenu) - harness.host.dispatchCopy(copy) - - expect(harness.selectCalls).toEqual([]) - expect(requireKeyHandler(harness.keyHandlers)(keyboardCopyEvent)).toBe(true) - expect(rightClick.stopImmediatePropagationCalls).toBe(0) - expect(contextMenu.stopImmediatePropagationCalls).toBe(0) - expect(copy.preventDefaultCalls).toBe(0) - expect(harness.textarea.focusCalls).toBe(0) - expect(harness.textarea.selectCalls).toBe(0) - expect(harness.textarea.value).toBe("") - expect(terminalMouseReports).toEqual([rightClick, contextMenu]) - harness.disposable.dispose() }) })