@zhangfengshun/dsh-remote-ssh 2.4.6 → 2.4.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,19 @@
2
2
 
3
3
  本文件的版本号与 `package.json` 的 `version` 保持一致。每个版本对应一个 Cordis Package 快照(`pkg-N`)。
4
4
 
5
+ ## [2.4.7] — `@` 文件引用补全支持远程工作区(issue #10)
6
+ ### 新增
7
+ - **远程工作区会话里 `@` 补全现在列远端文件**(感谢 @Linhaojing 的通道分析与挂载点核实):`@` 补全由宿主 `ctx.fileReferences` 服务提供(官方 provider `@deepseek-ai/dsh-file-reference-local`),它只遍历**本地磁盘**——远程工作区会话的 cwd 是本地镜像目录,于是候选只有镜像里那几个文件(通常只有本插件写入的 `README.md`)。该链路不经过任何 HTTP 路由(客户端经 remote gateway 调 `ctx.fileReferences.list`),因此采用**服务层包装**:
8
+ - 在 `ctx.inject(["fileReferences"])` 里包装已注册的服务实例,**远程工作区自己算候选**,其余一律委托回原实现——本地会话的索引与模糊排序**零改动**,不替换 composition row,也不新增 `@deepseek-ai/*` 运行时依赖;
9
+ - 桥接侧动态解析服务(`dsh-api-session-controller` 的 `this.ctx.fileReferences.list(...)`),实例包装对其立即生效(已核实);
10
+ - 服务缺失或不可包装时打印一条 warn 并保持原行为;卸载时自动还原原方法。
11
+ - **查询语义与官方 provider 逐条对齐**:空查询/含 `/` 走目录列举、纯片段走模糊查询;隐藏文件仅在小片段以 `.` 开头时可见;评分常量(同名 1000 / 前缀 900 / 名称子串 700 / 路径子串 500 / 子序列 300+间隔惩罚)、目录 +25、排序 tiebreak(目录优先 → 路径短 → 字典序)、排除目录表(与上游 `DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES` 的 15 条一致)全部照搬。
12
+ - **索引来源按远端实际测量取舍**:git 仓库用 `git ls-files --cached --others --exclude-standard`(真实超算实测 **0.117s / 137 条**,尊重 `.gitignore`、含未跟踪文件),非 git 回退有界 `find`(`maxdepth 5` + 剪枝,实测 **1.57s / 3121 条**)——作为对照,深遍历 `find` 在同一仓库要 **18.6s**。
13
+ - **性能与降级**:索引按 workspace 缓存 60s 并挂到既有 `cacheEpoch`(写/exec 后自动失效);单次查询只等 **900ms**,超时用陈旧索引或空结果作答、重建继续在后台(与本地 provider 的"陈旧索引照常回答"一致);`AbortSignal` 已取消时直接返回空;任何异常一律降级到本地实现,绝不把光标卡在 SSH 上。
14
+
15
+ ### 测试
16
+ - 新增 `tests/file-references.test.mjs`(**62 条断言**):查询拆分、索引解析(git/find 两种帧、父路径合成目录、排除目录剪枝、符号链接跳过、CRLF、条目上限)、评分与排序(含逐条对照上游常量)、隐藏文件可见性、索引命令生成(两条分支 + 15 条排除目录)、服务包装(远程走远端 / 本地委托 / 异常降级 / 恢复函数 / 非法服务安全返回)、以及**真实 HPC 输出样例**喂给解析器。
17
+
5
18
  ## [2.4.6] — 「文件」页签树根显示远程目录名(issue #9)
6
19
  ### 修复
