@zhangfengshun/dsh-remote-ssh 1.6.0 → 1.8.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,21 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [1.8.0] — 重做「添加工作区」弹窗
6
+ ### 改进
7
+ - **统一弹窗布局**:原先本地/远程是两个独立弹窗(各自标题),现改为**单个弹窗**:顶部「📁 本地目录 / 🌐 远程目录」分段切换按钮(当前激活的高亮),下方依次为连接选择(远程时)、路径栏(浏览按钮紧挨输入框右侧)、当前路径下的文件树(仅目录、可滚动)。
8
+ - 路径栏右侧的「浏览」按钮紧挨输入框,点击后按输入框路径加载文件树(原为「打开」)。
9
+ - 文件树无子目录时显示占位提示。
10
+ - 本地/远程切换不再关闭重开弹窗,在同一弹窗内即时切换。
11
+
12
+ ## [1.7.0] — SSH 连接复用(大幅加速)
13
+ ### 重大优化
14
+ - **持久 SSH 会话池**:`ls`/`cat`/`write`/`grep`/`glob`/`mkdir`/`delete`/`move` 等文件操作不再每次新建 ssh 子进程(每次都要完整 TCP 握手 + 密钥交换 + 认证,超算/跳板机单次 2-10 秒)。改为维护一条常驻 `ssh <host> bash` 进程,所有命令复用它,用哨兵标记(sentinel)分隔输出、解析退出码。首次连接后,后续操作近乎瞬时。
15
+ - **自动回退**:持久会话断开(网络中断、远端重启等)时自动清理并回退到一次性 `runRemote`,保证可靠性。
16
+ - **空闲清理**:会话空闲超过 10 分钟自动断开,避免占着连接。插件卸载时全部清理。
17
+ - `remote_ssh_exec` 与测试连接仍用一次性连接(保留 stdout/stderr 分离)。
18
+ - Windows OpenSSH 不支持 ControlMaster(已验证),故采用此进程内会话池方案,不依赖 ControlMaster。
19
+
5
20
  ## [1.6.0] — 远程搜索与文件操作工具
6
21
  ### 新增
7
22
  - **内容搜索 `remote_ssh_grep`**:在远端递归搜索文件内容(`grep -rnIE`,扩展正则),支持 `include` 文件名过滤(如 `*.py`)、`ignoreCase` 忽略大小写、`maxResults` 限流(默认 200)。借鉴 dsh-remote / dsh-remote-ssh 的远程搜索,但用通用 GNU grep(超算/Linux 通用,不依赖 ripgrep)。
package/README.md CHANGED
@@ -9,6 +9,7 @@
9
9
  | 能力 | 说明 |
10
10
  | --- | --- |
11
11
  | 🔌 远程连接 | SSH 连接超算 / 服务器,密钥认证(推荐)或密码认证(需本机 `sshpass`),内置「测试连接」;支持 `ProxyJump` 跳板机 |
12
+ | ⚡ 连接复用 | 持久 SSH 会话池,文件操作复用同一条已认证连接,不再每次握手(首次后近乎瞬时) |
12
13
  | 📥 配置导入 | 一键从 `~/.ssh/config`(递归 `Include`)发现主机并批量导入连接配置 |
13
14
  | 🗂️ 远程文件 | 在 better-sidebar「远程文件」页签浏览 / 打开 / 编辑 / 保存远程文件;选中工作区后可双向同步 |
14
15
  | 💻 远程终端 | 在 better-sidebar「远程终端」页签打开 `ssh -tt` 集成终端,支持多终端并发 |
@@ -29,7 +30,7 @@ dsh plugin --profile <name> add /absolute/path/to/dsh-remote-ssh
29
30
  ### 发布后安装
30
31
 
31
32
  ```bash
32
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.6.0
33
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@1.8.0
33
34
  ```
34
35
 
35
36
  > ⚠️ 安装后需**重启 DSH** 才生效;后续仅修改 Client 半边时刷新浏览器即可。
