333 lines
11 KiB
TypeScript
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,
|
|
},
|
|
]
|
|
})
|
|
}
|