+
{status}
-
diff --git a/packages/app/src/web/terminal-copy-interaction.ts b/packages/app/src/web/terminal-copy-interaction.ts
index 4899acf0..c9f9ea83 100644
--- a/packages/app/src/web/terminal-copy-interaction.ts
+++ b/packages/app/src/web/terminal-copy-interaction.ts
@@ -32,15 +32,33 @@ type TerminalCopyClipboardEvent = {
type TerminalCopyMouseEvent = TerminalMouseButtonEvent & TerminalSelectionModifierEvent
+type TerminalSelectionDragEventType = "mousemove" | "mouseup"
+
+type TerminalSelectionDragListenerRegistration = (
+ type: TerminalSelectionDragEventType,
+ listener: (event: TerminalSelectionModifierEvent) => void,
+ options: true
+) => void
+
+type TerminalSelectionDragTarget = {
+ readonly addEventListener: TerminalSelectionDragListenerRegistration
+ 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 TerminalCopyInteractionHost = {
- readonly addEventListener: {
- (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void
- (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void
- }
- readonly removeEventListener: {
- (type: "copy", listener: (event: TerminalCopyClipboardEvent) => void, options: true): void
- (type: "mousedown", listener: (event: TerminalCopyMouseEvent) => void, options: true): void
- }
+ readonly ownerDocument?: TerminalSelectionDragTarget | null
+ readonly addEventListener: TerminalCopyListenerRegistration
+ readonly removeEventListener: TerminalCopyListenerRegistration
}
type TerminalCopyInteractionArgs = {
@@ -48,6 +66,11 @@ type TerminalCopyInteractionArgs = {
readonly terminal: TerminalCopyInteractionTerminal
}
+type TerminalSelectionDragController = {
+ readonly dispose: () => void
+ readonly start: () => void
+}
+
const primaryMouseButton = 0
const secondaryMouseButton = 2
@@ -104,17 +127,70 @@ export const writeTerminalSelectionToClipboardData = (
return true
}
+const resolveTerminalSelectionDragTarget = (
+ host: TerminalCopyInteractionHost
+): TerminalSelectionDragTarget => host.ownerDocument ?? host
+
+const createTerminalSelectionDragController = (
+ host: TerminalCopyInteractionHost
+): TerminalSelectionDragController => {
+ let forcedSelectionDrag = false
+ let selectionDragTarget: TerminalSelectionDragTarget | null = null
+
+ const clearSelectionDrag = (): void => {
+ if (selectionDragTarget === null) {
+ forcedSelectionDrag = false
+ return
+ }
+ selectionDragTarget.removeEventListener("mousemove", onMouseMove, true)
+ selectionDragTarget.removeEventListener("mouseup", onMouseUp, true)
+ selectionDragTarget = null
+ forcedSelectionDrag = false
+ }
+
+ const onMouseMove = (event: TerminalSelectionModifierEvent): void => {
+ if (!forcedSelectionDrag) {
+ return
+ }
+ forceTerminalSelectionModifier(event)
+ }
+
+ const onMouseUp = (event: TerminalSelectionModifierEvent): void => {
+ if (forcedSelectionDrag) {
+ forceTerminalSelectionModifier(event)
+ }
+ clearSelectionDrag()
+ }
+
+ const startSelectionDrag = (): void => {
+ clearSelectionDrag()
+ forcedSelectionDrag = true
+ selectionDragTarget = resolveTerminalSelectionDragTarget(host)
+ selectionDragTarget.addEventListener("mousemove", onMouseMove, true)
+ selectionDragTarget.addEventListener("mouseup", onMouseUp, true)
+ }
+
+ return {
+ dispose: clearSelectionDrag,
+ start: startSelectionDrag
+ }
+}
+
export const attachTerminalCopyInteraction = (
args: TerminalCopyInteractionArgs
): { readonly dispose: () => void } => {
+ const selectionDrag = createTerminalSelectionDragController(args.host)
+
const onMouseDown = (event: TerminalCopyMouseEvent): void => {
- if (
- !shouldForceBrowserTerminalSelection(event, args.terminal) &&
- !shouldForceTerminalSelectionContext(event, args.terminal)
- ) {
+ const forceBrowserSelection = shouldForceBrowserTerminalSelection(event, args.terminal)
+ const forceSelectionContext = shouldForceTerminalSelectionContext(event, args.terminal)
+ if (!forceBrowserSelection && !forceSelectionContext) {
return
}
forceTerminalSelectionModifier(event)
+ if (forceBrowserSelection) {
+ selectionDrag.start()
+ }
}
const onCopy = (event: TerminalCopyClipboardEvent): void => {
if (!writeTerminalSelectionToClipboardData(args.terminal, event.clipboardData)) {
@@ -129,6 +205,7 @@ export const attachTerminalCopyInteraction = (
return {
dispose: () => {
+ selectionDrag.dispose()
args.host.removeEventListener("mousedown", onMouseDown, true)
args.host.removeEventListener("copy", onCopy, true)
}
diff --git a/packages/app/tests/docker-git/panel-terminal-skiller.test.ts b/packages/app/tests/docker-git/panel-terminal-skiller.test.ts
index 6ff7a520..cc932284 100644
--- a/packages/app/tests/docker-git/panel-terminal-skiller.test.ts
+++ b/packages/app/tests/docker-git/panel-terminal-skiller.test.ts
@@ -56,6 +56,17 @@ const renderTerminalPanel = (overrides: TerminalPanelRenderOverrides = {}): stri
}))
describe("TerminalPanel Skiller action", () => {
+ it("keeps desktop terminal metadata and actions in separate rows", () => {
+ const html = renderTerminalPanel()
+
+ expect(html).toContain("align-items:stretch")
+ expect(html).toContain("flex-direction:column")
+ expect(html).toContain("width:100%")
+ expect(html).toContain("text-overflow:ellipsis")
+ expect(html).toContain("white-space:nowrap")
+ expect(html).toContain(session.subtitle)
+ })
+
it("renders Skiller in the project terminal header action row", () => {
const html = renderTerminalPanel()
@@ -83,6 +94,7 @@ describe("TerminalPanel Skiller action", () => {
it("uses a compact image preview toggle label in compact terminal headers", () => {
const html = renderTerminalPanel({ mobileMode: true })
+ expect(html).toContain("flex-direction:row")
expect(html).toContain("Img on")
expect(html).not.toContain("Images on")
})
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 4a8f8db6..a4485e47 100644
--- a/packages/app/tests/docker-git/terminal-copy-interaction.test.ts
+++ b/packages/app/tests/docker-git/terminal-copy-interaction.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from "@effect/vitest"
import {
+ attachTerminalCopyInteraction,
forceTerminalSelectionModifier,
shouldForceBrowserTerminalSelection,
shouldForceTerminalSelectionContext,
@@ -18,6 +19,112 @@ 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)
@@ -71,4 +178,90 @@ describe("terminal copy interaction", () => {
expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", ""), clipboardData)).toBe(false)
expect(writeTerminalSelectionToClipboardData(terminalWithSelection("any", "selected"), null)).toBe(false)
})
+
+ it("forces the selection modifier through the full primary-button drag", () => {
+ const documentTarget = new FakeTerminalCopyEventTarget()
+ const host = new FakeTerminalCopyHost(documentTarget)
+ const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "") })
+ const down = mouseEvent(0)
+ const move = mouseEvent(0)
+ const up = mouseEvent(0)
+
+ host.dispatchMouse("mousedown", down)
+ documentTarget.dispatchMouse("mousemove", move)
+ documentTarget.dispatchMouse("mouseup", up)
+
+ expect(down.shiftKey).toBe(true)
+ expect(move.shiftKey).toBe(true)
+ expect(up.shiftKey).toBe(true)
+ expectNoDragListeners(documentTarget)
+
+ const afterReleaseMove = mouseEvent(0)
+ documentTarget.dispatchMouse("mousemove", afterReleaseMove)
+ expect(afterReleaseMove.shiftKey).toBe(false)
+
+ disposable.dispose()
+ })
+
+ it("does not start a forced selection drag when mouse tracking is inactive", () => {
+ const documentTarget = new FakeTerminalCopyEventTarget()
+ const host = new FakeTerminalCopyHost(documentTarget)
+ const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("none", "") })
+ const down = mouseEvent(0)
+
+ host.dispatchMouse("mousedown", down)
+
+ expect(down.shiftKey).toBe(false)
+ expectNoDragListeners(documentTarget)
+
+ disposable.dispose()
+ })
+
+ it("keeps right-click selection handling one-shot", () => {
+ const documentTarget = new FakeTerminalCopyEventTarget()
+ const host = new FakeTerminalCopyHost(documentTarget)
+ const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("any", "selected") })
+ const down = mouseEvent(2)
+ const move = mouseEvent(0)
+
+ host.dispatchMouse("mousedown", down)
+ documentTarget.dispatchMouse("mousemove", move)
+
+ expect(down.shiftKey).toBe(true)
+ expect(move.shiftKey).toBe(false)
+ expectNoDragListeners(documentTarget)
+
+ disposable.dispose()
+ })
+
+ it("falls back to host drag listeners when ownerDocument is unavailable", () => {
+ const host = new FakeTerminalCopyHost(null)
+ const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("drag", "") })
+ const move = mouseEvent(0)
+
+ host.dispatchMouse("mousedown", mouseEvent(0))
+ host.dispatchMouse("mousemove", move)
+
+ expect(move.shiftKey).toBe(true)
+
+ disposable.dispose()
+ expectNoDragListeners(host)
+ })
+
+ it("removes active drag listeners during dispose", () => {
+ const documentTarget = new FakeTerminalCopyEventTarget()
+ const host = new FakeTerminalCopyHost(documentTarget)
+ const disposable = attachTerminalCopyInteraction({ host, terminal: terminalWithSelection("vt200", "") })
+
+ host.dispatchMouse("mousedown", mouseEvent(0))
+ disposable.dispose()
+
+ const move = mouseEvent(0)
+ documentTarget.dispatchMouse("mousemove", move)
+
+ expect(move.shiftKey).toBe(false)
+ expect(host.listenerCount("mousedown")).toBe(0)
+ expect(host.listenerCount("copy")).toBe(0)
+ expectNoDragListeners(documentTarget)
+ })
})