diff --git a/packages/app/src/components/titlebar-tab-drag.ts b/packages/app/src/components/titlebar-tab-drag.ts new file mode 100644 index 000000000000..92c65768c7e0 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-drag.ts @@ -0,0 +1,137 @@ +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.querySelector("[data-titlebar-tab]") + if (!tab) continue + tabWidthById.set(id, tab.getBoundingClientRect().width) + } + + let dividerWidth = 0 + if (order.length >= 2) { + const secondId = order[1] + for (const slot of slots) { + if (slot.dataset.tabKey !== secondId) continue + const tab = slot.querySelector("[data-titlebar-tab]") + if (!tab) break + dividerWidth = slot.getBoundingClientRect().width - tab.getBoundingClientRect().width + 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 + + if (currentIndex > 0) { + const seam = slotLeft(others, currentIndex, layout) + if (pointerX < seam - deadband) target = currentIndex - 1 + } + + if (target === currentIndex && currentIndex < order.length - 1) { + const seam = slotLeft(others, currentIndex + 1, layout) + if (pointerX >= seam) target = currentIndex + 1 + } + + 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-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx new file mode 100644 index 000000000000..b0f78748991e --- /dev/null +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -0,0 +1,140 @@ +import { createMemo, createResource, Show } from "solid-js" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { getProjectAvatarVariant, type LocalProject } from "@/context/layout" +import { useGlobal } from "@/context/global" +import { ServerConnection } from "@/context/server" +import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" + +function ProjectTabAvatar(props: { + project?: LocalProject + directory: string + sessionId: string + activeServer: boolean +}) { + const directory = () => props.directory + const sessionId = () => props.sessionId + const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) + return ( + + ) +} + +export function TabNavItem(props: { + ref?: HTMLDivElement + href: string + server: ServerConnection.Key + directory: string + sessionId?: string + onClose: () => void + onNavigate: () => void + active?: boolean + activeServer: boolean + forceTruncate?: boolean + suppressNavigation?: () => boolean + dragging?: boolean + pressed?: boolean + hidden?: boolean +}) { + const closeTab = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + props.onClose() + } + const global = useGlobal() + const serverCtx = createMemo(() => { + const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) + if (conn) return global.createServerCtx(conn) + }) + const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) + + const [session] = createResource( + () => { + const ctx = dirSyncCtx() + if (!ctx || !props.sessionId) return + return [props.sessionId, ctx] as const + }, + async ([sessionId, dirSyncCtx]) => { + await dirSyncCtx.session.sync(sessionId).catch(() => {}) + return dirSyncCtx.session.get(sessionId) + }, + { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, + ) + + return ( +
{ + if (event.button !== 1) return + closeTab(event) + }} + > + + {(session) => { + const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) + + return ( + { + 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]" + > + + + + {session().title} + + ) + }} + + +
+
+ } + /> +
+
+ ) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx new file mode 100644 index 000000000000..c5b39591ae30 --- /dev/null +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -0,0 +1,399 @@ +import { + createEffect, + createMemo, + createSignal, + For, + onCleanup, + onMount, + Show, + type JSX, +} from "solid-js" +import { Portal } from "solid-js/web" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { decode64 } from "@/utils/base64" +import { tabHref, tabKey, type Tab } from "@/context/tabs" +import { ServerConnection } from "@/context/server" +import { TabNavItem } from "@/components/titlebar-tab-nav" +import { + ACTIVATION_DISTANCE, + autoscrollSpeed, + captureTabDragLayout, + clampFloaterLeft, + draftOrderChanged, + insertIndexFromVirtualLayout, + movePlaceholder, + pointerDistance, + syncLayoutScroll, + type TabDragLayout, +} from "@/components/titlebar-tab-drag" + +export function TitlebarTabStrip(props: { + tabs: Tab[] + currentTab: () => Tab | undefined + activeServerKey: ServerConnection.Key + forceTruncate: boolean + onNavigate: (tab: Tab, el: HTMLDivElement) => void + onClose: (tab: Tab) => void + onReorder: (keys: string[]) => void + onOverflowChange: (overflowing: boolean) => void + children?: JSX.Element +}) { + 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 + } + | 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 + + 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) + } + + 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) + 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 + 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 + const tabEl = (event.currentTarget as HTMLElement).querySelector("[data-titlebar-tab]") + if (!tabEl) return + const tab = props.tabs.find((item) => tabKey(item) === id) + if (!tab) return + setSuppressNavigation(true) + // Select the tab on press (before drag threshold), matching native browser tab strips. + props.onNavigate(tab, tabEl) + setPressedId(id) + const rect = tabEl.getBoundingClientRect() + setGesture("pending", { + id, + startX: event.clientX, + startY: event.clientY, + grabOffsetX: event.clientX - rect.left, + grabOffsetY: event.clientY - rect.top, + pointerId: event.pointerId, + width: rect.width, + }) + } + + function onPointerMove(event: PointerEvent) { + const pending = gesture.pending + if (pending && !drag.active) { + if (event.pointerId !== pending.pointerId) return + if (pointerDistance(pending.startX, pending.startY, event.clientX, event.clientY) < ACTIVATION_DISTANCE) return + startDrag(pending.id) + } + + if (!drag.active) return + if (dragPointerId !== undefined && event.pointerId !== dragPointerId) 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() + return () => cleanups.forEach((cleanup) => cleanup()) + }) + + onCleanup(stopAutoscroll) + + createEffect(() => { + props.tabs.length + tabIds() + refreshOverflow() + }) + + createEffect(() => { + if (!drag.active || !scrollRef) return + return 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 ( + <> +
+
+ + {(tab, index) => { + const id = tabKey(tab) + const first = () => index() === 0 + let ref!: HTMLDivElement + + const dragged = () => drag.active && drag.draggedId === id + + return ( +
{ + if (dragged()) return + onPointerDown(id, event) + }} + > + props.onNavigate(tab, ref)} + onClose={() => props.onClose(tab)} + active={props.currentTab() === tab} + activeServer={tab.server === props.activeServerKey} + forceTruncate={props.forceTruncate} + suppressNavigation={() => suppressNavigation()} + pressed={pressedId() === id} + hidden={dragged()} + /> +
+ ) + }} +
+ {props.children} +
+
+ + {(tab) => ( + +
+ {}} + onClose={() => {}} + active={props.currentTab() === tab()} + activeServer={tab().server === props.activeServerKey} + forceTruncate={props.forceTruncate} + dragging + /> +
+
+ )} +
+ + ) +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 371cf87c5cbd..d06576ab494a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,7 +1,6 @@ import { createEffect, createMemo, - createResource, createSignal, For, Match, @@ -21,7 +20,7 @@ import { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" +import { LayoutRoute, useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -30,15 +29,11 @@ import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" import { useServerSync } from "@/context/server-sync" import { base64Encode } from "@opencode-ai/core/util/encode" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" -import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" -import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" +import { TitlebarTabStrip } from "@/components/titlebar-tab-strip" import { makeEventListener } from "@solid-primitives/event-listener" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" -import { useGlobal } from "@/context/global" -import { decode64 } from "@/utils/base64" -import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs, type Tab } from "@/context/tabs" +import { useServer } from "@/context/server" +import { tabHref, tabKey, useTabs, type Tab } from "@/context/tabs" type TauriDesktopWindow = { startDragging?: () => Promise @@ -406,11 +401,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { }) const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false) - let tabScrollRef!: HTMLDivElement - - function refreshTabsAreOverflowing() { - setTabsAreOverflowing(tabScrollRef.scrollWidth > tabScrollRef.clientWidth) - } return (
-
{ + navigateTab(tab) + el.scrollIntoView({ behavior: "instant" }) + }} + onClose={(tab) => { + const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) + if (index !== -1) tabsStoreActions.removeTab(index) + }} + onReorder={(keys) => tabsStoreActions.reorder(keys)} > -
- - {(tab, i) => { - let ref!: HTMLDivElement - - onMount(() => { - refreshTabsAreOverflowing() - }) - - return ( - <> - {i() !== 0 && ( -
- )} - { - navigateTab(tab) - - ref.scrollIntoView({ behavior: "instant" }) - }} - onClose={() => tabsStoreActions.removeTab(i())} - active={currentTab() === tab} - activeServer={tab.server === server.key} - forceTruncate={tabsAreOverflowing()} - /> - - ) - }} - - - {(_) => { - let ref!: HTMLDivElement - - onMount(() => { - ref.scrollIntoView({ behavior: "instant" }) - }) - - return ( - <> -
- { - const tab = tabsStore.at(-1) - if (tab) navigateTab(tab) - else navigate("/") - }} - /> - - ) - }} - -
-
+ + {(_) => { + let ref!: HTMLDivElement + + onMount(() => { + ref.scrollIntoView({ behavior: "instant" }) + }) + + return ( + <> +
+ { + const tab = tabsStore.at(-1) + if (tab) navigateTab(tab) + else navigate("/") + }} + /> + + ) + }} + + void - onNavigate: () => void - active?: boolean - activeServer: boolean - forceTruncate?: boolean -}) { - const closeTab = (event: MouseEvent) => { - event.preventDefault() - event.stopPropagation() - props.onClose() - } - const global = useGlobal() - const serverCtx = createMemo(() => { - const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) - if (conn) return global.createServerCtx(conn) - }) - const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) - - const [session] = createResource( - () => { - const ctx = dirSyncCtx() - if (!ctx || !props.sessionId) return - return [props.sessionId, ctx] as const - }, - async ([sessionId, dirSyncCtx]) => { - await dirSyncCtx.session.sync(sessionId).catch(() => {}) - return dirSyncCtx.session.get(sessionId) - }, - { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, - ) - - return ( -
{ - if (event.button !== 1) return - closeTab(event) - }} - > - - {(session) => { - console.log({ session: session() }) - const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) - - return ( - { - event.preventDefault() - 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" - > - - - - {session().title} - - ) - }} - - -
-
- } - /> -
-
- ) -} - -function ProjectTabAvatar(props: { - project?: LocalProject - directory: string - sessionId: string - activeServer: boolean -}) { - const directory = () => props.directory - const sessionId = () => props.sessionId - const state = useSessionTabAvatarState(directory, sessionId, () => props.activeServer) - return ( - - ) -} - function NewSessionTabItem(props: { ref?: HTMLDivElement; href: string; title: string; onClose: () => void }) { const closeTab = (event: MouseEvent) => { event.preventDefault() diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 17374983799c..ac9368630ef6 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -83,6 +83,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }), ) }, + reorder(keys: string[]) { + setStore( + produce((tabs) => { + const byKey = new Map(tabs.map((tab) => [tabKey(tab), tab])) + const next = keys.map((key) => byKey.get(key)).filter((tab): tab is Tab => !!tab) + if (next.length !== tabs.length) return + tabs.splice(0, tabs.length, ...next) + }), + ) + }, removeTab: (index: number) => { const tab = store[index] if (!tab) return