| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437 |
- import { createStore, produce } from "solid-js/store"
- import { createSimpleContext } from "@opencode-ai/ui/context"
- import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
- import { useParams } from "@solidjs/router"
- import { useSDK } from "./sdk"
- import type { Platform } from "./platform"
- import { defaultTitle, titleNumber } from "./terminal-title"
- import { Persist, persisted, removePersisted } from "@/utils/persist"
- export type LocalPTY = {
- id: string
- title: string
- titleNumber: number
- rows?: number
- cols?: number
- buffer?: string
- scrollY?: number
- cursor?: number
- }
- const WORKSPACE_KEY = "__workspace__"
- const MAX_TERMINAL_SESSIONS = 20
- function record(value: unknown): value is Record<string, unknown> {
- return typeof value === "object" && value !== null && !Array.isArray(value)
- }
- function text(value: unknown) {
- return typeof value === "string" ? value : undefined
- }
- function num(value: unknown) {
- return typeof value === "number" && Number.isFinite(value) ? value : undefined
- }
- function numberFromTitle(title: string) {
- return titleNumber(title, MAX_TERMINAL_SESSIONS)
- }
- function pty(value: unknown): LocalPTY | undefined {
- if (!record(value)) return
- const id = text(value.id)
- if (!id) return
- const title = text(value.title) ?? ""
- const number = num(value.titleNumber)
- const rows = num(value.rows)
- const cols = num(value.cols)
- const buffer = text(value.buffer)
- const scrollY = num(value.scrollY)
- const cursor = num(value.cursor)
- return {
- id,
- title,
- titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
- ...(rows !== undefined ? { rows } : {}),
- ...(cols !== undefined ? { cols } : {}),
- ...(buffer !== undefined ? { buffer } : {}),
- ...(scrollY !== undefined ? { scrollY } : {}),
- ...(cursor !== undefined ? { cursor } : {}),
- }
- }
- export function migrateTerminalState(value: unknown) {
- if (!record(value)) return value
- const seen = new Set<string>()
- const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
- const next = pty(item)
- if (!next || seen.has(next.id)) return []
- seen.add(next.id)
- return [next]
- })
- const active = text(value.active)
- return {
- active: active && seen.has(active) ? active : all[0]?.id,
- all,
- }
- }
- export function getWorkspaceTerminalCacheKey(dir: string) {
- return `${dir}:${WORKSPACE_KEY}`
- }
- export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
- if (!legacySessionID) return [`${dir}/terminal.v1`]
- return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
- }
- type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
- type TerminalCacheEntry = {
- value: TerminalSession
- dispose: VoidFunction
- }
- const caches = new Set<Map<string, TerminalCacheEntry>>()
- const trimTerminal = (pty: LocalPTY) => {
- if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
- return {
- ...pty,
- buffer: undefined,
- cursor: undefined,
- scrollY: undefined,
- }
- }
- export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
- const key = getWorkspaceTerminalCacheKey(dir)
- for (const cache of caches) {
- const entry = cache.get(key)
- entry?.value.clear()
- }
- void removePersisted(Persist.workspace(dir, "terminal"), platform)
- const legacy = new Set(getLegacyTerminalStorageKeys(dir))
- for (const id of sessionIDs ?? []) {
- for (const key of getLegacyTerminalStorageKeys(dir, id)) {
- legacy.add(key)
- }
- }
- for (const key of legacy) {
- void removePersisted({ key }, platform)
- }
- }
- function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
- const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
- const [store, setStore, _, ready] = persisted(
- {
- ...Persist.workspace(dir, "terminal", legacy),
- migrate: migrateTerminalState,
- },
- createStore<{
- active?: string
- all: LocalPTY[]
- }>({
- all: [],
- }),
- )
- const pickNextTerminalNumber = () => {
- const existingTitleNumbers = new Set(
- store.all.flatMap((pty) => {
- const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
- if (direct !== undefined) return [direct]
- const parsed = numberFromTitle(pty.title)
- if (parsed === undefined) return []
- return [parsed]
- }),
- )
- return (
- Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
- (number) => !existingTitleNumbers.has(number),
- ) ?? 1
- )
- }
- const removeExited = (id: string) => {
- const all = store.all
- const index = all.findIndex((x) => x.id === id)
- if (index === -1) return
- const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
- batch(() => {
- setStore("active", active)
- setStore(
- "all",
- produce((draft) => {
- draft.splice(index, 1)
- }),
- )
- })
- }
- const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
- removeExited(event.properties.id)
- })
- onCleanup(unsub)
- const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
- const index = store.all.findIndex((x) => x.id === pty.id)
- const previous = index >= 0 ? store.all[index] : undefined
- if (index >= 0) {
- setStore("all", index, (item) => ({ ...item, ...pty }))
- }
- client.pty
- .update({
- ptyID: pty.id,
- title: pty.title,
- size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
- })
- .catch((error: unknown) => {
- if (previous) {
- const currentIndex = store.all.findIndex((item) => item.id === pty.id)
- if (currentIndex >= 0) setStore("all", currentIndex, previous)
- }
- console.error("Failed to update terminal", error)
- })
- }
- const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
- const index = store.all.findIndex((x) => x.id === id)
- const pty = store.all[index]
- if (!pty) return
- const next = await client.pty
- .create({
- title: pty.title,
- })
- .catch((error: unknown) => {
- console.error("Failed to clone terminal", error)
- return undefined
- })
- if (!next?.data) return
- const active = store.active === pty.id
- batch(() => {
- setStore("all", index, {
- id: next.data.id,
- title: next.data.title ?? pty.title,
- titleNumber: pty.titleNumber,
- buffer: undefined,
- cursor: undefined,
- scrollY: undefined,
- rows: undefined,
- cols: undefined,
- })
- if (active) {
- setStore("active", next.data.id)
- }
- })
- }
- return {
- ready,
- all: createMemo(() => store.all),
- active: createMemo(() => store.active),
- clear() {
- batch(() => {
- setStore("active", undefined)
- setStore("all", [])
- })
- },
- new() {
- const nextNumber = pickNextTerminalNumber()
- sdk.client.pty
- .create({ title: defaultTitle(nextNumber) })
- .then((pty: { data?: { id?: string; title?: string } }) => {
- const id = pty.data?.id
- if (!id) return
- const newTerminal = {
- id,
- title: pty.data?.title ?? defaultTitle(nextNumber),
- titleNumber: nextNumber,
- }
- setStore("all", store.all.length, newTerminal)
- setStore("active", id)
- })
- .catch((error: unknown) => {
- console.error("Failed to create terminal", error)
- })
- },
- update(pty: Partial<LocalPTY> & { id: string }) {
- update(sdk.client, pty)
- },
- trim(id: string) {
- const index = store.all.findIndex((x) => x.id === id)
- if (index === -1) return
- setStore("all", index, (pty) => trimTerminal(pty))
- },
- trimAll() {
- setStore("all", (all) => {
- const next = all.map(trimTerminal)
- if (next.every((pty, index) => pty === all[index])) return all
- return next
- })
- },
- async clone(id: string) {
- await clone(sdk.client, id)
- },
- bind() {
- const client = sdk.client
- return {
- trim(id: string) {
- const index = store.all.findIndex((x) => x.id === id)
- if (index === -1) return
- setStore("all", index, (pty) => trimTerminal(pty))
- },
- update(pty: Partial<LocalPTY> & { id: string }) {
- update(client, pty)
- },
- async clone(id: string) {
- await clone(client, id)
- },
- }
- },
- open(id: string) {
- setStore("active", id)
- },
- next() {
- const index = store.all.findIndex((x) => x.id === store.active)
- if (index === -1) return
- const nextIndex = (index + 1) % store.all.length
- setStore("active", store.all[nextIndex]?.id)
- },
- previous() {
- const index = store.all.findIndex((x) => x.id === store.active)
- if (index === -1) return
- const prevIndex = index === 0 ? store.all.length - 1 : index - 1
- setStore("active", store.all[prevIndex]?.id)
- },
- async close(id: string) {
- const index = store.all.findIndex((f) => f.id === id)
- if (index !== -1) {
- batch(() => {
- if (store.active === id) {
- const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
- setStore("active", next)
- }
- setStore(
- "all",
- produce((all) => {
- all.splice(index, 1)
- }),
- )
- })
- }
- await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
- console.error("Failed to close terminal", error)
- })
- },
- move(id: string, to: number) {
- const index = store.all.findIndex((f) => f.id === id)
- if (index === -1) return
- setStore(
- "all",
- produce((all) => {
- all.splice(to, 0, all.splice(index, 1)[0])
- }),
- )
- },
- }
- }
- export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
- name: "Terminal",
- gate: false,
- init: () => {
- const sdk = useSDK()
- const params = useParams()
- const cache = new Map<string, TerminalCacheEntry>()
- caches.add(cache)
- onCleanup(() => caches.delete(cache))
- const disposeAll = () => {
- for (const entry of cache.values()) {
- entry.dispose()
- }
- cache.clear()
- }
- onCleanup(disposeAll)
- const prune = () => {
- while (cache.size > MAX_TERMINAL_SESSIONS) {
- const first = cache.keys().next().value
- if (!first) return
- const entry = cache.get(first)
- entry?.dispose()
- cache.delete(first)
- }
- }
- const loadWorkspace = (dir: string, legacySessionID?: string) => {
- // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
- const key = getWorkspaceTerminalCacheKey(dir)
- const existing = cache.get(key)
- if (existing) {
- cache.delete(key)
- cache.set(key, existing)
- return existing.value
- }
- const entry = createRoot((dispose) => ({
- value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
- dispose,
- }))
- cache.set(key, entry)
- prune()
- return entry.value
- }
- const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
- createEffect(
- on(
- () => ({ dir: params.dir, id: params.id }),
- (next, prev) => {
- if (!prev?.dir) return
- if (next.dir === prev.dir && next.id === prev.id) return
- if (next.dir === prev.dir && next.id) return
- loadWorkspace(prev.dir, prev.id).trimAll()
- },
- { defer: true },
- ),
- )
- return {
- ready: () => workspace().ready(),
- all: () => workspace().all(),
- active: () => workspace().active(),
- new: () => workspace().new(),
- update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
- trim: (id: string) => workspace().trim(id),
- trimAll: () => workspace().trimAll(),
- clone: (id: string) => workspace().clone(id),
- bind: () => workspace(),
- open: (id: string) => workspace().open(id),
- close: (id: string) => workspace().close(id),
- move: (id: string, to: number) => workspace().move(id, to),
- next: () => workspace().next(),
- previous: () => workspace().previous(),
- }
- },
- })
|