Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: James Long <longster@gmail.com> Co-authored-by: James Long <jlongster@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: 冯基魁 <56265583+fengjikui@users.noreply.github.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: Kit Langton <kit.langton@gmail.com> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com> Co-authored-by: Jay <air@live.ca>
387 lines
14 KiB
TypeScript
387 lines
14 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, getOwner, onCleanup, 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"
|
|
import { createTabMemory } from "./tab-memory"
|
|
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
|
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
|
|
|
|
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
|
|
|
|
export type TabInfo = {
|
|
title?: string
|
|
directory?: string
|
|
}
|
|
|
|
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.window("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.window("tabs.recent"), createStore<RecentTab>({}))
|
|
const [info, setInfo] = persisted(Persist.window("tabs.info"), createStore<Record<string, TabInfo>>({}))
|
|
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
|
|
|
const params = useParams()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const memory = createTabMemory(getOwner())
|
|
|
|
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 updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
|
|
const apply = () => setClosed((stack) => update(stack))
|
|
if (closedReady()) {
|
|
apply()
|
|
return
|
|
}
|
|
void closedReady.promise?.then(apply)
|
|
}
|
|
|
|
const removeDraftPersisted = (draftID: string) => {
|
|
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
|
}
|
|
|
|
const removeInfo = (key: string) => {
|
|
if (!info[key]) return
|
|
setInfo(
|
|
produce((draft) => {
|
|
delete draft[key]
|
|
}),
|
|
)
|
|
}
|
|
|
|
onCleanup(memory.dispose)
|
|
|
|
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) {
|
|
for (const tab of store) {
|
|
if (!servers.has(tab.server)) {
|
|
const key = tabKey(tab)
|
|
memory.remove(key)
|
|
removeInfo(key)
|
|
}
|
|
}
|
|
setStore(() => next)
|
|
}
|
|
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
|
const keys = new Set(next.map(tabKey))
|
|
for (const key of Object.keys(info)) {
|
|
if (!keys.has(key)) removeInfo(key)
|
|
}
|
|
})
|
|
|
|
createEffect(() => {
|
|
if (!closedReady()) return
|
|
const servers = new Set(server.list.map(ServerConnection.key))
|
|
const next = closed.filter((entry) => servers.has(entry.tab.server))
|
|
if (next.length !== closed.length) setClosed(() => next)
|
|
})
|
|
|
|
const navigateTab = (tab: Tab) => {
|
|
const href = tabHref(tab)
|
|
setRecentKey(tabKey(tab))
|
|
navigate(href)
|
|
}
|
|
|
|
const removeTab = (index: number) => {
|
|
const tab = store[index]
|
|
if (!tab) return
|
|
const key = tabKey(tab)
|
|
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
|
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
|
|
closing.add(key)
|
|
void startTransition(() => {
|
|
setStore(
|
|
produce((tabs) => {
|
|
tabs.splice(index, 1)
|
|
}),
|
|
)
|
|
if (nextTab === null) {
|
|
setRecentKey(undefined)
|
|
navigate("/")
|
|
}
|
|
if (nextTab) navigateTab(nextTab)
|
|
}).finally(() => closing.delete(key))
|
|
memory.remove(key)
|
|
removeInfo(key)
|
|
if (draftID) removeDraftPersisted(draftID)
|
|
}
|
|
|
|
const actions = {
|
|
addSessionTab: (tab: Omit<SessionTab, "type">) => {
|
|
const next = { type: "session" as const, ...tab }
|
|
const existing = store.find((item) => tabKey(item) === tabKey(next))
|
|
if (existing) return existing
|
|
void startTransition(() => {
|
|
setStore(
|
|
produce((tabs) => {
|
|
if (tabs.some((item) => tabKey(item) === tabKey(next))) return
|
|
tabs.push(next)
|
|
}),
|
|
)
|
|
})
|
|
return next
|
|
},
|
|
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)
|
|
}),
|
|
)
|
|
},
|
|
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
|
|
},
|
|
async newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
|
|
const draftID = uuid()
|
|
const tab = { type: "draft" as const, draftID, ...draft }
|
|
memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model }))
|
|
await startTransition(() => {
|
|
setStore(
|
|
produce((tabs) => {
|
|
tabs.push(tab)
|
|
}),
|
|
)
|
|
navigate(draftHref(draftID))
|
|
})
|
|
return tab
|
|
},
|
|
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
|
|
void startTransition(() => {
|
|
setStore(
|
|
(tab) => tab.type === "draft" && tab.draftID === draftID,
|
|
produce((tab) => Object.assign(tab, draft)),
|
|
)
|
|
})
|
|
},
|
|
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
|
|
// Keep the replacement and navigation atomic so /new-session never renders
|
|
// after its backing draft tab has been removed from the store.
|
|
const active = location.pathname === "/new-session" && location.query.draftId === draftID
|
|
const next = { type: "session" as const, ...session }
|
|
void 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)
|
|
})
|
|
memory.remove(`draft:${draftID}`)
|
|
removeDraftPersisted(draftID)
|
|
},
|
|
removeTab,
|
|
// User-initiated close: records the tab so it can be reopened.
|
|
// Cleanup paths (missing sessions, archive, server removal) go through
|
|
// removeTab and friends directly and are not recorded.
|
|
closeTab(index: number) {
|
|
const tab = store[index]
|
|
if (!tab) return
|
|
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
|
|
removeTab(index)
|
|
},
|
|
reopenClosedTab() {
|
|
if (!closedReady()) {
|
|
void closedReady.promise?.then(() => actions.reopenClosedTab())
|
|
return
|
|
}
|
|
const result = takeClosedTab(closed, store)
|
|
if (result.stack.length === closed.length) return
|
|
setClosed(() => result.stack)
|
|
const entry = result.entry
|
|
if (!entry) return
|
|
const index = Math.min(entry.index, store.length)
|
|
void startTransition(() => {
|
|
setStore(
|
|
produce((tabs) => {
|
|
if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
|
|
tabs.splice(index, 0, entry.tab)
|
|
}),
|
|
)
|
|
navigateTab(entry.tab)
|
|
})
|
|
},
|
|
removeSessionTab(input: Omit<SessionTab, "type">) {
|
|
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
|
|
const index = store.findIndex(
|
|
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
|
|
)
|
|
if (index !== -1) removeTab(index)
|
|
},
|
|
removeServer(key: ServerConnection.Key) {
|
|
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== 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))
|
|
for (const key of removed) memory.remove(key)
|
|
for (const key of removed) removeInfo(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 targetServer = input.server ?? server.key
|
|
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
|
|
const removed = store
|
|
.filter(
|
|
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
|
)
|
|
.map(tabKey)
|
|
void startTransition(() => {
|
|
setStore(
|
|
produce((tabs) => {
|
|
const sessionIDs = new Set(input.sessionIDs)
|
|
const currentHref =
|
|
targetServer === server.key && params.dir && params.id
|
|
? tabHref({
|
|
type: "session",
|
|
server: targetServer,
|
|
sessionId: params.id,
|
|
})
|
|
: undefined
|
|
const currentIndex = currentHref
|
|
? tabs.findIndex(
|
|
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
|
|
)
|
|
: -1
|
|
const currentTab = tabs[currentIndex]
|
|
const removedCurrent =
|
|
currentTab?.type === "session" &&
|
|
currentTab.server === targetServer &&
|
|
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 !== targetServer) 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)
|
|
})
|
|
for (const key of removed) memory.remove(key)
|
|
for (const key of removed) removeInfo(key)
|
|
},
|
|
rememberSessionInfo(tab: SessionTab, session: Session) {
|
|
const key = tabKey(tab)
|
|
const next = { title: session.title, directory: session.directory }
|
|
const current = info[key]
|
|
if (current?.title === next.title && current.directory === next.directory) return
|
|
setInfo(key, next)
|
|
},
|
|
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("/")
|
|
},
|
|
state<T>(tab: Tab, name: string, init: () => T) {
|
|
return memory.ensure(tabKey(tab), name, init)
|
|
},
|
|
stateValue<T>(tab: Tab, name: string) {
|
|
return memory.get<T>(tabKey(tab), name)
|
|
},
|
|
}
|
|
|
|
return { ...actions, store, info, ready, recentReady }
|
|
},
|
|
})
|