diff --git a/bun.lock b/bun.lock index ef3433f780..c7ac6ab61c 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,10 @@ "name": "@opencode-ai/app", "version": "1.17.11", "dependencies": { + "@dnd-kit/abstract": "0.5.0", + "@dnd-kit/dom": "0.5.0", + "@dnd-kit/helpers": "0.5.0", + "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -1451,6 +1455,20 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/abstract": ["@dnd-kit/abstract@0.5.0", "", { "dependencies": { "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-hi13iMJgjPX/KDYVKg5VeDIhmYiV6buc9bAX+tCLYf4QdyYjPbsXjn2sPo6m7fQ6SGJBEFgHJ2PemeKDUbwBaA=="], + + "@dnd-kit/collision": ["@dnd-kit/collision@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-xUqRn3lS7oqLkT0AnnHS/STh/Czvwe1UapZFYiLbsUGxopMsQd4teaPCzPouOThoMdGEe+dHWjfqJl6t9iG4mQ=="], + + "@dnd-kit/dom": ["@dnd-kit/dom@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/collision": "^0.5.0", "@dnd-kit/geometry": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-f2xFJp5SYQ8EW/Fbtaa8iBb66hpkWc7qa8vU826KW11/tb44sH+AisZnGtwOOTWTQ0GraqBDr5ixTErww+eKXw=="], + + "@dnd-kit/geometry": ["@dnd-kit/geometry@0.5.0", "", { "dependencies": { "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-ubHQS1CiSDH8ssYH2xG5BnpwPSFP1tStXXjug7/Ba6qnQdu/EUH47l6QXKIksQnnanfVfDf0aGeevRxgZlj28A=="], + + "@dnd-kit/helpers": ["@dnd-kit/helpers@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "tslib": "^2.6.2" } }, "sha512-i4y+51/icSw+OHMr/su19qhnmNhAzh8PnBwXvapFYTd+64oodIyJRiRkB+hhfxAfnur7RYSW8qacDTrXjg2XOg=="], + + "@dnd-kit/solid": ["@dnd-kit/solid@0.5.0", "", { "dependencies": { "@dnd-kit/abstract": "^0.5.0", "@dnd-kit/dom": "^0.5.0", "@dnd-kit/state": "^0.5.0", "tslib": "^2.6.2" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-IKDqVZICS0jEeUzpJMIIF61w0WA4zisyx9U7K7Skbmkb/kQSDa3lB0cOc0947RwSO+ALoxytRNOuoNfyOIm3lQ=="], + + "@dnd-kit/state": ["@dnd-kit/state@0.5.0", "", { "dependencies": { "@preact/signals-core": "^1.10.0", "tslib": "^2.6.2" } }, "sha512-y7XbabQqjF58Lk8YmDQuR8l6QjN+Kh4qlGEjUvHuIeasLk1QP+9L5diXS98VMxQIivyMmUtX2//f+3N7qPJX4w=="], + "@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="], "@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="], @@ -2287,6 +2305,8 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@preact/signals-core": ["@preact/signals-core@1.14.3", "", {}, "sha512-m0K3vnbSLC5rHs2ZVfeAMvBtT1zIyq4mxx5OlNncSgMj5Iz6W5Rn3kPrDxAC+iIKmiVe0lSl6U37t5ZkEWoVAw=="], + "@protobuf-ts/plugin": ["@protobuf-ts/plugin@2.11.1", "", { "dependencies": { "@bufbuild/protobuf": "^2.4.0", "@bufbuild/protoplugin": "^2.4.0", "@protobuf-ts/protoc": "^2.11.1", "@protobuf-ts/runtime": "^2.11.1", "@protobuf-ts/runtime-rpc": "^2.11.1", "typescript": "^3.9" }, "bin": { "protoc-gen-ts": "bin/protoc-gen-ts", "protoc-gen-dump": "bin/protoc-gen-dump" } }, "sha512-HyuprDcw0bEEJqkOWe1rnXUP0gwYLij8YhPuZyZk6cJbIgc/Q0IFgoHQxOXNIXAcXM4Sbehh6kjVnCzasElw1A=="], "@protobuf-ts/protoc": ["@protobuf-ts/protoc@2.11.1", "", { "bin": { "protoc": "protoc.js" } }, "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg=="], diff --git a/packages/app/package.json b/packages/app/package.json index 54191cbab5..b8f52ebfc7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -44,6 +44,10 @@ "vite-plugin-solid": "catalog:" }, "dependencies": { + "@dnd-kit/abstract": "0.5.0", + "@dnd-kit/dom": "0.5.0", + "@dnd-kit/helpers": "0.5.0", + "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/src/components/titlebar-tab-drag.test.ts b/packages/app/src/components/titlebar-tab-drag.test.ts deleted file mode 100644 index 075c035988..0000000000 --- a/packages/app/src/components/titlebar-tab-drag.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { captureTabDragLayout, insertIndexFromVirtualLayout } from "./titlebar-tab-drag" -import { - canOpenTabRename, - captureTabPointerDown, - canStartTabDrag, - createTabDragPreview, - forwardTabRef, - isPrimaryPointerPressed, - isTabCloseTarget, -} from "./titlebar-tab-gesture" - -describe("titlebar tab drag", () => { - const layout = { - listLeft: 100, - dividerWidth: 13, - tabWidthById: new Map([ - ["a", 40], - ["b", 40], - ["c", 40], - ["d", 40], - ]), - } - - test("moves across multiple tabs from one pointer update", () => { - expect(insertIndexFromVirtualLayout(260, ["a", "b", "c", "d"], "a", 0, layout)).toBe(3) - expect(insertIndexFromVirtualLayout(90, ["a", "b", "c", "d"], "d", 3, layout)).toBe(0) - }) - - test("keeps the current index inside the left hysteresis deadband", () => { - expect(insertIndexFromVirtualLayout(146, ["a", "b", "c", "d"], "b", 1, layout)).toBe(1) - }) - - test("includes slot margins in captured divider width", () => { - const list = document.createElement("div") - const first = document.createElement("div") - const second = document.createElement("div") - const firstTab = document.createElement("div") - const secondTab = document.createElement("div") - first.dataset.titlebarTabSlot = "" - first.dataset.tabKey = "a" - second.dataset.titlebarTabSlot = "" - second.dataset.tabKey = "b" - second.style.marginLeft = "6px" - firstTab.dataset.titlebarTab = "" - secondTab.dataset.titlebarTab = "" - first.append(firstTab) - second.append(secondTab) - list.append(first, second) - document.body.append(list) - firstTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect - secondTab.getBoundingClientRect = () => ({ width: 40 }) as DOMRect - second.getBoundingClientRect = () => ({ width: 47 }) as DOMRect - list.getBoundingClientRect = () => ({ left: 100 }) as DOMRect - - expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13) - list.remove() - }) - - test("uses the list gap as the divider width", () => { - const list = document.createElement("div") - const first = document.createElement("div") - const second = document.createElement("div") - const firstTab = document.createElement("div") - const secondTab = document.createElement("div") - first.dataset.titlebarTabSlot = "" - first.dataset.tabKey = "a" - second.dataset.titlebarTabSlot = "" - second.dataset.tabKey = "b" - firstTab.dataset.titlebarTab = "" - secondTab.dataset.titlebarTab = "" - first.append(firstTab) - second.append(secondTab) - list.append(first, second) - list.style.columnGap = "13.5px" - document.body.append(list) - - expect(captureTabDragLayout(list, ["a", "b"]).dividerWidth).toBe(13.5) - list.remove() - }) -}) - -describe("titlebar tab gestures", () => { - test("excludes close controls from tab gestures", () => { - const close = document.createElement("div") - const button = document.createElement("button") - const link = document.createElement("a") - close.dataset.slot = "tab-close" - close.append(button) - expect(isTabCloseTarget(close)).toBe(true) - expect(isTabCloseTarget(button)).toBe(true) - expect(isTabCloseTarget(link)).toBe(false) - }) - - test("forwards component refs", () => { - const element = document.createElement("div") - let received: HTMLDivElement | undefined - forwardTabRef((value) => (received = value), element) - expect(received).toBe(element) - }) - - test("does not reopen rename while a save is pending", () => { - expect(canOpenTabRename(false, false, false)).toBe(true) - expect(canOpenTabRename(false, false, true)).toBe(false) - }) - - test("keeps the rendered tab content in the drag preview", () => { - const tab = document.createElement("div") - tab.innerHTML = 'Session' - const preview = createTabDragPreview(tab) - expect(preview.querySelector('[data-slot="project-avatar-slot"]')).not.toBeNull() - expect(preview.querySelector('[data-slot="tab-title"]')?.textContent).toBe("Session") - }) - - test("captures the grab offset before navigation scrolls the tab", () => { - const tab = document.createElement("div") - tab.getBoundingClientRect = () => ({ left: 80, top: 10, width: 120 }) as DOMRect - - expect(captureTabPointerDown(tab, 100, 20)).toEqual({ - startX: 100, - startY: 20, - grabOffsetX: 20, - grabOffsetY: 10, - width: 120, - element: tab, - }) - }) - - test("detects when the primary pointer button was released outside the window", () => { - expect(isPrimaryPointerPressed(1)).toBe(true) - expect(isPrimaryPointerPressed(3)).toBe(true) - expect(isPrimaryPointerPressed(0)).toBe(false) - expect(isPrimaryPointerPressed(2)).toBe(false) - }) - - test("preserves native panning for touch pointers", () => { - expect(canStartTabDrag("mouse")).toBe(true) - expect(canStartTabDrag("pen")).toBe(true) - expect(canStartTabDrag("touch")).toBe(false) - }) -}) diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts deleted file mode 100644 index b0d75750e0..0000000000 --- a/packages/app/src/components/titlebar-tab-drag.ts +++ /dev/null @@ -1,137 +0,0 @@ -export type TabDragLayout = { - tabWidthById: Map - dividerWidth: number - listLeft: number -} - -export const ACTIVATION_DISTANCE = 4 -export const HYSTERESIS_DEADBAND = 8 -export const AUTOSCROLL_EDGE = 24 -export const AUTOSCROLL_MAX_SPEED = 8 -export const FLOATER_OVERSHOOT_MAX = 8 - -export function pointerDistance(x1: number, y1: number, x2: number, y2: number) { - const dx = x2 - x1 - const dy = y2 - y1 - return Math.sqrt(dx * dx + dy * dy) -} - -export function captureTabDragLayout(list: HTMLElement, order: string[]) { - const tabWidthById = new Map() - const slots = list.querySelectorAll("[data-titlebar-tab-slot]") - for (const slot of slots) { - const id = slot.dataset.tabKey - if (!id) continue - const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector("[data-titlebar-tab]") - if (!tab) continue - tabWidthById.set(id, tab.getBoundingClientRect().width) - } - - let dividerWidth = 0 - if (order.length >= 2) { - const gap = Number.parseFloat(getComputedStyle(list).columnGap) || 0 - const secondId = order[1] - for (const slot of slots) { - if (slot.dataset.tabKey !== secondId) continue - const tab = slot.matches("[data-titlebar-tab]") ? slot : slot.querySelector("[data-titlebar-tab]") - if (!tab) break - const style = getComputedStyle(slot) - dividerWidth = - gap || - slot.getBoundingClientRect().width - - tab.getBoundingClientRect().width + - (Number.parseFloat(style.marginLeft) || 0) + - (Number.parseFloat(style.marginRight) || 0) - break - } - } - - return { - tabWidthById, - dividerWidth, - listLeft: list.getBoundingClientRect().left, - } -} - -export function syncLayoutScroll(list: HTMLElement, layout: TabDragLayout) { - layout.listLeft = list.getBoundingClientRect().left -} - -function slotWidthAt(order: readonly string[], index: number, layout: TabDragLayout) { - const id = order[index] - if (!id) return 0 - const tabWidth = layout.tabWidthById.get(id) ?? 0 - return index === 0 ? tabWidth : layout.dividerWidth + tabWidth -} - -function slotLeft(order: readonly string[], index: number, layout: TabDragLayout) { - let left = layout.listLeft - for (let i = 0; i < index; i++) { - left += slotWidthAt(order, i, layout) - } - return left -} - -export function insertIndexFromVirtualLayout( - pointerX: number, - order: readonly string[], - draggedId: string, - currentIndex: number, - layout: TabDragLayout, - deadband = HYSTERESIS_DEADBAND, -) { - if (order.length === 0) return 0 - - const others = order.filter((id) => id !== draggedId) - let target = currentIndex - - while (target > 0 && pointerX < slotLeft(others, target, layout) - deadband) target-- - while (target < order.length - 1 && pointerX >= slotLeft(others, target + 1, layout)) target++ - - return target -} - -export function movePlaceholder(order: readonly string[], draggedId: string, toIndex: number) { - const fromIndex = order.indexOf(draggedId) - if (fromIndex === -1 || fromIndex === toIndex) return [...order] - const next = [...order] - next.splice(toIndex, 0, ...next.splice(fromIndex, 1)) - return next -} - -export function draftOrderChanged(initial: readonly string[], final: readonly string[]) { - if (initial.length === 0 || final.length === 0 || initial.length !== final.length) return false - return final.some((key, index) => key !== initial[index]) -} - -function easeOvershoot(overshoot: number) { - return (FLOATER_OVERSHOOT_MAX * overshoot) / (overshoot + FLOATER_OVERSHOOT_MAX) -} - -export function clampFloaterLeft(left: number, width: number, stripLeft: number, stripRight: number) { - const stripWidth = stripRight - stripLeft - if (width >= stripWidth) return stripLeft - - const maxLeft = stripRight - width - if (left > maxLeft) return maxLeft + easeOvershoot(left - maxLeft) - if (left < stripLeft) return stripLeft - easeOvershoot(stripLeft - left) - - return left -} - -export function autoscrollSpeed(pointerX: number, containerLeft: number, containerRight: number) { - const leftEdge = containerLeft + AUTOSCROLL_EDGE - const rightEdge = containerRight - AUTOSCROLL_EDGE - - if (pointerX < leftEdge) { - const depth = (leftEdge - pointerX) / AUTOSCROLL_EDGE - return -Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1)) - } - - if (pointerX > rightEdge) { - const depth = (pointerX - rightEdge) / AUTOSCROLL_EDGE - return Math.ceil(AUTOSCROLL_MAX_SPEED * Math.min(depth, 1)) - } - - return 0 -} diff --git a/packages/app/src/components/titlebar-tab-gesture.test.ts b/packages/app/src/components/titlebar-tab-gesture.test.ts new file mode 100644 index 0000000000..a6ab899248 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-gesture.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { canOpenTabRename, canStartTabDrag, forwardTabRef, isTabCloseTarget } from "./titlebar-tab-gesture" + +describe("titlebar tab gestures", () => { + test("excludes close controls from tab gestures", () => { + const close = document.createElement("div") + const button = document.createElement("button") + const link = document.createElement("a") + close.dataset.slot = "tab-close" + close.append(button) + expect(isTabCloseTarget(close)).toBe(true) + expect(isTabCloseTarget(button)).toBe(true) + expect(isTabCloseTarget(link)).toBe(false) + }) + + test("forwards component refs", () => { + const element = document.createElement("div") + let received: HTMLDivElement | undefined + forwardTabRef((value) => (received = value), element) + expect(received).toBe(element) + }) + + test("does not reopen rename while a save is pending", () => { + expect(canOpenTabRename(false, false, false)).toBe(true) + expect(canOpenTabRename(false, false, true)).toBe(false) + }) + + test("preserves native panning for touch pointers", () => { + expect(canStartTabDrag("mouse")).toBe(true) + expect(canStartTabDrag("pen")).toBe(true) + expect(canStartTabDrag("touch")).toBe(false) + }) +}) diff --git a/packages/app/src/components/titlebar-tab-gesture.ts b/packages/app/src/components/titlebar-tab-gesture.ts index 02866d4111..329bf5e864 100644 --- a/packages/app/src/components/titlebar-tab-gesture.ts +++ b/packages/app/src/components/titlebar-tab-gesture.ts @@ -8,22 +8,6 @@ export function canStartTabDrag(pointerType: string) { return pointerType !== "touch" } -export function isPrimaryPointerPressed(buttons: number) { - return (buttons & 1) !== 0 -} - -export function captureTabPointerDown(element: HTMLDivElement, clientX: number, clientY: number) { - const rect = element.getBoundingClientRect() - return { - startX: clientX, - startY: clientY, - grabOffsetX: clientX - rect.left, - grabOffsetY: clientY - rect.top, - width: rect.width, - element, - } -} - export function forwardTabRef(ref: Ref | undefined, element: HTMLDivElement) { if (typeof ref === "function") ref(element) } @@ -31,7 +15,3 @@ export function forwardTabRef(ref: Ref | undefined, element: HTM export function canOpenTabRename(dragging: boolean | undefined, editing: boolean, committing: boolean) { return !dragging && !editing && !committing } - -export function createTabDragPreview(element: HTMLDivElement) { - return element.cloneNode(true) as HTMLDivElement -} diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 07a6d1d052..5fb2fa892c 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -9,6 +9,10 @@ justify-content: center; } +[data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { + display: none; +} + [data-titlebar-tab-list] { gap: 13.5px; } diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 03196a4006..346d1db15b 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -29,9 +29,6 @@ export function TabNavItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean - tabKey: string - dragActive: boolean - onPointerDown: (event: PointerEvent) => void }) { const language = useLanguage() const [editing, setEditing] = createSignal(false) @@ -173,112 +170,108 @@ export function TabNavItem(props: { return (
{ + tabRoot = el + forwardTabRef(props.ref, el) + }} + data-titlebar-tab + data-slot="titlebar-tab-item" + data-title-overflow={titleOverflowing()} + data-editing={editing()} + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + classList={{ invisible: props.hidden }} + data-active={props.active} + data-dragging={props.dragging} + data-pressed={props.pressed} + onMouseDown={(event) => { + if (event.button !== 1) return + closeTab(event) + }} > -
{ - tabRoot = el - forwardTabRef(props.ref, el) - }} - data-titlebar-tab - data-slot="titlebar-tab-item" - data-title-overflow={titleOverflowing()} - data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" - data-active={props.active} - data-dragging={props.dragging} - data-pressed={props.pressed} - onMouseDown={(event) => { - if (event.button !== 1) return - closeTab(event) - }} - > - - {(session) => { - return ( - { + + {(session) => { + return ( + { + event.preventDefault() + event.stopPropagation() + }} + onClick={(event) => { + event.preventDefault() + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = session().title + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { + event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return event.preventDefault() + titleEl.textContent = session().title + void closeRename(false) + }} + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return event.stopPropagation() }} onClick={(event) => { + if (!editing()) return event.preventDefault() - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - { - titleEl = el - titleEl.textContent = session().title - }} - data-slot="tab-title" - data-titlebar-tab-title - class="min-w-0 flex-1 outline-none leading-4" - classList={{ - "overflow-hidden text-clip whitespace-nowrap": !editing(), - "select-text": editing(), - }} - contenteditable={editing() ? true : undefined} - onDblClick={openRename} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void closeRename(true) - return - } - if (event.key !== "Escape") return - event.preventDefault() - titleEl.textContent = session().title - void closeRename(false) - }} - onBlur={() => void closeRename(true)} - onPointerDown={(event) => { - if (!editing()) return - event.stopPropagation() - }} - onClick={(event) => { - if (!editing()) return - event.preventDefault() - }} - /> - - ) - }} - + /> + + ) + }} + -
- { - event.preventDefault() - event.stopPropagation() - }} - onClick={closeTab} - icon={} - /> -
+
+ { + event.preventDefault() + event.stopPropagation() + }} + onClick={closeTab} + icon={} + />
) @@ -295,9 +288,6 @@ export function DraftTabItem(props: { dragging?: boolean pressed?: boolean hidden?: boolean - tabKey: string - dragActive: boolean - onPointerDown: (event: PointerEvent) => void }) { const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -306,69 +296,62 @@ export function DraftTabItem(props: { } return (
forwardTabRef(props.ref, el)} + data-titlebar-tab + data-slot="titlebar-tab-item" + data-active={props.active} + data-dragging={props.dragging} + data-pressed={props.pressed} + class="group relative flex h-7 w-full min-w-0 flex-row items-center gap-1.5 overflow-hidden rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] whitespace-nowrap [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + classList={{ invisible: props.hidden }} + onMouseDown={(event) => { + if (event.button !== 1) return + closeTab(event) + }} > -
forwardTabRef(props.ref, el)} - data-titlebar-tab - data-slot="titlebar-tab-item" - data-active={props.active} - data-dragging={props.dragging} - data-pressed={props.pressed} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" - onMouseDown={(event) => { - if (event.button !== 1) return - closeTab(event) + { + event.preventDefault() + event.stopPropagation() }} + onClick={(event) => { + event.preventDefault() + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]" > - { + + + + + {props.title} + + +
+ { event.preventDefault() event.stopPropagation() }} - onClick={(event) => { + onMouseDown={(event) => { event.preventDefault() - if (props.suppressNavigation?.()) return - props.onNavigate() + event.stopPropagation() }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - - {props.title} - - -
- { - event.preventDefault() - event.stopPropagation() - }} - onMouseDown={(event) => { - event.preventDefault() - event.stopPropagation() - }} - class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" - onClick={closeTab} - icon={} - aria-label="Close tab" - /> -
+ class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" + onClick={closeTab} + icon={} + aria-label="Close tab" + />
) diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index e6188fe8b5..ef404157e4 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,18 +1,11 @@ -import { - createEffect, - createMemo, - createResource, - createRoot, - createSignal, - For, - onCleanup, - onMount, - Show, -} from "solid-js" -import { Portal } from "solid-js/web" -import { createStore } from "solid-js/store" -import { makeEventListener } from "@solid-primitives/event-listener" +import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js" import { createResizeObserver } from "@solid-primitives/resize-observer" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { arrayMove } from "@dnd-kit/helpers" import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs" import { ServerConnection } from "@/context/server" import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav" @@ -22,42 +15,30 @@ import { useCommand } from "@/context/command" import { useTabs } from "@/context/tabs" import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" -import { - captureTabPointerDown, - canStartTabDrag, - createTabDragPreview, - isPrimaryPointerPressed, - isTabCloseTarget, -} from "./titlebar-tab-gesture" -import { - ACTIVATION_DISTANCE, - autoscrollSpeed, - captureTabDragLayout, - clampFloaterLeft, - draftOrderChanged, - insertIndexFromVirtualLayout, - movePlaceholder, - pointerDistance, - syncLayoutScroll, - type TabDragLayout, -} from "@/components/titlebar-tab-drag" +import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" + +const sortableTransition = { duration: 0 } function SessionTabSlot(props: { tab: SessionTab id: string + index: () => number active: () => boolean activeServerKey: ServerConnection.Key forceTruncate: boolean - dragActive: boolean - dragged: () => boolean - pressed: () => boolean serverCtx: () => ServerCtx | undefined - suppressNavigation: () => boolean - onPointerDown: (event: PointerEvent) => void onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { const tabs = useTabs() + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index() + }, + }) let ref!: HTMLDivElement const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) @@ -100,33 +81,79 @@ function SessionTabSlot(props: { }) return ( - { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onTitleChangeFailed={(title) => { - const value = session() - const ctx = props.serverCtx() - if (value && ctx) ctx.sync.session.remember({ ...value, title }) - }} - onNavigate={() => props.onNavigate(ref)} - onClose={props.onClose} - active={props.active()} - activeServer={props.tab.server === props.activeServerKey} - forceTruncate={props.forceTruncate} - suppressNavigation={props.suppressNavigation} - pressed={props.pressed()} - hidden={props.dragged() || !session()} - /> +
+ { + ref = el + }} + href={tabHref(props.tab)} + server={props.tab.server} + session={session} + onTitleChange={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onTitleChangeFailed={(title) => { + const value = session() + const ctx = props.serverCtx() + if (value && ctx) ctx.sync.session.remember({ ...value, title }) + }} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + activeServer={props.tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + dragging={sortable.isDragSource()} + /> +
+ ) +} + +function DraftTabSlot(props: { + tab: Extract + id: string + index: () => number + active: () => boolean + title: string + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const sortable = useSortable({ + get id() { + return props.id + }, + get index() { + return props.index() + }, + }) + let ref!: HTMLDivElement + + return ( +
+ { + ref = el + }} + href={tabHref(props.tab)} + title={props.title} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + dragging={sortable.isDragSource()} + /> +
) } @@ -142,52 +169,12 @@ export function TitlebarTabStrip(props: { }) { const global = useGlobal() const language = useLanguage() - const [drag, setDrag] = createStore({ - active: false, - draggedId: undefined as string | undefined, - placeholderIndex: 0, - draftOrder: [] as string[], - initialOrder: [] as string[], - draggedWidth: 0, - pointerX: 0, - grabOffsetX: 0, - floaterTop: 0, - }) - - const [gesture, setGesture] = createStore({ - pending: undefined as - | { - id: string - startX: number - startY: number - grabOffsetX: number - grabOffsetY: number - pointerId: number - width: number - element: HTMLDivElement - } - | undefined, - }) - - const [suppressNavigation, setSuppressNavigation] = createSignal(false) - const [pressedId, setPressedId] = createSignal() - const [stripScrollLeft, setStripScrollLeft] = createSignal(0) let scrollRef!: HTMLDivElement let listRef!: HTMLDivElement - let dragLayout: TabDragLayout | undefined - let dragPointerId: number | undefined - let autoscrollFrame: number | undefined let resizeFrame: number | undefined - let dragPreview: HTMLDivElement | undefined const tabIds = () => props.tabs.map(tabKey) - const displayTabs = createMemo(() => { - if (!drag.active || drag.draftOrder.length === 0) return props.tabs - const byKey = new Map(props.tabs.map((tab) => [tabKey(tab), tab])) - return drag.draftOrder.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab) - }) - function refreshOverflow() { if (!scrollRef) return props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth) @@ -200,220 +187,14 @@ export function TitlebarTabStrip(props: { resizeFrame = requestAnimationFrame(() => { resizeFrame = undefined refreshOverflow() - if (!drag.active || !listRef) return - dragLayout = captureTabDragLayout(listRef, drag.draftOrder) - updateInsertIndex() }) }, ) - function syncScroll() { - if (!scrollRef || !listRef || !dragLayout) return - syncLayoutScroll(listRef, dragLayout) - setStripScrollLeft(scrollRef.scrollLeft) - updateInsertIndex() - } - - function stopAutoscroll() { - if (autoscrollFrame === undefined) return - cancelAnimationFrame(autoscrollFrame) - autoscrollFrame = undefined - } - - function tickAutoscroll() { - if (!drag.active || !scrollRef) return - - const strip = scrollRef.getBoundingClientRect() - const speed = autoscrollSpeed(drag.pointerX, strip.left, strip.right) - - if (speed !== 0) { - scrollRef.scrollLeft += speed - syncScroll() - } - - autoscrollFrame = requestAnimationFrame(tickAutoscroll) - } - - function startAutoscroll() { - stopAutoscroll() - autoscrollFrame = requestAnimationFrame(tickAutoscroll) - } - - function applyPlaceholderIndex(nextIndex: number) { - const id = drag.draggedId - if (!id) return - const next = movePlaceholder(drag.draftOrder, id, nextIndex) - setDrag({ - draftOrder: next, - placeholderIndex: nextIndex, - }) - } - - function updateInsertIndex() { - if (!drag.active || !dragLayout) return - const draggedId = drag.draggedId - if (!draggedId) return - const nextIndex = insertIndexFromVirtualLayout( - drag.pointerX, - drag.draftOrder, - draggedId, - drag.placeholderIndex, - dragLayout, - ) - if (nextIndex === drag.placeholderIndex) return - applyPlaceholderIndex(nextIndex) - } - - function startDrag(id: string) { - const order = tabIds() - const index = order.indexOf(id) - const pending = gesture.pending - if (index === -1 || !pending || !listRef || !scrollRef) return - - dragLayout = captureTabDragLayout(listRef, order) - dragPreview = createTabDragPreview(pending.element) - dragPointerId = pending.pointerId - setGesture("pending", undefined) - - setDrag({ - active: true, - draggedId: id, - placeholderIndex: index, - draftOrder: order, - initialOrder: order, - draggedWidth: pending.width, - pointerX: pending.startX, - grabOffsetX: pending.grabOffsetX, - floaterTop: pending.startY - pending.grabOffsetY, - }) - setPressedId(undefined) - setStripScrollLeft(scrollRef.scrollLeft) - startAutoscroll() - } - - function endDrag(commit: boolean) { - const initial = drag.initialOrder - const final = drag.draftOrder - const moved = drag.active - - if (commit && moved && draftOrderChanged(initial, final)) { - props.onReorder(final) - } - - if (moved) setSuppressNavigation(true) - - setDrag({ - active: false, - draggedId: undefined, - placeholderIndex: 0, - draftOrder: [], - initialOrder: [], - draggedWidth: 0, - pointerX: 0, - grabOffsetX: 0, - floaterTop: 0, - }) - - dragLayout = undefined - dragPreview = undefined - dragPointerId = undefined - setGesture("pending", undefined) - setPressedId(undefined) - stopAutoscroll() - refreshOverflow() - requestAnimationFrame(() => setSuppressNavigation(false)) - } - - function onPointerDown(id: string, event: PointerEvent) { - if (event.button !== 0 || drag.active) return - if (!canStartTabDrag(event.pointerType)) return - if (isTabCloseTarget(event.target)) return - const target = event.currentTarget as HTMLDivElement - const tabEl = target.matches("[data-titlebar-tab]") - ? target - : target.querySelector("[data-titlebar-tab]") - if (!tabEl) return - if (!tabEl.querySelector('[data-slot="tab-link"]')) return - const tab = props.tabs.find((item) => tabKey(item) === id) - if (!tab) return - const pointer = captureTabPointerDown(tabEl, event.clientX, event.clientY) - setSuppressNavigation(true) - props.onNavigate(tab, tabEl) - setPressedId(id) - setGesture("pending", { - id, - pointerId: event.pointerId, - ...pointer, - }) - } - - function onPointerMove(event: PointerEvent) { - const pending = gesture.pending - if (pending && event.pointerId !== pending.pointerId) return - if (drag.active && dragPointerId !== undefined && event.pointerId !== dragPointerId) return - if (!isPrimaryPointerPressed(event.buttons)) { - if (drag.active) endDrag(true) - if (pending) { - setGesture("pending", undefined) - setPressedId(undefined) - requestAnimationFrame(() => setSuppressNavigation(false)) - } - return - } - - if (pending && !drag.active) { - if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return - startDrag(pending.id) - } - - if (!drag.active) return - - setDrag("pointerX", event.clientX) - syncScroll() - } - - function onPointerUp(event: PointerEvent) { - if (drag.active) { - if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return - setDrag("pointerX", event.clientX) - syncScroll() - endDrag(true) - return - } - - const pending = gesture.pending - if (pending && event.pointerId !== pending.pointerId) return - - setGesture("pending", undefined) - setPressedId(undefined) - requestAnimationFrame(() => setSuppressNavigation(false)) - } - - function onPointerCancel(event: PointerEvent) { - if (drag.active) { - if (dragPointerId !== undefined && event.pointerId !== dragPointerId) return - endDrag(false) - return - } - - if (!gesture.pending) return - if (gesture.pending.pointerId !== event.pointerId) return - setGesture("pending", undefined) - setPressedId(undefined) - requestAnimationFrame(() => setSuppressNavigation(false)) - } - onMount(() => { - const cleanups = [ - makeEventListener(window, "pointermove", onPointerMove), - makeEventListener(window, "pointerup", onPointerUp), - makeEventListener(window, "pointercancel", onPointerCancel), - ] refreshOverflow() - onCleanup(() => cleanups.forEach((cleanup) => cleanup())) }) - onCleanup(stopAutoscroll) onCleanup(() => { if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame) }) @@ -424,50 +205,54 @@ export function TitlebarTabStrip(props: { refreshOverflow() }) - createEffect(() => { - if (!drag.active || !scrollRef) return - onCleanup(makeEventListener(scrollRef, "scroll", syncScroll)) - }) - - const floaterStyle = () => { - stripScrollLeft() - const strip = scrollRef?.getBoundingClientRect() - const left = strip - ? clampFloaterLeft(drag.pointerX - drag.grabOffsetX, drag.draggedWidth, strip.left, strip.right) - : drag.pointerX - drag.grabOffsetX - - return { - position: "fixed" as const, - top: `${drag.floaterTop}px`, - left: `${left}px`, - width: `${drag.draggedWidth}px`, - "z-index": "10000", - "pointer-events": "none" as const, - } - } - - const draggedTab = createMemo(() => { - const id = drag.draggedId - if (!id) return - return props.tabs.find((tab) => tabKey(tab) === id) - }) - return ( - <> -
-
+
+ + !canStartTabDrag(event.pointerType) || + isTabCloseTarget(event.target) || + (event.target instanceof Element && !!event.target.closest('[contenteditable="true"]')), + }), + ]} + modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => listRef })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragStart={(event) => { + const source = event.operation.source + if (!source) return + const tab = props.tabs.find((item) => tabKey(item) === source.id.toString()) + if (!tab) return + const tabEl = source.element?.querySelector("[data-titlebar-tab]") + props.onNavigate(tab, tabEl ?? undefined) + }} + onDragEnd={(event) => { + const current = tabIds() + const source = event.operation.source + if (event.canceled || !isSortable(source)) return + + const { initialIndex, index } = source + if (initialIndex !== index) { + props.onReorder(arrayMove(current, source.initialIndex, source.index)) + } + }} > -
- +
+ {(tab, index) => { const id = tabKey(tab) let ref!: HTMLDivElement useTabShortcut(index, () => props.onNavigate(tab, ref)) - - const dragged = () => drag.active && drag.draggedId === id const serverCtx = createMemo(() => { if (tab.type !== "session") return const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) @@ -479,68 +264,50 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} activeServerKey={props.activeServerKey} forceTruncate={props.forceTruncate} - dragActive={drag.active} - dragged={dragged} - pressed={() => pressedId() === id} serverCtx={serverCtx} - suppressNavigation={() => suppressNavigation()} - onPointerDown={(event) => { - if (dragged()) return - onPointerDown(id, event) + onNavigate={(element) => { + ref = element + props.onNavigate(tab, element) }} - onNavigate={(element) => props.onNavigate(tab, element)} onClose={() => props.onClose(tab)} /> ) } return ( - { - if (dragged()) return - onPointerDown(id, event) - }} - ref={ref} - href={tabHref(tab)} + props.currentTab() === tab} title={language.t("command.session.new")} - onNavigate={() => props.onNavigate(tab, ref)} + onNavigate={(element) => { + ref = element + props.onNavigate(tab, element) + }} onClose={() => props.onClose(tab)} - suppressNavigation={() => suppressNavigation()} - active={props.currentTab() === tab} - pressed={pressedId() === id} - hidden={dragged()} /> ) }}
-
-