diff --git a/packages/app/src/web/panel-terminal.tsx b/packages/app/src/web/panel-terminal.tsx index 8bbbb0c2..3d79f1ad 100644 --- a/packages/app/src/web/panel-terminal.tsx +++ b/packages/app/src/web/panel-terminal.tsx @@ -51,17 +51,20 @@ const terminalPanelStyle = (mobileMode: boolean, keyboardOpen: boolean): CSSProp }) const headerStyle: CSSProperties = { - alignItems: "center", + alignItems: "stretch", background: "#101419", borderBottom: "1px solid #3a4652", display: "flex", - gap: "12px", + flexDirection: "column", + gap: "8px", justifyContent: "flex-start", padding: "10px 12px" } const compactHeaderStyle: CSSProperties = { ...headerStyle, + alignItems: "center", + flexDirection: "row", flexWrap: "wrap", gap: "6px", overflow: "visible", @@ -139,14 +142,17 @@ const headerActionsStyle: CSSProperties = { flexShrink: 0, flexWrap: "wrap", gap: "8px", - justifyContent: "flex-end", - marginLeft: "auto" + justifyContent: "flex-start", + width: "100%" } const compactHeaderActionsStyle: CSSProperties = { ...headerActionsStyle, flexWrap: "wrap", - gap: "4px" + gap: "4px", + justifyContent: "flex-end", + marginLeft: "auto", + width: "auto" } const mobileControlsCollapsedStyle: CSSProperties = { @@ -225,6 +231,27 @@ const compactStatusStyle = (status: TerminalStatus): CSSProperties => ({ whiteSpace: "nowrap" }) +const headerTitleStyle: CSSProperties = { + color: "#f6fbff", + fontWeight: 700, + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + +const headerStatusStyle = (status: TerminalStatus): CSSProperties => ({ + color: statusColor(status), + whiteSpace: "nowrap" +}) + +const headerSubtitleStyle: CSSProperties = { + color: "#8fa6c4", + fontSize: "12px", + overflow: "hidden", + textOverflow: "ellipsis", + whiteSpace: "nowrap" +} + const resolveInitialTerminalStatus = (session: ActiveTerminalSession): TerminalStatus => isPendingActiveTerminalSession(session) && session.pendingConnection.phase === "error" ? "error" : "connecting" @@ -248,14 +275,14 @@ const TerminalHeaderTitle = ( ) : ( -
-
+
+
{session.header}
-
+
{status}
-
+
{session.subtitle}
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) + }) })