@zhangfengshun/dsh-remote-ssh 1.2.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 ADDED
@@ -0,0 +1,778 @@
1
+ /**
2
+ * dsh-remote-ssh — Client 半边(静态 DSH Web 插件,浏览器 bundle)
3
+ *
4
+ * 通过 dsh-better-sidebar 的 `ctx.betterSidebar.registerTab` 注册两个侧边栏页签:
5
+ * - remssh:files 远程文件(文件浏览器 + 工作区选择)
6
+ * - remssh:term 远程终端(ssh -tt 管道终端)
7
+ * 并在 DSH 设置页注册「远程连接」小节(settings.section)管理连接配置;
8
+ * 同时接管原生「添加工作区」流程(directoryFlow 槽位,优先级 -1 覆盖内置选择器),
9
+ * 提供「本地目录 / 远程目录」两选:远程分支创建本地镜像目录并注册为原生工作区。
10
+ */
11
+ window.__ModuleLoader__.load({
12
+ id: "dsh-remote-ssh",
13
+ factory: (require) => {
14
+ var module = { exports: {} };
15
+ var exports = module.exports;
16
+ var React = require("react");
17
+ var primitives = require("@deepseek-ai/dsh-client-ui-primitives");
18
+ var Modal = primitives.Modal;
19
+
20
+ function h(tag, props) {
21
+ var args = [tag, props];
22
+ for (var i = 2; i < arguments.length; i++) args.push(arguments[i]);
23
+ return React.createElement.apply(React, args);
24
+ }
25
+
26
+ var CSS =
27
+ ".rssh-row{margin:6px 0}.rssh-label{color:#9aa0a6;font-size:11px;display:block;margin-bottom:2px}" +
28
+ ".rssh-input{width:100%;box-sizing:border-box;background:#121316;color:#eee;border:1px solid #444;border-radius:4px;padding:5px 7px;margin:3px 0;font-size:12px}" +
29
+ ".rssh-select{width:100%;box-sizing:border-box;background:#121316;color:#eee;border:1px solid #444;border-radius:4px;padding:5px 7px;margin:3px 0;font-size:12px}" +
30
+ ".rssh-btn{background:#3a3d41;color:#eee;border:1px solid #555;border-radius:4px;padding:4px 9px;cursor:pointer;font-size:12px}" +
31
+ ".rssh-btn:hover{background:#45494e}.rssh-btn:disabled{opacity:.5;cursor:default}" +
32
+ ".rssh-card{padding:8px 10px;border:1px solid #333;border-radius:6px;margin:8px 0;background:#26282c}" +
33
+ ".rssh-title{font-weight:600;font-size:13px}.rssh-sub{color:#9aa0a6;font-size:11px}" +
34
+ ".rssh-actions{margin-top:6px;display:flex;gap:5px;flex-wrap:wrap}" +
35
+ ".rssh-tree-item{display:flex;align-items:center;gap:5px;padding:3px 5px;cursor:pointer;border-radius:3px}" +
36
+ ".rssh-tree-item:hover{background:#2f3237}" +
37
+ ".rssh-tree-dir{color:#7ab0ff}.rssh-tree-file{color:#d7d7d7}" +
38
+ ".rssh-tree-size{color:#6a7076;font-size:11px;margin-left:auto}" +
39
+ ".rssh-pathbar{display:flex;gap:4px;align-items:center;margin:4px 0}" +
40
+ ".rssh-pathbar .rssh-input{flex:1;margin:0}" +
41
+ ".rssh-content{white-space:pre-wrap;font-family:Consolas,monospace;font-size:12px;background:#121316;padding:8px;border-radius:4px;margin-top:6px;max-height:400px;overflow:auto}" +
42
+ ".rssh-terminal{background:#0d0e10;border-radius:4px;padding:6px;margin-top:8px;font-family:Consolas,monospace;font-size:12px}" +
43
+ ".rssh-terminal-out{white-space:pre-wrap;max-height:320px;overflow:auto;min-height:120px;line-height:1.4}" +
44
+ ".rssh-terminal-bar{display:flex;gap:5px;align-items:center;margin-bottom:4px}" +
45
+ ".rssh-terminal-input-row{display:flex;gap:4px;margin-top:5px}.rssh-terminal-input-row .rssh-input{flex:1}" +
46
+ ".rssh-ok{color:#4ade80}.rssh-err{color:#f87171}.rssh-muted{color:#9aa0a6;font-size:12px;padding:6px 0}" +
47
+ ".rssh-empty{padding:12px;text-align:center;color:#9aa0a6;font-size:12px}" +
48
+ ".rssh-flow-dialog{width:690px!important;max-width:94vw!important;min-width:460px;resize:horizontal;overflow:auto!important}" +
49
+ // 设置面板导航:隐藏「远程连接」栏目左侧壳层硬编码的齿轮图标(本栏目 order=200 位于末尾)。
50
+ ".VOzbGW_navList .VOzbGW_navCell:last-child .VOzbGW_navIcon{display:none}";
51
+
52
+ // ---- 国际化(跟随 DSH 设置中的语言自动切换)----
53
+ var NS = "dsh-remote-ssh";
54
+ var zh = {
55
+ "tabs.files": "远程文件",
56
+ "tabs.term": "远程终端",
57
+ "settings.nav": "🖥️ 远程连接",
58
+ "settings.desc": "管理 Remote-SSH 的远程连接配置(SSH 密钥或密码认证)。",
59
+ "settings.empty": "还没有连接配置。请在下方添加。",
60
+ "settings.add": "添加连接",
61
+ "settings.name": "名称",
62
+ "settings.namePlaceholder": "如:我的超算",
63
+ "settings.host": "主机 host",
64
+ "settings.hostPlaceholder": "login.example.com",
65
+ "settings.port": "端口 port",
66
+ "settings.user": "用户名 user",
67
+ "settings.userPlaceholder": "your-username",
68
+ "settings.auth": "认证方式",
69
+ "settings.key": "密钥 (推荐)",
70
+ "settings.password": "密码 (需 sshpass)",
71
+ "settings.keyPath": "私钥路径 keyPath",
72
+ "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
73
+ "settings.passwordField": "密码 password",
74
+ "settings.remoteRoot": "远程根目录 remoteRoot",
75
+ "settings.test": "测试连接",
76
+ "settings.saved": "已保存",
77
+ "settings.connOk": "连接成功",
78
+ "settings.connFail": "连接失败: ",
79
+ "settings.errSave": "保存失败",
80
+ "common.selectProfile": "— 选择连接 —",
81
+ "common.profile": "连接配置",
82
+ "common.save": "保存",
83
+ "common.open": "打开",
84
+ "common.close": "关闭",
85
+ "common.delete": "删除",
86
+ "common.up": "⬆ 上级",
87
+ "common.loading": "加载中…",
88
+ "common.processing": "处理中…",
89
+ "common.errList": "读取目录失败",
90
+ "files.workspace": "远程工作区",
91
+ "files.selectWorkspace": "— 选择工作区 —",
92
+ "files.orProfile": "或直接选连接配置",
93
+ "files.defaultDir": "默认目录(打开该工作区时进入的远程目录)",
94
+ "files.reading": "读取中…",
95
+ "files.errRead": "读取失败",
96
+ "files.errSave": "保存失败",
97
+ "files.errSaveDefaultDir": "保存默认目录失败",
98
+ "term.title": "终端 {id}",
99
+ "term.exited": " (已退出)",
100
+ "term.connecting": "正在连接…",
101
+ "term.noOutput": "(无输出)",
102
+ "term.inputPlaceholder": "输入命令后回车…",
103
+ "term.send": "发送",
104
+ "term.needProfile": "请先选择连接配置",
105
+ "term.newTerminal": "+ 新建终端",
106
+ "term.emptyHint": "从上面的连接配置新建一个终端,直接操作远程环境。",
107
+ "term.errSpawn": "启动终端失败",
108
+ "picker.dir": "目录",
109
+ "picker.choose": "选择此目录",
110
+ "flow.localTitle": "选择本地目录",
111
+ "flow.remoteTitle": "选择远程目录",
112
+ "flow.localDir": "本地目录",
113
+ "flow.remoteDir": "远程目录",
114
+ "flow.chooseRemote": "选择远程目录…",
115
+ "flow.backLocal": "← 选择本地目录",
116
+ "flow.errCreate": "创建远程工作区失败"
117
+ };
118
+ var en = {
119
+ "tabs.files": "Remote Files",
120
+ "tabs.term": "Remote Terminal",
121
+ "settings.nav": "🖥️ Remote Connections",
122
+ "settings.desc": "Manage Remote-SSH connection profiles (SSH key or password authentication).",
123
+ "settings.empty": "No connection profiles yet. Add one below.",
124
+ "settings.add": "Add Profile",
125
+ "settings.name": "Name",
126
+ "settings.namePlaceholder": "e.g. My HPC",
127
+ "settings.host": "Host",
128
+ "settings.hostPlaceholder": "login.example.com",
129
+ "settings.port": "Port",
130
+ "settings.user": "User",
131
+ "settings.userPlaceholder": "your-username",
132
+ "settings.auth": "Authentication",
133
+ "settings.key": "Key (recommended)",
134
+ "settings.password": "Password (requires sshpass)",
135
+ "settings.keyPath": "Private key path (keyPath)",
136
+ "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
137
+ "settings.passwordField": "Password",
138
+ "settings.remoteRoot": "Remote root (remoteRoot)",
139
+ "settings.test": "Test Connection",
140
+ "settings.saved": "Saved",
141
+ "settings.connOk": "Connected",
142
+ "settings.connFail": "Connection failed: ",
143
+ "settings.errSave": "Save failed",
144
+ "common.selectProfile": "— Select profile —",
145
+ "common.profile": "Profile",
146
+ "common.save": "Save",
147
+ "common.open": "Open",
148
+ "common.close": "Close",
149
+ "common.delete": "Delete",
150
+ "common.up": "⬆ Up",
151
+ "common.loading": "Loading…",
152
+ "common.processing": "Processing…",
153
+ "common.errList": "Failed to list directory",
154
+ "files.workspace": "Remote Workspace",
155
+ "files.selectWorkspace": "— Select workspace —",
156
+ "files.orProfile": "or pick a profile directly",
157
+ "files.defaultDir": "Default directory (entered when opening this workspace)",
158
+ "files.reading": "Reading…",
159
+ "files.errRead": "Failed to read",
160
+ "files.errSave": "Failed to save",
161
+ "files.errSaveDefaultDir": "Failed to save default directory",
162
+ "term.title": "Terminal {id}",
163
+ "term.exited": " (exited)",
164
+ "term.connecting": "Connecting…",
165
+ "term.noOutput": "(no output)",
166
+ "term.inputPlaceholder": "Type a command and press Enter…",
167
+ "term.send": "Send",
168
+ "term.needProfile": "Select a profile first",
169
+ "term.newTerminal": "+ New terminal",
170
+ "term.emptyHint": "Create a terminal from the profile above to operate the remote environment.",
171
+ "term.errSpawn": "Failed to start terminal",
172
+ "picker.dir": "Directory",
173
+ "picker.choose": "Choose this directory",
174
+ "flow.localTitle": "Select Local Directory",
175
+ "flow.remoteTitle": "Select Remote Directory",
176
+ "flow.localDir": "Local directory",
177
+ "flow.remoteDir": "Remote directory",
178
+ "flow.chooseRemote": "Choose remote directory…",
179
+ "flow.backLocal": "← Choose local directory",
180
+ "flow.errCreate": "Failed to create remote workspace"
181
+ };
182
+ var i18n = {
183
+ t: function (key) { return key; },
184
+ subscribe: function () { return function () {}; },
185
+ getSnapshot: function () { return 0; }
186
+ };
187
+ function useT() {
188
+ var state = React.useState(0);
189
+ var force = state[1];
190
+ React.useEffect(function () {
191
+ return i18n.subscribe(function () { force(function (n) { return n + 1; }); });
192
+ }, []);
193
+ return i18n.t;
194
+ }
195
+
196
+ // ---- 跨会话切换的状态记忆(按 sessionId 分桶,模块级存活,页面刷新即失效)----
197
+ var memoryBySession = {};
198
+ function useSessionMemory(sessionId, key, initial) {
199
+ var sid = sessionId || "global";
200
+ var bucket = memoryBySession[sid] || (memoryBySession[sid] = {});
201
+ var state = React.useState(function () {
202
+ if (Object.prototype.hasOwnProperty.call(bucket, key)) return bucket[key];
203
+ var init = typeof initial === "function" ? initial() : initial;
204
+ return bucket[key] = init;
205
+ });
206
+ var value = state[0], setValue = state[1];
207
+ React.useEffect(function () { bucket[key] = value; }, [value]);
208
+ return [value, setValue];
209
+ }
210
+
211
+ // ---- 与 Host 半边的 JSON RPC ----
212
+ async function api(method, args) {
213
+ try {
214
+ var r = await fetch("/remote-ssh/api/" + method, {
215
+ method: "POST",
216
+ headers: { "content-type": "application/json" },
217
+ body: JSON.stringify(args || {})
218
+ });
219
+ var j = await r.json();
220
+ if (j && j.ok) return j.value;
221
+ return { ok: false, error: (j && j.error && j.error.message) || ("HTTP " + r.status) };
222
+ } catch (e) {
223
+ return { ok: false, error: String(e && e.message ? e.message : e) };
224
+ }
225
+ }
226
+
227
+ // ---- 跨页签「打开工作区」的轻量共享选择 ----
228
+ var sel = { workspaceId: null };
229
+ var selListeners = [];
230
+ function setSelWorkspace(id) { sel.workspaceId = id; selListeners.forEach(function (fn) { fn(); }); }
231
+ function subscribeSel(fn) { selListeners.push(fn); return function () { var i = selListeners.indexOf(fn); if (i >= 0) selListeners.splice(i, 1); }; }
232
+
233
+ // ---- 通用:拉取连接配置(refreshKey 变化时重新拉取,避免缓存过期)----
234
+ function useProfiles(refreshKey) {
235
+ var state = React.useState([]);
236
+ var profiles = state[0], setProfiles = state[1];
237
+ React.useEffect(function () {
238
+ api("listProfiles").then(function (r) { if (r && r.ok) setProfiles(r.profiles || []); });
239
+ }, [refreshKey]);
240
+ return profiles;
241
+ }
242
+
243
+ function Field(props) {
244
+ return h("div", { className: "rssh-row" },
245
+ h("span", { className: "rssh-label" }, props.label),
246
+ h("input", { className: "rssh-input", type: props.type || "text", value: props.value, placeholder: props.placeholder,
247
+ onChange: function (e) { props.onChange(e.target.value); } })
248
+ );
249
+ }
250
+
251
+ // ======================================================================
252
+ // 远程文件页签
253
+ // ======================================================================
254
+ function FilesTab(props) {
255
+ var t = useT();
256
+ var sessionId = props.sessionId;
257
+ var [workspaces, setWorkspaces] = React.useState([]);
258
+ var profiles = useProfiles();
259
+ var [wsId, setWsId] = useSessionMemory(sessionId, "wsId", sel.workspaceId);
260
+ var [profileId, setProfileId] = useSessionMemory(sessionId, "profileId", "");
261
+ var [path, setPath] = useSessionMemory(sessionId, "path", "");
262
+ var [entries, setEntries] = useSessionMemory(sessionId, "entries", []);
263
+ var [err, setErr] = React.useState(null);
264
+ var [loading, setLoading] = React.useState(false);
265
+ var [openFile, setOpenFile] = useSessionMemory(sessionId, "openFile", null);
266
+ var [defaultDir, setDefaultDir] = useSessionMemory(sessionId, "defaultDir", "");
267
+
268
+ React.useEffect(function () {
269
+ api("listWorkspaces").then(function (r) { if (r && r.ok) setWorkspaces(r.workspaces || []); });
270
+ return subscribeSel(function () { setWsId(sel.workspaceId); });
271
+ }, []);
272
+
273
+ function load(targetProfile, targetPath) {
274
+ setLoading(true); setErr(null);
275
+ api("listDir", { profileId: targetProfile, path: targetPath }).then(function (r) {
276
+ setLoading(false);
277
+ if (r && r.ok) { setEntries(r.entries || []); setPath(r.path || targetPath); }
278
+ else setErr((r && r.error) || t("common.errList"));
279
+ });
280
+ }
281
+
282
+ // 工作区/配置选择变化时进入对应目录
283
+ React.useEffect(function () {
284
+ if (!wsId && !profileId) return;
285
+ var pid = profileId;
286
+ var rootPath = "";
287
+ if (wsId) {
288
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
289
+ if (ws) { pid = ws.profileId; rootPath = ws.remotePath; }
290
+ }
291
+ if (!pid) return;
292
+ setProfileId(pid);
293
+ load(pid, rootPath || undefined);
294
+ }, [wsId, profileId, workspaces]);
295
+
296
+ // 选中工作区时,把「默认目录」输入框同步为该工作区的 remotePath。
297
+ React.useEffect(function () {
298
+ if (wsId) {
299
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
300
+ if (ws) setDefaultDir(ws.remotePath || "");
301
+ }
302
+ }, [wsId, workspaces]);
303
+
304
+ function saveDefaultDir() {
305
+ if (!wsId) return;
306
+ api("updateWorkspace", { id: wsId, remotePath: defaultDir }).then(function (r) {
307
+ if (r && r.ok) { setWorkspaces(r.workspaces || []); setErr(null); }
308
+ else setErr((r && r.error) || t("files.errSaveDefaultDir"));
309
+ });
310
+ }
311
+
312
+ function openEntry(e) {
313
+ var base = path ? (path.replace(/\/+$/, "") + "/" + e.name) : e.name;
314
+ if (e.type === "directory") {
315
+ setPath(base);
316
+ load(profileId, base);
317
+ } else {
318
+ setOpenFile({ path: base, content: "", loading: true });
319
+ api("readFile", { profileId: profileId, path: base }).then(function (r) {
320
+ if (r && r.ok) setOpenFile({ path: base, content: r.content, binary: r.binary, dirty: false, loading: false });
321
+ else setOpenFile({ path: base, content: "", error: (r && r.error) || t("files.errRead"), loading: false });
322
+ });
323
+ }
324
+ }
325
+
326
+ function goUp() {
327
+ if (!path) return;
328
+ var parts = path.replace(/\/+$/, "").split("/");
329
+ parts.pop();
330
+ var up = parts.join("/") || "/";
331
+ setPath(up);
332
+ load(profileId, up);
333
+ }
334
+
335
+ function saveFile() {
336
+ if (!openFile) return;
337
+ api("writeFile", { profileId: profileId, path: openFile.path, content: openFile.content }).then(function (r) {
338
+ if (r && r.ok) setOpenFile(Object.assign({}, openFile, { dirty: false }));
339
+ else setErr((r && r.error) || t("files.errSave"));
340
+ });
341
+ }
342
+
343
+ return h("div", null,
344
+ h("div", { className: "rssh-row" },
345
+ h("span", { className: "rssh-label" }, t("files.workspace")),
346
+ h("select", { className: "rssh-select", value: wsId || "", onChange: function (e) { setWsId(e.target.value); setProfileId(""); } },
347
+ h("option", { value: "" }, t("files.selectWorkspace")),
348
+ workspaces.map(function (w) { return h("option", { key: w.id, value: w.id }, w.title); })
349
+ )
350
+ ),
351
+ h("div", { className: "rssh-row" },
352
+ h("span", { className: "rssh-label" }, t("files.orProfile")),
353
+ h("select", { className: "rssh-select", value: profileId, onChange: function (e) { setProfileId(e.target.value); setWsId(""); } },
354
+ h("option", { value: "" }, t("common.selectProfile")),
355
+ profiles.map(function (p) { return h("option", { key: p.id, value: p.id }, p.name); })
356
+ )
357
+ ),
358
+ wsId ? h("div", { className: "rssh-row" },
359
+ h("span", { className: "rssh-label" }, t("files.defaultDir")),
360
+ h("div", { className: "rssh-pathbar" },
361
+ h("input", { className: "rssh-input", value: defaultDir, onChange: function (e) { setDefaultDir(e.target.value); }, placeholder: "/home/user/project" }),
362
+ h("button", { className: "rssh-btn", onClick: saveDefaultDir }, t("common.save"))
363
+ )
364
+ ) : null,
365
+ h("div", { className: "rssh-pathbar" },
366
+ h("input", { className: "rssh-input", value: path, onChange: function (e) { setPath(e.target.value); }, placeholder: "/home/user" }),
367
+ h("button", { className: "rssh-btn", onClick: function () { load(profileId, path); } }, t("common.open")),
368
+ h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
369
+ ),
370
+ loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
371
+ err ? h("div", { className: "rssh-err", style: { whiteSpace: "pre-wrap" } }, err) : null,
372
+ h("div", { style: { marginTop: 6 } },
373
+ entries.map(function (e) {
374
+ return h("div", { key: e.name, className: "rssh-tree-item", onClick: function () { openEntry(e); } },
375
+ h("span", { className: e.type === "directory" ? "rssh-tree-dir" : "rssh-tree-file" }, e.type === "directory" ? "📁 " + e.name : "📄 " + e.name),
376
+ e.type === "file" ? h("span", { className: "rssh-tree-size" }, String(e.size)) : null
377
+ );
378
+ })
379
+ ),
380
+ openFile ? h("div", null,
381
+ h("div", { className: "rssh-pathbar", style: { marginTop: 8 } },
382
+ h("span", { className: "rssh-title", style: { flex: 1, wordBreak: "break-all" } }, openFile.path),
383
+ h("button", { className: "rssh-btn", onClick: saveFile }, t("common.save")),
384
+ h("button", { className: "rssh-btn", onClick: function () { setOpenFile(null); } }, t("common.close"))
385
+ ),
386
+ openFile.loading ? h("div", { className: "rssh-muted" }, t("files.reading")) :
387
+ openFile.error ? h("div", { className: "rssh-err" }, openFile.error) :
388
+ h("textarea", { className: "rssh-content", style: { width: "100%", boxSizing: "border-box", minHeight: 200, background: "#121316", color: "#eee", border: "1px solid #444" },
389
+ value: openFile.content, onChange: function (e) { setOpenFile(Object.assign({}, openFile, { content: e.target.value, dirty: true })); } })
390
+ ) : null
391
+ );
392
+ }
393
+
394
+ // ======================================================================
395
+ // 远程终端页签
396
+ // ======================================================================
397
+ function TerminalView(props) {
398
+ var t = useT();
399
+ var [data, setData] = React.useState("");
400
+ var [status, setStatus] = React.useState("running");
401
+ var [input, setInput] = React.useState("");
402
+ var endRef = React.useRef(null);
403
+
404
+ React.useEffect(function () {
405
+ var timer = setInterval(function () {
406
+ api("terminalRead", { id: props.id }).then(function (r) {
407
+ if (r && r.ok) {
408
+ if (r.data) setData(function (d) { return d + r.data; });
409
+ if (r.status) setStatus(r.status);
410
+ }
411
+ });
412
+ }, 200);
413
+ return function () { clearInterval(timer); };
414
+ }, [props.id]);
415
+
416
+ React.useEffect(function () {
417
+ if (endRef.current) endRef.current.scrollTop = endRef.current.scrollHeight;
418
+ }, [data]);
419
+
420
+ function send() {
421
+ if (!input) return;
422
+ api("terminalWrite", { id: props.id, data: input + "\r" });
423
+ setInput("");
424
+ }
425
+
426
+ return h("div", { className: "rssh-terminal" },
427
+ h("div", { className: "rssh-terminal-bar" },
428
+ h("span", { style: { flex: 1, color: status === "running" ? "#4ade80" : "#f87171" } }, t("term.title", { id: props.id }) + (status === "exited" ? t("term.exited") : "")),
429
+ h("button", { className: "rssh-btn", onClick: function () { props.onClose(props.id); } }, t("common.close"))
430
+ ),
431
+ h("div", { className: "rssh-terminal-out", ref: endRef }, data || (status === "running" ? t("term.connecting") : t("term.noOutput"))),
432
+ h("div", { className: "rssh-terminal-input-row" },
433
+ h("input", { className: "rssh-input", value: input,
434
+ onChange: function (e) { setInput(e.target.value); },
435
+ onKeyDown: function (e) { if (e.key === "Enter") send(); },
436
+ placeholder: t("term.inputPlaceholder") }),
437
+ h("button", { className: "rssh-btn", onClick: send }, t("term.send"))
438
+ )
439
+ );
440
+ }
441
+
442
+ function TerminalTab(props) {
443
+ var t = useT();
444
+ var sessionId = props.sessionId;
445
+ var profiles = useProfiles();
446
+ var [profileId, setProfileId] = useSessionMemory(sessionId, "profileId", "");
447
+ var [terms, setTerms] = useSessionMemory(sessionId, "terms", []);
448
+ var [err, setErr] = React.useState(null);
449
+
450
+ function spawn() {
451
+ if (!profileId) { setErr(t("term.needProfile")); return; }
452
+ setErr(null);
453
+ api("spawnTerminal", { profileId: profileId }).then(function (r) {
454
+ if (r && r.ok) setTerms(function (list) { return list.concat([{ id: r.id }]); });
455
+ else setErr((r && r.error) || t("term.errSpawn"));
456
+ });
457
+ }
458
+
459
+ function close(id) {
460
+ api("terminalClose", { id: id });
461
+ setTerms(function (list) { return list.filter(function (x) { return x.id !== id; }); });
462
+ }
463
+
464
+ return h("div", null,
465
+ h("div", { className: "rssh-row" },
466
+ h("span", { className: "rssh-label" }, t("common.profile")),
467
+ h("select", { className: "rssh-select", value: profileId, onChange: function (e) { setProfileId(e.target.value); } },
468
+ h("option", { value: "" }, t("common.selectProfile")),
469
+ profiles.map(function (p) { return h("option", { key: p.id, value: p.id }, p.name); })
470
+ )
471
+ ),
472
+ h("button", { className: "rssh-btn", onClick: spawn }, t("term.newTerminal")),
473
+ err ? h("div", { className: "rssh-err", style: { marginTop: 6 } }, err) : null,
474
+ terms.length === 0 ? h("div", { className: "rssh-empty" }, t("term.emptyHint")) : null,
475
+ terms.map(function (term) { return h(TerminalView, { key: term.id, id: term.id, onClose: close }); })
476
+ );
477
+ }
478
+
479
+ // ======================================================================
480
+ // 目录选择器(供「添加工作区」弹窗流程使用:本地 browse / 远程 SSH)
481
+ // ======================================================================
482
+ // 本地目录列举:走 DSH browse 能力(无原生对话框时替代 pickDirectory)。
483
+ function localListFn(workspaces, p) {
484
+ return workspaces.listDirectory(p).then(function (r) {
485
+ return {
486
+ ok: true,
487
+ path: r.path,
488
+ entries: (r.entries || []).map(function (e) { return { name: e.name, type: "directory", path: e.path }; })
489
+ };
490
+ }).catch(function (e) {
491
+ return { ok: false, error: String(e && e.message ? e.message : e) };
492
+ });
493
+ }
494
+
495
+ // 远程目录列举:走本插件 SSH API,并为每条记录补全绝对 path。
496
+ function remoteListFn(profileId, p) {
497
+ return api("listDir", { profileId: profileId, path: p }).then(function (r) {
498
+ if (!(r && r.ok)) return r;
499
+ var parent = r.path || "";
500
+ var entries = (r.entries || []).map(function (e) {
501
+ return { name: e.name, type: e.type, path: (parent ? parent.replace(/\/+$/, "") + "/" : "") + e.name };
502
+ });
503
+ return { ok: true, path: r.path, entries: entries };
504
+ });
505
+ }
506
+
507
+ // 计算父目录,兼容 Windows(\\) 与 POSIX(/) 分隔符。
508
+ function parentOf(path) {
509
+ if (!path) return null;
510
+ var str = String(path).replace(/[\/\\]+$/, "");
511
+ if (str === "") return null;
512
+ var idx = Math.max(str.lastIndexOf("/"), str.lastIndexOf("\\"));
513
+ if (idx < 0) return null;
514
+ var parent = str.slice(0, idx);
515
+ if (/^[A-Za-z]:$/.test(parent)) return parent + "\\";
516
+ if (parent === "") return "/";
517
+ return parent;
518
+ }
519
+
520
+ // 通用目录选择器:listFn(path) 返回 {ok, path, entries:[{name,type,path}]}。
521
+ // resetKey 变化(如远程切换连接)时从根目录重新加载。
522
+ function DirPicker(props) {
523
+ var t = useT();
524
+ var [path, setPath] = React.useState("");
525
+ var [entries, setEntries] = React.useState([]);
526
+ var [err, setErr] = React.useState(null);
527
+ var [loading, setLoading] = React.useState(false);
528
+
529
+ var listFnRef = React.useRef(props.listFn);
530
+ listFnRef.current = props.listFn;
531
+
532
+ function load(p) {
533
+ setLoading(true); setErr(null);
534
+ listFnRef.current(p).then(function (r) {
535
+ setLoading(false);
536
+ if (r && r.ok) { setEntries(r.entries || []); setPath(r.path || p || ""); }
537
+ else setErr((r && r.error) || t("common.errList"));
538
+ });
539
+ }
540
+ React.useEffect(function () { load(undefined); }, [props.resetKey]);
541
+
542
+ function enterDir(e) { load(e.path); }
543
+ function goUp() {
544
+ var up = parentOf(path);
545
+ if (up !== null) load(up);
546
+ }
547
+
548
+ return h("div", { style: { border: "1px solid #333", borderRadius: 6, padding: 8, marginTop: 6 } },
549
+ h("div", { className: "rssh-pathbar" },
550
+ h("input", { className: "rssh-input", value: path, onChange: function (e) { setPath(e.target.value); }, placeholder: props.placeholder || t("picker.dir") }),
551
+ h("button", { className: "rssh-btn", onClick: function () { load(path); } }, t("common.open")),
552
+ h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
553
+ ),
554
+ loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
555
+ err ? h("div", { className: "rssh-err" }, err) : null,
556
+ h("div", { style: { maxHeight: 180, overflow: "auto", marginTop: 4 } },
557
+ entries.filter(function (e) { return e.type === "directory"; }).map(function (e) {
558
+ return h("div", { key: e.path || e.name, className: "rssh-tree-item", onClick: function () { enterDir(e); } },
559
+ h("span", { className: "rssh-tree-dir" }, "📁 " + e.name));
560
+ })
561
+ ),
562
+ h("button", { className: "rssh-btn", style: { marginTop: 8 }, disabled: !path, onClick: function () { props.onChoose(path); } }, t("picker.choose"))
563
+ );
564
+ }
565
+
566
+ function RemoteWorkspaceFlow(props) {
567
+ var t = useT();
568
+ var [mode, setMode] = React.useState("local");
569
+ var profiles = useProfiles(props.open);
570
+ var [profileId, setProfileId] = React.useState("");
571
+ var [creating, setCreating] = React.useState(false);
572
+
573
+ // 每次打开流程时重置为「本地目录」起始界面。
574
+ React.useEffect(function () {
575
+ if (props.open) { setMode("local"); setProfileId(""); }
576
+ }, [props.open]);
577
+
578
+ if (!props.open) return null;
579
+
580
+ function chooseLocal(p) { props.onPicked(p); }
581
+
582
+ function chooseRemote(remotePath) {
583
+ setCreating(true);
584
+ api("createRemoteWorkspace", { profileId: profileId, remotePath: remotePath }).then(function (r) {
585
+ setCreating(false);
586
+ if (r && r.ok && r.mirrorPath) props.onPicked(r.mirrorPath);
587
+ else props.onError((r && r.error) || t("flow.errCreate"));
588
+ });
589
+ }
590
+
591
+ var canClose = !creating && !props.busy;
592
+
593
+ if (mode === "remote") {
594
+ return h(Modal, { open: true, onClose: function () { if (canClose) props.onCancel(); }, title: t("flow.remoteTitle"), closeLabel: t("common.close"), className: "rssh-flow-dialog" },
595
+ h("div", { style: { padding: 8 } },
596
+ creating || props.busy ? h("div", { className: "rssh-muted" }, t("common.processing")) : null,
597
+ h("div", { className: "rssh-row" },
598
+ h("span", { className: "rssh-label" }, t("common.profile")),
599
+ h("select", { className: "rssh-select", value: profileId, onChange: function (e) { setProfileId(e.target.value); } },
600
+ h("option", { value: "" }, t("common.selectProfile")),
601
+ profiles.map(function (p) { return h("option", { key: p.id, value: p.id }, p.name); })
602
+ )
603
+ ),
604
+ profileId ? h(DirPicker, { resetKey: profileId, placeholder: t("flow.remoteDir"), listFn: function (p) { return remoteListFn(profileId, p); }, onChoose: chooseRemote }) : null,
605
+ h("div", { className: "rssh-actions", style: { marginTop: 8 } },
606
+ h("button", { className: "rssh-btn", onClick: function () { setMode("local"); }, disabled: !!props.busy }, t("flow.backLocal"))
607
+ )
608
+ )
609
+ );
610
+ }
611
+
612
+ return h(Modal, { open: true, onClose: function () { if (canClose) props.onCancel(); }, title: t("flow.localTitle"), closeLabel: t("common.close"), className: "rssh-flow-dialog" },
613
+ h("div", { style: { padding: 8 } },
614
+ props.busy ? h("div", { className: "rssh-muted" }, t("common.processing")) : null,
615
+ h(DirPicker, { resetKey: "local", placeholder: t("flow.localDir"), listFn: function (p) { return localListFn(props.workspaces, p); }, onChoose: chooseLocal }),
616
+ h("div", { className: "rssh-actions", style: { marginTop: 8 } },
617
+ h("button", { className: "rssh-btn", onClick: function () { setMode("remote"); }, disabled: !!props.busy }, t("flow.chooseRemote"))
618
+ )
619
+ )
620
+ );
621
+ }
622
+
623
+ // ======================================================================
624
+ // 设置页小节(连接配置管理)
625
+ // ======================================================================
626
+ function SettingsSection(props) {
627
+ var t = useT();
628
+ var [profiles, setProfiles] = React.useState([]);
629
+ var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
630
+ var [result, setResult] = React.useState(null);
631
+ var [busy, setBusy] = React.useState(false);
632
+ var set = function (k, v) { setForm(function (f) { var n = {}; for (var key in f) n[key] = f[key]; n[k] = v; return n; }); };
633
+
634
+ function reload() { api("listProfiles").then(function (r) { if (r && r.ok) setProfiles(r.profiles || []); }); }
635
+ React.useEffect(reload, []);
636
+
637
+ function save() {
638
+ setBusy(true); setResult(null);
639
+ api("saveProfile", Object.assign({}, form, { port: parseInt(form.port, 10) || 22 })).then(function (r) {
640
+ setBusy(false);
641
+ if (r && r.ok) {
642
+ setProfiles(r.profiles || []);
643
+ setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
644
+ setResult({ ok: true, msg: t("settings.saved") });
645
+ } else setResult({ ok: false, msg: (r && r.error) || t("settings.errSave") });
646
+ });
647
+ }
648
+ function test(p) {
649
+ setBusy(true); setResult(null);
650
+ api("testConnection", { id: p.id }).then(function (r) {
651
+ setBusy(false);
652
+ if (r && r.ok) setResult({ ok: true, msg: t("settings.connOk") + "\n" + (r.stdout || "") });
653
+ else setResult({ ok: false, msg: t("settings.connFail") + ((r && r.error) || (r && r.stderr) || "") });
654
+ });
655
+ }
656
+ function del(p) {
657
+ api("deleteProfile", { id: p.id }).then(function (r) { if (r && r.ok) { setProfiles(r.profiles || []); } });
658
+ }
659
+
660
+ return h("div", null,
661
+ h("p", { className: "rssh-muted" }, t("settings.desc")),
662
+ profiles.map(function (p) {
663
+ return h("div", { key: p.id, className: "rssh-card" },
664
+ h("div", { className: "rssh-title" }, p.name),
665
+ h("div", { className: "rssh-sub" }, (p.user || "") + "@" + (p.host || "") + ":" + (p.port || 22) + " [" + (p.authMethod || "key") + "]"),
666
+ h("div", { className: "rssh-actions" },
667
+ h("button", { className: "rssh-btn", onClick: function () { test(p); }, disabled: busy }, t("settings.test")),
668
+ h("button", { className: "rssh-btn", onClick: function () { del(p); } }, t("common.delete"))
669
+ )
670
+ );
671
+ }),
672
+ profiles.length === 0 ? h("div", { className: "rssh-empty" }, t("settings.empty")) : null,
673
+ h("div", { className: "rssh-card" },
674
+ h("div", { className: "rssh-title" }, t("settings.add")),
675
+ Field({ label: t("settings.name"), value: form.name, onChange: function (v) { set("name", v); }, placeholder: t("settings.namePlaceholder") }),
676
+ Field({ label: t("settings.host"), value: form.host, onChange: function (v) { set("host", v); }, placeholder: t("settings.hostPlaceholder") }),
677
+ Field({ label: t("settings.port"), value: form.port, onChange: function (v) { set("port", v); } }),
678
+ Field({ label: t("settings.user"), value: form.user, onChange: function (v) { set("user", v); }, placeholder: t("settings.userPlaceholder") }),
679
+ h("div", { className: "rssh-row" },
680
+ h("span", { className: "rssh-label" }, t("settings.auth")),
681
+ h("select", { className: "rssh-select", value: form.authMethod, onChange: function (e) { set("authMethod", e.target.value); } },
682
+ h("option", { value: "key" }, t("settings.key")),
683
+ h("option", { value: "password" }, t("settings.password"))
684
+ )
685
+ ),
686
+ form.authMethod === "key" ? Field({ label: t("settings.keyPath"), value: form.keyPath, onChange: function (v) { set("keyPath", v); }, placeholder: t("settings.keyPathPlaceholder") }) : null,
687
+ form.authMethod === "password" ? Field({ label: t("settings.passwordField"), type: "password", value: form.password, onChange: function (v) { set("password", v); } }) : null,
688
+ Field({ label: t("settings.remoteRoot"), value: form.remoteRoot, onChange: function (v) { set("remoteRoot", v); }, placeholder: "~/project" }),
689
+ h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save"))
690
+ ),
691
+ result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null
692
+ );
693
+ }
694
+
695
+ // ======================================================================
696
+ // apply
697
+ // ======================================================================
698
+ function apply(ctx) {
699
+ var betterSidebar = ctx.betterSidebar;
700
+ var slots = ctx.slots;
701
+ var locale = ctx.locale;
702
+
703
+ // 注册中英文词典并绑定当前语言;locale 切换时 i18n.subscribe 触发组件重渲染。
704
+ if (locale) {
705
+ ctx.effect(function () { return locale.register(NS, { zh: zh, en: en }); }, "dsh-remote-ssh: dictionaries");
706
+ i18n.t = locale.bind(NS);
707
+ i18n.subscribe = function (cb) { return locale.subscribe(cb); };
708
+ i18n.getSnapshot = function () { return locale.getSnapshot(); };
709
+ }
710
+
711
+ ctx.effect(function () {
712
+ var el = document.createElement("style");
713
+ el.textContent = CSS;
714
+ document.head.appendChild(el);
715
+ return function () { if (el.parentNode) el.parentNode.removeChild(el); };
716
+ });
717
+
718
+ if (betterSidebar) {
719
+ ctx.effect(function () {
720
+ return betterSidebar.registerTab({
721
+ id: "remssh:files",
722
+ title: function () { return i18n.t("tabs.files"); },
723
+ order: 90,
724
+ single: true,
725
+ icon: function () { return h("span", null, "🗂️"); },
726
+ component: function (props) {
727
+ var sid = props && props.scope ? props.scope.sessionId : null;
728
+ return h(FilesTab, { key: "files:" + (sid || "global"), sessionId: sid });
729
+ }
730
+ });
731
+ });
732
+ ctx.effect(function () {
733
+ return betterSidebar.registerTab({
734
+ id: "remssh:term",
735
+ title: function () { return i18n.t("tabs.term"); },
736
+ order: 91,
737
+ single: true,
738
+ icon: function () { return h("span", null, "💻"); },
739
+ component: function (props) {
740
+ var sid = props && props.scope ? props.scope.sessionId : null;
741
+ return h(TerminalTab, { key: "term:" + (sid || "global"), sessionId: sid });
742
+ }
743
+ });
744
+ });
745
+ }
746
+
747
+ if (slots) {
748
+ slots.inject("settings.section", function () {
749
+ return slots.register({
750
+ name: "settings.section",
751
+ id: "remote-ssh",
752
+ order: 200,
753
+ label: function () { return i18n.t("settings.nav"); },
754
+ locale: NS
755
+ }, SettingsSection);
756
+ });
757
+
758
+ // 接管原生「添加工作区」流程:用明显更低的优先级覆盖内置的本地目录选择器,
759
+ // 弹窗内提供「本地目录(DSH browse)/ 远程目录(SSH)」两种浏览。
760
+ var flowInject = function () { return { workspaces: ctx.get("workspaces") }; };
761
+ ctx.effect(function () {
762
+ return slots.inject("conversation.hero.workspace.directoryFlow", function () {
763
+ return slots.register({ name: "conversation.hero.workspace.directoryFlow", priority: -100, inject: flowInject }, RemoteWorkspaceFlow);
764
+ });
765
+ });
766
+ ctx.effect(function () {
767
+ return slots.inject("sidebar.workspaces.directoryFlow", function () {
768
+ return slots.register({ name: "sidebar.workspaces.directoryFlow", priority: -100, inject: flowInject }, RemoteWorkspaceFlow);
769
+ });
770
+ });
771
+ }
772
+ }
773
+
774
+ exports.apply = apply;
775
+ exports.inject = ["betterSidebar", "slots", "locale"];
776
+ return module.exports;
777
+ }
778
+ });