340 lines
9.8 KiB
TypeScript
340 lines
9.8 KiB
TypeScript
import { createStore, produce } from "solid-js/store"
|
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
|
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
|
|
import { useParams } from "@solidjs/router"
|
|
import { useSDK } from "./sdk"
|
|
import type { Platform } from "./platform"
|
|
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
|
|
|
|
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>>()
|
|
|
|
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()
|
|
}
|
|
|
|
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) {
|
|
removePersisted({ key }, platform)
|
|
}
|
|
}
|
|
|
|
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
|
|
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
|
|
|
|
const numberFromTitle = (title: string) => {
|
|
const match = title.match(/^Terminal (\d+)$/)
|
|
if (!match) return
|
|
const value = Number(match[1])
|
|
if (!Number.isFinite(value) || value <= 0) return
|
|
return value
|
|
}
|
|
|
|
const [store, setStore, _, ready] = persisted(
|
|
Persist.workspace(dir, "terminal", legacy),
|
|
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 meta = { migrated: false }
|
|
|
|
createEffect(() => {
|
|
if (!ready()) return
|
|
if (meta.migrated) return
|
|
meta.migrated = true
|
|
|
|
setStore("all", (all) => {
|
|
const next = all.map((pty) => {
|
|
const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
|
|
if (direct !== undefined) return pty
|
|
const parsed = numberFromTitle(pty.title)
|
|
if (parsed === undefined) return pty
|
|
return { ...pty, titleNumber: parsed }
|
|
})
|
|
if (next.every((pty, index) => pty === all[index])) return all
|
|
return next
|
|
})
|
|
})
|
|
|
|
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: `Terminal ${nextNumber}` })
|
|
.then((pty: { data?: { id?: string; title?: string } }) => {
|
|
const id = pty.data?.id
|
|
if (!id) return
|
|
const newTerminal = {
|
|
id,
|
|
title: pty.data?.title ?? "Terminal",
|
|
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 }) {
|
|
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 }))
|
|
}
|
|
sdk.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)
|
|
})
|
|
},
|
|
async clone(id: string) {
|
|
const index = store.all.findIndex((x) => x.id === id)
|
|
const pty = store.all[index]
|
|
if (!pty) return
|
|
const clone = await sdk.client.pty
|
|
.create({
|
|
title: pty.title,
|
|
})
|
|
.catch((error: unknown) => {
|
|
console.error("Failed to clone terminal", error)
|
|
return undefined
|
|
})
|
|
if (!clone?.data) return
|
|
|
|
const active = store.active === pty.id
|
|
|
|
batch(() => {
|
|
setStore("all", index, {
|
|
id: clone.data.id,
|
|
title: clone.data.title ?? pty.title,
|
|
titleNumber: pty.titleNumber,
|
|
// New PTY process, so start clean.
|
|
buffer: undefined,
|
|
cursor: undefined,
|
|
scrollY: undefined,
|
|
rows: undefined,
|
|
cols: undefined,
|
|
})
|
|
if (active) {
|
|
setStore("active", clone.data.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))
|
|
|
|
return {
|
|
ready: () => workspace().ready(),
|
|
all: () => workspace().all(),
|
|
active: () => workspace().active(),
|
|
new: () => workspace().new(),
|
|
update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
|
|
clone: (id: string) => workspace().clone(id),
|
|
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(),
|
|
}
|
|
},
|
|
})
|