@deepseek-ai/dsh-api-terminal-controller 0.1.6-alpha.1
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/LICENSE +21 -0
- package/README.i18n.yaml +6 -0
- package/README.md +93 -0
- package/README.zh.md +93 -0
- package/lib/client.js +677 -0
- package/lib/index.js +844 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1131 -0
- package/lib/typert.remote-client.d.ts +49 -0
- package/lib/typert.remote-client.js +529 -0
- package/lib/types/client/close-requests.d.ts +31 -0
- package/lib/types/client/close-requests.js +75 -0
- package/lib/types/client/index.d.ts +88 -0
- package/lib/types/client/index.js +157 -0
- package/lib/types/client/model.d.ts +119 -0
- package/lib/types/client/model.js +323 -0
- package/lib/types/client/shell-preference.d.ts +11 -0
- package/lib/types/client/shell-preference.js +26 -0
- package/lib/types/index.d.ts +135 -0
- package/lib/types/index.js +371 -0
- package/lib/types/shells.d.ts +21 -0
- package/lib/types/shells.js +54 -0
- package/lib/types/stream.d.ts +29 -0
- package/lib/types/stream.js +78 -0
- package/lib/types/terminal.d.ts +61 -0
- package/lib/types/terminal.js +168 -0
- package/lib/types/types.d.ts +69 -0
- package/lib/types/types.js +2 -0
- package/package.json +93 -0
package/lib/client.js
ADDED
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@deepseek-ai/dsh-api-terminal-controller",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let _deepseek_ai_cordis = require("@deepseek-ai/cordis");
|
|
8
|
+
let _deepseek_ai_dsh_client_store = require("@deepseek-ai/dsh-client-store");
|
|
9
|
+
let _deepseek_ai_dsh_api_gateway_client = require("@deepseek-ai/dsh-api-gateway/client");
|
|
10
|
+
//#region ../../typert/protocol/lib/index.js
|
|
11
|
+
/** The one Remote failure class shared by owners, the Gateway, and consumers. */
|
|
12
|
+
/**
|
|
13
|
+
* One Remote call failure: a real Error carrying its stable code and typed
|
|
14
|
+
* details. Owners throw it at the failure point; the Host Gateway encodes it
|
|
15
|
+
* onto the wire unchanged; the Client face rebuilds an instance for the
|
|
16
|
+
* `RemoteResult` error branch, so `throw result.error` keeps throw semantics.
|
|
17
|
+
* Discrimination is always by `code`, never by instanceof.
|
|
18
|
+
*/
|
|
19
|
+
var RemoteError = class extends Error {
|
|
20
|
+
code;
|
|
21
|
+
details;
|
|
22
|
+
/** Structural marker: cross-realm/bundle identification never uses instanceof. */
|
|
23
|
+
isDSHRemoteError = true;
|
|
24
|
+
/**
|
|
25
|
+
* @param code - stable failure code declared in {@link RemoteErrorDetailsMap}.
|
|
26
|
+
* @param message - human diagnostic carried across the wire.
|
|
27
|
+
* @param details - structured payload typed by the code.
|
|
28
|
+
* @param options - standard Error options (`cause` survives in-process only).
|
|
29
|
+
*/
|
|
30
|
+
constructor(code, message, details, options) {
|
|
31
|
+
super(message, options);
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.details = details;
|
|
34
|
+
this.name = "RemoteError";
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Structurally identify a RemoteError thrown across module or realm copies of
|
|
39
|
+
* this class. Mechanism-internal: the Gateway and test assertions use it;
|
|
40
|
+
* business code receives typed failures and never needs it.
|
|
41
|
+
* @param value - a caught value.
|
|
42
|
+
* @returns the failure when the marker matches, otherwise undefined.
|
|
43
|
+
*/
|
|
44
|
+
function remoteErrorOf(value) {
|
|
45
|
+
if (typeof value === "object" && value !== null && value.isDSHRemoteError === true && typeof value.code === "string") return value;
|
|
46
|
+
}
|
|
47
|
+
//#endregion
|
|
48
|
+
//#region lib/types/client/shell-preference.js
|
|
49
|
+
/** Browser-local shell preference; Host discovery decides whether the saved path is usable. */
|
|
50
|
+
const KEY = "dsh.terminal.shell";
|
|
51
|
+
/**
|
|
52
|
+
* Read the browser preference.
|
|
53
|
+
* @returns the last selected shell path, or null when storage is unavailable.
|
|
54
|
+
*/
|
|
55
|
+
function preferredShell() {
|
|
56
|
+
try {
|
|
57
|
+
return typeof localStorage === "undefined" ? null : localStorage.getItem(KEY);
|
|
58
|
+
} catch (_storageUnavailable) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Remember the selected shell without making storage a startup dependency.
|
|
64
|
+
* @param path - verified executable path offered by the Host.
|
|
65
|
+
*/
|
|
66
|
+
function rememberShell(path) {
|
|
67
|
+
try {
|
|
68
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(KEY, path);
|
|
69
|
+
} catch (_storageUnavailable) {}
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region ../../util/crypto/lib/index.js
|
|
73
|
+
/**
|
|
74
|
+
* Random v4 UUID, minted from `crypto.getRandomValues`.
|
|
75
|
+
* @returns the UUID string.
|
|
76
|
+
*/
|
|
77
|
+
function randomUUID() {
|
|
78
|
+
const bytes = globalThis.crypto.getRandomValues(new Uint8Array(16));
|
|
79
|
+
const hex = Array.from(bytes, (byte, index) => {
|
|
80
|
+
return (index === 6 ? byte & 15 | 64 : index === 8 ? byte & 63 | 128 : byte).toString(16).padStart(2, "0");
|
|
81
|
+
}).join("");
|
|
82
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
85
|
+
//#region lib/types/client/model.js
|
|
86
|
+
/** React-free browser terminal state and reconnecting Remote-stream ownership. */
|
|
87
|
+
var TerminalViewError = class extends RemoteError {
|
|
88
|
+
constructor(issue, message = issue) {
|
|
89
|
+
super("terminal/view", message, { issue });
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
/** A view survives DOM unmount; its process only ends on explicit close. */
|
|
93
|
+
var TerminalView = class {
|
|
94
|
+
sessionId;
|
|
95
|
+
remote;
|
|
96
|
+
gateway;
|
|
97
|
+
id;
|
|
98
|
+
createWhenMissing;
|
|
99
|
+
shellPath;
|
|
100
|
+
/** Observable controls, process metadata and the next screen update awaiting acknowledgement. */
|
|
101
|
+
state = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)({
|
|
102
|
+
phase: "idle",
|
|
103
|
+
writable: false
|
|
104
|
+
});
|
|
105
|
+
lifetime = new AbortController();
|
|
106
|
+
stream;
|
|
107
|
+
mounted = false;
|
|
108
|
+
attachmentId;
|
|
109
|
+
pendingRender;
|
|
110
|
+
revision = 0;
|
|
111
|
+
creation;
|
|
112
|
+
loading;
|
|
113
|
+
closing;
|
|
114
|
+
writes = Promise.resolve();
|
|
115
|
+
queuedInput = 0;
|
|
116
|
+
detaching = /* @__PURE__ */ new Set();
|
|
117
|
+
/**
|
|
118
|
+
* @param sessionId - Session owning the terminal.
|
|
119
|
+
* @param remote - typed terminal Remote operations.
|
|
120
|
+
* @param gateway - reconnecting stream factory.
|
|
121
|
+
* @param id - Host terminal identity, reused when recovering an item from its Session list.
|
|
122
|
+
* @param createWhenMissing - allow allocation only for a new tab, never a listed terminal.
|
|
123
|
+
* @param shellPath - explicit shell chosen at the guide; omission uses the remembered available shell.
|
|
124
|
+
*/
|
|
125
|
+
constructor(sessionId, remote, gateway, id, createWhenMissing = true, shellPath) {
|
|
126
|
+
this.sessionId = sessionId;
|
|
127
|
+
this.remote = remote;
|
|
128
|
+
this.gateway = gateway;
|
|
129
|
+
this.id = id;
|
|
130
|
+
this.createWhenMissing = createWhenMissing;
|
|
131
|
+
this.shellPath = shellPath;
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Attach the DOM lifetime, starting the chosen shell or reconnecting the saved process.
|
|
135
|
+
* @returns a detach callback that leaves the terminal process alive.
|
|
136
|
+
*/
|
|
137
|
+
mount() {
|
|
138
|
+
this.mounted = true;
|
|
139
|
+
if (this.state.getSnapshot().info === void 0) this.refresh();
|
|
140
|
+
else this.connect();
|
|
141
|
+
return () => {
|
|
142
|
+
this.mounted = false;
|
|
143
|
+
this.detach();
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Start or recover this tab, deduplicating mounts and retries during allocation.
|
|
148
|
+
* Only a new tab may allocate a shell; listed terminals cannot be silently replaced.
|
|
149
|
+
* @returns after environment lookup and creation or recovery settle.
|
|
150
|
+
*/
|
|
151
|
+
refresh() {
|
|
152
|
+
if (this.creation !== void 0) return this.creation;
|
|
153
|
+
if (this.loading !== void 0) return this.loading;
|
|
154
|
+
if (this.closing !== void 0 || this.lifetime.signal.aborted) return Promise.resolve();
|
|
155
|
+
this.patch({
|
|
156
|
+
phase: "loading",
|
|
157
|
+
error: void 0,
|
|
158
|
+
issue: void 0
|
|
159
|
+
});
|
|
160
|
+
this.loading = (async () => {
|
|
161
|
+
const [environment, available] = await Promise.all([this.remote.environment(this.sessionId, this.lifetime.signal), this.remote.list(this.sessionId)]);
|
|
162
|
+
if (this.stopped()) return;
|
|
163
|
+
this.patch({ environment: valueOf(environment) });
|
|
164
|
+
const info = valueOf(available).find((item) => item.id === this.id);
|
|
165
|
+
if (info !== void 0) this.adopt(info);
|
|
166
|
+
else if (this.createWhenMissing) {
|
|
167
|
+
let path = this.shellPath;
|
|
168
|
+
if (path === void 0) {
|
|
169
|
+
const shells = valueOf(await this.remote.shells(this.sessionId, this.lifetime.signal));
|
|
170
|
+
const previous = preferredShell();
|
|
171
|
+
path = shells.find((shell) => shell.path === previous)?.path ?? shells[0]?.path;
|
|
172
|
+
}
|
|
173
|
+
if (this.stopped()) return;
|
|
174
|
+
if (path !== void 0) rememberShell(path);
|
|
175
|
+
await this.create(valueOf(environment), path);
|
|
176
|
+
} else throw new TerminalViewError("missingTerminal");
|
|
177
|
+
})().catch((error) => {
|
|
178
|
+
this.fail(error);
|
|
179
|
+
}).finally(() => {
|
|
180
|
+
this.loading = void 0;
|
|
181
|
+
});
|
|
182
|
+
return this.loading;
|
|
183
|
+
}
|
|
184
|
+
stopped() {
|
|
185
|
+
return this.lifetime.signal.aborted || this.closing !== void 0;
|
|
186
|
+
}
|
|
187
|
+
async create(environment, shellPath) {
|
|
188
|
+
this.patch({
|
|
189
|
+
phase: "creating",
|
|
190
|
+
error: void 0,
|
|
191
|
+
issue: void 0
|
|
192
|
+
});
|
|
193
|
+
this.creation = (async () => {
|
|
194
|
+
const info = valueOf(await this.remote.create(this.sessionId, {
|
|
195
|
+
id: this.id,
|
|
196
|
+
...shellPath === void 0 ? {} : { shellPath },
|
|
197
|
+
cols: Math.min(80, environment.maxCols),
|
|
198
|
+
rows: Math.min(24, environment.maxRows)
|
|
199
|
+
}, this.lifetime.signal));
|
|
200
|
+
if (!this.lifetime.signal.aborted) this.adopt(info);
|
|
201
|
+
})().catch((error) => {
|
|
202
|
+
this.fail(error);
|
|
203
|
+
}).finally(() => {
|
|
204
|
+
this.creation = void 0;
|
|
205
|
+
});
|
|
206
|
+
await this.creation;
|
|
207
|
+
}
|
|
208
|
+
adopt(info) {
|
|
209
|
+
this.patch({
|
|
210
|
+
info,
|
|
211
|
+
title: info.title
|
|
212
|
+
});
|
|
213
|
+
if (this.mounted && this.closing === void 0) this.connect();
|
|
214
|
+
}
|
|
215
|
+
/** Reattach with a fresh screen and regain input control. */
|
|
216
|
+
connect() {
|
|
217
|
+
const info = this.state.getSnapshot().info;
|
|
218
|
+
if (info === void 0 || !this.mounted || this.closing !== void 0 || this.lifetime.signal.aborted) return;
|
|
219
|
+
this.detach();
|
|
220
|
+
const stream = this.gateway.$stream({
|
|
221
|
+
name: "Browser terminal output",
|
|
222
|
+
open: (signal) => {
|
|
223
|
+
const attachmentId = randomUUID();
|
|
224
|
+
this.attachmentId = attachmentId;
|
|
225
|
+
return this.remote.follow(this.sessionId, info.id, attachmentId, signal);
|
|
226
|
+
},
|
|
227
|
+
ended: () => new TerminalViewError("attachmentEnded"),
|
|
228
|
+
carrierFailed: () => {
|
|
229
|
+
if (this.stream === stream) this.patch({
|
|
230
|
+
phase: "disconnected",
|
|
231
|
+
writable: false
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
this.stream = stream;
|
|
236
|
+
this.patch({
|
|
237
|
+
phase: "connecting",
|
|
238
|
+
writable: false,
|
|
239
|
+
error: void 0,
|
|
240
|
+
issue: void 0,
|
|
241
|
+
render: void 0
|
|
242
|
+
});
|
|
243
|
+
this.consume(stream);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Release the next stream item after xterm has parsed this frame.
|
|
247
|
+
* @param revision - locally delivered render revision.
|
|
248
|
+
*/
|
|
249
|
+
acknowledge(revision) {
|
|
250
|
+
if (this.pendingRender?.revision !== revision) return;
|
|
251
|
+
this.pendingRender.resolve();
|
|
252
|
+
this.pendingRender = void 0;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Serialize raw input so concurrent RPC requests cannot reorder keystrokes.
|
|
256
|
+
* @param data - input from the terminal emulator.
|
|
257
|
+
*/
|
|
258
|
+
write(data) {
|
|
259
|
+
const state = this.state.getSnapshot();
|
|
260
|
+
const attachmentId = this.attachmentId;
|
|
261
|
+
if (!state.writable || state.info === void 0 || attachmentId === void 0) return;
|
|
262
|
+
const bytes = new TextEncoder().encode(data).byteLength;
|
|
263
|
+
if (this.queuedInput + bytes > (state.environment?.maxInputBytes ?? 0)) {
|
|
264
|
+
this.fail(new TerminalViewError("inputFull"));
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
this.queuedInput += bytes;
|
|
268
|
+
const id = state.info.id;
|
|
269
|
+
this.writes = this.writes.then(async () => {
|
|
270
|
+
if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return;
|
|
271
|
+
valueOf(await this.remote.write(this.sessionId, id, attachmentId, data));
|
|
272
|
+
}).catch((error) => {
|
|
273
|
+
if (this.attachmentId === attachmentId) this.fail(error);
|
|
274
|
+
}).finally(() => {
|
|
275
|
+
this.queuedInput -= bytes;
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Resize only from the currently writable view.
|
|
280
|
+
* @param cols - measured column count.
|
|
281
|
+
* @param rows - measured row count.
|
|
282
|
+
*/
|
|
283
|
+
resize(cols, rows) {
|
|
284
|
+
const state = this.state.getSnapshot();
|
|
285
|
+
const attachmentId = this.attachmentId;
|
|
286
|
+
if (!state.writable || state.info === void 0 || attachmentId === void 0) return;
|
|
287
|
+
if (state.info.cols === cols && state.info.rows === rows) return;
|
|
288
|
+
const id = state.info.id;
|
|
289
|
+
cols = Math.min(cols, state.environment?.maxCols ?? cols);
|
|
290
|
+
rows = Math.min(rows, state.environment?.maxRows ?? rows);
|
|
291
|
+
this.writes = this.writes.then(async () => {
|
|
292
|
+
if (this.attachmentId !== attachmentId || !this.state.getSnapshot().writable) return;
|
|
293
|
+
valueOf(await this.remote.resize(this.sessionId, id, attachmentId, cols, rows));
|
|
294
|
+
}).catch((error) => {
|
|
295
|
+
if (this.attachmentId === attachmentId) this.fail(error);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Update the Host terminal's display name.
|
|
300
|
+
* @param title - user-entered terminal title.
|
|
301
|
+
* @returns after the rename settles and its result is reflected in view state.
|
|
302
|
+
*/
|
|
303
|
+
async rename(title) {
|
|
304
|
+
if (title.trim() === this.state.getSnapshot().title || this.lifetime.signal.aborted) return;
|
|
305
|
+
try {
|
|
306
|
+
valueOf(await this.remote.rename(this.sessionId, this.id, title));
|
|
307
|
+
const current = this.state.getSnapshot().info;
|
|
308
|
+
this.patch({
|
|
309
|
+
...current === void 0 ? {} : { info: {
|
|
310
|
+
...current,
|
|
311
|
+
title: title.trim()
|
|
312
|
+
} },
|
|
313
|
+
title: title.trim()
|
|
314
|
+
});
|
|
315
|
+
} catch (error) {
|
|
316
|
+
this.fail(error);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Explicitly terminate this view's process independently of its DOM lifetime.
|
|
321
|
+
* @returns after Host process cleanup succeeds; failures remain retryable by the owner.
|
|
322
|
+
*/
|
|
323
|
+
close() {
|
|
324
|
+
if (this.closing !== void 0) return this.closing;
|
|
325
|
+
this.patch({
|
|
326
|
+
phase: "closing",
|
|
327
|
+
writable: false,
|
|
328
|
+
error: void 0,
|
|
329
|
+
issue: void 0
|
|
330
|
+
});
|
|
331
|
+
this.detach();
|
|
332
|
+
this.closing = (async () => {
|
|
333
|
+
await this.creation;
|
|
334
|
+
valueOf(await this.remote.close(this.sessionId, this.id));
|
|
335
|
+
this.detach();
|
|
336
|
+
this.patch({
|
|
337
|
+
phase: "closed",
|
|
338
|
+
writable: false
|
|
339
|
+
});
|
|
340
|
+
})().catch((error) => {
|
|
341
|
+
this.closing = void 0;
|
|
342
|
+
this.fail(error);
|
|
343
|
+
throw error;
|
|
344
|
+
});
|
|
345
|
+
return this.closing;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Stop Client work on plugin unload without closing Host terminals.
|
|
349
|
+
* @returns after active and previously detached stream iterators have closed.
|
|
350
|
+
*/
|
|
351
|
+
async dispose() {
|
|
352
|
+
this.mounted = false;
|
|
353
|
+
this.lifetime.abort();
|
|
354
|
+
this.detach();
|
|
355
|
+
await Promise.all(this.detaching);
|
|
356
|
+
}
|
|
357
|
+
detach() {
|
|
358
|
+
const previous = this.stream;
|
|
359
|
+
this.stream = void 0;
|
|
360
|
+
this.attachmentId = void 0;
|
|
361
|
+
this.pendingRender?.resolve();
|
|
362
|
+
this.pendingRender = void 0;
|
|
363
|
+
if (previous !== void 0) {
|
|
364
|
+
const cleanup = previous.dispose().finally(() => {
|
|
365
|
+
this.detaching.delete(cleanup);
|
|
366
|
+
});
|
|
367
|
+
this.detaching.add(cleanup);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
async consume(stream) {
|
|
371
|
+
let generation = 0;
|
|
372
|
+
let sequence = 0;
|
|
373
|
+
try {
|
|
374
|
+
for await (const item of stream) {
|
|
375
|
+
if (this.stream !== stream) return;
|
|
376
|
+
const frame = item.value;
|
|
377
|
+
if (generation !== item.generation) {
|
|
378
|
+
if (frame.type !== "snapshot") throw new TerminalViewError("invalidOutput", "Terminal output generation is missing its screen snapshot");
|
|
379
|
+
generation = item.generation;
|
|
380
|
+
sequence = frame.sequence;
|
|
381
|
+
item.accept();
|
|
382
|
+
} else if (frame.type === "output") {
|
|
383
|
+
if (frame.sequence !== sequence + 1) throw new TerminalViewError("invalidOutput", "Terminal output sequence has a gap");
|
|
384
|
+
sequence = frame.sequence;
|
|
385
|
+
} else if (frame.type === "snapshot") throw new TerminalViewError("invalidOutput", "Unexpected terminal screen snapshot");
|
|
386
|
+
if (frame.type !== "output") this.patch({
|
|
387
|
+
info: frame.info,
|
|
388
|
+
title: frame.info.title,
|
|
389
|
+
phase: "connected",
|
|
390
|
+
writable: frame.info.state === "running" && frame.info.controllerId === this.attachmentId
|
|
391
|
+
});
|
|
392
|
+
if (frame.type !== "state") {
|
|
393
|
+
const revision = ++this.revision;
|
|
394
|
+
await new Promise((resolve) => {
|
|
395
|
+
this.pendingRender = {
|
|
396
|
+
revision,
|
|
397
|
+
resolve
|
|
398
|
+
};
|
|
399
|
+
const aborted = () => {
|
|
400
|
+
this.acknowledge(revision);
|
|
401
|
+
};
|
|
402
|
+
item.signal.addEventListener("abort", aborted, { once: true });
|
|
403
|
+
this.pendingRender.resolve = () => {
|
|
404
|
+
item.signal.removeEventListener("abort", aborted);
|
|
405
|
+
resolve();
|
|
406
|
+
};
|
|
407
|
+
this.patch({ render: {
|
|
408
|
+
revision,
|
|
409
|
+
frame
|
|
410
|
+
} });
|
|
411
|
+
if (item.signal.aborted) aborted();
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
if (this.stream === stream) if (this.state.getSnapshot().info?.state === "exited") this.patch({
|
|
417
|
+
phase: "closed",
|
|
418
|
+
writable: false
|
|
419
|
+
});
|
|
420
|
+
else this.fail(error);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
patch(patch) {
|
|
424
|
+
if (this.lifetime.signal.aborted) return;
|
|
425
|
+
this.state.set({
|
|
426
|
+
...this.state.getSnapshot(),
|
|
427
|
+
...patch
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
fail(error) {
|
|
431
|
+
const failure = remoteErrorOf(error);
|
|
432
|
+
if (failure?.code === "terminal/control-unavailable") {
|
|
433
|
+
this.patch({
|
|
434
|
+
writable: false,
|
|
435
|
+
error: void 0,
|
|
436
|
+
issue: void 0
|
|
437
|
+
});
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
const issue = failure?.code === "terminal/view" ? failure.details.issue : failure?.code === "terminal/limit-reached" ? "terminalLimit" : void 0;
|
|
441
|
+
this.patch({
|
|
442
|
+
phase: error instanceof _deepseek_ai_dsh_api_gateway_client.RemoteStreamCarrierError ? "disconnected" : "failed",
|
|
443
|
+
writable: false,
|
|
444
|
+
issue,
|
|
445
|
+
error: error instanceof Error ? error.message : String(error)
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
function valueOf(result) {
|
|
450
|
+
if (!result.ok) throw result.error;
|
|
451
|
+
return result.value;
|
|
452
|
+
}
|
|
453
|
+
//#endregion
|
|
454
|
+
//#region lib/types/client/close-requests.js
|
|
455
|
+
const PREFIX = "dsh.terminal.close.v1.";
|
|
456
|
+
/** Each request has its own storage key, so other browser windows cannot overwrite its cleanup. */
|
|
457
|
+
var TerminalCloseRequests = class {
|
|
458
|
+
requests = /* @__PURE__ */ new Map();
|
|
459
|
+
constructor() {
|
|
460
|
+
try {
|
|
461
|
+
if (typeof localStorage === "undefined") return;
|
|
462
|
+
for (let index = 0; index < localStorage.length; index++) {
|
|
463
|
+
const key = localStorage.key(index);
|
|
464
|
+
if (key?.startsWith(PREFIX)) this.load(key);
|
|
465
|
+
}
|
|
466
|
+
} catch (error) {
|
|
467
|
+
console.error("Terminal cleanup recovery failed:", error);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Read cleanup work still awaiting Host confirmation.
|
|
472
|
+
* @returns unfinished requests owned by this browser instance.
|
|
473
|
+
*/
|
|
474
|
+
pending() {
|
|
475
|
+
return [...this.requests.values()];
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Retain cleanup across reload before removing a tab.
|
|
479
|
+
* @param request - close intent to save before removing its tab.
|
|
480
|
+
*/
|
|
481
|
+
save(request) {
|
|
482
|
+
this.requests.set(request.id, request);
|
|
483
|
+
try {
|
|
484
|
+
if (typeof localStorage !== "undefined") localStorage.setItem(PREFIX + request.id, JSON.stringify(request));
|
|
485
|
+
} catch (error) {
|
|
486
|
+
console.error("Terminal cleanup persistence failed:", error);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Forget confirmed cleanup in memory and browser storage.
|
|
491
|
+
* @param id - terminal whose Host cleanup succeeded.
|
|
492
|
+
*/
|
|
493
|
+
remove(id) {
|
|
494
|
+
this.requests.delete(id);
|
|
495
|
+
try {
|
|
496
|
+
if (typeof localStorage !== "undefined") localStorage.removeItem(PREFIX + id);
|
|
497
|
+
} catch (error) {
|
|
498
|
+
console.error("Terminal cleanup persistence failed:", error);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
load(key) {
|
|
502
|
+
try {
|
|
503
|
+
const raw = localStorage.getItem(key);
|
|
504
|
+
if (raw === null) return;
|
|
505
|
+
const parsed = JSON.parse(raw);
|
|
506
|
+
if (!isRequest(parsed) || key !== PREFIX + parsed.id) throw new Error("Invalid terminal cleanup request");
|
|
507
|
+
this.requests.set(parsed.id, parsed);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
console.error("Terminal cleanup recovery failed:", error);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
function isRequest(value) {
|
|
514
|
+
if (typeof value !== "object" || value === null) return false;
|
|
515
|
+
const request = value;
|
|
516
|
+
return typeof request.sessionId === "string" && request.sessionId.length > 0 && typeof request.id === "string" && /^[\w-]{1,128}$/u.test(request.id) && typeof request.title === "string";
|
|
517
|
+
}
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region lib/types/client/index.js
|
|
520
|
+
/** Client terminal model service; views are keyed independently from Host terminal identities. */
|
|
521
|
+
/** Session and occurrence lookup, independent tab and terminal identities and background cleanup. */
|
|
522
|
+
var ClientTerminals = class extends _deepseek_ai_cordis.Service {
|
|
523
|
+
remote;
|
|
524
|
+
/** Failed cleanup tasks; successful and in-progress closes have no visible notification. */
|
|
525
|
+
closeFailures = (0, _deepseek_ai_dsh_client_store.createSnapshotStore)([]);
|
|
526
|
+
requests = new TerminalCloseRequests();
|
|
527
|
+
closing = /* @__PURE__ */ new Map();
|
|
528
|
+
closed = new Set(this.requests.pending().map((request) => request.id));
|
|
529
|
+
disposed = false;
|
|
530
|
+
views = /* @__PURE__ */ new Map();
|
|
531
|
+
/**
|
|
532
|
+
* @param ctx - Client root Context with Gateway and terminal Remote namespace.
|
|
533
|
+
* @param remote - generated terminal namespace.
|
|
534
|
+
*/
|
|
535
|
+
constructor(ctx, remote) {
|
|
536
|
+
super(ctx, "webTerminals");
|
|
537
|
+
this.remote = remote;
|
|
538
|
+
ctx.effect(() => async () => {
|
|
539
|
+
this.disposed = true;
|
|
540
|
+
const detaching = [...this.views.values()].flatMap((views) => [...views.values()].map((view) => view.dispose()));
|
|
541
|
+
this.views.clear();
|
|
542
|
+
await Promise.all([...detaching, ...this.closing.values()]);
|
|
543
|
+
}, "terminal-controller.client.views");
|
|
544
|
+
for (const request of this.requests.pending()) this.cleanup(request);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* Return the stable model for one sidebar occurrence.
|
|
548
|
+
* @param sessionId - owning Session.
|
|
549
|
+
* @param key - sidebar occurrence key.
|
|
550
|
+
* @param terminalId - existing Host identity when restoring a listed terminal.
|
|
551
|
+
* @param shellPath - explicit shell for a new terminal; restored terminals retain their own shell.
|
|
552
|
+
* @returns its observable state and terminal commands.
|
|
553
|
+
*/
|
|
554
|
+
view(sessionId, key, terminalId, shellPath) {
|
|
555
|
+
let views = this.views.get(sessionId);
|
|
556
|
+
if (views === void 0) {
|
|
557
|
+
views = /* @__PURE__ */ new Map();
|
|
558
|
+
this.views.set(sessionId, views);
|
|
559
|
+
}
|
|
560
|
+
let view = views.get(key);
|
|
561
|
+
if (view === void 0) {
|
|
562
|
+
const id = terminalId ?? randomUUID();
|
|
563
|
+
view = new TerminalView(sessionId, this.remote, this.ctx.remote, id, terminalId === void 0, shellPath);
|
|
564
|
+
views.set(key, view);
|
|
565
|
+
view.refresh();
|
|
566
|
+
}
|
|
567
|
+
return view;
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Discover available launch choices on demand without allocating a PTY.
|
|
571
|
+
* @param sessionId - target Session.
|
|
572
|
+
* @param signal - the menu request lifetime.
|
|
573
|
+
* @returns installed shells and the currently usable browser preference.
|
|
574
|
+
*/
|
|
575
|
+
async launchShells(sessionId, signal) {
|
|
576
|
+
const result = await this.remote.shells(sessionId, signal);
|
|
577
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
578
|
+
const previous = preferredShell();
|
|
579
|
+
return {
|
|
580
|
+
shells: result.value,
|
|
581
|
+
selectedShell: result.value.find((shell) => shell.path === previous)?.path ?? result.value[0]?.path
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* Remember the guide selection before allocating its terminal tab.
|
|
586
|
+
* @param path - shell selected from Host discovery.
|
|
587
|
+
*/
|
|
588
|
+
selectShell(path) {
|
|
589
|
+
rememberShell(path);
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* Save a close intent and release the tab immediately; cleanup outlives DOM unmount and reload.
|
|
593
|
+
* @param sessionId - owning Session.
|
|
594
|
+
* @param key - sidebar occurrence key, including an inactive restored tab.
|
|
595
|
+
* @param terminalId - restored identity if the tab has no model yet.
|
|
596
|
+
*/
|
|
597
|
+
close(sessionId, key, terminalId) {
|
|
598
|
+
const views = this.views.get(sessionId);
|
|
599
|
+
const view = views?.get(key);
|
|
600
|
+
const id = view?.id ?? terminalId;
|
|
601
|
+
if (id === void 0) return;
|
|
602
|
+
const request = {
|
|
603
|
+
sessionId,
|
|
604
|
+
id,
|
|
605
|
+
title: view?.state.getSnapshot().title ?? key
|
|
606
|
+
};
|
|
607
|
+
this.closed.add(id);
|
|
608
|
+
this.requests.save(request);
|
|
609
|
+
views?.delete(key);
|
|
610
|
+
if (views?.size === 0) this.views.delete(sessionId);
|
|
611
|
+
this.cleanup(request, view);
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Query Host terminals that have neither a tab in this page nor an unfinished close.
|
|
615
|
+
* @param sessionId - Session being displayed.
|
|
616
|
+
* @returns terminals available for opening as recovered tabs.
|
|
617
|
+
*/
|
|
618
|
+
async recover(sessionId) {
|
|
619
|
+
const result = await this.remote.list(sessionId);
|
|
620
|
+
if (!result.ok) throw new Error(result.error.message);
|
|
621
|
+
const held = new Set([...this.views.get(sessionId)?.values() ?? []].map((view) => view.id));
|
|
622
|
+
const closing = new Set(this.requests.pending().map((request) => request.id));
|
|
623
|
+
return result.value.filter((info) => !held.has(info.id) && !closing.has(info.id) && !this.closed.has(info.id));
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Retry a saved close request without reopening its tab.
|
|
627
|
+
* @param id - failed terminal identity.
|
|
628
|
+
*/
|
|
629
|
+
retryClose(id) {
|
|
630
|
+
const record = this.requests.pending().find((item) => item.id === id);
|
|
631
|
+
if (record !== void 0) this.cleanup(record);
|
|
632
|
+
}
|
|
633
|
+
cleanup(record, view) {
|
|
634
|
+
if (this.closing.has(record.id) || this.disposed) return;
|
|
635
|
+
this.closeFailures.set(this.closeFailures.getSnapshot().filter((failure) => failure.id !== record.id));
|
|
636
|
+
const pending = (async () => {
|
|
637
|
+
if (view !== void 0) await view.close();
|
|
638
|
+
else {
|
|
639
|
+
const result = await this.remote.close(record.sessionId, record.id);
|
|
640
|
+
if (!result.ok) throw result.error;
|
|
641
|
+
}
|
|
642
|
+
this.requests.remove(record.id);
|
|
643
|
+
})().catch((error) => {
|
|
644
|
+
if (remoteErrorOf(error)?.code === "session/not-found") {
|
|
645
|
+
this.requests.remove(record.id);
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
if (!this.disposed) this.closeFailures.set([...this.closeFailures.getSnapshot(), {
|
|
649
|
+
id: record.id,
|
|
650
|
+
title: record.title,
|
|
651
|
+
message: error instanceof Error ? error.message : String(error)
|
|
652
|
+
}]);
|
|
653
|
+
}).then(async () => {
|
|
654
|
+
await view?.dispose();
|
|
655
|
+
this.closing.delete(record.id);
|
|
656
|
+
});
|
|
657
|
+
this.closing.set(record.id, pending);
|
|
658
|
+
}
|
|
659
|
+
};
|
|
660
|
+
/** Required Client transport and terminal namespace. */
|
|
661
|
+
const inject = ["remote", "remote.terminal"];
|
|
662
|
+
/**
|
|
663
|
+
* Install the Client terminal models.
|
|
664
|
+
* @param ctx - Client root Context.
|
|
665
|
+
*/
|
|
666
|
+
function apply(ctx) {
|
|
667
|
+
new ClientTerminals(ctx, ctx.remote.terminal);
|
|
668
|
+
}
|
|
669
|
+
//#endregion
|
|
670
|
+
exports.ClientTerminals = ClientTerminals;
|
|
671
|
+
exports.apply = apply;
|
|
672
|
+
exports.inject = inject;
|
|
673
|
+
return module.exports;
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
//# sourceMappingURL=client.js.map
|