package/lib/client.js CHANGED
@@ -47,6 +47,13 @@ window.__ModuleLoader__.load({
47
47
  ".rssh-ok{color:var(--dsw-alias-state-success-primary)}.rssh-err{color:var(--dsw-alias-state-error-primary)}.rssh-muted{color:var(--dsw-alias-label-tertiary);font-size:12px;padding:6px 0}" +
48
48
  ".rssh-empty{padding:12px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:12px}" +
49
49
  ".rssh-flow-dialog{width:690px!important;max-width:94vw!important;min-width:460px;resize:horizontal;overflow:auto!important}" +
50
+ // 添加工作区弹窗:上方本地/远程分段切换 + 路径栏 + 文件树
51
+ ".rssh-seg{display:flex;gap:0;margin-bottom:8px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px;overflow:hidden}" +
52
+ ".rssh-seg-btn{flex:1;padding:7px 12px;text-align:center;cursor:pointer;font-size:12px;font-weight:500;background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary);border:none;transition:background .15s}" +
53
+ ".rssh-seg-btn:hover{background:var(--dsw-alias-interactive-bg-hover)}" +
54
+ ".rssh-seg-btn.active{background:var(--dsw-alias-state-business-primary);color:#fff}" +
55
+ ".rssh-pick-tree{border:1px solid var(--dsw-alias-border-l1);border-radius:4px;max-height:240px;overflow-y:auto;margin-top:4px;background:var(--dsw-alias-bg-layer-2)}" +
56
+ ".rssh-pick-empty{padding:12px;text-align:center;color:var(--dsw-alias-label-tertiary);font-size:11px}" +
50
57
  // 设置面板导航:隐藏「远程连接」栏目左侧壳层硬编码的齿轮图标(本栏目 order=200 位于末尾)。
51
58
  ".VOzbGW_navList .VOzbGW_navCell:last-child .VOzbGW_navIcon{display:none}";
52
59
 
@@ -84,6 +91,7 @@ window.__ModuleLoader__.load({
84
91
  "common.save": "保存",
85
92
  "common.open": "打开",
86
93
  "common.close": "关闭",
94
+ "common.noEntries": "(无子目录)",
87
95
  "common.delete": "删除",
88
96
  "common.up": "⬆ 上级",
89
97
  "common.loading": "加载中…",
@@ -114,6 +122,10 @@ window.__ModuleLoader__.load({
114
122
  "flow.remoteTitle": "选择远程目录",
115
123
  "flow.localDir": "本地目录",
116
124
  "flow.remoteDir": "远程目录",
125
+ "flow.addWsTitle": "添加工作区",
126
+ "flow.localSeg": "📁 本地目录",
127
+ "flow.remoteSeg": "🌐 远程目录",
128
+ "flow.browse": "浏览",
117
129
  "flow.chooseRemote": "选择远程目录…",
118
130
  "flow.backLocal": "← 选择本地目录",
119
131
  "flow.errCreate": "创建远程工作区失败",
@@ -178,6 +190,7 @@ window.__ModuleLoader__.load({
178
190
  "common.save": "Save",
179
191
  "common.open": "Open",
180
192
  "common.close": "Close",
193
+ "common.noEntries": "(no subdirectories)",
181
194
  "common.delete": "Delete",
182
195
  "common.up": "⬆ Up",
183
196
  "common.loading": "Loading…",
@@ -208,6 +221,10 @@ window.__ModuleLoader__.load({
208
221
  "flow.remoteTitle": "Select Remote Directory",
209
222
  "flow.localDir": "Local directory",
210
223
  "flow.remoteDir": "Remote directory",
224
+ "flow.addWsTitle": "Add Workspace",
225
+ "flow.localSeg": "📁 Local",
226
+ "flow.remoteSeg": "🌐 Remote",
227
+ "flow.browse": "Browse",
211
228
  "flow.chooseRemote": "Choose remote directory…",
212
229
  "flow.backLocal": "← Choose local directory",
213
230
  "flow.errCreate": "Failed to create remote workspace",
@@ -790,21 +807,27 @@ window.__ModuleLoader__.load({
790
807
  if (up !== null) load(up);
791
808
  }
792
809
 
793
- return h("div", { style: { border: "1px solid var(--dsw-alias-border-l1)", borderRadius: 6, padding: 8, marginTop: 6 } },
810
+ // 路径栏:输入框 + 浏览按钮(紧挨在右侧)+ 上级
811
+ return h("div", null,
794
812
  h("div", { className: "rssh-pathbar" },
795
813
  h("input", { className: "rssh-input", value: path, onChange: function (e) { setPath(e.target.value); }, placeholder: props.placeholder || t("picker.dir") }),
796
- h("button", { className: "rssh-btn", onClick: function () { load(path); } }, t("common.open")),
814
+ h("button", { className: "rssh-btn", onClick: function () { load(path); } }, t("flow.browse")),
797
815
  h("button", { className: "rssh-btn", onClick: goUp }, t("common.up"))
798
816
  ),
799
817
  loading ? h("div", { className: "rssh-muted" }, t("common.loading")) : null,
800
818
  err ? h("div", { className: "rssh-err" }, err) : null,
801
- h("div", { style: { maxHeight: 180, overflow: "auto", marginTop: 4 } },
802
- entries.filter(function (e) { return e.type === "directory"; }).map(function (e) {
803
- return h("div", { key: e.path || e.name, className: "rssh-tree-item", onClick: function () { enterDir(e); } },
804
- h("span", { className: "rssh-tree-dir" }, "📁 " + e.name));
805
- })
819
+ // 当前路径下的文件树(仅目录可进入)
820
+ h("div", { className: "rssh-pick-tree" },
821
+ (entries.filter(function (e) { return e.type === "directory"; })).length === 0 && !loading
822
+ ? h("div", { className: "rssh-pick-empty" }, t("common.noEntries"))
823
+ : entries.filter(function (e) { return e.type === "directory"; }).map(function (e) {
824
+ return h("div", { key: e.path || e.name, className: "rssh-tree-item", onClick: function () { enterDir(e); } },
825
+ h("span", { className: "rssh-tree-dir" }, "📁 " + e.name));
826
+ })
806
827
  ),
807
- h("button", { className: "rssh-btn", style: { marginTop: 8 }, disabled: !path, onClick: function () { props.onChoose(path); } }, t("picker.choose"))
828
+ h("div", { className: "rssh-actions", style: { marginTop: 8 } },
829
+ h("button", { className: "rssh-btn", disabled: !path || props.disabled, onClick: function () { props.onChoose(path); } }, t("picker.choose"))
830
+ )
808
831
  );
809
832
  }
810
833
 
@@ -834,33 +857,30 @@ window.__ModuleLoader__.load({
834
857
  }
835
858
 
836
859
  var canClose = !creating && !props.busy;
860
+ var isRemote = mode === "remote";
837
861
 
838
- if (mode === "remote") {
839
- return h(Modal, { open: true, onClose: function () { if (canClose) props.onCancel(); }, title: t("flow.remoteTitle"), closeLabel: t("common.close"), className: "rssh-flow-dialog" },
840
- h("div", { style: { padding: 8 } },
841
- creating || props.busy ? h("div", { className: "rssh-muted" }, t("common.processing")) : null,
842
- h("div", { className: "rssh-row" },
843
- h("span", { className: "rssh-label" }, t("common.profile")),
844
- h("select", { className: "rssh-select", value: profileId, onChange: function (e) { setProfileId(e.target.value); } },
845
- h("option", { value: "" }, t("common.selectProfile")),
846
- profiles.map(function (p) { return h("option", { key: p.id, value: p.id }, p.name); })
847
- )
848
- ),
849
- profileId ? h(DirPicker, { resetKey: profileId, placeholder: t("flow.remoteDir"), listFn: function (p) { return remoteListFn(profileId, p); }, onChoose: chooseRemote }) : null,
850
- h("div", { className: "rssh-actions", style: { marginTop: 8 } },
851
- h("button", { className: "rssh-btn", onClick: function () { setMode("local"); }, disabled: !!props.busy }, t("flow.backLocal"))
852
- )
853
- )
854
- );
855
- }
856
-
857
- return h(Modal, { open: true, onClose: function () { if (canClose) props.onCancel(); }, title: t("flow.localTitle"), closeLabel: t("common.close"), className: "rssh-flow-dialog" },
862
+ // 统一弹窗:上方本地/远程分段切换 远程时显示连接选择 → 路径栏(浏览按钮紧挨右侧)→ 文件树
863
+ return h(Modal, { open: true, onClose: function () { if (canClose) props.onCancel(); }, title: t("flow.addWsTitle"), closeLabel: t("common.close"), className: "rssh-flow-dialog" },
858
864
  h("div", { style: { padding: 8 } },
859
- props.busy ? h("div", { className: "rssh-muted" }, t("common.processing")) : null,
860
- h(DirPicker, { resetKey: "local", placeholder: t("flow.localDir"), listFn: function (p) { return localListFn(props.workspaces, p); }, onChoose: chooseLocal }),
861
- h("div", { className: "rssh-actions", style: { marginTop: 8 } },
862
- h("button", { className: "rssh-btn", onClick: function () { setMode("remote"); }, disabled: !!props.busy }, t("flow.chooseRemote"))
863
- )
865
+ creating || props.busy ? h("div", { className: "rssh-muted" }, t("common.processing")) : null,
866
+ // 分段切换:本地 / 远程
867
+ h("div", { className: "rssh-seg" },
868
+ h("button", { className: "rssh-seg-btn" + (isRemote ? "" : " active"), onClick: function () { setMode("local"); }, disabled: !!props.busy || creating }, t("flow.localSeg")),
869
+ h("button", { className: "rssh-seg-btn" + (isRemote ? " active" : ""), onClick: function () { setMode("remote"); }, disabled: !!props.busy || creating }, t("flow.remoteSeg"))
870
+ ),
871
+ // 远程模式:连接配置选择
872
+ isRemote ? h("div", { className: "rssh-row" },
873
+ h("span", { className: "rssh-label" }, t("common.profile")),
874
+ h("select", { className: "rssh-select", value: profileId, onChange: function (e) { setProfileId(e.target.value); }, disabled: creating },
875
+ h("option", { value: "" }, t("common.selectProfile")),
876
+ profiles.map(function (p) { return h("option", { key: p.id, value: p.id }, p.name); })
877
+ )
878
+ ) : null,
879
+ // 目录选择器:远程需选好连接后才显示
880
+ isRemote
881
+ ? (profileId ? h(DirPicker, { resetKey: profileId, placeholder: t("flow.remoteDir"), listFn: function (p) { return remoteListFn(profileId, p); }, onChoose: chooseRemote, disabled: creating })
882
+ : h("div", { className: "rssh-pick-empty" }, t("common.selectProfile")))
883
+ : h(DirPicker, { resetKey: "local", placeholder: t("flow.localDir"), listFn: function (p) { return localListFn(props.workspaces, p); }, onChoose: chooseLocal })
864
884
  )
865
885
  );
866
886
  }
package/lib/index.js CHANGED
@@ -155,6 +155,121 @@ async function listSshConfigHosts() {
155
155
  return { ok: true, hosts: out };
156
156
  }
157
157
 
158
+ /**
159
+ * 持久 SSH 会话:维护一条常驻 ssh <host> bash 进程,所有命令复用它,
160
+ * 避免每次操作都做完整 TCP 握手 + SSH 密钥交换 + 认证(超算/跳板机单次 2-10 秒)。
161
+ *
162
+ * 工作原理:向 bash 的 stdin 写入命令 + 哨兵标记 printf,从 stdout 读取直到哨兵出现,
163
+ * 哨兵后的数字即退出码。每条命令用 2>&1 合并 stderr(成功时 stderr 为空,不影响解析;
164
+ * 失败时错误信息在 stdout 中,退出码非 0)。带 stdin 的写操作用 heredoc。
165
+ */
166
+ class CommandSession {
167
+ constructor(subprocess, profile) {
168
+ this.subprocess = subprocess;
169
+ this.profile = profile;
170
+ this.handle = null;
171
+ this.alive = false;
172
+ this.buf = "";
173
+ this.queue = [];
174
+ this.current = null;
175
+ this.sentinel = "DSHEOF" + Math.random().toString(36).slice(2, 14) + Math.random().toString(36).slice(2, 10) + "DSHEOF";
176
+ this.lastUsed = Date.now();
177
+ this.connectPromise = null;
178
+ }
179
+
180
+ async connect() {
181
+ if (this.alive && this.handle) return;
182
+ if (this.connectPromise) return this.connectPromise;
183
+ this.connectPromise = this._doConnect();
184
+ try { await this.connectPromise; } finally { this.connectPromise = null; }
185
+ }
186
+
187
+ async _doConnect() {
188
+ if (this.handle) { try { this.handle.terminate(); } catch (e) {} this.handle = null; }
189
+ this.alive = false;
190
+ this.buf = "";
191
+ this.current = null;
192
+ this.queue = [];
193
+ const argv = sshArgv(this.profile, "bash", false);
194
+ const h = this.subprocess.spawn({
195
+ argv: argv,
196
+ cwd: process.cwd(),
197
+ stdio: { stdin: "pipe", stdout: "pipe", stderr: "pipe" },
198
+ graceMs: 60000
199
+ });
200
+ this.handle = h;
201
+ h.stdout.on("data", (chunk) => {
202
+ this.buf += chunk.toString("utf8");
203
+ this._check();
204
+ });
205
+ const onEnd = () => {
206
+ this.alive = false;
207
+ if (this.current) { this.current.reject(new Error("ssh 会话已关闭")); this.current = null; }
208
+ while (this.queue.length) this.queue.shift().reject(new Error("ssh 会话已关闭"));
209
+ };
210
+ h.stdout.on("end", onEnd);
211
+ h.stdout.on("error", onEnd);
212
+ h.done.then(() => { this.alive = false; }, () => { this.alive = false; });
213
+ this.alive = true;
214
+ this.lastUsed = Date.now();
215
+ }
216
+
217
+ _check() {
218
+ if (!this.current) return;
219
+ const idx = this.buf.indexOf(this.sentinel);
220
+ if (idx < 0) return;
221
+ const before = this.buf.slice(0, idx);
222
+ const after = this.buf.slice(idx + this.sentinel.length);
223
+ const nl = after.indexOf("\n");
224
+ const codeStr = nl >= 0 ? after.slice(0, nl) : after;
225
+ const rest = nl >= 0 ? after.slice(nl + 1) : "";
226
+ this.buf = rest;
227
+ const exitCode = parseInt(codeStr, 10);
228
+ const cur = this.current;
229
+ this.current = null;
230
+ cur.resolve({ stdout: before, exitCode: isNaN(exitCode) ? -1 : exitCode });
231
+ this._next();
232
+ }
233
+
234
+ _next() {
235
+ if (this.current || !this.alive || this.queue.length === 0) return;
236
+ const item = this.queue.shift();
237
+ this.current = { resolve: item.resolve, reject: item.reject };
238
+ try {
239
+ let toWrite;
240
+ if (item.stdinData !== undefined && item.stdinData !== null) {
241
+ // 写操作:heredoc 传内容,不用 2>&1(cat > file 不产生 stdout)
242
+ const delim = "DSHW" + Math.random().toString(36).slice(2, 14);
243
+ toWrite = item.cmd + " <<'" + delim + "'\n" + String(item.stdinData) + "\n" + delim + "\nprintf '\\n" + this.sentinel + "%s\\n' $?\n";
244
+ } else {
245
+ // 读/列举/搜索:2>&1 合并 stderr(成功时 stderr 为空)
246
+ toWrite = "{ " + item.cmd + " ; } 2>&1\nprintf '\\n" + this.sentinel + "%s\\n' $?\n";
247
+ }
248
+ this.handle.stdin.write(toWrite);
249
+ } catch (e) {
250
+ this.current = null;
251
+ item.reject(e);
252
+ this._next();
253
+ }
254
+ }
255
+
256
+ async exec(cmd, stdinData) {
257
+ await this.connect();
258
+ this.lastUsed = Date.now();
259
+ return new Promise((resolve, reject) => {
260
+ this.queue.push({ cmd, stdinData, resolve, reject });
261
+ this._next();
262
+ });
263
+ }
264
+
265
+ close() {
266
+ this.alive = false;
267
+ if (this.current) { this.current.reject(new Error("session closed")); this.current = null; }
268
+ while (this.queue.length) this.queue.shift().reject(new Error("session closed"));
269
+ if (this.handle) { try { this.handle.terminate(); } catch (e) {} this.handle = null; }
270
+ }
271
+ }
272
+
158
273
  function sshArgv(p, remoteCmd, tty) {
159
274
  const opts = ["ssh"];
160
275
  if (tty) opts.push("-tt");
@@ -214,10 +329,10 @@ async function runRemote(subprocess, p, remoteCmd, stdinData, maxBytes) {
214
329
  };
215
330
  }
216
331
 
217
- async function remoteListDir(subprocess, p, path) {
332
+ async function remoteListDir(runner, p, path) {
218
333
  const target = path || p.remoteRoot || "~";
219
334
  const script = "cd " + shellQuotePath(target) + " 2>/dev/null || { echo '__DSH_ERR__ cannot cd'; exit 1; }; find . -maxdepth 1 -mindepth 1 -printf '%Y\\t%f\\t%s\\n' 2>/dev/null | sort";
220
- const r = await runRemote(subprocess, p, script, undefined, MAX_BYTES);
335
+ const r = await runner(p, script, undefined, MAX_BYTES);
221
336
  if (!r.ok) return { ok: false, error: (r.error || r.stderr || "").trim() || "读取目录失败" };
222
337
  const entries = [];
223
338
  const lines = String(r.stdout || "").split("\n");
@@ -236,9 +351,9 @@ async function remoteListDir(subprocess, p, path) {
236
351
  return { ok: true, path: target, entries: entries };
237
352
  }
238
353
 
239
- async function remoteReadFile(subprocess, p, path) {
354
+ async function remoteReadFile(runner, p, path) {
240
355
  if (!path) return { ok: false, error: "path 为必填项" };
241
- const r = await runRemote(subprocess, p, "base64 -w0 " + shellQuotePath(path), undefined, MAX_BYTES);
356
+ const r = await runner(p, "base64 -w0 " + shellQuotePath(path), undefined, MAX_BYTES);
242
357
  if (!r.ok) return r;
243
358
  let text = "";
244
359
  let binary = false;
@@ -253,10 +368,10 @@ async function remoteReadFile(subprocess, p, path) {
253
368
  return { ok: true, path: path, content: text, binary: binary, truncated: r.truncated };
254
369
  }
255
370
 
256
- async function remoteWriteFile(subprocess, p, path, content) {
371
+ async function remoteWriteFile(runner, p, path, content) {
257
372
  if (!path) return { ok: false, error: "path 为必填项" };
258
373
  const c = content !== undefined ? String(content) : "";
259
- return await runRemote(subprocess, p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
374
+ return await runner(p, "cat > " + shellQuotePath(path), c, MAX_BYTES);
260
375
  }
261
376
 
262
377
  /**
@@ -264,7 +379,7 @@ async function remoteWriteFile(subprocess, p, path, content) {
264
379
  * 但用通用的 GNU grep(超算/Linux 通用),不依赖 ripgrep。
265
380
  * exit 1 = 无匹配(ok,空结果);exit 0 = 有匹配;exit 2 = 出错。
266
381
  */
267
- async function remoteGrep(subprocess, p, pattern, path, opts) {
382
+ async function remoteGrep(runner, p, pattern, path, opts) {
268
383
  opts = opts || {};
269
384
  const target = path || p.remoteRoot || "~";
270
385
  const parts = ["grep", "-rnIE"];
@@ -272,7 +387,7 @@ async function remoteGrep(subprocess, p, pattern, path, opts) {
272
387
  if (opts.include) parts.push("--include=" + String(opts.include));
273
388
  parts.push("--", shellQuote(pattern), shellQuotePath(target));
274
389
  const cmd = parts.join(" ");
275
- const r = await runRemote(subprocess, p, cmd, undefined, MAX_BYTES);
390
+ const r = await runner(p, cmd, undefined, MAX_BYTES);
276
391
  if (r.exitCode === 1 || (r.ok && !r.stdout)) {
277
392
  return { ok: true, pattern: pattern, path: target, matches: [], truncated: false, error: "" };
278
393
  }
@@ -304,14 +419,14 @@ async function remoteGrep(subprocess, p, pattern, path, opts) {
304
419
  * 在远端按 glob 模式查找文件(find -name)。find 已递归;pattern 为 basename 通配
305
420
  * (如 .py 后缀),自动剥离前导双星号-斜杠与星号-斜杠以适配 POSIX find -name。
306
421
  */
307
- async function remoteGlob(subprocess, p, pattern, path, opts) {
422
+ async function remoteGlob(runner, p, pattern, path, opts) {
308
423
  opts = opts || {};
309
424
  const target = path || p.remoteRoot || "~";
310
425
  let pat = String(pattern || "").trim();
311
426
  pat = pat.replace(/^(\*\*\/|\*\/)+/, "");
312
427
  const max = parseInt(String(opts.maxResults || 500), 10) || 500;
313
428
  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);
429
+ const r = await runner(p, cmd, undefined, MAX_BYTES);
315
430
  const files = String(r.stdout || "").split("\n").filter(function (s) { return !!s; });
316
431
  return { ok: true, pattern: pattern, path: target, files: files, truncated: !!r.truncated, error: "" };
317
432
  }
@@ -530,6 +645,40 @@ function apply(ctx, config) {
530
645
  const terminals = new Map();
531
646
  let nextTerminalId = 1;
532
647
 
648
+ // ---- 持久 SSH 会话池(连接复用,避免每次操作都做完整 SSH 握手)----
649
+ const sessions = new Map(); // profileKey -> CommandSession
650
+ const SESSION_IDLE_MS = 10 * 60 * 1000;
651
+ function profileKey(p) {
652
+ return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "");
653
+ }
654
+ function getSession(p) {
655
+ const key = profileKey(p);
656
+ let s = sessions.get(key);
657
+ if (!s) { s = new CommandSession(subprocess, p); sessions.set(key, s); }
658
+ return s;
659
+ }
660
+ /** 池化执行:复用持久会话,失败时回退到一次性 runRemote。 */
661
+ async function runPooled(p, cmd, stdinData, maxBytes) {
662
+ try {
663
+ const s = getSession(p);
664
+ const r = await s.exec(cmd, stdinData);
665
+ return { ok: r.exitCode === 0, exitCode: r.exitCode, stdout: r.stdout, stderr: "", error: r.exitCode !== 0 ? String(r.stdout).trim().slice(0, 500) : "", truncated: false };
666
+ } catch (e) {
667
+ // 会话挂了 —— 清理并回退到一次性连接
668
+ const key = profileKey(p);
669
+ const old = sessions.get(key);
670
+ if (old) { old.close(); sessions.delete(key); }
671
+ return runRemote(subprocess, p, cmd, stdinData, maxBytes);
672
+ }
673
+ }
674
+ // 定期清理空闲会话
675
+ const idleTimer = setInterval(function () {
676
+ const now = Date.now();
677
+ for (const [key, s] of sessions) {
678
+ if (now - s.lastUsed > SESSION_IDLE_MS) { s.close(); sessions.delete(key); }
679
+ }
680
+ }, 60 * 1000);
681
+
533
682
  // ---- settings 持久化 ----
534
683
  let settingsFace = {
535
684
  read: () => ({ profiles: [], workspaces: [] }),
@@ -728,45 +877,45 @@ function apply(ctx, config) {
728
877
  listDir: async (args) => {
729
878
  const p = resolveProfile(args);
730
879
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
731
- return await remoteListDir(subprocess, p, args && args.path);
880
+ return await remoteListDir(runPooled, p, args && args.path);
732
881
  },
733
882
  readFile: async (args) => {
734
883
  const p = resolveProfile(args);
735
884
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
736
- return await remoteReadFile(subprocess, p, args && args.path);
885
+ return await remoteReadFile(runPooled, p, args && args.path);
737
886
  },
738
887
  writeFile: async (args) => {
739
888
  const p = resolveProfile(args);
740
889
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
741
- return await remoteWriteFile(subprocess, p, args && args.path, args && args.content);
890
+ return await remoteWriteFile(runPooled, p, args && args.path, args && args.content);
742
891
  },
743
892
  grep: async (args) => {
744
893
  const p = resolveProfile(args);
745
894
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
746
- return await remoteGrep(subprocess, p, args && args.pattern, args && args.path, args || {});
895
+ return await remoteGrep(runPooled, p, args && args.pattern, args && args.path, args || {});
747
896
  },
748
897
  glob: async (args) => {
749
898
  const p = resolveProfile(args);
750
899
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
751
- return await remoteGlob(subprocess, p, args && args.pattern, args && args.path, args || {});
900
+ return await remoteGlob(runPooled, p, args && args.pattern, args && args.path, args || {});
752
901
  },
753
902
  mkdir: async (args) => {
754
903
  const p = resolveProfile(args);
755
904
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
756
905
  if (!args || !args.path) return { ok: false, error: "path 为必填项" };
757
- return await runRemote(subprocess, p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
906
+ return await runPooled(p, "mkdir -p " + shellQuotePath(args.path), undefined, undefined);
758
907
  },
759
908
  deleteFile: async (args) => {
760
909
  const p = resolveProfile(args);
761
910
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
762
911
  if (!args || !args.path) return { ok: false, error: "path 为必填项" };
763
- return await runRemote(subprocess, p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
912
+ return await runPooled(p, "rm -rf " + shellQuotePath(args.path), undefined, undefined);
764
913
  },
765
914
  move: async (args) => {
766
915
  const p = resolveProfile(args);
767
916
  if (!p) return { ok: false, error: "需要 profileId 或 host+user" };
768
917
  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);
918
+ return await runPooled(p, "mv " + shellQuotePath(args.src) + " " + shellQuotePath(args.dst), undefined, undefined);
770
919
  },
771
920
  /** 远端 → 本地镜像(按工作区)。 */
772
921
  syncDown: async (args) => {
@@ -934,7 +1083,7 @@ function apply(ctx, config) {
934
1083
  execute: async function (args, exec) {
935
1084
  const tc = toolContext(args, exec);
936
1085
  if (!tc.profile) return { ok: false, path: "", entries: [], error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
937
- const r = await remoteListDir(subprocess, tc.profile, resolveRemotePath(args.path, tc.remotePath));
1086
+ const r = await remoteListDir(runPooled, tc.profile, resolveRemotePath(args.path, tc.remotePath));
938
1087
  return { ok: !!r.ok, path: r.path || "", entries: r.entries || [], error: r.error || "" };
939
1088
  }
940
1089
  });
@@ -959,7 +1108,7 @@ function apply(ctx, config) {
959
1108
  execute: async function (args, exec) {
960
1109
  const tc = toolContext(args, exec);
961
1110
  if (!tc.profile) return { ok: false, path: "", content: "", binary: false, truncated: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
962
- const r = await remoteReadFile(subprocess, tc.profile, resolveRemotePath(args.path, tc.remotePath));
1111
+ const r = await remoteReadFile(runPooled, tc.profile, resolveRemotePath(args.path, tc.remotePath));
963
1112
  return { ok: !!r.ok, path: r.path || "", content: r.content || "", binary: !!r.binary, truncated: !!r.truncated, error: r.error || "" };
964
1113
  }
965
1114
  });
@@ -978,7 +1127,7 @@ function apply(ctx, config) {
978
1127
  execute: async function (args, exec) {
979
1128
  const tc = toolContext(args, exec);
980
1129
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
981
- return normExec(await remoteWriteFile(subprocess, tc.profile, resolveRemotePath(args.path, tc.remotePath), args.content));
1130
+ return normExec(await remoteWriteFile(runPooled, tc.profile, resolveRemotePath(args.path, tc.remotePath), args.content));
982
1131
  }
983
1132
  });
984
1133
 
@@ -1059,7 +1208,7 @@ function apply(ctx, config) {
1059
1208
  const tc = toolContext(args, exec);
1060
1209
  if (!tc.profile) return { ok: false, pattern: "", path: "", matches: [], truncated: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
1061
1210
  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);
1211
+ const r = await remoteGrep(runPooled, tc.profile, args.pattern, resolveRemotePath(args.path, tc.remotePath), args);
1063
1212
  return { ok: !!r.ok, pattern: r.pattern || args.pattern, path: r.path || "", matches: r.matches || [], truncated: !!r.truncated, error: r.error || "" };
1064
1213
  }
1065
1214
  });
@@ -1095,7 +1244,7 @@ function apply(ctx, config) {
1095
1244
  const tc = toolContext(args, exec);
1096
1245
  if (!tc.profile) return { ok: false, pattern: "", path: "", files: [], truncated: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" };
1097
1246
  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);
1247
+ const r = await remoteGlob(runPooled, tc.profile, args.pattern, resolveRemotePath(args.path, tc.remotePath), args);
1099
1248
  return { ok: !!r.ok, pattern: r.pattern || args.pattern, path: r.path || "", files: r.files || [], truncated: !!r.truncated, error: r.error || "" };
1100
1249
  }
1101
1250
  });
@@ -1111,7 +1260,7 @@ function apply(ctx, config) {
1111
1260
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1112
1261
  if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
1113
1262
  let cmd = "mkdir -p " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1114
- return normExec(await runRemote(subprocess, tc.profile, cmd, undefined, undefined));
1263
+ return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
1115
1264
  }
1116
1265
  });
1117
1266
 
@@ -1126,7 +1275,7 @@ function apply(ctx, config) {
1126
1275
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1127
1276
  if (!args.path) return normExec({ ok: false, error: "path 为必填项" });
1128
1277
  let cmd = "rm -rf " + shellQuotePath(resolveRemotePath(args.path, tc.remotePath));
1129
- return normExec(await runRemote(subprocess, tc.profile, cmd, undefined, undefined));
1278
+ return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
1130
1279
  }
1131
1280
  });
1132
1281
 
@@ -1144,12 +1293,15 @@ function apply(ctx, config) {
1144
1293
  if (!tc.profile) return normExec({ ok: false, error: "需要 profileId 或 host+user(当前会话非远程工作区时必填)" });
1145
1294
  if (!args.src || !args.dst) return normExec({ ok: false, error: "src 和 dst 为必填项" });
1146
1295
  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));
1296
+ return normExec(await runPooled(tc.profile, cmd, undefined, undefined));
1148
1297
  }
1149
1298
  });
1150
1299
 
1151
1300
  // ---- 清理 ----
1152
1301
  ctx.effect(() => () => {
1302
+ clearInterval(idleTimer);
1303
+ sessions.forEach(function (s) { try { s.close(); } catch (e) {} });
1304
+ sessions.clear();
1153
1305
  terminals.forEach(function (s) { try { s.handle.terminate(); } catch (e) {} });
1154
1306
  terminals.clear();
1155
1307
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "1.6.0",
3
+ "version": "1.8.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",