@zhangfengshun/dsh-remote-ssh 2.1.5 → 2.1.7

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,49 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.1.7] — 兼容 DSH Desktop ≥ 2.0.4:polyfill __DSH_MODULES__ + 可靠性/安全修复
6
+ ### 修复
7
+ - **__DSH_MODULES__ polyfill**:better-sidebar 0.15+ 的懒加载 chunk(编辑器等)依赖 `globalThis.__DSH_MODULES__`,而 DSH Desktop ≥ 2.0.4 的 shell 不再挂载该全局,导致侧边栏面板打不开("chunk ... client module system unavailable")。Client 半边启动时用内核 `modules` 服务补挂该全局(better-sidebar 已注入则保持原值),并带定期兜底重试。
8
+ - **持久会话 stderr 排空**:SSH 会话的 stderr 管道此前无人消费,写满后远端 ssh 进程阻塞、会话假死;现在持续排空并保留最近 64KB,会话意外关闭时报错会附带最近 stderr 尾部,便于排查断连原因。
9
+ - **修复 `remote_ssh_grep` 命令注入**:`--include` 参数此前未做 shell 转义,可通过构造参数在远端执行任意命令;现已与其它参数一样经 `shellQuote` 引用。
10
+ - **删除连接配置时级联清理**:删除 profile 时同步删除其远程工作区的原生注册与本地镜像目录(复用统一的 `cleanupWorkspace`),不再留下指向已删配置的孤儿工作区。
11
+ - **密钥路径支持 `~` 展开**:`keyPath` 按 `~`、`~/` 前缀展开为绝对路径,与设置界面占位符(`~/.ssh/id_rsa`)行为一致。
12
+ - **会话池 key 纳入认证信息**:修改连接配置的 keyPath/authMethod 后立即重建 SSH 会话,不再复用旧连接。
13
+ - 清理每次文件请求的 `console.log` 调试输出(改走 `ctx.logger.debug`);`localToRemote` 对镜像目录之外的路径不再静默拼出错误远程路径。
14
+ - 文档:补全 2.1.0 以来缺失的 CHANGELOG 条目;README 安装命令版本号同步为 2.1.7。
15
+
16
+ ## [2.1.6] — P0 打磨:远程下载 / 流式上传完善 / 远程 Git / 重连
17
+ ### 修复 / 改进
18
+ - **远程文件下载与媒体预览**:拦截 better-sidebar 的 `/sidebar/file`(同路径 exact 路由优先),远程工作区中的文件直接从远端 base64 拉回(上限 64MB),本地照旧。
19
+ - **流式上传**:`/remote-ssh/upload` 把请求体直接管道到远端 `cat > target`(二进制安全、恒定内存),请求中断时终止远端进程。
20
+ - **远程 Git 面板**:拦截 `/sidebar/api/git.*`(status/diff/log/branch/commit-diff/show/stage/unstage/commit/checkout/discard/revert/cherry-pick),远程工作区中在远端目录执行同语义 git 命令。
21
+ - **SSH 失败修复建议**:端口转发占用 / 公钥认证失败 / 连接拒绝 / 超时 / 域名解析失败翻译为带修复建议的中文提示。
22
+
23
+ ## [2.1.5] — 同步 better-sidebar 0.15+ 上传到远程工作区
24
+ ### 修复
25
+ - better-sidebar 0.15+ 的上传 UI 直连 `/sidebar/upload`,远程工作区镜像目录会导致文件只落在本地镜像。Client 半边包装 `fetch`:目标目录位于远程工作区镜像内时把上传重定向到 `/remote-ssh/upload`,经 SSH 直接写远端。
26
+
27
+ ## [2.1.4] — 修复远程工作区标题卡在镜像目录名
28
+ ### 修复
29
+ - `workspaceRegistry.create` 只对新建记录应用 title,旧工作区(早期版本未传 title)标题卡在镜像目录名(如 `wmt3tev5cdfge`)。创建时显式 `setTitle`,并新增启动自愈 `healWorkspaceTitles`(仅在标题等于镜像目录 basename 时修复,不覆盖用户自定义标题)。
30
+
31
+ ## [2.1.3] — 远程工作区标题默认取远程路径最后一段
32
+ ### 改进
33
+ - 创建远程工作区时的默认标题改为远程路径最后一段目录名(如 `~/run/zfs/codex/DSH_Test → DSH_Test`),取不到有效段时回退为 `连接名:远程路径`;原生工作区标题带 🌐 前缀。
34
+
35
+ ## [2.1.2] — 修复 ssh config RemoteForward 端口占用导致连接失败
36
+ ### 修复
37
+ - 插件内部连接(文件读写 / 工具 / 同步)追加 `ClearAllForwardings=yes`;shell wrapper 追加 `ExitOnForwardFailure=no`。`~/.ssh/config` 里的 `RemoteForward` 端口被上次会话占用且 `ExitOnForwardFailure yes` 时不再导致连接直接失败。
38
+ - 把 SSH 常见失败(端口转发占用 / 公钥认证失败 / 连接拒绝 / 超时 / 域名解析失败)翻译成带修复建议的中文提示。
39
+
40
+ ## [2.1.1] — 新增英文 README
41
+ ### 文档
42
+ - 新增 README_EN.md,中英文版本互链。
43
+
44
+ ## [2.1.0] — README 拆分语言版本
45
+ ### 文档
46
+ - 移除 README 内联双语,拆分为 README.md(中文)与 README_EN.md(英文)。
47
+
5
48
  ## [2.0.1] — 修复远程检测:从 payload.path 也查找 .remote-ssh.json
6
49
  ### 修复
7
50
  - **sessionCwd 为空时无法检测远程工作区**:客户端请求有时只带 `payload.path` 不带 `cwd`,而 `ctx.sessions.get(sessionId)` 可能返回 undefined(session 未创建或 ID 不匹配)。现改为:先尝试 `sessionCwd`,再尝试 `payload.path`,两者都会检查 `.remote-ssh.json`。
package/README.md CHANGED
@@ -17,7 +17,7 @@
17
17
  ## 安装
18
18
 
19
19
  ```bash
20
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.0
20
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
21
21
  ```
22
22
 
23
23
  > 安装后需**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
