From a001c6fff32bb6ab46f0687ff15f9715f2e897b7 Mon Sep 17 00:00:00 2001 From: asd880921 Date: Fri, 16 Oct 2026 21:33:00 +0800 Subject: [PATCH 1/3] feat: Drag panes onto tabs to move them or break them into a new tab --- FEATURES.md | 3 + scripts/check-web-assets.mjs | 2 +- web/src/browserNavigation.test.ts | 86 ++++++ web/src/components/PaneMoveMenu.css | 43 ++- web/src/components/PaneMoveMenu.test.ts | 169 ++++++++++- web/src/components/PaneMoveMenu.tsx | 274 ++++++++++++++---- web/src/components/TabBar.tsx | 1 + .../components/TerminalView.inputMode.test.ts | 1 + web/src/paneDrop.test.ts | 21 ++ web/src/paneDrop.ts | 21 ++ web/src/store.ts | 86 ++++++ 11 files changed, 644 insertions(+), 63 deletions(-) create mode 100644 web/src/paneDrop.test.ts create mode 100644 web/src/paneDrop.ts diff --git a/FEATURES.md b/FEATURES.md index e3d1ea81..92da712f 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -13,6 +13,9 @@ A Web/PWA client for a running [Herdr](https://herdr.dev) server. as you type. Leave the field blank to use Herdr's default directory. - Split, resize, focus, swap (drag the handle at a pane's top center), zoom, and close panes. +- Drag a pane's handle onto another tab to move the pane there, or onto **+** + to give it a tab of its own. Hold it over a tab to open that tab, then drop + it on a pane's edge to place it on that side. Running programs keep going. - The command menu searches workspaces, worktrees, files, tabs, panes, and agents, and opens typed file paths. - Desktop **Zen mode** hides app chrome so the terminal fills the window. diff --git a/scripts/check-web-assets.mjs b/scripts/check-web-assets.mjs index 41eafca3..e071ac55 100644 --- a/scripts/check-web-assets.mjs +++ b/scripts/check-web-assets.mjs @@ -6,7 +6,7 @@ import { fileURLToPath, URL } from "node:url"; const publicRoot = fileURLToPath(new URL("../server/public/", import.meta.url)); const maxFileCount = 160; const maxTotalBytes = 14 * 1024 * 1024; -const maxInitialJsBytes = 802 * 1024; +const maxInitialJsBytes = 804 * 1024; const maxInitialJsGzipBytes = 256 * 1024; const maxInitialCssBytes = 196 * 1024; diff --git a/web/src/browserNavigation.test.ts b/web/src/browserNavigation.test.ts index e6be65ac..6f211b2b 100644 --- a/web/src/browserNavigation.test.ts +++ b/web/src/browserNavigation.test.ts @@ -525,6 +525,92 @@ describe("store browser-local navigation", () => { }); }); + test.each([ + ["right", "right", false], + ["down", "down", false], + ["left", "right", true], + ["up", "down", true], + ] as const)( + "a pane placed %s of a pane in another tab splits %s (swap: %s)", + async (side, split, swaps) => { + await withBrowserStore(async (calls, topology) => { + const originalConnection = bridge.connection; + bridge.connection = (connectionId, generation) => { + const client = originalConnection(connectionId, generation); + return { + ...client, + call: async (method, params) => { + const result = await client.call(method, params); + return method === "pane.move" + ? { + move_result: { + pane: { ...topology.panes[0], tab_id: "a2" }, + }, + } + : result; + }, + }; + }; + await store.movePaneBeside("a1p", "a2p", side); + expect( + calls + .filter( + (call) => + call.method.startsWith("pane.") && + call.method !== "pane.list" && + call.method !== "pane.layout", + ) + .map(({ method, params }) => [method, params]), + ).toEqual([ + [ + "pane.move", + { + pane_id: "a1p", + destination: { + type: "tab", + tab_id: "a2", + split, + target_pane_id: "a2p", + }, + focus: false, + }, + ], + ...(swaps + ? [["pane.swap", { source_pane_id: "a1p", target_pane_id: "a2p" }]] + : []), + ]); + expect(store.get().browserNavigation.tabIds.a).toBe("a2"); + }); + }, + ); + + test("pane moves ignore same-tab and cross-workspace targets and lone panes", async () => { + await withBrowserStore(async (calls) => { + for (const [source, target] of [ + ["a1p", "a1q"], + ["a1p", "missing"], + ["missing", "a2p"], + ["a1p", "b1p"], + ]) + await store.movePaneBeside(source, target, "right"); + await store.movePaneToNewTab("a2p"); + await store.movePaneToNewTab("missing"); + expect(calls.filter((call) => call.method === "pane.move")).toEqual([]); + await store.movePaneToNewTab("a1p"); + expect( + calls + .filter((call) => call.method === "pane.move") + .map((call) => call.params), + ).toEqual([ + { + pane_id: "a1p", + destination: { type: "new_tab", workspace_id: "a" }, + focus: false, + }, + ]); + }); + }); + test.each(["workspace", "tab", "agent"])( "%s selection renders the target pane while its layout is deferred", async (route) => { diff --git a/web/src/components/PaneMoveMenu.css b/web/src/components/PaneMoveMenu.css index e10e2dc7..821a2394 100644 --- a/web/src/components/PaneMoveMenu.css +++ b/web/src/components/PaneMoveMenu.css @@ -37,11 +37,14 @@ inset: -5px; } -.pane-layout-cell .pane-drag-handle { +.pane-layout-cell .pane-drag-handle, +.workspace-terminal-surface > .terminal-shell > .pane-drag-handle { display: inline-flex; } +/* A tab's only pane renders without the grid but can still move to a tab. */ .pane-layout-cell:hover .pane-drag-handle, +.workspace-terminal-surface > .terminal-shell:hover > .pane-drag-handle, .pane-drag-handle:focus-visible, .pane-layout-cell.is-swap-source .pane-drag-handle { opacity: 1; @@ -58,8 +61,8 @@ transform: translate(-50%, 0) scale(0.95); } -.pane-layout.is-swapping, -.pane-layout.is-swapping * { +.is-pane-dragging, +.is-pane-dragging * { cursor: grabbing !important; user-select: none; } @@ -74,7 +77,8 @@ } .pane-layout-cell.is-swap-source::after, -.pane-layout-cell.is-swap-target::after { +.pane-layout-cell.is-swap-target::after, +.is-place-target::after { content: ""; position: absolute; inset: 3px; @@ -95,7 +99,32 @@ animation: pane-swap-target-in 140ms ease-out; } -.pane-layout-cell.is-swap-target::before { +/* A pane dragged in from another tab lands on the highlighted half. */ +.is-place-target[data-drop-side="left"]::after { + right: 50%; +} + +.is-place-target[data-drop-side="right"]::after { + left: 50%; +} + +.is-place-target[data-drop-side="up"]::after { + bottom: 50%; +} + +.is-place-target[data-drop-side="down"]::after { + top: 50%; +} + +.tabbar .tabbar-tab.is-pane-drop-target, +.tabbar .tabbar-add.is-pane-drop-target { + border-color: var(--accent); + background: var(--accent-soft); + color: var(--text-strong); +} + +.pane-layout-cell.is-swap-target::before, +.is-place-target::before { content: "Swap here"; position: absolute; top: 14px; @@ -110,6 +139,10 @@ pointer-events: none; } +.is-place-target::before { + content: "Place here"; +} + .pane-move-preview { position: fixed; top: 0; diff --git a/web/src/components/PaneMoveMenu.test.ts b/web/src/components/PaneMoveMenu.test.ts index afc7c9d6..f9ffdf25 100644 --- a/web/src/components/PaneMoveMenu.test.ts +++ b/web/src/components/PaneMoveMenu.test.ts @@ -1,10 +1,18 @@ -import { afterAll, afterEach, beforeAll, expect, spyOn, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + expect, + jest, + spyOn, + test, +} from "bun:test"; import { Window } from "happy-dom"; import { act, createElement } from "react"; import { createRoot, type Root } from "react-dom/client"; import { store } from "../store"; import { terminalFocusBlockedByOverlay } from "../terminalFocus"; -import type { PaneLayout } from "../types"; +import type { Pane, PaneLayout } from "../types"; import { PaneDragHandle, PaneMoveMenu } from "./PaneMoveMenu"; // DOM globals must not leak into the other store and terminal tests. @@ -131,7 +139,7 @@ function registerDomTests() { ), ), ); - button = source.querySelector('[aria-label="Drag pane to swap"]')!; + button = source.querySelector('[aria-label="Drag pane"]')!; menuButton = source.querySelector('[aria-label="Move pane"]')!; let captured = false; button.setPointerCapture = () => { @@ -146,9 +154,10 @@ function registerDomTests() { type: string, x: number, options: ConstructorParameters[1] = {}, + on: EventTarget = button, ) { await act(async () => - button.dispatchEvent( + on.dispatchEvent( new browser.PointerEvent(type, { bubbles: true, cancelable: true, @@ -178,7 +187,9 @@ function registerDomTests() { function clean() { expect(document.querySelector(".pane-move-preview")).toBeNull(); expect(grid.querySelector(".is-swap-target, .is-swap-source")).toBeNull(); - expect(grid.classList.contains("is-swapping")).toBe(false); + expect( + document.documentElement.classList.contains("is-pane-dragging"), + ).toBe(false); expect(button.hasPointerCapture(1)).toBe(false); } @@ -202,7 +213,7 @@ function registerDomTests() { expect(preview.classList.contains("is-ready")).toBe(true); hit = button; await pointer("pointermove", 160); - expect(preview.textContent).toBe("Pane 1Drag onto another pane"); + expect(preview.textContent).toBe("Pane 1Drag onto a pane or tab"); expect(preview.classList.contains("is-ready")).toBe(false); expect(target.classList.contains("is-swap-target")).toBe(false); hit = target; @@ -404,4 +415,150 @@ function registerDomTests() { await pointer("pointerup", 120); expect(swaps).toEqual([]); }); + + function pane(pane_id: string, tab_id: string, focused = false) { + return { pane_id, tab_id, focused, workspace_id: "workspace" } as Pane; + } + function tabStrip() { + const strip = document.createElement("div"); + strip.className = "tabbar-tabs"; + const tab = document.createElement("div"); + tab.className = "tabbar-tab"; + tab.dataset.tabId = "other"; + const name = document.createElement("span"); + name.className = "tabbar-name"; + name.textContent = "Tab 2"; + tab.append(name); + const add = document.createElement("button"); + add.className = "tabbar-add"; + strip.append(tab, add); + document.body.append(strip); + restore.push(() => strip.remove()); + return { tab, add }; + } + function spyMoves() { + const moves: unknown[][] = []; + const beside = spyOn(store, "movePaneBeside").mockImplementation( + async (...args) => { + moves.push(["beside", ...args]); + }, + ); + const newTab = spyOn(store, "movePaneToNewTab").mockImplementation( + async (...args) => { + moves.push(["new-tab", ...args]); + }, + ); + const focus = spyOn(store, "focusTab").mockImplementation( + async (...args) => { + moves.push(["focus", ...args]); + }, + ); + restore.push( + () => beside.mockRestore(), + () => newTab.mockRestore(), + () => focus.mockRestore(), + ); + return moves; + } + + test("dropping on another tab or the new-tab button moves the pane there", async () => { + await mount(); + snapshot = { + ...snapshot, + panes: [pane("a", "tab"), pane("y", "other"), pane("x", "other", true)], + }; + const moves = spyMoves(); + const { tab, add } = tabStrip(); + const preview = () => + document.querySelector(".pane-move-preview")!.textContent; + await pointer("pointerdown", 10); + hit = tab; + await pointer("pointermove", 120); + expect(tab.classList.contains("is-pane-drop-target")).toBe(true); + expect(preview()).toBe( + "Pane 1Release to move to Tab 2, or hold to open it", + ); + await pointer("pointerup", 120); + expect(tab.classList.contains("is-pane-drop-target")).toBe(false); + clean(); + await pointer("pointerdown", 10); + hit = add; + await pointer("pointermove", 120); + expect(add.classList.contains("is-pane-drop-target")).toBe(true); + expect(preview()).toBe("Pane 1Release to move to a new tab"); + await pointer("pointerup", 120); + clean(); + expect(moves).toEqual([ + ["beside", "a", "x", "right"], + ["new-tab", "a"], + ]); + expect(swaps).toEqual([]); + }); + + test("hovering another tab opens it and the pane lands beside a pane there", async () => { + await mount(); + snapshot = { + ...snapshot, + panes: [pane("a", "tab"), pane("x", "other", true)], + }; + const moves = spyMoves(); + const { tab } = tabStrip(); + jest.useFakeTimers(); + try { + await pointer("pointerdown", 10); + hit = tab; + await pointer("pointermove", 120); + jest.advanceTimersByTime(599); + expect(moves).toEqual([]); + jest.advanceTimersByTime(1); + expect(moves).toEqual([["focus", "other"]]); + // Opening the tab replaces the layout and unmounts the source handle. + snapshot = { + ...snapshot, + layout: { + ...layout, + tab_id: "other", + panes: [{ ...layout.panes[2], pane_id: "x" }], + }, + }; + target.dataset.paneId = "x"; + target.getBoundingClientRect = () => + new browser.DOMRect(0, 0, 100, 100) as unknown as DOMRect; + await act(async () => root.unmount()); + expect(document.querySelector(".pane-move-preview")).not.toBeNull(); + hit = target; + await pointer("pointermove", 90, { clientY: 50 }, document.body); + expect(target.classList.contains("is-place-target")).toBe(true); + expect(target.dataset.dropSide).toBe("right"); + await pointer("pointermove", 50, { clientY: 5 }, document.body); + expect(target.dataset.dropSide).toBe("up"); + expect(document.querySelector(".pane-move-preview")!.textContent).toBe( + "Pane 1Release to place above", + ); + // A tab's only pane renders without the grid, so its shell is the target. + const surface = document.createElement("div"); + surface.className = "workspace-terminal-surface"; + const shell = document.createElement("div"); + shell.className = "terminal-shell"; + shell.getBoundingClientRect = target.getBoundingClientRect; + surface.append(shell); + document.body.append(surface); + restore.push(() => surface.remove()); + hit = shell; + await pointer("pointermove", 10, { clientY: 50 }, document.body); + expect(target.classList.contains("is-place-target")).toBe(false); + expect(shell.classList.contains("is-place-target")).toBe(true); + expect(shell.dataset.dropSide).toBe("left"); + await pointer("pointerup", 10, { clientY: 50 }, document.body); + expect(shell.classList.contains("is-place-target")).toBe(false); + expect(shell.dataset.dropSide).toBeUndefined(); + expect(document.querySelector(".pane-move-preview")).toBeNull(); + expect(moves).toEqual([ + ["focus", "other"], + ["beside", "a", "x", "left"], + ]); + } finally { + jest.useRealTimers(); + } + }); } diff --git a/web/src/components/PaneMoveMenu.tsx b/web/src/components/PaneMoveMenu.tsx index 594793e9..502d25e4 100644 --- a/web/src/components/PaneMoveMenu.tsx +++ b/web/src/components/PaneMoveMenu.tsx @@ -8,8 +8,9 @@ import { } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { browserPaneInDirection } from "../browserNavigation"; +import { nearestPaneSide } from "../paneDrop"; import type { PaneLayout } from "../types"; -import { store } from "../store"; +import { type PaneSide, store, useStoreSelector } from "../store"; import "./PaneMoveMenu.css"; type Direction = "left" | "right" | "up" | "down"; @@ -25,7 +26,44 @@ const DIRECTIONS: { { direction: "down", label: "Move pane down", Icon: ArrowDown }, ]; -/** A hover-revealed handle for swapping visible panes, with keyboard moves. */ +/** How long a dragged pane hovers over a tab before that tab opens. */ +const TAB_OPEN_DELAY_MS = 600; + +const SIDE_LABELS: Record = { + left: "on the left", + right: "on the right", + up: "above", + down: "below", +}; + +type DropTarget = + | { kind: "swap"; element: HTMLElement; paneId: string } + | { kind: "place"; element: HTMLElement; paneId: string; side: PaneSide } + | { kind: "tab"; element: HTMLElement; tabId: string } + | { kind: "new-tab"; element: HTMLElement }; + +function sameTarget(a: DropTarget | null, b: DropTarget | null) { + if (!a || !b) return a === b; + return ( + a.kind === b.kind && + a.element === b.element && + (a.kind !== "place" || b.kind !== "place" || a.side === b.side) + ); +} + +function paneCell(paneId: string) { + for (const cell of document.querySelectorAll( + ".pane-layout > .pane-layout-cell", + )) + if (cell.dataset.paneId === paneId) return cell; + return null; +} + +/** + * A hover-revealed handle for rearranging panes, with keyboard moves. Dropping + * on a pane in the same tab swaps them; dropping on a tab moves the pane there, + * and hovering a tab opens it so the pane can be placed beside one of its panes. + */ export function PaneDragHandle({ paneId, layout, @@ -35,58 +73,166 @@ export function PaneDragHandle({ layout: PaneLayout | null; onPointerDown: (e: React.PointerEvent) => void; }) { - const cancelDrag = useRef<(() => void) | null>(null); - useEffect(() => () => cancelDrag.current?.(), []); + const drag = useRef<{ cancel: () => void; leftTab: boolean } | null>(null); + // Opening another tab unmounts this handle, so only an in-tab drag ends here. + useEffect( + () => () => { + if (!drag.current?.leftTab) drag.current?.cancel(); + }, + [], + ); + const otherTabs = useStoreSelector( + (state) => + state.tabs.filter( + (tab) => + tab.workspace_id === layout?.workspace_id && + tab.tab_id !== layout?.tab_id, + ).length, + ); const startDrag = (e: React.PointerEvent) => { onPointerDown(e); - if (!e.isPrimary || e.button !== 0 || cancelDrag.current) return; + if (!e.isPrimary || e.button !== 0 || drag.current) return; const button = e.currentTarget; - const source = button.closest(".pane-layout-cell"); - const grid = source?.closest(".pane-layout"); - if (!source || !grid || !layout || layout.zoomed) return; + if (!layout || layout.zoomed) return; const initial = store.get(); + const sourceTabId = layout.tab_id; const { pointerId, clientX: startX, clientY: startY } = e; let dragging = false; - let target: HTMLElement | null = null; + let target: DropTarget | null = null; + let sourceCell: HTMLElement | null = null; let preview: HTMLDivElement | null = null; let hint: HTMLSpanElement | null = null; + let openTabTimer: ReturnType | null = null; + let openTabId: string | undefined; + const session = { cancel: () => cleanup(), leftTab: false }; const currentSession = () => { const current = store.get(); - return ( - current.activeConnectionId === initial.activeConnectionId && - current.connectionGeneration === initial.connectionGeneration && - current.serverRuntimeGeneration === initial.serverRuntimeGeneration && - current.layout?.tab_id === layout.tab_id && - !current.layout.zoomed && - current.layout.panes.some((pane) => pane.pane_id === paneId) - ); + if ( + current.activeConnectionId !== initial.activeConnectionId || + current.connectionGeneration !== initial.connectionGeneration || + current.serverRuntimeGeneration !== initial.serverRuntimeGeneration + ) + return false; + // The source tab may be in the background after another tab opened. + return current.layout?.tab_id === sourceTabId + ? !current.layout.zoomed && + current.layout.panes.some((pane) => pane.pane_id === paneId) + : session.leftTab && + current.panes.some( + (pane) => pane.pane_id === paneId && pane.tab_id === sourceTabId, + ); }; - const findTarget = (event: PointerEvent) => { - const cell = document - .elementFromPoint(event.clientX, event.clientY) - ?.closest(".pane-layout-cell"); - return cell && - cell !== source && - cell.parentElement === grid && - store - .get() - .layout?.panes.some((pane) => pane.pane_id === cell.dataset.paneId) - ? cell + const sourceTabPaneCount = () => { + const current = store.get(); + return current.layout?.tab_id === sourceTabId + ? current.layout.panes.length + : current.panes.filter((pane) => pane.tab_id === sourceTabId).length; + }; + const findTarget = (event: PointerEvent): DropTarget | null => { + const hit = document.elementFromPoint(event.clientX, event.clientY); + const current = store.get().layout; + let cell = hit?.closest(".pane-layout-cell"); + let cellPaneId = cell?.dataset.paneId; + if (!cell?.parentElement?.classList.contains("pane-layout")) { + // A tab's only pane renders without the grid. + cell = hit?.closest( + ".workspace-terminal-surface > .terminal-shell", + ); + cellPaneId = + current?.panes.length === 1 ? current.panes[0].pane_id : undefined; + } + if ( + cell && + cellPaneId && + cellPaneId !== paneId && + current?.panes.some((pane) => pane.pane_id === cellPaneId) + ) { + if (current.tab_id === sourceTabId) + return { kind: "swap", element: cell, paneId: cellPaneId }; + return { + kind: "place", + element: cell, + paneId: cellPaneId, + side: nearestPaneSide( + cell.getBoundingClientRect(), + event.clientX, + event.clientY, + ), + }; + } + const tab = hit?.closest(".tabbar-tab[data-tab-id]"); + if (tab) { + // Hovering the source tab still opens it, but dropping there is a no-op. + return tab.dataset.tabId === sourceTabId + ? null + : { kind: "tab", element: tab, tabId: tab.dataset.tabId! }; + } + const strip = hit?.closest(".tabbar-tabs"); + const add = strip?.querySelector(".tabbar-add"); + return add && sourceTabPaneCount() > 1 + ? { kind: "new-tab", element: add } : null; }; + const hoveredTabId = (event: PointerEvent) => + document + .elementFromPoint(event.clientX, event.clientY) + ?.closest(".tabbar-tab[data-tab-id]")?.dataset.tabId; + const clearOpenTabTimer = () => { + if (openTabTimer) clearTimeout(openTabTimer); + openTabTimer = null; + openTabId = undefined; + }; + const setTarget = (next: DropTarget | null) => { + if (sameTarget(next, target)) return; + if (target) { + target.element.classList.remove( + "is-swap-target", + "is-place-target", + "is-pane-drop-target", + ); + delete target.element.dataset.dropSide; + } + target = next; + if (!target) return; + if (target.kind === "swap") + target.element.classList.add("is-swap-target"); + else if (target.kind === "place") { + target.element.classList.add("is-place-target"); + target.element.dataset.dropSide = target.side; + } else target.element.classList.add("is-pane-drop-target"); + }; + const hintText = () => { + switch (target?.kind) { + case "swap": + return "Release to swap"; + case "place": + return `Release to place ${SIDE_LABELS[target.side]}`; + case "tab": { + const name = + target.element.querySelector(".tabbar-name")?.textContent || + "this tab"; + return `Release to move to ${name}, or hold to open it`; + } + case "new-tab": + return "Release to move to a new tab"; + default: + return "Drag onto a pane or tab"; + } + }; const cleanup = () => { window.removeEventListener("pointermove", move, true); window.removeEventListener("pointerup", drop, true); window.removeEventListener("pointercancel", cancel, true); window.removeEventListener("blur", cleanup); window.removeEventListener("keydown", escape, true); - button.removeEventListener("lostpointercapture", cleanup); - target?.classList.remove("is-swap-target"); + button.removeEventListener("lostpointercapture", lostCapture); + clearOpenTabTimer(); + setTarget(null); preview?.remove(); - source.classList.remove("is-swap-source"); - grid.classList.remove("is-swapping"); - cancelDrag.current = null; + sourceCell?.classList.remove("is-swap-source"); + document.documentElement.classList.remove("is-pane-dragging"); + if (drag.current === session) drag.current = null; if (button.hasPointerCapture(pointerId)) button.releasePointerCapture(pointerId); }; @@ -97,8 +243,7 @@ export function PaneDragHandle({ if (Math.hypot(event.clientX - startX, event.clientY - startY) < 4) return; dragging = true; - source.classList.add("is-swap-source"); - grid.classList.add("is-swapping"); + document.documentElement.classList.add("is-pane-dragging"); preview = document.createElement("div"); preview.className = "pane-move-preview"; preview.setAttribute("aria-hidden", "true"); @@ -111,17 +256,29 @@ export function PaneDragHandle({ document.body.append(preview); } event.preventDefault(); - const nextTarget = findTarget(event); - if (nextTarget !== target) { - target?.classList.remove("is-swap-target"); - target = nextTarget; - target?.classList.add("is-swap-target"); + // The source cell is re-created when its tab is opened again. + const nextSourceCell = paneCell(paneId); + if (nextSourceCell !== sourceCell) { + sourceCell?.classList.remove("is-swap-source"); + sourceCell = nextSourceCell; + sourceCell?.classList.add("is-swap-source"); + } + setTarget(findTarget(event)); + const tabId = hoveredTabId(event); + if (tabId !== openTabId) clearOpenTabTimer(); + if (tabId && tabId !== store.get().layout?.tab_id && !openTabTimer) { + openTabId = tabId; + openTabTimer = setTimeout(() => { + openTabTimer = null; + openTabId = undefined; + if (!currentSession()) return cleanup(); + session.leftTab ||= tabId !== sourceTabId; + void store.focusTab(tabId); + }, TAB_OPEN_DELAY_MS); } if (preview && hint) { preview.classList.toggle("is-ready", !!target); - hint.textContent = target - ? "Release to swap" - : "Drag onto another pane"; + hint.textContent = hintText(); // Pointer/rects use viewport pixels; the body inherits the app's CSS zoom. const rect = preview.getBoundingClientRect(); const scale = rect.width / preview.offsetWidth || 1; @@ -138,14 +295,28 @@ export function PaneDragHandle({ }; const drop = (event: PointerEvent) => { if (event.pointerId !== pointerId) return; - const targetId = - dragging && currentSession() ? findTarget(event)?.dataset.paneId : null; + const dropped = dragging && currentSession() ? findTarget(event) : null; cleanup(); - if (targetId) void store.swapPanes(paneId, targetId); + if (!dropped) return; + if (dropped.kind === "swap") void store.swapPanes(paneId, dropped.paneId); + else if (dropped.kind === "place") + void store.movePaneBeside(paneId, dropped.paneId, dropped.side); + else if (dropped.kind === "new-tab") void store.movePaneToNewTab(paneId); + else { + const panes = store + .get() + .panes.filter((pane) => pane.tab_id === dropped.tabId); + const anchor = panes.find((pane) => pane.focused) ?? panes[0]; + if (anchor) void store.movePaneBeside(paneId, anchor.pane_id, "right"); + } }; const cancel = (event: PointerEvent) => { if (event.pointerId === pointerId) cleanup(); }; + const lostCapture = () => { + // Capture also ends when the handle unmounts because another tab opened. + if (button.isConnected) cleanup(); + }; const escape = (event: KeyboardEvent) => { if (event.key !== "Escape") return; event.preventDefault(); @@ -153,23 +324,24 @@ export function PaneDragHandle({ cleanup(); }; button.setPointerCapture(pointerId); - cancelDrag.current = cleanup; + drag.current = session; window.addEventListener("pointermove", move, true); window.addEventListener("pointerup", drop, true); window.addEventListener("pointercancel", cancel, true); window.addEventListener("blur", cleanup); window.addEventListener("keydown", escape, true); - button.addEventListener("lostpointercapture", cleanup); + button.addEventListener("lostpointercapture", lostCapture); }; - if (!layout || layout.zoomed || layout.panes.length < 2) return null; + if (!layout || layout.zoomed || (layout.panes.length < 2 && !otherTabs)) + return null; return (