7
20
  - **树根标签不再显示本地镜像目录 ID**(感谢 @Linhaojing 的链路定位):better-sidebar「文件」页签的树根标签取自会话 cwd(= 本地镜像路径)的 basename,于是显示成 `wmu3sxe24jpvg` 之类的镜像目录名。这条链路**不经过任何 `fs.*` 路由**,且 Host 侧 `session.cwd` 虽已返回 `root` 字段但客户端只消费 `cwd`(`api.sessionCwd(...).then(r => setFetchedCwd(r.cwd))`,另有 `useSessionCwd` 直接读客户端会话列表的 `cwd`),因此只能在渲染层替换文本。现由客户端取 `listWorkspaces` 的 `mirrorPath`/`remotePath` 建立「镜像 basename → 远程 basename」映射,把树根那一行换成**远程目录名**(如 `IB_Robot`),并给该行加 `title` 显示完整远程路径(如 `~/lhj/IB_Robot`)。
package/README.md CHANGED
@@ -15,6 +15,7 @@
15
15
  | 💻 远程终端 | 内置「终端」页签自动检测远程工作区,SSH 交互式终端,**落在工作区对应的远程目录**(与 VSCode Remote-SSH 一致) |
16
16
  | 🌐 远程工作区 | 选择远程目录创建原生工作区,一键进入远程环境 |
17
17
  | 🤖 模型工具 | 13 个 `remote_ssh_*` 工具,会话感知免填连接参数;命令级超时 + `remote_ssh_kill` 兜底恢复 |
18
+ | 🗂️ `@` 引用补全 | 远程工作区会话里 `@` 补全列**远端**文件(git 仓库走 `git ls-files`,实测 0.1s;非 git 用有界 `find`;索引缓存 + 900ms 查询预算,超时降级不卡输入框) |
18
19
  | ⚡ 打开提速 | 单往返合并读 + raw 文本快路径 + 结果缓存(LRU + 5s TTL):首开 ≈**1.31×**,TTL 内重复打开 **0 往返**,过期复验 **≈5×**(真实超算实测);`remote_ssh_exec` 连接复用 **≈15×** |
19
20
 
20
21
  ## 截图
@@ -45,7 +46,7 @@
45
46
  **一条命令安装**(无需 token、API Key 或额外配置):
46
47
 
47
48
  ```bash
48
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.6
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.7
49
50
  ```
50
51
 
51
52
  安装后**重启 DSH**。`@zhangfengshun/dsh-remote-ssh` 必须在 bundles 列表中排在 `dsh-better-sidebar` **之后**。
@@ -155,6 +156,15 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
155
156
 
156
157
  远程工作区会话中调用工具可免填 `profileId` 等连接参数;全部文件/命令类工具走持久 SSH 会话池 + 结果缓存,`remote_ssh_exec` 单命令实测 ≈15× 提速。
157
158
 
159
+ ## `@` 文件引用补全
160
+
161
+ 远程工作区会话里输入 `@`,候选来自**远端**(与「文件」页签的树一致),而不是本地镜像目录:
162
+
163
+ - **索引来源**:git 仓库用 `git ls-files --cached --others --exclude-standard`(尊重 `.gitignore`、含未跟踪文件;真实超算实测 0.117s / 137 条),非 git 目录回退有界 `find`(`maxdepth 5` + 剪枝,实测 1.57s / 3121 条);
164
+ - **查询语义与官方 provider 逐条对齐**:`@` 与 `@src/` 走远端目录列举;`@read` 走索引模糊匹配(同名 > 前缀 > 名称子串 > 路径子串 > 子序列,目录加权 +25);
165
+ - **不卡输入框**:索引按工作区缓存 60 秒(写文件/执行命令后自动失效),单次补全只等 900ms——超时先用旧索引作答、重建在后台进行;连接异常时自动回退到本地行为;
166
+ - **本地工作区不受影响**:非远程会话直接委托宿主原实现,索引与排序完全没有改动。
167
+
158
168
  ## 命令超时与恢复
159
169
 