package/README_EN.md ADDED
@@ -0,0 +1,67 @@
1
+ # @zhangfengshun/dsh-remote-ssh
2
+
3
+ English | [中文](./README.md)
4
+
5
+ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via SSH, and directly operate remote files and terminals within DSH's built-in **Files** and **Terminal** sidebar tabs.
6
+
7
+ ## Features
8
+
9
+ | Feature | Description |
10
+ | --- | --- |
11
+ | 🔌 SSH Connection | Key / password auth, ProxyJump bastion, one-click import from `~/.ssh/config` |
12
+ | 📂 Remote Files | Built-in **Files** tab reads/writes remote files directly via SSH — no sync needed |
13
+ | 💻 Remote Terminal | Built-in **Terminal** tab auto-detects remote workspaces, opens SSH interactive shell |
14
+ | 🌐 Remote Workspace | Select a remote directory to create a native workspace, one-click enter |
15
+ | 🤖 Model Tools | 12 `remote_ssh_*` tools, session-aware with auto-filled connection params |
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.1.7
21
+ ```
22
+
23
+ > **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
24
+
25
+ ## Usage
26
+
27
+ 1. **Settings → 🖥️ Remote SSH** → Add a connection (host/port/user/key) → Click "Test Connection"
28
+ 2. **Add Workspace** → Choose "Select Remote Directory…" → Pick a connection → Browse and select
29
+ 3. Open the built-in **Files** tab → Remote files shown directly, edits save back to remote
30
+ 4. Open the built-in **Terminal** tab → Auto SSH to remote host (key auth only)
31
+
32
+ ## Model Tools
33
+
34
+ | Tool | Purpose |
35
+ | --- | --- |
36
+ | `remote_ssh_profiles` | List saved connections + current session's remote workspace context |
37
+ | `remote_ssh_exec` | Execute remote command |
38
+ | `remote_ssh_ls` | List remote directory |
39
+ | `remote_ssh_cat` | Read remote file |
40
+ | `remote_ssh_write` | Write remote file |
41
+ | `remote_ssh_grep` | Search remote file contents |
42
+ | `remote_ssh_glob` | Find remote files by glob |
43
+ | `remote_ssh_mkdir` | Create remote directory |
44
+ | `remote_ssh_delete` | Delete remote file/directory |
45
+ | `remote_ssh_move` | Move/rename |
46
+ | `remote_ssh_sync` | Sync remote to local mirror |
47
+ | `remote_ssh_push` | Push local mirror back to remote |
48
+
49
+ In a remote-workspace session, `profileId` and other connection params can be omitted.
50
+
51
+ ## How It Works
52
+
53
+ The plugin registers 4 exact routes (`/sidebar/api/fs.tree`, `fs.read`, `fs.write`, `fs.search`) that intercept better-sidebar's prefix route. When the session cwd contains `.remote-ssh.json`, requests go through SSH; otherwise local fs. The client sees local mirror paths — the Host transparently translates them to remote paths.
54
+
55
+ A shell wrapper (`~/.dsh/remote-ssh/dsh-remote-shell[.cmd]`) detects the workspace's `.remote-ssh.json` and auto-launches `ssh -tt`, making the built-in **Terminal** tab transparently connect to remote.
56
+
57
+ ## ❤️ Happy Qixi
58
+
59
+ This project is a Qixi Festival gift for **zhangyi**.
60
+
61
+ May it connect us as closely as it connects to distant supercomputers. Happy Qixi ❤️
62
+
63
+ —— August 18, 2026
64
+
65
+ ## License
66
+
67
+ [MIT](./LICENSE)
package/lib/client.js CHANGED
@@ -1063,6 +1063,39 @@ window.__ModuleLoader__.load({
1063
1063
  var slots = ctx.slots;
1064
1064
  var locale = ctx.locale;
1065
1065
 
1066
+ // 兼容 DSH Desktop ≥2.0.4:shell 不再把客户端模块系统挂到
1067
+ // globalThis.__DSH_MODULES__,而 dsh-better-sidebar 0.15+ 的懒加载
1068
+ // chunk(editor 等)会回退到该全局——缺失时侧边栏面板打不开、报
1069
+ // "chunk ... client module system unavailable"。这里用内核提供的
1070
+ // modules 服务补挂该全局;better-sidebar 已注入则保持原值。
1071
+ try {
1072
+ if (typeof globalThis !== "undefined") {
1073
+ var fallbackModules = null;
1074
+ try { if (ctx.modules && typeof ctx.modules.import === "function") fallbackModules = ctx.modules; } catch (e) {}
1075
+ if (!fallbackModules && ctx.get && typeof ctx.get === "function") {
1076
+ try { fallbackModules = ctx.get("modules"); } catch (e) { fallbackModules = null; }
1077
+ }
1078
+ // 定期兜底:better-sidebar 若把本插件应用时间之后的模块系统清掉,也补回。
1079
+ if (globalThis.__DSH_MODULES__ === undefined && fallbackModules === null) {
1080
+ var waitModules = 0;
1081
+ var waitTimer = setInterval(function () {
1082
+ waitModules += 1;
1083
+ try {
1084
+ var ms = (ctx.get && typeof ctx.get === "function") ? ctx.get("modules") : null;
1085
+ if (ms && typeof ms.import === "function") {
1086
+ globalThis.__DSH_MODULES__ = ms;
1087
+ clearInterval(waitTimer);
1088
+ }
1089
+ } catch (e) {}
1090
+ if (globalThis.__DSH_MODULES__ !== undefined || waitModules > 40) clearInterval(waitTimer);
1091
+ }, 250);
1092
+ }
1093
+ if (!globalThis.__DSH_MODULES__ && fallbackModules && typeof fallbackModules.import === "function") {
1094
+ globalThis.__DSH_MODULES__ = fallbackModules;
1095
+ }
1096
+ }
1097
+ } catch (e) {}
1098
+
1066
1099
  // 注册中英文词典并绑定当前语言;locale 切换时 i18n.subscribe 触发组件重渲染。
1067
1100
  if (locale) {
1068
1101
  ctx.effect(function () { return locale.register(NS, { zh: zh, en: en }); }, "dsh-remote-ssh: dictionaries");
package/lib/index.js CHANGED
@@ -176,6 +176,8 @@ class CommandSession {
176
176
  this.sentinel = "DSHEOF" + Math.random().toString(36).slice(2, 14) + Math.random().toString(36).slice(2, 10) + "DSHEOF";
177
177
  this.lastUsed = Date.now();
178
178
  this.connectPromise = null;
179
+ /** 最近一段 stderr(防背压消费 + 会话关闭诊断)。 */
180
+ this.errBuf = "";
179
181
  }
180
182
 
181
183
  async connect() {
@@ -189,6 +191,7 @@ class CommandSession {
189
191
  if (this.handle) { try { this.handle.terminate(); } catch (e) {} this.handle = null; }
190
192
  this.alive = false;
191
193
  this.buf = "";
194
+ this.errBuf = "";
192
195
  this.current = null;
193
196
  this.queue = [];
194
197
  const argv = sshArgv(this.profile, "bash", false);
@@ -203,10 +206,16 @@ class CommandSession {
203
206
  this.buf += chunk.toString("utf8");
204
207
  this._check();
205
208
  });
209
+ // stderr 必须消费:不挂监听会让管道缓冲填满、远端 ssh 写 stderr 阻塞(会话假死)。
210
+ // 保留最近 64KB 用于会话关闭时的诊断。
211
+ h.stderr.on("data", (chunk) => {
212
+ this.errBuf = (this.errBuf + chunk.toString("utf8")).slice(-65536);
213
+ });
206
214
  const onEnd = () => {
207
215
  this.alive = false;
208
- if (this.current) { this.current.reject(new Error("ssh 会话已关闭")); this.current = null; }
209
- while (this.queue.length) this.queue.shift().reject(new Error("ssh 会话已关闭"));
216
+ const errMsg = "ssh 会话已关闭" + (this.errBuf.trim() ? ": " + this.errBuf.trim().slice(-300) : "");
217
+ if (this.current) { this.current.reject(new Error(errMsg)); this.current = null; }
218
+ while (this.queue.length) this.queue.shift().reject(new Error(errMsg));
210
219
  };
211
220
  h.stdout.on("end", onEnd);
212
221
  h.stdout.on("error", onEnd);
@@ -283,7 +292,7 @@ function sshArgv(p, remoteCmd, tty) {
283
292
  // 清除 ssh config 里的 RemoteForward/LocalForward,避免端口被占时连接直接失败。
284
293
  opts.push("-o", "ClearAllForwardings=yes");
285
294
  if (p.proxyJump) opts.push("-o", "ProxyJump=" + String(p.proxyJump));
286
- if (p.authMethod === "key" && p.keyPath) opts.push("-i", p.keyPath);
295
+ if (p.authMethod === "key" && p.keyPath) opts.push("-i", expandSshPath(p.keyPath));
287
296
  const target = String(p.user || "") + "@" + String(p.host || "");
288
297
  const head = (p.authMethod === "password" && p.password) ? ["sshpass", "-p", String(p.password)] : [];
289
298
  const argv = head.concat(opts, [target]);
@@ -388,7 +397,7 @@ async function remoteGrep(runner, p, pattern, path, opts) {
388
397
  const target = path || p.remoteRoot || "~";
389
398
  const parts = ["grep", "-rnIE"];
390
399
  if (opts.ignoreCase) parts.push("-i");
391
- if (opts.include) parts.push("--include=" + String(opts.include));
400
+ if (opts.include) parts.push("--include=" + shellQuote(String(opts.include)));
392
401
  parts.push("--", shellQuote(pattern), shellQuotePath(target));
393
402
  const cmd = parts.join(" ");
394
403
  const r = await runner(p, cmd, undefined, MAX_BYTES);
@@ -653,7 +662,8 @@ function apply(ctx, config) {
653
662
  const sessions = new Map(); // profileKey -> CommandSession
654
663
  const SESSION_IDLE_MS = 10 * 60 * 1000;
655
664
  function profileKey(p) {
656
- return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "");
665
+ // 认证信息变化时应重建会话:修改 keyPath/authMethod 后旧连接不能继续复用。
666
+ return String(p.id) + "|" + String(p.host) + ":" + String(p.port || 22) + "|" + String(p.user || "") + "|" + String(p.authMethod || "key") + "|" + String(p.keyPath || "");
657
667
  }
658
668
  function getSession(p) {
659
669
  const key = profileKey(p);
@@ -661,18 +671,40 @@ function apply(ctx, config) {
661
671
  if (!s) { s = new CommandSession(subprocess, p); sessions.set(key, s); }
662
672
  return s;
663
673
  }
664
- /** 池化执行:复用持久会话,失败时回退到一次性 runRemote。 */
674
+ /** SSH 常见失败翻译成带修复建议的中文提示。 */
675
+ function sshErrorHint(text) {
676
+ const t = String(text || "");
677
+ if (/remote port forwarding failed for listen port (\d+)/i.test(t)) {
678
+ const m = t.match(/remote port forwarding failed for listen port (\d+)/i);
679
+ return "SSH 端口转发失败(本地端口 " + m[1] + " 已被占用)。这通常由 ~/.ssh/config 里的 RemoteForward 造成:远端该端口被上次会话占用时,ExitOnForwardFailure yes 会让 ssh 直接退出。本插件已对该连接加 ClearAllForwardings,若仍出现请检查 ssh config。原始信息: " + t.trim().slice(0, 300);
680
+ }
681
+ if (/permission denied \(publickey/i.test(t)) return "公钥认证失败:请检查 keyPath 私钥路径是否正确、远端 ~/.ssh/authorized_keys 是否包含对应公钥。原始信息: " + t.trim().slice(0, 300);
682
+ if (/connection refused/i.test(t)) return "连接被拒绝:请确认远端 sshd 已启动且端口正确。原始信息: " + t.trim().slice(0, 300);
683
+ if (/connection timed out|timed out/i.test(t)) return "连接超时:请确认网络可达、端口开放,或检查 ProxyJump 配置。原始信息: " + t.trim().slice(0, 300);
684
+ if (/could not resolve hostname/i.test(t)) return "无法解析主机名:请检查连接配置中的 host 拼写。原始信息: " + t.trim().slice(0, 300);
685
+ return t.trim().slice(0, 500);
686
+ }
687
+
688
+ /** 池化执行:复用持久会话;连接层失败(exit 255)或会话异常时清理会话并经一次性连接重试。 */
665
689
  async function runPooled(p, cmd, stdinData, maxBytes) {
690
+ const dropSession = () => {
691
+ const key = profileKey(p);
692
+ const old = sessions.get(key);
693
+ if (old) { old.close(); sessions.delete(key); }
694
+ };
666
695
  try {
667
696
  const s = getSession(p);
668
697
  const r = await s.exec(cmd, stdinData);
669
- 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 };
698
+ if (r.exitCode === 0) return { ok: true, exitCode: 0, stdout: r.stdout, stderr: "", error: "", truncated: false };
699
+ // exit 255 = ssh 连接层失败(非远程命令失败):会话作废,下次调用重建。
700
+ if (r.exitCode === 255) dropSession();
701
+ return { ok: false, exitCode: r.exitCode, stdout: r.stdout, stderr: "", error: sshErrorHint(r.exitCode === 255 ? r.stdout : String(r.stdout).trim().slice(0, 500)), truncated: false };
670
702
  } catch (e) {
671
- // 会话挂了 —— 清理并回退到一次性连接
672
- const key = profileKey(p);
673
- const old = sessions.get(key);
674
- if (old) { old.close(); sessions.delete(key); }
675
- return runRemote(subprocess, p, cmd, stdinData, maxBytes);
703
+ // 会话挂了 —— 清理并回退到一次性连接(相当于自动重连一次)
704
+ dropSession();
705
+ const r2 = await runRemote(subprocess, p, cmd, stdinData, maxBytes);
706
+ if (!r2.ok && r2.exitCode === 255) r2.error = sshErrorHint(r2.stdout || r2.stderr || r2.error);
707
+ return r2;
676
708
  }
677
709
  }
678
710
  // 定期清理空闲会话
@@ -760,6 +792,18 @@ function apply(ctx, config) {
760
792
  return base.replace(/\/+$/, "") + "/" + String(path);
761
793
  }
762
794
 
795
+ /** 清理远程工作区的原生注册与本地镜像目录(删除工作区或级联删除 profile 时调用)。 */
796
+ async function cleanupWorkspace(ws) {
797
+ if (!ws || !ws.mirrorPath) return;
798
+ if (workspaceRegistry) {
799
+ try {
800
+ const entity = await workspaceRegistry.resolveByPath(ws.mirrorPath);
801
+ if (entity) await workspaceRegistry.delete(entity.id);
802
+ } catch (e) {}
803
+ }
804
+ try { await rm(ws.mirrorPath, { recursive: true, force: true }); } catch (e) {}
805
+ }
806
+
763
807
  const api = {
764
808
  listProfiles: async () => ({ ok: true, profiles: settingsFace.read().profiles }),
765
809
  /** 读取本机 ~/.ssh/config 发现的主机(借鉴 Yan-Zero dsh-remote-ssh)。 */
@@ -794,9 +838,12 @@ function apply(ctx, config) {
794
838
  deleteProfile: async (args) => {
795
839
  const id = args && args.id;
796
840
  const profiles = settingsFace.read().profiles.filter((p) => p.id !== id);
841
+ const orphaned = settingsFace.read().workspaces.filter((w) => w.profileId === id);
797
842
  const workspaces = settingsFace.read().workspaces.filter((w) => w.profileId !== id);
798
843
  await settingsFace.updateProfiles(profiles);
799
844
  await settingsFace.updateWorkspaces(workspaces);
845
+ // 级联清理:相关远程工作区的原生注册与本地镜像目录一并删除,不留孤儿。
846
+ for (const ws of orphaned) await cleanupWorkspace(ws);
800
847
  return { ok: true, profiles: profiles, workspaces: workspaces };
801
848
  },
802
849
  testConnection: async (args) => {
@@ -869,15 +916,7 @@ function apply(ctx, config) {
869
916
  const ws = all.find((w) => w.id === id);
870
917
  const workspaces = all.filter((w) => w.id !== id);
871
918
  await settingsFace.updateWorkspaces(workspaces);
872
- if (ws && ws.mirrorPath) {
873
- if (workspaceRegistry) {
874
- try {
875
- const entity = await workspaceRegistry.resolveByPath(ws.mirrorPath);
876
- if (entity) await workspaceRegistry.delete(entity.id);
877
- } catch (e) {}
878
- }
879
- try { await rm(ws.mirrorPath, { recursive: true, force: true }); } catch (e) {}
880
- }
919
+ await cleanupWorkspace(ws);
881
920
  return { ok: true, workspaces: workspaces };
882
921
  },
883
922
  updateWorkspace: async (args) => {
@@ -1458,7 +1497,9 @@ function apply(ctx, config) {
1458
1497
  if (rel.toLowerCase().startsWith(mc.toLowerCase())) {
1459
1498
  rel = rel.slice(mc.length);
1460
1499
  } else {
1461
- // 可能是相对于 cwd 的路径
1500
+ // 不在镜像目录下:剥掉可能的前导斜杠与盘符,按相对路径处理(此前会静默拼出错误路径)。
1501
+ rel = rel.replace(/^[A-Za-z]:/, "").replace(/^\/+/, "");
1502
+ try { ctx.logger?.debug("[dsh-remote-ssh] localToRemote: path outside mirror, treated as relative: " + localPath); } catch (e) {}
1462
1503
  }
1463
1504
  if (rel.startsWith("/")) rel = rel.slice(1);
1464
1505
  if (!rel) return remoteBase;
@@ -1612,7 +1653,7 @@ function apply(ctx, config) {
1612
1653
  remoteInfo = readRemoteInfoSync(payload.path);
1613
1654
  remoteBase = payload.path;
1614
1655
  }
1615
- console.log("[dsh-remote-ssh] intercept " + method + ": sessionId=" + sessionId + " cwd=" + sessionCwd + " path=" + (payload && payload.path) + " remote=" + (remoteInfo ? "YES" : "NO"));
1656
+ try { ctx.logger?.debug("[dsh-remote-ssh] intercept " + method + ": sessionId=" + sessionId + " cwd=" + sessionCwd + " path=" + (payload && payload.path) + " remote=" + (remoteInfo ? "YES" : "NO")); } catch (e) {}
1616
1657
  let result;
1617
1658
  if (remoteInfo && remoteInfo.keyPath && remoteInfo.host && remoteInfo.user) {
1618
1659
  // ---- 远程工作区:走 SSH ----
@@ -1692,6 +1733,286 @@ function apply(ctx, config) {
1692
1733
  return null;
1693
1734
  }
1694
1735
 
1736
+ // ---- P0: 远程工作区的 Git 面板重定向(git.* 走远端,本地照旧本机 git)----
1737
+ const GIT_METHODS = ["status", "diff", "log", "branch", "commit-diff", "show", "stage", "unstage", "commit", "checkout", "discard", "revert", "cherry-pick"];
1738
+
1739
+ /** 本机 git 执行(better-sidebar 原行为):失败抛 {code:'git-error',message}。 */
1740
+ async function runGitLocal(cwd, args) {
1741
+ let handle;
1742
+ try {
1743
+ handle = subprocess.spawn({
1744
+ argv: ["git", "-C", cwd, "--no-pager", "-c", "color.ui=false", ...args],
1745
+ cwd: process.cwd(),
1746
+ stdio: {
1747
+ stdin: "ignore",
1748
+ stdout: { maxBytes: 8 * 1024 * 1024, spill: { maxBytes: 8 * 1024 * 1024 } },
1749
+ stderr: { maxBytes: 512 * 1024, spill: { maxBytes: 512 * 1024 } }
1750
+ },
1751
+ graceMs: 30000
1752
+ });
1753
+ } catch (e) {
1754
+ throw { code: "git-error", message: "cannot run git: " + String(e && e.message ? e.message : e) };
1755
+ }
1756
+ const outcome = await handle.done.catch((e) => { throw { code: "git-error", message: String(e && e.message ? e.message : e) }; });
1757
+ const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
1758
+ const se = (handle.collected && handle.collected.stderr) ? handle.collected.stderr.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
1759
+ if (outcome.exitCode !== 0) throw { code: "git-error", message: String(se.text || "").trim() || ("git exited with " + outcome.exitCode) };
1760
+ return so.text;
1761
+ }
1762
+
1763
+ /** 远端 git 执行(远程工作区):在 remoteDir 里跑同一 git 语义。 */
1764
+ async function runGitRemote(profile, remoteDir, args) {
1765
+ const quoted = args.map((a) => shellQuote(a)).join(" ");
1766
+ const r = await runPooled(profile, "git -C " + shellQuotePath(remoteDir) + " --no-pager -c color.ui=false " + quoted, undefined, 8 * 1024 * 1024);
1767
+ if (!r.ok) throw { code: "git-error", message: String(r.stdout || r.error || "").trim() || "remote git exited with " + r.exitCode };
1768
+ return r.stdout;
1769
+ }
1770
+
1771
+ function parsePorcelainZ(output) {
1772
+ const tokens = String(output).split("\0");
1773
+ const entries = [];
1774
+ let index = 0;
1775
+ while (index < tokens.length) {
1776
+ const token = tokens[index];
1777
+ index += 1;
1778
+ if (token === "") continue;
1779
+ const xy = token.slice(0, 2);
1780
+ const rest = token.slice(3);
1781
+ entries.push({ path: rest, xy: xy });
1782
+ if ((xy[0] === "R" || xy[0] === "C") && tokens[index] !== undefined && tokens[index] !== "") index += 1;
1783
+ }
1784
+ return entries;
1785
+ }
1786
+
1787
+ function parseLogLines(output) {
1788
+ const rows = [];
1789
+ for (const line of String(output).split("\n")) {
1790
+ if (line === "") continue;
1791
+ const [hash, subject, author, date, hashFull, refs] = line.split("\x1f");
1792
+ if (hash === undefined || subject === undefined) continue;
1793
+ rows.push({ hash, subject, author: author ?? "", date: date ?? "", hashFull: hashFull ?? hash, refs: refs ?? "" });
1794
+ }
1795
+ return rows;
1796
+ }
1797
+
1798
+ /** git.status 的完整实现(isRepo 检测 + 分支 + porcelain 解析),本地/远端共用。 */
1799
+ async function gitStatusImpl(run, dir) {
1800
+ let inside = "false";
1801
+ try { inside = String((await run(dir, ["rev-parse", "--is-inside-work-tree"])).trim()); } catch (e) { inside = "false"; }
1802
+ if (inside !== "true") return { isRepo: false, entries: [] };
1803
+ let branch = "HEAD";
1804
+ try { branch = String((await run(dir, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()) || "HEAD"; } catch (e) {}
1805
+ let raw = "";
1806
+ try {
1807
+ raw = await run(dir, ["status", "--porcelain=v1", "-z", "--untracked-files=normal"]);
1808
+ } catch (e) {
1809
+ return { isRepo: true, branch: branch, entries: [] };
1810
+ }
1811
+ return { isRepo: true, branch: branch, entries: parsePorcelainZ(raw) };
1812
+ }
1813
+
1814
+ /** 解析一次 /sidebar/api/git.* 请求的上下文:远程工作区 → {profile, remoteDir},否则 null。 */
1815
+ function gitContextOf(payload) {
1816
+ const sessionId = payload && payload.sessionId;
1817
+ const clientCwd = payload && payload.cwd;
1818
+ let cwd = clientCwd;
1819
+ if ((!cwd || cwd === "") && sessionId) {
1820
+ const sessions = ctx.get("sessions");
1821
+ const session = sessions ? sessions.get(sessionId) : null;
1822
+ cwd = session && session.header && session.header.cwd;
1823
+ }
1824
+ if (!cwd) return null;
1825
+ const ws = matchRemoteWorkspace(cwd, cwd);
1826
+ if (!ws) return null;
1827
+ const remoteInfo = readRemoteInfoSync(ws.mirrorPath);
1828
+ const profile = remoteInfo && getProfile(remoteInfo.profileId);
1829
+ if (!remoteInfo || !profile) return null;
1830
+ const remoteDir = localToRemote(cwd, ws.mirrorPath, remoteInfo.remotePath);
1831
+ return { profile: profile, remoteDir: remoteDir, cwd: cwd };
1832
+ }
1833
+
1834
+ async function interceptGitHandler(req, res, method) {
1835
+ if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
1836
+ if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
1837
+ let payload;
1838
+ try { payload = await readJsonBody(req); }
1839
+ catch (e) { writeJson(res, 400, { ok: false, error: { code: "bad-request", message: String(e && e.message ? e.message : e) } }); return; }
1840
+ try {
1841
+ const remote = gitContextOf(payload);
1842
+ // 本地会话没有 session/cwd 时无法判定工作目录 → 按本机 git 于 process.cwd() 处理。
1843
+ const cwd = remote ? remote.remoteDir : (payload && payload.cwd) || process.cwd();
1844
+ const run = remote
1845
+ ? (dir, args) => runGitRemote(remote.profile, remote.remoteDir, args)
1846
+ : (dir, args) => runGitLocal(cwd, args);
1847
+ let value;
1848
+ switch (method) {
1849
+ case "status":
1850
+ value = await gitStatusImpl(run, cwd);
1851
+ break;
1852
+ case "diff": {
1853
+ const p = payload;
1854
+ const path = p.path !== undefined ? String(p.path) : undefined;
1855
+ const staged = p.staged === true;
1856
+ const args = ["diff", "--no-ext-diff", "--no-color", "-U3"];
1857
+ if (staged) args.push("--cached");
1858
+ if (path !== undefined && path !== "") args.push("--", path);
1859
+ value = { diff: await run(cwd, args) };
1860
+ break;
1861
+ }
1862
+ case "log": {
1863
+ const p = payload;
1864
+ const count = typeof p.count === "number" && Number.isInteger(p.count) && p.count > 0 ? p.count : 30;
1865
+ const skip = typeof p.skip === "number" && Number.isInteger(p.skip) && p.skip >= 0 ? p.skip : 0;
1866
+ value = parseLogLines(await run(cwd, ["log", "-n", String(count), "--skip", String(skip), "--decorate=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ai%x1f%H%x1f%D"]));
1867
+ break;
1868
+ }
1869
+ case "branch": {
1870
+ let current = "HEAD";
1871
+ try { current = String((await run(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])).trim()) || "HEAD"; } catch (e) {}
1872
+ const raw = await run(cwd, ["for-each-ref", "--format=%(refname:short)", "refs/heads"]);
1873
+ const names = String(raw).split("\n").filter((l) => l !== "");
1874
+ value = { current: current, names: names.includes(current) ? names : [current, ...names] };
1875
+ break;
1876
+ }
1877
+ case "commit-diff": {
1878
+ const hash = String(payload && payload.hash || "").trim();
1879
+ if (!hash) throw { code: "bad-request", message: "hash is required" };
1880
+ value = { diff: await run(cwd, ["show", "--no-ext-diff", "--no-color", "--format=", "-m", "--first-parent", hash]) };
1881
+ break;
1882
+ }
1883
+ case "show": {
1884
+ const p = payload;
1885
+ const rev = String(p && p.rev || "").trim();
1886
+ const path = String(p && p.path || "").trim();
1887
+ if (!rev || !path) throw { code: "bad-request", message: "rev and path are required" };
1888
+ let content = null;
1889
+ try { content = await run(cwd, ["show", rev + ":" + path]); } catch (e) { content = null; }
1890
+ value = { content: content };
1891
+ break;
1892
+ }
1893
+ case "stage": {
1894
+ const path = payload && payload.path !== undefined ? String(payload.path) : undefined;
1895
+ await run(cwd, ["add", "-A", ...(path !== undefined && path !== "" ? ["--", path] : [])]);
1896
+ value = { ok: true };
1897
+ break;
1898
+ }
1899
+ case "unstage": {
1900
+ const path = payload && payload.path !== undefined ? String(payload.path) : undefined;
1901
+ await run(cwd, ["reset", "-q", ...(path !== undefined && path !== "" ? ["--", path] : [])]);
1902
+ value = { ok: true };
1903
+ break;
1904
+ }
1905
+ case "commit": {
1906
+ const message = String(payload && payload.message || "").trim();
1907
+ if (!message) throw { code: "bad-request", message: "message is required" };
1908
+ await run(cwd, ["commit", "-m", message]);
1909
+ value = { ok: true };
1910
+ break;
1911
+ }
1912
+ case "checkout": {
1913
+ const branch = String(payload && payload.branch || "").trim();
1914
+ if (!branch) throw { code: "bad-request", message: "branch is required" };
1915
+ await run(cwd, ["checkout", branch]);
1916
+ value = { ok: true };
1917
+ break;
1918
+ }
1919
+ case "discard": {
1920
+ const path = String(payload && payload.path || "").trim();
1921
+ if (!path) throw { code: "bad-request", message: "path is required" };
1922
+ await run(cwd, ["checkout", "--", path]);
1923
+ value = { ok: true };
1924
+ break;
1925
+ }
1926
+ case "revert": {
1927
+ const hash = String(payload && payload.hash || "").trim();
1928
+ if (!hash) throw { code: "bad-request", message: "hash is required" };
1929
+ await run(cwd, ["revert", "--no-edit", hash]);
1930
+ value = { ok: true };
1931
+ break;
1932
+ }
1933
+ case "cherry-pick": {
1934
+ const hash = String(payload && payload.hash || "").trim();
1935
+ if (!hash) throw { code: "bad-request", message: "hash is required" };
1936
+ await run(cwd, ["cherry-pick", hash]);
1937
+ value = { ok: true };
1938
+ break;
1939
+ }
1940
+ default:
1941
+ writeJson(res, 404, { ok: false, error: { code: "not-found", message: "unknown git method " + method } }); return;
1942
+ }
1943
+ if (value === undefined) value = { ok: true };
1944
+ writeOk(res, value);
1945
+ } catch (e) {
1946
+ const code = (e && e.code) || "git-error";
1947
+ const message = (e && e.message) || String(e);
1948
+ writeJson(res, code === "bad-request" ? 400 : 500, { ok: false, error: { code: code, message: message } });
1949
+ }
1950
+ }
1951
+
1952
+ GIT_METHODS.forEach(function (m) {
1953
+ ctx.effect(() => ctx.webServer.register({
1954
+ kind: "exact",
1955
+ path: "/sidebar/api/git." + m,
1956
+ handler: function (req, res) { return interceptGitHandler(req, res, m); }
1957
+ }), "dsh-remote-ssh: intercept /sidebar/api/git." + m);
1958
+ });
1959
+
1960
+ /** 等待可写流 drain(带回退:已销毁则即时返回)。 */
1961
+ function awaitOnce(emitter, event) {
1962
+ return new Promise((resolve, reject) => {
1963
+ emitter.once(event, () => resolve());
1964
+ emitter.once("error", reject);
1965
+ });
1966
+ }
1967
+
1968
+ /** 流式写入:把请求体直接管道到远端 `cat > target`(二进制安全、恒定内存)。 */
1969
+ async function streamRemoteUpload(profile, target, req, limit) {
1970
+ let handle;
1971
+ try {
1972
+ handle = subprocess.spawn({
1973
+ argv: sshArgv(profile, "cat > " + shellQuotePath(target), false),
1974
+ cwd: process.cwd(),
1975
+ stdio: {
1976
+ stdin: "pipe",
1977
+ stdout: { maxBytes: 64 * 1024, spill: { maxBytes: 64 * 1024 } },
1978
+ stderr: { maxBytes: 64 * 1024, spill: { maxBytes: 64 * 1024 } }
1979
+ },
1980
+ graceMs: 10000
1981
+ });
1982
+ } catch (e) {
1983
+ return { ok: false, code: "spawn-failed", message: "spawn 失败: " + String(e && e.message ? e.message : e), size: 0 };
1984
+ }
1985
+ let size = 0;
1986
+ let aborted = false;
1987
+ const onAbort = () => { aborted = true; try { handle.terminate(); } catch (e) {} };
1988
+ try {
1989
+ req.on("aborted", onAbort);
1990
+ req.on("close", () => { if (!req.complete) onAbort(); });
1991
+ for await (const chunk of req) {
1992
+ if (aborted) break;
1993
+ const b = Buffer.from(chunk);
1994
+ size += b.length;
1995
+ if (size > limit) { handle.terminate(); return { ok: false, code: "too-large", message: "upload exceeds the remote upload limit (" + limit + " bytes)", size: size }; }
1996
+ if (handle.stdin.destroyed) return { ok: false, code: "pipe-closed", message: "ssh stdin closed during upload", size: size };
1997
+ if (!handle.stdin.write(b)) await awaitOnce(handle.stdin, "drain");
1998
+ }
1999
+ if (aborted) return { ok: false, code: "aborted", message: "upload aborted", size: size };
2000
+ handle.stdin.end();
2001
+ const outcome = await handle.done;
2002
+ const so = (handle.collected && handle.collected.stdout) ? handle.collected.stdout.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
2003
+ const se = (handle.collected && handle.collected.stderr) ? handle.collected.stderr.readFrom(0) : { text: "", nextOffset: 0, lossy: false };
2004
+ if (outcome.exitCode !== 0) {
2005
+ return { ok: false, code: "fs-error", message: String(se.text || "").trim() || ("ssh 退出码 " + outcome.exitCode), size: size };
2006
+ }
2007
+ return { ok: true, size: size, stdout: so.text, stderr: se.text };
2008
+ } catch (e) {
2009
+ try { handle.terminate(); } catch (e2) {}
2010
+ return { ok: false, code: "fs-error", message: String(e && e.message ? e.message : e), size: size };
2011
+ } finally {
2012
+ req.removeListener("aborted", onAbort);
2013
+ }
2014
+ }
2015
+
1695
2016
  async function handleRemoteUpload(req, res) {
1696
2017
  if (!isTrusted(req)) { writeJson(res, 403, { ok: false, error: { code: "forbidden", message: "forbidden" } }); return; }
1697
2018
  if (req.method !== "POST") { writeJson(res, 405, { ok: false, error: { code: "method-error", message: "method not allowed" } }); return; }
@@ -1712,25 +2033,19 @@ function apply(ctx, config) {
1712
2033
  const remoteDir = localToRemote(dir, ws.mirrorPath, remoteInfo.remotePath);
1713
2034
  const target = remoteDir.replace(/\/+$/, "") + (rel ? "/" + rel : "");
1714
2035
  const parent = target.slice(0, Math.max(target.lastIndexOf("/"), 0));
1715
- // 读取原始字节流(File/Blob 直传,二进制安全)
1716
- const chunks = [];
1717
- let size = 0;
1718
- for await (const chunk of req) {
1719
- const b = Buffer.from(chunk);
1720
- size += b.length;
1721
- if (size > REMOTE_UPLOAD_LIMIT) { writeJson(res, 413, { ok: false, error: { code: "too-large", message: "upload exceeds the remote upload limit (" + REMOTE_UPLOAD_LIMIT + " bytes)" } }); return; }
1722
- chunks.push(b);
1723
- }
1724
- const payloadBase64 = Buffer.concat(chunks).toString("base64");
1725
2036
  // 远端建父目录
1726
2037
  if (parent && parent !== remoteDir.replace(/\/+$/, "")) {
1727
2038
  const mk = await runPooled(profile, "mkdir -p " + shellQuotePath(parent), undefined, 65536);
1728
2039
  if (!mk.ok) { writeJson(res, 500, { ok: false, error: { code: "fs-error", message: String(mk.error || mk.stdout || "mkdir failed").trim() } }); return; }
1729
2040
  }
1730
- // 二进制安全落地:base64 -d 还原后写入目标文件
1731
- const w = await runPooled(profile, "base64 -d > " + shellQuotePath(target), payloadBase64, REMOTE_UPLOAD_LIMIT);
1732
- if (!w.ok) { writeJson(res, 500, { ok: false, error: { code: "fs-error", message: String(w.error || w.stdout || "remote write failed").trim() } }); return; }
1733
- writeOk(res, { path: dir.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel, size: size });
2041
+ // 流式上传(cat > target 直接吃 stdin,无 base64、无内存膨胀)
2042
+ const r = await streamRemoteUpload(profile, target, req, REMOTE_UPLOAD_LIMIT);
2043
+ if (!r.ok) {
2044
+ const status = r.code === "too-large" ? 413 : (r.code === "bad-request" ? 400 : 500);
2045
+ writeJson(res, status, { ok: false, error: { code: r.code, message: r.message } });
2046
+ return;
2047
+ }
2048
+ writeOk(res, { path: dir.replace(/\\/g, "/").replace(/\/+$/, "") + "/" + rel, size: r.size });
1734
2049
  } catch (e) {
1735
2050
  writeJson(res, 400, { ok: false, error: { code: "fs-error", message: String(e && e.message ? e.message : e) } });
1736
2051
  }
@@ -1742,6 +2057,105 @@ function apply(ctx, config) {
1742
2057
  handler: function (req, res) { return handleRemoteUpload(req, res); }
1743
2058
  }), "dsh-remote-ssh: /remote-ssh/upload route");
1744
2059
 
2060
+ // ---- P0: 远程文件下载 / 媒体预览(拦截 better-sidebar 的 /sidebar/file)----
2061
+ // better-sidebar 注册的是 prefix 路由,这里注册同路径 exact 路由优先匹配。
2062
+ const MEDIA_TYPES = {
2063
+ ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
2064
+ ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon",
2065
+ ".avif": "image/avif", ".pdf": "application/pdf", ".html": "text/html; charset=utf-8", ".htm": "text/html; charset=utf-8"
2066
+ };
2067
+ const REMOTE_DOWNLOAD_LIMIT = 64 * 1024 * 1024; // 远端内容走 base64 传输,设一个合理上限
2068
+
2069
+ function mediaTypeForPath(path) {
2070
+ const ext = String(path || "").toLowerCase();
2071
+ const dot = ext.lastIndexOf(".");
2072
+ const key = dot >= 0 ? ext.slice(dot) : "";
2073
+ return MEDIA_TYPES[key] || "application/octet-stream";
2074
+ }
2075
+
2076
+ /** 二进制安全地取回远端文件字节(base64 走 stdout,长度上限 maxBytes)。 */
2077
+ async function remoteFetchBytes(profile, remotePath, maxBytes) {
2078
+ // 先拿大小(stat),超限直接拒绝;base64 会膨胀 4/3,给 stdout 相应上限。
2079
+ const st = await runPooled(profile, "stat -c%s " + shellQuotePath(remotePath), undefined, 65536);
2080
+ if (!st.ok) return { ok: false, error: String(st.stdout || st.error || "stat failed").trim() };
2081
+ const size = parseInt(String(st.stdout).trim(), 10);
2082
+ if (isNaN(size)) return { ok: false, error: "cannot stat remote file size" };
2083
+ if (size > maxBytes) return { ok: false, error: "file too large (" + size + " bytes; limit " + maxBytes + ")" };
2084
+ const cap = Math.ceil(size * 1.5) + 4096;
2085
+ const r = await runPooled(profile, "base64 -w0 " + shellQuotePath(remotePath), undefined, cap);
2086
+ if (!r.ok) return { ok: false, error: String(r.stdout || r.error || "read failed").trim().slice(0, 500) };
2087
+ try {
2088
+ const buf = Buffer.from(String(r.stdout).replace(/\s+/g, ""), "base64");
2089
+ return { ok: true, buffer: buf };
2090
+ } catch (e) {
2091
+ return { ok: false, error: "decode failed: " + String(e) };
2092
+ }
2093
+ }
2094
+
2095
+ async function handleSidebarFile(req, res) {
2096
+ if (!isTrusted(req)) { res.writeHead(403); res.end("forbidden"); return; }
2097
+ if (req.method !== "GET") { res.writeHead(405); res.end(); return; }
2098
+ try {
2099
+ const url = new URL(req.url || "/", "http://dsh.internal");
2100
+ const sessionId = url.searchParams.get("sessionId");
2101
+ const rawPath = url.searchParams.get("path");
2102
+ const cwdParam = url.searchParams.get("cwd") || "";
2103
+ const download = url.searchParams.get("download") === "1";
2104
+ if (!sessionId || !rawPath) { res.writeHead(400); res.end("sessionId and path are required"); return; }
2105
+ const ws = matchRemoteWorkspace(rawPath, cwdParam);
2106
+ if (ws) {
2107
+ // ---- 远程工作区:SSH 拉取远程文件 ----
2108
+ const remoteInfo = readRemoteInfoSync(ws.mirrorPath);
2109
+ const profile = remoteInfo && getProfile(remoteInfo.profileId);
2110
+ if (!remoteInfo || !profile) {
2111
+ writeJson(res, 500, { ok: false, error: { code: "internal", message: "remote workspace profile not found" } });
2112
+ return;
2113
+ }
2114
+ const remotePath = localToRemote(rawPath, ws.mirrorPath, remoteInfo.remotePath);
2115
+ const r = await remoteFetchBytes(profile, remotePath, REMOTE_DOWNLOAD_LIMIT);
2116
+ if (!r.ok) {
2117
+ writeJson(res, 400, { ok: false, error: { code: "fs-error", message: r.error || "read failed" } });
2118
+ return;
2119
+ }
2120
+ const body = r.buffer;
2121
+ const headers = {
2122
+ "content-type": mediaTypeForPath(remotePath),
2123
+ "cache-control": "no-cache"
2124
+ };
2125
+ if (download) headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(String(remotePath).split("/").pop() || "download")}`;
2126
+ res.writeHead(200, headers);
2127
+ res.end(body);
2128
+ return;
2129
+ }
2130
+ // ---- 本地工作区:沿用 better-sidebar 原行为(读本地文件)----
2131
+ let cwd = cwdParam;
2132
+ if (!cwd && sessionId) {
2133
+ const sessions = ctx.get("sessions");
2134
+ const session = sessions ? sessions.get(sessionId) : null;
2135
+ cwd = session && session.header && session.header.cwd;
2136
+ }
2137
+ const info = await stat(rawPath).catch((e) => { throw new Error("cannot read: " + String(e && e.message ? e.message : e)); });
2138
+ if (!info.isFile() || info.size > 20 * 1024 * 1024) { res.writeHead(400); res.end("not a file or too large"); return; }
2139
+ const body = await readFile(rawPath);
2140
+ const headers = {
2141
+ "content-type": mediaTypeForPath(rawPath),
2142
+ "cache-control": "no-cache"
2143
+ };
2144
+ if (download) headers["content-disposition"] = `attachment; filename*=UTF-8''${encodeURIComponent(rawPath.split(/[\\/]/).pop() || "download")}`;
2145
+ res.writeHead(200, headers);
2146
+ res.end(body);
2147
+ } catch (e) {
2148
+ res.writeHead(400);
2149
+ res.end(String(e && e.message ? e.message : e));
2150
+ }
2151
+ }
2152
+
2153
+ ctx.effect(() => ctx.webServer.register({
2154
+ kind: "exact",
2155
+ path: "/sidebar/file",
2156
+ handler: function (req, res) { return handleSidebarFile(req, res); }
2157
+ }), "dsh-remote-ssh: intercept /sidebar/file");
2158
+
1745
2159
  // ---- 清理 ----
1746
2160
  ctx.effect(() => () => {
1747
2161
  clearInterval(idleTimer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.1.5",
3
+ "version": "2.1.7",
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",
@@ -40,6 +40,7 @@
40
40
  },
41
41
  "client": {
42
42
  "inject": [
43
+ "@deepseek-ai/dsh-client-modules",
43
44
  "@deepseek-ai/dsh-client-runtime",
44
45
  "@deepseek-ai/dsh-client-locale",
45
46
  "@deepseek-ai/dsh-client-ui-slots",
@@ -66,6 +67,7 @@
66
67
  "lib/client.js",
67
68
  "cordis.patch.yml",
68
69
  "README.md",
70
+ "README_EN.md",
69
71
  "LICENSE",
70
72
  "CHANGELOG.md"
71
73
  ],