diff --git a/packages/app/src/web/terminal-copy-interaction.ts b/packages/app/src/web/terminal-copy-interaction.ts index c9f9ea83..0f704976 100644 --- a/packages/app/src/web/terminal-copy-interaction.ts +++ b/packages/app/src/web/terminal-copy-interaction.ts @@ -30,29 +30,38 @@ type TerminalCopyClipboardEvent = { readonly stopPropagation: () => void } -type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent +type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent & { + readonly buttons?: number | undefined + readonly clientX?: number | undefined + readonly clientY?: number | undefined + readonly ctrlKey?: boolean | undefined + readonly detail?: number | undefined + readonly metaKey?: boolean | undefined + readonly preventDefault?: (() => void) | undefined + readonly screenX?: number | undefined + readonly screenY?: number | undefined + readonly stopImmediatePropagation?: (() => void) | undefined + readonly stopPropagation?: (() => void) | undefined +} type TerminalSelectionDragEventType = "mousemove" | "mouseup" +type TerminalCopyMouseEventType = "mousedown" | TerminalSelectionDragEventType type TerminalSelectionDragListenerRegistration = ( type: TerminalSelectionDragEventType, - listener: (event: TerminalSelectionModifierEvent) => void, + listener: (event: TerminalCopyMouseEvent) => void, options: true ) => void type TerminalSelectionDragTarget = { readonly addEventListener: TerminalSelectionDragListenerRegistration + readonly dispatchEvent?: ((event: Event) => boolean) | undefined readonly removeEventListener: TerminalSelectionDragListenerRegistration } type TerminalCopyListenerRegistration = { (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void - (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void - ( - type: TerminalSelectionDragEventType, - listener: (event: TerminalSelectionModifierEvent) => void, - options: true - ): void + (type: TerminalCopyMouseEventType, listener: (event: TerminalCopyMouseEvent) => void, options: true): void } type TerminalCopyInteractionHost = { @@ -131,6 +140,81 @@ const resolveTerminalSelectionDragTarget = ( host: TerminalCopyInteractionHost ): TerminalSelectionDragTarget => host.ownerDocument ?? host +const optionalNumber = (value: number | undefined): number => value ?? 0 + +const optionalBoolean = (value: boolean | undefined): boolean => value ?? false + +const forcedTerminalMouseUpInit = (event: TerminalCopyMouseEvent): MouseEventInit => { + const selectionModifier = terminalSelectionModifier(currentNavigatorPlatform()) + return { + altKey: selectionModifier === "altKey" ? true : event.altKey, + bubbles: true, + button: event.button, + buttons: 0, + cancelable: true, + clientX: optionalNumber(event.clientX), + clientY: optionalNumber(event.clientY), + ctrlKey: optionalBoolean(event.ctrlKey), + detail: optionalNumber(event.detail), + metaKey: optionalBoolean(event.metaKey), + screenX: optionalNumber(event.screenX), + screenY: optionalNumber(event.screenY), + shiftKey: selectionModifier === "shiftKey" ? true : event.shiftKey + } +} + +const defineMouseEventProperty = ( + event: Event, + property: string, + value: boolean | number +): void => { + Reflect.defineProperty(event, property, { + configurable: true, + value + }) +} + +const copyMouseEventInitProperties = ( + event: Event, + init: MouseEventInit +): void => { + defineMouseEventProperty(event, "altKey", optionalBoolean(init.altKey)) + defineMouseEventProperty(event, "button", optionalNumber(init.button)) + defineMouseEventProperty(event, "buttons", optionalNumber(init.buttons)) + defineMouseEventProperty(event, "clientX", optionalNumber(init.clientX)) + defineMouseEventProperty(event, "clientY", optionalNumber(init.clientY)) + defineMouseEventProperty(event, "ctrlKey", optionalBoolean(init.ctrlKey)) + defineMouseEventProperty(event, "detail", optionalNumber(init.detail)) + defineMouseEventProperty(event, "metaKey", optionalBoolean(init.metaKey)) + defineMouseEventProperty(event, "screenX", optionalNumber(init.screenX)) + defineMouseEventProperty(event, "screenY", optionalNumber(init.screenY)) + defineMouseEventProperty(event, "shiftKey", optionalBoolean(init.shiftKey)) +} + +const createForcedTerminalMouseUpEvent = ( + sourceEvent: TerminalCopyMouseEvent +): Event => { + const init = forcedTerminalMouseUpInit(sourceEvent) + const event = typeof MouseEvent === "function" + ? new MouseEvent("mouseup", init) + : new Event("mouseup", { bubbles: true, cancelable: true }) + copyMouseEventInitProperties(event, init) + return event +} + +const suppressOriginalTerminalMouseUp = (event: TerminalCopyMouseEvent): void => { + event.preventDefault?.() + event.stopPropagation?.() + event.stopImmediatePropagation?.() +} + +const replayForcedTerminalMouseUp = ( + target: TerminalSelectionDragTarget, + event: TerminalCopyMouseEvent +): void => { + target.dispatchEvent?.(createForcedTerminalMouseUpEvent(event)) +} + const createTerminalSelectionDragController = ( host: TerminalCopyInteractionHost ): TerminalSelectionDragController => { @@ -148,16 +232,27 @@ const createTerminalSelectionDragController = ( forcedSelectionDrag = false } - const onMouseMove = (event: TerminalSelectionModifierEvent): void => { + const onMouseMove = (event: TerminalCopyMouseEvent): void => { if (!forcedSelectionDrag) { return } forceTerminalSelectionModifier(event) } - const onMouseUp = (event: TerminalSelectionModifierEvent): void => { - if (forcedSelectionDrag) { - forceTerminalSelectionModifier(event) + const onMouseUp = (event: TerminalCopyMouseEvent): void => { + if (!forcedSelectionDrag) { + return + } + const target = selectionDragTarget + forceTerminalSelectionModifier(event) + if (target?.dispatchEvent !== undefined) { + // CHANGE: replay a clean document mouseup for xterm selection finalization. + // WHY: xterm's mouse-report mouseup treats the original release as pty input, + // which triggers onUserInput and clears the just-created selection. + suppressOriginalTerminalMouseUp(event) + clearSelectionDrag() + replayForcedTerminalMouseUp(target, event) + return } clearSelectionDrag() } diff --git a/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts b/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts new file mode 100644 index 00000000..2e0fbf5e --- /dev/null +++ b/packages/app/tests/docker-git/fixtures/terminal-copy-interaction.ts @@ -0,0 +1,264 @@ +import { expect } from "@effect/vitest" + +type TerminalCopyTestClipboardData = { + readonly setData: (format: string, data: string) => void +} + +type TerminalCopyTestClipboardEvent = { + readonly clipboardData: TerminalCopyTestClipboardData | null + readonly preventDefault: () => void + readonly stopPropagation: () => void +} + +export type TerminalCopyTestMouseEvent = Event & { + readonly altKey: boolean + readonly button: number + readonly buttons: number + readonly clientX: number + readonly clientY: number + readonly screenX: number + readonly screenY: number + readonly shiftKey: boolean +} + +type TerminalCopyTestMouseType = "mousedown" | "mousemove" | "mouseup" +type TerminalCopyTestEventType = "copy" | TerminalCopyTestMouseType +type TerminalCopyTestCopyListener = (event: TerminalCopyTestClipboardEvent) => void +type TerminalCopyTestMouseListener = (event: TerminalCopyTestMouseEvent) => void +type TerminalCopyTestListener = + | { readonly listener: TerminalCopyTestCopyListener; readonly type: "copy" } + | { + readonly listener: TerminalCopyTestMouseListener + readonly phase: "bubble" | "capture" + readonly type: TerminalCopyTestMouseType + } +type TerminalCopyTestAnyListener = TerminalCopyTestCopyListener | TerminalCopyTestMouseListener +type TerminalCopyTestMouseOptions = Pick< + TerminalCopyTestMouseEvent, + "altKey" | "buttons" | "clientX" | "clientY" | "screenX" | "screenY" | "shiftKey" +> + +const isCopyTestListener = ( + type: TerminalCopyTestEventType, + _listener: TerminalCopyTestAnyListener +): _listener is TerminalCopyTestCopyListener => type === "copy" + +const isMouseTestListener = ( + type: TerminalCopyTestEventType, + _listener: TerminalCopyTestAnyListener +): _listener is TerminalCopyTestMouseListener => type !== "copy" + +const isMouseTestEventType = ( + type: string +): type is TerminalCopyTestMouseType => type === "mousedown" || type === "mousemove" || type === "mouseup" + +const isMouseTestListenerEntry = ( + entry: TerminalCopyTestListener +): entry is { + readonly listener: TerminalCopyTestMouseListener + readonly phase: "bubble" | "capture" + readonly type: TerminalCopyTestMouseType +} => entry.type !== "copy" + +const isTerminalCopyTestMouseEvent = (event: Event): event is TerminalCopyTestMouseEvent => + "altKey" in event && + "button" in event && + "buttons" in event && + "clientX" in event && + "clientY" in event && + "screenX" in event && + "screenY" in event && + "shiftKey" in event + +const optionalBoolean = (value: boolean | undefined): boolean => value ?? false + +const optionalNumber = (value: number | undefined): number => value ?? 0 + +const defaultButtons = (type: TerminalCopyTestMouseType): number => type === "mouseup" ? 0 : 1 + +const resolveMouseOptions = ( + type: TerminalCopyTestMouseType, + options: Partial +): TerminalCopyTestMouseOptions => ({ + altKey: optionalBoolean(options.altKey), + buttons: options.buttons ?? defaultButtons(type), + clientX: optionalNumber(options.clientX), + clientY: optionalNumber(options.clientY), + screenX: optionalNumber(options.screenX), + screenY: optionalNumber(options.screenY), + shiftKey: optionalBoolean(options.shiftKey) +}) + +export class FakeTerminalCopyMouseEvent extends Event { + altKey: boolean + readonly button: number + readonly buttons: number + readonly clientX: number + readonly clientY: number + readonly screenX: number + readonly screenY: number + shiftKey: boolean + preventDefaultCalls = 0 + stopImmediatePropagationCalls = 0 + stopPropagationCalls = 0 + + constructor( + type: TerminalCopyTestMouseType, + button: number, + options: Partial = {} + ) { + super(type, { bubbles: true, cancelable: true }) + const resolved = resolveMouseOptions(type, options) + this.altKey = resolved.altKey + this.button = button + this.buttons = resolved.buttons + this.clientX = resolved.clientX + this.clientY = resolved.clientY + this.screenX = resolved.screenX + this.screenY = resolved.screenY + this.shiftKey = resolved.shiftKey + } + + override preventDefault(): void { + this.preventDefaultCalls += 1 + super.preventDefault() + } + + override stopImmediatePropagation(): void { + this.stopImmediatePropagationCalls += 1 + super.stopImmediatePropagation() + } + + override stopPropagation(): void { + this.stopPropagationCalls += 1 + super.stopPropagation() + } +} + +const isPropagationStopped = (event: TerminalCopyTestMouseEvent): boolean => + event instanceof FakeTerminalCopyMouseEvent && + (event.stopPropagationCalls > 0 || event.stopImmediatePropagationCalls > 0) + +const isImmediatePropagationStopped = (event: TerminalCopyTestMouseEvent): boolean => + event instanceof FakeTerminalCopyMouseEvent && event.stopImmediatePropagationCalls > 0 + +export class FakeTerminalCopyEventTarget { + private listeners: Array = [] + readonly dispatchedEvents: Array = [] + + addEventListener(type: "copy", listener: TerminalCopyTestCopyListener, options: true): void + addEventListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener, options: true): void + addEventListener( + type: TerminalCopyTestEventType, + listener: TerminalCopyTestAnyListener, + _options: true + ): void { + if (isCopyTestListener(type, listener)) { + this.listeners.push({ listener, type: "copy" }) + return + } + if (isMouseTestEventType(type) && isMouseTestListener(type, listener)) { + this.listeners.push({ listener, phase: "capture", type }) + } + } + + addBubbleMouseListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener): void { + this.listeners.push({ listener, phase: "bubble", type }) + } + + removeEventListener(type: "copy", listener: TerminalCopyTestCopyListener, options: true): void + removeEventListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener, options: true): void + removeEventListener( + type: TerminalCopyTestEventType, + listener: TerminalCopyTestAnyListener, + _options: true + ): void { + this.listeners = this.listeners.filter((entry) => entry.type !== type || entry.listener !== listener) + } + + dispatchMousePhase( + type: TerminalCopyTestMouseType, + event: TerminalCopyTestMouseEvent, + phase: "bubble" | "capture" + ): void { + for (const entry of this.listeners) { + if (isImmediatePropagationStopped(event)) { + return + } + if (isMouseTestListenerEntry(entry) && entry.phase === phase && entry.type === type) { + entry.listener(event) + } + } + } + + dispatchMouse(type: TerminalCopyTestMouseType, event: TerminalCopyTestMouseEvent): void { + this.dispatchMousePhase(type, event, "capture") + if (isPropagationStopped(event)) { + return + } + this.dispatchMousePhase(type, event, "bubble") + } + + dispatchEvent(event: Event): boolean { + this.dispatchedEvents.push(event) + if (isMouseTestEventType(event.type) && isTerminalCopyTestMouseEvent(event)) { + this.dispatchMouse(event.type, event) + } + return !event.defaultPrevented + } + + listenerCount(type: TerminalCopyTestEventType): number { + return this.listeners.filter((entry) => entry.type === type).length + } + + captureListenerCount(type: TerminalCopyTestMouseType): number { + return this.listeners.filter( + (entry) => isMouseTestListenerEntry(entry) && entry.phase === "capture" && entry.type === type + ).length + } +} + +export class FakeTerminalCopyHost extends FakeTerminalCopyEventTarget { + readonly ownerDocument: FakeTerminalCopyEventTarget | null + + constructor(ownerDocument: FakeTerminalCopyEventTarget | null) { + super() + this.ownerDocument = ownerDocument + } + + dispatchBubblingMouse(type: TerminalCopyTestMouseType, event: TerminalCopyTestMouseEvent): void { + this.ownerDocument?.dispatchMousePhase(type, event, "capture") + if (isPropagationStopped(event)) { + return + } + this.dispatchMouse(type, event) + if (isPropagationStopped(event)) { + return + } + this.ownerDocument?.dispatchMousePhase(type, event, "bubble") + } +} + +export const mouseEvent = ( + button: number, + type: TerminalCopyTestMouseType = "mousedown", + options?: Partial< + Pick + > +): FakeTerminalCopyMouseEvent => new FakeTerminalCopyMouseEvent(type, button, options) + +export const expectNoDragListeners = (target: FakeTerminalCopyEventTarget): void => { + expect(target.captureListenerCount("mousemove")).toBe(0) + expect(target.captureListenerCount("mouseup")).toBe(0) +} + +export const expectSingleMouseEvent = ( + events: ReadonlyArray +): TerminalCopyTestMouseEvent => { + expect(events).toHaveLength(1) + const event = events[0] + if (event === undefined) { + throw new Error("Expected one mouse event.") + } + return event +} diff --git a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts index a4485e47..bced3bb3 100644 --- a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts +++ b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts @@ -9,6 +9,14 @@ import { type TerminalMouseTrackingMode, writeTerminalSelectionToClipboardData } from "../../src/web/terminal-copy-interaction.js" +import { + expectNoDragListeners, + expectSingleMouseEvent, + FakeTerminalCopyEventTarget, + FakeTerminalCopyHost, + mouseEvent, + type TerminalCopyTestMouseEvent +} from "./fixtures/terminal-copy-interaction.js" const terminalWithSelection = ( mouseTrackingMode: TerminalMouseTrackingMode, @@ -19,112 +27,6 @@ const terminalWithSelection = ( modes: { mouseTrackingMode } }) -type TerminalCopyTestClipboardData = { - readonly setData: (format: string, data: string) => void -} - -type TerminalCopyTestClipboardEvent = { - readonly clipboardData: TerminalCopyTestClipboardData | null - readonly preventDefault: () => void - readonly stopPropagation: () => void -} - -type TerminalCopyTestMouseEvent = { - readonly button: number - altKey: boolean - shiftKey: boolean -} - -type TerminalCopyTestMouseType = "mousedown" | "mousemove" | "mouseup" -type TerminalCopyTestEventType = "copy" | TerminalCopyTestMouseType -type TerminalCopyTestCopyListener = (event: TerminalCopyTestClipboardEvent) => void -type TerminalCopyTestMouseListener = (event: TerminalCopyTestMouseEvent) => void -type TerminalCopyTestListener = - | { readonly listener: TerminalCopyTestCopyListener; readonly type: "copy" } - | { readonly listener: TerminalCopyTestMouseListener; readonly type: TerminalCopyTestMouseType } -type TerminalCopyTestAnyListener = TerminalCopyTestCopyListener | TerminalCopyTestMouseListener - -const isCopyTestListener = ( - type: TerminalCopyTestEventType, - _listener: TerminalCopyTestAnyListener -): _listener is TerminalCopyTestCopyListener => type === "copy" - -const isMouseTestListener = ( - type: TerminalCopyTestEventType, - _listener: TerminalCopyTestAnyListener -): _listener is TerminalCopyTestMouseListener => type !== "copy" - -const isMouseTestEventType = ( - type: TerminalCopyTestEventType -): type is TerminalCopyTestMouseType => type !== "copy" - -const isMouseTestListenerEntry = ( - entry: TerminalCopyTestListener -): entry is { readonly listener: TerminalCopyTestMouseListener; readonly type: TerminalCopyTestMouseType } => - entry.type !== "copy" - -class FakeTerminalCopyEventTarget { - private listeners: Array = [] - - addEventListener(type: "copy", listener: TerminalCopyTestCopyListener, options: true): void - addEventListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener, options: true): void - addEventListener( - type: TerminalCopyTestEventType, - listener: TerminalCopyTestAnyListener, - _options: true - ): void { - if (isCopyTestListener(type, listener)) { - this.listeners.push({ listener, type: "copy" }) - return - } - if (isMouseTestEventType(type) && isMouseTestListener(type, listener)) { - this.listeners.push({ listener, type }) - } - } - - removeEventListener(type: "copy", listener: TerminalCopyTestCopyListener, options: true): void - removeEventListener(type: TerminalCopyTestMouseType, listener: TerminalCopyTestMouseListener, options: true): void - removeEventListener( - type: TerminalCopyTestEventType, - listener: TerminalCopyTestAnyListener, - _options: true - ): void { - this.listeners = this.listeners.filter((entry) => entry.type !== type || entry.listener !== listener) - } - - dispatchMouse(type: TerminalCopyTestMouseType, event: TerminalCopyTestMouseEvent): void { - for (const entry of this.listeners) { - if (isMouseTestListenerEntry(entry) && entry.type === type) { - entry.listener(event) - } - } - } - - listenerCount(type: TerminalCopyTestEventType): number { - return this.listeners.filter((entry) => entry.type === type).length - } -} - -class FakeTerminalCopyHost extends FakeTerminalCopyEventTarget { - readonly ownerDocument: FakeTerminalCopyEventTarget | null - - constructor(ownerDocument: FakeTerminalCopyEventTarget | null) { - super() - this.ownerDocument = ownerDocument - } -} - -const mouseEvent = (button: number): TerminalCopyTestMouseEvent => ({ - altKey: false, - button, - shiftKey: false -}) - -const expectNoDragListeners = (target: FakeTerminalCopyEventTarget): void => { - expect(target.listenerCount("mousemove")).toBe(0) - expect(target.listenerCount("mouseup")).toBe(0) -} - describe("terminal copy interaction", () => { it("forces browser selection for primary mouse input while terminal mouse tracking is active", () => { expect(shouldForceBrowserTerminalSelection({ button: 0 }, terminalWithSelection("any", ""))).toBe(true) @@ -203,6 +105,73 @@ describe("terminal copy interaction", () => { disposable.dispose() }) + it("suppresses real mouseup reports and replays a document mouseup for selection finalization", () => { + const documentTarget = new FakeTerminalCopyEventTarget() + const host = new FakeTerminalCopyHost(documentTarget) + const finalizedSelectionEvents: Array = [] + const mouseReportEvents: Array = [] + documentTarget.addBubbleMouseListener("mouseup", (event) => { + finalizedSelectionEvents.push(event) + }) + host.addBubbleMouseListener("mouseup", (event) => { + mouseReportEvents.push(event) + }) + const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "") }) + const up = mouseEvent(0, "mouseup", { + clientX: 12, + clientY: 34, + screenX: 56, + screenY: 78 + }) + + host.dispatchMouse("mousedown", mouseEvent(0)) + host.dispatchBubblingMouse("mouseup", up) + + expect(up.shiftKey).toBe(true) + expect(up.preventDefaultCalls).toBe(1) + expect(up.stopImmediatePropagationCalls).toBe(1) + expect(up.stopPropagationCalls).toBeGreaterThanOrEqual(1) + expect(mouseReportEvents).toEqual([]) + expect(documentTarget.dispatchedEvents).toHaveLength(1) + + const replayed = expectSingleMouseEvent(finalizedSelectionEvents) + expect(replayed).not.toBe(up) + expect(replayed.button).toBe(0) + expect(replayed.buttons).toBe(0) + expect(replayed.clientX).toBe(12) + expect(replayed.clientY).toBe(34) + expect(replayed.screenX).toBe(56) + expect(replayed.screenY).toBe(78) + expect(replayed.shiftKey).toBe(true) + expect(replayed.altKey).toBe(false) + expectNoDragListeners(documentTarget) + + disposable.dispose() + }) + + it("does not suppress or replay mouseup when mouse tracking is inactive", () => { + const documentTarget = new FakeTerminalCopyEventTarget() + const host = new FakeTerminalCopyHost(documentTarget) + const mouseReportEvents: Array = [] + host.addBubbleMouseListener("mouseup", (event) => { + mouseReportEvents.push(event) + }) + const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("none", "") }) + const up = mouseEvent(0, "mouseup") + + host.dispatchMouse("mousedown", mouseEvent(0)) + host.dispatchBubblingMouse("mouseup", up) + + expect(up.shiftKey).toBe(false) + expect(up.preventDefaultCalls).toBe(0) + expect(up.stopImmediatePropagationCalls).toBe(0) + expect(up.stopPropagationCalls).toBe(0) + expect(documentTarget.dispatchedEvents).toEqual([]) + expect(mouseReportEvents).toEqual([up]) + + disposable.dispose() + }) + it("does not start a forced selection drag when mouse tracking is inactive", () => { const documentTarget = new FakeTerminalCopyEventTarget() const host = new FakeTerminalCopyHost(documentTarget) @@ -229,6 +198,7 @@ describe("terminal copy interaction", () => { expect(down.shiftKey).toBe(true) expect(move.shiftKey).toBe(false) + expect(documentTarget.dispatchedEvents).toEqual([]) expectNoDragListeners(documentTarget) disposable.dispose()