@zhangfengshun/dsh-remote-ssh 1.5.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 +11 -0
- package/README.md +8 -3
- package/lib/client.js +168 -3
- package/lib/index.js +216 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
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
|
+
|
|
5
16
|
## [1.5.0] — 导入 OpenSSH 配置 + 双向同步
|
|
6
17
|
### 新增
|
|
7
18
|
- **从 `~/.ssh/config` 导入连接**:设置小节「从 ~/.ssh/config 导入…」按钮读取本机 OpenSSH 用户配置(递归解析 `Include`),多选主机后批量导入为连接配置(`HostName` / `User` / `Port` / `IdentityFile` / `ProxyJump` 一并带入)。借鉴 Yan-Zero `dsh-remote-ssh`,免去逐条手动录入。
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
| 💻 远程终端 | 在 better-sidebar「远程终端」页签打开 `ssh -tt` 集成终端,支持多终端并发 |
|
|
15
15
|
| 🔄 双向同步 | `remote_ssh_sync`(远端 → 本地镜像)/ `remote_ssh_push`(本地镜像 → 远端),tar 流式管道,同步后内置文件工具可见 |
|
|
16
16
|
| 🌐 远程工作区 | 选择远程目录创建**原生工作区**(本地镜像目录 + 原生注册),一键打开进入远程环境 |
|
|
17
|
-
| 🤖 模型工具 |
|
|
17
|
+
| 🤖 模型工具 | 12 个 `remote_ssh_*` 工具,模型可读写远程文件、执行远程命令、内容搜索、文件名查找、目录/删除/移动、双向同步;在远程工作区会话中免填连接参数 |
|
|
18
18
|
| 🌍 国际化 | 界面文案 + 工具描述中英双语,通过 `ctx.locale` 跟随 DSH 语言设置自动切换 |
|
|
19
19
|
| 🧠 会话记忆 | 切换会话后,「远程文件」「远程终端」页签记住已选连接、浏览目录、打开的文件与终端 |
|
|
20
20
|
|
|
@@ -29,7 +29,7 @@ dsh plugin --profile <name> add /absolute/path/to/dsh-remote-ssh
|
|
|
29
29
|
### 发布后安装
|
|
30
30
|
|
|
31
31
|
```bash
|
|
32
|
-
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.
|
|
32
|
+
dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.6.0
|
|
33
33
|
```
|
|
34
34
|
|
|
35
35
|
> ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
|
|
@@ -90,6 +90,11 @@ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.5.0
|
|
|
90
90
|
| `remote_ssh_write` | 写入远程文件(覆盖写入) |
|
|
91
91
|
| `remote_ssh_sync` | 远端文件同步到本地镜像目录(tar 流式管道) |
|
|
92
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) |
|
|
93
98
|
|
|
94
99
|
**会话感知**:当会话是从远程工作区创建时,模型调用这些工具可**不填** `profileId` / `host` / `user` 等连接参数,自动复用该工作区的连接,相对路径基于该工作区远程目录解析。
|
|
95
100
|
|
|
@@ -116,7 +121,7 @@ dsh-remote-ssh/
|
|
|
116
121
|
│ · 远程文件 / 远程终端 │ ◀────── │ · SSH:listDir/readFile/… │
|
|
117
122
|
│ ctx.slots: settings.section │ JSON │ · settings:profiles/workspaces │
|
|
118
123
|
│ · DSH 设置页「远程连接」 │ │ · terminals:ssh -tt 管道 │
|
|
119
|
-
└───────────────────────────────┘ │ ctx.tools:
|
|
124
|
+
└───────────────────────────────┘ │ ctx.tools:12 个 remote_ssh_* │
|
|
120
125
|
└───────────────┬──────────────────┘
|
|
121
126
|
│ SSH
|
|
122
127
|
远程超算 / 服务器
|
package/lib/client.js
CHANGED
|
@@ -126,7 +126,25 @@ window.__ModuleLoader__.load({
|
|
|
126
126
|
"sync.syncing": "同步中…",
|
|
127
127
|
"sync.pushing": "推送中…",
|
|
128
128
|
"sync.done": "同步完成",
|
|
129
|
-
"sync.fail": "同步失败"
|
|
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": "操作失败"
|
|
130
148
|
};
|
|
131
149
|
var en = {
|
|
132
150
|
"tabs.files": "Remote Files",
|
|
@@ -202,7 +220,25 @@ window.__ModuleLoader__.load({
|
|
|
202
220
|
"sync.syncing": "Syncing…",
|
|
203
221
|
"sync.pushing": "Pushing…",
|
|
204
222
|
"sync.done": "Sync complete",
|
|
205
|
-
"sync.fail": "Sync failed"
|
|
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"
|
|
206
242
|
};
|
|
207
243
|
var i18n = {
|
|
208
244
|
t: function (key) { return key; },
|
|
@@ -273,6 +309,63 @@ window.__ModuleLoader__.load({
|
|
|
273
309
|
);
|
|
274
310
|
}
|
|
275
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
|
+
|
|
276
369
|
// ======================================================================
|
|
277
370
|
// 远程文件页签
|
|
278
371
|
// ======================================================================
|
|
@@ -290,6 +383,7 @@ window.__ModuleLoader__.load({
|
|
|
290
383
|
var [loading, setLoading] = React.useState(false);
|
|
291
384
|
var [defaultDir, setDefaultDir] = useSessionMemory(sessionId, "defaultDir", "");
|
|
292
385
|
var [syncSt, setSyncSt] = React.useState(null); // { busy, msg, ok }
|
|
386
|
+
var [opsDlg, setOpsDlg] = React.useState(null); // { type, p1, p2, result, busy }
|
|
293
387
|
|
|
294
388
|
React.useEffect(function () {
|
|
295
389
|
api("listWorkspaces").then(function (r) { if (r && r.ok) setWorkspaces(r.workspaces || []); });
|
|
@@ -347,6 +441,69 @@ window.__ModuleLoader__.load({
|
|
|
347
441
|
});
|
|
348
442
|
}
|
|
349
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
|
+
|
|
350
507
|
function openEntry(e) {
|
|
351
508
|
var base = path ? (path.replace(/\/+$/, "") + "/" + e.name) : e.name;
|
|
352
509
|
if (e.type === "directory") {
|
|
@@ -408,8 +565,16 @@ window.__ModuleLoader__.load({
|
|
|
408
565
|
h("button", { className: "rssh-btn", onClick: function () { doSync("up"); }, disabled: syncSt && syncSt.busy }, t("sync.up")),
|
|
409
566
|
syncSt ? h("span", { className: syncSt.ok === false ? "rssh-err" : (syncSt.ok ? "rssh-ok" : "rssh-muted"), style: { flex: 1, fontSize: 11 } }, syncSt.msg) : null
|
|
410
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,
|
|
411
575
|
loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
|
|
412
|
-
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
|
|
413
578
|
),
|
|
414
579
|
h("div", { style: { flex: 1, minHeight: 0, overflowY: "auto", marginTop: 6 } },
|
|
415
580
|
entries.map(function (e) {
|
package/lib/index.js
CHANGED
|
@@ -259,6 +259,63 @@ async function remoteWriteFile(subprocess, p, path, content) {
|
|
|
259
259
|
return await runRemote(subprocess, p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
|
|
260
260
|
}
|
|
261
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
|
+
|
|
262
319
|
/**
|
|
263
320
|
* tar-over-ssh 双向同步(借鉴 flymysql dsh-remote 的 SFTP 镜像同步,但复用现有 ssh.exe + tar,
|
|
264
321
|
* 不引入 ssh2 依赖)。用流式管道把一条 ssh 的 stdout 喂给本地 tar 的 stdin(反之亦然),
|
|
@@ -443,6 +500,13 @@ function remoteWorkspaceReadme(profileName, remotePath) {
|
|
|
443
500
|
"- `remote_ssh_cat` — read a remote file / 读取远程文件",
|
|
444
501
|
"- `remote_ssh_write` — write a remote file / 写入远程文件",
|
|
445
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 / 推送本地改动回远端",
|
|
446
510
|
"- `remote_ssh_profiles` — list profiles and the current remote-workspace context / 查看配置与当前上下文",
|
|
447
511
|
"",
|
|
448
512
|
"Workspace info / 工作区信息:",
|
|
@@ -676,6 +740,34 @@ function apply(ctx, config) {
|
|
|
676
740
|
if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
|
|
677
741
|
return await remoteWriteFile(subprocess, p, args && args.path, args && args.content);
|
|
678
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
|
+
},
|
|
679
771
|
/** 远端 → 本地镜像(按工作区)。 */
|
|
680
772
|
syncDown: async (args) => {
|
|
681
773
|
const a = args || {};
|
|
@@ -932,6 +1024,130 @@ function apply(ctx, config) {
|
|
|
932
1024
|
}
|
|
933
1025
|
});
|
|
934
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
|
+
|
|
935
1151
|
// ---- 清理 ----
|
|
936
1152
|
ctx.effect(() => () => {
|
|
937
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.
|
|
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",
|