@lijian-ui/dsh-term 0.1.2 → 0.3.0
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/lib/client.js +698 -43875
- package/lib/client.js.map +1 -1
- package/lib/index.js +455 -30
- package/lib/tsconfig.client.tsbuildinfo +1 -1
- package/lib/tsconfig.host.tsbuildinfo +1 -1
- package/lib/types/client/client-i18n.d.ts +29 -0
- package/lib/types/client/client-i18n.d.ts.map +1 -0
- package/lib/types/client/i18n-seat.d.ts +15 -0
- package/lib/types/client/i18n-seat.d.ts.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/term/AnimatedDock.d.ts +10 -20
- package/lib/types/client/term/AnimatedDock.d.ts.map +1 -1
- package/lib/types/client/term/DockItem.d.ts +15 -0
- package/lib/types/client/term/DockItem.d.ts.map +1 -0
- package/lib/types/client/term/TerminalPanel.d.ts +8 -3
- package/lib/types/client/term/TerminalPanel.d.ts.map +1 -1
- package/lib/types/core/types.d.ts +1 -0
- package/lib/types/core/types.d.ts.map +1 -1
- package/lib/types/gateway/i18n.d.ts +17 -0
- package/lib/types/gateway/i18n.d.ts.map +1 -0
- package/lib/types/host/routes.d.ts +3 -1
- package/lib/types/host/routes.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/client-i18n.ts +68 -0
- package/src/client/i18n-seat.ts +27 -0
- package/src/client/index.ts +139 -46
- package/src/client/term/AnimatedDock.tsx +27 -114
- package/src/client/term/DockItem.tsx +42 -0
- package/src/client/term/TerminalPanel.tsx +339 -32
- package/src/client/term/api.ts +17 -2
- package/src/client/term/chat-helper.ts +28 -0
- package/src/client/term/term.module.css +137 -0
- package/src/core/types.ts +22 -4
- package/src/gateway/i18n.ts +67 -0
- package/src/host/pty-service.ts +160 -17
- package/src/host/routes.ts +44 -4
- package/src/index.ts +31 -1
package/lib/index.js
CHANGED
|
@@ -1,5 +1,163 @@
|
|
|
1
|
+
import { Service } from "@deepseek-ai/cordis";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, statSync } from "node:fs";
|
|
4
|
+
import { exec } from "node:child_process";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { homedir } from "node:os";
|
|
2
7
|
import * as nodePty from "node-pty";
|
|
8
|
+
//#region ../../node_modules/@deepseek-ai/dsh-settings/lib/index.js
|
|
9
|
+
/**
|
|
10
|
+
* Structural secret redaction for settings values. `role('secret')` fields are
|
|
11
|
+
* removed from a value before it crosses a wire boundary; a sidecar records
|
|
12
|
+
* each schema-declared secret position and whether it currently holds a value,
|
|
13
|
+
* so a configuration surface can render a write-only input without ever
|
|
14
|
+
* receiving the secret itself.
|
|
15
|
+
* @module @deepseek-ai/dsh-settings/redact
|
|
16
|
+
*/
|
|
17
|
+
/** Whether a value is a plain data object the walker may recurse into. */
|
|
18
|
+
function isRecord(value) {
|
|
19
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
|
+
}
|
|
21
|
+
function walk(node, value, path, secrets) {
|
|
22
|
+
if (node === void 0) return value;
|
|
23
|
+
if (node.meta?.role === "secret") {
|
|
24
|
+
secrets.push({
|
|
25
|
+
path,
|
|
26
|
+
set: value !== void 0
|
|
27
|
+
});
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
switch (node.type) {
|
|
31
|
+
case "object": {
|
|
32
|
+
const properties = node.dict ?? {};
|
|
33
|
+
const source = isRecord(value) ? value : void 0;
|
|
34
|
+
const rebuilt = {};
|
|
35
|
+
if (source !== void 0) for (const [key, entry] of Object.entries(source)) {
|
|
36
|
+
if (key in properties) continue;
|
|
37
|
+
rebuilt[key] = entry;
|
|
38
|
+
}
|
|
39
|
+
for (const [key, child] of Object.entries(properties)) {
|
|
40
|
+
const stripped = walk(child, source?.[key], [...path, key], secrets);
|
|
41
|
+
if (stripped !== void 0) rebuilt[key] = stripped;
|
|
42
|
+
}
|
|
43
|
+
return source === void 0 && Object.keys(rebuilt).length === 0 ? value : rebuilt;
|
|
44
|
+
}
|
|
45
|
+
case "dict": {
|
|
46
|
+
if (!isRecord(value)) return value;
|
|
47
|
+
const rebuilt = {};
|
|
48
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
49
|
+
const stripped = walk(node.inner, entry, [...path, key], secrets);
|
|
50
|
+
if (stripped !== void 0) rebuilt[key] = stripped;
|
|
51
|
+
}
|
|
52
|
+
return rebuilt;
|
|
53
|
+
}
|
|
54
|
+
case "array":
|
|
55
|
+
if (!Array.isArray(value)) return value;
|
|
56
|
+
return value.map((entry, index) => walk(node.inner, entry, [...path, String(index)], secrets));
|
|
57
|
+
default: return value;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Service Definition for the user-settings capability seam (`ctx.settings`). Providers store one raw document of
|
|
62
|
+
* per-namespace sections; plugins register a namespace schema and read the
|
|
63
|
+
* resolved value, which layers schema defaults, the registrant's composition
|
|
64
|
+
* `base`, and the user document section, in that order.
|
|
65
|
+
* @module @deepseek-ai/dsh-settings
|
|
66
|
+
*/
|
|
67
|
+
const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
68
|
+
/**
|
|
69
|
+
* Brand a raw string as a {@link SettingsNamespace}.
|
|
70
|
+
* @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
|
|
71
|
+
* @returns the branded namespace.
|
|
72
|
+
*/
|
|
73
|
+
function settingsNamespace(value) {
|
|
74
|
+
if (!NAMESPACE_PATTERN.test(value)) throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`);
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Deep equality over JSON-compatible data (objects, arrays, primitives) — the
|
|
79
|
+
* Service Definition's single change-detection predicate, exported so the invariant
|
|
80
|
+
* companion checks exactly the implementation's relation.
|
|
81
|
+
* @param a - one JSON-compatible value.
|
|
82
|
+
* @param b - the other JSON-compatible value.
|
|
83
|
+
* @returns whether the two values are structurally equal.
|
|
84
|
+
*/
|
|
85
|
+
function deepEqualJson(a, b) {
|
|
86
|
+
if (a === b) return true;
|
|
87
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
88
|
+
if (Array.isArray(a) || Array.isArray(b)) {
|
|
89
|
+
if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false;
|
|
90
|
+
return a.every((entry, index) => deepEqualJson(entry, b[index]));
|
|
91
|
+
}
|
|
92
|
+
const left = a;
|
|
93
|
+
const right = b;
|
|
94
|
+
const keys = Object.keys(left);
|
|
95
|
+
if (keys.length !== Object.keys(right).length) return false;
|
|
96
|
+
return keys.every((key) => key in right && deepEqualJson(left[key], right[key]));
|
|
97
|
+
}
|
|
98
|
+
/** Whether a value is a plain data object (not an array, null, or class instance). */
|
|
99
|
+
function isPlainObject(value) {
|
|
100
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
101
|
+
const proto = Object.getPrototypeOf(value);
|
|
102
|
+
return proto === Object.prototype || proto === null;
|
|
103
|
+
}
|
|
104
|
+
/** Apply one path op to a detached section, returning the next section. */
|
|
105
|
+
function applyPathOp(section, op) {
|
|
106
|
+
const [head, ...rest] = op.path;
|
|
107
|
+
if (head === void 0) {
|
|
108
|
+
if (op.op === "unset") return {};
|
|
109
|
+
if (!isPlainObject(op.value)) throw new TypeError("settings mutate: setting the section root requires a plain object");
|
|
110
|
+
return { ...op.value };
|
|
111
|
+
}
|
|
112
|
+
if (rest.length === 0) {
|
|
113
|
+
if (op.op === "set") return {
|
|
114
|
+
...section,
|
|
115
|
+
[head]: op.value
|
|
116
|
+
};
|
|
117
|
+
const { [head]: _removed, ...kept } = section;
|
|
118
|
+
return kept;
|
|
119
|
+
}
|
|
120
|
+
const child = section[head];
|
|
121
|
+
if (!isPlainObject(child)) {
|
|
122
|
+
if (op.op === "unset") return section;
|
|
123
|
+
return {
|
|
124
|
+
...section,
|
|
125
|
+
[head]: applyPathOp({}, {
|
|
126
|
+
...op,
|
|
127
|
+
path: rest
|
|
128
|
+
})
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
...section,
|
|
133
|
+
[head]: applyPathOp(child, {
|
|
134
|
+
...op,
|
|
135
|
+
path: rest
|
|
136
|
+
})
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Layer `over` onto `under`: plain objects merge recursively, every other
|
|
141
|
+
* value (arrays included) replaces the lower layer wholesale. `over` never
|
|
142
|
+
* carries `undefined` entries — sections come from parsed documents and write
|
|
143
|
+
* snapshots pass {@link cloneJsonShaped}, which strips them so a sparse patch
|
|
144
|
+
* cannot erase lower keys.
|
|
145
|
+
*/
|
|
146
|
+
function mergeLayers(under, over) {
|
|
147
|
+
if (over === void 0) return under;
|
|
148
|
+
if (!isPlainObject(under) || !isPlainObject(over)) return over;
|
|
149
|
+
const merged = { ...under };
|
|
150
|
+
for (const [key, value] of Object.entries(over)) merged[key] = key in merged ? mergeLayers(merged[key], value) : value;
|
|
151
|
+
return merged;
|
|
152
|
+
}
|
|
153
|
+
/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
|
|
154
|
+
function deepFreeze(value) {
|
|
155
|
+
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
|
|
156
|
+
for (const entry of Object.values(value)) deepFreeze(entry);
|
|
157
|
+
return Object.freeze(value);
|
|
158
|
+
}
|
|
159
|
+
Service.init;
|
|
160
|
+
//#endregion
|
|
3
161
|
//#region src/mount-once.ts
|
|
4
162
|
/**
|
|
5
163
|
* Host single-instance guard shared by the plugin family. The family bundle
|
|
@@ -55,19 +213,88 @@ function mountOnce(packageName, fn) {
|
|
|
55
213
|
* gives full control over multi-tab local shells.
|
|
56
214
|
* @module dsh-term/host/pty-service
|
|
57
215
|
*/
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
216
|
+
const IS_WIN = process.platform === "win32";
|
|
217
|
+
/** Windows-only Git Bash locations. */
|
|
218
|
+
const GIT_BASH_CANDIDATES_WIN = ["C:\\Program Files\\Git\\bin\\bash.exe", "C:\\Program Files (x86)\\Git\\bin\\bash.exe"];
|
|
219
|
+
/** macOS / Linux stock shells (always present on a normal desktop install). */
|
|
220
|
+
const UNIX_SHELL_PATHS = {
|
|
221
|
+
zsh: "/bin/zsh",
|
|
222
|
+
bash: "/bin/bash"
|
|
223
|
+
};
|
|
224
|
+
/** True if `name` resolves on PATH. Uses `where` on Windows, `command -v` on Unix. */
|
|
225
|
+
function commandExistsAsync(name) {
|
|
226
|
+
const probe = IS_WIN ? `where ${name}` : `command -v ${name}`;
|
|
227
|
+
return new Promise((r) => {
|
|
228
|
+
exec(probe, {
|
|
229
|
+
windowsHide: true,
|
|
230
|
+
timeout: 5e3
|
|
231
|
+
}, (err) => {
|
|
232
|
+
r(!err);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
/** Git Bash is only relevant on Windows (probed via launcher / bash / git). */
|
|
237
|
+
async function hasGitBash() {
|
|
238
|
+
if (!IS_WIN) return false;
|
|
239
|
+
if (await commandExistsAsync("git-bash.exe")) return true;
|
|
240
|
+
if (await commandExistsAsync("bash.exe") && (await commandExistsAsync("git.exe") || GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p)))) return true;
|
|
241
|
+
return GIT_BASH_CANDIDATES_WIN.some((p) => existsSync(p));
|
|
242
|
+
}
|
|
243
|
+
/** Map a ShellType to { command, args } for node-pty.spawn. */
|
|
244
|
+
function resolveShell(shell) {
|
|
245
|
+
switch (shell) {
|
|
246
|
+
case "powershell": return {
|
|
247
|
+
command: "powershell.exe",
|
|
248
|
+
args: ["-NoLogo"]
|
|
249
|
+
};
|
|
250
|
+
case "cmd": return {
|
|
251
|
+
command: "cmd.exe",
|
|
252
|
+
args: []
|
|
253
|
+
};
|
|
254
|
+
case "bash": return {
|
|
255
|
+
command: IS_WIN ? "bash.exe" : UNIX_SHELL_PATHS.bash,
|
|
256
|
+
args: ["--login", "-i"]
|
|
257
|
+
};
|
|
258
|
+
case "zsh": return {
|
|
259
|
+
command: UNIX_SHELL_PATHS.zsh,
|
|
260
|
+
args: ["-l", "-i"]
|
|
261
|
+
};
|
|
262
|
+
default:
|
|
263
|
+
if (IS_WIN) {
|
|
264
|
+
for (const c of GIT_BASH_CANDIDATES_WIN) if (existsSync(c)) return {
|
|
265
|
+
command: c,
|
|
266
|
+
args: ["--login", "-i"]
|
|
267
|
+
};
|
|
268
|
+
return {
|
|
269
|
+
command: "bash",
|
|
270
|
+
args: ["--login", "-i"]
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
command: UNIX_SHELL_PATHS.bash,
|
|
275
|
+
args: ["--login", "-i"]
|
|
276
|
+
};
|
|
277
|
+
}
|
|
62
278
|
}
|
|
63
|
-
/** Default
|
|
64
|
-
function
|
|
65
|
-
if (
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
279
|
+
/** Default shell kind for the platform. */
|
|
280
|
+
function defaultShellType() {
|
|
281
|
+
if (IS_WIN) return "powershell";
|
|
282
|
+
return existsSync(UNIX_SHELL_PATHS.zsh) ? "zsh" : "bash";
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Resolve a spawn cwd that is guaranteed to be an existing directory.
|
|
286
|
+
* node-pty fails at spawn time (macOS: "posix_spawnp failed") if the cwd
|
|
287
|
+
* does not exist — which happens on a cross-platform machine when a stale
|
|
288
|
+
* Windows-style workspace path is still in the store, or when the renderer
|
|
289
|
+
* sends an empty/null cwd. Fall back to $HOME so the PTY always spawns.
|
|
290
|
+
*/
|
|
291
|
+
function resolveCwd(cwd) {
|
|
292
|
+
const candidate = cwd && cwd.trim().length > 0 ? cwd.trim() : process.cwd();
|
|
293
|
+
try {
|
|
294
|
+
const abs = resolve(candidate);
|
|
295
|
+
if (existsSync(abs) && statSync(abs).isDirectory()) return abs;
|
|
296
|
+
} catch {}
|
|
297
|
+
return homedir();
|
|
71
298
|
}
|
|
72
299
|
/**
|
|
73
300
|
* The PTY registry. Every mutation goes through this class so the route
|
|
@@ -76,32 +303,71 @@ function defaultArgs(shell) {
|
|
|
76
303
|
*/
|
|
77
304
|
var PtyService = class {
|
|
78
305
|
sessions = /* @__PURE__ */ new Map();
|
|
306
|
+
/** Cached available shells (computed once on first query). */
|
|
307
|
+
availableShellsCache = null;
|
|
79
308
|
/** Fired with raw PTY output chunks (UTF-8). Bound by the route layer. */
|
|
80
309
|
onOutput = () => {};
|
|
81
310
|
/** Fired once when a session exits. Bound by the route layer. */
|
|
82
311
|
onExit = () => {};
|
|
312
|
+
/** Fired when a session is detached (tab closed, PTY kept alive). */
|
|
313
|
+
onDetach = () => {};
|
|
314
|
+
/** Fired when a session is reattached. */
|
|
315
|
+
onReattach = () => {};
|
|
316
|
+
/**
|
|
317
|
+
* Returns the shells actually available on this machine, so the browser
|
|
318
|
+
* can hide options the user never installed (Git Bash) instead of letting
|
|
319
|
+
* node-pty fail at spawn time. Results are cached after the first async
|
|
320
|
+
* detection to avoid repeated `where` calls.
|
|
321
|
+
*/
|
|
322
|
+
async detectShells() {
|
|
323
|
+
if (this.availableShellsCache !== null) return this.availableShellsCache;
|
|
324
|
+
const available = [];
|
|
325
|
+
if (await hasGitBash()) available.push("gitbash");
|
|
326
|
+
if (IS_WIN) {
|
|
327
|
+
if (await commandExistsAsync("powershell.exe") || await commandExistsAsync("pwsh.exe")) available.push("powershell");
|
|
328
|
+
if (await commandExistsAsync("cmd.exe")) available.push("cmd");
|
|
329
|
+
} else {
|
|
330
|
+
if (existsSync(UNIX_SHELL_PATHS.zsh)) available.push("zsh");
|
|
331
|
+
if (existsSync(UNIX_SHELL_PATHS.bash)) available.push("bash");
|
|
332
|
+
}
|
|
333
|
+
this.availableShellsCache = available.map((id) => ({
|
|
334
|
+
id,
|
|
335
|
+
labelKey: `ui.shell.${id}`
|
|
336
|
+
}));
|
|
337
|
+
return this.availableShellsCache;
|
|
338
|
+
}
|
|
83
339
|
/** Open one session; returns the wire info immediately (output streams async). */
|
|
84
340
|
spawn(req) {
|
|
85
341
|
const id = randomUUID();
|
|
86
|
-
const
|
|
87
|
-
const
|
|
342
|
+
const shellType = req.shell ?? defaultShellType();
|
|
343
|
+
const { command, args } = resolveShell(shellType);
|
|
344
|
+
const finalArgs = req.args ?? args;
|
|
88
345
|
const cols = req.cols ?? 80;
|
|
89
346
|
const rows = req.rows ?? 24;
|
|
90
|
-
const cwd = req.cwd
|
|
91
|
-
const
|
|
347
|
+
const cwd = resolveCwd(req.cwd);
|
|
348
|
+
const env = {
|
|
349
|
+
...process.env,
|
|
350
|
+
TERM: "xterm-256color",
|
|
351
|
+
FORCE_COLOR: "1",
|
|
352
|
+
...req.env ?? {}
|
|
353
|
+
};
|
|
354
|
+
const pty = nodePty.spawn(command, finalArgs, {
|
|
92
355
|
name: "xterm-256color",
|
|
93
356
|
cols,
|
|
94
357
|
rows,
|
|
95
|
-
cwd
|
|
358
|
+
cwd,
|
|
359
|
+
env
|
|
96
360
|
});
|
|
97
361
|
const info = {
|
|
98
362
|
id,
|
|
99
|
-
title: req.name ??
|
|
363
|
+
title: req.name ?? shellType,
|
|
100
364
|
cwd,
|
|
101
365
|
cols,
|
|
102
366
|
rows,
|
|
103
367
|
alive: true,
|
|
104
|
-
exitCode: null
|
|
368
|
+
exitCode: null,
|
|
369
|
+
shell: shellType,
|
|
370
|
+
detached: false
|
|
105
371
|
};
|
|
106
372
|
this.sessions.set(id, {
|
|
107
373
|
info,
|
|
@@ -151,19 +417,61 @@ var PtyService = class {
|
|
|
151
417
|
}
|
|
152
418
|
return true;
|
|
153
419
|
}
|
|
154
|
-
/**
|
|
420
|
+
/**
|
|
421
|
+
* Detach a session: mark it as detached but keep the PTY process alive.
|
|
422
|
+
* This lets the user close a tab without losing a running command (e.g.
|
|
423
|
+
* `npm install`); reopening re-attaches to the same session.
|
|
424
|
+
*/
|
|
425
|
+
detach(id) {
|
|
426
|
+
const live = this.sessions.get(id);
|
|
427
|
+
if (live === void 0) return false;
|
|
428
|
+
const newInfo = {
|
|
429
|
+
...live.info,
|
|
430
|
+
detached: true
|
|
431
|
+
};
|
|
432
|
+
this.sessions.set(id, {
|
|
433
|
+
info: newInfo,
|
|
434
|
+
pty: live.pty
|
|
435
|
+
});
|
|
436
|
+
this.onDetach(id);
|
|
437
|
+
return true;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Reattach to a detached session: mark it as attached and return the info.
|
|
441
|
+
* The caller creates a fresh xterm and starts routing SSE output to it.
|
|
442
|
+
*/
|
|
443
|
+
reattach(id) {
|
|
444
|
+
const live = this.sessions.get(id);
|
|
445
|
+
if (live === void 0) return null;
|
|
446
|
+
const newInfo = {
|
|
447
|
+
...live.info,
|
|
448
|
+
detached: false
|
|
449
|
+
};
|
|
450
|
+
this.sessions.set(id, {
|
|
451
|
+
info: newInfo,
|
|
452
|
+
pty: live.pty
|
|
453
|
+
});
|
|
454
|
+
this.onReattach(newInfo);
|
|
455
|
+
return newInfo;
|
|
456
|
+
}
|
|
457
|
+
/** Close a session forcefully (kill the PTY). Returns false when unknown. */
|
|
155
458
|
close(id) {
|
|
156
459
|
const live = this.sessions.get(id);
|
|
157
460
|
if (live === void 0) return false;
|
|
158
461
|
try {
|
|
159
462
|
live.pty.kill();
|
|
160
463
|
} catch {}
|
|
464
|
+
this.sessions.delete(id);
|
|
161
465
|
return true;
|
|
162
466
|
}
|
|
163
|
-
/** The full session listing snapshot. */
|
|
467
|
+
/** The full session listing snapshot (including detached sessions). */
|
|
164
468
|
list() {
|
|
165
469
|
return [...this.sessions.values()].map(({ info }) => ({ ...info }));
|
|
166
470
|
}
|
|
471
|
+
/** Only the detached sessions (for the "reopen" dropdown). */
|
|
472
|
+
detachedList() {
|
|
473
|
+
return [...this.sessions.values()].filter(({ info }) => info.detached).map(({ info }) => ({ ...info }));
|
|
474
|
+
}
|
|
167
475
|
/** Close every session (route teardown). */
|
|
168
476
|
dispose() {
|
|
169
477
|
for (const live of this.sessions.values()) try {
|
|
@@ -238,7 +546,7 @@ function readBody(req) {
|
|
|
238
546
|
let size = 0;
|
|
239
547
|
req.on("data", (chunk) => {
|
|
240
548
|
size += chunk.length;
|
|
241
|
-
if (size >
|
|
549
|
+
if (size > 64 * 1024) {
|
|
242
550
|
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
243
551
|
req.destroy();
|
|
244
552
|
return;
|
|
@@ -267,9 +575,10 @@ function json(res, envelope, status = 200) {
|
|
|
267
575
|
* Register the /dsh-term routes.
|
|
268
576
|
* @param ctx - context carrying the webServer service.
|
|
269
577
|
* @param pty - the session registry.
|
|
578
|
+
* @param getT - lazy translator getter (reflects current language).
|
|
270
579
|
* @returns route disposers.
|
|
271
580
|
*/
|
|
272
|
-
function registerTermRoutes(ctx, pty) {
|
|
581
|
+
function registerTermRoutes(ctx, pty, getT) {
|
|
273
582
|
const subscribers = /* @__PURE__ */ new Set();
|
|
274
583
|
const push = (event) => {
|
|
275
584
|
for (const subscriber of subscribers) subscriber.res.write(`event: term\ndata: ${JSON.stringify(event)}\n\n`);
|
|
@@ -279,10 +588,22 @@ function registerTermRoutes(ctx, pty) {
|
|
|
279
588
|
id,
|
|
280
589
|
data
|
|
281
590
|
});
|
|
282
|
-
pty.onExit = (id, exitCode) =>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
591
|
+
pty.onExit = (id, exitCode) => {
|
|
592
|
+
const msg = getT().t("msg.sessionExited", exitCode);
|
|
593
|
+
push({
|
|
594
|
+
kind: "exit",
|
|
595
|
+
id,
|
|
596
|
+
exitCode,
|
|
597
|
+
message: msg
|
|
598
|
+
});
|
|
599
|
+
};
|
|
600
|
+
pty.onDetach = (id) => push({
|
|
601
|
+
kind: "detached",
|
|
602
|
+
id
|
|
603
|
+
});
|
|
604
|
+
pty.onReattach = (session) => push({
|
|
605
|
+
kind: "reattached",
|
|
606
|
+
session
|
|
286
607
|
});
|
|
287
608
|
const handler = async (req, res) => {
|
|
288
609
|
if (!isLoopbackRequest(req)) {
|
|
@@ -295,6 +616,11 @@ function registerTermRoutes(ctx, pty) {
|
|
|
295
616
|
json(res, OK({ sessions: pty.list() }));
|
|
296
617
|
return;
|
|
297
618
|
}
|
|
619
|
+
if (req.method === "GET" && url.pathname === "/dsh-term/shells") {
|
|
620
|
+
const shells = await pty.detectShells();
|
|
621
|
+
json(res, OK({ shells }));
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
298
624
|
if (req.method !== "POST") {
|
|
299
625
|
json(res, MALFORMED, 405);
|
|
300
626
|
return;
|
|
@@ -316,10 +642,17 @@ function registerTermRoutes(ctx, pty) {
|
|
|
316
642
|
const session = pty.spawn({
|
|
317
643
|
name: typeof request.name === "string" ? request.name : void 0,
|
|
318
644
|
cwd: typeof request.cwd === "string" ? request.cwd : void 0,
|
|
319
|
-
shell: typeof request.shell === "string"
|
|
645
|
+
shell: typeof request.shell === "string" && [
|
|
646
|
+
"bash",
|
|
647
|
+
"zsh",
|
|
648
|
+
"powershell",
|
|
649
|
+
"cmd",
|
|
650
|
+
"gitbash"
|
|
651
|
+
].includes(request.shell) ? request.shell : void 0,
|
|
320
652
|
args: Array.isArray(request.args) ? request.args.filter((a) => typeof a === "string") : void 0,
|
|
321
653
|
cols: typeof request.cols === "number" ? request.cols : void 0,
|
|
322
|
-
rows: typeof request.rows === "number" ? request.rows : void 0
|
|
654
|
+
rows: typeof request.rows === "number" ? request.rows : void 0,
|
|
655
|
+
env: typeof request.env === "object" && request.env !== null ? Object.fromEntries(Object.entries(request.env).filter(([, v]) => typeof v === "string")) : void 0
|
|
323
656
|
});
|
|
324
657
|
push({
|
|
325
658
|
kind: "start",
|
|
@@ -364,6 +697,29 @@ function registerTermRoutes(ctx, pty) {
|
|
|
364
697
|
json(res, OK({ ok: pty.close(body.id) }));
|
|
365
698
|
return;
|
|
366
699
|
}
|
|
700
|
+
case "/dsh-term/detach": {
|
|
701
|
+
const body = payload;
|
|
702
|
+
if (typeof body?.id !== "string") {
|
|
703
|
+
json(res, MALFORMED, 400);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
json(res, OK({ ok: pty.detach(body.id) }));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
case "/dsh-term/reattach": {
|
|
710
|
+
const body = payload;
|
|
711
|
+
if (typeof body?.id !== "string") {
|
|
712
|
+
json(res, MALFORMED, 400);
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
const session = pty.reattach(body.id);
|
|
716
|
+
if (session === null) {
|
|
717
|
+
json(res, FAIL("session not found", "not_found"), 404);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
json(res, OK(session));
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
367
723
|
default: json(res, MALFORMED, 404);
|
|
368
724
|
}
|
|
369
725
|
} catch (error) {
|
|
@@ -410,6 +766,57 @@ function registerTermRoutes(ctx, pty) {
|
|
|
410
766
|
};
|
|
411
767
|
}
|
|
412
768
|
//#endregion
|
|
769
|
+
//#region src/gateway/i18n.ts
|
|
770
|
+
const zh = {
|
|
771
|
+
"msg.sessionExited": "[dsh-term] 进程已退出(code {0})",
|
|
772
|
+
"msg.spawnFailed": "[dsh-term] 启动失败: {0}",
|
|
773
|
+
"ui.panel.title": "终端",
|
|
774
|
+
"ui.panel.addTabTitle": "新建终端",
|
|
775
|
+
"ui.panel.collapseTitle": "收起",
|
|
776
|
+
"ui.panel.emptyHint": "点击 + 新建终端",
|
|
777
|
+
"ui.dock.label": "终端",
|
|
778
|
+
"ui.tab.closeAria": "关闭 {0}",
|
|
779
|
+
"ui.panel.shellTitle": "Shell",
|
|
780
|
+
"ui.shell.bash": "Bash",
|
|
781
|
+
"ui.shell.zsh": "Zsh",
|
|
782
|
+
"ui.shell.powershell": "PowerShell",
|
|
783
|
+
"ui.shell.cmd": "命令提示符",
|
|
784
|
+
"ui.shell.gitbash": "Git Bash"
|
|
785
|
+
};
|
|
786
|
+
const dicts = {
|
|
787
|
+
zh,
|
|
788
|
+
en: {
|
|
789
|
+
"msg.sessionExited": "[dsh-term] Process exited (code {0})",
|
|
790
|
+
"msg.spawnFailed": "[dsh-term] Spawn failed: {0}",
|
|
791
|
+
"ui.panel.title": "Terminal",
|
|
792
|
+
"ui.panel.addTabTitle": "New Terminal",
|
|
793
|
+
"ui.panel.collapseTitle": "Collapse",
|
|
794
|
+
"ui.panel.emptyHint": "Click + to create a terminal",
|
|
795
|
+
"ui.dock.label": "Terminal",
|
|
796
|
+
"ui.tab.closeAria": "Close {0}",
|
|
797
|
+
"ui.panel.shellTitle": "Shell",
|
|
798
|
+
"ui.shell.bash": "Bash",
|
|
799
|
+
"ui.shell.zsh": "Zsh",
|
|
800
|
+
"ui.shell.powershell": "PowerShell",
|
|
801
|
+
"ui.shell.cmd": "Command Prompt",
|
|
802
|
+
"ui.shell.gitbash": "Git Bash"
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
var Translator = class {
|
|
806
|
+
lang;
|
|
807
|
+
constructor(lang) {
|
|
808
|
+
this.lang = lang;
|
|
809
|
+
}
|
|
810
|
+
t(key, ...args) {
|
|
811
|
+
let s = (dicts[this.lang] ?? zh)[key] ?? zh[key] ?? key;
|
|
812
|
+
for (let i = 0; i < args.length; i++) s = s.replaceAll(`{${i}}`, String(args[i]));
|
|
813
|
+
return s;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
function createTranslator(lang) {
|
|
817
|
+
return new Translator(lang);
|
|
818
|
+
}
|
|
819
|
+
//#endregion
|
|
413
820
|
//#region src/index.ts
|
|
414
821
|
/** Required services: the route registry. */
|
|
415
822
|
const inject = ["webServer"];
|
|
@@ -422,13 +829,31 @@ const DSH_TERM_GUIDANCE = "本机已安装 dsh-term 插件(DSH Web GUI 的面
|
|
|
422
829
|
const apply = mountOnce("@lijian-ui/dsh-term", applyImpl);
|
|
423
830
|
function applyImpl(ctx) {
|
|
424
831
|
const pty = new PtyService();
|
|
832
|
+
/** Current language; resolved from dsh global settings. */
|
|
833
|
+
let lang = "zh";
|
|
834
|
+
createTranslator(lang);
|
|
835
|
+
/** Resolve the user's language preference from dsh settings. */
|
|
836
|
+
function resolveLang() {
|
|
837
|
+
try {
|
|
838
|
+
const settings = ctx.get("settings");
|
|
839
|
+
if (!settings) return "zh";
|
|
840
|
+
return settings.get(settingsNamespace("locale"))?.preference === "en" ? "en" : "zh";
|
|
841
|
+
} catch {
|
|
842
|
+
return "zh";
|
|
843
|
+
}
|
|
844
|
+
}
|
|
425
845
|
ctx.effect(() => {
|
|
426
|
-
|
|
846
|
+
lang = resolveLang();
|
|
847
|
+
const disposeRoutes = registerTermRoutes(ctx, pty, () => createTranslator(lang));
|
|
427
848
|
return () => {
|
|
428
849
|
disposeRoutes();
|
|
429
850
|
pty.dispose();
|
|
430
851
|
};
|
|
431
852
|
}, "dsh-term: routes + pty lifecycle");
|
|
853
|
+
ctx.root.on("settings/updated", (ns, next) => {
|
|
854
|
+
if (ns !== settingsNamespace("locale")) return;
|
|
855
|
+
lang = next?.preference === "en" ? "en" : "zh";
|
|
856
|
+
});
|
|
432
857
|
}
|
|
433
858
|
//#endregion
|
|
434
859
|
export { DSH_TERM_GUIDANCE, apply, inject };
|