@zhangfengshun/dsh-remote-ssh 2.4.10 → 2.4.11

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,27 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.11] — `fs.search` 返回契约对齐 better-sidebar:补上 `matches`(issue #13)
6
+ ### 修复
7
+ - **「文件」页签的「按文件名搜索」不再崩掉整块页签**(感谢 @Linhaojing 的契约考古与最小复现):better-sidebar 客户端契约是 `{ matches: string[], truncated }`(其注释原文:*matches are cwd-relative '/'-separated paths*,自 v0.13.0 引入搜索框起未变),并在渲染阶段直接读 `results.matches.length` / `results.matches.map(rel => …)`;而本插件的 `fs.search` 拦截只返回 `{ entries: [{path,isDir}], truncated }`,于是 `undefined.length` 抛 TypeError 被 `RenderBoundary` 兜住 → 整个页签变成错误条、搜索框与文件树一起消失。因为拦截是**无条件**的(不区分远程/本地会话),**本地工作区同样复现**,与 better-sidebar 版本无关。
8
+ - 两条分支统一经 `fsSearchResult()` 产出:`matches` **必定存在**且为 **cwd 相对、`/` 分隔**的字符串数组(与 better-sidebar 自带 `searchFiles` 的行为一致,结果行显示相对路径),`entries`(绝对路径 + `isDir`)保留作向前兼容;
9
+ - 本地分支:`matches` 由 `toPosixRelative(sessionCwd, entry.path)` 从绝对路径裁剪而来(Windows 盘符路径大小写不敏感、POSIX 保持大小写敏感、前缀相近的非子路径不误裁、非 ASCII 安全);
10
+ - **附带修复远程分支的路径 bug**:`remoteGlob` 的 `files` 是 `find` 以命令行起点拼出的**远端绝对路径**,此前却用 `join(mirrorBase, f)` 拼成了畸形镜像路径(如 `<镜像根>/home/user/proj/src/a.jl`),远程搜索结果的路径本来就是错的;现在 `remoteGlob` 新增 `opts.relative`(`-printf '%P\n'`)取相对路径,`entries` 再据此拼镜像绝对路径(`remote_ssh_glob` 工具仍走默认的 `%p`,行为不变)。
11
+ - README 兼容性表中原先笼统的 `fs.tree/read/write/search(4 端点契约)` 一行,已改为显式写明 `fs.search` 的 `{ matches, truncated }` 契约。
12
+ - **远程工作区的搜索不再一直「加载中…」**(同 issue 的实测跟进:崩溃修好后,在真实 OpenFOAM 工作区里搜索变成永久 loading)。旧命令有两个致命点:① `… | sort | head -n 500` 里的 `sort` 会缓冲**全部** `find` 输出后才吐第一行,`head` 的提前短路完全失效;② 没有深度/时间约束,而 `find` 是深度优先——巨型子目录(`processor*` / 数据目录)会吃光预算,连顶层文件都轮不到。实测该工作区:**旧命令 5 分钟零输出**(还占住池化 SSH 会话,把文件树/读写一起拖住)。现在:
13
+ - **浅层优先 + 有结果即返回**:第一趟 `-maxdepth 3`(3s 预算,实测冷 0.68s / 热 0.11s)——**只要有命中就立刻返回**,深挖转**后台预热缓存**(同一 query 的后续请求会拿到更全的结果);只有浅层**一无所获**时才同步等深挖趟(`-maxdepth 8`,5s 预算,实测会吃满)。此前条件是「浅层命中 < 200 就同步深挖」,而文件名片段极少命中 200 个 → 几乎每次查询都要多等一趟 ≤5s 的深挖(用户实测反馈「有点慢」),现已消除;两趟结果合并去重后本地排序(不再需要远端 `sort`)。实测同一工作区:**浅层 0.96s 返回 43 条命中**(旧命令 5 分钟 0 条);
14
+ - **遍历前剪噪声目录**:`REMOTE_SEARCH_SKIP_DIRS` = 本插件索引排除表 ∪ 上游 `SEARCH_SKIP_DIRS`(`.git` / `node_modules` / `dist` / `.next` / `.pnpm-store` / `.turbo` / …);
15
+ - **远端墙钟预算**:`timeout`(macOS 回退 `gtimeout`,都没有则退回无预算版本由 SSH 层兜底)到点杀掉 `find`,并把**已收集到的部分结果**照常返回 + `truncated: true`——宁可给部分结果,也不让 UI 无限转圈;
16
+ - **命中上限 200**(与上游 `DEFAULT_MAX_MATCHES` 一致)、**SSH 层 15s 超时**(不再占用池化会话 120s)、**同 query 30s 短缓存 + 并发合并**(连打键盘不会堆起一串 `find`),缓存挂 `cacheEpoch`(写/exec 后自动失效)。
17
+ - **`@文件名` 在某些远程项目里完全没有候选**(用户实测反馈:同一台机器上 DFS-Dev 正常、另一个项目全空;而单独输入 `@` 正常——因为 `@` 走目录列举、模糊查询走索引)。根因:索引在 git 仓库里首选 `git ls-files --cached --others --exclude-standard`,而 `--others` 需要**遍历整棵工作树**枚举未跟踪文件,在巨型项目上根本跑不完(实测该目录 **10s 被超时杀掉、零输出**;`git ls-files --cached` 反而只要 0.085s 且为 0 条——仓库里没有任何已跟踪文件)→ 20s 构建预算内拿不到条目 → 索引为空。现在三级降级,每级都有墙钟预算:
18
+ - ① 完整 git(6s)→ ② 仅索引 git(3s,恒定快)→ ③ 有界 `find`(`maxdepth 3` + 5s;原为 `maxdepth 5` 且无预算,实测该目录 5 层遍历 >180s 都跑不完);
19
+ - 实测同一项目:新命令 **921 条 / 6.68s**(其中 916 条含 `kOmega`、`node_modules` 零残留),三级合计 ≤14s < 20s 构建预算;
20
+ - **另外加了即时兜底**:模糊查询在索引尚未就绪(大仓库要 6.7s 才建好,而单次查询只等 900ms)时,**不再直接返回空列表**,而是用与侧栏搜索同一套有界 find(剪噪声 + 无 `sort` + `maxdepth 3` + 5s 预算,实测 **0.65s**)立刻给出候选;索引建好后自动切换到更全的索引结果。
21
+
22
+ ### 测试
23
+ - 新增 `tests/fs-search-contract.test.mjs`(**34 条断言**):`toPosixRelative`(POSIX / 尾部斜杠 / Windows 反斜杠 / 盘符大小写不敏感 / POSIX 大小写敏感 / 非子路径不误裁 / 空 base / null / 非 ASCII)、`fsSearchResult` 形状(`matches` 恒为字符串数组——**原崩溃点**、缺参数时仍为空数组而非 undefined、过滤空串与非字符串、`truncated` 归一化)、**契约模拟**(客户端 `.length` / `.map(rel)` 渲染相对路径 / 点击解析回 `entries.path`)、接线断言(两条分支、`remoteGlob` 的 `relative` 选项与 `%p` 默认值、不再有只回 `entries` 的旧分支)与 README 契约描述。
24
+ - 新增 `tests/remote-search.test.mjs`(**36 条断言**):两趟命令生成(`-maxdepth 3/8`、3s/5s 预算、无 `sort`、`%P`、`head 200`、`-prune`、`timeout`/`gtimeout` 探测与回退)、**延迟策略**(浅层有结果立即返回、后台预热写回缓存、浅层零命中才同步深挖、浅层满额不再深挖、预算到点返回部分结果 + `truncated`)、硬失败不深挖且不缓存、缓存命中与 `cacheEpoch` 失效、三个并发同 query 只跑一趟遍历、以及 `fs.search` 分支的接线。
25
+
5
26
  ## [2.4.10] — 非 loopback 访问不再全线 403:信任判定改用宿主 webRuntime.trustedHosts(issue #12)
6
27
  ### 修复
7
28
  - **信任判定与 `/api` 网关、better-sidebar 同源**(感谢 @Linhaojing 的验证矩阵与自我更正后的方案):此前 `isTrusted()` 硬编码 loopback 白名单(`localhost` / `127.0.0.1` / `::1` / `0.0.0.0`),从局域网(例如经配对设备或反向代理访问)打开 GUI 时,本插件的 **5 类路由全部 403** —— `/remote-ssh/api/*` 全部 RPC、6 个被拦截的 `fs.*`、`git.*`、文件上传、`/sidebar/file`,也就是文件树 / 读写 / 搜索 / 重命名 / 删除 / git / 上传全线失效,插件几乎等于没装。而同一 Host 下 DSH 官方路由正常,说明是本插件单方面比宿主更严。
package/README.md CHANGED
@@ -46,7 +46,7 @@
46
46
  **一条命令安装**(无需 token、API Key 或额外配置):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.10
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.11
50
50
  ```
51
51
 
52
52
  安装后**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -183,7 +183,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
183
183
  | --- | --- | --- |
184
184
  | DSH | 0.1.5-rc.1(DSH Desktop v2.0.9) | ✅ 主机服务 / settings / tools / slot / 上传下载拦截全部咬合 |
185
185
  | DSH | 0.1.2-rc.1 稳定线 | ✅(插件 2.3.x 时代基线) |
186
- | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree/read/write/search`(4 端点契约) |
186
+ | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree`/`fs.read`/`fs.write` + `fs.search`(`{ matches: cwd 相对 '/'-分隔路径, truncated }` 契约,2.4.11 起;此前只回 `entries` 会让「按文件名搜索」崩掉整块页签) |
187
187
  | dsh-better-sidebar | 0.19.x | ⚠️ 插件侧已适配 6 端点(含 `fs.rename`/`fs.remove`);但 0.19.0/0.19.1 自身在 DSH Desktop 上主机半边无法加载,需等上游修复(见[安装](#安装)的警告) |
188
188
  | 远程主机 sshd | 标准 OpenSSH(Linux / 超算 / Windows) | ✅ 密钥认证;密码认证需本机 `sshpass`(POSIX) |
189
189
 
@@ -203,6 +203,9 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
203
203
  | 大仓里 `@` 搜不到真实文件(如根目录 `AGENTS.md`、`src/**`) | 2.4.8 起已修复:此前排除目录发生在截断之后,`node_modules/` 这类目录会吃光索引配额;现在排除由远端 `grep`/`-prune` 在截断前完成,并会在索引达上限时打 warn 提示 |
204
204
  | 想加的远程目录还不存在,「添加工作区」里没法创建 | 2.4.9 起「目录选择器」底部有「📁 新建目录」(本地 / 远程 tab 均有):输入名字即可就地创建并自动进入 |
205
205
  | 从局域网 / 另一台设备访问时插件文件能力全部报 403 | 2.4.10 起已修复:信任判定改用宿主 `ctx.webRuntime.trustedHosts`(与 `/api` 网关同源)。把访问地址加进 DSH 信任列表即可:启动时加 `--trusted-host <host[:port]>`(或经配对设备访问);未配置时行为与之前一致(仅本机 loopback) |
206
+ | 「文件」页签的「按文件名搜索」一输入就报 `Cannot read properties of undefined (reading 'length')` | 2.4.11 起已修复:`fs.search` 拦截此前只返回 `entries`,而 better-sidebar 客户端契约是 `{ matches, truncated }`;现在补上 `matches`(cwd 相对、`/` 分隔,与上游自带实现一致)并保留 `entries` |
207
+ | 远程工作区里「按文件名搜索」一直转圈(大工作区) | 2.4.11 起已修复:改为浅层优先(`-maxdepth 3`,实测冷 0.68s / 热 0.11s)且**有命中就立即返回**(深挖转后台预热缓存,浅层零命中才同步等深挖 `-maxdepth 8`),遍历前剪噪声目录、去掉会阻塞短路的 `sort`,并加远端墙钟预算——到点返回**已收集的部分结果**并标记不完整。实测某 OpenFOAM 工作区:旧实现 5 分钟零输出 → 现在 **0.96s 返回 43 条** |
208
+ | 远程会话里 `@文件名` 没有候选,但单独输入 `@` 有 | 2.4.11 起已修复:模糊查询依赖索引,而索引首选 `git ls-files --cached --others`(`--others` 要遍历整棵工作树,巨型项目上跑不完 → 索引为空)。现在三级降级(完整 git 6s → 仅索引 git 3s → 有界 `find` `maxdepth 3` + 5s),并在索引未就绪时用有界 find 即时兜底(实测 0.65s),不再出现「全空」 |
206
209
  | 安装时提示 `minimumReleaseAge` 或「No matching version」(刚发布) | npm 供应链新鲜度策略,等 1–5 分钟后重试即可 |
207
210
  | 命令卡住不返回 | 默认 120s 超时后自动丢弃会话;长时任务用 `timeoutMs: 0`,随时可用 `remote_ssh_kill` 强杀 |
208
211
  | 大文件读取被截断 | 单文件读取上限 4MB、下载池化路径约 6.29MB(更大自动回落一次性连接);用 `remote_ssh_exec` + `head`/`tail` 分段处理 |
package/README_EN.md CHANGED
@@ -46,7 +46,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
46
46
  **One command** (no token, API key or extra configuration needed):
47
47
 
48
48
  ```bash
49
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.10
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.11
50
50
  ```
51
51
 
52
52
  **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -183,7 +183,7 @@ All SSH commands default to a **120-second** timeout (issue #5): a hung remote c
183
183
  | --- | --- | --- |
184
184
  | DSH | 0.1.5-rc.1 (DSH Desktop v2.0.9) | ✅ host services / settings / tools / slots / upload & download interception all compatible |
185
185
  | DSH | 0.1.2-rc.1 stable line | ✅ (the 2.3.x-era baseline) |
186
- | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree/read/write/search` (4-endpoint contract) |
186
+ | dsh-better-sidebar | 0.15.0 – 0.18.0 | ✅ `fs.tree`/`fs.read`/`fs.write` + `fs.search` (the `{ matches: cwd-relative '/'-separated paths, truncated }` contract, since 2.4.11; returning only `entries` used to crash the whole Files tab on search) |
187
187
  | dsh-better-sidebar | 0.19.x | ⚠️ this plugin already supports the 6-endpoint contract (incl. `fs.rename`/`fs.remove`); 0.19.0/0.19.1 themselves cannot load their host half on DSH Desktop until upstream fixes it (see the warning under [Installation](#installation)) |
188
188
  | Remote sshd | standard OpenSSH (Linux / HPC / Windows) | ✅ key auth; password auth needs `sshpass` on the host (POSIX) |
189
189
 
@@ -203,6 +203,9 @@ The plugin never patches DSH sources or injects into the profile dependency tree
203
203
  | In a large repo `@` cannot find real files (e.g. root `AGENTS.md`, `src/**`) | Fixed in 2.4.8: exclusion used to run *after* truncation, so a `node_modules/` tree could exhaust the index quota; exclusion now happens remotely (`grep`/`-prune`) before truncation, and hitting the cap logs a warning |
204
204
  | The remote directory you want does not exist yet and "Add Workspace" cannot create it | Fixed in 2.4.9: the directory picker has a "📁 New folder" button (both tabs) — type a name to create it in place and enter it automatically |
205
205
  | Accessing from the LAN / another device makes every file capability return 403 | Fixed in 2.4.10: the trust fence now reads the host's `ctx.webRuntime.trustedHosts` (same source as the `/api` gateway). Add the address to the DSH trust list — start with `--trusted-host <host[:port]>`, or access through your paired remote-access setup; with nothing configured the behaviour is unchanged (loopback only) |
206
+ | The Files tab's "search by file name" crashes with `Cannot read properties of undefined (reading 'length')` | Fixed in 2.4.11: the `fs.search` interception returned only `entries`, while better-sidebar's client contract is `{ matches, truncated }`; it now provides `matches` (cwd-relative, `/`-separated, matching upstream's own implementation) and keeps `entries` |
207
+ | "Search by file name" spins forever in a remote workspace (large trees) | Fixed in 2.4.11: shallow-first (`-maxdepth 3`, measured 0.68s cold / 0.11s warm) **returning as soon as anything matches** (the deeper pass becomes a background cache warm-up and only runs synchronously when the shallow pass finds nothing), noise directories pruned before traversal, the short-circuit-blocking `sort` removed, and a remote wall-clock budget that returns the **partial results collected so far** and flags them as incomplete. Measured on an OpenFOAM workspace: 5 minutes with zero output before → **43 matches in 0.96s** now |
208
+ | In a remote session `@filename` shows no candidates, while a bare `@` works | Fixed in 2.4.11: fuzzy queries rely on the index, and the index preferred `git ls-files --cached --others` (`--others` must walk the whole working tree and never finishes on huge projects → empty index). It now degrades in three budgeted steps (full git 6s → index-only git 3s → bounded `find` `maxdepth 3` + 5s) and falls back to a bounded `find` (measured 0.65s) whenever the index is not ready yet, so candidates are never empty |
206
209
  | Install fails with `minimumReleaseAge` or "No matching version" right after a release | npm supply-chain freshness policy — retry after 1–5 minutes |
207
210
  | A command hangs forever | The 120s timeout discards the pooled session automatically; use `timeoutMs: 0` for long jobs and `remote_ssh_kill` at any time |
208
211
  | Large files are truncated | 4MB per read, ≈6.29MB on the pooled download path (larger files fall back to a one-shot connection); use `remote_ssh_exec` with `head`/`tail` to page through |
package/lib/index.js CHANGED
@@ -877,9 +877,25 @@ async function remoteGrep(runner, p, pattern, path, opts) {
877
877
  return { ok: true, pattern: pattern, path: target, matches: matches, truncated: truncated, error: "" };
878
878
  }
879
879
 
880
+ /** 侧栏文件名搜索要剪掉的噪声目录:定义见下方 REF_EXCLUDED_DIRS 之后
881
+ * (模块级 const 不能引用尚未初始化的绑定 —— 曾因展开 REF_EXCLUDED_DIRS 触发 TDZ,
882
+ * 导致整个插件树加载失败,故这里只留说明,实体定义放在其依赖之后)。 */
883
+
880
884
  /**
881
885
  * 在远端按 glob 模式查找文件(find -name)。find 已递归;pattern 为 basename 通配
882
886
  * (如 .py 后缀),自动剥离前导双星号-斜杠与星号-斜杠以适配 POSIX find -name。
887
+ *
888
+ * 选项:
889
+ * - `relative`:用 `-printf '%P\n'` 输出**相对起始目录**的路径(issue #13:侧栏文件名搜索
890
+ * 要 cwd 相对路径;默认仍输出 find 原样的绝对路径,供 remote_ssh_glob 工具使用)。
891
+ * - `sorted !== false`:管道里加 `sort`。搜索路径必须关掉它 —— `sort` 会缓冲**全部** find
892
+ * 输出后才吐第一行,使 `head` 的提前短路完全失效(issue #13 实测:一个 OpenFOAM 工作区
893
+ * 5 分钟都跑不完,UI 一直「加载中…」,还占住池化会话阻塞其他操作)。
894
+ * - `pruneNoise`:遍历前剪掉 REMOTE_SEARCH_SKIP_DIRS。
895
+ * - `maxDepth`:`-maxdepth N`(浅层优先搜索用;不设则全深度)。
896
+ * - `timeBudgetSec`:远端墙钟预算(`timeout`/`gtimeout`,存在才用),到点杀掉 find 并返回
897
+ * **已收集到的部分结果** + truncated,避免把用户吊死在一次遍历上。
898
+ * - `timeoutMs`:SSH 层命令超时(默认沿用全局 120s)。
883
899
  */
884
900
  async function remoteGlob(runner, p, pattern, path, opts) {
885
901
  opts = opts || {};
@@ -887,10 +903,37 @@ async function remoteGlob(runner, p, pattern, path, opts) {
887
903
  let pat = String(pattern || "").trim();
888
904
  pat = pat.replace(/^(\*\*\/|\*\/)+/, "");
889
905
  const max = parseInt(String(opts.maxResults || 500), 10) || 500;
890
- const cmd = "find " + shellQuotePath(target) + " -name " + shellQuote(pat) + " -printf '%p\\n' 2>/dev/null | sort | head -n " + max;
891
- const r = await runner(p, cmd, undefined, MAX_BYTES);
892
- const files = String(r.stdout || "").split("\n").filter(function (s) { return !!s; });
893
- return { ok: true, pattern: pattern, path: target, files: files, truncated: !!r.truncated, error: "" };
906
+ const depth = parseInt(String(opts.maxDepth || 0), 10) || 0;
907
+ const fmt = opts.relative ? "%P" : "%p";
908
+ const prune = opts.pruneNoise
909
+ ? "\\( " + Array.from(REMOTE_SEARCH_SKIP_DIRS).map((n) => "-name " + shellQuote(n)).join(" -o ") + " \\) -prune -o "
910
+ : "";
911
+ const findCmd = "find " + shellQuotePath(target) + " " + (depth > 0 ? "-maxdepth " + depth + " " : "")
912
+ + prune + "-name " + shellQuote(pat) + " -printf '" + fmt + "\\n' 2>/dev/null";
913
+ const pipeline = findCmd + (opts.sorted === false ? "" : " | sort") + " | head -n " + max;
914
+ let cmd = pipeline;
915
+ const budget = parseInt(String(opts.timeBudgetSec || 0), 10) || 0;
916
+ if (budget > 0) {
917
+ // timeout 是 GNU coreutils(Linux 常见),macOS 需 gtimeout;都没有时退回无预算版本,
918
+ // 由 SSH 层超时兜底(此时不会拿到部分结果)。
919
+ cmd = [
920
+ "T=$(command -v timeout || command -v gtimeout || true)",
921
+ "if [ -n \"$T\" ]; then",
922
+ "\"$T\" " + budget + " " + pipeline,
923
+ "else",
924
+ pipeline,
925
+ "fi"
926
+ ].join("\n");
927
+ }
928
+ const r = await runner(p, cmd, undefined, MAX_BYTES, false, opts.timeoutMs);
929
+ const files = String(r && r.stdout ? r.stdout : "").split("\n").filter(function (s) { return !!s; });
930
+ // 预算到点(timeout → 124)或被 kill 时,stdout 里已有部分结果:照常返回并标记 truncated,
931
+ // 让用户看到「部分结果」而不是一个错误或永久加载。
932
+ if (r && r.ok === false && files.length === 0) {
933
+ return { ok: false, pattern: pattern, path: target, files: [], truncated: false, error: (r && r.error) || "glob failed" };
934
+ }
935
+ const budgetHit = !!(r && (r.ok === false || r.exitCode === 124 || r.timeout));
936
+ return { ok: true, pattern: pattern, path: target, files: files, truncated: !!((r && r.truncated) || budgetHit || files.length >= max), error: "" };
894
937
  }
895
938
 
896
939
  /**
@@ -902,6 +945,111 @@ function spawnOne(subprocess, argv, stdio, cwd) {
902
945
  return subprocess.spawn({ argv: argv, cwd: cwd || process.cwd(), stdio: stdio, graceMs: 60000 });
903
946
  }
904
947
 
948
+ /** 侧栏文件名搜索的预算与缓存(issue #13 实测:一个 OpenFOAM 工作区 `find` 5 分钟都跑不完,
949
+ * UI 一直「加载中…」,而且池化 SSH 会话被这条命令占住,其他操作一起卡)。
950
+ *
951
+ * 实测该工作区:顶层仅 12 个条目、命中集中在 depth 1–3(0.02–0.42s),depth 4 起耗时跳到 3s+
952
+ * 且不再有新命中——`find` 是深度优先,巨型子目录会吃光预算,连顶层文件都轮不到。
953
+ * 因此分两趟:先浅层(快、通常已够);**浅层一无所获才同步等深挖**,有结果时深挖转后台预热
954
+ * 缓存(否则常见查询都要多等一趟 ≤5s 的深挖,实测「有点慢」)。两趟都受远端墙钟预算约束。 */
955
+ const REF_SEARCH_MAX_MATCHES = 200; // 与上游 DEFAULT_MAX_MATCHES 一致
956
+ const REF_SEARCH_SHALLOW_DEPTH = 3; // 第一趟深度
957
+ const REF_SEARCH_SHALLOW_BUDGET_SEC = 3; // 第一趟墙钟预算
958
+ const REF_SEARCH_DEEP_DEPTH = 8; // 第二趟深度(仅在浅层未找满时)
959
+ const REF_SEARCH_DEEP_BUDGET_SEC = 5; // 第二趟墙钟预算
960
+ const REF_SEARCH_TIMEOUT_MS = 15000; // SSH 层超时(单趟预算 + 余量),避免占住池化会话
961
+ const REF_SEARCH_TTL_MS = 30000; // 相同 query 的短缓存(连打键盘/重复打开搜索框时省一次遍历)
962
+ const remoteSearchCache = new Map(); // key -> { at, epoch, result }
963
+ const remoteSearchInflight = new Map(); // key -> Promise(并发合并同一 query)
964
+
965
+ /** 远端文件名搜索:浅层优先 + 按需深挖 + 剪噪声目录 + 无 sort(head 可提前短路)+ 部分结果降级。 */
966
+ async function remoteSearch(runner, profile, remoteRoot, query) {
967
+ const pk = profileKey(profile);
968
+ const key = pk + "|" + String(remoteRoot || "~") + "|" + String(query || "");
969
+ const epoch = cacheEpoch(pk);
970
+ const hit = remoteSearchCache.get(key);
971
+ if (hit && hit.epoch === epoch && Date.now() - hit.at <= REF_SEARCH_TTL_MS) return hit.result;
972
+ let inflight = remoteSearchInflight.get(key);
973
+ if (!inflight) {
974
+ inflight = (async () => {
975
+ try {
976
+ const q = String(query || "");
977
+ const common = {
978
+ relative: true,
979
+ sorted: false,
980
+ pruneNoise: true,
981
+ maxResults: REF_SEARCH_MAX_MATCHES,
982
+ timeoutMs: REF_SEARCH_TIMEOUT_MS
983
+ };
984
+ const first = await remoteGlob(runner, profile, "*" + q + "*", remoteRoot, Object.assign({}, common, {
985
+ maxDepth: REF_SEARCH_SHALLOW_DEPTH,
986
+ timeBudgetSec: REF_SEARCH_SHALLOW_BUDGET_SEC
987
+ }));
988
+ const files = (first && first.files) || [];
989
+ let truncated = !!(first && first.truncated);
990
+ let error = first && first.ok ? "" : (first && first.error) || "search failed";
991
+ // 深挖策略(延迟关键):**有结果就立刻返回**,深挖转后台预热缓存。
992
+ // 此前条件是「浅层命中 < 200 就深挖」,而文件名片段极少能命中 200 个 → 几乎每次查询都要
993
+ // 再等一趟最多 5s 的深挖(实测浅层 0.96s + 深挖 ≤5s ≈ 最多 6s,用户反馈「有点慢」)。
994
+ // 现在:浅层一无所获时才同步等深挖(此时用户没别的可看);否则后台跑,同一 query 的
995
+ // 后续请求/重复打开搜索框会命中更全的缓存结果。
996
+ const deepOpts = Object.assign({}, common, {
997
+ maxDepth: REF_SEARCH_DEEP_DEPTH,
998
+ timeBudgetSec: REF_SEARCH_DEEP_BUDGET_SEC
999
+ });
1000
+ const warmDeepPass = () => {
1001
+ Promise.resolve()
1002
+ .then(() => remoteGlob(runner, profile, "*" + q + "*", remoteRoot, deepOpts))
1003
+ .then((deep) => {
1004
+ if (!deep || !deep.ok || !deep.files || deep.files.length === 0) return;
1005
+ if (cacheEpoch(pk) !== epoch) return; // 期间发生过写/exec:结果已过期,不写入
1006
+ const cur = remoteSearchCache.get(key);
1007
+ const merged = new Set((cur && cur.result && cur.result.files) || files);
1008
+ for (const f of deep.files) merged.add(f);
1009
+ remoteSearchCache.set(key, {
1010
+ at: Date.now(),
1011
+ epoch: epoch,
1012
+ result: {
1013
+ ok: true,
1014
+ pattern: "*" + q + "*",
1015
+ path: remoteRoot,
1016
+ files: Array.from(merged).slice(0, REF_SEARCH_MAX_MATCHES).sort(),
1017
+ truncated: !!(deep.truncated || merged.size >= REF_SEARCH_MAX_MATCHES),
1018
+ error: ""
1019
+ }
1020
+ });
1021
+ })
1022
+ .catch(() => {});
1023
+ };
1024
+ if (first && first.ok && files.length === 0) {
1025
+ // 浅层零命中:同步等深挖(用户此时没有可看的结果)
1026
+ const deep = await remoteGlob(runner, profile, "*" + q + "*", remoteRoot, deepOpts);
1027
+ const seen = new Set(files);
1028
+ for (const f of ((deep && deep.files) || [])) { if (!seen.has(f)) { seen.add(f); files.push(f); } }
1029
+ truncated = truncated || !!(deep && deep.truncated);
1030
+ if (deep && !deep.ok && files.length === 0) error = deep.error || error;
1031
+ } else if (first && first.ok && files.length > 0 && files.length < REF_SEARCH_MAX_MATCHES) {
1032
+ warmDeepPass(); // 后台预热:不增加本次响应延迟(浅层已满额时无需再深挖)
1033
+ }
1034
+ const result = {
1035
+ ok: files.length > 0 || !!(first && first.ok),
1036
+ pattern: "*" + q + "*",
1037
+ path: remoteRoot,
1038
+ files: files.slice(0, REF_SEARCH_MAX_MATCHES).sort(),
1039
+ truncated: truncated || files.length >= REF_SEARCH_MAX_MATCHES,
1040
+ error: files.length > 0 ? "" : error
1041
+ };
1042
+ if (result.ok) remoteSearchCache.set(key, { at: Date.now(), epoch, result: result });
1043
+ return result;
1044
+ } finally {
1045
+ remoteSearchInflight.delete(key);
1046
+ }
1047
+ })();
1048
+ remoteSearchInflight.set(key, inflight);
1049
+ }
1050
+ return await inflight;
1051
+ }
1052
+
905
1053
  function readCollected(handle, name) {
906
1054
  if (!handle || !handle.collected || !handle.collected[name]) return "";
907
1055
  const r = handle.collected[name].readFrom(0);
@@ -1207,14 +1355,26 @@ const REF_INDEX_TTL_MS = 60000;
1207
1355
  const REF_QUERY_BUDGET_MS = 900;
1208
1356
  /** 后台建索引的预算。 */
1209
1357
  const REF_INDEX_BUILD_TIMEOUT_MS = 20000;
1210
- /** git 仓库的 find 深度上限。 */
1211
- const REF_FIND_MAXDEPTH = 5;
1358
+ /** 索引回退 find 的深度上限(原为 5,在巨型目录上 5 层遍历会跑不完——实测 >180s)。 */
1359
+ const REF_INDEX_FIND_MAXDEPTH = 3;
1360
+ /** 远端各步的墙钟预算(秒):完整 git → 仅索引 git → find 回退,合计 14s < 上面的 20s 构建预算。 */
1361
+ const REF_INDEX_GIT_FULL_BUDGET_SEC = 6;
1362
+ const REF_INDEX_GIT_CACHED_BUDGET_SEC = 3;
1363
+ const REF_INDEX_FIND_BUDGET_SEC = 5;
1212
1364
  /** 排除目录:与 @deepseek-ai/dsh-file-reference-local 的 DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES 逐条一致。 */
1213
1365
  const REF_EXCLUDED_DIRS = new Set([
1214
1366
  ".git", "node_modules", "dist", "build", "out", "coverage", "target",
1215
1367
  ".next", ".nuxt", ".turbo", ".venv", "__pycache__", ".pytest_cache",
1216
1368
  ".mypy_cache", ".gradle"
1217
1369
  ]);
1370
+
1371
+ /** 侧栏文件名搜索要剪掉的噪声目录(上游 SEARCH_SKIP_DIRS 与本地索引排除表的并集)。
1372
+ * 在远端遍历前剪枝,避免把配额与时间花在依赖/构建产物上。
1373
+ * **必须定义在 REF_EXCLUDED_DIRS 之后**:模块级 const 的展开会在模块求值期立即读取该绑定。 */
1374
+ const REMOTE_SEARCH_SKIP_DIRS = new Set([
1375
+ ...REF_EXCLUDED_DIRS,
1376
+ ".pnpm-store", ".yarn", ".turbopack", ".output", ".cache", ".parcel-cache", ".umi", ".umi-production"
1377
+ ]);
1218
1378
  const refIndexCache = new Map(); // key = profileKey|root -> { at, epoch, entries }
1219
1379
  const refIndexBuilds = new Map(); // key -> Promise<entries>(同一 workspace 只建一次)
1220
1380
  const refTruncationWarned = new Set(); // 已就索引截断告警过的 workspace(避免每次重建都刷日志)
@@ -1325,27 +1485,77 @@ function refExcludeRegex() {
1325
1485
  return "(^|/)(" + alts + ")(/|$)";
1326
1486
  }
1327
1487
 
1328
- /** 远端建索引命令:git 仓库走 git ls-files(快且尊重 .gitignore),否则有界 find。
1488
+ /** 远端建索引命令:git 仓库优先(尊重 .gitignore),跑不完/为空时回退到有界 find。
1329
1489
  *
1330
- * 排除必须发生在 `head` **之前**(issue #10 实测反馈):`git ls-files --cached --others`
1490
+ * 排除必须发生在 `head` **之前**(issue #10 实测反馈):`git ls-files --cached --others`
1331
1491
  * 的输出**不是全局字典序**——未跟踪文件按 readdir 顺序先输出,`node_modules/` 这类目录
1332
1492
  * 可能占满前两万行,把 `head` 的配额吃光,导致 `AGENTS.md`、`src/**` 等真实文件被整段切掉
1333
1493
  * (实测仓库:node_modules 18,880 条 = 84.5%,`AGENTS.md` 落在第 20417 行)。
1334
- * 因此 git 分支在 head 前先 `grep -vE` 掉排除目录;find 分支本就用 `-prune` 在远端剪枝。
1494
+ * 每一步都要有**墙钟预算**(用户实测反馈:某个远程项目里 `@文件名` 完全没有候选,
1495
+ * 而 `@` 单独输入正常 —— 因为模糊查询走索引、目录列举不走):`git ls-files --others`
1496
+ * 需要遍历整棵工作树枚举未跟踪文件,在巨型目录上**根本跑不完**(实测 10s 被超时杀掉、
1497
+ * 零输出),于是索引为空。现在:完整 git(6s)→ 仅索引 git(3s,恒定快)→ 有界 find
1498
+ * (`maxdepth 3` + 5s,实测该目录 921 条 / 0.65s),三级合计 ≤14s < 20s 构建预算。
1335
1499
  * 客户端的 excludedSegment 仍保留作双保险(见 refParseIndexOutput)。 */
1336
1500
  function refIndexCommand(root) {
1337
1501
  const target = shellQuotePath(root || "~");
1338
1502
  const prune = Array.from(REF_EXCLUDED_DIRS).map((n) => "-name " + shellQuote(n)).join(" -o ");
1503
+ const cap = REF_MAX_ENTRIES + 1;
1504
+ const gitPipe = "| grep -vE '" + refExcludeRegex() + "' | head -n " + cap;
1505
+ const findFallback = "find . -mindepth 1 -maxdepth " + REF_INDEX_FIND_MAXDEPTH
1506
+ + " \\( " + prune + " \\) -prune -o -printf '%y\\t%P\\n' 2>/dev/null | head -n " + cap;
1339
1507
  return [
1340
1508
  "cd " + target + " 2>/dev/null || exit 3",
1509
+ "T=$(command -v timeout || command -v gtimeout || true)",
1510
+ "G=",
1341
1511
  "if [ -d .git ] && command -v git >/dev/null 2>&1; then",
1342
- "git ls-files --cached --others --exclude-standard 2>/dev/null | grep -vE '" + refExcludeRegex() + "' | head -n " + (REF_MAX_ENTRIES + 1) + " | sed 's/^/f\t/'",
1343
- "else",
1344
- "find . -mindepth 1 -maxdepth " + REF_FIND_MAXDEPTH + " \\( " + prune + " \\) -prune -o -printf '%y\t%P\\n' 2>/dev/null | head -n " + (REF_MAX_ENTRIES + 1),
1512
+ " if [ -n \"$T\" ]; then G=$(\"$T\" " + REF_INDEX_GIT_FULL_BUDGET_SEC + " git ls-files --cached --others --exclude-standard 2>/dev/null " + gitPipe + "); else G=$(git ls-files --cached --others --exclude-standard 2>/dev/null " + gitPipe + "); fi",
1513
+ " if [ -z \"$G\" ]; then",
1514
+ " if [ -n \"$T\" ]; then G=$(\"$T\" " + REF_INDEX_GIT_CACHED_BUDGET_SEC + " git ls-files --cached 2>/dev/null " + gitPipe + "); else G=$(git ls-files --cached 2>/dev/null " + gitPipe + "); fi",
1515
+ " fi",
1516
+ "fi",
1517
+ "if [ -n \"$G\" ]; then printf '%s\\n' \"$G\" | sed 's/^/f\t/'; else",
1518
+ " if [ -n \"$T\" ]; then \"$T\" " + REF_INDEX_FIND_BUDGET_SEC + " " + findFallback + "; else " + findFallback + "; fi",
1345
1519
  "fi"
1346
1520
  ].join("\n");
1347
1521
  }
1348
1522
 
1523
+ // ---------------------------------------------------------------------------
1524
+ // fs.search 的返回契约(issue #13)
1525
+ //
1526
+ // better-sidebar 客户端契约(v0.13.0 起至今未变,其 client-registry 注释原文:
1527
+ // "matches are cwd-relative '/'-separated paths"):
1528
+ // fs.search → { matches: string[], truncated: boolean }
1529
+ // 而本插件此前的两条分支只返回 { entries: [{path,isDir}], truncated } —— 客户端在渲染阶段
1530
+ // 读 results.matches.length / results.matches.map(...),undefined 直接抛 TypeError,
1531
+ // 被 RenderBoundary 兜住 → 整个「文件」页签变成错误条(本地会话也复现,因为拦截是无条件的)。
1532
+ // 现在统一经 fsSearchResult() 产出:matches 必须存在且为 cwd 相对、'/' 分隔的字符串数组;
1533
+ // entries(绝对路径 + isDir)保留作向前兼容。
1534
+ // ---------------------------------------------------------------------------
1535
+
1536
+ /** 把绝对路径裁成相对 base 的 POSIX 路径;不在 base 之下时回退为归一化后的绝对路径。 */
1537
+ function toPosixRelative(base, target) {
1538
+ const t = String(target == null ? "" : target).replace(/\\/g, "/");
1539
+ const b = String(base == null ? "" : base).replace(/\\/g, "/").replace(/\/+$/, "");
1540
+ if (!b) return t;
1541
+ // Windows 盘符路径大小写不敏感;POSIX 保持大小写敏感
1542
+ const fold = /^[a-z]:\//i.test(b);
1543
+ const tt = fold ? t.toLowerCase() : t;
1544
+ const bb = fold ? b.toLowerCase() : b;
1545
+ if (tt === bb) return "";
1546
+ if (tt.startsWith(bb + "/")) return t.slice(b.length + 1);
1547
+ return t;
1548
+ }
1549
+
1550
+ /** 组装 fs.search 的响应:matches 永远存在且为字符串数组(客户端会直接 .length / .map)。 */
1551
+ function fsSearchResult(matches, entries, truncated) {
1552
+ return {
1553
+ matches: Array.isArray(matches) ? matches.filter((m) => typeof m === "string" && m !== "") : [],
1554
+ entries: Array.isArray(entries) ? entries : [],
1555
+ truncated: !!truncated
1556
+ };
1557
+ }
1558
+
1349
1559
  // ---------------------------------------------------------------------------
1350
1560
  // apply
1351
1561
  // ---------------------------------------------------------------------------
@@ -2562,11 +2772,16 @@ function apply(ctx, config) {
2562
2772
  const rp = localToRemote(localPath, remoteBase, remoteInfo.remotePath);
2563
2773
  result = await remoteWriteFile(runPooled, profile, rp, payload.content || "");
2564
2774
  } else if (method === "fs.search") {
2775
+ // remoteSearch:相对路径(%P)+ 剪噪声目录 + 无 sort(head 提前短路)+ 8s 远端预算
2776
+ // + 15s SSH 超时 + 同 query 合并/短缓存(issue #13:大工作区此前会一直「加载中…」)。
2565
2777
  const rp = remoteInfo.remotePath;
2566
- const r = await remoteGlob(runPooled, profile, "*" + (payload.query || "") + "*", rp);
2567
- result = { entries: (r.files || []).map(function (f) {
2568
- return { path: join(remoteBase, f), isDir: false };
2569
- }), truncated: !!r.truncated };
2778
+ const r = await remoteSearch(runPooled, profile, rp, payload.query || "");
2779
+ const matches = (r.files || []).map(function (f) {
2780
+ return String(f).replace(/\\/g, "/").replace(/^\.\//, "");
2781
+ }).filter(function (f) { return f !== ""; });
2782
+ result = fsSearchResult(matches, matches.map(function (rel) {
2783
+ return { path: join(remoteBase, rel), isDir: false };
2784
+ }), r.truncated);
2570
2785
  } else if (method === "fs.rename") {
2571
2786
  // better-sidebar 0.19 新增端点:重命名必须落在远端,绝不能让本地镜像目录被改。
2572
2787
  const localPath = payload.path || "";
@@ -2620,7 +2835,11 @@ function apply(ctx, config) {
2620
2835
  } else if (method === "fs.write") {
2621
2836
  result = await localWriteFile(payload.path, payload.content || "");
2622
2837
  } else if (method === "fs.search") {
2623
- result = await localSearchFiles(sessionCwd, payload.query || "");
2838
+ // matches 必须是 cwd 相对、'/' 分隔的路径(better-sidebar 客户端直接 .map 渲染并据此打开);
2839
+ // entries 保留绝对路径(本插件 fs.read 拦截据此映射回远端)。
2840
+ const r = await localSearchFiles(sessionCwd, payload.query || "");
2841
+ const entries = (r && r.entries) || [];
2842
+ result = fsSearchResult(entries.map(function (e) { return toPosixRelative(sessionCwd, e.path); }), entries, r && r.truncated);
2624
2843
  } else if (method === "fs.rename") {
2625
2844
  result = await localRenameEntry(payload.path, String(payload.name || ""));
2626
2845
  } else if (method === "fs.remove") {
@@ -3180,9 +3399,28 @@ function apply(ctx, config) {
3180
3399
  return await remoteRefListDirectory(profile, root, split.directory, split.fragment);
3181
3400
  }
3182
3401
  const entries = await refIndexFor(profile, root);
3183
- if (!entries.length) return [];
3184
- const visible = entries.filter((e) => refVisibleForGlobalQuery(e.path, split.fragment));
3185
- return refRankCandidates(visible, split.fragment, REF_MAX_RESULTS);
3402
+ if (entries.length) {
3403
+ const visible = entries.filter((e) => refVisibleForGlobalQuery(e.path, split.fragment));
3404
+ return refRankCandidates(visible, split.fragment, REF_MAX_RESULTS);
3405
+ }
3406
+ // 索引尚未就绪时不能直接给空列表 —— 那正是「@文件名 没有候选、而 @ 单独输入正常」的观感
3407
+ // (用户实测反馈:某个远程项目里模糊查询全空)。原因:大仓库里索引的 git 那一步会吃满预算
3408
+ // (实测 6.7s 才建好),而单次查询只等 REF_QUERY_BUDGET_MS=900ms。
3409
+ // 这里用与侧栏搜索同一套**有界 find** 兜底:剪噪声目录 + 无 sort + 浅层深度 + 墙钟预算(实测 0.65s)。
3410
+ const r = await remoteGlob(runPooled, profile, "*" + split.fragment + "*", root, {
3411
+ relative: true,
3412
+ sorted: false,
3413
+ pruneNoise: true,
3414
+ maxResults: REF_MAX_RESULTS,
3415
+ maxDepth: REF_INDEX_FIND_MAXDEPTH,
3416
+ timeBudgetSec: REF_INDEX_FIND_BUDGET_SEC,
3417
+ timeoutMs: 10000
3418
+ });
3419
+ if (!r || !r.files || !r.files.length) return [];
3420
+ const quick = r.files.map(function (f) {
3421
+ return { path: String(f).replace(/\\/g, "/").replace(/^\.\//, ""), kind: "file" };
3422
+ }).filter(function (e) { return e.path !== "" && refVisibleForGlobalQuery(e.path, split.fragment); });
3423
+ return refRankCandidates(quick, split.fragment, REF_MAX_RESULTS);
3186
3424
  }
3187
3425
 
3188
3426
  /** 该 agent 是远程工作区会话则返回远端候选,否则返回 null(调用方委托原实现)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.4.10",
3
+ "version": "2.4.11",
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",