diff --git a/packages/terminal/src/web/terminal-copy-interaction.ts b/packages/terminal/src/web/terminal-copy-interaction.ts index 71e5629f..ba4dcbb1 100644 --- a/packages/terminal/src/web/terminal-copy-interaction.ts +++ b/packages/terminal/src/web/terminal-copy-interaction.ts @@ -1,3 +1,18 @@ +import { + clearNativeBrowserCopyMenu, + prepareNativeBrowserCopyMenu, + 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, @@ -7,22 +22,24 @@ 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 -} - -export type TerminalCopyKeyboardEvent = { - readonly altKey: boolean - readonly ctrlKey: boolean - readonly key: string - readonly metaKey: boolean - readonly type: string +type TerminalDisposable = { + readonly dispose: () => void } export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { @@ -32,11 +49,9 @@ export type TerminalCopyInteractionTerminal = TerminalSelectionTarget & { readonly modes: { readonly mouseTrackingMode: TerminalMouseTrackingMode } -} - -type TerminalCopyClipboardData = { - readonly setData: (format: string, data: string) => void -} + readonly onSelectionChange?: (handler: () => void) => TerminalDisposable + readonly textarea?: TerminalCopyTextarea | undefined +} & TerminalSelectionRestoreTarget type TerminalCopyClipboardEvent = { readonly clipboardData: TerminalCopyClipboardData | null @@ -49,7 +64,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 @@ -62,141 +77,15 @@ 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 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 + private selectionChangeDisposable: TerminalDisposable | null = null constructor(private readonly args: TerminalCopyInteractionArgs) { this.selectionContext = new TerminalSelectionContextSnapshot(args.terminal) @@ -205,6 +94,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,14 +102,55 @@ class TerminalCopyInteractionController { return { dispose: this.dispose } } - private readonly onTerminalKeyEvent = (event: TerminalCopyKeyboardEvent): boolean => - !shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) + private readonly shouldLetBrowserHandleCopyShortcut = (event: TerminalCopyKeyboardEvent): boolean => + shouldLetBrowserHandleTerminalCopyShortcut(event, this.args.terminal) || + (isKeyboardCopyShortcut(event) && this.selectionContext.has()) - private readonly shouldProtectSelectionContext = (event: TerminalCopyMouseEvent): boolean => - isSecondaryMouseButton(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. + // 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.restore() + return + } + this.selectionContext.refresh() + } + + private readonly hasProtectedSelectionContext = (): boolean => 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() + private readonly onSelectionContextMouseEvent = (event: TerminalCopyMouseEvent): boolean => { if (!this.shouldProtectSelectionContext(event)) { return false @@ -234,18 +165,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 } @@ -262,7 +198,24 @@ 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.hasProtectedSelectionContext()) { + return + } + forceTerminalSelectionModifier(event) + if (this.args.terminal.hasSelection()) { + this.selectionContext.refresh() + } + this.prepareNativeBrowserCopyMenu(event) + suppressTerminalMouseReport(event) } private readonly onCopy = (event: TerminalCopyClipboardEvent): void => { @@ -272,13 +225,17 @@ class TerminalCopyInteractionController { return } this.selectionContext.clear() + this.clearNativeBrowserCopyMenu() event.preventDefault() event.stopPropagation() } private readonly dispose = (): void => { + this.selectionChangeDisposable?.dispose() + 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..da3ea458 --- /dev/null +++ b/packages/terminal/src/web/terminal-copy-native-menu.ts @@ -0,0 +1,230 @@ +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 + readonly style: { + height: string + left: string + top: string + width: string + zIndex: string + } + 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 + readonly top: number + } +} + +/** + * 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 +} + +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" + +/** + * 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 => { + const getBoundingClientRect = host.getBoundingClientRect + if (getBoundingClientRect === undefined) { + return null + } + return { + getBoundingClientRect: () => getBoundingClientRect.call(host) + } +} + +/** + * 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 => { + 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 +} + +/** + * 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 => { + if (textarea !== undefined) { + textarea.value = "" + } +} 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-interaction.test.ts b/packages/terminal/tests/web/terminal-copy-interaction.test.ts index ed5ca787..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,6 +242,43 @@ describe("terminal copy interaction", () => { 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() const host = new FakeTerminalCopyHost(documentTarget) 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..80f13b3b --- /dev/null +++ b/packages/terminal/tests/web/terminal-copy-redraw-interaction.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" + +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" +} as const + +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.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 }) + + 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 }) + } + }) + 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 b1ceb7df..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() @@ -194,17 +197,46 @@ 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.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) + + 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() + }), + { 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 new file mode 100644 index 00000000..7232d053 --- /dev/null +++ b/packages/terminal/tests/web/terminal-copy-selection-restore.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect } from "effect" +import * as fc from "fast-check" + +import { + attachTerminalCopyInteraction, + type TerminalCopyInteractionTerminal, + type TerminalCopyKeyboardEvent +} from "../../src/web/terminal-copy-interaction.js" +import { copyEvent, FakeTerminalCopyHost } 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" +} 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 + 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.") + +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(() => { + fc.assert( + fc.property( + nonEmptySelectionTextArbitrary, + selectionCoordinateArbitrary, + (selectedText, { bufferType, extraCols, startColumn, startRow }) => { + 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 } + ) + })) + + 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) => { + 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 } + ) + })) + + 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 }) => { + 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 } + ) + })) + + 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() + }) +})