@zhangfengshun/dsh-remote-ssh 1.3.1 → 1.5.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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [1.5.0] — 导入 OpenSSH 配置 + 双向同步
6
+ ### 新增
7
+ - **从 `~/.ssh/config` 导入连接**:设置小节「从 ~/.ssh/config 导入…」按钮读取本机 OpenSSH 用户配置(递归解析 `Include`),多选主机后批量导入为连接配置(`HostName` / `User` / `Port` / `IdentityFile` / `ProxyJump` 一并带入)。借鉴 Yan-Zero `dsh-remote-ssh`,免去逐条手动录入。
8
+ - **跳板机(ProxyJump)**:连接配置新增可选 `ProxyJump` 字段,`sshArgv` 追加 `-o ProxyJump=...`,OpenSSH 原生支持;导入的带跳板机主机开箱即用。
9
+ - **tar-over-ssh 双向同步**:新增 `syncDown`(远端 → 本地镜像)/ `syncUp`(本地镜像 → 远端)HTTP API 与 `remote_ssh_sync` / `remote_ssh_push` 模型工具,用流式管道把一条 ssh 的 stdout 喂给本地 `tar` 的 stdin(反之亦然),不经过 4MB 缓冲上限。借鉴 flymysql `dsh-remote` 的 `rw_sync` / `rw_push`,但复用现有 `ssh.exe` + `tar`,不引入 `ssh2` 依赖。
10
+ - 「远程文件」页签在选中工作区时显示「⬇ 同步 / ⬆ 推送」按钮,同步后镜像目录装下真实远程文件,内置 `read`/`grep`/`glob` 工具可直接读取镜像。
11
+ - `remote_ssh_sync` / `remote_ssh_push` 工具会话感知:在远程工作区会话中无需 `workspaceId`,自动识别当前工作区。
12
+
13
+ ## [1.4.0] — 远程文件单开一栏预览
14
+ ### 新增
15
+ - 点击「远程文件」文件树中的文件,改为在 better-sidebar 中**单开一栏(独立页签)预览/编辑**,与内置「文件」页签一致,不再在文件树下方内嵌预览。新增隐藏页签类型 `remssh:editor`,同一远程文件按 (profileId, path) 去重复用。
16
+
5
17
  ## [1.3.1] — 修复远程文件树滚动
6
18
  ### 修复
7
19
  - 「远程文件」页签改为 flex 布局:文件树在文件很多时占据剩余高度并内部滚动(`flex:1; overflow-y:auto`),不再因父容器 `overflow:hidden` 而无法往下拉。
package/README.md CHANGED
@@ -8,11 +8,13 @@
8
8
 
9
9
  | 能力 | 说明 |
10
10
  | --- | --- |
11
- | 🔌 远程连接 | SSH 连接超算 / 服务器,密钥认证(推荐)或密码认证(需本机 `sshpass`),内置「测试连接」 |
12
- | 🗂️ 远程文件 | better-sidebar「远程文件」页签浏览 / 打开 / 编辑 / 保存远程文件 |
11
+ | 🔌 远程连接 | SSH 连接超算 / 服务器,密钥认证(推荐)或密码认证(需本机 `sshpass`),内置「测试连接」;支持 `ProxyJump` 跳板机 |
12
+ | 📥 配置导入 | 一键从 `~/.ssh/config`(递归 `Include`)发现主机并批量导入连接配置 |
13
+ | 🗂️ 远程文件 | 在 better-sidebar「远程文件」页签浏览 / 打开 / 编辑 / 保存远程文件;选中工作区后可双向同步 |
13
14
  | 💻 远程终端 | 在 better-sidebar「远程终端」页签打开 `ssh -tt` 集成终端,支持多终端并发 |
15
+ | 🔄 双向同步 | `remote_ssh_sync`(远端 → 本地镜像)/ `remote_ssh_push`(本地镜像 → 远端),tar 流式管道,同步后内置文件工具可见 |
14
16
  | 🌐 远程工作区 | 选择远程目录创建**原生工作区**(本地镜像目录 + 原生注册),一键打开进入远程环境 |
15
- | 🤖 模型工具 | 5 个 `remote_ssh_*` 工具,模型可读写远程文件、执行远程命令;在远程工作区会话中免填连接参数 |
17
+ | 🤖 模型工具 | 7 个 `remote_ssh_*` 工具,模型可读写远程文件、执行远程命令、双向同步;在远程工作区会话中免填连接参数 |
16
18
  | 🌍 国际化 | 界面文案 + 工具描述中英双语,通过 `ctx.locale` 跟随 DSH 语言设置自动切换 |
17
19
  | 🧠 会话记忆 | 切换会话后,「远程文件」「远程终端」页签记住已选连接、浏览目录、打开的文件与终端 |
18
20
 
@@ -27,7 +29,7 @@ dsh plugin --profile <name> add /absolute/path/to/dsh-remote-ssh
27
29
  ### 发布后安装
28
30
 
29
31
  ```bash
30
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.2.0
32
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.5.0
31
33
  ```
32
34
 
33
35
  > ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
@@ -86,6 +88,8 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.2.0
86
88
  | `remote_ssh_ls` | 列举远程目录 |
87
89
  | `remote_ssh_cat` | 读取远程文本文件(base64 传输,二进制安全) |
88
90
  | `remote_ssh_write` | 写入远程文件(覆盖写入) |
91
+ | `remote_ssh_sync` | 远端文件同步到本地镜像目录(tar 流式管道) |
92
+ | `remote_ssh_push` | 本地镜像目录推送回远端(tar 流式管道) |
89
93
 
90
94
  **会话感知**:当会话是从远程工作区创建时,模型调用这些工具可**不填** `profileId` / `host` / `user` 等连接参数,自动复用该工作区的连接,相对路径基于该工作区远程目录解析。
91
95
 
