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.
- package/README.en.md +27 -10
- package/README.md +26 -11
- package/cordis.patch.yml +2 -0
- package/lib/client.js +1039 -417
- package/lib/client.js.map +1 -1
- package/lib/index.js +15 -249
- package/lib/index.js.map +1 -1
- package/lib/types/client/DshBrowserPage.d.ts +6 -0
- package/lib/types/client/DshPanelPage.d.ts +9 -0
- package/lib/types/client/Icon.d.ts +1 -1
- package/lib/types/client/TerminalPanel.d.ts +5 -2
- package/lib/types/client/Workbench.d.ts +7 -1
- package/lib/types/client/browser.d.ts +26 -0
- package/lib/types/client/dshPanels.d.ts +4 -0
- package/lib/types/client/shortcuts.d.ts +2 -0
- package/lib/types/client/terminal.d.ts +37 -32
- package/package.json +15 -4
- package/src/client/DshBrowserPage.tsx +61 -0
- package/src/client/DshPanelPage.tsx +63 -0
- package/src/client/Icon.tsx +1 -1
- package/src/client/TerminalPanel.tsx +63 -40
- package/src/client/Workbench.tsx +72 -11
- package/src/client/browser.ts +68 -0
- package/src/client/dshPanels.ts +16 -0
- package/src/client/index.tsx +25 -6
- package/src/client/shortcuts.ts +9 -0
- package/src/client/styles.ts +18 -7
- package/src/client/terminal.ts +125 -78
- package/src/index.ts +0 -2
- package/src/lsp.ts +22 -14
- package/lib/types/terminal.d.ts +0 -46
- package/src/terminal.ts +0 -173
package/src/client/terminal.ts
CHANGED
|
@@ -1,100 +1,147 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
import {
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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(
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
/**
|
|
73
|
-
export class TerminalWorkspaceController
|
|
74
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
async rename(id: string, title: string): Promise<void> {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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)
|
package/lib/types/terminal.d.ts
DELETED
|
@@ -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
|
-
}
|