160
170
  所有 SSH 命令默认 **120 秒**超时(issue #5):一条挂起的远端命令(网络卡顿、远端进程僵死、等待 stdin 的 `cat`)不会再永久占用会话、拖死后续命令。
@@ -188,6 +198,7 @@ dsh plugin --profile <name> remove @zhangfengshun/dsh-remote-ssh
188
198
  | 内置「终端」页签连不上 | 终端为 `ssh -tt` 交互式通道,**仅支持密钥认证**;密码认证的连接会回退为本地 shell 并打印一行提示(避免把本地 shell 误认为已连上远程),密码认证请改用「文件」页签与模型工具 |
189
199
  | 终端落在远程 `$HOME` 而不是工作区目录 | 2.4.5 起已修复(wrapper 会 `cd` 到工作区 `remotePath`,目录不存在时回退 `$HOME`);若仍停在 `$HOME`,确认 2.4.5 已装入并重启 DSH |
190
200
  | 「文件」页签树根显示镜像目录 ID(如 `wmu3sxe24jpvg`) | 2.4.6 起已修复:树根改为显示**远程目录名**(如 `IB_Robot`),悬停可见完整远程路径;该标签不经过 `fs.*` 路由,由客户端渲染层替换 |
201
+ | `@` 补全只搜到镜像里那几个文件 | 2.4.7 起已修复:远程工作区会话的 `@` 补全改列远端文件(索引缓存 60s + 900ms 查询预算);若仍只有镜像文件,确认 2.4.7 已装入并重启 DSH |
191
202
  | 安装时提示 `minimumReleaseAge` 或「No matching version」(刚发布) | npm 供应链新鲜度策略,等 1–5 分钟后重试即可 |
192
203
  | 命令卡住不返回 | 默认 120s 超时后自动丢弃会话;长时任务用 `timeoutMs: 0`,随时可用 `remote_ssh_kill` 强杀 |
193
204
  | 大文件读取被截断 | 单文件读取上限 4MB、下载池化路径约 6.29MB(更大自动回落一次性连接);用 `remote_ssh_exec` + `head`/`tail` 分段处理 |
package/README_EN.md CHANGED
@@ -15,6 +15,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
15
15
  | 💻 Remote Terminal | Built-in **Terminal** tab auto-detects remote workspaces, opens an SSH interactive shell **in the workspace's remote directory** (like VSCode Remote-SSH) |
16
16
  | 🌐 Remote Workspace | Select a remote directory to create a native workspace, one-click enter |
17
17
  | 🤖 Model Tools | 13 `remote_ssh_*` tools, session-aware with auto-filled connection params; command-level timeout + `remote_ssh_kill` recovery |
18
+ | 🗂️ `@` Completion | In a remote-workspace session, `@` completion lists **remote** files (git repos via `git ls-files`, measured 0.1s; bounded `find` otherwise; cached index + 900ms query budget so the caret never stalls) |
18
19
  | ⚡ Faster Opens | Single-roundtrip merged reads + raw text fast path + result cache (LRU + 5s TTL): first open ≈**1.31×**, repeat opens within TTL **0 round-trips**, expired revalidation **≈5×** (measured on a real HPC); `remote_ssh_exec` connection reuse **≈15×** |
19
20
 
20
21
  ## Screenshots
@@ -45,7 +46,7 @@ A **DSH** plugin like **VSCode Remote-SSH**: connect to remote HPC / servers via
45
46
  **One command** (no token, API key or extra configuration needed):
46
47
 
47
48
  ```bash
48
- dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.6
49
+ dsh plugin --profile <name> add @zhangfengshun/dsh-remote-ssh@2.4.7
49
50
  ```
50
51
 
51
52
  **Restart DSH** after installation. `@zhangfengshun/dsh-remote-ssh` must come **after** `dsh-better-sidebar` in the bundles list.
@@ -155,6 +156,15 @@ Returns `{ ok, exitCode, stdout, stderr, error, truncated, isTimeout }` — e.g.
155
156
 
156
157
  In a remote-workspace session, `profileId` and other connection params can be omitted. All file/command tools run over the persistent SSH session pool + result cache; `remote_ssh_exec` measures ≈15× faster per command.
157
158
 
159
+ ## `@` File Reference Completion
160
+
161
+ In a remote-workspace session, typing `@` offers **remote** candidates (matching the Files tab tree) instead of the local mirror directory:
162
+
163
+ - **Index source**: git repos use `git ls-files --cached --others --exclude-standard` (respects `.gitignore`, includes untracked files; measured 0.117s / 137 entries on a real HPC), non-git directories fall back to a bounded `find` (`maxdepth 5` + pruning, measured 1.57s / 3121 entries);
164
+ - **Query semantics mirror the official provider**: `@` and `@src/` list a remote directory; `@read` runs the fuzzy index (exact name > prefix > name substring > path substring > subsequence, directories +25);
165
+ - **The caret never stalls**: the index is cached per workspace for 60s (invalidated after writes/commands) and a single completion waits at most 900ms — on timeout the stale index answers and the rebuild continues in the background; connection failures fall back to local behaviour;
166
+ - **Local workspaces are untouched**: non-remote sessions delegate straight to the host implementation.
167
+
158
168
  ## Command Timeout & Recovery
159
169
 
160
170
  All SSH commands default to a **120-second** timeout (issue #5): a hung remote command (network stall, stuck remote process, `cat` waiting on stdin) can no longer occupy the session forever and block every later command.
@@ -188,6 +198,7 @@ The plugin never patches DSH sources or injects into the profile dependency tree
188
198
  | Built-in Terminal tab cannot connect | The terminal is an `ssh -tt` interactive channel and supports **key auth only**; password-auth profiles fall back to a local shell and now print a one-line notice (so a local shell is not mistaken for a remote one) — use the Files tab and the model tools for password auth |
189
199
  | Terminal opens in the remote `$HOME` instead of the workspace directory | Fixed in 2.4.5 (the wrapper `cd`s into the workspace `remotePath`, falling back to `$HOME` when it no longer exists); if it still starts in `$HOME`, make sure 2.4.5 is installed and DSH restarted |
190
200
  | Files tab tree root shows the mirror directory id (e.g. `wmu3sxe24jpvg`) | Fixed in 2.4.6: the root row now shows the **remote directory name** (e.g. `IB_Robot`) with the full remote path on hover; that label never passes through the `fs.*` routes, so the client renders the replacement |
201
+ | `@` completion only finds the few files in the mirror | Fixed in 2.4.7: in a remote-workspace session `@` now lists remote files (60s index cache + 900ms query budget); if only mirror files show up, make sure 2.4.7 is installed and DSH restarted |
191
202
  | Install fails with `minimumReleaseAge` or "No matching version" right after a release | npm supply-chain freshness policy — retry after 1–5 minutes |
192
203
  | 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 |
193
204
  | 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
@@ -1109,6 +1109,154 @@ function remoteWorkspaceReadme(profileName, remotePath) {
1109
1109
  ].join("\n");
1110
1110
  }
1111
1111
 
1112
+ // ---------------------------------------------------------------------------
1113
+ // @ 文件引用补全的远端支持(issue #10)
1114
+ //
1115
+ // 「@」补全由宿主 ctx.fileReferences 服务提供(官方 provider:@deepseek-ai/dsh-file-reference-local),
1116
+ // 它只遍历**本地磁盘**——远程工作区会话里,会话 cwd 是本地镜像目录,于是候选只有镜像里那几个文件
1117
+ // (镜像通常只有本插件写入的 README.md 与 .remote-ssh.json)。这条链路不经过任何 HTTP 路由
1118
+ // (客户端经 remote gateway 调 ctx.fileReferences.list),所以只能用服务层方案:
1119
+ // 包装已注册的服务实例,远程工作区自己算候选,其余一律委托回原实现(本地索引/模糊排序零改动)。
1120
+ //
1121
+ // 查询语义与本地 provider 完全对齐(对照 dsh-file-reference-local 的 WorkspaceFileSearch):
1122
+ // - 空查询或含 "/":目录列举(隐藏文件仅在小片段以 . 开头时可见;排除目录剪枝)
1123
+ // - 纯片段:全量模糊搜索(子序列评分 + 目录加权 + 长度/字典序 tiebreak)
1124
+ // 索引来源对远端做了取舍:git 仓库用 `git ls-files --cached --others --exclude-standard`
1125
+ // (实测 0.1s / 尊重 .gitignore / 含未跟踪),非 git 退化为有界 find(maxdepth 5),
1126
+ // 结果按 workspace 缓存并挂到既有 cacheEpoch 上,写/exec 后自动失效。
1127
+ // ---------------------------------------------------------------------------
1128
+
1129
+ /** 与本地 provider 的 maxResults 默认值一致。 */
1130
+ const REF_MAX_RESULTS = 20;
1131
+ /** 索引条目上限(本地默认 5 万;远端按传输与内存预算收紧)。 */
1132
+ const REF_MAX_ENTRIES = 20000;
1133
+ /** 索引缓存 TTL:过期后仍先用旧索引作答,重建在后台进行(与本地 provider 的陈旧索引策略一致)。 */
1134
+ const REF_INDEX_TTL_MS = 60000;
1135
+ /** 单次查询等待索引的预算:超过就返回旧索引/空,绝不把光标卡在 SSH 上。 */
1136
+ const REF_QUERY_BUDGET_MS = 900;
1137
+ /** 后台建索引的预算。 */
1138
+ const REF_INDEX_BUILD_TIMEOUT_MS = 20000;
1139
+ /** 非 git 仓库的 find 深度上限。 */
1140
+ const REF_FIND_MAXDEPTH = 5;
1141
+ /** 排除目录:与 @deepseek-ai/dsh-file-reference-local 的 DEFAULT_FILE_SEARCH_EXCLUDED_DIRECTORIES 逐条一致。 */
1142
+ const REF_EXCLUDED_DIRS = new Set([
1143
+ ".git", "node_modules", "dist", "build", "out", "coverage", "target",
1144
+ ".next", ".nuxt", ".turbo", ".venv", "__pycache__", ".pytest_cache",
1145
+ ".mypy_cache", ".gradle"
1146
+ ]);
1147
+ const refIndexCache = new Map(); // key = profileKey|root -> { at, epoch, entries }
1148
+ const refIndexBuilds = new Map(); // key -> Promise<entries>(同一 workspace 只建一次)
1149
+
1150
+ /** 拆分 @ 查询:返回 { directory, fragment, isDirectoryQuery }(与本地 list() 的分支判据一致)。 */
1151
+ function refSplitQuery(rawQuery) {
1152
+ const query = String(rawQuery == null ? "" : rawQuery).replace(/\\/g, "/");
1153
+ const slash = query.lastIndexOf("/");
1154
+ if (query === "" || slash >= 0) {
1155
+ return {
1156
+ directory: slash < 0 ? "" : query.slice(0, slash + 1),
1157
+ fragment: slash < 0 ? "" : query.slice(slash + 1),
1158
+ isDirectoryQuery: true
1159
+ };
1160
+ }
1161
+ return { directory: "", fragment: query, isDirectoryQuery: false };
1162
+ }
1163
+
1164
+ /** 子序列评分(对照本地 subsequenceScore:按命中字符的间隔惩罚)。 */
1165
+ function refSubsequenceScore(target, query) {
1166
+ let targetIndex = 0;
1167
+ let gap = 0;
1168
+ for (const character of query) {
1169
+ const found = target.indexOf(character, targetIndex);
1170
+ if (found < 0) return undefined;
1171
+ gap += found - targetIndex;
1172
+ targetIndex = found + 1;
1173
+ }
1174
+ return Math.max(0, 100 - gap);
1175
+ }
1176
+
1177
+ /** 候选评分(逐字对照本地 scoreCandidate 的常量:1000/900/700/500/300 + 目录 25)。 */
1178
+ function refScoreCandidate(candidate, query) {
1179
+ if (query === "") return 0;
1180
+ const path = String(candidate.path).toLowerCase();
1181
+ const name = path.slice(path.lastIndexOf("/") + 1);
1182
+ const needle = String(query).toLowerCase();
1183
+ const directoryBonus = candidate.kind === "directory" ? 25 : 0;
1184
+ if (name === needle) return 1000 + directoryBonus;
1185
+ if (name.startsWith(needle)) return 900 + directoryBonus;
1186
+ if (name.includes(needle)) return 700 + directoryBonus;
1187
+ if (path.includes(needle)) return 500 + directoryBonus;
1188
+ const subsequence = refSubsequenceScore(path, needle);
1189
+ return subsequence === undefined ? undefined : 300 + subsequence + directoryBonus;
1190
+ }
1191
+
1192
+ /** 排序(逐字对照本地 rankCandidates:评分降序 → 目录优先 → 路径短优先 → 字典序)。 */
1193
+ function refRankCandidates(candidates, query, limit) {
1194
+ const ranked = [];
1195
+ for (const candidate of candidates) {
1196
+ const score = refScoreCandidate(candidate, query);
1197
+ if (score !== undefined) ranked.push({ candidate, score });
1198
+ }
1199
+ const kindRank = (kind) => (kind === "directory" ? 0 : 1);
1200
+ const compareText = (l, r) => (l < r ? -1 : l > r ? 1 : 0);
1201
+ ranked.sort((left, right) =>
1202
+ right.score - left.score ||
1203
+ kindRank(left.candidate.kind) - kindRank(right.candidate.kind) ||
1204
+ (query === "" ? 0 : left.candidate.path.length - right.candidate.path.length) ||
1205
+ compareText(left.candidate.path, right.candidate.path));
1206
+ return ranked.slice(0, limit).map((entry) => entry.candidate);
1207
+ }
1208
+
1209
+ /** 隐藏文件可见性(对照本地 visibleForGlobalQuery)。 */
1210
+ function refVisibleForGlobalQuery(path, query) {
1211
+ if (query.startsWith(".") || query.includes("/.")) return true;
1212
+ return !String(path).split("/").some((segment) => segment.startsWith("."));
1213
+ }
1214
+
1215
+ /** 解析索引输出(`<type>\t<path>` 逐行;git 分支只有 f,目录由父路径合成)。 */
1216
+ function refParseIndexOutput(text) {
1217
+ const files = new Set();
1218
+ const dirs = new Set();
1219
+ const excludedSegment = (p) => p.split("/").some((seg) => REF_EXCLUDED_DIRS.has(seg));
1220
+ for (const line of String(text || "").split("\n")) {
1221
+ if (!line) continue;
1222
+ const tab = line.indexOf("\t");
1223
+ if (tab < 0) continue;
1224
+ const type = line.slice(0, tab);
1225
+ const path = line.slice(tab + 1).replace(/\r$/, "");
1226
+ if (!path || excludedSegment(path)) continue;
1227
+ if (type === "d") dirs.add(path);
1228
+ else if (type === "f") files.add(path);
1229
+ // 其它类型(符号链接等)跳过:与本地 provider 只收 isFile/isDirectory 一致
1230
+ }
1231
+ for (const path of Array.from(files)) {
1232
+ let i = path.indexOf("/");
1233
+ while (i > 0) {
1234
+ const dir = path.slice(0, i);
1235
+ if (excludedSegment(dir)) break;
1236
+ dirs.add(dir);
1237
+ i = path.indexOf("/", i + 1);
1238
+ }
1239
+ }
1240
+ const out = [];
1241
+ for (const d of Array.from(dirs).sort()) out.push({ path: d, kind: "directory" });
1242
+ for (const f of Array.from(files).sort()) out.push({ path: f, kind: "file" });
1243
+ return out.length > REF_MAX_ENTRIES ? out.slice(0, REF_MAX_ENTRIES) : out;
1244
+ }
1245
+
1246
+ /** 远端建索引命令:git 仓库走 git ls-files(快且尊重 .gitignore),否则有界 find。 */
1247
+ function refIndexCommand(root) {
1248
+ const target = shellQuotePath(root || "~");
1249
+ const prune = Array.from(REF_EXCLUDED_DIRS).map((n) => "-name " + shellQuote(n)).join(" -o ");
1250
+ return [
1251
+ "cd " + target + " 2>/dev/null || exit 3",
1252
+ "if [ -d .git ] && command -v git >/dev/null 2>&1; then",
1253
+ "git ls-files --cached --others --exclude-standard 2>/dev/null | head -n " + (REF_MAX_ENTRIES + 1) + " | sed 's/^/f\t/'",
1254
+ "else",
1255
+ "find . -mindepth 1 -maxdepth " + REF_FIND_MAXDEPTH + " \\( " + prune + " \\) -prune -o -printf '%y\t%P\\n' 2>/dev/null | head -n " + (REF_MAX_ENTRIES + 1),
1256
+ "fi"
1257
+ ].join("\n");
1258
+ }
1259
+
1112
1260
  // ---------------------------------------------------------------------------
1113
1261
  // apply
1114
1262
  // ---------------------------------------------------------------------------
@@ -2815,8 +2963,124 @@ function apply(ctx, config) {
2815
2963
  handler: function (req, res) { return handleSidebarFile(req, res); }
2816
2964
  }), "dsh-remote-ssh: intercept /sidebar/file");
