opencode/packages/app/src/context/tabs.tsx
opencode-agent[bot] 09757c605a chore: generate
2026-06-23 06:20:50 +00:00

253 lines
9.3 KiB
TypeScript

import type { Session } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { createEffect, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
import { uuid } from "@/utils/uuid"
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { sessionHref } from "@/utils/session-route"
export type SessionTab = {
type: "session"
server: ServerConnection.Key
sessionId: string
}
export type DraftTab = {
type: "draft"
draftID: string
server: ServerConnection.Key
directory: string
worktree?: string
}
export type Tab = SessionTab | DraftTab
type RecentTab = {
key?: string
}
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId)
export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`)
export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) {
return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id)
}
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const server = useServer()
const platform = usePlatform()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.global("tabs"),
migrate: (value: unknown) => {
if (!Array.isArray(value)) return value
return value.map((tab) => {
if (!tab || typeof tab !== "object" || "server" in tab) return tab
return { ...tab, server: fallback }
})
},
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
const closing = new Set<string>()
let recentWrite = 0
let recentValue: string | undefined
const recentKey = () => (recentWrite ? recentValue : recent.key)
const setRecentKey = (key: string | undefined) => {
const write = ++recentWrite
recentValue = key
if (recentReady()) {
setRecent("key", key)
return
}
void recentReady.promise?.then(() => {
if (write === recentWrite) setRecent("key", key)
})
}
const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
}
createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server))
if (next.length !== store.length) setStore(() => next)
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
})
const navigateTab = (tab: Tab) => {
const href = tabHref(tab)
setRecentKey(tabKey(tab))
navigate(href)
}
const actions = {
addSessionTab: (tab: Omit<SessionTab, "type">) => {
const next = { type: "session" as const, ...tab }
if (closing.has(tabKey(next))) return
setStore(
produce((tabs) => {
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
tabs.push(next)
}),
)
},
draft(draftID: string) {
const tab = store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
const draftID = uuid()
setStore(
produce((tabs) => {
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// We're viewing this draft when /new-session?draftId=… points at it. Promoting
// replaces the draft tab with a session tab, so the draft route would stop resolving
// and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
startTransition(() => {
setStore(
produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = next
}),
)
if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
})
removeDraftPersisted(draftID)
},
removeTab: (index: number) => {
const tab = store[index]
if (!tab) return
const key = tabKey(tab)
const draftID = tab.type === "draft" ? tab.draftID : undefined
const nextTab = store[index + 1] ?? store[index - 1]
closing.add(key)
void startTransition(() => {
setStore(
produce((tabs) => {
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
if (draftID) removeDraftPersisted(draftID)
},
removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
const removed = store.filter((tab) => tab.server === key).map(tabKey)
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
const removed = store
.filter(
(tab) => tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
)
.map(tabKey)
void startTransition(() => {
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
params.dir && params.id
? tabHref({
type: "session",
server: server.key,
sessionId: params.id,
})
: undefined
const currentIndex = currentHref
? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref,
)
: -1
const currentTab = tabs[currentIndex]
const removedCurrent =
currentTab?.type === "session" &&
currentTab.server === server.key &&
sessionIDs.has(currentTab.sessionId)
for (let i = tabs.length - 1; i >= 0; i--) {
const tab = tabs[i]
if (!tab || tab.type !== "session") continue
if (tab.server !== server.key) continue
if (!sessionIDs.has(tab.sessionId)) continue
tabs.splice(i, 1)
}
if (!removedCurrent) return
const nextTab =
tabs.slice(currentIndex).find((tab) => tab.type === "session") ??
tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session")
if (nextTab) navigateTab(nextTab)
else navigate("/")
}),
)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
})
},
select: navigateTab,
remember(tab: Tab) {
const key = tabKey(tab)
if (recentKey() !== key) setRecentKey(key)
},
toggleHome(input: { home: boolean; current?: Tab }) {
if (input.home) {
const tab = store.find((tab) => tabKey(tab) === recentKey())
if (tab) navigateTab(tab)
return
}
if (input.current) {
setRecentKey(tabKey(input.current))
navigate("/")
return
}
navigate("/")
},
}
return { ...actions, store, ready, recentReady }
},
})