@zhangfengshun/dsh-remote-ssh 1.4.0 → 1.6.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,25 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [1.6.0] — 远程搜索与文件操作工具
6
+ ### 新增
7
+ - **内容搜索 `remote_ssh_grep`**:在远端递归搜索文件内容(`grep -rnIE`,扩展正则),支持 `include` 文件名过滤(如 `*.py`)、`ignoreCase` 忽略大小写、`maxResults` 限流(默认 200)。借鉴 dsh-remote / dsh-remote-ssh 的远程搜索,但用通用 GNU grep(超算/Linux 通用,不依赖 ripgrep)。
8
+ - **文件名查找 `remote_ssh_glob`**:按通配符查找远程文件(`find -name`,递归),自动剥离前导 `**/` 以适配 POSIX find。
9
+ - **创建目录 `remote_ssh_mkdir`**:`mkdir -p`(含父目录)。
10
+ - **删除 `remote_ssh_delete`**:删除远程文件或目录(`rm -rf`,递归不询问,⚠️ 不可恢复)。
11
+ - **移动/重命名 `remote_ssh_move`**:`mv` 移动或重命名文件/目录。
12
+ - 上述 5 个工具均会话感知:在远程工作区会话中免填 `profileId`,相对路径基于工作区远程目录解析。
13
+ - 「远程文件」页签新增操作栏(选中工作区或连接时显示):🔍 搜索 / 按名查找 / 新建目录 / 重命名移动 / 删除,均以弹窗形式执行并就地显示结果。
14
+ - 远程工作区镜像目录的 `README.md` 补全新增工具说明。
15
+
16
+ ## [1.5.0] — 导入 OpenSSH 配置 + 双向同步
17
+ ### 新增
18
+ - **从 `~/.ssh/config` 导入连接**:设置小节「从 ~/.ssh/config 导入…」按钮读取本机 OpenSSH 用户配置(递归解析 `Include`),多选主机后批量导入为连接配置(`HostName` / `User` / `Port` / `IdentityFile` / `ProxyJump` 一并带入)。借鉴 Yan-Zero `dsh-remote-ssh`,免去逐条手动录入。
19
+ - **跳板机(ProxyJump)**:连接配置新增可选 `ProxyJump` 字段,`sshArgv` 追加 `-o ProxyJump=...`,OpenSSH 原生支持;导入的带跳板机主机开箱即用。
20
+ - **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` 依赖。
21
+ - 「远程文件」页签在选中工作区时显示「⬇ 同步 / ⬆ 推送」按钮,同步后镜像目录装下真实远程文件,内置 `read`/`grep`/`glob` 工具可直接读取镜像。
22
+ - `remote_ssh_sync` / `remote_ssh_push` 工具会话感知:在远程工作区会话中无需 `workspaceId`,自动识别当前工作区。
23
+
5
24
  ## [1.4.0] — 远程文件单开一栏预览
6
25
  ### 新增
7
26
  - 点击「远程文件」文件树中的文件,改为在 better-sidebar 中**单开一栏(独立页签)预览/编辑**,与内置「文件」页签一致,不再在文件树下方内嵌预览。新增隐藏页签类型 `remssh:editor`,同一远程文件按 (profileId, path) 去重复用。
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
+ | 🤖 模型工具 | 12 个 `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.6.0
31
33
  ```
32
34
 
33
35
  > ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
@@ -86,6 +88,13 @@ 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 流式管道) |
93
+ | `remote_ssh_grep` | 递归搜索远程文件内容(grep -rnIE,支持 include/ignoreCase) |
94
+ | `remote_ssh_glob` | 按通配符查找远程文件(find -name,递归) |
95
+ | `remote_ssh_mkdir` | 创建远程目录(mkdir -p) |
96
+ | `remote_ssh_delete` | 删除远程文件或目录(rm -rf,⚠️ 不可恢复) |
97
+ | `remote_ssh_move` | 移动/重命名远程文件或目录(mv) |
89
98
 
90
99
  **会话感知**:当会话是从远程工作区创建时,模型调用这些工具可**不填** `profileId` / `host` / `user` 等连接参数,自动复用该工作区的连接,相对路径基于该工作区远程目录解析。
91
100
 
@@ -112,7 +121,7 @@ dsh-remote-ssh/
112
121
  │ · 远程文件 / 远程终端 │ ◀────── │ · SSH:listDir/readFile/… │
113
122
  │ ctx.slots: settings.section │ JSON │ · settings:profiles/workspaces │
114
123
  │ · DSH 设置页「远程连接」 │ │ · terminals:ssh -tt 管道 │
115
- └───────────────────────────────┘ │ ctx.tools:5 个 remote_ssh_*
124
+ └───────────────────────────────┘ │ ctx.tools:12 个 remote_ssh_*
116
125
  └───────────────┬──────────────────┘
117
126
  │ SSH
118
127
  远程超算 / 服务器
@@ -132,15 +141,16 @@ dsh-remote-ssh/
132
141
  1. **终端非本地 PTY**:走 `ssh -tt` 管道通道(跨平台可用);`vim` / `htop` 等全屏程序体验受限,后续可接入 `terminals.registerBackend` + `node-pty` 真 PTY。
133
142
  2. **文件操作依赖 GNU 工具**(`find -printf`、`base64 -w0`):目标为 Linux 超算时通用。
134
143
  3. **密码认证需本机 `sshpass`**(Windows 默认没有);密钥认证无此依赖。
135
- 4. **无大文件上传 / 下载**:当前读 / 写为文本流,可后续加 `scp` / `sftp`。
136
- 5. **镜像目录对内置文件工具不可见**:远程工作区的本地镜像目录是注册占位,模型对远程文件应使用 `remote_ssh_*` 工具(镜像目录内已放置说明文件)。
144
+ 4. **同步为全量 tar**:`remote_ssh_sync` / `remote_ssh_push` `tar` 流式全量同步,单次同步大目录耗时与传输量较高,后续可换 `rsync -e ssh` 增量。
145
+ 5. **原生工具编辑的是本地镜像副本**,不会自动回传 —— 须点「推送」或调用 `remote_ssh_push` 同步回远端(与 `dsh-remote` 的 `rw_sync` / `rw_push` 模型一致)。
137
146
 
138
147
  ## 路线图
139
148
 
149
+ - [x] 从 `~/.ssh/config` 自动导入连接
150
+ - [x] ProxyJump / 跳板机、SSH config 复用
151
+ - [x] `tar`-over-ssh 双向同步(`scp` / `sftp` 增量同步为后续优化)
152
+ - [ ] `rsync -e ssh` 增量同步
140
153
  - [ ] 真 PTY 终端(`terminals.registerBackend` + `node-pty`)
141
- - [ ] `scp` / `sftp` 上传下载
142
- - [ ] 从 `~/.ssh/config` 自动导入连接
143
- - [ ] ProxyJump / 跳板机、SSH config 复用
144
154
  - [ ] 密码凭据走 `credentials` 服务(避免明文进 argv)
145
155
 
146
156
  ## 许可证
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": "连接成功",
@@ -115,7 +116,35 @@ window.__ModuleLoader__.load({
115
116
  "flow.remoteDir": "远程目录",
116
117
  "flow.chooseRemote": "选择远程目录…",
117
118
  "flow.backLocal": "← 选择本地目录",
118
- "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": "同步失败",
130
+ "ops.search": "🔍 搜索",
131
+ "ops.find": "按名查找",
132
+ "ops.searchPh": "正则或关键词…",
133
+ "ops.globPh": "*.py",
134
+ "ops.newDir": "📁 新建目录",
135
+ "ops.newDirPh": "目录名",
136
+ "ops.rename": "✏ 重命名/移动",
137
+ "ops.renamePh": "目标路径",
138
+ "ops.delete": "🗑 删除",
139
+ "ops.confirmDel": "确认删除远程文件/目录?(不可恢复)",
140
+ "ops.grepTitle": "远程内容搜索 (grep)",
141
+ "ops.globTitle": "按文件名查找 (glob)",
142
+ "ops.mkdirTitle": "新建远程目录",
143
+ "ops.moveTitle": "移动/重命名",
144
+ "ops.deleteTitle": "删除远程文件/目录",
145
+ "ops.noMatch": "无匹配",
146
+ "ops.done": "操作完成",
147
+ "ops.fail": "操作失败"
119
148
  };
120
149
  var en = {
121
150
  "tabs.files": "Remote Files",
@@ -138,6 +167,7 @@ window.__ModuleLoader__.load({
138
167
  "settings.keyPathPlaceholder": "~/.ssh/id_rsa",
139
168
  "settings.passwordField": "Password",
140
169
  "settings.remoteRoot": "Remote root (remoteRoot)",
170
+ "settings.proxyJump": "ProxyJump (jump host)",
141
171
  "settings.test": "Test Connection",
142
172
  "settings.saved": "Saved",
143
173
  "settings.connOk": "Connected",
@@ -180,7 +210,35 @@ window.__ModuleLoader__.load({
180
210
  "flow.remoteDir": "Remote directory",
181
211
  "flow.chooseRemote": "Choose remote directory…",
182
212
  "flow.backLocal": "← Choose local directory",
183
- "flow.errCreate": "Failed to create remote workspace"
213
+ "flow.errCreate": "Failed to create remote workspace",
214
+ "settings.importSshConfig": "Import from ~/.ssh/config…",
215
+ "settings.importTitle": "Import ~/.ssh/config hosts",
216
+ "settings.noneFound": "No usable hosts found in ~/.ssh/config",
217
+ "settings.imported": "Imported {n} connection(s)",
218
+ "sync.down": "⬇ Sync",
219
+ "sync.up": "⬆ Push",
220
+ "sync.syncing": "Syncing…",
221
+ "sync.pushing": "Pushing…",
222
+ "sync.done": "Sync complete",
223
+ "sync.fail": "Sync failed",
224
+ "ops.search": "🔍 Search",
225
+ "ops.find": "Find by name",
226
+ "ops.searchPh": "regex or keyword…",
227
+ "ops.globPh": "*.py",
228
+ "ops.newDir": "📁 New dir",
229
+ "ops.newDirPh": "dir name",
230
+ "ops.rename": "✏ Rename/Move",
231
+ "ops.renamePh": "target path",
232
+ "ops.delete": "🗑 Delete",
233
+ "ops.confirmDel": "Confirm delete remote file/dir? (irreversible)",
234
+ "ops.grepTitle": "Remote content search (grep)",
235
+ "ops.globTitle": "Find by name (glob)",
236
+ "ops.mkdirTitle": "Create remote directory",
237
+ "ops.moveTitle": "Move/Rename",
238
+ "ops.deleteTitle": "Delete remote file/dir",
239
+ "ops.noMatch": "No match",
240
+ "ops.done": "Done",
241
+ "ops.fail": "Operation failed"
184
242
  };
185
243
  var i18n = {
186
244
  t: function (key) { return key; },
@@ -251,6 +309,63 @@ window.__ModuleLoader__.load({
251
309
  );
252
310
  }
253
311
 
312
+ // ======================================================================
313
+ // 远程操作弹窗(grep / glob / mkdir / move / delete)
314
+ // ======================================================================
315
+ function OpsModal(props) {
316
+ var d = props.opsDlg;
317
+ var t = props.t;
318
+ var patchOps = props.patchOps;
319
+ var runOps = props.runOps;
320
+ var title = ({
321
+ grep: t("ops.grepTitle"), glob: t("ops.globTitle"), mkdir: t("ops.mkdirTitle"),
322
+ move: t("ops.moveTitle"), delete: t("ops.deleteTitle")
323
+ })[d.type] || "";
324
+ var isGrep = d.type === "grep";
325
+ var isGlob = d.type === "glob";
326
+ var isMove = d.type === "move";
327
+ var isDel = d.type === "delete";
328
+ // 结果渲染
329
+ var resultEl = null;
330
+ if (d.result) {
331
+ var r = d.result;
332
+ if (isGrep) {
333
+ var ms = r.matches || [];
334
+ resultEl = h("div", { style: { maxHeight: 260, overflow: "auto", marginTop: 8, fontSize: 11, whiteSpace: "pre-wrap", fontFamily: "monospace" } },
335
+ !r.ok ? h("div", { className: "rssh-err" }, r.error || t("ops.fail")) :
336
+ ms.length === 0 ? h("div", { className: "rssh-muted" }, t("ops.noMatch")) :
337
+ ms.map(function (m, i) { return h("div", { key: i }, m.file + ":" + m.line + ": " + m.content); }),
338
+ r.truncated ? h("div", { className: "rssh-muted" }, "…") : null
339
+ );
340
+ } else if (isGlob) {
341
+ var fs2 = r.files || [];
342
+ resultEl = h("div", { style: { maxHeight: 260, overflow: "auto", marginTop: 8, fontSize: 11, whiteSpace: "pre-wrap", fontFamily: "monospace" } },
343
+ !r.ok ? h("div", { className: "rssh-err" }, r.error || t("ops.fail")) :
344
+ fs2.length === 0 ? h("div", { className: "rssh-muted" }, t("ops.noMatch")) :
345
+ fs2.map(function (f, i) { return h("div", { key: i }, f); })
346
+ );
347
+ } else {
348
+ // exec 结果(mkdir/move/delete)
349
+ resultEl = h("div", { style: { marginTop: 8 } },
350
+ r.ok ? h("div", { className: "rssh-ok" }, t("ops.done") + (r.stderr ? " " + String(r.stderr).slice(0, 200) : "")) :
351
+ h("div", { className: "rssh-err" }, (r.error || r.stderr || t("ops.fail")))
352
+ );
353
+ }
354
+ }
355
+ return h(Modal, { open: true, onClose: props.close, title: title, closeLabel: t("common.close"), className: "rssh-flow-dialog" },
356
+ h("div", { style: { display: "flex", flexDirection: "column", gap: 6 } },
357
+ h("input", { className: "rssh-input", value: d.p1, onChange: function (e) { patchOps({ p1: e.target.value }); }, placeholder: isGrep ? t("ops.searchPh") : (isGlob ? t("ops.globPh") : t("ops.newDirPh")) }),
358
+ isGrep ? h("input", { className: "rssh-input", value: d.p2, onChange: function (e) { patchOps({ p2: e.target.value }); }, placeholder: "*.py (include)" }) : null,
359
+ isMove ? h("input", { className: "rssh-input", value: d.p2, onChange: function (e) { patchOps({ p2: e.target.value }); }, placeholder: t("ops.renamePh") }) : null,
360
+ h("div", { className: "rssh-actions" },
361
+ h("button", { className: "rssh-btn", onClick: runOps, disabled: d.busy }, d.busy ? t("common.processing") : (isDel ? t("ops.delete") : t("common.save"))),
362
+ h("button", { className: "rssh-btn", onClick: props.close, disabled: d.busy }, t("common.close"))
363
+ ),
364
+ resultEl
365
+ )
366
+ );
367
+ }
368
+
254
369
  // ======================================================================
255
370
  // 远程文件页签
256
371
  // ======================================================================
@@ -267,6 +382,8 @@ window.__ModuleLoader__.load({
267
382
  var [err, setErr] = React.useState(null);
268
383
  var [loading, setLoading] = React.useState(false);
269
384
  var [defaultDir, setDefaultDir] = useSessionMemory(sessionId, "defaultDir", "");
385
+ var [syncSt, setSyncSt] = React.useState(null); // { busy, msg, ok }
386
+ var [opsDlg, setOpsDlg] = React.useState(null); // { type, p1, p2, result, busy }
270
387
 
271
388
  React.useEffect(function () {
272
389
  api("listWorkspaces").then(function (r) { if (r && r.ok) setWorkspaces(r.workspaces || []); });
@@ -312,6 +429,81 @@ window.__ModuleLoader__.load({
312
429
  });
313
430
  }
314
431
 
432
+ function doSync(direction) {
433
+ if (!wsId) return;
434
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
435
+ if (!ws) return;
436
+ setSyncSt({ busy: true, msg: direction === "down" ? t("sync.syncing") : t("sync.pushing"), ok: null });
437
+ var method = direction === "down" ? "syncDown" : "syncUp";
438
+ api(method, { workspaceId: wsId }).then(function (r) {
439
+ if (r && r.ok) setSyncSt({ busy: false, msg: direction === "down" ? t("sync.done") : t("sync.done"), ok: true });
440
+ else setSyncSt({ busy: false, msg: (r && r.error) || t("sync.fail"), ok: false });
441
+ });
442
+ }
443
+
444
+ function activeProfileId() {
445
+ if (wsId) {
446
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
447
+ if (ws) return ws.profileId;
448
+ }
449
+ return profileId;
450
+ }
451
+ function opsBase() {
452
+ if (wsId) {
453
+ var ws = workspaces.find(function (w) { return w.id === wsId; });
454
+ if (ws) return ws.remotePath || path || "~";
455
+ }
456
+ return path || "~";
457
+ }
458
+ function openOps(type) {
459
+ var init = { type: type, p1: "", p2: "", result: null, busy: false };
460
+ if (type === "mkdir") init.p1 = path ? (path.replace(/\/+$/, "") + "/") : "";
461
+ if (type === "move") { init.p1 = path ? (path.replace(/\/+$/, "") + "/") : ""; init.p2 = path ? (path.replace(/\/+$/, "") + "/") : ""; }
462
+ if (type === "delete") init.p1 = path ? (path.replace(/\/+$/, "") + "/") : "";
463
+ setOpsDlg(init);
464
+ }
465
+ function patchOps(patch) {
466
+ setOpsDlg(function (d) { var n = {}; for (var k in d) n[k] = d[k]; for (var k2 in patch) n[k2] = patch[k2]; return n; });
467
+ }
468
+ function runOps() {
469
+ if (!opsDlg) return;
470
+ var pid = activeProfileId();
471
+ if (!pid) { patchOps({ result: { ok: false, msg: t("ops.fail") }, busy: false }); return; }
472
+ var base = opsBase();
473
+ var d = opsDlg;
474
+ patchOps({ busy: true, result: null });
475
+ function resolveP(p) {
476
+ if (!p) return p;
477
+ var c = String(p).charAt(0);
478
+ if (c === "/" || c === "~") return p;
479
+ return base.replace(/\/+$/, "") + "/" + p;
480
+ }
481
+ if (d.type === "grep") {
482
+ api("grep", { profileId: pid, pattern: d.p1, path: base, include: d.p2 || undefined }).then(function (r) {
483
+ patchOps({ busy: false, result: r });
484
+ });
485
+ } else if (d.type === "glob") {
486
+ api("glob", { profileId: pid, pattern: d.p1, path: base }).then(function (r) {
487
+ patchOps({ busy: false, result: r });
488
+ });
489
+ } else if (d.type === "mkdir") {
490
+ api("mkdir", { profileId: pid, path: resolveP(d.p1) }).then(function (r) {
491
+ patchOps({ busy: false, result: r });
492
+ if (r && r.ok) { setErr(null); load(pid, path); }
493
+ });
494
+ } else if (d.type === "move") {
495
+ api("move", { profileId: pid, src: resolveP(d.p1), dst: resolveP(d.p2) }).then(function (r) {
496
+ patchOps({ busy: false, result: r });
497
+ if (r && r.ok) { setErr(null); load(pid, path); }
498
+ });
499
+ } else if (d.type === "delete") {
500
+ api("deleteFile", { profileId: pid, path: resolveP(d.p1) }).then(function (r) {
501
+ patchOps({ busy: false, result: r });
502
+ if (r && r.ok) { setErr(null); load(pid, path); }
503
+ });
504
+ }
505
+ }
506
+
315
507
  function openEntry(e) {
316
508
  var base = path ? (path.replace(/\/+$/, "") + "/" + e.name) : e.name;
317
509
  if (e.type === "directory") {
@@ -368,8 +560,21 @@ window.__ModuleLoader__.load({
368
560
  h("button", { className: "rssh-btn", onClick: function () { load(profileId, path); } }, t("common.open")),
369
561
  h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
370
562
  ),
563
+ wsId ? h("div", { className: "rssh-pathbar" },
564
+ h("button", { className: "rssh-btn", onClick: function () { doSync("down"); }, disabled: syncSt && syncSt.busy }, t("sync.down")),
565
+ h("button", { className: "rssh-btn", onClick: function () { doSync("up"); }, disabled: syncSt && syncSt.busy }, t("sync.up")),
566
+ syncSt ? h("span", { className: syncSt.ok === false ? "rssh-err" : (syncSt.ok ? "rssh-ok" : "rssh-muted"), style: { flex: 1, fontSize: 11 } }, syncSt.msg) : null
567
+ ) : null,
568
+ (wsId || profileId) ? h("div", { className: "rssh-pathbar", style: { flexWrap: "wrap" } },
569
+ h("button", { className: "rssh-btn", onClick: function () { openOps("grep"); } }, t("ops.search")),
570
+ h("button", { className: "rssh-btn", onClick: function () { openOps("glob"); } }, t("ops.find")),
571
+ h("button", { className: "rssh-btn", onClick: function () { openOps("mkdir"); } }, t("ops.newDir")),
572
+ h("button", { className: "rssh-btn", onClick: function () { openOps("move"); } }, t("ops.rename")),
573
+ h("button", { className: "rssh-btn", onClick: function () { openOps("delete"); } }, t("ops.delete"))
574
+ ) : null,
371
575
  loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
372
- err ? h("div", { className: "rssh-err", style: { whiteSpace: "pre-wrap" } }, err) : null
576
+ err ? h("div", { className: "rssh-err", style: { whiteSpace: "pre-wrap" } }, err) : null,
577
+ opsDlg ? h(OpsModal, { opsDlg: opsDlg, patchOps: patchOps, runOps: runOps, close: function () { setOpsDlg(null); }, t: t }) : null
373
578
  ),
374
579
  h("div", { style: { flex: 1, minHeight: 0, overflowY: "auto", marginTop: 6 } },
375
580
  entries.map(function (e) {
@@ -666,9 +871,10 @@ window.__ModuleLoader__.load({
666
871
  function SettingsSection(props) {
667
872
  var t = useT();
668
873
  var [profiles, setProfiles] = React.useState([]);
669
- var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
874
+ var [form, setForm] = React.useState({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
670
875
  var [result, setResult] = React.useState(null);
671
876
  var [busy, setBusy] = React.useState(false);
877
+ var [importDlg, setImportDlg] = React.useState(null);
672
878
  var set = function (k, v) { setForm(function (f) { var n = {}; for (var key in f) n[key] = f[key]; n[k] = v; return n; }); };
673
879
 
674
880
  function reload() { api("listProfiles").then(function (r) { if (r && r.ok) setProfiles(r.profiles || []); }); }
@@ -680,7 +886,7 @@ window.__ModuleLoader__.load({
680
886
  setBusy(false);
681
887
  if (r && r.ok) {
682
888
  setProfiles(r.profiles || []);
683
- setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~" });
889
+ setForm({ name: "", host: "", port: "22", user: "", authMethod: "key", keyPath: "", password: "", remoteRoot: "~", proxyJump: "" });
684
890
  setResult({ ok: true, msg: t("settings.saved") });
685
891
  } else setResult({ ok: false, msg: (r && r.error) || t("settings.errSave") });
686
892
  });
@@ -697,6 +903,47 @@ window.__ModuleLoader__.load({
697
903
  api("deleteProfile", { id: p.id }).then(function (r) { if (r && r.ok) { setProfiles(r.profiles || []); } });
698
904
  }
699
905
 
906
+ function openImport() {
907
+ setImportDlg({ loading: true, hosts: [], selected: {} });
908
+ api("listSshConfigHosts").then(function (r) {
909
+ if (r && r.ok) {
910
+ var hosts = r.hosts || [];
911
+ var sel = {};
912
+ hosts.forEach(function (h) { sel[h.name + "|" + h.hostName] = true; });
913
+ setImportDlg({ loading: false, hosts: hosts, selected: sel });
914
+ } else {
915
+ setImportDlg({ loading: false, hosts: [], selected: {}, error: (r && r.error) || t("settings.noneFound") });
916
+ }
917
+ });
918
+ }
919
+ function toggleImport(key) {
920
+ setImportDlg(function (d) {
921
+ var sel = {}; for (var k in d.selected) sel[k] = d.selected[k];
922
+ sel[key] = !sel[key];
923
+ return Object.assign({}, d, { selected: sel });
924
+ });
925
+ }
926
+ function doImport() {
927
+ if (!importDlg) return;
928
+ var picked = importDlg.hosts.filter(function (h) { return importDlg.selected[h.name + "|" + h.hostName]; });
929
+ setBusy(true);
930
+ var i = 0;
931
+ function next() {
932
+ if (i >= picked.length) {
933
+ setBusy(false); setImportDlg(null);
934
+ setResult({ ok: true, msg: t("settings.imported").replace("{n}", String(picked.length)) });
935
+ reload();
936
+ return;
937
+ }
938
+ var h = picked[i++];
939
+ api("saveProfile", {
940
+ name: h.name, host: h.hostName, port: h.port || 22, user: h.user || "",
941
+ authMethod: "key", keyPath: h.identityFile || "", password: "", remoteRoot: "~", proxyJump: h.proxyJump || ""
942
+ }).then(next);
943
+ }
944
+ next();
945
+ }
946
+
700
947
  return h("div", null,
701
948
  h("p", { className: "rssh-muted" }, t("settings.desc")),
702
949
  profiles.map(function (p) {
@@ -726,9 +973,33 @@ window.__ModuleLoader__.load({
726
973
  form.authMethod === "key" ? Field({ label: t("settings.keyPath"), value: form.keyPath, onChange: function (v) { set("keyPath", v); }, placeholder: t("settings.keyPathPlaceholder") }) : null,
727
974
  form.authMethod === "password" ? Field({ label: t("settings.passwordField"), type: "password", value: form.password, onChange: function (v) { set("password", v); } }) : null,
728
975
  Field({ label: t("settings.remoteRoot"), value: form.remoteRoot, onChange: function (v) { set("remoteRoot", v); }, placeholder: "~/project" }),
729
- h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save"))
976
+ Field({ label: t("settings.proxyJump"), value: form.proxyJump, onChange: function (v) { set("proxyJump", v); }, placeholder: "bastion.example.com" }),
977
+ h("div", { className: "rssh-actions" },
978
+ h("button", { className: "rssh-btn", onClick: save, disabled: busy }, busy ? t("common.processing") : t("common.save")),
979
+ h("button", { className: "rssh-btn", onClick: openImport, disabled: busy }, t("settings.importSshConfig"))
980
+ )
730
981
  ),
731
- result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null
982
+ result ? h("div", { className: result.ok ? "rssh-ok" : "rssh-err", style: { whiteSpace: "pre-wrap", marginTop: 6 } }, result.msg) : null,
983
+ importDlg ? h(Modal, { open: true, onClose: function () { if (!busy) setImportDlg(null); }, title: t("settings.importTitle"), closeLabel: t("common.close") },
984
+ importDlg.loading ? h("div", { className: "rssh-muted" }, t("common.loading")) :
985
+ importDlg.error ? h("div", { className: "rssh-err" }, importDlg.error) :
986
+ (importDlg.hosts.length === 0 ? h("div", { className: "rssh-muted" }, t("settings.noneFound")) :
987
+ h("div", null,
988
+ importDlg.hosts.map(function (hh) {
989
+ var key = hh.name + "|" + hh.hostName;
990
+ return h("label", { key: key, className: "rssh-tree-item", style: { cursor: "pointer" } },
991
+ h("input", { type: "checkbox", checked: !!importDlg.selected[key], onChange: function () { toggleImport(key); } }),
992
+ h("span", { className: "rssh-tree-dir" }, hh.name),
993
+ h("span", { className: "rssh-sub" }, (hh.user || "?") + "@" + hh.hostName + ":" + (hh.port || 22) + (hh.proxyJump ? " → " + hh.proxyJump : ""))
994
+ );
995
+ }),
996
+ h("div", { className: "rssh-actions", style: { marginTop: 8 } },
997
+ h("button", { className: "rssh-btn", onClick: doImport, disabled: busy }, busy ? t("common.processing") : t("common.save")),
998
+ h("button", { className: "rssh-btn", onClick: function () { setImportDlg(null); }, disabled: busy }, t("common.close"))
999
+ )
1000
+ )
1001
+ )
1002
+ ) : null
732
1003
  );
733
1004
  }
734
1005
 
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,153 @@ 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
+ * 在远端递归搜索文件内容(grep -rnIE)。借鉴 dsh-remote / dsh-remote-ssh 的远程内容搜索,
264
+ * 但用通用的 GNU grep(超算/Linux 通用),不依赖 ripgrep。
265
+ * exit 1 = 无匹配(ok,空结果);exit 0 = 有匹配;exit 2 = 出错。
266
+ */
267
+ async function remoteGrep(subprocess, p, pattern, path, opts) {
268
+ opts = opts || {};
269
+ const target = path || p.remoteRoot || "~";
270
+ const parts = ["grep", "-rnIE"];
271
+ if (opts.ignoreCase) parts.push("-i");
272
+ if (opts.include) parts.push("--include=" + String(opts.include));
273
+ parts.push("--", shellQuote(pattern), shellQuotePath(target));
274
+ const cmd = parts.join(" ");
275
+ const r = await runRemote(subprocess, p, cmd, undefined, MAX_BYTES);
276
+ if (r.exitCode === 1 || (r.ok && !r.stdout)) {
277
+ return { ok: true, pattern: pattern, path: target, matches: [], truncated: false, error: "" };
278
+ }
279
+ if (r.exitCode > 1 && !r.ok) {
280
+ const err = (r.stderr || r.error || "").trim();
281
+ if (err && !r.stdout) return { ok: false, pattern: pattern, path: target, matches: [], truncated: false, error: err };
282
+ }
283
+ const all = [];
284
+ const lines = String(r.stdout || "").split("\n");
285
+ for (let i = 0; i < lines.length; i++) {
286
+ const line = lines[i];
287
+ if (!line) continue;
288
+ const c1 = line.indexOf(":");
289
+ if (c1 < 0) { all.push({ file: line, line: 0, content: "" }); continue; }
290
+ const file = line.slice(0, c1);
291
+ const rest = line.slice(c1 + 1);
292
+ const c2 = rest.indexOf(":");
293
+ if (c2 < 0) { all.push({ file: file, line: 0, content: rest }); continue; }
294
+ const ln = parseInt(rest.slice(0, c2), 10);
295
+ all.push({ file: file, line: isNaN(ln) ? 0 : ln, content: rest.slice(c2 + 1) });
296
+ }
297
+ const max = parseInt(String(opts.maxResults || 200), 10) || 200;
298
+ const truncated = all.length > max || !!r.truncated;
299
+ const matches = all.slice(0, max);
300
+ return { ok: true, pattern: pattern, path: target, matches: matches, truncated: truncated, error: "" };
301
+ }
302
+
303
+ /**
304
+ * 在远端按 glob 模式查找文件(find -name)。find 已递归;pattern 为 basename 通配
305
+ * (如 .py 后缀),自动剥离前导双星号-斜杠与星号-斜杠以适配 POSIX find -name。
306
+ */
307
+ async function remoteGlob(subprocess, p, pattern, path, opts) {
308
+ opts = opts || {};
309
+ const target = path || p.remoteRoot || "~";
310
+ let pat = String(pattern || "").trim();
311
+ pat = pat.replace(/^(\*\*\/|\*\/)+/, "");
312
+ const max = parseInt(String(opts.maxResults || 500), 10) || 500;
313
+ const cmd = "find " + shellQuotePath(target) + " -name " + shellQuote(pat) + " -printf '%p\\n' 2>/dev/null | sort | head -n " + max;
314
+ const r = await runRemote(subprocess, p, cmd, undefined, MAX_BYTES);
315
+ const files = String(r.stdout || "").split("\n").filter(function (s) { return !!s; });
316
+ return { ok: true, pattern: pattern, path: target, files: files, truncated: !!r.truncated, error: "" };
317
+ }
318
+
319
+ /**
320
+ * tar-over-ssh 双向同步(借鉴 flymysql dsh-remote 的 SFTP 镜像同步,但复用现有 ssh.exe + tar,
321
+ * 不引入 ssh2 依赖)。用流式管道把一条 ssh 的 stdout 喂给本地 tar 的 stdin(反之亦然),
322
+ * 不经过 4MB Buffer 上限。
323
+ */
324
+ function spawnOne(subprocess, argv, stdio, cwd) {
325
+ return subprocess.spawn({ argv: argv, cwd: cwd || process.cwd(), stdio: stdio, graceMs: 60000 });
326
+ }
327
+
328
+ function readCollected(handle, name) {
329
+ if (!handle || !handle.collected || !handle.collected[name]) return "";
330
+ const r = handle.collected[name].readFrom(0);
331
+ return r ? r.text : "";
332
+ }
333
+
334
+ /** 远端 → 本地镜像:先清空 mirror,再 tar 展开到 mirror。 */
335
+ async function remoteSyncDown(subprocess, p, remotePath, mirrorPath) {
336
+ if (!remotePath) return { ok: false, error: "remotePath 为必填项" };
337
+ if (!mirrorPath) return { ok: false, error: "mirrorPath 为必填项" };
338
+ try { await rm(mirrorPath, { recursive: true, force: true }); } catch (e) {}
339
+ try { await mkdir(mirrorPath, { recursive: true }); } catch (e) {
340
+ return { ok: false, error: "创建镜像目录失败: " + String(e && e.message ? e.message : e) };
341
+ }
342
+ const COLLECT = { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } };
343
+ let sshH, tarH;
344
+ try {
345
+ sshH = spawnOne(subprocess, sshArgv(p, "tar cf - -C " + shellQuotePath(remotePath) + " ."), {
346
+ stdin: "ignore", stdout: "pipe", stderr: COLLECT
347
+ });
348
+ tarH = spawnOne(subprocess, ["tar", "xf", "-", "-C", mirrorPath], {
349
+ stdin: "pipe", stdout: "ignore", stderr: COLLECT
350
+ });
351
+ } catch (e) {
352
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
353
+ }
354
+ let pipeErr = "";
355
+ try {
356
+ if (sshH.stdout && tarH.stdin) sshH.stdout.pipe(tarH.stdin);
357
+ else pipeErr = "stdout/stdin 管道不可用(subprocess 未暴露原始流)";
358
+ } catch (e) { pipeErr = String(e && e.message ? e.message : e); }
359
+ let sshOut, tarOut;
360
+ try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
361
+ try { tarOut = await tarH.done; } catch (e) { tarOut = { exitCode: -1 }; }
362
+ const sshErr = readCollected(sshH, "stderr");
363
+ const tarErr = readCollected(tarH, "stderr");
364
+ // ssh 正常退出且 tar 正常退出才算成功
365
+ if (sshOut.exitCode === 0 && tarOut.exitCode === 0) return { ok: true, mirrorPath: mirrorPath };
366
+ const parts = [];
367
+ if (pipeErr) parts.push("管道: " + pipeErr);
368
+ if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
369
+ if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
370
+ if (!parts.length) parts.push("远端可能缺少 tar,或本机无 tar.exe");
371
+ return { ok: false, error: "同步失败: " + parts.join(";") };
372
+ }
373
+
374
+ /** 本地镜像 → 远端:本地 tar 打包喂给远端 tar 展开。 */
375
+ async function remoteSyncUp(subprocess, p, remotePath, mirrorPath) {
376
+ if (!remotePath) return { ok: false, error: "remotePath 为必填项" };
377
+ if (!mirrorPath) return { ok: false, error: "mirrorPath 为必填项" };
378
+ const COLLECT = { maxBytes: 1024 * 1024, spill: { maxBytes: 1024 * 1024 } };
379
+ let tarH, sshH;
380
+ try {
381
+ tarH = spawnOne(subprocess, ["tar", "cf", "-", "-C", mirrorPath, "."], {
382
+ stdin: "ignore", stdout: "pipe", stderr: COLLECT
383
+ });
384
+ sshH = spawnOne(subprocess, sshArgv(p, "tar xf - -C " + shellQuotePath(remotePath)), {
385
+ stdin: "pipe", stdout: "ignore", stderr: COLLECT
386
+ });
387
+ } catch (e) {
388
+ return { ok: false, error: "spawn 失败: " + String(e && e.message ? e.message : e) };
389
+ }
390
+ let pipeErr = "";
391
+ try {
392
+ if (tarH.stdout && sshH.stdin) tarH.stdout.pipe(sshH.stdin);
393
+ else pipeErr = "stdout/stdin 管道不可用(subprocess 未暴露原始流)";
394
+ } catch (e) { pipeErr = String(e && e.message ? e.message : e); }
395
+ let tarOut, sshOut;
396
+ try { tarOut = await tarH.done; } catch (e) { tarOut = { exitCode: -1 }; }
397
+ try { sshOut = await sshH.done; } catch (e) { sshOut = { exitCode: -1 }; }
398
+ const tarErr = readCollected(tarH, "stderr");
399
+ const sshErr = readCollected(sshH, "stderr");
400
+ if (tarOut.exitCode === 0 && sshOut.exitCode === 0) return { ok: true, remotePath: remotePath };
401
+ const parts = [];
402
+ if (pipeErr) parts.push("管道: " + pipeErr);
403
+ if (tarOut.exitCode !== 0) parts.push("tar 退出码 " + tarOut.exitCode + (tarErr ? ": " + tarErr.slice(0, 500) : ""));
404
+ if (sshOut.exitCode !== 0) parts.push("ssh 退出码 " + sshOut.exitCode + (sshErr ? ": " + sshErr.slice(0, 500) : ""));
405
+ if (!parts.length) parts.push("远端可能缺少 tar,或本机无 tar.exe");
406
+ return { ok: false, error: "推送失败: " + parts.join(";") };
407
+ }
408
+
174
409
  function normExec(r) {
175
410
  return {
176
411
  ok: !!r.ok,
@@ -265,6 +500,13 @@ function remoteWorkspaceReadme(profileName, remotePath) {
265
500
  "- `remote_ssh_cat` — read a remote file / 读取远程文件",
266
501
  "- `remote_ssh_write` — write a remote file / 写入远程文件",
267
502
  "- `remote_ssh_exec` — run a command on the remote host (like a terminal) / 在远程执行命令",
503
+ "- `remote_ssh_grep` — search file contents (recursive grep) / 递归搜索文件内容",
504
+ "- `remote_ssh_glob` — find files by glob pattern / 按通配符查找文件",
505
+ "- `remote_ssh_mkdir` — create a directory (mkdir -p) / 创建目录",
506
+ "- `remote_ssh_delete` — delete a file or directory (rm -rf) / 删除文件或目录",
507
+ "- `remote_ssh_move` — move/rename a file or directory (mv) / 移动或重命名",
508
+ "- `remote_ssh_sync` — sync remote files to this local mirror / 同步远端文件到本地镜像",
509
+ "- `remote_ssh_push` — push local mirror changes back to remote / 推送本地改动回远端",
268
510
  "- `remote_ssh_profiles` — list profiles and the current remote-workspace context / 查看配置与当前上下文",
269
511
  "",
270
512
  "Workspace info / 工作区信息:",
@@ -346,6 +588,16 @@ function apply(ctx, config) {
346
588
  return { profile: null, remotePath: undefined };
347
589
  }
348
590
 
591
+ /** 解析同步工具的目标工作区:显式 workspaceId 优先,其次当前会话的远程工作区。 */
592
+ function resolveWorkspace(args, exec) {
593
+ const a = args || {};
594
+ const workspaces = settingsFace.read().workspaces;
595
+ if (a.workspaceId) {
596
+ return workspaces.find((w) => w.id === a.workspaceId) || null;
597
+ }
598
+ return remoteContextFor(exec);
599
+ }
600
+
349
601
  /** 相对路径拼到远程工作目录下,绝对路径(/、~、盘符)原样返回。 */
350
602
  function resolveRemotePath(path, base) {
351
603
  if (!base) return path || "";
@@ -357,6 +609,8 @@ function apply(ctx, config) {
357
609
 
358
610
  const api = {
359
611
  listProfiles: async () => ({ ok: true, profiles: settingsFace.read().profiles }),
612
+ /** 读取本机 ~/.ssh/config 发现的主机(借鉴 Yan-Zero dsh-remote-ssh)。 */
613
+ listSshConfigHosts: async () => listSshConfigHosts(),
360
614
  saveProfile: async (args) => {
361
615
  const p = args || {};
362
616
  if (!p.host || !p.user) return { ok: false, error: "host 和 user 为必填项" };
@@ -372,7 +626,8 @@ function apply(ctx, config) {
372
626
  authMethod: p.authMethod || "key",
373
627
  keyPath: p.keyPath || "",
374
628
  password: p.password || "",
375
- remoteRoot: p.remoteRoot || "~"
629
+ remoteRoot: p.remoteRoot || "~",
630
+ proxyJump: p.proxyJump || ""
376
631
  };
377
632
  if (existing) {
378
633
  const i = profiles.indexOf(existing);
@@ -485,6 +740,52 @@ function apply(ctx, config) {
485
740
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
486
741
  return await remoteWriteFile(subprocess, p, args && args.path, args && args.content);
487
742
  },
743
+ grep: async (args) => {
744
+ const p = resolveProfile(args);
745
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
746
+ return await remoteGrep(subprocess, p, args && args.pattern, args && args.path, args || {});
747
+ },
748
+ glob: async (args) => {
749
+ const p = resolveProfile(args);
750
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
751
+ return await remoteGlob(subprocess, p, args && args.pattern, args && args.path, args || {});
752
+ },
753
+ mkdir: async (args) => {
754
+ const p = resolveProfile(args);
755
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
756
+ if (!args || !args.path) return { ok: false, error: "path 为必填项" };
757
+ return await runRemote(subprocess, p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
758
+ },
759
+ deleteFile: async (args) => {
760
+ const p = resolveProfile(args);
761
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
762
+ if (!args || !args.path) return { ok: false, error: "path 为必填项" };
763
+ return await runRemote(subprocess, p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
764
+ },
765
+ move: async (args) => {
766
+ const p = resolveProfile(args);
767
+ if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
768
+ if (!args || !args.src || !args.dst) return { ok: false, error: "src 和 dst 为必填项" };
769
+ return await runRemote(subprocess, p, "mv " + shellQuotePath(args.src) + " " + shellQuotePath(args.dst), undefined, undefined);
770
+ },
771
+ /** 远端 → 本地镜像(按工作区)。 */
772
+ syncDown: async (args) => {
773
+ const a = args || {};
774
+ const ws = settingsFace.read().workspaces.find((w) => w.id === (a.workspaceId || a.id));
775
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
776
+ const p = getProfile(ws.profileId);
777
+ if (!p) return { ok: false, error: "未找到连接配置" };
778
+ return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
779
+ },
780
+ /** 本地镜像 → 远端(按工作区)。 */
781
+ syncUp: async (args) => {
782
+ const a = args || {};
783
+ const ws = settingsFace.read().workspaces.find((w) => w.id === (a.workspaceId || a.id));
784
+ if (!ws) return { ok: false, error: "未找到远程工作区" };
785
+ const p = getProfile(ws.profileId);
786
+ if (!p) return { ok: false, error: "未找到连接配置" };
787
+ return await remoteSyncUp(subprocess, p, ws.remotePath, ws.mirrorPath);
788
+ },
488
789
  spawnTerminal: async (args) => {
489
790
  const p = resolveProfile(args);
490
791
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
@@ -681,6 +982,172 @@ function apply(ctx, config) {
681
982
  }
682
983
  });
683
984
 
985
+ // 同步:把远程工作区的文件同步到本地镜像目录(syncDown),或把本地镜像改动推回远端(syncUp)。
986
+ // 借鉴 flymysql dsh-remote 的 rw_sync / rw_push,但用 tar-over-ssh 流式管道,不引入 ssh2。
987
+ const syncDesc = "按远程工作区双向同步:sync 把远端文件同步到本地镜像目录,push 把本地镜像改动推回远端。" +
988
+ "用 workspaceId 指定工作区,或留空在当前远程工作区会话中自动识别。" +
989
+ "Sync/push a remote workspace to its local mirror (or back). Pass workspaceId, or omit it inside a remote-workspace session.";
990
+ const syncParams = {
991
+ workspaceId: { type: "string", description: "远程工作区 id(留空则在当前远程工作区会话中自动识别)。" }
992
+ };
993
+ const syncOutput = {
994
+ schema: { type: "object", additionalProperties: false, properties: {
995
+ ok: { type: "boolean", required: true },
996
+ error: { type: "string", required: true }
997
+ } },
998
+ render: textRender(function (a, v) { return v.ok ? "同步完成" : (v.error || "同步失败"); })
999
+ };
1000
+ register({
1001
+ name: "remote_ssh_sync",
1002
+ description: syncDesc,
1003
+ parameters: syncParams,
1004
+ output: syncOutput,
1005
+ execute: async function (args, exec) {
1006
+ const ws = resolveWorkspace(args, exec);
1007
+ if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
1008
+ const p = getProfile(ws.profileId);
1009
+ if (!p) return { ok: false, error: "未找到连接配置" };
1010
+ return await remoteSyncDown(subprocess, p, ws.remotePath, ws.mirrorPath);
1011
+ }
1012
+ });
1013
+ register({
1014
+ name: "remote_ssh_push",
1015
+ description: syncDesc,
1016
+ parameters: syncParams,
1017
+ output: syncOutput,
1018
+ execute: async function (args, exec) {
1019
+ const ws = resolveWorkspace(args, exec);
1020
+ if (!ws) return { ok: false, error: "未找到远程工作区(workspaceId 必填,或当前会话非远程工作区)" };
1021
+ const p = getProfile(ws.profileId);
1022
+ if (!p) return { ok: false, error: "未找到连接配置" };
1023
+ return await remoteSyncUp(subprocess, p, ws.remotePath, ws.mirrorPath);
1024
+ }
1025
+ });
1026
+
1027
+ // ---- 搜索与文件操作(借鉴 dsh-remote 的 rw_grep/rw_glob/rw_mkdir/rw_delete/rw_move)----
1028
+ // 内容搜索:grep -rnIE(GNU grep,超算/Linux 通用,不依赖 ripgrep)。
1029
+ register({
1030
+ name: "remote_ssh_grep",
1031
+ description: "通过 SSH 在远程主机上递归搜索文件内容(grep -rnIE,扩展正则)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,path 默认为工作区远程目录。可选 include 限定文件名通配(如 *.py)、ignoreCase 忽略大小写、maxResults 限制条数(默认 200)。Search file contents on a remote host over SSH (recursive grep).",
1032
+ parameters: Object.assign({}, CONN_PARAMS, {
1033
+ pattern: { type: "string", required: true, description: "搜索模式(grep 扩展正则)。" },
1034
+ path: { type: "string", description: "搜索目录,默认使用工作区远程目录。" },
1035
+ include: { type: "string", description: "文件名通配过滤,如 *.py(GNU grep --include)。" },
1036
+ ignoreCase: { type: "boolean", description: "忽略大小写。" },
1037
+ maxResults: { type: "number", description: "最多返回的匹配条数,默认 200。" }
1038
+ }),
1039
+ output: {
1040
+ schema: {
1041
+ type: "object", additionalProperties: false, properties: {
1042
+ ok: { type: "boolean", required: true },
1043
+ pattern: { type: "string", required: true },
1044
+ path: { type: "string", required: true },
1045
+ matches: { type: "array", required: true, items: { type: "object", additionalProperties: true } },
1046
+ truncated: { type: "boolean", required: true },
1047
+ error: { type: "string", required: true }
1048
+ }
1049
+ },
1050
+ render: textRender(function (a, v) {
1051
+ if (!v.ok) return v.error || "搜索失败";
1052
+ const ms = v.matches || [];
1053
+ if (!ms.length) return "无匹配";
1054
+ const lines = ms.map(function (m) { return m.file + ":" + m.line + ": " + m.content; });
1055
+ return lines.join("\n") + (v.truncated ? "\n…(已截断)" : "");
1056
+ })
1057
+ },
1058
+ execute: async function (args, exec) {
1059
+ const tc = toolContext(args, exec);
1060
+ if (!tc.profile) return { ok: false, pattern: "", path: "", matches: [], truncated: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
1061
+ if (!args.pattern) return { ok: false, pattern: "", path: "", matches: [], truncated: false, error: "pattern 为必填项" };
1062
+ const r = await remoteGrep(subprocess, tc.profile, args.pattern, resolveRemotePath(args.path, tc.remotePath), args);
1063
+ return { ok: !!r.ok, pattern: r.pattern || args.pattern, path: r.path || "", matches: r.matches || [], truncated: !!r.truncated, error: r.error || "" };
1064
+ }
1065
+ });
1066
+
1067
+ // 文件名通配查找:find -name(已递归,自动剥离前导 **/)。
1068
+ register({
1069
+ name: "remote_ssh_glob",
1070
+ description: "通过 SSH 在远程主机上按通配符查找文件(find -name,递归)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,path 默认为工作区远程目录。pattern 为 basename 通配,如 *.py(自动剥离前导 **/ 以适配 POSIX find)。Find files by glob pattern on a remote host over SSH.",
1071
+ parameters: Object.assign({}, CONN_PARAMS, {
1072
+ pattern: { type: "string", required: true, description: "文件名通配,如 *.py 或 *.js。" },
1073
+ path: { type: "string", description: "查找目录,默认使用工作区远程目录。" },
1074
+ maxResults: { type: "number", description: "最多返回条数,默认 500。" }
1075
+ }),
1076
+ output: {
1077
+ schema: {
1078
+ type: "object", additionalProperties: false, properties: {
1079
+ ok: { type: "boolean", required: true },
1080
+ pattern: { type: "string", required: true },
1081
+ path: { type: "string", required: true },
1082
+ files: { type: "array", required: true, items: { type: "string" } },
1083
+ truncated: { type: "boolean", required: true },
1084
+ error: { type: "string", required: true }
1085
+ }
1086
+ },
1087
+ render: textRender(function (a, v) {
1088
+ if (!v.ok) return v.error || "查找失败";
1089
+ const fs2 = v.files || [];
1090
+ if (!fs2.length) return "无匹配文件";
1091
+ return fs2.join("\n") + (v.truncated ? "\n…(已截断)" : "");
1092
+ })
1093
+ },
1094
+ execute: async function (args, exec) {
1095
+ const tc = toolContext(args, exec);
1096
+ if (!tc.profile) return { ok: false, pattern: "", path: "", files: [], truncated: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
1097
+ if (!args.pattern) return { ok: false, pattern: "", path: "", files: [], truncated: false, error: "pattern 为必填项" };
1098
+ const r = await remoteGlob(subprocess, tc.profile, args.pattern, resolveRemotePath(args.path, tc.remotePath), args);
1099
+ return { ok: !!r.ok, pattern: r.pattern || args.pattern, path: r.path || "", files: r.files || [], truncated: !!r.truncated, error: r.error || "" };
1100
+ }
1101
+ });
1102
+
1103
+ // 创建目录:mkdir -p。
1104
+ register({
1105
+ name: "remote_ssh_mkdir",
1106
+ description: "通过 SSH 在远程主机上创建目录(mkdir -p,含父目录)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,相对路径基于工作区远程目录解析。Create a directory on a remote host over SSH (mkdir -p).",
1107
+ parameters: Object.assign({}, CONN_PARAMS, { path: { type: "string", required: true, description: "远程目录路径。" } }),
1108
+ output: { schema: execSchema(), render: textRender(function (a, v) { return v.ok ? "已创建" : (v.error || v.stderr || "创建失败"); }) },
1109
+ execute: async function (args, exec) {
1110
+ const tc = toolContext(args, exec);
1111
+ if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1112
+ if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
1113
+ let cmd = "mkdir -p " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1114
+ return normExec(await runRemote(subprocess, tc.profile, cmd, undefined, undefined));
1115
+ }
1116
+ });
1117
+
1118
+ // 删除文件或目录:rm -rf。谨慎使用。
1119
+ register({
1120
+ name: "remote_ssh_delete",
1121
+ description: "通过 SSH 删除远程主机上的文件或目录(rm -rf,递归不询问)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,相对路径基于工作区远程目录解析。⚠️ 不可恢复,请谨慎使用。Delete a file or directory on a remote host over SSH (rm -rf).",
1122
+ parameters: Object.assign({}, CONN_PARAMS, { path: { type: "string", required: true, description: "远程文件或目录路径。" } }),
1123
+ output: { schema: execSchema(), render: textRender(function (a, v) { return v.ok ? "已删除" : (v.error || v.stderr || "删除失败"); }) },
1124
+ execute: async function (args, exec) {
1125
+ const tc = toolContext(args, exec);
1126
+ if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1127
+ if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
1128
+ let cmd = "rm -rf " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1129
+ return normExec(await runRemote(subprocess, tc.profile, cmd, undefined, undefined));
1130
+ }
1131
+ });
1132
+
1133
+ // 移动/重命名:mv。
1134
+ register({
1135
+ name: "remote_ssh_move",
1136
+ description: "通过 SSH 在远程主机上移动或重命名文件/目录(mv)。用 profileId 引用已保存配置,或直接给 host/user。在当前远程工作区会话中可不填连接参数,相对路径基于工作区远程目录解析。Move or rename a file/directory on a remote host over SSH (mv).",
1137
+ parameters: Object.assign({}, CONN_PARAMS, {
1138
+ src: { type: "string", required: true, description: "源路径。" },
1139
+ dst: { type: "string", required: true, description: "目标路径。" }
1140
+ }),
1141
+ output: { schema: execSchema(), render: textRender(function (a, v) { return v.ok ? "已移动" : (v.error || v.stderr || "移动失败"); }) },
1142
+ execute: async function (args, exec) {
1143
+ const tc = toolContext(args, exec);
1144
+ if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1145
+ if (!args.src || !args.dst) return normExec({ ok: false, error: "src 和 dst 为必填项" });
1146
+ let cmd = "mv " + shellQuotePath(resolveRemotePath(args.src, tc.remotePath)) + " " + shellQuotePath(resolveRemotePath(args.dst, tc.remotePath));
1147
+ return normExec(await runRemote(subprocess, tc.profile, cmd, undefined, undefined));
1148
+ }
1149
+ });
1150
+
684
1151
  // ---- 清理 ----
685
1152
  ctx.effect(() => () => {
686
1153
  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.4.0",
3
+ "version": "1.6.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",