dsh-vscode-workbench 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,100 +1,147 @@
1
- import type { TerminalInfo } from '../terminal.ts'
2
- import { ApiError, withSessionRetry } from './api.ts'
3
- import { websocketUrl } from './websocket.ts'
4
-
5
- export interface TerminalConnection {
6
- write(data: string): void
7
- resize(cols: number, rows: number): void
8
- close(): void
9
- subscribe(listener: (output: { sequence: number; data: string; replay?: boolean }) => void): () => void
10
- subscribeError(listener: (message: string) => void): () => void
11
- }
1
+ import type { ClientTerminals, TerminalView, TerminalViewState } from '@deepseek-ai/dsh-api-terminal-controller/client'
2
+ import type { SessionId } from '@deepseek-ai/dsh-session/types'
12
3
 
13
- export interface TerminalWorkspaceSnapshot { terminals: readonly TerminalInfo[]; loading: boolean; error?: string }
14
-
15
- /** Browser client for the workbench's local terminal transport. */
16
- export interface TerminalWorkspace {
17
- refresh(): Promise<boolean>
18
- create(title?: string): Promise<TerminalInfo>
19
- close(id: string): Promise<void>
20
- rename(id: string, title: string): Promise<void>
21
- connect(id: string): TerminalConnection
22
- getSnapshot(): TerminalWorkspaceSnapshot
23
- subscribe(listener: () => void): () => void
24
- }
4
+ const STORAGE_PREFIX = 'dvw:native-terminal-tab:v1:'
5
+ export interface TerminalEntry { id: string; title: string }
6
+ export interface TerminalWorkspaceSnapshot { terminals: readonly TerminalEntry[]; loading: boolean; error?: string }
25
7
 
