opencode/packages/app/src/components/titlebar-tab-strip.tsx
2026-07-07 05:11:08 +00:00

333 lines
11 KiB
TypeScript

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"
import { useGlobal, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
import { createTabPromptState } from "@/context/prompt"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
function SessionTabSlot(props: {
tab: SessionTab
id: string
index: () => number
active: () => boolean
forceTruncate: boolean
serverCtx: () => ServerCtx | undefined
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
const tabs = useTabs()
const language = useLanguage()
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))
const persisted = createMemo(() => tabs.info[props.id])
const [loadedSession] = createResource(
() => {
const ctx = props.serverCtx()
return ctx ? { id: props.tab.sessionId, ctx } : null
},
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
)
const session = createMemo(() => cachedSession() ?? loadedSession())
const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session())
let prefetched = false
createEffect(() => {
const ctx = props.serverCtx()
const value = session()
if (!ctx || !value || prefetched) return
prefetched = true
createRoot((dispose) => {
try {
void ctx.sync
.ensureDirSyncContext(value.directory)
.session.sync(value.id)
.catch(() => {})
.finally(dispose)
} catch {
dispose()
}
})
})
createEffect(() => {
const value = session()
if (!value) return
tabs.rememberSessionInfo(props.tab, value)
const current = sdk()
if (!current) return
createTabPromptState(tabs, props.tab, current.scope, {
dir: base64Encode(value.directory),
id: value.id,
})
})
return (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
classList={{ hidden: !session() && !missingSession() && !persisted()?.title }}
>
<TabNavItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
server={props.tab.server}
session={session}
fallbackTitle={persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined)}
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()}
forceTruncate={props.forceTruncate}
dragging={sortable.isDragSource()}
/>
</div>
)
}
function DraftTabSlot(props: {
tab: Extract<Tab, { type: "draft" }>
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 (
<div
ref={sortable.ref}
data-titlebar-tab-slot
data-tab-key={props.id}
data-active={props.active()}
class="relative flex w-56 min-w-7 max-w-56 flex-shrink"
>
<DraftTabItem
ref={(el) => {
ref = el
}}
href={tabHref(props.tab)}
title={props.title}
onNavigate={() => props.onNavigate(ref)}
onClose={props.onClose}
active={props.active()}
dragging={sortable.isDragSource()}
/>
</div>
)
}
export function TitlebarTabStrip(props: {
tabs: Tab[]
currentTab: () => Tab | undefined
forceTruncate: boolean
onNavigate: (tab: Tab, el?: HTMLDivElement) => void
onClose: (tab: Tab) => void
onReorder: (keys: string[]) => void
onOverflowChange: (overflowing: boolean) => void
}) {
const global = useGlobal()
const language = useLanguage()
let scrollRef!: HTMLDivElement
let listRef!: HTMLDivElement
let resizeFrame: number | undefined
const tabIds = () => props.tabs.map(tabKey)
function refreshOverflow() {
if (!scrollRef) return
props.onOverflowChange(scrollRef.scrollWidth > scrollRef.clientWidth)
}
createResizeObserver(
() => [scrollRef, listRef],
() => {
if (resizeFrame !== undefined) return
resizeFrame = requestAnimationFrame(() => {
resizeFrame = undefined
refreshOverflow()
})
},
)
onMount(() => {
refreshOverflow()
})
onCleanup(() => {
if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame)
})
createEffect(() => {
props.tabs.length
tabIds()
refreshOverflow()
})
return (
<div data-slot="titlebar-tabs" class="relative min-w-0">
<div
data-slot="titlebar-tabs-scroll"
class="flex min-w-0 flex-row items-center gap-1.5 overflow-x-auto no-scrollbar [app-region:no-drag]"
ref={scrollRef}
>
<DragDropProvider
sensors={[
PointerSensor.configure({
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
preventActivation: (event) =>
!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<HTMLDivElement>("[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))
}
}}
>
<div data-titlebar-tab-list class="flex w-full min-w-0 flex-row items-center" ref={listRef}>
<For each={props.tabs}>
{(tab, index) => {
const id = tabKey(tab)
let ref!: HTMLDivElement
useTabShortcut(index, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => {
if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
})
if (tab.type === "session") {
return (
<SessionTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
forceTruncate={props.forceTruncate}
serverCtx={serverCtx}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
/>
)
}
return (
<DraftTabSlot
tab={tab}
id={id}
index={index}
active={() => props.currentTab() === tab}
title={language.t("command.session.new")}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
}}
onClose={() => props.onClose(tab)}
/>
)
}}
</For>
</div>
</DragDropProvider>
</div>
<div
data-slot="titlebar-tabs-fade-left"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 left-0 z-10 w-6 bg-[linear-gradient(to_right,var(--v2-background-bg-deep),transparent)]"
/>
<div
data-slot="titlebar-tabs-fade-right"
aria-hidden="true"
class="pointer-events-none absolute inset-y-0 right-0 z-10 w-6 bg-[linear-gradient(to_left,var(--v2-background-bg-deep),transparent)]"
/>
</div>
)
}
function useTabShortcut(index: () => number, onSelect: () => void) {
const command = useCommand()
command.register(() => {
const number = index() + 1
if (number > 9) return []
return [
{
id: `tab.${number}`,
category: "tab",
title: "",
keybind: `mod+${number}`,
hidden: true,
onSelect,
},
]
})
}