2817
2965
 
2966
+ // ---- @ 文件引用补全:远程工作区走 SSH(issue #10)----
2967
+ // 包装宿主 ctx.fileReferences 服务实例:远程工作区自己算候选,其余委托回原实现。
2968
+ // 不替换 composition row(file-reference-local)也不需要新的 @deepseek-ai 运行时依赖:
2969
+ // 本地会话的索引/模糊排序保持原样,零回归风险。
2970
+ /** 远端目录列举(复用 remoteListDir 的 LRU 缓存),语义对齐本地 listDirectory。 */
2971
+ async function remoteRefListDirectory(profile, root, displayDirectory, fragment) {
2972
+ const dirRel = String(displayDirectory || "").replace(/\/+$/, "");
2973
+ if (dirRel.split("/").some((seg) => seg && REF_EXCLUDED_DIRS.has(seg))) return [];
2974
+ const base = String(root || "~").replace(/\/+$/, "") || "~";
2975
+ const remoteDir = dirRel ? base + "/" + dirRel : base;
2976
+ const r = await remoteListDir(runPooled, profile, remoteDir);
2977
+ if (!r.ok) return [];
2978
+ const out = [];
2979
+ for (const e of r.entries || []) {
2980
+ const name = String(e && e.name ? e.name : "");
2981
+ if (!name) continue;
2982
+ const isDir = e.type === "directory";
2983
+ if (name.startsWith(".") && !String(fragment || "").startsWith(".")) continue;
2984
+ if (isDir && REF_EXCLUDED_DIRS.has(name)) continue;
2985
+ out.push({ path: String(displayDirectory || "") + name, kind: isDir ? "directory" : "file" });
2986
+ }
2987
+ return refRankCandidates(out, String(fragment || ""), REF_MAX_RESULTS);
2988
+ }
2989
+
2990
+ /** 远端 workspace 索引:缓存 + 后台重建;单次查询只等 REF_QUERY_BUDGET_MS。 */
2991
+ async function refIndexFor(profile, root) {
2992
+ const pk = profileKey(profile);
2993
+ const key = pk + "|" + String(root || "~");
2994
+ const epoch = cacheEpoch(pk);
2995
+ const hit = refIndexCache.get(key);
2996
+ if (hit && hit.epoch === epoch && Date.now() - hit.at <= REF_INDEX_TTL_MS) return hit.entries;
2997
+ let build = refIndexBuilds.get(key);
2998
+ if (!build) {
2999
+ build = (async () => {
3000
+ try {
3001
+ const r = await runPooled(profile, refIndexCommand(root), undefined, MAX_BYTES, false, REF_INDEX_BUILD_TIMEOUT_MS);
3002
+ const entries = refParseIndexOutput(r && r.stdout ? r.stdout : "");
3003
+ // 只有成功(或确实拿到了输出)才写缓存:cd 失败/断连时不能把空索引缓存 60 秒
3004
+ if (r && (r.ok || (r.stdout && String(r.stdout).length > 0))) {
3005
+ refIndexCache.set(key, { at: Date.now(), epoch, entries });
3006
+ }
3007
+ return entries;
3008
+ } catch (e) {
3009
+ return (hit && hit.entries) || [];
3010
+ } finally {
3011
+ refIndexBuilds.delete(key);
3012
+ }
3013
+ })();
3014
+ refIndexBuilds.set(key, build);
3015
+ }
3016
+ try {
3017
+ return await withTimeout(build, REF_QUERY_BUDGET_MS, "文件引用索引", null);
3018
+ } catch (e) {
3019
+ // 超时:先用陈旧索引作答(与本地 provider 的「陈旧索引照常回答、重建在后台」一致)
3020
+ return (hit && hit.entries) || [];
3021
+ }
3022
+ }
3023
+
3024
+ /** 远程 @ 补全:查询语义与本地一致(目录查询 vs 模糊查询)。 */
3025
+ async function remoteRefList(profile, root, query, signal) {
3026
+ if (signal && signal.aborted) return [];
3027
+ const split = refSplitQuery(query);
3028
+ if (split.isDirectoryQuery) {
3029
+ return await remoteRefListDirectory(profile, root, split.directory, split.fragment);
3030
+ }
3031
+ const entries = await refIndexFor(profile, root);
3032
+ if (!entries.length) return [];
3033
+ const visible = entries.filter((e) => refVisibleForGlobalQuery(e.path, split.fragment));
3034
+ return refRankCandidates(visible, split.fragment, REF_MAX_RESULTS);
3035
+ }
3036
+
3037
+ /** 该 agent 是远程工作区会话则返回远端候选,否则返回 null(调用方委托原实现)。 */
3038
+ async function remoteRefListForAgent(agent, query, signal) {
3039
+ const session = agent && agent.session;
3040
+ const cwd = session && session.header && session.header.cwd;
3041
+ if (!cwd) return null;
3042
+ const info = readRemoteInfoSync(cwd);
3043
+ if (!info || !info.host || !info.user || !info.keyPath) return null;
3044
+ const profile = getProfile(info.profileId);
3045
+ if (!profile) return null;
3046
+ return await remoteRefList(profile, info.remotePath || "~", query, signal);
3047
+ }
3048
+
3049
+ /** 包装服务实例;返回恢复函数(失败返回 null)。 */
3050
+ function installFileReferenceBridge(sctx) {
3051
+ const svc = sctx && typeof sctx.get === "function" ? sctx.get("fileReferences") : sctx && sctx.fileReferences;
3052
+ if (!svc || typeof svc.list !== "function") return null;
3053
+ const original = svc.list.bind(svc);
3054
+ const patched = async function (agent, query, signal) {
3055
+ try {
3056
+ const remote = await remoteRefListForAgent(agent, query, signal);
3057
+ if (remote !== null) return remote;
3058
+ } catch (e) {
3059
+ // 任何异常都降级到本地实现:补全不该因为远端问题而不可用
3060
+ }
3061
+ return original(agent, query, signal);
3062
+ };
3063
+ try {
3064
+ svc.list = patched;
3065
+ } catch (e) {
3066
+ return null;
3067
+ }
3068
+ return function restoreFileReferenceList() {
3069
+ try { if (svc.list === patched) svc.list = original; } catch (e) {}
3070
+ };
3071
+ }
3072
+
3073
+ let restoreFileReferences = null;
3074
+ ctx.inject(["fileReferences"], (sctx) => {
3075
+ restoreFileReferences = installFileReferenceBridge(sctx);
3076
+ if (!restoreFileReferences) {
3077
+ try { ctx.logger?.warn("[dsh-remote-ssh] fileReferences 服务不可包装,@ 补全在远程工作区仍只覆盖本地镜像"); } catch (e) {}
3078
+ }
3079
+ });
3080
+
2818
3081
  // ---- 清理 ----
2819
3082
  ctx.effect(() => () => {
3083
+ if (restoreFileReferences) { try { restoreFileReferences(); } catch (e) {} restoreFileReferences = null; }
2820
3084
  clearInterval(idleTimer);
2821
3085
  sessions.forEach(function (s) { try { s.close(); } catch (e) {} });
2822
3086
  sessions.clear();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangfengshun/dsh-remote-ssh",
3
- "version": "2.4.6",
3
+ "version": "2.4.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",