@@ -112,7 +116,7 @@ dsh-remote-ssh/
112
116
  │ · 远程文件 / 远程终端 │ ◀────── │ · SSH:listDir/readFile/… │
113
117
  │ ctx.slots: settings.section │ JSON │ · settings:profiles/workspaces │
114
118
  │ · DSH 设置页「远程连接」 │ │ · terminals:ssh -tt 管道 │
115
- └───────────────────────────────┘ │ ctx.tools:5 个 remote_ssh_* │
119
+ └───────────────────────────────┘ │ ctx.tools:7 个 remote_ssh_* │
116
120
  └───────────────┬──────────────────┘
117
121
  │ SSH
118
122
  远程超算 / 服务器
@@ -132,15 +136,16 @@ dsh-remote-ssh/
132
136
  1. **终端非本地 PTY**:走 `ssh -tt` 管道通道(跨平台可用);`vim` / `htop` 等全屏程序体验受限,后续可接入 `terminals.registerBackend` + `node-pty` 真 PTY。
133
137
  2. **文件操作依赖 GNU 工具**(`find -printf`、`base64 -w0`):目标为 Linux 超算时通用。
134
138
  3. **密码认证需本机 `sshpass`**(Windows 默认没有);密钥认证无此依赖。
135
- 4. **无大文件上传 / 下载**:当前读 / 写为文本流,可后续加 `scp` / `sftp`。
136
- 5. **镜像目录对内置文件工具不可见**:远程工作区的本地镜像目录是注册占位,模型对远程文件应使用 `remote_ssh_*` 工具(镜像目录内已放置说明文件)。
139
+ 4. **同步为全量 tar**:`remote_ssh_sync` / `remote_ssh_push` `tar` 流式全量同步,单次同步大目录耗时与传输量较高,后续可换 `rsync -e ssh` 增量。
140
+ 5. **原生工具编辑的是本地镜像副本**,不会自动回传 —— 须点「推送」或调用 `remote_ssh_push` 同步回远端(与 `dsh-remote` 的 `rw_sync` / `rw_push` 模型一致)。
137
141
 
138
142
  ## 路线图
139
143
 
144
+ - [x] 从 `~/.ssh/config` 自动导入连接
145
+ - [x] ProxyJump / 跳板机、SSH config 复用
146
+ - [x] `tar`-over-ssh 双向同步(`scp` / `sftp` 增量同步为后续优化)
147
+ - [ ] `rsync -e ssh` 增量同步
140
148
  - [ ] 真 PTY 终端(`terminals.registerBackend` + `node-pty`)
141
- - [ ] `scp` / `sftp` 上传下载
142
- - [ ] 从 `~/.ssh/config` 自动导入连接
143
- - [ ] ProxyJump / 跳板机、SSH config 复用
144
149
  - [ ] 密码凭据走 `credentials` 服务(避免明文进 argv)
145
150
 
146
151
  ## 许可证
