terminal.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  1. import { createStore, produce } from "solid-js/store"
  2. import { createSimpleContext } from "@opencode-ai/ui/context"
  3. import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
  4. import { useParams } from "@solidjs/router"
  5. import { useSDK } from "./sdk"
  6. import type { Platform } from "./platform"
  7. import { defaultTitle, titleNumber } from "./terminal-title"
  8. import { Persist, persisted, removePersisted } from "@/utils/persist"
  9. export type LocalPTY = {
  10. id: string
  11. title: string
  12. titleNumber: number
  13. rows?: number
  14. cols?: number
  15. buffer?: string
  16. scrollY?: number
  17. cursor?: number
  18. }
  19. const WORKSPACE_KEY = "__workspace__"
  20. const MAX_TERMINAL_SESSIONS = 20
  21. function record(value: unknown): value is Record<string, unknown> {
  22. return typeof value === "object" && value !== null && !Array.isArray(value)
  23. }
  24. function text(value: unknown) {
  25. return typeof value === "string" ? value : undefined
  26. }
  27. function num(value: unknown) {
  28. return typeof value === "number" && Number.isFinite(value) ? value : undefined
  29. }
  30. function numberFromTitle(title: string) {
  31. return titleNumber(title, MAX_TERMINAL_SESSIONS)
  32. }
  33. function pty(value: unknown): LocalPTY | undefined {
  34. if (!record(value)) return
  35. const id = text(value.id)
  36. if (!id) return
  37. const title = text(value.title) ?? ""
  38. const number = num(value.titleNumber)
  39. const rows = num(value.rows)
  40. const cols = num(value.cols)
  41. const buffer = text(value.buffer)
  42. const scrollY = num(value.scrollY)
  43. const cursor = num(value.cursor)
  44. return {
  45. id,
  46. title,
  47. titleNumber: number && number > 0 ? number : (numberFromTitle(title) ?? 0),
  48. ...(rows !== undefined ? { rows } : {}),
  49. ...(cols !== undefined ? { cols } : {}),
  50. ...(buffer !== undefined ? { buffer } : {}),
  51. ...(scrollY !== undefined ? { scrollY } : {}),
  52. ...(cursor !== undefined ? { cursor } : {}),
  53. }
  54. }
  55. export function migrateTerminalState(value: unknown) {
  56. if (!record(value)) return value
  57. const seen = new Set<string>()
  58. const all = (Array.isArray(value.all) ? value.all : []).flatMap((item) => {
  59. const next = pty(item)
  60. if (!next || seen.has(next.id)) return []
  61. seen.add(next.id)
  62. return [next]
  63. })
  64. const active = text(value.active)
  65. return {
  66. active: active && seen.has(active) ? active : all[0]?.id,
  67. all,
  68. }
  69. }
  70. export function getWorkspaceTerminalCacheKey(dir: string) {
  71. return `${dir}:${WORKSPACE_KEY}`
  72. }
  73. export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
  74. if (!legacySessionID) return [`${dir}/terminal.v1`]
  75. return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
  76. }
  77. type TerminalSession = ReturnType<typeof createWorkspaceTerminalSession>
  78. type TerminalCacheEntry = {
  79. value: TerminalSession
  80. dispose: VoidFunction
  81. }
  82. const caches = new Set<Map<string, TerminalCacheEntry>>()
  83. const trimTerminal = (pty: LocalPTY) => {
  84. if (!pty.buffer && pty.cursor === undefined && pty.scrollY === undefined) return pty
  85. return {
  86. ...pty,
  87. buffer: undefined,
  88. cursor: undefined,
  89. scrollY: undefined,
  90. }
  91. }
  92. export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
  93. const key = getWorkspaceTerminalCacheKey(dir)
  94. for (const cache of caches) {
  95. const entry = cache.get(key)
  96. entry?.value.clear()
  97. }
  98. void removePersisted(Persist.workspace(dir, "terminal"), platform)
  99. const legacy = new Set(getLegacyTerminalStorageKeys(dir))
  100. for (const id of sessionIDs ?? []) {
  101. for (const key of getLegacyTerminalStorageKeys(dir, id)) {
  102. legacy.add(key)
  103. }
  104. }
  105. for (const key of legacy) {
  106. void removePersisted({ key }, platform)
  107. }
  108. }
  109. function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
  110. const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
  111. const [store, setStore, _, ready] = persisted(
  112. {
  113. ...Persist.workspace(dir, "terminal", legacy),
  114. migrate: migrateTerminalState,
  115. },
  116. createStore<{
  117. active?: string
  118. all: LocalPTY[]
  119. }>({
  120. all: [],
  121. }),
  122. )
  123. const pickNextTerminalNumber = () => {
  124. const existingTitleNumbers = new Set(
  125. store.all.flatMap((pty) => {
  126. const direct = Number.isFinite(pty.titleNumber) && pty.titleNumber > 0 ? pty.titleNumber : undefined
  127. if (direct !== undefined) return [direct]
  128. const parsed = numberFromTitle(pty.title)
  129. if (parsed === undefined) return []
  130. return [parsed]
  131. }),
  132. )
  133. return (
  134. Array.from({ length: existingTitleNumbers.size + 1 }, (_, index) => index + 1).find(
  135. (number) => !existingTitleNumbers.has(number),
  136. ) ?? 1
  137. )
  138. }
  139. const removeExited = (id: string) => {
  140. const all = store.all
  141. const index = all.findIndex((x) => x.id === id)
  142. if (index === -1) return
  143. const active = store.active === id ? (index === 0 ? all[1]?.id : all[0]?.id) : store.active
  144. batch(() => {
  145. setStore("active", active)
  146. setStore(
  147. "all",
  148. produce((draft) => {
  149. draft.splice(index, 1)
  150. }),
  151. )
  152. })
  153. }
  154. const unsub = sdk.event.on("pty.exited", (event: { properties: { id: string } }) => {
  155. removeExited(event.properties.id)
  156. })
  157. onCleanup(unsub)
  158. const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
  159. const index = store.all.findIndex((x) => x.id === pty.id)
  160. const previous = index >= 0 ? store.all[index] : undefined
  161. if (index >= 0) {
  162. setStore("all", index, (item) => ({ ...item, ...pty }))
  163. }
  164. client.pty
  165. .update({
  166. ptyID: pty.id,
  167. title: pty.title,
  168. size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
  169. })
  170. .catch((error: unknown) => {
  171. if (previous) {
  172. const currentIndex = store.all.findIndex((item) => item.id === pty.id)
  173. if (currentIndex >= 0) setStore("all", currentIndex, previous)
  174. }
  175. console.error("Failed to update terminal", error)
  176. })
  177. }
  178. const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
  179. const index = store.all.findIndex((x) => x.id === id)
  180. const pty = store.all[index]
  181. if (!pty) return
  182. const next = await client.pty
  183. .create({
  184. title: pty.title,
  185. })
  186. .catch((error: unknown) => {
  187. console.error("Failed to clone terminal", error)
  188. return undefined
  189. })
  190. if (!next?.data) return
  191. const active = store.active === pty.id
  192. batch(() => {
  193. setStore("all", index, {
  194. id: next.data.id,
  195. title: next.data.title ?? pty.title,
  196. titleNumber: pty.titleNumber,
  197. buffer: undefined,
  198. cursor: undefined,
  199. scrollY: undefined,
  200. rows: undefined,
  201. cols: undefined,
  202. })
  203. if (active) {
  204. setStore("active", next.data.id)
  205. }
  206. })
  207. }
  208. return {
  209. ready,
  210. all: createMemo(() => store.all),
  211. active: createMemo(() => store.active),
  212. clear() {
  213. batch(() => {
  214. setStore("active", undefined)
  215. setStore("all", [])
  216. })
  217. },
  218. new() {
  219. const nextNumber = pickNextTerminalNumber()
  220. sdk.client.pty
  221. .create({ title: defaultTitle(nextNumber) })
  222. .then((pty: { data?: { id?: string; title?: string } }) => {
  223. const id = pty.data?.id
  224. if (!id) return
  225. const newTerminal = {
  226. id,
  227. title: pty.data?.title ?? defaultTitle(nextNumber),
  228. titleNumber: nextNumber,
  229. }
  230. setStore("all", store.all.length, newTerminal)
  231. setStore("active", id)
  232. })
  233. .catch((error: unknown) => {
  234. console.error("Failed to create terminal", error)
  235. })
  236. },
  237. update(pty: Partial<LocalPTY> & { id: string }) {
  238. update(sdk.client, pty)
  239. },
  240. trim(id: string) {
  241. const index = store.all.findIndex((x) => x.id === id)
  242. if (index === -1) return
  243. setStore("all", index, (pty) => trimTerminal(pty))
  244. },
  245. trimAll() {
  246. setStore("all", (all) => {
  247. const next = all.map(trimTerminal)
  248. if (next.every((pty, index) => pty === all[index])) return all
  249. return next
  250. })
  251. },
  252. async clone(id: string) {
  253. await clone(sdk.client, id)
  254. },
  255. bind() {
  256. const client = sdk.client
  257. return {
  258. trim(id: string) {
  259. const index = store.all.findIndex((x) => x.id === id)
  260. if (index === -1) return
  261. setStore("all", index, (pty) => trimTerminal(pty))
  262. },
  263. update(pty: Partial<LocalPTY> & { id: string }) {
  264. update(client, pty)
  265. },
  266. async clone(id: string) {
  267. await clone(client, id)
  268. },
  269. }
  270. },
  271. open(id: string) {
  272. setStore("active", id)
  273. },
  274. next() {
  275. const index = store.all.findIndex((x) => x.id === store.active)
  276. if (index === -1) return
  277. const nextIndex = (index + 1) % store.all.length
  278. setStore("active", store.all[nextIndex]?.id)
  279. },
  280. previous() {
  281. const index = store.all.findIndex((x) => x.id === store.active)
  282. if (index === -1) return
  283. const prevIndex = index === 0 ? store.all.length - 1 : index - 1
  284. setStore("active", store.all[prevIndex]?.id)
  285. },
  286. async close(id: string) {
  287. const index = store.all.findIndex((f) => f.id === id)
  288. if (index !== -1) {
  289. batch(() => {
  290. if (store.active === id) {
  291. const next = index > 0 ? store.all[index - 1]?.id : store.all[1]?.id
  292. setStore("active", next)
  293. }
  294. setStore(
  295. "all",
  296. produce((all) => {
  297. all.splice(index, 1)
  298. }),
  299. )
  300. })
  301. }
  302. await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => {
  303. console.error("Failed to close terminal", error)
  304. })
  305. },
  306. move(id: string, to: number) {
  307. const index = store.all.findIndex((f) => f.id === id)
  308. if (index === -1) return
  309. setStore(
  310. "all",
  311. produce((all) => {
  312. all.splice(to, 0, all.splice(index, 1)[0])
  313. }),
  314. )
  315. },
  316. }
  317. }
  318. export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
  319. name: "Terminal",
  320. gate: false,
  321. init: () => {
  322. const sdk = useSDK()
  323. const params = useParams()
  324. const cache = new Map<string, TerminalCacheEntry>()
  325. caches.add(cache)
  326. onCleanup(() => caches.delete(cache))
  327. const disposeAll = () => {
  328. for (const entry of cache.values()) {
  329. entry.dispose()
  330. }
  331. cache.clear()
  332. }
  333. onCleanup(disposeAll)
  334. const prune = () => {
  335. while (cache.size > MAX_TERMINAL_SESSIONS) {
  336. const first = cache.keys().next().value
  337. if (!first) return
  338. const entry = cache.get(first)
  339. entry?.dispose()
  340. cache.delete(first)
  341. }
  342. }
  343. const loadWorkspace = (dir: string, legacySessionID?: string) => {
  344. // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
  345. const key = getWorkspaceTerminalCacheKey(dir)
  346. const existing = cache.get(key)
  347. if (existing) {
  348. cache.delete(key)
  349. cache.set(key, existing)
  350. return existing.value
  351. }
  352. const entry = createRoot((dispose) => ({
  353. value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
  354. dispose,
  355. }))
  356. cache.set(key, entry)
  357. prune()
  358. return entry.value
  359. }
  360. const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
  361. createEffect(
  362. on(
  363. () => ({ dir: params.dir, id: params.id }),
  364. (next, prev) => {
  365. if (!prev?.dir) return
  366. if (next.dir === prev.dir && next.id === prev.id) return
  367. if (next.dir === prev.dir && next.id) return
  368. loadWorkspace(prev.dir, prev.id).trimAll()
  369. },
  370. { defer: true },
  371. ),
  372. )
  373. return {
  374. ready: () => workspace().ready(),
  375. all: () => workspace().all(),
  376. active: () => workspace().active(),
  377. new: () => workspace().new(),
  378. update: (pty: Partial<LocalPTY> & { id: string }) => workspace().update(pty),
  379. trim: (id: string) => workspace().trim(id),
  380. trimAll: () => workspace().trimAll(),
  381. clone: (id: string) => workspace().clone(id),
  382. bind: () => workspace(),
  383. open: (id: string) => workspace().open(id),
  384. close: (id: string) => workspace().close(id),
  385. move: (id: string, to: number) => workspace().move(id, to),
  386. next: () => workspace().next(),
  387. previous: () => workspace().previous(),
  388. }
  389. },
  390. })