26
- async function request<T>(method: string, body: Record<string, unknown>): Promise<T> {
27
- const response = await fetch(`/dsh-vscode-terminal/api/${method}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
28
- const text = await response.text()
29
- let answer: { ok?: boolean; value?: T; error?: { code?: string; message?: string } }
30
- try { answer = JSON.parse(text) as { ok?: boolean; value?: T; error?: { code?: string; message?: string } } } catch { throw new Error(`terminal request returned invalid JSON (${response.status})`) }
31
- if (!response.ok || answer.ok !== true) throw new ApiError(answer.error?.code ?? 'terminal-request-failed', answer.error?.message ?? 'terminal request failed')
32
- return answer.value as T
33
- }
8
+ /** Own workbench membership without overwriting the official sidebar's terminal inventory. */
9
+ export class TerminalWorkspaces {
10
+ private readonly workspaces = new Map<string, TerminalWorkspaceController>()
11
+ private saved: Record<string, string[]> = Object.create(null)
12
+
13
+ constructor(readonly models: ClientTerminals) {
14
+ try {
15
+ this.saved = this.readSaved()
16
+ } catch { /* Invalid or unavailable storage must not prevent opening the terminal. */ }
17
+ this.retain()
18
+ window.addEventListener('storage', this.onStorage)
19
+ }
20
+
21
+ get(sessionId: string): TerminalWorkspaceController {
22
+ let workspace = this.workspaces.get(sessionId)
23
+ if (workspace === undefined) {
24
+ workspace = new TerminalWorkspaceController(this, sessionId, this.saved[sessionId] ?? [])
25
+ this.workspaces.set(sessionId, workspace)
26
+ }
27
+ return workspace
28
+ }
29
+
30
+ save(sessionId: string, ids: string[]): void {
31
+ const previous = this.saved[sessionId] ?? []
32
+ try {
33
+ // Independent records preserve concurrent additions from other windows and Sessions.
34
+ for (const id of ids) if (!previous.includes(id)) localStorage.setItem(STORAGE_PREFIX + JSON.stringify([sessionId, id]), 'open')
35
+ for (const id of previous) if (!ids.includes(id)) localStorage.removeItem(STORAGE_PREFIX + JSON.stringify([sessionId, id]))
36
+ this.sync(this.readSaved())
37
+ } catch { this.saved[sessionId] = ids; this.retain() }
38
+ }
34
39
 
35
- class SocketConnection implements TerminalConnection {
36
- private readonly socket: WebSocket
37
- private readonly listeners = new Set<(output: { sequence: number; data: string; replay?: boolean }) => void>()
38
- private readonly errorListeners = new Set<(message: string) => void>()
39
- private closed = false
40
- private queue: string[] = []
41
-
42
- constructor(sessionId: string, id: string) {
43
- const url = websocketUrl(`/dsh-vscode-terminal/ws?sessionId=${encodeURIComponent(sessionId)}&id=${encodeURIComponent(id)}`)
44
- if (url === undefined) throw new Error('无法连接终端:桌面端没有发布本地 Host 地址')
45
- this.socket = new WebSocket(url)
46
- this.socket.onopen = () => { for (const value of this.queue) this.socket.send(value); this.queue = [] }
47
- this.socket.onerror = () => { for (const listener of this.errorListeners) listener('终端 WebSocket 连接失败') }
48
- this.socket.onclose = () => { if (!this.closed) for (const listener of this.errorListeners) listener('终端 WebSocket 连接已断开') }
49
- this.socket.onmessage = event => {
40
+ private readSaved(): Record<string, string[]> {
41
+ const saved: Record<string, string[]> = Object.create(null)
42
+ for (let index = 0; index < localStorage.length; index++) {
43
+ const key = localStorage.key(index)
44
+ if (!key?.startsWith(STORAGE_PREFIX)) continue
50
45
  try {
51
- const value = JSON.parse(String(event.data)) as { type?: unknown; sequence?: unknown; data?: unknown }
52
- if (typeof value.sequence !== 'number' || typeof value.data !== 'string') return
53
- for (const listener of this.listeners) listener({ sequence: value.sequence, data: value.data, replay: value.type === 'replay' })
54
- } catch { /* Ignore malformed WebSocket messages from a disconnected server. */ }
46
+ const value: unknown = JSON.parse(key.slice(STORAGE_PREFIX.length))
47
+ if (!Array.isArray(value) || value.length !== 2) continue
48
+ const [sessionId, id] = value
49
+ if (typeof sessionId !== 'string' || typeof id !== 'string' || !id.startsWith('dvw-terminal:')) continue
50
+ ;(saved[sessionId] ??= []).push(id)
51
+ } catch { /* Ignore only malformed external layout entries. */ }
55
52
  }
53
+ return saved
56
54
  }
57
55
 
58
- private send(value: unknown): void { const text = JSON.stringify(value); if (this.socket.readyState === WebSocket.OPEN) this.socket.send(text); else this.queue.push(text) }
59
- write(data: string): void { this.send({ type: 'input', data }) }
60
- resize(cols: number, rows: number): void { this.send({ type: 'resize', cols, rows }) }
61
- close(): void { this.closed = true; this.socket.close() }
62
- subscribe(listener: (output: { sequence: number; data: string; replay?: boolean }) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener) }
63
- subscribeError(listener: (message: string) => void): () => void { this.errorListeners.add(listener); return () => this.errorListeners.delete(listener) }
64
- }
56
+ private sync(saved: Record<string, string[]>): void {
57
+ this.saved = saved
58
+ for (const [sessionId, workspace] of this.workspaces) workspace.reconcile(saved[sessionId] ?? [])
59
+ this.retain()
60
+ }
61
+
62
+ private onStorage = (event: StorageEvent): void => {
63
+ if (event.storageArea !== localStorage || (event.key !== null && !event.key.startsWith(STORAGE_PREFIX))) return
64
+ this.sync(this.readSaved())
65
+ }
65
66
 
66
- /** Identify automatic terminal-emulator replies that must not be echoed during replay. */
67
- export function isTerminalProtocolReply(data: string): boolean {
68
- return /^\x1b\](?:10|11|12);(?:rgb:[\da-f]{4}\/[\da-f]{4}\/[\da-f]{4}|#[\da-f]{6})(?:\x07|\x1b\\)$/i.test(data)
69
- || /^\x1b\[\??[\d;]*[cR]$/.test(data)
67
+ private retain(): void {
68
+ this.models.retainTabs(Object.entries(this.saved).flatMap(([sessionId, ids]) => ids.map(id => ({ sessionId: sessionId as SessionId, tabId: id, contentId: id }))))
69
+ }
70
+
71
+ dispose(): void { window.removeEventListener('storage', this.onStorage); for (const workspace of this.workspaces.values()) workspace.dispose(); this.models.retainTabs([]) }
70
72
  }
71
73
 
72
- /** Maintain one terminal collection for a DSH session. */
73
- export class TerminalWorkspaceController implements TerminalWorkspace {
74
- private snapshot: TerminalWorkspaceSnapshot = { terminals: [], loading: false }
74
+ /** Layout only; the official model owns processes, stream ordering, recovery and input control. */
75
+ export class TerminalWorkspaceController {
76
+ readonly closeFailures: ClientTerminals['closeFailures']
77
+ private snapshot: TerminalWorkspaceSnapshot
75
78
  private readonly listeners = new Set<() => void>()
79
+ private readonly views = new Map<string, { model: TerminalView; unsubscribe: () => void }>()
80
+
81
+ constructor(private readonly owner: TerminalWorkspaces, readonly sessionId: string, ids: readonly string[]) {
82
+ this.closeFailures = owner.models.closeFailures
83
+ this.snapshot = { terminals: ids.map(id => ({ id, title: '终端' })), loading: false }
84
+ }
76
85
 
77
- constructor(private readonly sessionId: string) {}
86
+ view(id: string): TerminalView {
87
+ const existing = this.views.get(id)
88
+ if (existing !== undefined) return existing.model
89
+ const model = this.owner.models.view(this.sessionId as SessionId, id, id)
90
+ const unsubscribe = model.state.subscribe(() => this.updateTitles())
91
+ this.views.set(id, { model, unsubscribe })
92
+ this.updateTitles()
93
+ return model
94
+ }
78
95
 
79
96
  async refresh(): Promise<boolean> {
80
97
  this.set({ ...this.snapshot, loading: true, error: undefined })
81
98
  try {
82
- this.set({ terminals: await withSessionRetry(() => request<TerminalInfo[]>('list', { sessionId: this.sessionId })), loading: false })
99
+ await Promise.all(this.snapshot.terminals.map(entry => this.view(entry.id).refresh()))
100
+ this.set({ ...this.snapshot, loading: false })
83
101
  return true
84
- } catch (error) {
85
- this.set({ ...this.snapshot, loading: false, error: error instanceof Error ? error.message : String(error) })
86
- return false
87
- }
102
+ } catch (error) { this.fail(error); return false }
103
+ }
104
+
105
+ async create(): Promise<TerminalEntry> {
106
+ const entry = { id: `dvw-terminal:${crypto.randomUUID()}`, title: '终端' }
107
+ this.set({ terminals: [...this.snapshot.terminals, entry], loading: false })
108
+ this.save()
109
+ const model = this.view(entry.id)
110
+ await model.refresh()
111
+ return this.snapshot.terminals.find(row => row.id === entry.id) ?? entry
88
112
  }
89
113
 
90
- async create(title?: string): Promise<TerminalInfo> {
91
- try { const terminal = await withSessionRetry(() => request<TerminalInfo>('create', { sessionId: this.sessionId, ...(title === undefined ? {} : { title }) })); await this.refresh(); return terminal }
92
- catch (error) { this.set({ ...this.snapshot, loading: false, error: error instanceof Error ? error.message : String(error) }); throw error }
114
+ close(id: string): void {
115
+ this.owner.models.close(this.sessionId as SessionId, id, id)
116
+ this.views.get(id)?.unsubscribe()
117
+ this.views.delete(id)
118
+ this.set({ ...this.snapshot, terminals: this.snapshot.terminals.filter(entry => entry.id !== id) })
119
+ this.save()
93
120
  }
94
- async close(id: string): Promise<void> { await withSessionRetry(() => request('close', { sessionId: this.sessionId, id })); await this.refresh() }
95
- async rename(id: string, title: string): Promise<void> { await withSessionRetry(() => request('rename', { sessionId: this.sessionId, id, title })); await this.refresh() }
96
- connect(id: string): TerminalConnection { return new SocketConnection(this.sessionId, id) }
97
- getSnapshot(): TerminalWorkspaceSnapshot { return this.snapshot }
98
- subscribe(listener: () => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener) }
121
+
122
+ async rename(id: string, title: string): Promise<void> {
123
+ try { await this.view(id).rename(title) } catch (error) { this.fail(error) }
124
+ }
125
+ retryClose(id: Parameters<ClientTerminals['retryClose']>[0]): void { this.owner.models.retryClose(id) }
126
+
127
+ reconcile(ids: readonly string[]): void {
128
+ const entries = new Map(this.snapshot.terminals.map(entry => [entry.id, entry]))
129
+ for (const [id, view] of this.views) if (!ids.includes(id)) { view.unsubscribe(); this.views.delete(id) }
130
+ this.set({ ...this.snapshot, terminals: ids.map(id => entries.get(id) ?? { id, title: '终端' }) })
131
+ }
132
+
133
+ getSnapshot = (): TerminalWorkspaceSnapshot => this.snapshot
134
+ subscribe = (listener: () => void): (() => void) => { this.listeners.add(listener); return () => this.listeners.delete(listener) }
135
+ dispose(): void { for (const view of this.views.values()) view.unsubscribe(); this.views.clear() }
136
+ private save(): void { this.owner.save(this.sessionId, this.snapshot.terminals.map(entry => entry.id)) }
137
+ private updateTitles(): void {
138
+ this.set({ ...this.snapshot, terminals: this.snapshot.terminals.map(entry => ({ ...entry, title: this.views.get(entry.id)?.model.state.getSnapshot().title ?? entry.title })) })
139
+ }
140
+ private fail(error: unknown): void { this.set({ ...this.snapshot, loading: false, error: error instanceof Error ? error.message : String(error) }) }
99
141
  private set(snapshot: TerminalWorkspaceSnapshot): void { this.snapshot = snapshot; for (const listener of this.listeners) listener() }
100
142
  }
143
+
144
+ export function terminalIssue(state: TerminalViewState): string | undefined {
145
+ const messages = { missingTerminal: '此终端已不存在,请关闭后新建终端。', inputFull: '终端输入缓冲已满,请重新连接。', attachmentEnded: '终端连接已结束,请重新连接。', invalidOutput: '终端输出无法解析,请重新连接。', terminalLimit: '终端数量已达上限,请关闭不用的终端。' }
146
+ return state.issue === undefined ? state.error ?? state.info?.error : messages[state.issue]
147
+ }
package/src/index.ts CHANGED
@@ -13,7 +13,6 @@ import { generateCommitMessage, normalizeCommitPreferences } from './commit-mess
13
13
  import { codiconAsset, createEntry, cwdFor, deleteEntry, fileIconAsset, gitAction, gitCommitContext, gitStatus, listFiles, monacoAsset, openWorkspaceFromPicker, pdfWorkerAsset, xlsxAsset, readDocument, projectSources, renameEntry, saveDocument, workspaceDownloadFile, searchWorkspace, uploadFile, workspacePreviewFile } from './host.ts'
14
14
  import { installLsp, lspStatuses, resolveLsp, uninstallLsp, pipeLanguageServer } from './lsp.ts'
15
15
  import { officePdf, officeStatus } from './office.ts'
16
- import { installTerminal } from './terminal.ts'
17
16
  import type { SearchRequest } from './types.ts'
18
17
  import { watchIgnore, workspaceWatchIgnores } from './watch-ignore.ts'
19
18
  import { readBinaryBody, readBody, send, sendError, stringField, authorize, WorkbenchError } from './wire.ts'
@@ -77,7 +76,6 @@ export function apply(ctx: HostContext): void {
77
76
  })
78
77
  return () => { void fiber.dispose?.() }
79
78
  }, 'dsh-vscode-workbench: optional DSH services')
80
- installTerminal(ctx)
81
79
  ctx.effect(() => ctx.webServer.register({
82
80
  kind: 'exact', path: '/dsh-vscode/codicons/codicon.ttf', handler: async (req, res) => {
83
81
  if (!authorize(req, res, ctx.connection)) return
package/src/lsp.ts CHANGED
@@ -71,25 +71,33 @@ async function exists(path: string): Promise<boolean> { return access(path).then
71
71
  */
72
72
  export function lspSearchDirs(env: NodeJS.ProcessEnv = process.env, home = homedir()): string[] {
73
73
  const fromPath = (env.PATH ?? env.Path ?? '').split(delimiter).filter(Boolean)
74
- const extras = [
75
- join(home, 'go', 'bin'),
76
- join(home, '.cargo', 'bin'),
77
- join(home, '.dotnet', 'tools'),
78
- join(home, '.local', 'bin'),
79
- join(home, 'bin'),
80
- '/opt/homebrew/bin',
81
- '/usr/local/bin',
82
- '/usr/bin',
83
- '/bin',
84
- '/usr/sbin',
85
- '/sbin',
86
- ]
74
+ const extras = process.platform === 'win32'
75
+ ? [
76
+ join(home, 'go', 'bin'),
77
+ join(home, '.cargo', 'bin'),
78
+ join(home, '.dotnet', 'tools'),
79
+ join(home, 'bin'),
80
+ ]
81
+ : [
82
+ join(home, 'go', 'bin'),
83
+ join(home, '.cargo', 'bin'),
84
+ join(home, '.dotnet', 'tools'),
85
+ join(home, '.local', 'bin'),
86
+ join(home, 'bin'),
87
+ '/opt/homebrew/bin',
88
+ '/usr/local/bin',
89
+ '/usr/bin',
90
+ '/bin',
91
+ '/usr/sbin',
92
+ '/sbin',
93
+ ]
87
94
  return [...new Set([...fromPath, ...extras])]
88
95
  }
89
96
 
90
97
  async function executableIn(directory: string, command: string): Promise<string | undefined> {
98
+ // Windows has no executable bit: access(X_OK) accepts any file, so only accept real command extensions.
91
99
  const names = process.platform === 'win32'
92
- ? [`${command}.exe`, `${command}.cmd`, `${command}.bat`, command]
100
+ ? [`${command}.exe`, `${command}.cmd`, `${command}.bat`, `${command}.com`]
93
101
  : [command]
94
102
  for (const name of names) {
95
103
  const file = join(directory, name)
@@ -1,46 +0,0 @@
1
- import type { IPty } from 'node-pty';
2
- import type { HostContext } from './context.ts';
3
- export interface TerminalInfo {
4
- id: string;
5
- title: string;
6
- cwd: string;
7
- running: boolean;
8
- exitCode?: number;
9
- sequence: number;
10
- }
11
- export interface TerminalOutput {
12
- sequence: number;
13
- data: string;
14
- }
15
- interface TerminalHandle {
16
- info: TerminalInfo;
17
- process: IPty;
18
- replay: string;
19
- listeners: Set<(output: TerminalOutput) => void>;
20
- }
21
- /** Remove credentials while retaining ordinary interactive-shell variables. */
22
- export declare function sanitizedTerminalEnv(env: NodeJS.ProcessEnv): Record<string, string>;
23
- /** Select the user's interactive shell with platform-specific fallbacks. */
24
- export declare function defaultTerminalShell(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): {
25
- shell: string;
26
- args: string[];
27
- };
28
- /** Add execute bits when a registry package extracted a native helper without them. */
29
- export declare function ensureExecutable(path: string): boolean;
30
- /** Repair node-pty's macOS/Linux helper, whose npm package currently loses its execute bit. */
31
- export declare function repairNodePtyHelper(): void;
32
- /** Own interactive terminals by their canonical workspace directory. */
33
- export declare class TerminalManager {
34
- private readonly workspaces;
35
- constructor();
36
- private workspace;
37
- list(cwd: string): TerminalInfo[];
38
- create(cwd: string, title?: string): TerminalInfo;
39
- get(cwd: string, id: string): TerminalHandle | undefined;
40
- close(cwd: string, id: string): void;
41
- rename(cwd: string, id: string, title: string): TerminalInfo;
42
- dispose(): void;
43
- }
44
- /** Register the private local terminal API and WebSocket transport. */
45
- export declare function installTerminal(ctx: HostContext): void;
46
- export {};
package/src/terminal.ts DELETED
@@ -1,173 +0,0 @@
1
- import { randomUUID } from 'node:crypto'
2
- import { chmodSync, realpathSync, statSync } from 'node:fs'
3
- import { createRequire } from 'node:module'
4
- import { basename, dirname, join } from 'node:path'
5
- import type { IncomingMessage } from 'node:http'
6
- import type { Duplex } from 'node:stream'
7
- import * as pty from 'node-pty'
8
- import type { IPty } from 'node-pty'
9
- import { WebSocket, WebSocketServer } from 'ws'
10
- import type { HostContext } from './context.ts'
11
- import { cwdFor } from './host.ts'
12
- import { authorize, readBody, send, sendError } from './wire.ts'
13
-
14
- const require = createRequire(import.meta.url)
15
-
16
- export interface TerminalInfo { id: string; title: string; cwd: string; running: boolean; exitCode?: number; sequence: number }
17
- export interface TerminalOutput { sequence: number; data: string }
18
-
19
- interface TerminalHandle { info: TerminalInfo; process: IPty; replay: string; listeners: Set<(output: TerminalOutput) => void> }
20
- const SENSITIVE = /(api[_-]?key|token|credential|secret|password|passwd|private[_-]?key|^dsh_.*(?:key|token|secret))/i
21
- const MAX_TERMINALS = 8
22
- const REPLAY_BYTES = 2 * 1024 * 1024
23
-
24
- /** Remove credentials while retaining ordinary interactive-shell variables. */
25
- export function sanitizedTerminalEnv(env: NodeJS.ProcessEnv): Record<string, string> {
26
- return Object.fromEntries(Object.entries(env).filter(([key, value]) => value !== undefined && !SENSITIVE.test(key)) as Array<[string, string]>)
27
- }
28
-
29
- /** Select the user's interactive shell with platform-specific fallbacks. */
30
- export function defaultTerminalShell(platform = process.platform, env = process.env): { shell: string; args: string[] } {
31
- if (platform === 'win32') return { shell: env.COMSPEC?.toLowerCase().includes('powershell') ? env.COMSPEC : 'powershell.exe', args: ['-NoLogo'] }
32
- return { shell: env.SHELL || (platform === 'darwin' ? '/bin/zsh' : '/bin/bash'), args: ['-l'] }
33
- }
34
-
35
- /** Add execute bits when a registry package extracted a native helper without them. */
36
- export function ensureExecutable(path: string): boolean {
37
- if (process.platform === 'win32') return true
38
- try {
39
- const mode = statSync(path).mode
40
- if ((mode & 0o111) === 0) chmodSync(path, mode | 0o111)
41
- return true
42
- } catch {
43
- return false
44
- }
45
- }
46
-
47
- /** Repair node-pty's macOS/Linux helper, whose npm package currently loses its execute bit. */
48
- export function repairNodePtyHelper(): void {
49
- if (process.platform === 'win32') return
50
- const packageDirectory = dirname(require.resolve('node-pty/package.json'))
51
- ensureExecutable(join(packageDirectory, 'build', 'Release', 'spawn-helper'))
52
- ensureExecutable(join(packageDirectory, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'))
53
- }
54
-
55
- /** Own interactive terminals by their canonical workspace directory. */
56
- export class TerminalManager {
57
- private readonly workspaces = new Map<string, Map<string, TerminalHandle>>()
58
-
59
- constructor() { repairNodePtyHelper() }
60
-
61
- private workspace(cwd: string): string { return realpathSync(cwd) }
62
-
63
- list(cwd: string): TerminalInfo[] { return [...(this.workspaces.get(this.workspace(cwd))?.values() ?? [])].map(handle => ({ ...handle.info })) }
64
-
65
- create(cwd: string, title?: string): TerminalInfo {
66
- const key = this.workspace(cwd)
67
- const terminals = this.workspaces.get(key) ?? new Map<string, TerminalHandle>()
68
- this.workspaces.set(key, terminals)
69
- if (terminals.size >= MAX_TERMINALS) throw new Error(`terminal limit reached (${MAX_TERMINALS})`)
70
- const { shell, args } = defaultTerminalShell()
71
- const child = pty.spawn(shell, args, { name: 'xterm-256color', cols: 80, rows: 24, cwd: key, env: sanitizedTerminalEnv(process.env) })
72
- const info: TerminalInfo = { id: randomUUID(), title: title || basename(shell), cwd: key, running: true, sequence: 0 }
73
- const handle: TerminalHandle = { info, process: child, replay: '', listeners: new Set() }
74
- terminals.set(info.id, handle)
75
- child.onData(data => {
76
- info.sequence++
77
- handle.replay += data
78
- if (Buffer.byteLength(handle.replay) > REPLAY_BYTES) handle.replay = handle.replay.slice(-REPLAY_BYTES)
79
- for (const listener of handle.listeners) listener({ sequence: info.sequence, data })
80
- })
81
- child.onExit(({ exitCode }) => {
82
- info.running = false
83
- info.exitCode = exitCode
84
- info.sequence++
85
- const data = `\r\n[process exited with code ${exitCode}]\r\n`
86
- for (const listener of handle.listeners) listener({ sequence: info.sequence, data })
87
- })
88
- return { ...info }
89
- }
90
-
91
- get(cwd: string, id: string): TerminalHandle | undefined { return this.workspaces.get(this.workspace(cwd))?.get(id) }
92
-
93
- close(cwd: string, id: string): void {
94
- const key = this.workspace(cwd)
95
- const terminals = this.workspaces.get(key)
96
- const handle = terminals?.get(id)
97
- if (handle === undefined || terminals === undefined) return
98
- terminals.delete(id)
99
- try { handle.process.kill() } catch { /* The shell may already have exited. */ }
100
- if (terminals.size === 0) this.workspaces.delete(key)
101
- }
102
-
103
- rename(cwd: string, id: string, title: string): TerminalInfo {
104
- const handle = this.get(cwd, id)
105
- if (handle === undefined) throw new Error('terminal not found')
106
- handle.info.title = title
107
- return { ...handle.info }
108
- }
109
-
110
- dispose(): void { for (const [cwd, terminals] of this.workspaces) for (const id of terminals.keys()) this.close(cwd, id) }
111
- }
112
-
113
- function terminalRequestAllowed(req: IncomingMessage): boolean {
114
- const host = req.headers.host
115
- if (host === undefined || req.headers['sec-fetch-site'] === 'cross-site') return false
116
- let hostname: string
117
- try { hostname = new URL(`http://${host}`).hostname } catch { return false }
118
- if (hostname !== 'localhost' && hostname !== '[::1]' && !hostname.startsWith('127.')) return false
119
- const origin = req.headers.origin
120
- try { return origin === undefined || new URL(origin).host === host } catch { return false }
121
- }
122
-
123
- /** Register the private local terminal API and WebSocket transport. */
124
- export function installTerminal(ctx: HostContext): void {
125
- const manager = new TerminalManager()
126
- ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/dsh-vscode-terminal/api', handler: async (req, res) => {
127
- if (!authorize(req, res, ctx.connection)) return
128
- if (!terminalRequestAllowed(req)) return send(res, 403, { ok: false, error: { message: 'forbidden' } })
129
- if (req.method !== 'POST') return send(res, 405, { ok: false, error: { message: 'POST required' } })
130
- try {
131
- const body = await readBody(req)
132
- const sessionId = typeof body.sessionId === 'string' ? body.sessionId : ''
133
- if (sessionId === '') throw new Error('sessionId required')
134
- const cwd = cwdFor(ctx, sessionId)
135
- const method = new URL(req.url ?? '/', 'http://dsh.local').pathname.slice('/dsh-vscode-terminal/api/'.length)
136
- let value: unknown
137
- if (method === 'list') value = manager.list(cwd)
138
- else if (method === 'create') value = manager.create(cwd, typeof body.title === 'string' ? body.title : undefined)
139
- else if (method === 'close') { manager.close(cwd, typeof body.id === 'string' ? body.id : ''); value = manager.list(cwd) }
140
- else if (method === 'rename') value = manager.rename(cwd, typeof body.id === 'string' ? body.id : '', typeof body.title === 'string' ? body.title : '')
141
- else throw new Error('unknown terminal method')
142
- send(res, 200, { ok: true, value })
143
- } catch (error) { sendError(res, error) }
144
- } }), 'dsh-vscode-workbench: terminal API')
145
-
146
- const webSocket = new WebSocketServer({ noServer: true })
147
- ctx.effect(() => ctx.webServer.registerUpgrade({ path: '/dsh-vscode-terminal/ws', handler(req, socket, head) {
148
- if (ctx.connection.requestRejection(req) !== undefined || !terminalRequestAllowed(req)) { socket.destroy(); return }
149
- webSocket.handleUpgrade(req as IncomingMessage, socket as Duplex, head, connection => {
150
- const url = new URL(req.url ?? '/', 'http://dsh.local')
151
- const sessionId = url.searchParams.get('sessionId')
152
- const id = url.searchParams.get('id')
153
- if (sessionId === null || id === null) { connection.close(1008, 'sessionId and id required'); return }
154
- let handle: TerminalHandle | undefined
155
- try { handle = manager.get(cwdFor(ctx, sessionId), id) }
156
- catch { connection.close(1008, 'session workspace unavailable'); return }
157
- if (handle === undefined) { connection.close(1008, 'terminal not found'); return }
158
- if (handle.replay !== '') connection.send(JSON.stringify({ type: 'replay', sequence: handle.info.sequence, data: handle.replay }))
159
- const listener = (output: TerminalOutput) => { if (connection.readyState === WebSocket.OPEN) connection.send(JSON.stringify({ type: 'output', ...output })) }
160
- handle.listeners.add(listener)
161
- connection.on('message', raw => {
162
- const text = raw.toString()
163
- try {
164
- const message: unknown = JSON.parse(text)
165
- if (message !== null && typeof message === 'object' && 'type' in message && message.type === 'input' && 'data' in message && typeof message.data === 'string') handle.process.write(message.data)
166
- else if (message !== null && typeof message === 'object' && 'type' in message && message.type === 'resize' && 'cols' in message && 'rows' in message && typeof message.cols === 'number' && typeof message.rows === 'number') handle.process.resize(Math.max(2, Math.floor(message.cols)), Math.max(2, Math.floor(message.rows)))
167
- } catch { handle.process.write(text) }
168
- })
169
- connection.on('close', () => handle.listeners.delete(listener))
170
- })
171
- } }), 'dsh-vscode-workbench: terminal WebSocket')
172
- ctx.effect(() => () => { webSocket.close(); manager.dispose() }, 'dsh-vscode-workbench: terminal teardown')
173
- }