index.ts 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. /** Client terminal model service; views are keyed independently from Host terminal identities. */
  2. import { Service, type Context } from '@deepseek-ai/cordis'
  3. import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
  4. import type { SessionId } from '@deepseek-ai/dsh-session/types'
  5. import type {} from '@deepseek-ai/dsh-api-gateway/client'
  6. import { TerminalView, type TerminalRemote } from './model.ts'
  7. import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store'
  8. import { randomUUID } from '@deepseek-ai/dsh-util-crypto'
  9. import type { WebTerminalId, WebTerminalInfo } from '../types.ts'
  10. import { TerminalCloseRequests, type TerminalCloseRequest } from './close-requests.ts'
  11. export type { TerminalView, TerminalViewState, TerminalViewIssue, TerminalRenderFrame, TerminalRemote } from './model.ts'
  12. declare module '@deepseek-ai/cordis' {
  13. interface Context {
  14. /** React-free browser terminal views and explicit process cleanup. */
  15. webTerminals: ClientTerminals
  16. }
  17. }
  18. /** A failed background close that can be retried without restoring its tab. */
  19. export interface TerminalCloseFailure {
  20. readonly id: WebTerminalId
  21. readonly title: string
  22. readonly message: string
  23. }
  24. /** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
  25. export class ClientTerminals extends Service {
  26. /** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
  27. readonly closeFailures: SnapshotStore<readonly TerminalCloseFailure[]> = createSnapshotStore<readonly TerminalCloseFailure[]>([])
  28. private readonly requests = new TerminalCloseRequests()
  29. private readonly closing = new Map<WebTerminalId, Promise<void>>()
  30. private readonly closed = new Set<WebTerminalId>(this.requests.pending().map(request => request.id))
  31. private disposed = false
  32. private readonly views = new Map<SessionId, Map<string, TerminalView>>()
  33. /**
  34. * @param ctx - Client root Context with Gateway and terminal Remote namespace.
  35. * @param remote - generated terminal namespace.
  36. */
  37. constructor(ctx: Context, private readonly remote: TerminalRemote) {
  38. super(ctx, 'webTerminals')
  39. ctx.effect(() => async () => {
  40. this.disposed = true
  41. const detaching = [...this.views.values()].flatMap(views => [...views.values()].map(view => view.dispose()))
  42. this.views.clear()
  43. await Promise.all([...detaching, ...this.closing.values()])
  44. }, 'terminal-controller.client.views')
  45. for (const request of this.requests.pending()) this.cleanup(request)
  46. }
  47. /**
  48. * Return the stable model for one sidebar occurrence.
  49. * @param sessionId - owning Session.
  50. * @param key - sidebar occurrence key.
  51. * @param terminalId - existing Host identity when restoring a listed terminal.
  52. * @returns its observable state and terminal commands.
  53. */
  54. view(sessionId: SessionId, key: string, terminalId?: WebTerminalId): TerminalView {
  55. let views = this.views.get(sessionId)
  56. if (views === undefined) { views = new Map(); this.views.set(sessionId, views) }
  57. let view = views.get(key)
  58. if (view === undefined) {
  59. const id = terminalId ?? randomUUID() as WebTerminalId
  60. view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === undefined)
  61. views.set(key, view)
  62. void view.refresh()
  63. }
  64. return view
  65. }
  66. /**
  67. * Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
  68. * @param sessionId - owning Session.
  69. * @param key - sidebar occurrence key, including an inactive restored tab.
  70. * @param terminalId - restored identity if the tab has no model yet.
  71. */
  72. close(sessionId: SessionId, key: string, terminalId?: WebTerminalId): void {
  73. const views = this.views.get(sessionId)
  74. const view = views?.get(key)
  75. const id = view?.id ?? terminalId
  76. if (id === undefined) return
  77. const request: TerminalCloseRequest = { sessionId, id, title: view?.state.getSnapshot().title ?? key }
  78. this.closed.add(id)
  79. this.requests.save(request)
  80. views?.delete(key)
  81. if (views?.size === 0) this.views.delete(sessionId)
  82. this.cleanup(request, view)
  83. }
  84. /**
  85. * Query Host terminals that have neither a tab in this page nor an unfinished close.
  86. * @param sessionId - Session being displayed.
  87. * @returns terminals available for opening as recovered tabs.
  88. */
  89. async recover(sessionId: SessionId): Promise<WebTerminalInfo[]> {
  90. const result = await this.remote.list(sessionId)
  91. if (!result.ok) throw new Error(result.error.message)
  92. const held = new Set([...(this.views.get(sessionId)?.values() ?? [])].map(view => view.id))
  93. const closing = new Set(this.requests.pending().map(request => request.id))
  94. return result.value.filter(info => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id))
  95. }
  96. /**
  97. * Retry a saved close request without reopening its tab.
  98. * @param id - failed terminal identity.
  99. */
  100. retryClose(id: WebTerminalId): void {
  101. const record = this.requests.pending().find(item => item.id === id)
  102. if (record !== undefined) this.cleanup(record)
  103. }
  104. private cleanup(record: TerminalCloseRequest, view?: TerminalView): void {
  105. if (this.closing.has(record.id) || this.disposed) return
  106. this.closeFailures.set(this.closeFailures.getSnapshot().filter(failure => failure.id !== record.id))
  107. const pending = (async () => {
  108. if (view !== undefined) await view.close()
  109. else {
  110. const result = await this.remote.close(record.sessionId, record.id)
  111. if (!result.ok) throw result.error
  112. }
  113. this.requests.remove(record.id)
  114. })().catch((error: unknown) => {
  115. if (remoteErrorOf(error)?.code === 'session/not-found') {
  116. this.requests.remove(record.id)
  117. return
  118. }
  119. if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
  120. id: record.id, title: record.title,
  121. message: error instanceof Error ? error.message : String(error),
  122. }])
  123. }).then(async () => {
  124. await view?.dispose()
  125. this.closing.delete(record.id)
  126. })
  127. this.closing.set(record.id, pending)
  128. }
  129. }
  130. /** Required Client transport and terminal namespace. */
  131. export const inject = ['remote', 'remote.terminal']
  132. /**
  133. * Install the Client terminal models.
  134. * @param ctx - Client root Context.
  135. */
  136. export function apply(ctx: Context): void {
  137. new ClientTerminals(ctx, ctx.remote.terminal)
  138. }