package/lib/client.js CHANGED
@@ -73,6 +73,7 @@ window.__ModuleLoader__.load({
73
73
  "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
74
74
  "settings.passwordField": "密码 password",
75
75
  "settings.remoteRoot": "远程根目录 remoteRoot",
76
+ "settings.proxyJump": "跳板机 ProxyJump",
76
77
  "settings.test": "测试连接",
77
78
  "settings.saved": "已保存",
78
79
  "settings.connOk": "连接成功",
@@ -96,6 +97,7 @@ window.__ModuleLoader__.load({
96
97
  "files.errRead": "读取失败",
97
98
  "files.errSave": "保存失败",
98
99
  "files.errSaveDefaultDir": "保存默认目录失败",
100
+ "files.binary": "二进制文件,无法预览",
99
101
  "term.title": "终端 {id}",
100
102
  "term.exited": " (已退出)",
101
103
  "term.connecting": "正在连接…",
@@ -114,7 +116,17 @@ window.__ModuleLoader__.load({
114
116
  "flow.remoteDir": "远程目录",
115
117
  "flow.chooseRemote": "选择远程目录…",
116
118
  "flow.backLocal": "← 选择本地目录",
117
- "flow.errCreate": "创建远程工作区失败"
119
+ "flow.errCreate": "创建远程工作区失败",
120
+ "settings.importSshConfig": "从 ~/.ssh/config 导入…",
121
+ "settings.importTitle": "导入 ~/.ssh/config 主机",
122
+ "settings.noneFound": "未在 ~/.ssh/config 中发现可用主机",
123
+ "settings.imported": "已导入 {n} 个连接",
124
+ "sync.down": "⬇ 同步",
125
+ "sync.up": "⬆ 推送",
126
+ "sync.syncing": "同步中…",
127
+ "sync.pushing": "推送中…",
128
+ "sync.done": "同步完成",
129
+ "sync.fail": "同步失败"
118
130
  };
119
131
  var en = {
120
132
  "tabs.files": "Remote Files",
@@ -137,6 +149,7 @@ window.__ModuleLoader__.load({
137
149
  "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
138
150
  "settings.passwordField": "Password",
139
151
  "settings.remoteRoot": "Remote root (remoteRoot)",
152
+ "settings.proxyJump": "ProxyJump (jump host)",
140
153
  "settings.test": "Test Connection",
141
154
  "settings.saved": "Saved",
142
155
  "settings.connOk": "Connected",
@@ -160,6 +173,7 @@ window.__ModuleLoader__.load({
160
173
  "files.errRead": "Failed to read",
161
174
  "files.errSave": "Failed to save",
162
175
  "files.errSaveDefaultDir": "Failed to save default directory",
176
+ "files.binary": "Binary file — preview not available",
163
177
  "term.title": "Terminal {id}",
164
178
  "term.exited": " (exited)",
165
179
  "term.connecting": "Connecting…",
@@ -178,7 +192,17 @@ window.__ModuleLoader__.load({
178
192
  "flow.remoteDir": "Remote directory",
179
193
  "flow.chooseRemote": "Choose remote directory…",
180
194
  "flow.backLocal": "← Choose local directory",
181
- "flow.errCreate": "Failed to create remote workspace"
195
+ "flow.errCreate": "Failed to create remote workspace",
196
+ "settings.importSshConfig": "Import from ~/.ssh/config…",
197
+ "settings.importTitle": "Import ~/.ssh/config hosts",
198
+ "settings.noneFound": "No usable hosts found in ~/.ssh/config",
199
+ "settings.imported": "Imported {n} connection(s)",
200
+ "sync.down": "⬇ Sync",
201
+ "sync.up": "⬆ Push",
202
+ "sync.syncing": "Syncing…",
203
+ "sync.pushing": "Pushing…",
204
+ "sync.done": "Sync complete",
205
+ "sync.fail": "Sync failed"
182
206
  };
183
207
  var i18n = {
184
208
  t: function (key) { return key; },
@@ -255,6 +279,7 @@ window.__ModuleLoader__.load({
255
279
  function FilesTab(props) {
256
280
  var t = useT();
257
281
  var sessionId = props.sessionId;
282
+ var betterSidebar = props.betterSidebar;
258
283
  var [workspaces, setWorkspaces] = React.useState([]);
259
284
  var profiles = useProfiles();
260
285
  var [wsId, setWsId] = useSessionMemory(sessionId, "wsId", sel.workspaceId);
@@ -263,8 +288,8 @@ window.__ModuleLoader__.load({
263
288
  var [entries, setEntries] = useSessionMemory(sessionId, "entries", []);
264
289
  var [err, setErr] = React.useState(null);
265
290
  var [loading, setLoading] = React.useState(false);
266
- var [openFile, setOpenFile] = useSessionMemory(sessionId, "openFile", null);
267
291
  var [defaultDir, setDefaultDir] = useSessionMemory(sessionId, "defaultDir", "");
292
+ var [syncSt, setSyncSt] = React.useState(null); // { busy, msg, ok }
268
293
 
269
294
  React.useEffect(function () {
270
295
  api("listWorkspaces").then(function (r) { if (r && r.ok) setWorkspaces(r.workspaces || []); });
@@ -310,17 +335,34 @@ window.__ModuleLoader__.load({
310
335
  });
311
336
  }
312
337
 
338
+ function doSync(direction) {
339
+ if (!wsId) return;
340
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
341
+ if (!ws) return;
342
+ setSyncSt({ busy: true, msg: direction === "down" ? t("sync.syncing") : t("sync.pushing"), ok: null });
343
+ var method = direction === "down" ? "syncDown" : "syncUp";
344
+ api(method, { workspaceId: wsId }).then(function (r) {
345
+ if (r && r.ok) setSyncSt({ busy: false, msg: direction === "down" ? t("sync.done") : t("sync.done"), ok: true });
346
+ else setSyncSt({ busy: false, msg: (r && r.error) || t("sync.fail"), ok: false });
347
+ });
348
+ }
349
+
313
350
  function openEntry(e) {
314
351
  var base = path ? (path.replace(/\/+$/, "") + "/" + e.name) : e.name;
315
352
  if (e.type === "directory") {
316
353
  setPath(base);
317
354
  load(profileId, base);
318
355
  } else {
319
- setOpenFile({ path: base, content: "", loading: true });
320
- api("readFile", { profileId: profileId, path: base }).then(function (r) {
321
- if (r && r.ok) setOpenFile({ path: base, content: r.content, binary: r.binary, dirty: false, loading: false });
322
- else setOpenFile({ path: base, content: "", error: (r && r.error) || t("files.errRead"), loading: false });
323
- });
356
+ // 用独立页签预览远程文件(与内置「文件」页签一致),而非在文件树下方预览。
357
+ if (betterSidebar && betterSidebar.openTab) {
358
+ betterSidebar.openTab({
359
+ type: "remssh:editor",
360
+ id: "remssh:editor:" + profileId + ":" + base,
361
+ title: e.name,
362
+ path: base,
363
+ meta: { profileId: profileId, remotePath: base }
364
+ }, { sessionId: sessionId });
365
+ }
324
366
  }
325
367
  }
326
368
 
@@ -333,14 +375,6 @@ window.__ModuleLoader__.load({
333
375
  load(profileId, up);
334
376
  }
335
377
 
336
- function saveFile() {
337
- if (!openFile) return;
338
- api("writeFile", { profileId: profileId, path: openFile.path, content: openFile.content }).then(function (r) {
339
- if (r && r.ok) setOpenFile(Object.assign({}, openFile, { dirty: false }));
340
- else setErr((r && r.error) || t("files.errSave"));
341
- });
342
- }
343
-
344
378
  return h("div", { style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0, overflow: "hidden" } },
345
379
  h("div", { style: { flex: "none" } },
346
380
  h("div", { className: "rssh-row" },
@@ -369,6 +403,11 @@ window.__ModuleLoader__.load({
369
403
  h("button", { className: "rssh-btn", onClick: function () { load(profileId, path); } }, t("common.open")),
370
404
  h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
371
405
  ),
406
+ wsId ? h("div", { className: "rssh-pathbar" },
407
+ h("button", { className: "rssh-btn", onClick: function () { doSync("down"); }, disabled: syncSt && syncSt.busy }, t("sync.down")),
408
+ h("button", { className: "rssh-btn", onClick: function () { doSync("up"); }, disabled: syncSt && syncSt.busy }, t("sync.up")),
409
+ syncSt ? h("span", { className: syncSt.ok === false ? "rssh-err" : (syncSt.ok ? "rssh-ok" : "rssh-muted"), style: { flex: 1, fontSize: 11 } }, syncSt.msg) : null
410
+ ) : null,
372
411
  loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
373
412
  err ? h("div", { className: "rssh-err", style: { whiteSpace: "pre-wrap" } }, err) : null
374
413
  ),
@@ -379,18 +418,56 @@ window.__ModuleLoader__.load({
379
418
  e.type === "file" ? h("span", { className: "rssh-tree-size" }, String(e.size)) : null
380
419
  );
381
420
  })
421
+ )
422
+ );
423
+ }
424
+
425
+ // ======================================================================
426
+ // 远程文件编辑器页签:点击文件树中的文件后单开一栏预览/编辑(与内置「文件」页签一致)
427
+ // ======================================================================
428
+ function RemoteEditor(props) {
429
+ var t = useT();
430
+ var tab = props.tab || {};
431
+ var ctx = props.ctx;
432
+ var meta = tab.meta || {};
433
+ var profileId = meta.profileId || "";
434
+ var remotePath = tab.path || meta.remotePath || "";
435
+ var [data, setData] = React.useState({ loading: true, content: "", binary: false, dirty: false, error: null });
436
+
437
+ React.useEffect(function () {
438
+ var alive = true;
439
+ setData({ loading: true, content: "", binary: false, dirty: false, error: null });
440
+ api("readFile", { profileId: profileId, path: remotePath }).then(function (r) {
441
+ if (!alive) return;
442
+ if (r && r.ok) setData({ loading: false, content: r.content || "", binary: !!r.binary, dirty: false, error: null });
443
+ else setData({ loading: false, content: "", binary: false, dirty: false, error: (r && r.error) || t("files.errRead") });
444
+ });
445
+ return function () { alive = false; };
446
+ }, [profileId, remotePath]);
447
+
448
+ function save() {
449
+ api("writeFile", { profileId: profileId, path: remotePath, content: data.content }).then(function (r) {
450
+ if (r && r.ok) setData(Object.assign({}, data, { dirty: false, error: null }));
451
+ else setData(Object.assign({}, data, { error: (r && r.error) || t("files.errSave") }));
452
+ });
453
+ }
454
+
455
+ function close() {
456
+ if (ctx && ctx.betterSidebar && tab.id) ctx.betterSidebar.closeTab(tab.id);
457
+ }
458
+
459
+ if (data.loading) return h("div", { className: "rssh-muted", style: { padding: 8 } }, t("files.reading"));
460
+ if (data.error) return h("div", { className: "rssh-err", style: { padding: 8, whiteSpace: "pre-wrap" } }, data.error);
461
+ if (data.binary) return h("div", { className: "rssh-muted", style: { padding: 8 } }, t("files.binary"));
462
+
463
+ return h("div", { style: { display: "flex", flexDirection: "column", flex: 1, minHeight: 0, padding: 8, overflow: "hidden" } },
464
+ h("div", { className: "rssh-pathbar", style: { flex: "none" } },
465
+ h("span", { className: "rssh-title", style: { flex: 1, wordBreak: "break-all" } }, remotePath),
466
+ h("button", { className: "rssh-btn", onClick: save }, t("common.save")),
467
+ h("button", { className: "rssh-btn", onClick: close }, t("common.close"))
382
468
  ),
383
- openFile ? h("div", { style: { flex: "none" } },
384
- h("div", { className: "rssh-pathbar", style: { marginTop: 8 } },
385
- h("span", { className: "rssh-title", style: { flex: 1, wordBreak: "break-all" } }, openFile.path),
386
- h("button", { className: "rssh-btn", onClick: saveFile }, t("common.save")),
387
- h("button", { className: "rssh-btn", onClick: function () { setOpenFile(null); } }, t("common.close"))
388
- ),
389
- openFile.loading ? h("div", { className: "rssh-muted" }, t("files.reading")) :
390
- openFile.error ? h("div", { className: "rssh-err" }, openFile.error) :
391
- h("textarea", { className: "rssh-content", style: { width: "100%", boxSizing: "border-box", minHeight: 200, background: "var(--dsw-alias-markdown-code-block)", color: "var(--dsw-alias-label-primary)", border: "1px solid var(--dsw-alias-border-l2)" },
392
- value: openFile.content, onChange: function (e) { setOpenFile(Object.assign({}, openFile, { content: e.target.value, dirty: true })); } })
393
- ) : null
469
+ h("textarea", { className: "rssh-content", style: { flex: 1, minHeight: 0, maxHeight: "none", width: "100%", boxSizing: "border-box", background: "var(--dsw-alias-markdown-code-block)", color: "var(--dsw-alias-label-primary)", border: "1px solid var(--dsw-alias-border-l2)" },
470
+ value: data.content, onChange: function (e) { setData(Object.assign({}, data, { content: e.target.value, dirty: true })); } })
394
471
  );
395
472
  }
396
473
 
@@ -629,9 +706,10 @@ window.__ModuleLoader__.load({
629
706
  function SettingsSection(props) {
630
707
  var t = useT();
631
708
  var [profiles, setProfiles] = React.useState([]);
632
- var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
709
+ var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
633
710
  var [result, setResult] = React.useState(null);
634
711
  var [busy, setBusy] = React.useState(false);
712
+ var [importDlg, setImportDlg] = React.useState(null);
635
713
  var set = function (k, v) { setForm(function (f) { var n = {}; for (var key in f) n[key] = f[key]; n[k] = v; return n; }); };
636
714
 
637
715
  function reload() { api("listProfiles").then(function (r) { if (r && r.ok) setProfiles(r.profiles || []); }); }
@@ -643,7 +721,7 @@ window.__ModuleLoader__.load({
643
721
  setBusy(false);
644
722
  if (r && r.ok) {
645
723
  setProfiles(r.profiles || []);
646
- setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
724
+ setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
647
725
  setResult({ ok: true, msg: t("settings.saved") });
648
726
  } else setResult({ ok: false, msg: (r && r.error) || t("settings.errSave") });
649
727
  });
@@ -660,6 +738,47 @@ window.__ModuleLoader__.load({
660
738
  api("deleteProfile", { id: p.id }).then(function (r) { if (r && r.ok) { setProfiles(r.profiles || []); } });
661
739
  }
662
740
 
741
+ function openImport() {
742
+ setImportDlg({ loading: true, hosts: [], selected: {} });
743
+ api("listSshConfigHosts").then(function (r) {
744
+ if (r && r.ok) {
745
+ var hosts = r.hosts || [];
746
+ var sel = {};
747
+ hosts.forEach(function (h) { sel[h.name + "|" + h.hostName] = true; });
748
+ setImportDlg({ loading: false, hosts: hosts, selected: sel });
749
+ } else {
750
+ setImportDlg({ loading: false, hosts: [], selected: {}, error: (r && r.error) || t("settings.noneFound") });
751
+ }
752
+ });
753
+ }
754
+ function toggleImport(key) {
755
+ setImportDlg(function (d) {
756
+ var sel = {}; for (var k in d.selected) sel[k] = d.selected[k];
757
+ sel[key] = !sel[key];
758
+ return Object.assign({}, d, { selected: sel });
759
+ });
760
+ }
761
+ function doImport() {
762
+ if (!importDlg) return;
763
+ var picked = importDlg.hosts.filter(function (h) { return importDlg.selected[h.name + "|" + h.hostName]; });
764
+ setBusy(true);
765
+ var i = 0;
766
+ function next() {
767
+ if (i >= picked.length) {
768
+ setBusy(false); setImportDlg(null);
769
+ setResult({ ok: true, msg: t("settings.imported").replace("{n}", String(picked.length)) });
770
+ reload();
771
+ return;
772
+ }
773
+ var h = picked[i++];
774
+ api("saveProfile", {
775
+ name: h.name, host: h.hostName, port: h.port || 22, user: h.user || "",
776
+ authMethod: "key", keyPath: h.identityFile || "", password: "", remoteRoot: "~", proxyJump: h.proxyJump || ""
777
+ }).then(next);
778
+ }
779
+ next();
780
+ }
781
+
663
782
  return h("div", null,
664
783
  h("p", { className: "rssh-muted" }, t("settings.desc")),
665
784
  profiles.map(function (p) {
@@ -689,9 +808,33 @@ window.__ModuleLoader__.load({
689
808
  form.authMethod === "key" ? Field({ label: t("settings.keyPath"), value: form.keyPath, onChange: function (v) { set("keyPath", v); }, placeholder: t("settings.keyPathPlaceholder") }) : null,
690
809
  form.authMethod === "password" ? Field({ label: t("settings.passwordField"), type: "password", value: form.password, onChange: function (v) { set("password", v); } }) : null,
691
810
  Field({ label: t("settings.remoteRoot"), value: form.remoteRoot, onChange: function (v) { set("remoteRoot", v); }, placeholder: "~/project" }),
692
- h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save"))
811
+ Field({ label: t("settings.proxyJump"), value: form.proxyJump, onChange: function (v) { set("proxyJump", v); }, placeholder: "bastion.example.com" }),
812
+ h("div", { className: "rssh-actions" },
813
+ h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save")),
814
+ h("button", { className: "rssh-btn", onClick: openImport, disabled: busy }, t("settings.importSshConfig"))
815
+ )
693
816
  ),
694
- result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null
817
+ result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null,
818
+ importDlg ? h(Modal, { open: true, onClose: function () { if (!busy) setImportDlg(null); }, title: t("settings.importTitle"), closeLabel: t("common.close") },
819
+ importDlg.loading ? h("div", { className: "rssh-muted" }, t("common.loading")) :
820
+ importDlg.error ? h("div", { className: "rssh-err" }, importDlg.error) :
821
+ (importDlg.hosts.length === 0 ? h("div", { className: "rssh-muted" }, t("settings.noneFound")) :
822
+ h("div", null,
823
+ importDlg.hosts.map(function (hh) {
824
+ var key = hh.name + "|" + hh.hostName;
825
+ return h("label", { key: key, className: "rssh-tree-item", style: { cursor: "pointer" } },
826
+ h("input", { type: "checkbox", checked: !!importDlg.selected[key], onChange: function () { toggleImport(key); } }),
827
+ h("span", { className: "rssh-tree-dir" }, hh.name),
828
+ h("span", { className: "rssh-sub" }, (hh.user || "?") + "@" + hh.hostName + ":" + (hh.port || 22) + (hh.proxyJump ? " → " + hh.proxyJump : ""))
829
+ );
830
+ }),
831
+ h("div", { className: "rssh-actions", style: { marginTop: 8 } },
832
+ h("button", { className: "rssh-btn", onClick: doImport, disabled: busy }, busy ? t("common.processing") : t("common.save")),
833
+ h("button", { className: "rssh-btn", onClick: function () { setImportDlg(null); }, disabled: busy }, t("common.close"))
834
+ )
835
+ )
836
+ )
837
+ ) : null
695
838
  );
696
839
  }
697
840
 
@@ -728,7 +871,20 @@ window.__ModuleLoader__.load({
728
871
  icon: function () { return h("span", null, "🗂️"); },
729
872
  component: function (props) {
730
873
  var sid = props && props.scope ? props.scope.sessionId : null;
731
- return h(FilesTab, { key: "files:" + (sid || "global"), sessionId: sid });
874
+ return h(FilesTab, { key: "files:" + (sid || "global"), sessionId: sid, betterSidebar: props && props.ctx ? props.ctx.betterSidebar : null });
875
+ }
876
+ });
877
+ });
878
+ // 远程文件编辑器页签(隐藏页签,仅由文件树点击打开,每个远程文件单开一栏)。
879
+ ctx.effect(function () {
880
+ return betterSidebar.registerTab({
881
+ id: "remssh:editor",
882
+ title: function () { return i18n.t("tabs.files"); },
883
+ hidden: true,
884
+ dedupeKey: function (tab) { return (tab && tab.meta && tab.meta.profileId || "") + "\u0000" + (tab && tab.path || ""); },
885
+ icon: function () { return h("span", null, "📄"); },
886
+ component: function (props) {
887
+ return h(RemoteEditor, { tab: props && props.tab, ctx: props && props.ctx, scope: props && props.scope });
732
888
  }
733
889
  });
734
890
  });
package/lib/index.js CHANGED
@@ -11,7 +11,7 @@
11
11
  import z from "schemastery";
12
12
  import { defineTool } from "@deepseek-ai/dsh-tools";
13
13
  import { settingsNamespace } from "@deepseek-ai/dsh-settings";
14
- import { mkdir, rm, writeFile } from "node:fs/promises";
14
+ import { mkdir, rm, writeFile, readFile } from "node:fs/promises";
15
15
  import { homedir } from "node:os";
16
16
  import { join } from "node:path";
17
17
 
@@ -36,7 +36,8 @@ const ProfileSchema = z.object({
36
36
  authMethod: z.string().default("key"),
37
37
  keyPath: z.string().default(""),
38
38
  password: z.string().default("").role("secret"),
39
- remoteRoot: z.string().default("~")
39
+ remoteRoot: z.string().default("~"),
40
+ proxyJump: z.string().default("")
40
41
  });
41
42
  /** 远程工作区 schema(连接配置 + 远程根目录 + 本地镜像目录)。 */
42
43
  const WorkspaceSchema = z.object({
@@ -68,6 +69,92 @@ function shellQuotePath(s) {
68
69
  return shellQuote(str);
69
70
  }
70
71
 
72
+ /**
73
+ * 解析 OpenSSH 用户配置(~/.ssh/config),递归处理 Include,发现其中的具体主机。
74
+ * 借鉴 Yan-Zero dsh-remote-ssh:自动读取 OpenSSH 配置发现主机,免去手动录入。
75
+ * 返回 [{ name, hostName, user, port, identityFile, proxyJump }]。
76
+ */
77
+ function expandSshPath(raw) {
78
+ const s = String(raw || "").trim();
79
+ if (s === "~") return join(homedir());
80
+ if (s.startsWith("~/")) return join(homedir(), s.slice(2));
81
+ return s;
82
+ }
83
+
84
+ async function parseSshConfigFile(filePath, seen) {
85
+ const real = filePath;
86
+ if (seen.has(real)) return [];
87
+ seen.add(real);
88
+ let text;
89
+ try {
90
+ text = await readFile(filePath, "utf8");
91
+ } catch (e) {
92
+ return [];
93
+ }
94
+ const hosts = [];
95
+ let cur = null;
96
+ const lines = text.split(/\r?\n/);
97
+ for (let i = 0; i < lines.length; i++) {
98
+ const raw = lines[i];
99
+ const hash = raw.indexOf("#");
100
+ const line = (hash >= 0 ? raw.slice(0, hash) : raw).trim();
101
+ if (!line) continue;
102
+ const sp = line.search(/\s+/);
103
+ if (sp < 0) continue;
104
+ const key = line.slice(0, sp).toLowerCase();
105
+ const val = line.slice(sp).trim();
106
+ if (key === "host") {
107
+ // 多个名字空格分隔;含通配符 * / ? 的跳过
108
+ const names = val.split(/\s+/);
109
+ cur = null;
110
+ for (const n of names) {
111
+ if (n && !/[*?]/.test(n)) {
112
+ cur = { name: n, hostName: "", user: "", port: 22, identityFile: "", proxyJump: "" };
113
+ hosts.push(cur);
114
+ break;
115
+ }
116
+ }
117
+ } else if (cur) {
118
+ if (key === "hostname") cur.hostName = val;
119
+ else if (key === "user") cur.user = val;
120
+ else if (key === "port") { const pn = parseInt(val, 10); if (!isNaN(pn)) cur.port = pn; }
121
+ else if (key === "identityfile") cur.identityFile = expandSshPath(val);
122
+ else if (key === "proxyjump") cur.proxyJump = val;
123
+ else if (key === "include") {
124
+ // 递归 Include(相对 ~/.ssh/ 或绝对)
125
+ for (const inc of val.split(/\s+/)) {
126
+ if (!inc) continue;
127
+ const incPath = expandSshPath(inc);
128
+ const sub = await parseSshConfigFile(incPath, seen);
129
+ for (const h of sub) hosts.push(h);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return hosts;
135
+ }
136
+
137
+ async function listSshConfigHosts() {
138
+ const cfg = join(homedir(), ".ssh", "config");
139
+ const seen = new Set();
140
+ let hosts = [];
141
+ try {
142
+ hosts = await parseSshConfigFile(cfg, seen);
143
+ } catch (e) {
144
+ return { ok: false, error: "读取 ~/.ssh/config 失败: " + String(e && e.message ? e.message : e) };
145
+ }
146
+ // 过滤掉没有 hostName 的占位条目;保留 user 默认
147
+ const out = hosts.filter((h) => h.hostName).map((h) => ({
148
+ name: h.name,
149
+ hostName: h.hostName,
150
+ user: h.user || "",
151
+ port: h.port || 22,
152
+ identityFile: h.identityFile || "",
153
+ proxyJump: h.proxyJump || ""
154
+ }));
155
+ return { ok: true, hosts: out };
156
+ }
157
+
71
158
  function sshArgv(p, remoteCmd, tty) {
72
159
  const opts = ["ssh"];
73
160
  if (tty) opts.push("-tt");
@@ -76,6 +163,7 @@ function sshArgv(p, remoteCmd, tty) {
76
163
  opts.push("-o", "ConnectTimeout=15");
77
164
  opts.push("-o", "ServerAliveInterval=30");
78
165
  opts.push("-o", "ServerAliveCountMax=3");
166
+ if (p.proxyJump) opts.push("-o", "ProxyJump=" + String(p.proxyJump));
79
167
  if (p.authMethod === "key" && p.keyPath) opts.push("-i", p.keyPath);
80
168
  const target = String(p.user || "") + "@" + String(p.host || "");
81
169
  const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
@@ -171,6 +259,96 @@ async function remoteWriteFile(subprocess, p, path, content) {
171
259
  return await runRemote(subprocess, p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
172
260
  }
173
261
 
262
+ /**
263
+ * tar-over-ssh 双向同步(借鉴 flymysql dsh-remote 的 SFTP 镜像同步,但复用现有 ssh.exe + tar,
264
+ * 不引入 ssh2 依赖)。用流式管道把一条 ssh 的 stdout 喂给本地 tar 的 stdin(反之亦然),
265
+ * 不经过 4MB Buffer 上限。
266
+ */
267
+ function spawnOne(subprocess, argv, stdio, cwd) {
268
+ return subprocess.spawn({ argv: argv, cwd: cwd || process.cwd(), stdio: stdio, graceMs: 60000 });
269
+ }
270
+
271
+ function readCollected(handle, name) {
272
+ if (!handle || !handle.collected || !handle.collected[name]) return "";
273
+ const r = handle.collected[name].readFrom(0);
274
+ return r ? r.text : "";
275
+ }
276
+
277
+ /** 远端 → 本地镜像:先清空 mirror,再 tar 展开到 mirror。 */
278
+ async function remoteSyncDown(subprocess, p, remotePath, mirrorPath) {
279
+ if (!remotePath) return { ok: false, error: "remotePath 为必填项" };
280
+ if (!mirrorPath) return { ok: false, error: "mirrorPath 为必填项" };
281
+ try { await rm(mirrorPath, { recursive: true, force: true }); } catch (e) {}
282
+ try { await mkdir(mirrorPath, { recursive: true }); } catch (e) {
283
+ return { ok: false, error: "创建镜像目录失败: " + String(e && e.message ? e.message : e) };
284
+ }
285
+ const COLLECT = { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } };
286
+ let sshH, tarH;
287
+ try {
288
+ sshH = spawnOne(subprocess, sshArgv(p, "tar cf - -C " + shellQuotePath(remotePath) + " ."), {
289
+ stdin: "ignore", stdout: "pipe", stderr: COLLECT
290
+ });
291
+ tarH = spawnOne(subprocess, ["tar", "xf", "-", "-C", mirrorPath], {
292
+ stdin: "pipe", stdout: "ignore", stderr: COLLECT
293
+ });
294
+ } catch (e) {
295
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
296
+ }
297
+ let pipeErr = "";
298
+ try {
299
+ if (sshH.stdout && tarH.stdin) sshH.stdout.pipe(tarH.stdin);
300
+ else pipeErr = "stdout/stdin 管道不可用(subprocess 未暴露原始流)";
301
+ } catch (e) { pipeErr = String(e && e.message ? e.message : e); }
302
+ let sshOut, tarOut;
303
+ try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
304
+ try { tarOut = await tarH.done; } catch (e) { tarOut = { exitCode: -1 }; }
305
+ const sshErr = readCollected(sshH, "stderr");
306
+ const tarErr = readCollected(tarH, "stderr");
307
+ // ssh 正常退出且 tar 正常退出才算成功
308
+ if (sshOut.exitCode === 0 && tarOut.exitCode === 0) return { ok: true, mirrorPath: mirrorPath };
309
+ const parts = [];
310
+ if (pipeErr) parts.push("管道: " + pipeErr);
311
+ if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
312
+ if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
313
+ if (!parts.length) parts.push("远端可能缺少 tar,或本机无 tar.exe");
314
+ return { ok: false, error: "同步失败: " + parts.join(";") };
315
+ }
316
+
317
+ /** 本地镜像 → 远端:本地 tar 打包喂给远端 tar 展开。 */
318
+ async function remoteSyncUp(subprocess, p, remotePath, mirrorPath) {
319
+ if (!remotePath) return { ok: false, error: "remotePath 为必填项" };
320
+ if (!mirrorPath) return { ok: false, error: "mirrorPath 为必填项" };
321
+ const COLLECT = { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } };
322
+ let tarH, sshH;
323
+ try {
324
+ tarH = spawnOne(subprocess, ["tar", "cf", "-", "-C", mirrorPath, "."], {
325
+ stdin: "ignore", stdout: "pipe", stderr: COLLECT
326
+ });
327
+ sshH = spawnOne(subprocess, sshArgv(p, "tar xf - -C " + shellQuotePath(remotePath)), {
328
+ stdin: "pipe", stdout: "ignore", stderr: COLLECT
329
+ });
330
+ } catch (e) {
331
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
332
+ }
333
+ let pipeErr = "";
334
+ try {
335
+ if (tarH.stdout && sshH.stdin) tarH.stdout.pipe(sshH.stdin);
336
+ else pipeErr = "stdout/stdin 管道不可用(subprocess 未暴露原始流)";
337
+ } catch (e) { pipeErr = String(e && e.message ? e.message : e); }
338
+ let tarOut, sshOut;
339
+ try { tarOut = await tarH.done; } catch (e) { tarOut = { exitCode: -1 }; }
340
+ try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
341
+ const tarErr = readCollected(tarH, "stderr");
342
+ const sshErr = readCollected(sshH, "stderr");
343
+ if (tarOut.exitCode === 0 && sshOut.exitCode === 0) return { ok: true, remotePath: remotePath };
344
+ const parts = [];
345
+ if (pipeErr) parts.push("管道: " + pipeErr);
346
+ if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
347
+ if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
348
+ if (!parts.length) parts.push("远端可能缺少 tar,或本机无 tar.exe");
349
+ return { ok: false, error: "推送失败: " + parts.join(";") };
350
+ }
351
+
174
352
  function normExec(r) {
175
353
  return {
176
354
  ok: !!r.ok,
@@ -346,6 +524,16 @@ function apply(ctx, config) {
346
524
  return { profile: null, remotePath: undefined };
347
525
  }
348
526
 
527
+ /** 解析同步工具的目标工作区:显式 workspaceId 优先,其次当前会话的远程工作区。 */
528
+ function resolveWorkspace(args, exec) {
529
+ const a = args || {};
530
+ const workspaces = settingsFace.read().workspaces;
531
+ if (a.workspaceId) {
532
+ return workspaces.find((w) => w.id === a.workspaceId) || null;
533
+ }
534
+ return remoteContextFor(exec);
535
+ }
536
+
349
537
  /** 相对路径拼到远程工作目录下,绝对路径(/、~、盘符)原样返回。 */
350
538
  function resolveRemotePath(path, base) {
351
539
  if (!base) return path || "";
@@ -357,6 +545,8 @@ function apply(ctx, config) {
357
545
 
358
546
  const api = {
359
547
  listProfiles: async () => ({ ok: true, profiles: settingsFace.read().profiles }),
548
+ /** 读取本机 ~/.ssh/config 发现的主机(借鉴 Yan-Zero dsh-remote-ssh)。 */
549
+ listSshConfigHosts: async () => listSshConfigHosts(),
360
550
  saveProfile: async (args) => {
361
551
  const p = args || {};
362
552
  if (!p.host || !p.user) return { ok: false, error: "host 和 user 为必填项" };
@@ -372,7 +562,8 @@ function apply(ctx, config) {
372
562
  authMethod: p.authMethod || "key",
373
563
  keyPath: p.keyPath || "",
374
564
  password: p.password || "",
375
- remoteRoot: p.remoteRoot || "~"
565
+ remoteRoot: p.remoteRoot || "~",
566
+ proxyJump: p.proxyJump || ""
376
567
  };
377
568
  if (existing) {
378
569
  const i = profiles.indexOf(existing);
@@ -485,6 +676,24 @@ function apply(ctx, config) {
485
676
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
486
677
  return await remoteWriteFile(subprocess, p, args && args.path, args && args.content);
487
678
  },
679
+ /** 远端 → 本地镜像(按工作区)。 */
680
+ syncDown: async (args) => {
681
+ const a = args || {};
682
+ const ws = settingsFace.read().workspaces.find((w) => w.id === (a.workspaceId || a.id));
683
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
684
+ const p = getProfile(ws.profileId);
685
+ if (!p) return { ok: false, error: "未找到连接配置" };
686
+ return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
687
+ },
688
+ /** 本地镜像 → 远端(按工作区)。 */
689
+ syncUp: async (args) => {
690
+ const a = args || {};
691
+ const ws = settingsFace.read().workspaces.find((w) => w.id === (a.workspaceId || a.id));
692
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
693
+ const p = getProfile(ws.profileId);
694
+ if (!p) return { ok: false, error: "未找到连接配置" };
695
+ return await remoteSyncUp(subprocess, p, ws.remotePath, ws.mirrorPath);
696
+ },
488
697
  spawnTerminal: async (args) => {
489
698
  const p = resolveProfile(args);
490
699
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
@@ -681,6 +890,48 @@ function apply(ctx, config) {
681
890
  }
682
891
  });
683
892
 
893
+ // 同步:把远程工作区的文件同步到本地镜像目录(syncDown),或把本地镜像改动推回远端(syncUp)。
894
+ // 借鉴 flymysql dsh-remote 的 rw_sync / rw_push,但用 tar-over-ssh 流式管道,不引入 ssh2。
895
+ const syncDesc = "按远程工作区双向同步:sync 把远端文件同步到本地镜像目录,push 把本地镜像改动推回远端。" +
896
+ "用 workspaceId 指定工作区,或留空在当前远程工作区会话中自动识别。" +
897
+ "Sync/push a remote workspace to its local mirror (or back). Pass workspaceId, or omit it inside a remote-workspace session.";
898
+ const syncParams = {
899
+ workspaceId: { type: "string", description: "远程工作区 id(留空则在当前远程工作区会话中自动识别)。" }
900
+ };
901
+ const syncOutput = {
902
+ schema: { type: "object", additionalProperties: false, properties: {
903
+ ok: { type: "boolean", required: true },
904
+ error: { type: "string", required: true }
905
+ } },
906
+ render: textRender(function (a, v) { return v.ok ? "同步完成" : (v.error || "同步失败"); })
907
+ };
908
+ register({
909
+ name: "remote_ssh_sync",
910
+ description: syncDesc,
911
+ parameters: syncParams,
912
+ output: syncOutput,
913
+ execute: async function (args, exec) {
914
+ const ws = resolveWorkspace(args, exec);
915
+ if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
916
+ const p = getProfile(ws.profileId);
917
+ if (!p) return { ok: false, error: "未找到连接配置" };
918
+ return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
919
+ }
920
+ });
921
+ register({
922
+ name: "remote_ssh_push",
923
+ description: syncDesc,
924
+ parameters: syncParams,
925
+ output: syncOutput,
926
+ execute: async function (args, exec) {
927
+ const ws = resolveWorkspace(args, exec);
928
+ if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
929
+ const p = getProfile(ws.profileId);
930
+ if (!p) return { ok: false, error: "未找到连接配置" };
931
+ return await remoteSyncUp(subprocess, p, ws.remotePath, ws.mirrorPath);
932
+ }
933
+ });
934
+
684
935
  // ---- 清理 ----
685
936
  ctx.effect(() => () => {
686
937
  terminals.forEach(function (s) { try { s.handle.terminate(); } catch (e) {} });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "1.3.1",
3
+ "version": "1.5.0",
4
4
  "description": "DSH web plugin: VSCode Remote-SSH-like remote development (SSH to supercomputers/servers, remote workspace, file explorer, integrated terminal), integrated with dsh-better-sidebar and DSH settings.",
5
5
  "keywords": [
6
6
  "dsh",