dsh-vscode-mode 0.1.30 → 0.1.35
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/LICENSE +21 -0
- package/README.md +30 -0
- package/lib/client.js +1439 -46
- package/lib/client.js.map +1 -1
- package/lib/index.js +3519 -222
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/capture.ts +3 -0
- package/src/client/events.ts +11 -0
- package/src/client/index.ts +20 -2
- package/src/client/monaco/lsp/index.ts +30 -0
- package/src/client/monaco/lsp/lspClient.ts +192 -0
- package/src/client/monaco/lsp/providers.ts +229 -0
- package/src/client/outline/OutlinePanel.tsx +1 -1
- package/src/client/outline/lspSource.ts +49 -0
- package/src/client/outline/parse.ts +31 -1
- package/src/client/outline/types.ts +2 -0
- package/src/client/paths.ts +36 -0
- package/src/client/sidebar/panels/FileExplorer.ts +95 -30
- package/src/client/sidebar/panels/SearchPanel.ts +11 -2
- package/src/client/state/explorerCache.ts +3 -1
- package/src/client/state/explorerEntriesCache.ts +172 -0
- package/src/client/state/viewStateCache.ts +3 -1
- package/src/client/styles/editor.css +16 -0
- package/src/client/styles/mcp.css +63 -0
- package/src/client/ui/EditorView.ts +61 -6
- package/src/client/ui/LspSettings.ts +268 -0
- package/src/client/ui/McpSettings.ts +5 -1
- package/src/client/ui/PerfSettings.ts +242 -0
- package/src/client/ui/PerfSizeBadge.ts +54 -0
- package/src/compat.ts +3 -2
- package/src/fileOpenSettings.ts +1 -0
- package/src/index.ts +25 -2
- package/src/lsp/client.ts +205 -0
- package/src/lsp/config.ts +107 -0
- package/src/lsp/extmgr.ts +208 -0
- package/src/lsp/jsonrpc.ts +86 -0
- package/src/lsp/manager.ts +133 -0
- package/src/lsp/providers.ts +235 -0
- package/src/lsp/rpc.ts +334 -0
- package/src/lsp/server.ts +346 -0
- package/src/lsp/settings.ts +44 -0
- package/src/lsp/transport.ts +143 -0
- package/src/lsp/uri.ts +62 -0
- package/src/lsp/zip.ts +82 -0
- package/src/paths.ts +284 -0
- package/src/perf.ts +340 -0
- package/src/perfPatch.ts +76 -0
- package/src/routes.ts +7 -12
- package/src/rpc.ts +184 -36
- package/src/search/content.ts +14 -7
- package/src/search/ripgrep.ts +54 -1
- package/src/search/types.ts +4 -1
- package/src/shared/lsp.ts +138 -0
- package/src/shared/rpc.ts +103 -2
- package/src/store.ts +2 -2
- package/src/treeIndex.ts +397 -0
- package/src/treeRead.ts +87 -0
package/lib/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import { readFile, stat } from "node:fs/promises";
|
|
3
|
-
import { dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { basename, dirname, extname, join, normalize, resolve, sep } from "node:path";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import { inflateRawSync } from "node:zlib";
|
|
8
10
|
//#region src/mcp.ts
|
|
9
11
|
/**
|
|
10
12
|
* dsh-vscode-mode host — MCP 运行时管理适配层。
|
|
@@ -368,6 +370,258 @@ async function runPnpmInstall(ctx, profileDir) {
|
|
|
368
370
|
}
|
|
369
371
|
}
|
|
370
372
|
//#endregion
|
|
373
|
+
//#region src/shared/rpc.ts
|
|
374
|
+
/** webServer 精确路由。 */
|
|
375
|
+
const RPC_PATH = "/edrv/rpc";
|
|
376
|
+
//#endregion
|
|
377
|
+
//#region src/paths.ts
|
|
378
|
+
/**
|
|
379
|
+
* dsh-vscode-mode host — 路径常量与动态路径统一出口(PathConst)。
|
|
380
|
+
* 所有 host 侧文件路径/缓存键/路由前缀从这里取,避免散落内联:
|
|
381
|
+
* - 静态:插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 工作区级与
|
|
382
|
+
* user/ 用户级)、日志根、工作区侧车名、路由前缀、静态资源目录(import.meta.url 派生)
|
|
383
|
+
* - 动态:每工作区树缓存文件(workspace/<id>/tree.v<schema>.json)、home 解析
|
|
384
|
+
* (DSH_HOME → ~/.dsh)
|
|
385
|
+
* - 清理:缓存 sweep(分级:工作区级按版本/TTL/空目录、用户级按版本/保留期、根级残留)
|
|
386
|
+
* 纯函数可单测;raw fs 写入与引擎自身插件(settings-file 用 node:fs + DSH_HOME)一致。
|
|
387
|
+
* 作者 ddj 2026-09-01
|
|
388
|
+
*/
|
|
389
|
+
/** 插件 id(也是 .dsh 下插件工作区目录名)。 */
|
|
390
|
+
const PLUGIN_ID = "dsh-vscode-mode";
|
|
391
|
+
/** DSH 工作区目录名(默认 home 下)。 */
|
|
392
|
+
const DSH_DIR_NAME = ".dsh";
|
|
393
|
+
/** home 覆盖环境变量(与引擎 resolveDshHome 一致)。 */
|
|
394
|
+
const DSH_HOME_ENV = "DSH_HOME";
|
|
395
|
+
/** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
|
|
396
|
+
const CACHE_TOTAL_CAP = 209715200;
|
|
397
|
+
/** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
|
|
398
|
+
const SIDECAR = ".dsh-edit-review.json";
|
|
399
|
+
/** 工作区根侧车:归档(用户数据)。 */
|
|
400
|
+
const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
|
|
401
|
+
/** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
|
|
402
|
+
const DEBUG_LOG = ".dsh-edit-review-debug.log";
|
|
403
|
+
/** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
|
|
404
|
+
const LEGACY_TREE_SIDECARS = [".dsh-edit-review-tree.json", ".dsh-vscode-mode-tree.json"];
|
|
405
|
+
/** 路由前缀(兼容性检查用)。 */
|
|
406
|
+
const ROUTE_PREFIX = "/edrv";
|
|
407
|
+
/** Monaco 静态资源前缀。 */
|
|
408
|
+
const VENDOR_PREFIX = "/edrv/vendor";
|
|
409
|
+
/** 稳定短哈希(cwd → 缓存文件名段;sha1 截断,跨运行稳定)。 */
|
|
410
|
+
function hashOf(text) {
|
|
411
|
+
return createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
|
|
412
|
+
}
|
|
413
|
+
/**
|
|
414
|
+
* 可读安全路径段编码:非安全码位 → ~XXXX 大写十六进制(`~` 本身也转义)。
|
|
415
|
+
* 与 DSH 引擎 dsh-session-persistence-jsonl 的 encodeSegment 逐字节一致,
|
|
416
|
+
* 用于从 cwd/id 反推 `~/.dsh/sessions` 下的会话目录,纯函数可单测。
|
|
417
|
+
* @author ddj 2026年09月02号
|
|
418
|
+
* @param raw 原始路径段(非空)
|
|
419
|
+
* @returns 路径安全段(`.`/`..` 特判为 ~002E / ~002E~002E)
|
|
420
|
+
*/
|
|
421
|
+
function encodeSegment(raw) {
|
|
422
|
+
if (raw.length === 0) throw new Error("cannot encode an empty path segment");
|
|
423
|
+
if (raw === ".") return "~002E";
|
|
424
|
+
if (raw === "..") return "~002E~002E";
|
|
425
|
+
let out = "";
|
|
426
|
+
for (let i = 0; i < raw.length; i++) {
|
|
427
|
+
const code = raw.charCodeAt(i);
|
|
428
|
+
const ch = String.fromCharCode(code);
|
|
429
|
+
if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
|
|
430
|
+
else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
431
|
+
}
|
|
432
|
+
return out;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
|
|
436
|
+
* 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
|
|
437
|
+
* @author ddj 2026年09月02号
|
|
438
|
+
* @param cwd 会话工作区绝对路径(非空)
|
|
439
|
+
* @returns `~/.dsh/sessions` 下的工作区目录名
|
|
440
|
+
*/
|
|
441
|
+
function sessionWorkspaceKey(cwd) {
|
|
442
|
+
if (cwd.length === 0) throw new Error("cannot encode an empty project path");
|
|
443
|
+
let readable = "";
|
|
444
|
+
let separatorRun = false;
|
|
445
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
446
|
+
const code = cwd.charCodeAt(i);
|
|
447
|
+
const ch = String.fromCharCode(code);
|
|
448
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
449
|
+
if (!separatorRun) readable += "-";
|
|
450
|
+
separatorRun = true;
|
|
451
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
452
|
+
readable += ch;
|
|
453
|
+
separatorRun = false;
|
|
454
|
+
} else {
|
|
455
|
+
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
456
|
+
separatorRun = false;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
460
|
+
}
|
|
461
|
+
/** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
|
|
462
|
+
function sessionIdSegment(id) {
|
|
463
|
+
return encodeSegment(id);
|
|
464
|
+
}
|
|
465
|
+
/** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
|
|
466
|
+
function sessionsRoot(home = dshHome()) {
|
|
467
|
+
return join(home, "sessions");
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* 解析 DSH home:DSH_HOME(去空白)→ ~/.dsh。
|
|
471
|
+
* @author ddj 2026年09月01号
|
|
472
|
+
* @param env 环境映射(缺省 process.env;测试可注入)
|
|
473
|
+
* @returns 归一化的 DSH home 绝对路径
|
|
474
|
+
*/
|
|
475
|
+
function dshHome(env = process.env) {
|
|
476
|
+
const value = env[DSH_HOME_ENV];
|
|
477
|
+
if (value && value.trim()) return value;
|
|
478
|
+
return join(homedir(), DSH_DIR_NAME);
|
|
479
|
+
}
|
|
480
|
+
/** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
|
|
481
|
+
function pluginCacheRoot(home = dshHome()) {
|
|
482
|
+
return join(home, PLUGIN_ID, "cache");
|
|
483
|
+
}
|
|
484
|
+
/** 工作区级缓存目录:按工作区 id(cwd hash)隔离,换工作区即失效。 */
|
|
485
|
+
function workspaceCacheDir(workspaceId, home = dshHome()) {
|
|
486
|
+
return join(pluginCacheRoot(home), "workspace", workspaceId);
|
|
487
|
+
}
|
|
488
|
+
/** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
|
|
489
|
+
function pluginLogRoot(home = dshHome()) {
|
|
490
|
+
return join(home, PLUGIN_ID, "logs");
|
|
491
|
+
}
|
|
492
|
+
/** 每工作区树缓存文件(工作区级:workspace/<id>/tree.v<schema>.json,文件名含版本便于 sweep)。 */
|
|
493
|
+
function treeCacheFile(cwd, home = dshHome(), schema = 1) {
|
|
494
|
+
return join(workspaceCacheDir(hashOf(cwd), home), "tree.v" + schema + ".json");
|
|
495
|
+
}
|
|
496
|
+
/** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
|
|
497
|
+
function debugLogFile(cwd, home = dshHome()) {
|
|
498
|
+
return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
|
|
499
|
+
}
|
|
500
|
+
/** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
|
|
501
|
+
function assetsDirOf(moduleUrl) {
|
|
502
|
+
return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
|
|
503
|
+
}
|
|
504
|
+
/** Monaco AMD 静态目录(随包发布)。 */
|
|
505
|
+
function vendorDirOf(moduleUrl) {
|
|
506
|
+
return join(assetsDirOf(moduleUrl), "vendor");
|
|
507
|
+
}
|
|
508
|
+
/** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
|
|
509
|
+
function imageDirOf(config, moduleUrl) {
|
|
510
|
+
const cfg = config;
|
|
511
|
+
if (cfg && typeof cfg.imageDir === "string" && cfg.imageDir) return cfg.imageDir.replace(/\\/g, "/");
|
|
512
|
+
return assetsDirOf(moduleUrl);
|
|
513
|
+
}
|
|
514
|
+
/** 树缓存文件名 → schema 版本(不匹配返回 null)。 */
|
|
515
|
+
function treeSchemaOf(filename) {
|
|
516
|
+
const m = /^tree\.v(\d+)\./.exec(filename);
|
|
517
|
+
return m ? Number(m[1]) : null;
|
|
518
|
+
}
|
|
519
|
+
/** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
|
|
520
|
+
async function sweepDirFiles(dir, removed, now, dropAll) {
|
|
521
|
+
const names = await readdir(dir).catch(() => []);
|
|
522
|
+
for (const name of names) {
|
|
523
|
+
const full = join(dir, name);
|
|
524
|
+
let drop = dropAll;
|
|
525
|
+
if (!drop) {
|
|
526
|
+
const schema = treeSchemaOf(name);
|
|
527
|
+
if (schema === null) drop = true;
|
|
528
|
+
else if (schema !== 1) drop = true;
|
|
529
|
+
else try {
|
|
530
|
+
if (now - (await stat(full)).mtimeMs > 2592e6) drop = true;
|
|
531
|
+
} catch (error) {
|
|
532
|
+
drop = true;
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
if (!drop) continue;
|
|
536
|
+
try {
|
|
537
|
+
await rm(full, { force: true });
|
|
538
|
+
removed.push(name);
|
|
539
|
+
} catch (error) {}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
/** 清扫工作区级缓存:逐工作区目录按版本/TTL 清文件,空目录删除。 */
|
|
543
|
+
async function sweepWorkspaceTier(dir, removed, now) {
|
|
544
|
+
const ids = await readdir(dir).catch(() => []);
|
|
545
|
+
for (const id of ids) {
|
|
546
|
+
const wsDir = join(dir, id);
|
|
547
|
+
await sweepDirFiles(wsDir, removed, now, false);
|
|
548
|
+
if (!(await readdir(wsDir).catch(() => [])).length) await rm(wsDir, {
|
|
549
|
+
recursive: true,
|
|
550
|
+
force: true
|
|
551
|
+
}).catch(() => {});
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
/** 总预算兜底:递归收集全部缓存文件,按 mtime 从旧到新删,直到总大小 ≤ 预算。 */
|
|
555
|
+
async function sweepByTotalCap(root, removed, cap) {
|
|
556
|
+
const files = [];
|
|
557
|
+
const walk = async (dir) => {
|
|
558
|
+
const names = await readdir(dir).catch(() => []);
|
|
559
|
+
for (const name of names) {
|
|
560
|
+
const full = join(dir, name);
|
|
561
|
+
try {
|
|
562
|
+
const info = await stat(full);
|
|
563
|
+
if (info.isDirectory()) await walk(full);
|
|
564
|
+
else files.push({
|
|
565
|
+
path: full,
|
|
566
|
+
mtime: info.mtimeMs,
|
|
567
|
+
size: info.size
|
|
568
|
+
});
|
|
569
|
+
} catch (error) {}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
await walk(root);
|
|
573
|
+
let total = files.reduce((sum, f) => sum + f.size, 0);
|
|
574
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
575
|
+
for (const f of files) {
|
|
576
|
+
if (total <= cap) break;
|
|
577
|
+
try {
|
|
578
|
+
await rm(f.path, { force: true });
|
|
579
|
+
total -= f.size;
|
|
580
|
+
removed.push(f.path.slice(root.length + 1));
|
|
581
|
+
} catch (error) {}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* 清理插件缓存根(分级 + 总预算兜底):
|
|
586
|
+
* - 根级残留文件(旧扁平格式/未知)→ 删
|
|
587
|
+
* - workspace/ 工作区级 → 按「schema 版本 + 保留期」清文件,空目录删除
|
|
588
|
+
* - user/ 用户级 → 按「schema 版本 + 保留期」清文件
|
|
589
|
+
* - 总大小超预算 → 按最旧先删(最后防线,防任意增长撑爆磁盘)
|
|
590
|
+
* best-effort 静默。
|
|
591
|
+
* @author ddj 2026年09月01号
|
|
592
|
+
* @param home DSH home(测试可注入)
|
|
593
|
+
* @param cap 总预算字节数(缺省 CACHE_TOTAL_CAP;测试可注入小预算)
|
|
594
|
+
* @returns 删除的文件相对路径清单
|
|
595
|
+
*/
|
|
596
|
+
async function sweepTreeCache(home = dshHome(), cap = CACHE_TOTAL_CAP) {
|
|
597
|
+
const dir = pluginCacheRoot(home);
|
|
598
|
+
const names = await readdir(dir).catch(() => []);
|
|
599
|
+
if (!names.length) return [];
|
|
600
|
+
const removed = [];
|
|
601
|
+
const now = Date.now();
|
|
602
|
+
await Promise.all(names.map(async (name) => {
|
|
603
|
+
const full = join(dir, name);
|
|
604
|
+
try {
|
|
605
|
+
if ((await stat(full)).isDirectory()) {
|
|
606
|
+
if (name === "workspace") await sweepWorkspaceTier(full, removed, now);
|
|
607
|
+
else if (name === "user") await sweepDirFiles(full, removed, now, false);
|
|
608
|
+
else {
|
|
609
|
+
await rm(full, {
|
|
610
|
+
recursive: true,
|
|
611
|
+
force: true
|
|
612
|
+
});
|
|
613
|
+
removed.push(name);
|
|
614
|
+
}
|
|
615
|
+
} else {
|
|
616
|
+
await rm(full, { force: true });
|
|
617
|
+
removed.push(name);
|
|
618
|
+
}
|
|
619
|
+
} catch (error) {}
|
|
620
|
+
}));
|
|
621
|
+
await sweepByTotalCap(dir, removed, cap);
|
|
622
|
+
return removed;
|
|
623
|
+
}
|
|
624
|
+
//#endregion
|
|
371
625
|
//#region src/compat.ts
|
|
372
626
|
/**
|
|
373
627
|
* dsh-vscode-mode host — 兼容层:与其他插件 / DSH 版本的统一探测、适配、护栏与自诊断。
|
|
@@ -381,8 +635,6 @@ const PLUGIN_NAME = "dsh-vscode-mode";
|
|
|
381
635
|
const MCP_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
|
382
636
|
/** 项目级 MCP 的 loader entry id 固定前缀(区分 profile 全局 MCP;loader id 不能用 ':',故用 '.')。 */
|
|
383
637
|
const PROJECT_ENTRY_PREFIX = "vsm-mcp.";
|
|
384
|
-
/** webServer 路由前缀(RPC 精确路由 + 静态资源前缀共用)。 */
|
|
385
|
-
const ROUTE_PREFIX = "/edrv";
|
|
386
638
|
/** 判断新旧格式项目 MCP 条目 id(旧残留不被视为全局 MCP)。 */
|
|
387
639
|
function isProjectEntryId(id) {
|
|
388
640
|
return id.startsWith("vsm-mcp.") || id.startsWith("vsm-mcp:");
|
|
@@ -508,10 +760,6 @@ async function buildReport(ctx, options) {
|
|
|
508
760
|
};
|
|
509
761
|
}
|
|
510
762
|
//#endregion
|
|
511
|
-
//#region src/shared/rpc.ts
|
|
512
|
-
/** webServer 精确路由。 */
|
|
513
|
-
const RPC_PATH = "/edrv/rpc";
|
|
514
|
-
//#endregion
|
|
515
763
|
//#region src/routes.ts
|
|
516
764
|
/**
|
|
517
765
|
* dsh-vscode-mode host — webServer 路由注册(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)。
|
|
@@ -526,7 +774,6 @@ const IMG_ROUTES = [{
|
|
|
526
774
|
file: "compare_select.png"
|
|
527
775
|
}];
|
|
528
776
|
/** Monaco AMD 构建静态资源(随包发布,离线可用;前缀路由 /edrv/vendor/*)。 */
|
|
529
|
-
const VENDOR_PREFIX = "/edrv/vendor";
|
|
530
777
|
const VENDOR_MIME = {
|
|
531
778
|
".js": "text/javascript; charset=utf-8",
|
|
532
779
|
".css": "text/css; charset=utf-8",
|
|
@@ -543,14 +790,10 @@ const VENDOR_MIME = {
|
|
|
543
790
|
".html": "text/html; charset=utf-8",
|
|
544
791
|
".md": "text/markdown"
|
|
545
792
|
};
|
|
546
|
-
/**
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
return join(dirname(fileURLToPath(import.meta.url)), "..", "assets");
|
|
551
|
-
}
|
|
552
|
-
/** Monaco AMD 静态目录(随包发布)。 */
|
|
553
|
-
const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "assets", "vendor");
|
|
793
|
+
/** 图标目录(paths.ts 统一解析:config.imageDir 覆盖优先,否则插件包 assets/)。 */
|
|
794
|
+
const imgDirOf = (config) => imageDirOf(config, import.meta.url);
|
|
795
|
+
/** Monaco AMD 静态目录(随包发布,paths.ts 统一解析)。 */
|
|
796
|
+
const vendorDir = vendorDirOf(import.meta.url);
|
|
554
797
|
/**
|
|
555
798
|
* 注册全部 webServer 路由(带兼容护栏:前缀冲突预警 + 注册失败降级跳过)。
|
|
556
799
|
* @author ddj 2026年08月20号
|
|
@@ -560,7 +803,7 @@ const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "assets",
|
|
|
560
803
|
* @param onWarning 兼容性警告收集(可选)
|
|
561
804
|
*/
|
|
562
805
|
function registerRoutes(ctx, config, handleRpc, onWarning) {
|
|
563
|
-
const imgDir =
|
|
806
|
+
const imgDir = imgDirOf(config);
|
|
564
807
|
const web = ctx.get("webServer");
|
|
565
808
|
if (!web) return;
|
|
566
809
|
resetOwnRoutes();
|
|
@@ -1068,10 +1311,6 @@ function archiveEntryFor(recs, cwd, reason) {
|
|
|
1068
1311
|
}))
|
|
1069
1312
|
};
|
|
1070
1313
|
}
|
|
1071
|
-
//#endregion
|
|
1072
|
-
//#region src/store.ts
|
|
1073
|
-
const SIDECAR = ".dsh-edit-review.json";
|
|
1074
|
-
const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
|
|
1075
1314
|
/** 会话沙箱策略(policyOf):可能不存在,调用方需容忍 undefined。 */
|
|
1076
1315
|
function policyOf(ctx, session) {
|
|
1077
1316
|
const svc = ctx.get("sandboxPolicy");
|
|
@@ -1333,6 +1572,459 @@ function bucketOf(registry, ctx, cwd) {
|
|
|
1333
1572
|
});
|
|
1334
1573
|
}
|
|
1335
1574
|
//#endregion
|
|
1575
|
+
//#region src/tree.ts
|
|
1576
|
+
/** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
|
|
1577
|
+
function entryOrder(a, b) {
|
|
1578
|
+
const aDir = a.type === "directory" ? 0 : 1;
|
|
1579
|
+
const bDir = b.type === "directory" ? 0 : 1;
|
|
1580
|
+
if (aDir !== bDir) return aDir - bDir;
|
|
1581
|
+
const la = a.name.toLocaleLowerCase("en-US");
|
|
1582
|
+
const lb = b.name.toLocaleLowerCase("en-US");
|
|
1583
|
+
return la < lb ? -1 : la > lb ? 1 : 0;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
|
|
1587
|
+
* 任一路径段为 `..` 返回 null(拒绝越界)。
|
|
1588
|
+
* @author ddj 2026年08月26号
|
|
1589
|
+
* @param path 原始相对路径('' 表示根目录)
|
|
1590
|
+
* @returns 归一化相对路径('' = 根),非法返回 null
|
|
1591
|
+
*/
|
|
1592
|
+
function normalizeRel(path) {
|
|
1593
|
+
const rel = String(path ?? "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
1594
|
+
if (!rel) return "";
|
|
1595
|
+
const segs = rel.split("/").filter((seg) => seg !== ".");
|
|
1596
|
+
if (!segs.length) return "";
|
|
1597
|
+
for (const seg of segs) if (seg === "..") return null;
|
|
1598
|
+
return segs.join("/");
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
|
|
1602
|
+
* @author ddj 2026年08月26号
|
|
1603
|
+
* @param rel 父目录相对路径('' = 根)
|
|
1604
|
+
* @param children 目录子项
|
|
1605
|
+
* @returns 排序后的树条目
|
|
1606
|
+
*/
|
|
1607
|
+
function toTreeEntries(rel, children) {
|
|
1608
|
+
const out = [];
|
|
1609
|
+
for (const child of children) {
|
|
1610
|
+
if (!child || typeof child.name !== "string" || !child.name) continue;
|
|
1611
|
+
out.push({
|
|
1612
|
+
name: child.name,
|
|
1613
|
+
path: rel ? rel + "/" + child.name : child.name,
|
|
1614
|
+
type: child.type === "directory" ? "directory" : child.type === "file" ? "file" : "other",
|
|
1615
|
+
size: typeof child.size === "number" ? child.size : void 0
|
|
1616
|
+
});
|
|
1617
|
+
}
|
|
1618
|
+
return out.sort(entryOrder);
|
|
1619
|
+
}
|
|
1620
|
+
/** 列取失败(带稳定语义 code,调用方转文案)。 */
|
|
1621
|
+
var DirListError = class extends Error {
|
|
1622
|
+
code;
|
|
1623
|
+
constructor(code, message) {
|
|
1624
|
+
super(message);
|
|
1625
|
+
this.code = code;
|
|
1626
|
+
}
|
|
1627
|
+
};
|
|
1628
|
+
/** node fs 错误码 → 稳定语义错误。 */
|
|
1629
|
+
function codeOf(err) {
|
|
1630
|
+
const code = err?.code;
|
|
1631
|
+
if (code === "ENOENT") return new DirListError("not-found", "目录不存在");
|
|
1632
|
+
if (code === "ENOTDIR") return new DirListError("not-dir", "不是目录");
|
|
1633
|
+
if (code === "EACCES" || code === "EPERM" || code === "EBUSY") return new DirListError("permission", "权限不足");
|
|
1634
|
+
return new DirListError("io", String(err?.message ?? err));
|
|
1635
|
+
}
|
|
1636
|
+
/** 并行 stat 一批绝对路径 → 目录/文件/other(follow symlink,失败按 other)。 */
|
|
1637
|
+
async function statTypes(paths, out) {
|
|
1638
|
+
const cap = Math.min(paths.length, 256);
|
|
1639
|
+
for (let i = 0; i < cap; i += 32) {
|
|
1640
|
+
const chunk = paths.slice(i, i + 32);
|
|
1641
|
+
await Promise.all(chunk.map(async (p) => {
|
|
1642
|
+
try {
|
|
1643
|
+
const info = await stat(p);
|
|
1644
|
+
out.set(p, info.isDirectory() ? "directory" : info.isFile() ? "file" : "other");
|
|
1645
|
+
} catch (error) {
|
|
1646
|
+
out.set(p, "other");
|
|
1647
|
+
}
|
|
1648
|
+
}));
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* 快速列取目录:一次 readdir(withFileTypes) 判型;symlink 并行 follow-stat。
|
|
1653
|
+
* @author ddj 2026年08月31号
|
|
1654
|
+
* @param absDir 绝对目录路径(fs.resolve 后的 targetKey)
|
|
1655
|
+
* @returns 子项列表(name/type;size 缺省,树界面不展示大小)
|
|
1656
|
+
* @throws DirListError 目录不存在/不是目录/权限不足/IO 失败
|
|
1657
|
+
*/
|
|
1658
|
+
async function listDirCheap(absDir) {
|
|
1659
|
+
let dirents;
|
|
1660
|
+
try {
|
|
1661
|
+
dirents = await readdir(absDir, {
|
|
1662
|
+
withFileTypes: true,
|
|
1663
|
+
encoding: "utf8"
|
|
1664
|
+
});
|
|
1665
|
+
} catch (error) {
|
|
1666
|
+
throw codeOf(error);
|
|
1667
|
+
}
|
|
1668
|
+
const out = [];
|
|
1669
|
+
const syms = [];
|
|
1670
|
+
for (const d of dirents) if (d.isDirectory()) out.push({
|
|
1671
|
+
name: d.name,
|
|
1672
|
+
type: "directory"
|
|
1673
|
+
});
|
|
1674
|
+
else if (d.isFile()) out.push({
|
|
1675
|
+
name: d.name,
|
|
1676
|
+
type: "file"
|
|
1677
|
+
});
|
|
1678
|
+
else if (d.isSymbolicLink()) syms.push(d.name);
|
|
1679
|
+
else out.push({
|
|
1680
|
+
name: d.name,
|
|
1681
|
+
type: "other"
|
|
1682
|
+
});
|
|
1683
|
+
if (syms.length) {
|
|
1684
|
+
const symAbs = syms.map((name) => join(absDir, name));
|
|
1685
|
+
const types = /* @__PURE__ */ new Map();
|
|
1686
|
+
await statTypes(symAbs, types);
|
|
1687
|
+
for (let i = 0; i < syms.length; i++) out.push({
|
|
1688
|
+
name: syms[i],
|
|
1689
|
+
type: types.get(symAbs[i]) ?? "other"
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
return out;
|
|
1693
|
+
}
|
|
1694
|
+
/** 落盘单目录条目上限:超出截断并标 full=false(截断仍可即时渲染,客户端后台刷新补全)。 */
|
|
1695
|
+
const PERSIST_DIR_ENTRIES = 1500;
|
|
1696
|
+
/** 落盘防抖:变更后静默 10s 再写一次。 */
|
|
1697
|
+
const PERSIST_DEBOUNCE_MS = 1e4;
|
|
1698
|
+
/** re-heal 防抖:写入密集期合并为 3s 后一轮。 */
|
|
1699
|
+
const HEAL_DEBOUNCE_MS = 3e3;
|
|
1700
|
+
/** cwd → 索引状态(随会话销毁清理)。 */
|
|
1701
|
+
const states = /* @__PURE__ */ new Map();
|
|
1702
|
+
/** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出/裁剪顺序可预期)。 */
|
|
1703
|
+
let clock = 0;
|
|
1704
|
+
/** 取下一时间戳(不小于真实时间且严格递增)。 */
|
|
1705
|
+
function nextTs() {
|
|
1706
|
+
clock = Math.max(Date.now(), clock + 1);
|
|
1707
|
+
return clock;
|
|
1708
|
+
}
|
|
1709
|
+
/** 条目原始值 → 合法条目(非法返回 null)。 */
|
|
1710
|
+
function entryOf(raw) {
|
|
1711
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1712
|
+
const e = raw;
|
|
1713
|
+
if (typeof e.name !== "string" || !e.name) return null;
|
|
1714
|
+
const type = e.type === "directory" ? "directory" : e.type === "file" ? "file" : "other";
|
|
1715
|
+
return {
|
|
1716
|
+
name: e.name,
|
|
1717
|
+
type
|
|
1718
|
+
};
|
|
1719
|
+
}
|
|
1720
|
+
/** 写入路径 → 相对工作区根的 rel(绝对路径按 cwd 前缀裁剪;越界/非法 → null)。 */
|
|
1721
|
+
function relOf(cwd, path) {
|
|
1722
|
+
let rel = String(path ?? "").trim().replace(/\\/g, "/");
|
|
1723
|
+
const cl = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
1724
|
+
if (rel.toLowerCase().startsWith(cl + "/")) rel = rel.slice(cl.length + 1);
|
|
1725
|
+
else if (rel.startsWith("/") || /^[a-z]:\//i.test(rel)) return null;
|
|
1726
|
+
rel = rel.replace(/^\/+|\/+$/g, "");
|
|
1727
|
+
if (!rel) return null;
|
|
1728
|
+
const segs = rel.split("/").filter((s) => s !== "" && s !== ".");
|
|
1729
|
+
if (!segs.length) return null;
|
|
1730
|
+
for (const s of segs) if (s === "..") return null;
|
|
1731
|
+
return segs.join("/");
|
|
1732
|
+
}
|
|
1733
|
+
/** rel 的父目录 + 全部祖先('a/b/c' → ['a/b','a',''];'a' → [''])。 */
|
|
1734
|
+
function ancestorsOf(rel) {
|
|
1735
|
+
const segs = rel.split("/");
|
|
1736
|
+
const out = [];
|
|
1737
|
+
for (let i = segs.length - 1; i >= 1; i--) out.push(segs.slice(0, i).join("/"));
|
|
1738
|
+
out.push("");
|
|
1739
|
+
return out;
|
|
1740
|
+
}
|
|
1741
|
+
/** 序列化索引数据。 */
|
|
1742
|
+
function indexSerialize(data) {
|
|
1743
|
+
const lean = {
|
|
1744
|
+
v: 1,
|
|
1745
|
+
root: data.root,
|
|
1746
|
+
dirs: {}
|
|
1747
|
+
};
|
|
1748
|
+
for (const [rel, dir] of Object.entries(data.dirs)) lean.dirs[rel] = {
|
|
1749
|
+
ts: dir.ts,
|
|
1750
|
+
full: dir.full,
|
|
1751
|
+
entries: dir.entries.map((e) => ({
|
|
1752
|
+
name: e.name,
|
|
1753
|
+
type: e.type
|
|
1754
|
+
}))
|
|
1755
|
+
};
|
|
1756
|
+
return JSON.stringify(lean);
|
|
1757
|
+
}
|
|
1758
|
+
/** 解析索引文本;损坏/格式不符 → null。 */
|
|
1759
|
+
function indexParse(text) {
|
|
1760
|
+
if (!text) return null;
|
|
1761
|
+
try {
|
|
1762
|
+
const raw = JSON.parse(text);
|
|
1763
|
+
if (!raw || raw.v !== 1 || !raw.dirs || typeof raw.dirs !== "object") return null;
|
|
1764
|
+
const dirs = {};
|
|
1765
|
+
for (const [rel, d] of Object.entries(raw.dirs)) {
|
|
1766
|
+
if (!d || typeof d !== "object") continue;
|
|
1767
|
+
const dir = d;
|
|
1768
|
+
if (!Array.isArray(dir.entries)) continue;
|
|
1769
|
+
const entries = [];
|
|
1770
|
+
for (const rawEntry of dir.entries) {
|
|
1771
|
+
const e = entryOf(rawEntry);
|
|
1772
|
+
if (!e) continue;
|
|
1773
|
+
entries.push({
|
|
1774
|
+
name: e.name,
|
|
1775
|
+
path: rel ? rel + "/" + e.name : e.name,
|
|
1776
|
+
type: e.type
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
if (!entries.length) continue;
|
|
1780
|
+
dirs[rel] = {
|
|
1781
|
+
ts: typeof dir.ts === "number" ? dir.ts : 0,
|
|
1782
|
+
full: dir.full === true,
|
|
1783
|
+
entries
|
|
1784
|
+
};
|
|
1785
|
+
}
|
|
1786
|
+
return {
|
|
1787
|
+
v: 1,
|
|
1788
|
+
root: typeof raw.root === "string" ? raw.root : null,
|
|
1789
|
+
dirs
|
|
1790
|
+
};
|
|
1791
|
+
} catch (error) {
|
|
1792
|
+
return null;
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
/** 落盘裁剪:目录按 ts 取新裁旧、单目录超限截断标 full=false(内存不受影响)。 */
|
|
1796
|
+
function trimPersist(data, maxDirs = 400, maxEntries = PERSIST_DIR_ENTRIES) {
|
|
1797
|
+
const dirs = {};
|
|
1798
|
+
const sorted = Object.entries(data.dirs).sort((a, b) => b[1].ts - a[1].ts);
|
|
1799
|
+
for (const [rel, dir] of sorted) {
|
|
1800
|
+
if (Object.keys(dirs).length >= maxDirs) break;
|
|
1801
|
+
const full = dir.entries.length <= maxEntries;
|
|
1802
|
+
dirs[rel] = {
|
|
1803
|
+
ts: dir.ts,
|
|
1804
|
+
full,
|
|
1805
|
+
entries: full ? dir.entries : dir.entries.slice(0, maxEntries)
|
|
1806
|
+
};
|
|
1807
|
+
}
|
|
1808
|
+
return {
|
|
1809
|
+
v: 1,
|
|
1810
|
+
root: data.root,
|
|
1811
|
+
dirs
|
|
1812
|
+
};
|
|
1813
|
+
}
|
|
1814
|
+
/** 取(或建)某 cwd 的索引状态,并惰性加载落盘快照(合并:文件 ts 更新才采纳)。 */
|
|
1815
|
+
function ensureState(ctx, cwd) {
|
|
1816
|
+
const existing = states.get(cwd);
|
|
1817
|
+
if (existing) return existing;
|
|
1818
|
+
const state = {
|
|
1819
|
+
ctx,
|
|
1820
|
+
cwd,
|
|
1821
|
+
data: {
|
|
1822
|
+
v: 1,
|
|
1823
|
+
root: null,
|
|
1824
|
+
dirs: {}
|
|
1825
|
+
},
|
|
1826
|
+
stale: /* @__PURE__ */ new Set(),
|
|
1827
|
+
dirty: false,
|
|
1828
|
+
persistTimer: null,
|
|
1829
|
+
healTimer: null,
|
|
1830
|
+
pending: /* @__PURE__ */ new Map(),
|
|
1831
|
+
loading: false,
|
|
1832
|
+
loadPromise: null
|
|
1833
|
+
};
|
|
1834
|
+
states.set(cwd, state);
|
|
1835
|
+
state.loadPromise = loadIndex(state);
|
|
1836
|
+
removeLegacyTreeSidecars(state.cwd);
|
|
1837
|
+
return state;
|
|
1838
|
+
}
|
|
1839
|
+
/** 清理旧工作区树 sidecar(.dsh-edit-review-tree.json/.dsh-vscode-mode-tree.json)。 */
|
|
1840
|
+
async function removeLegacyTreeSidecars(cwd) {
|
|
1841
|
+
await Promise.all(LEGACY_TREE_SIDECARS.map(async (name) => {
|
|
1842
|
+
try {
|
|
1843
|
+
await rm(join(cwd, name), { force: true });
|
|
1844
|
+
} catch (error) {}
|
|
1845
|
+
}));
|
|
1846
|
+
}
|
|
1847
|
+
/** 惰性加载落盘快照一次(缺失/损坏 → 空索引;失败静默)。 */
|
|
1848
|
+
async function loadIndex(state) {
|
|
1849
|
+
if (state.loading) return;
|
|
1850
|
+
state.loading = true;
|
|
1851
|
+
try {
|
|
1852
|
+
const parsed = indexParse(await readFile(treeCacheFile(state.cwd), "utf8"));
|
|
1853
|
+
if (!parsed) return;
|
|
1854
|
+
for (const [rel, dir] of Object.entries(parsed.dirs)) {
|
|
1855
|
+
const cur = state.data.dirs[rel];
|
|
1856
|
+
if (!cur || dir.ts > cur.ts) state.data.dirs[rel] = dir;
|
|
1857
|
+
}
|
|
1858
|
+
if (parsed.root && !state.data.root) state.data.root = parsed.root;
|
|
1859
|
+
} catch (error) {} finally {
|
|
1860
|
+
state.loading = false;
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
/** 命中:新鲜且未失效 → 条目(并刷新 ts 保活);否则 null。 */
|
|
1864
|
+
function hitIndex(cwd, rel) {
|
|
1865
|
+
const state = states.get(cwd);
|
|
1866
|
+
if (!state) return null;
|
|
1867
|
+
const dir = state.data.dirs[rel];
|
|
1868
|
+
if (!dir || state.stale.has(rel)) return null;
|
|
1869
|
+
const now = Date.now();
|
|
1870
|
+
if (now - dir.ts > 6e4) return null;
|
|
1871
|
+
dir.ts = now;
|
|
1872
|
+
return dir.entries;
|
|
1873
|
+
}
|
|
1874
|
+
/** 更新某目录条目:写内存 + 打脏 + 防抖落盘;stale 撤销。 */
|
|
1875
|
+
function putIndex(ctx, cwd, rel, entries) {
|
|
1876
|
+
const state = ensureState(ctx, cwd);
|
|
1877
|
+
const dirs = state.data.dirs;
|
|
1878
|
+
if (!dirs[rel] && Object.keys(dirs).length >= 2e3) {
|
|
1879
|
+
let oldest = rel;
|
|
1880
|
+
let oldestTs = Infinity;
|
|
1881
|
+
for (const [k, v] of Object.entries(dirs)) if (v.ts < oldestTs) {
|
|
1882
|
+
oldestTs = v.ts;
|
|
1883
|
+
oldest = k;
|
|
1884
|
+
}
|
|
1885
|
+
delete dirs[oldest];
|
|
1886
|
+
}
|
|
1887
|
+
dirs[rel] = {
|
|
1888
|
+
ts: nextTs(),
|
|
1889
|
+
full: entries.length <= PERSIST_DIR_ENTRIES,
|
|
1890
|
+
entries
|
|
1891
|
+
};
|
|
1892
|
+
state.stale.delete(rel);
|
|
1893
|
+
state.dirty = true;
|
|
1894
|
+
schedulePersist(state);
|
|
1895
|
+
}
|
|
1896
|
+
/** 写入路径失效:父目录+祖先进 stale,并调度后台 re-heal。 */
|
|
1897
|
+
function invalidateIndex(ctx, cwd, path) {
|
|
1898
|
+
const rel = relOf(cwd, path);
|
|
1899
|
+
if (rel === null) return;
|
|
1900
|
+
const state = ensureState(ctx, cwd);
|
|
1901
|
+
for (const anc of ancestorsOf(rel)) state.stale.add(anc);
|
|
1902
|
+
scheduleHeal(state);
|
|
1903
|
+
}
|
|
1904
|
+
/** 调度 re-heal(防抖合并;已有定时器则复用)。 */
|
|
1905
|
+
function scheduleHeal(state) {
|
|
1906
|
+
if (state.healTimer) return;
|
|
1907
|
+
state.healTimer = setTimeout(() => {
|
|
1908
|
+
state.healTimer = null;
|
|
1909
|
+
healRound(state);
|
|
1910
|
+
}, HEAL_DEBOUNCE_MS);
|
|
1911
|
+
}
|
|
1912
|
+
/** 后台自愈一轮:并行重列 ≤HEAL_ROUND_MAX 个 stale 目录;失败移出(交给 TTL/展开路径自愈)。 */
|
|
1913
|
+
async function healRound(state) {
|
|
1914
|
+
if (!state.stale.size) return;
|
|
1915
|
+
const targets = [...state.stale].slice(0, 20);
|
|
1916
|
+
await Promise.all(targets.map(async (rel) => {
|
|
1917
|
+
if ("error" in await listDirCached(state.ctx, state.cwd, rel, true)) state.stale.delete(rel);
|
|
1918
|
+
}));
|
|
1919
|
+
if (state.stale.size) scheduleHeal(state);
|
|
1920
|
+
}
|
|
1921
|
+
/** 调度落盘(防抖;已有定时器则重置)。 */
|
|
1922
|
+
function schedulePersist(state) {
|
|
1923
|
+
if (state.persistTimer) clearTimeout(state.persistTimer);
|
|
1924
|
+
state.persistTimer = setTimeout(() => {
|
|
1925
|
+
state.persistTimer = null;
|
|
1926
|
+
persistNow(state);
|
|
1927
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
1928
|
+
}
|
|
1929
|
+
/** 落盘快照(裁剪 + 序列化 + 超限跳过;raw fs 写 ~/.dsh 缓存根,失败静默)。 */
|
|
1930
|
+
async function persistNow(state) {
|
|
1931
|
+
if (!state.dirty) return;
|
|
1932
|
+
try {
|
|
1933
|
+
const json = indexSerialize(trimPersist(state.data));
|
|
1934
|
+
if (json.length > 8388608) {
|
|
1935
|
+
state.dirty = false;
|
|
1936
|
+
return;
|
|
1937
|
+
}
|
|
1938
|
+
await mkdir(dirname(treeCacheFile(state.cwd)), { recursive: true });
|
|
1939
|
+
await writeFile(treeCacheFile(state.cwd), json, "utf8");
|
|
1940
|
+
state.dirty = false;
|
|
1941
|
+
} catch (error) {}
|
|
1942
|
+
}
|
|
1943
|
+
/** 清理某 cwd 索引(会话销毁时):清定时器,dirty 则尽力补写一次。 */
|
|
1944
|
+
function disposeIndex(cwd) {
|
|
1945
|
+
const state = states.get(cwd);
|
|
1946
|
+
if (!state) return;
|
|
1947
|
+
if (state.persistTimer) {
|
|
1948
|
+
clearTimeout(state.persistTimer);
|
|
1949
|
+
state.persistTimer = null;
|
|
1950
|
+
}
|
|
1951
|
+
if (state.healTimer) {
|
|
1952
|
+
clearTimeout(state.healTimer);
|
|
1953
|
+
state.healTimer = null;
|
|
1954
|
+
}
|
|
1955
|
+
if (state.dirty) persistNow(state);
|
|
1956
|
+
states.delete(cwd);
|
|
1957
|
+
}
|
|
1958
|
+
/** 错误 → 用户文案(DirListError 用自身 message,其余带上下文)。 */
|
|
1959
|
+
function errorText(error) {
|
|
1960
|
+
if (error instanceof DirListError) return error.message;
|
|
1961
|
+
return "读取目录失败:" + String(error);
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* 目录列取统一入口(RPC handler 用):命中索引直出;失效/force 走快路径
|
|
1965
|
+
* (resolve + listDirCheap + putIndex);同 (cwd,rel) 在途请求复用同一 Promise。
|
|
1966
|
+
* @author ddj 2026年08月31号 / 2026年09月01号
|
|
1967
|
+
* @param ctx DSH 上下文(仅用于工作区 IO)
|
|
1968
|
+
* @param cwd 工作区根
|
|
1969
|
+
* @param rel 归一化相对路径('' = 根)
|
|
1970
|
+
* @param force 跳过索引命中,强制实列
|
|
1971
|
+
* @returns 目录条目与工作区根,或错误文案
|
|
1972
|
+
*/
|
|
1973
|
+
async function listDirCached(ctx, cwd, rel, force) {
|
|
1974
|
+
const state = ensureState(ctx, cwd);
|
|
1975
|
+
if (state.loadPromise) await state.loadPromise;
|
|
1976
|
+
const fs = ctx.get("fs");
|
|
1977
|
+
if (!fs) return { error: "缺少 fs" };
|
|
1978
|
+
if (!state.data.root) try {
|
|
1979
|
+
state.data.root = String(fs.processPath(await fs.resolve(".", { cwd })));
|
|
1980
|
+
} catch (error) {
|
|
1981
|
+
return { error: errorText(error) };
|
|
1982
|
+
}
|
|
1983
|
+
const root = state.data.root;
|
|
1984
|
+
if (!force) {
|
|
1985
|
+
const hit = hitIndex(cwd, rel);
|
|
1986
|
+
if (hit) return {
|
|
1987
|
+
entries: hit,
|
|
1988
|
+
root
|
|
1989
|
+
};
|
|
1990
|
+
}
|
|
1991
|
+
const inflight = state.pending.get(rel);
|
|
1992
|
+
if (inflight) {
|
|
1993
|
+
const res = await inflight;
|
|
1994
|
+
return "entries" in res ? {
|
|
1995
|
+
entries: res.entries,
|
|
1996
|
+
root
|
|
1997
|
+
} : res;
|
|
1998
|
+
}
|
|
1999
|
+
const task = (async () => {
|
|
2000
|
+
try {
|
|
2001
|
+
const entries = toTreeEntries(rel, await listDirCheap((await fs.resolve(rel || ".", { cwd })).targetKey));
|
|
2002
|
+
putIndex(ctx, cwd, rel, entries);
|
|
2003
|
+
return { entries };
|
|
2004
|
+
} catch (error) {
|
|
2005
|
+
if (error instanceof DirListError && error.code === "not-found") {
|
|
2006
|
+
if (state.data.dirs[rel]) {
|
|
2007
|
+
delete state.data.dirs[rel];
|
|
2008
|
+
state.dirty = true;
|
|
2009
|
+
schedulePersist(state);
|
|
2010
|
+
}
|
|
2011
|
+
state.stale.delete(rel);
|
|
2012
|
+
}
|
|
2013
|
+
return { error: errorText(error) };
|
|
2014
|
+
}
|
|
2015
|
+
})();
|
|
2016
|
+
state.pending.set(rel, task);
|
|
2017
|
+
try {
|
|
2018
|
+
const res = await task;
|
|
2019
|
+
return "entries" in res ? {
|
|
2020
|
+
entries: res.entries,
|
|
2021
|
+
root
|
|
2022
|
+
} : res;
|
|
2023
|
+
} finally {
|
|
2024
|
+
if (state.pending.get(rel) === task) state.pending.delete(rel);
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
//#endregion
|
|
1336
2028
|
//#region src/capture.ts
|
|
1337
2029
|
/** 校验并提取工具 result meta 中的文件 hunk。 */
|
|
1338
2030
|
function validHunks(raw) {
|
|
@@ -1432,6 +2124,7 @@ async function captureToolResult(ctx, registry, exec, result) {
|
|
|
1432
2124
|
bucket.set(exec.callId, record);
|
|
1433
2125
|
prune(bucket);
|
|
1434
2126
|
await saveBucket(ctx, cwd, bucket, session);
|
|
2127
|
+
invalidateIndex(ctx, cwd, path);
|
|
1435
2128
|
} catch (error) {
|
|
1436
2129
|
console.error("edrv capture failed", error);
|
|
1437
2130
|
}
|
|
@@ -1487,7 +2180,7 @@ function pathMatch(value, query) {
|
|
|
1487
2180
|
* 作者 ddj 2026年08月24号
|
|
1488
2181
|
*/
|
|
1489
2182
|
const STDOUT_CAP$1 = 4 << 20;
|
|
1490
|
-
const STDERR_CAP$
|
|
2183
|
+
const STDERR_CAP$2 = 65536;
|
|
1491
2184
|
const GRACE_MS$1 = 2e4;
|
|
1492
2185
|
/** 默认排除目录(文件/内容搜索共用,导出供 provider 复用)。 */
|
|
1493
2186
|
const EXCLUDES = [
|
|
@@ -1579,6 +2272,65 @@ function parseOutput(handle, maxResults) {
|
|
|
1579
2272
|
};
|
|
1580
2273
|
}
|
|
1581
2274
|
/**
|
|
2275
|
+
* 读取已收集的 stderr(无收集器 → 空文本)。
|
|
2276
|
+
* @author ddj 2026年08月27号
|
|
2277
|
+
* @param handle subprocess handle
|
|
2278
|
+
* @returns stderr 文本
|
|
2279
|
+
*/
|
|
2280
|
+
function readStderr(handle) {
|
|
2281
|
+
const reader = handle.collected?.stderr;
|
|
2282
|
+
return reader ? reader.readFrom(0) : { text: "" };
|
|
2283
|
+
}
|
|
2284
|
+
/**
|
|
2285
|
+
* 取 stderr 首条非空行(去 \r,截断 300 字符防撑爆 UI)。
|
|
2286
|
+
* @author ddj 2026年08月27号
|
|
2287
|
+
* @param text stderr 全文
|
|
2288
|
+
* @returns 首条诊断行(无则空串)
|
|
2289
|
+
*/
|
|
2290
|
+
function firstStderrLine(text) {
|
|
2291
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
2292
|
+
const line = raw.trim();
|
|
2293
|
+
if (!line) continue;
|
|
2294
|
+
return line.length > 300 ? line.slice(0, 300) + "…" : line;
|
|
2295
|
+
}
|
|
2296
|
+
return "";
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
2299
|
+
* 分类 rg 非 0/1 退出:pattern=模式错误(正则/glob 无效,无结果),
|
|
2300
|
+
* partial=遍历错误(rg 已输出的命中仍有效,结果不完整),hard=硬失败(其他退出码/超时被杀)。
|
|
2301
|
+
* @author ddj 2026年08月27号
|
|
2302
|
+
* @param code 退出码(null/undefined = 被终止,通常为 20s 超时)
|
|
2303
|
+
* @param stderrText stderr 全文
|
|
2304
|
+
* @returns 分类与中文提示文案(partial 的 message 即 warning 内容)
|
|
2305
|
+
*/
|
|
2306
|
+
function rgExitFailure(code, stderrText) {
|
|
2307
|
+
const detail = firstStderrLine(stderrText);
|
|
2308
|
+
if (/regex parse error/i.test(detail)) return {
|
|
2309
|
+
kind: "pattern",
|
|
2310
|
+
message: "正则表达式无效:" + detail
|
|
2311
|
+
};
|
|
2312
|
+
if (/error parsing glob/i.test(detail)) return {
|
|
2313
|
+
kind: "pattern",
|
|
2314
|
+
message: "文件过滤模式无效:" + detail
|
|
2315
|
+
};
|
|
2316
|
+
if (code === 2) {
|
|
2317
|
+
const count = String(stderrText ?? "").split(/\r?\n/).filter((line) => line.trim()).length;
|
|
2318
|
+
const suffix = count > 1 ? "(共 " + count + " 处)" : "";
|
|
2319
|
+
return {
|
|
2320
|
+
kind: "partial",
|
|
2321
|
+
message: detail ? "部分路径无法访问,结果可能不完整:" + detail + suffix : "部分路径无法访问,结果可能不完整"
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
if (code === null || code === void 0) return {
|
|
2325
|
+
kind: "hard",
|
|
2326
|
+
message: "搜索超时(20 秒内未完成,已终止)"
|
|
2327
|
+
};
|
|
2328
|
+
return {
|
|
2329
|
+
kind: "hard",
|
|
2330
|
+
message: "ripgrep 退出码:" + String(code) + (detail ? "(" + detail + ")" : "")
|
|
2331
|
+
};
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
1582
2334
|
* 使用打包 ripgrep 搜索工作区文件。
|
|
1583
2335
|
* @author ddj 2026年08月24号
|
|
1584
2336
|
* @param input provider 输入
|
|
@@ -1609,7 +2361,7 @@ async function searchRipgrep(input) {
|
|
|
1609
2361
|
stdio: {
|
|
1610
2362
|
stdin: "ignore",
|
|
1611
2363
|
stdout: { maxBytes: STDOUT_CAP$1 },
|
|
1612
|
-
stderr: { maxBytes: STDERR_CAP$
|
|
2364
|
+
stderr: { maxBytes: STDERR_CAP$2 }
|
|
1613
2365
|
},
|
|
1614
2366
|
graceMs: GRACE_MS$1,
|
|
1615
2367
|
signal: input.signal
|
|
@@ -1621,7 +2373,16 @@ async function searchRipgrep(input) {
|
|
|
1621
2373
|
throw new Error("ripgrep 启动失败:" + String(error));
|
|
1622
2374
|
}
|
|
1623
2375
|
const code = outcome.exitCode ?? outcome.code;
|
|
1624
|
-
if (code !== 0 && code !== 1)
|
|
2376
|
+
if (code !== 0 && code !== 1) {
|
|
2377
|
+
const failure = rgExitFailure(code, readStderr(handle).text);
|
|
2378
|
+
if (failure.kind !== "partial") throw new Error(failure.message);
|
|
2379
|
+
return {
|
|
2380
|
+
...parseOutput(handle, input.maxResults),
|
|
2381
|
+
warning: failure.message,
|
|
2382
|
+
complete: false,
|
|
2383
|
+
source: "ripgrep"
|
|
2384
|
+
};
|
|
2385
|
+
}
|
|
1625
2386
|
const parsed = parseOutput(handle, input.maxResults);
|
|
1626
2387
|
if (!handle.collected?.stdout) throw new Error("ripgrep stdout 不可用");
|
|
1627
2388
|
return {
|
|
@@ -2065,7 +2826,7 @@ function newSearcher(ctx) {
|
|
|
2065
2826
|
//#endregion
|
|
2066
2827
|
//#region src/search/content.ts
|
|
2067
2828
|
const STDOUT_CAP = 16 << 20;
|
|
2068
|
-
const STDERR_CAP = 65536;
|
|
2829
|
+
const STDERR_CAP$1 = 65536;
|
|
2069
2830
|
const GRACE_MS = 2e4;
|
|
2070
2831
|
const DEFAULT_MAX_MATCHES = 500;
|
|
2071
2832
|
const DEFAULT_MAX_FILES = 100;
|
|
@@ -2227,7 +2988,7 @@ async function searchRipgrepContent(input) {
|
|
|
2227
2988
|
stdio: {
|
|
2228
2989
|
stdin: "ignore",
|
|
2229
2990
|
stdout: { maxBytes: STDOUT_CAP },
|
|
2230
|
-
stderr: { maxBytes: STDERR_CAP }
|
|
2991
|
+
stderr: { maxBytes: STDERR_CAP$1 }
|
|
2231
2992
|
},
|
|
2232
2993
|
graceMs: GRACE_MS,
|
|
2233
2994
|
signal: input.signal
|
|
@@ -2239,17 +3000,23 @@ async function searchRipgrepContent(input) {
|
|
|
2239
3000
|
throw new Error("ripgrep 启动失败:" + String(error));
|
|
2240
3001
|
}
|
|
2241
3002
|
const code = outcome.exitCode ?? outcome.code;
|
|
2242
|
-
|
|
3003
|
+
const failure = code !== 0 && code !== 1 ? rgExitFailure(code, readStderr(handle).text) : null;
|
|
3004
|
+
if (failure && failure.kind !== "partial") throw new Error(failure.message);
|
|
2243
3005
|
const reader = handle.collected?.stdout;
|
|
2244
3006
|
if (!reader) throw new Error("ripgrep stdout 不可用");
|
|
2245
3007
|
const output = reader.readFrom(0);
|
|
2246
3008
|
const capped = applyCaps(parseRgJsonLines(output.text, root), Math.max(1, Math.min(input.maxResults ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES)), DEFAULT_MAX_FILES);
|
|
2247
3009
|
const truncated = Boolean(output.lossy) || capped.truncated;
|
|
2248
|
-
|
|
3010
|
+
const result = {
|
|
2249
3011
|
...capped,
|
|
2250
3012
|
complete: !truncated,
|
|
2251
3013
|
source: "ripgrep"
|
|
2252
3014
|
};
|
|
3015
|
+
if (failure) {
|
|
3016
|
+
result.complete = false;
|
|
3017
|
+
result.warning = failure.message;
|
|
3018
|
+
}
|
|
3019
|
+
return result;
|
|
2253
3020
|
}
|
|
2254
3021
|
/**
|
|
2255
3022
|
* 内容搜索编排器:短期缓存 + 同根在途取消 + 会话清理。
|
|
@@ -2314,7 +3081,8 @@ var ContentSearcher = class {
|
|
|
2314
3081
|
const cached = this.cache.get(key);
|
|
2315
3082
|
if (cached) return {
|
|
2316
3083
|
matches: cached.matches,
|
|
2317
|
-
truncated: cached.truncated
|
|
3084
|
+
truncated: cached.truncated,
|
|
3085
|
+
warning: cached.warning
|
|
2318
3086
|
};
|
|
2319
3087
|
this.inflight.get(root)?.abort();
|
|
2320
3088
|
const controller = new AbortController();
|
|
@@ -2341,7 +3109,8 @@ var ContentSearcher = class {
|
|
|
2341
3109
|
this.cache.set(key, result);
|
|
2342
3110
|
return {
|
|
2343
3111
|
matches: result.matches,
|
|
2344
|
-
truncated: result.truncated
|
|
3112
|
+
truncated: result.truncated,
|
|
3113
|
+
warning: result.warning
|
|
2345
3114
|
};
|
|
2346
3115
|
} catch (error) {
|
|
2347
3116
|
if (controller.signal.aborted) return {
|
|
@@ -2813,53 +3582,7 @@ async function projectRefresh(ctx, workspacePath, serverName) {
|
|
|
2813
3582
|
return projectOf(ctx, workspacePath, workspace.title ?? "");
|
|
2814
3583
|
}
|
|
2815
3584
|
//#endregion
|
|
2816
|
-
//#region src/
|
|
2817
|
-
/** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
|
|
2818
|
-
function entryOrder(a, b) {
|
|
2819
|
-
const aDir = a.type === "directory" ? 0 : 1;
|
|
2820
|
-
const bDir = b.type === "directory" ? 0 : 1;
|
|
2821
|
-
if (aDir !== bDir) return aDir - bDir;
|
|
2822
|
-
const la = a.name.toLocaleLowerCase("en-US");
|
|
2823
|
-
const lb = b.name.toLocaleLowerCase("en-US");
|
|
2824
|
-
return la < lb ? -1 : la > lb ? 1 : 0;
|
|
2825
|
-
}
|
|
2826
|
-
/**
|
|
2827
|
-
* 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
|
|
2828
|
-
* 任一路径段为 `..` 返回 null(拒绝越界)。
|
|
2829
|
-
* @author ddj 2026年08月26号
|
|
2830
|
-
* @param path 原始相对路径('' 表示根目录)
|
|
2831
|
-
* @returns 归一化相对路径('' = 根),非法返回 null
|
|
2832
|
-
*/
|
|
2833
|
-
function normalizeRel(path) {
|
|
2834
|
-
const rel = String(path ?? "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
2835
|
-
if (!rel) return "";
|
|
2836
|
-
const segs = rel.split("/").filter((seg) => seg !== ".");
|
|
2837
|
-
if (!segs.length) return "";
|
|
2838
|
-
for (const seg of segs) if (seg === "..") return null;
|
|
2839
|
-
return segs.join("/");
|
|
2840
|
-
}
|
|
2841
|
-
/**
|
|
2842
|
-
* fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
|
|
2843
|
-
* @author ddj 2026年08月26号
|
|
2844
|
-
* @param rel 父目录相对路径('' = 根)
|
|
2845
|
-
* @param children 目录子项
|
|
2846
|
-
* @returns 排序后的树条目
|
|
2847
|
-
*/
|
|
2848
|
-
function toTreeEntries(rel, children) {
|
|
2849
|
-
const out = [];
|
|
2850
|
-
for (const child of children) {
|
|
2851
|
-
if (!child || typeof child.name !== "string" || !child.name) continue;
|
|
2852
|
-
out.push({
|
|
2853
|
-
name: child.name,
|
|
2854
|
-
path: rel ? rel + "/" + child.name : child.name,
|
|
2855
|
-
type: child.type === "directory" ? "directory" : child.type === "file" ? "file" : "other",
|
|
2856
|
-
size: typeof child.size === "number" ? child.size : void 0
|
|
2857
|
-
});
|
|
2858
|
-
}
|
|
2859
|
-
return out.sort(entryOrder);
|
|
2860
|
-
}
|
|
2861
|
-
//#endregion
|
|
2862
|
-
//#region src/reveal.ts
|
|
3585
|
+
//#region src/reveal.ts
|
|
2863
3586
|
/**
|
|
2864
3587
|
* dsh-vscode-mode host — 在 OS 文件浏览器中打开/定位路径(reveal 能力)。
|
|
2865
3588
|
* 纯函数 revealCommand 平台分发 + revealInExplorer 经 ctx.subprocess.spawn 发射
|
|
@@ -2926,6 +3649,462 @@ async function revealInExplorer(ctx, absPath, isDir) {
|
|
|
2926
3649
|
}
|
|
2927
3650
|
}
|
|
2928
3651
|
//#endregion
|
|
3652
|
+
//#region src/perf.ts
|
|
3653
|
+
/**
|
|
3654
|
+
* dsh-vscode-mode host — DSH 会话性能管理(会话卫生 + 体积 stat + 侧车摘要)。
|
|
3655
|
+
* 职责:
|
|
3656
|
+
* - 盘点 ~/.dsh/sessions 全部工作区会话(每会话仅 stat 日志文件,不解压,轻量);
|
|
3657
|
+
* - 移出/恢复/清除:会话目录在 ~/.dsh/sessions 与 ~/.dsh/sessions-archive 间可逆搬迁;
|
|
3658
|
+
* - 当前会话体积 stat(对话头部指示器用,纯 stat);
|
|
3659
|
+
* - 侧车摘要:agent/脚本取关键字段,避免整份读 .dsh-edit-review.json 进对话。
|
|
3660
|
+
* 纯函数可单测(路径编码/移出规划);fs 操作 best-effort 返回逐项结果不抛。
|
|
3661
|
+
* 作者 ddj 2026-09-02
|
|
3662
|
+
*/
|
|
3663
|
+
/** 会话日志文件名(zstd 优先,降级纯 jsonl)。 */
|
|
3664
|
+
const LOG_NAMES = ["session.jsonl.zstd", "session.jsonl"];
|
|
3665
|
+
/** 归档根(~/.dsh/sessions-archive,与 sessions 同级,可逆移出的落点)。 */
|
|
3666
|
+
function sessionsArchiveRoot(home = dshHome()) {
|
|
3667
|
+
return join(home, "sessions-archive");
|
|
3668
|
+
}
|
|
3669
|
+
/** 路径段校验:非空、非 . / ..、不含分隔符与 Windows 非法字符(防 join 逃逸)。 */
|
|
3670
|
+
function validDirName(name) {
|
|
3671
|
+
return typeof name === "string" && name.length > 0 && name !== "." && name !== ".." && !/[/\\]/.test(name) && !/[:*?"<>|]/.test(name);
|
|
3672
|
+
}
|
|
3673
|
+
/** target 必须落在 root 内(自身或其子路径),防路径穿越。 */
|
|
3674
|
+
function withinDir(target, root) {
|
|
3675
|
+
const rel = resolve(target);
|
|
3676
|
+
const base = resolve(root);
|
|
3677
|
+
return rel === base || rel.startsWith(base + sep);
|
|
3678
|
+
}
|
|
3679
|
+
/** stat 一个会话目录里的日志文件(首个存在的),缺失 → null。 */
|
|
3680
|
+
async function statSessionLog(sessionDir) {
|
|
3681
|
+
for (const name of LOG_NAMES) try {
|
|
3682
|
+
const info = await stat(join(sessionDir, name));
|
|
3683
|
+
if (info.isFile()) return {
|
|
3684
|
+
size: info.size,
|
|
3685
|
+
mtimeMs: info.mtimeMs
|
|
3686
|
+
};
|
|
3687
|
+
} catch (error) {}
|
|
3688
|
+
return null;
|
|
3689
|
+
}
|
|
3690
|
+
/**
|
|
3691
|
+
* 盘点 ~/.dsh/sessions:按工作区分组,每会话仅 stat 日志文件体积与 mtime。
|
|
3692
|
+
* 不解压日志(V8 展开成本高),活跃标记由调用方传入后置。
|
|
3693
|
+
* @author ddj 2026年09月02号
|
|
3694
|
+
* @param home DSH home(测试可注入)
|
|
3695
|
+
* @param archive 归档根(默认 sessions-archive;测试可注入)
|
|
3696
|
+
* @returns 工作区聚合(按体积降序)+ 会话明细(按体积降序)+ 汇总
|
|
3697
|
+
*/
|
|
3698
|
+
async function scanSessionInventory(home = dshHome(), archive = sessionsArchiveRoot(home)) {
|
|
3699
|
+
const root = sessionsRoot(home);
|
|
3700
|
+
const workspaces = [];
|
|
3701
|
+
const sessions = [];
|
|
3702
|
+
let totalBytes = 0;
|
|
3703
|
+
let totalCount = 0;
|
|
3704
|
+
const wsDirs = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
3705
|
+
for (const ws of wsDirs) {
|
|
3706
|
+
if (!ws.isDirectory() || !validDirName(ws.name)) continue;
|
|
3707
|
+
const wsDir = join(root, ws.name);
|
|
3708
|
+
const wsSessions = [];
|
|
3709
|
+
let wsBytes = 0;
|
|
3710
|
+
let minMtime = Number.POSITIVE_INFINITY;
|
|
3711
|
+
let maxMtime = 0;
|
|
3712
|
+
const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => []);
|
|
3713
|
+
for (const sd of sessionDirs) {
|
|
3714
|
+
if (!sd.isDirectory()) continue;
|
|
3715
|
+
const info = await statSessionLog(join(wsDir, sd.name));
|
|
3716
|
+
if (!info) continue;
|
|
3717
|
+
const row = {
|
|
3718
|
+
sessionId: sd.name,
|
|
3719
|
+
workspaceKey: ws.name,
|
|
3720
|
+
bytes: info.size,
|
|
3721
|
+
mtime: info.mtimeMs,
|
|
3722
|
+
active: false
|
|
3723
|
+
};
|
|
3724
|
+
wsSessions.push(row);
|
|
3725
|
+
wsBytes += info.size;
|
|
3726
|
+
totalBytes += info.size;
|
|
3727
|
+
totalCount += 1;
|
|
3728
|
+
if (info.mtimeMs < minMtime) minMtime = info.mtimeMs;
|
|
3729
|
+
if (info.mtimeMs > maxMtime) maxMtime = info.mtimeMs;
|
|
3730
|
+
}
|
|
3731
|
+
wsSessions.sort((a, b) => b.bytes - a.bytes);
|
|
3732
|
+
sessions.push(...wsSessions);
|
|
3733
|
+
workspaces.push({
|
|
3734
|
+
workspaceKey: ws.name,
|
|
3735
|
+
sessionCount: wsSessions.length,
|
|
3736
|
+
totalBytes: wsBytes,
|
|
3737
|
+
minMtime: Number.isFinite(minMtime) ? minMtime : 0,
|
|
3738
|
+
maxMtime
|
|
3739
|
+
});
|
|
3740
|
+
}
|
|
3741
|
+
workspaces.sort((a, b) => b.totalBytes - a.totalBytes);
|
|
3742
|
+
return {
|
|
3743
|
+
workspaces,
|
|
3744
|
+
sessions,
|
|
3745
|
+
totals: {
|
|
3746
|
+
workspaces: workspaces.length,
|
|
3747
|
+
sessions: totalCount,
|
|
3748
|
+
totalBytes
|
|
3749
|
+
}
|
|
3750
|
+
};
|
|
3751
|
+
}
|
|
3752
|
+
/** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
|
|
3753
|
+
function markActiveSessions(sessions, activeIds) {
|
|
3754
|
+
const raw = new Set(activeIds);
|
|
3755
|
+
const encoded = /* @__PURE__ */ new Set();
|
|
3756
|
+
for (const id of raw) encoded.add(sessionIdSegment(id));
|
|
3757
|
+
for (const s of sessions) if (raw.has(s.sessionId) || encoded.has(s.sessionId)) s.active = true;
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* 移出规划(纯函数):从盘点中按「显式集合」或「规则(minBytes / olderThanDays)」
|
|
3761
|
+
* 圈选可移出会话。活跃会话一律排除;无显式集合且无规则时不选(防误移)。
|
|
3762
|
+
* @author ddj 2026年09月02号
|
|
3763
|
+
* @param inventory 盘点结果
|
|
3764
|
+
* @param criteria 圈选条件
|
|
3765
|
+
* @returns 待移出清单与释放字节
|
|
3766
|
+
*/
|
|
3767
|
+
function planMoveOut(inventory, criteria) {
|
|
3768
|
+
const items = [];
|
|
3769
|
+
const explicit = Array.isArray(criteria.sessionIds) && criteria.sessionIds.length > 0;
|
|
3770
|
+
const cutoff = criteria.olderThanDays ? Date.now() - criteria.olderThanDays * 24 * 60 * 60 * 1e3 : 0;
|
|
3771
|
+
for (const s of inventory.sessions) {
|
|
3772
|
+
if (s.active) continue;
|
|
3773
|
+
if (criteria.workspaceKey && s.workspaceKey !== criteria.workspaceKey) continue;
|
|
3774
|
+
if (explicit) {
|
|
3775
|
+
if (!criteria.sessionIds.includes(s.sessionId)) continue;
|
|
3776
|
+
} else {
|
|
3777
|
+
if (criteria.minBytes && s.bytes < criteria.minBytes) continue;
|
|
3778
|
+
if (criteria.olderThanDays && s.mtime > cutoff) continue;
|
|
3779
|
+
if (!criteria.minBytes && !criteria.olderThanDays) continue;
|
|
3780
|
+
}
|
|
3781
|
+
items.push({
|
|
3782
|
+
workspaceKey: s.workspaceKey,
|
|
3783
|
+
sessionId: s.sessionId,
|
|
3784
|
+
bytes: s.bytes
|
|
3785
|
+
});
|
|
3786
|
+
}
|
|
3787
|
+
return {
|
|
3788
|
+
items,
|
|
3789
|
+
reclaimedBytes: items.reduce((sum, i) => sum + i.bytes, 0)
|
|
3790
|
+
};
|
|
3791
|
+
}
|
|
3792
|
+
/**
|
|
3793
|
+
* 移出会话目录到归档区(同卷 rename,可逆)。活跃会话拒绝;路径越界拒绝;
|
|
3794
|
+
* 逐项 best-effort,成功项写归档 manifest。默认不 dry-run(dryRun 时只校验不搬迁)。
|
|
3795
|
+
* @author ddj 2026年09月02号
|
|
3796
|
+
* @param home DSH home
|
|
3797
|
+
* @param archive 归档根
|
|
3798
|
+
* @param items 待移出清单(来自 planMoveOut 或用户确认)
|
|
3799
|
+
* @param activeIds 活跃会话 id 集合(执行期再校验一次)
|
|
3800
|
+
* @param dryRun 仅校验不搬迁
|
|
3801
|
+
*/
|
|
3802
|
+
async function moveOutSessions(home, archive, items, activeIds, dryRun = false) {
|
|
3803
|
+
const root = sessionsRoot(home);
|
|
3804
|
+
const rawActive = new Set(activeIds);
|
|
3805
|
+
const encodedActive = /* @__PURE__ */ new Set();
|
|
3806
|
+
for (const id of rawActive) encodedActive.add(sessionIdSegment(id));
|
|
3807
|
+
const isActive = (id) => rawActive.has(id) || encodedActive.has(id);
|
|
3808
|
+
const moved = [];
|
|
3809
|
+
const failures = [];
|
|
3810
|
+
for (const item of items) {
|
|
3811
|
+
if (!validDirName(item.workspaceKey)) {
|
|
3812
|
+
failures.push({
|
|
3813
|
+
workspaceKey: item.workspaceKey,
|
|
3814
|
+
sessionId: item.sessionId,
|
|
3815
|
+
error: "工作区键不合法"
|
|
3816
|
+
});
|
|
3817
|
+
continue;
|
|
3818
|
+
}
|
|
3819
|
+
if (isActive(item.sessionId)) {
|
|
3820
|
+
failures.push({
|
|
3821
|
+
workspaceKey: item.workspaceKey,
|
|
3822
|
+
sessionId: item.sessionId,
|
|
3823
|
+
error: "活跃会话,拒绝移出"
|
|
3824
|
+
});
|
|
3825
|
+
continue;
|
|
3826
|
+
}
|
|
3827
|
+
const seg = sessionIdSegment(item.sessionId);
|
|
3828
|
+
const src = join(root, item.workspaceKey, seg);
|
|
3829
|
+
const dst = join(archive, item.workspaceKey, seg);
|
|
3830
|
+
if (!withinDir(src, root) || !withinDir(dst, archive)) {
|
|
3831
|
+
failures.push({
|
|
3832
|
+
workspaceKey: item.workspaceKey,
|
|
3833
|
+
sessionId: item.sessionId,
|
|
3834
|
+
error: "路径越界,拒绝执行"
|
|
3835
|
+
});
|
|
3836
|
+
continue;
|
|
3837
|
+
}
|
|
3838
|
+
if (dryRun) {
|
|
3839
|
+
try {
|
|
3840
|
+
if ((await stat(src)).isDirectory()) moved.push({
|
|
3841
|
+
...item,
|
|
3842
|
+
bytes: (await statSessionLog(src))?.size ?? item.bytes
|
|
3843
|
+
});
|
|
3844
|
+
else failures.push({
|
|
3845
|
+
workspaceKey: item.workspaceKey,
|
|
3846
|
+
sessionId: item.sessionId,
|
|
3847
|
+
error: "源目录不存在"
|
|
3848
|
+
});
|
|
3849
|
+
} catch (error) {
|
|
3850
|
+
failures.push({
|
|
3851
|
+
workspaceKey: item.workspaceKey,
|
|
3852
|
+
sessionId: item.sessionId,
|
|
3853
|
+
error: "源目录不存在"
|
|
3854
|
+
});
|
|
3855
|
+
}
|
|
3856
|
+
continue;
|
|
3857
|
+
}
|
|
3858
|
+
try {
|
|
3859
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
3860
|
+
await rename(src, dst);
|
|
3861
|
+
const bytes = (await statSessionLog(dst))?.size ?? item.bytes;
|
|
3862
|
+
moved.push({
|
|
3863
|
+
...item,
|
|
3864
|
+
bytes
|
|
3865
|
+
});
|
|
3866
|
+
} catch (error) {
|
|
3867
|
+
failures.push({
|
|
3868
|
+
workspaceKey: item.workspaceKey,
|
|
3869
|
+
sessionId: item.sessionId,
|
|
3870
|
+
error: String(error)
|
|
3871
|
+
});
|
|
3872
|
+
}
|
|
3873
|
+
}
|
|
3874
|
+
if (moved.length && !dryRun) await appendMoveManifest(archive, moved);
|
|
3875
|
+
return {
|
|
3876
|
+
moved,
|
|
3877
|
+
failures,
|
|
3878
|
+
reclaimedBytes: moved.reduce((sum, i) => sum + i.bytes, 0)
|
|
3879
|
+
};
|
|
3880
|
+
}
|
|
3881
|
+
/** 恢复单个会话:归档 → sessions。目标已存在则拒绝(不覆盖)。 */
|
|
3882
|
+
async function restoreSession(home, archive, workspaceKey, sessionId) {
|
|
3883
|
+
if (!validDirName(workspaceKey)) return {
|
|
3884
|
+
ok: false,
|
|
3885
|
+
error: "工作区键不合法"
|
|
3886
|
+
};
|
|
3887
|
+
const seg = sessionIdSegment(sessionId);
|
|
3888
|
+
const root = sessionsRoot(home);
|
|
3889
|
+
const src = join(archive, workspaceKey, seg);
|
|
3890
|
+
const dst = join(root, workspaceKey, seg);
|
|
3891
|
+
if (!withinDir(src, archive) || !withinDir(dst, root)) return {
|
|
3892
|
+
ok: false,
|
|
3893
|
+
error: "路径越界,拒绝执行"
|
|
3894
|
+
};
|
|
3895
|
+
try {
|
|
3896
|
+
await stat(src);
|
|
3897
|
+
} catch (error) {
|
|
3898
|
+
return {
|
|
3899
|
+
ok: false,
|
|
3900
|
+
error: "归档中不存在该会话"
|
|
3901
|
+
};
|
|
3902
|
+
}
|
|
3903
|
+
try {
|
|
3904
|
+
await stat(dst);
|
|
3905
|
+
return {
|
|
3906
|
+
ok: false,
|
|
3907
|
+
error: "目标位置已存在同名会话"
|
|
3908
|
+
};
|
|
3909
|
+
} catch (error) {}
|
|
3910
|
+
try {
|
|
3911
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
3912
|
+
await rename(src, dst);
|
|
3913
|
+
return { ok: true };
|
|
3914
|
+
} catch (error) {
|
|
3915
|
+
return {
|
|
3916
|
+
ok: false,
|
|
3917
|
+
error: String(error)
|
|
3918
|
+
};
|
|
3919
|
+
}
|
|
3920
|
+
}
|
|
3921
|
+
/** 清除归档区早于 N 天的会话(破坏性,仅限归档区)。 */
|
|
3922
|
+
async function purgeArchive(archive, olderThanDays) {
|
|
3923
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1e3;
|
|
3924
|
+
let removed = 0;
|
|
3925
|
+
let reclaimedBytes = 0;
|
|
3926
|
+
const wsDirs = await readdir(archive, { withFileTypes: true }).catch(() => []);
|
|
3927
|
+
for (const ws of wsDirs) {
|
|
3928
|
+
if (!ws.isDirectory()) continue;
|
|
3929
|
+
const wsDir = join(archive, ws.name);
|
|
3930
|
+
const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => []);
|
|
3931
|
+
for (const sd of sessionDirs) {
|
|
3932
|
+
if (!sd.isDirectory()) continue;
|
|
3933
|
+
const info = await statSessionLog(join(wsDir, sd.name));
|
|
3934
|
+
if (!info || info.mtimeMs > cutoff) continue;
|
|
3935
|
+
try {
|
|
3936
|
+
await rm(join(wsDir, sd.name), {
|
|
3937
|
+
recursive: true,
|
|
3938
|
+
force: true
|
|
3939
|
+
});
|
|
3940
|
+
removed += 1;
|
|
3941
|
+
reclaimedBytes += info.size;
|
|
3942
|
+
} catch (error) {}
|
|
3943
|
+
}
|
|
3944
|
+
}
|
|
3945
|
+
return {
|
|
3946
|
+
removed,
|
|
3947
|
+
reclaimedBytes
|
|
3948
|
+
};
|
|
3949
|
+
}
|
|
3950
|
+
/** 当前会话持久化体积(对话头部指示器用):仅 stat,近零成本。 */
|
|
3951
|
+
async function sessionSizeOf(home, cwd, sessionId) {
|
|
3952
|
+
const dir = join(sessionsRoot(home), sessionWorkspaceKey(cwd), sessionIdSegment(sessionId));
|
|
3953
|
+
for (const name of LOG_NAMES) try {
|
|
3954
|
+
const info = await stat(join(dir, name));
|
|
3955
|
+
if (info.isFile()) return {
|
|
3956
|
+
bytes: info.size,
|
|
3957
|
+
exists: true
|
|
3958
|
+
};
|
|
3959
|
+
} catch (error) {}
|
|
3960
|
+
return {
|
|
3961
|
+
bytes: 0,
|
|
3962
|
+
exists: false
|
|
3963
|
+
};
|
|
3964
|
+
}
|
|
3965
|
+
/**
|
|
3966
|
+
* 侧车摘要:返回活跃记录数、每文件 pending 计数、归档体积。
|
|
3967
|
+
* agent/脚本取关键字段即可,不必整份读 .dsh-edit-review.json(避免大对象进对话)。
|
|
3968
|
+
* @author ddj 2026年09月02号
|
|
3969
|
+
* @param ctx DSH host 上下文(fs 服务)
|
|
3970
|
+
* @param cwd 工作区路径(null 时返回空摘要)
|
|
3971
|
+
*/
|
|
3972
|
+
async function sidecarSummaryOf(ctx, cwd) {
|
|
3973
|
+
const fs = ctx.get("fs");
|
|
3974
|
+
const summary = {
|
|
3975
|
+
active: 0,
|
|
3976
|
+
pendingByFile: [],
|
|
3977
|
+
archiveBytes: 0
|
|
3978
|
+
};
|
|
3979
|
+
if (!fs || !cwd) return summary;
|
|
3980
|
+
try {
|
|
3981
|
+
const recs = parseSidecar(await fs.readText(await fs.resolve(SIDECAR, { cwd })))?.workspaces?.[cwd]?.records;
|
|
3982
|
+
if (recs) {
|
|
3983
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
3984
|
+
let active = 0;
|
|
3985
|
+
for (const rec of Object.values(recs)) {
|
|
3986
|
+
if (rec.archived) continue;
|
|
3987
|
+
active += 1;
|
|
3988
|
+
const sm = recSummary(rec);
|
|
3989
|
+
if (sm.pending > 0 && rec.path) byFile.set(rec.path, (byFile.get(rec.path) ?? 0) + sm.pending);
|
|
3990
|
+
}
|
|
3991
|
+
summary.active = active;
|
|
3992
|
+
summary.pendingByFile = [...byFile.entries()].map(([path, pending]) => ({
|
|
3993
|
+
path,
|
|
3994
|
+
pending
|
|
3995
|
+
})).sort((a, b) => b.pending - a.pending);
|
|
3996
|
+
}
|
|
3997
|
+
try {
|
|
3998
|
+
summary.archiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
|
|
3999
|
+
} catch (error) {}
|
|
4000
|
+
} catch (error) {}
|
|
4001
|
+
return summary;
|
|
4002
|
+
}
|
|
4003
|
+
/** 解析归档 manifest(损坏/缺失 → 空)。 */
|
|
4004
|
+
function parseMoveManifest(text) {
|
|
4005
|
+
if (!text) return {
|
|
4006
|
+
version: 1,
|
|
4007
|
+
items: []
|
|
4008
|
+
};
|
|
4009
|
+
try {
|
|
4010
|
+
const data = JSON.parse(text);
|
|
4011
|
+
return {
|
|
4012
|
+
version: 1,
|
|
4013
|
+
items: Array.isArray(data.items) ? data.items : []
|
|
4014
|
+
};
|
|
4015
|
+
} catch (error) {
|
|
4016
|
+
return {
|
|
4017
|
+
version: 1,
|
|
4018
|
+
items: []
|
|
4019
|
+
};
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
/** 追加移出记录到归档 manifest(best-effort,失败不影响主流程)。 */
|
|
4023
|
+
async function appendMoveManifest(archive, items) {
|
|
4024
|
+
try {
|
|
4025
|
+
const file = join(archive, ".manifest.json");
|
|
4026
|
+
const prev = parseMoveManifest(await readFile(file, "utf8").catch(() => null));
|
|
4027
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
4028
|
+
for (const item of items) prev.items.push({
|
|
4029
|
+
at,
|
|
4030
|
+
workspaceKey: item.workspaceKey,
|
|
4031
|
+
sessionId: item.sessionId
|
|
4032
|
+
});
|
|
4033
|
+
await mkdir(archive, { recursive: true });
|
|
4034
|
+
await writeFile(file, JSON.stringify(prev, null, 2), "utf8");
|
|
4035
|
+
} catch (error) {}
|
|
4036
|
+
}
|
|
4037
|
+
//#endregion
|
|
4038
|
+
//#region src/perfPatch.ts
|
|
4039
|
+
/**
|
|
4040
|
+
* dsh-vscode-mode host — profile patch 压缩调优(文本行级,零 YAML 依赖)。
|
|
4041
|
+
* 在 ~/.dsh/profiles/<profile>/cordis.patch.yml 末尾追加/移除一段带标记注释的
|
|
4042
|
+
* id 定向覆盖块(compaction-basic / tool-result-pruner 更低阈值)。
|
|
4043
|
+
* 纯文本函数可单测:幂等追加、整段移除、无块原样返回。
|
|
4044
|
+
* 作者 ddj 2026-09-02
|
|
4045
|
+
*/
|
|
4046
|
+
/** 性能块起始标记注释(文件内唯一锚点)。 */
|
|
4047
|
+
const PERF_MARK_START = "# dsh-vscode-mode perf tuning (start)";
|
|
4048
|
+
/** 性能块结束标记注释。 */
|
|
4049
|
+
const PERF_MARK_END = "# dsh-vscode-mode perf tuning (end)";
|
|
4050
|
+
/**
|
|
4051
|
+
* 建议的压缩调优 YAML 块(顶层 loader patch 条目,id 定向覆盖)。
|
|
4052
|
+
* - compaction-basic:thresholdRatio 0.8→0.6、retainRatio 0.16→0.12(更早触发压缩);
|
|
4053
|
+
* - tool-result-pruner:thresholdChars 8192→4096、head/tail 同步收窄(大 tool result 提前截断)。
|
|
4054
|
+
* 注意:压缩只降低模型可见上下文压力,不重写已持久化会话日志(append-only)。
|
|
4055
|
+
* @author ddj 2026年09月02号
|
|
4056
|
+
* @returns 带前后标记注释与尾换行的 YAML 块
|
|
4057
|
+
*/
|
|
4058
|
+
function perfConfigBlock() {
|
|
4059
|
+
return [
|
|
4060
|
+
PERF_MARK_START,
|
|
4061
|
+
"# 更早触发会话内压缩:降低模型可见上下文压力(仅影响后续轮次,不缩小已持久化日志)。",
|
|
4062
|
+
"- id: compaction-basic",
|
|
4063
|
+
" config:",
|
|
4064
|
+
" thresholdRatio: 0.6",
|
|
4065
|
+
" retainRatio: 0.12",
|
|
4066
|
+
"- id: tool-result-pruner",
|
|
4067
|
+
" config:",
|
|
4068
|
+
" thresholdChars: 4096",
|
|
4069
|
+
" headChars: 2048",
|
|
4070
|
+
" tailChars: 512",
|
|
4071
|
+
PERF_MARK_END,
|
|
4072
|
+
""
|
|
4073
|
+
].join("\n");
|
|
4074
|
+
}
|
|
4075
|
+
/** 当前文本是否已包含性能块。 */
|
|
4076
|
+
function patchHasPerfConfig(text) {
|
|
4077
|
+
return text.includes(PERF_MARK_START);
|
|
4078
|
+
}
|
|
4079
|
+
/**
|
|
4080
|
+
* 移除性能块(起止标记注释之间的整段,含所在行),无块时原样返回。
|
|
4081
|
+
* 移除后压缩多余空行、确保以单个换行结尾。
|
|
4082
|
+
* @author ddj 2026年09月02号
|
|
4083
|
+
* @param text 原 patch 文本
|
|
4084
|
+
* @returns 移除后的文本
|
|
4085
|
+
*/
|
|
4086
|
+
function patchRemovePerfConfig(text) {
|
|
4087
|
+
const start = text.indexOf(PERF_MARK_START);
|
|
4088
|
+
if (start === -1) return text;
|
|
4089
|
+
const end = text.indexOf(PERF_MARK_END, start);
|
|
4090
|
+
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
|
4091
|
+
if (end === -1) return text.slice(0, lineStart).replace(/\n+$/, "\n");
|
|
4092
|
+
const after = (() => {
|
|
4093
|
+
const nl = text.indexOf("\n", end);
|
|
4094
|
+
return nl === -1 ? text.length : nl + 1;
|
|
4095
|
+
})();
|
|
4096
|
+
return (text.slice(0, lineStart) + text.slice(after)).replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
|
|
4097
|
+
}
|
|
4098
|
+
/**
|
|
4099
|
+
* 幂等追加性能块:已有块先整段移除再追加到文件末尾(保证全文件只有一份、位置固定)。
|
|
4100
|
+
* @author ddj 2026年09月02号
|
|
4101
|
+
* @param text 原 patch 文本
|
|
4102
|
+
* @returns 追加后的文本
|
|
4103
|
+
*/
|
|
4104
|
+
function patchInsertPerfConfig(text) {
|
|
4105
|
+
return patchRemovePerfConfig(text).replace(/\n+$/, "") + "\n" + perfConfigBlock();
|
|
4106
|
+
}
|
|
4107
|
+
//#endregion
|
|
2929
4108
|
//#region src/rpc.ts
|
|
2930
4109
|
/** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
|
|
2931
4110
|
const debugBuffers = /* @__PURE__ */ new Map();
|
|
@@ -2944,13 +4123,12 @@ const STALE_CHECK_MIN_MS = 1e4;
|
|
|
2944
4123
|
/**
|
|
2945
4124
|
* 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
|
|
2946
4125
|
* 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
|
|
2947
|
-
* @author ddj 2026年08月26号
|
|
4126
|
+
* @author ddj 2026年08月26号 / 2026年09月01号
|
|
2948
4127
|
* @param ctx DSH 上下文
|
|
2949
|
-
* @param cwd
|
|
2950
|
-
* @param policy 会话沙箱策略(入队时刻捕获)
|
|
4128
|
+
* @param cwd 工作区(日志按 cwd hash 存 ~/.dsh/dsh-vscode-mode/logs/)
|
|
2951
4129
|
* @param line 单条日志文本(不含换行)
|
|
2952
4130
|
*/
|
|
2953
|
-
function enqueueDebug(ctx, cwd,
|
|
4131
|
+
function enqueueDebug(ctx, cwd, line) {
|
|
2954
4132
|
const st = debugBuffers.get(cwd) ?? {
|
|
2955
4133
|
lines: [],
|
|
2956
4134
|
len: 0,
|
|
@@ -2963,7 +4141,7 @@ function enqueueDebug(ctx, cwd, policy, line) {
|
|
|
2963
4141
|
const batch = st.lines;
|
|
2964
4142
|
st.lines = [];
|
|
2965
4143
|
st.len = 0;
|
|
2966
|
-
flushDebug(
|
|
4144
|
+
flushDebug(cwd, batch);
|
|
2967
4145
|
};
|
|
2968
4146
|
if (st.len >= DEBUG_BUF_CAP) {
|
|
2969
4147
|
if (st.timer) clearTimeout(st.timer);
|
|
@@ -2971,24 +4149,27 @@ function enqueueDebug(ctx, cwd, policy, line) {
|
|
|
2971
4149
|
} else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
|
|
2972
4150
|
debugBuffers.set(cwd, st);
|
|
2973
4151
|
}
|
|
4152
|
+
/** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
|
|
4153
|
+
const debugLegacyCleaned = /* @__PURE__ */ new Set();
|
|
2974
4154
|
/**
|
|
2975
4155
|
* 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
|
|
2976
4156
|
* 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
|
|
2977
|
-
* @author ddj 2026年08月26号
|
|
2978
|
-
* @param
|
|
2979
|
-
* @param cwd 工作区
|
|
2980
|
-
* @param policy 会话沙箱策略(enqueue 时刻捕获)
|
|
4157
|
+
* @author ddj 2026年08月26号 / 2026年09月01号
|
|
4158
|
+
* @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
|
|
2981
4159
|
* @param batch 本批日志行
|
|
2982
4160
|
*/
|
|
2983
|
-
function flushDebug(
|
|
4161
|
+
function flushDebug(cwd, batch) {
|
|
2984
4162
|
const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
|
|
2985
4163
|
try {
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
const
|
|
2989
|
-
const appended = await fs.readText(target).catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
|
|
4164
|
+
await mkdir(pluginLogRoot(), { recursive: true });
|
|
4165
|
+
const target = debugLogFile(cwd);
|
|
4166
|
+
const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
|
|
2990
4167
|
const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
|
|
2991
|
-
await
|
|
4168
|
+
await writeFile(target, next, "utf8");
|
|
4169
|
+
if (!debugLegacyCleaned.has(cwd)) {
|
|
4170
|
+
debugLegacyCleaned.add(cwd);
|
|
4171
|
+
await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
|
|
4172
|
+
}
|
|
2992
4173
|
} catch (e) {}
|
|
2993
4174
|
});
|
|
2994
4175
|
debugWriteQueues.set(cwd, task);
|
|
@@ -3073,9 +4254,31 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
|
|
|
3073
4254
|
if (changed) await saveBucket(ctx, cwd, bucket, session);
|
|
3074
4255
|
return results;
|
|
3075
4256
|
}
|
|
4257
|
+
/** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
|
|
4258
|
+
function activeSessionIds(ctx) {
|
|
4259
|
+
const sessions = ctx.get("sessions");
|
|
4260
|
+
if (!sessions || typeof sessions.list !== "function") return /* @__PURE__ */ new Set();
|
|
4261
|
+
return new Set(sessions.list().map((s) => s.id).filter(Boolean));
|
|
4262
|
+
}
|
|
4263
|
+
/** profile patch 文件定位(依赖本插件的 profile,同 devForm)。 */
|
|
4264
|
+
async function patchFileInfo() {
|
|
4265
|
+
const profileDir = findProfileDir();
|
|
4266
|
+
if (!profileDir) return {};
|
|
4267
|
+
return {
|
|
4268
|
+
profileDir,
|
|
4269
|
+
patchPath: join(profileDir, "cordis.patch.yml")
|
|
4270
|
+
};
|
|
4271
|
+
}
|
|
4272
|
+
/** 最新一次压缩配置备份文件名(cordis.patch.yml.bak-<ts>,按名倒序取新)。 */
|
|
4273
|
+
async function latestPatchBackup(patchPath) {
|
|
4274
|
+
const dir = join(patchPath, "..");
|
|
4275
|
+
const base = basename(patchPath);
|
|
4276
|
+
return (await readdir(dir).catch(() => [])).filter((n) => n.startsWith(base + ".bak-")).sort().reverse()[0];
|
|
4277
|
+
}
|
|
3076
4278
|
/** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
|
|
3077
|
-
function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)) {
|
|
4279
|
+
function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers) {
|
|
3078
4280
|
return {
|
|
4281
|
+
...lspHandlers ?? {},
|
|
3079
4282
|
"edrv.list": async (args) => {
|
|
3080
4283
|
const sc = await requireSession(ctx, args.sessionId);
|
|
3081
4284
|
if ("err" in sc) return {
|
|
@@ -3261,6 +4464,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3261
4464
|
try {
|
|
3262
4465
|
const target = await resolveTarget(ctx, sc.session, args.path);
|
|
3263
4466
|
await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session));
|
|
4467
|
+
invalidateIndex(ctx, sc.cwd, args.path);
|
|
3264
4468
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
3265
4469
|
let changed = false;
|
|
3266
4470
|
for (const rec of bucket.values()) if (rec.path === args.path && rec.superseded !== true) {
|
|
@@ -3397,7 +4601,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3397
4601
|
error: sc.err
|
|
3398
4602
|
};
|
|
3399
4603
|
const text = String(args.text ?? "");
|
|
3400
|
-
enqueueDebug(ctx, sc.cwd,
|
|
4604
|
+
enqueueDebug(ctx, sc.cwd, text);
|
|
3401
4605
|
console.error("[edrv-debug] " + text);
|
|
3402
4606
|
return { ok: true };
|
|
3403
4607
|
},
|
|
@@ -3454,40 +4658,22 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3454
4658
|
ok: false,
|
|
3455
4659
|
error: sc.err
|
|
3456
4660
|
};
|
|
3457
|
-
const
|
|
3458
|
-
if (
|
|
4661
|
+
const rel = normalizeRel(args.path);
|
|
4662
|
+
if (rel === null) return {
|
|
3459
4663
|
ok: false,
|
|
3460
|
-
error: "
|
|
4664
|
+
error: "路径不合法"
|
|
4665
|
+
};
|
|
4666
|
+
const res = await listDirCached(ctx, sc.cwd, rel, args.force === true);
|
|
4667
|
+
if ("error" in res) return {
|
|
4668
|
+
ok: false,
|
|
4669
|
+
error: res.error
|
|
4670
|
+
};
|
|
4671
|
+
return {
|
|
4672
|
+
ok: true,
|
|
4673
|
+
root: res.root,
|
|
4674
|
+
path: rel,
|
|
4675
|
+
entries: res.entries
|
|
3461
4676
|
};
|
|
3462
|
-
try {
|
|
3463
|
-
const rel = normalizeRel(args.path);
|
|
3464
|
-
if (rel === null) return {
|
|
3465
|
-
ok: false,
|
|
3466
|
-
error: "路径不合法"
|
|
3467
|
-
};
|
|
3468
|
-
const target = await resolveTarget(ctx, sc.session, rel || ".");
|
|
3469
|
-
const info = await fs.stat(target);
|
|
3470
|
-
if (!info) return {
|
|
3471
|
-
ok: false,
|
|
3472
|
-
error: "目录不存在"
|
|
3473
|
-
};
|
|
3474
|
-
if (info.type !== "directory") return {
|
|
3475
|
-
ok: false,
|
|
3476
|
-
error: "不是目录"
|
|
3477
|
-
};
|
|
3478
|
-
const children = await fs.listDir(target);
|
|
3479
|
-
return {
|
|
3480
|
-
ok: true,
|
|
3481
|
-
root: fs.processPath(await fs.resolve(".", { cwd: sc.cwd })),
|
|
3482
|
-
path: rel,
|
|
3483
|
-
entries: toTreeEntries(rel, children)
|
|
3484
|
-
};
|
|
3485
|
-
} catch (error) {
|
|
3486
|
-
return {
|
|
3487
|
-
ok: false,
|
|
3488
|
-
error: "读取目录失败:" + String(error)
|
|
3489
|
-
};
|
|
3490
|
-
}
|
|
3491
4677
|
},
|
|
3492
4678
|
"edrv.revealInExplorer": async (args) => {
|
|
3493
4679
|
const sc = await requireSession(ctx, args.sessionId);
|
|
@@ -3705,58 +4891,263 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3705
4891
|
error: String(error)
|
|
3706
4892
|
};
|
|
3707
4893
|
}
|
|
3708
|
-
}
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
}
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
4894
|
+
},
|
|
4895
|
+
"edrv.perf.inventory": async () => {
|
|
4896
|
+
try {
|
|
4897
|
+
const home = dshHome();
|
|
4898
|
+
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
|
|
4899
|
+
const active = activeSessionIds(ctx);
|
|
4900
|
+
markActiveSessions(inventory.sessions, active);
|
|
4901
|
+
return {
|
|
4902
|
+
ok: true,
|
|
4903
|
+
...inventory,
|
|
4904
|
+
activeIds: [...active]
|
|
4905
|
+
};
|
|
4906
|
+
} catch (error) {
|
|
4907
|
+
return {
|
|
4908
|
+
ok: false,
|
|
4909
|
+
error: "盘点失败:" + String(error)
|
|
4910
|
+
};
|
|
4911
|
+
}
|
|
4912
|
+
},
|
|
4913
|
+
"edrv.perf.sessionSize": async (args) => {
|
|
4914
|
+
try {
|
|
4915
|
+
return {
|
|
4916
|
+
ok: true,
|
|
4917
|
+
...await sessionSizeOf(dshHome(), args.cwd, args.sessionId ?? "")
|
|
4918
|
+
};
|
|
4919
|
+
} catch (error) {
|
|
4920
|
+
return {
|
|
4921
|
+
ok: false,
|
|
4922
|
+
error: "读取会话体积失败:" + String(error)
|
|
4923
|
+
};
|
|
4924
|
+
}
|
|
4925
|
+
},
|
|
4926
|
+
"edrv.perf.movePlan": async (args) => {
|
|
4927
|
+
try {
|
|
4928
|
+
const home = dshHome();
|
|
4929
|
+
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
|
|
4930
|
+
markActiveSessions(inventory.sessions, activeSessionIds(ctx));
|
|
4931
|
+
return {
|
|
4932
|
+
ok: true,
|
|
4933
|
+
...planMoveOut(inventory, {
|
|
4934
|
+
workspaceKey: args.workspaceKey,
|
|
4935
|
+
sessionIds: args.sessionIds,
|
|
4936
|
+
minBytes: args.minBytes,
|
|
4937
|
+
olderThanDays: args.olderThanDays
|
|
4938
|
+
})
|
|
4939
|
+
};
|
|
4940
|
+
} catch (error) {
|
|
4941
|
+
return {
|
|
4942
|
+
ok: false,
|
|
4943
|
+
error: "移出规划失败:" + String(error)
|
|
4944
|
+
};
|
|
4945
|
+
}
|
|
4946
|
+
},
|
|
4947
|
+
"edrv.perf.moveOut": async (args) => {
|
|
4948
|
+
try {
|
|
4949
|
+
const home = dshHome();
|
|
4950
|
+
const items = (args.sessionIds ?? []).map((sessionId) => ({
|
|
4951
|
+
workspaceKey: args.workspaceKey,
|
|
4952
|
+
sessionId,
|
|
4953
|
+
bytes: 0
|
|
4954
|
+
}));
|
|
4955
|
+
return {
|
|
4956
|
+
ok: true,
|
|
4957
|
+
...await moveOutSessions(home, sessionsArchiveRoot(home), items, activeSessionIds(ctx), args.dryRun === true)
|
|
4958
|
+
};
|
|
4959
|
+
} catch (error) {
|
|
4960
|
+
return {
|
|
4961
|
+
ok: false,
|
|
4962
|
+
error: "移出失败:" + String(error)
|
|
4963
|
+
};
|
|
4964
|
+
}
|
|
4965
|
+
},
|
|
4966
|
+
"edrv.perf.restore": async (args) => {
|
|
4967
|
+
try {
|
|
4968
|
+
const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId);
|
|
4969
|
+
return result.ok ? {
|
|
4970
|
+
ok: true,
|
|
4971
|
+
restored: true
|
|
4972
|
+
} : {
|
|
4973
|
+
ok: false,
|
|
4974
|
+
error: result.error ?? "恢复失败"
|
|
4975
|
+
};
|
|
4976
|
+
} catch (error) {
|
|
4977
|
+
return {
|
|
4978
|
+
ok: false,
|
|
4979
|
+
error: "恢复失败:" + String(error)
|
|
4980
|
+
};
|
|
4981
|
+
}
|
|
4982
|
+
},
|
|
4983
|
+
"edrv.perf.purgeArchive": async (args) => {
|
|
4984
|
+
try {
|
|
4985
|
+
return {
|
|
4986
|
+
ok: true,
|
|
4987
|
+
...await purgeArchive(sessionsArchiveRoot(dshHome()), args.olderThanDays)
|
|
4988
|
+
};
|
|
4989
|
+
} catch (error) {
|
|
4990
|
+
return {
|
|
4991
|
+
ok: false,
|
|
4992
|
+
error: "清除失败:" + String(error)
|
|
4993
|
+
};
|
|
4994
|
+
}
|
|
4995
|
+
},
|
|
4996
|
+
"edrv.perf.sidecarSummary": async (args) => {
|
|
4997
|
+
const sc = await requireSession(ctx, args.sessionId);
|
|
4998
|
+
const cwd = "err" in sc ? null : sc.cwd;
|
|
4999
|
+
try {
|
|
5000
|
+
return {
|
|
5001
|
+
ok: true,
|
|
5002
|
+
...await sidecarSummaryOf(ctx, cwd)
|
|
5003
|
+
};
|
|
5004
|
+
} catch (error) {
|
|
5005
|
+
return {
|
|
5006
|
+
ok: false,
|
|
5007
|
+
error: "读取侧车摘要失败:" + String(error)
|
|
5008
|
+
};
|
|
5009
|
+
}
|
|
5010
|
+
},
|
|
5011
|
+
"edrv.perf.configGet": async () => {
|
|
5012
|
+
try {
|
|
5013
|
+
const { profileDir, patchPath } = await patchFileInfo();
|
|
5014
|
+
const block = perfConfigBlock();
|
|
5015
|
+
if (!patchPath) return {
|
|
5016
|
+
ok: true,
|
|
5017
|
+
applied: false,
|
|
5018
|
+
block
|
|
5019
|
+
};
|
|
5020
|
+
const text = await readFile(patchPath, "utf8").catch(() => "");
|
|
5021
|
+
const backup = await latestPatchBackup(patchPath);
|
|
5022
|
+
return {
|
|
5023
|
+
ok: true,
|
|
5024
|
+
profileDir,
|
|
5025
|
+
patchPath,
|
|
5026
|
+
applied: patchHasPerfConfig(text),
|
|
5027
|
+
block,
|
|
5028
|
+
backup
|
|
5029
|
+
};
|
|
5030
|
+
} catch (error) {
|
|
5031
|
+
return {
|
|
5032
|
+
ok: false,
|
|
5033
|
+
error: "读取压缩配置失败:" + String(error)
|
|
5034
|
+
};
|
|
5035
|
+
}
|
|
5036
|
+
},
|
|
5037
|
+
"edrv.perf.configApply": async () => {
|
|
5038
|
+
try {
|
|
5039
|
+
const { patchPath } = await patchFileInfo();
|
|
5040
|
+
if (!patchPath) return {
|
|
5041
|
+
ok: false,
|
|
5042
|
+
error: "未找到依赖本插件的 profile(检查 DSH_HOME/profiles)"
|
|
5043
|
+
};
|
|
5044
|
+
const text = await readFile(patchPath, "utf8").catch(() => "");
|
|
5045
|
+
const backup = patchPath + ".bak-" + Date.now();
|
|
5046
|
+
if (text) await writeFile(backup, text, "utf8");
|
|
5047
|
+
await mkdir(join(patchPath, ".."), { recursive: true });
|
|
5048
|
+
await writeFile(patchPath, patchInsertPerfConfig(text), "utf8");
|
|
5049
|
+
return {
|
|
5050
|
+
ok: true,
|
|
5051
|
+
applied: true,
|
|
5052
|
+
backup,
|
|
5053
|
+
restart: true
|
|
5054
|
+
};
|
|
5055
|
+
} catch (error) {
|
|
5056
|
+
return {
|
|
5057
|
+
ok: false,
|
|
5058
|
+
error: "写入压缩配置失败:" + String(error)
|
|
5059
|
+
};
|
|
5060
|
+
}
|
|
5061
|
+
},
|
|
5062
|
+
"edrv.perf.configUndo": async () => {
|
|
5063
|
+
try {
|
|
5064
|
+
const { patchPath } = await patchFileInfo();
|
|
5065
|
+
if (!patchPath) return {
|
|
5066
|
+
ok: false,
|
|
5067
|
+
error: "未找到依赖本插件的 profile(检查 DSH_HOME/profiles)"
|
|
5068
|
+
};
|
|
5069
|
+
const backup = await latestPatchBackup(patchPath);
|
|
5070
|
+
if (backup) {
|
|
5071
|
+
const backupPath = join(join(patchPath, ".."), backup);
|
|
5072
|
+
const saved = await readFile(backupPath, "utf8").catch(() => null);
|
|
5073
|
+
if (saved !== null) {
|
|
5074
|
+
await writeFile(patchPath, saved, "utf8");
|
|
5075
|
+
await rm(backupPath, { force: true }).catch(() => {});
|
|
5076
|
+
return {
|
|
5077
|
+
ok: true,
|
|
5078
|
+
restored: true,
|
|
5079
|
+
backup
|
|
5080
|
+
};
|
|
5081
|
+
}
|
|
5082
|
+
}
|
|
5083
|
+
const text = await readFile(patchPath, "utf8").catch(() => "");
|
|
5084
|
+
if (!patchHasPerfConfig(text)) return {
|
|
5085
|
+
ok: false,
|
|
5086
|
+
error: "没有可撤销的压缩配置"
|
|
5087
|
+
};
|
|
5088
|
+
await writeFile(patchPath, patchRemovePerfConfig(text), "utf8");
|
|
5089
|
+
return {
|
|
5090
|
+
ok: true,
|
|
5091
|
+
restored: true
|
|
5092
|
+
};
|
|
5093
|
+
} catch (error) {
|
|
5094
|
+
return {
|
|
5095
|
+
ok: false,
|
|
5096
|
+
error: "撤销压缩配置失败:" + String(error)
|
|
5097
|
+
};
|
|
5098
|
+
}
|
|
5099
|
+
}
|
|
5100
|
+
};
|
|
5101
|
+
}
|
|
5102
|
+
/**
|
|
5103
|
+
* 统一入口:按方法分发到 handler 表。
|
|
5104
|
+
* @author ddj 2026年08月20号 / 2026年08月26号
|
|
5105
|
+
*/
|
|
5106
|
+
async function handleRpc(ctx, registry, method, args, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx), lspHandlers) {
|
|
5107
|
+
const handler = buildHandlers(ctx, registry, searcher, contentSearcher, lspHandlers)[method];
|
|
5108
|
+
if (!handler) return {
|
|
5109
|
+
ok: false,
|
|
5110
|
+
error: "未知方法: " + String(method)
|
|
5111
|
+
};
|
|
5112
|
+
return handler(args);
|
|
5113
|
+
}
|
|
5114
|
+
//#endregion
|
|
5115
|
+
//#region src/mcpIsolation.ts
|
|
5116
|
+
/**
|
|
5117
|
+
* dsh-vscode-mode host — 项目 MCP 的 agent 级隔离。
|
|
5118
|
+
* 项目 MCP 连接仍由 Host 维护,但每个 agent 只继承当前 workspace 的项目工具;
|
|
5119
|
+
* tools.restrict() 负责模型可见性,tools.guard() 负责执行层兜底拒绝。
|
|
5120
|
+
* 作者 ddj 2026年08月22号
|
|
5121
|
+
*/
|
|
5122
|
+
const UNKNOWN_PROJECT = "__unknown_project__";
|
|
5123
|
+
/** 统一 workspace 路径,供 cwd 父路径匹配。 */
|
|
5124
|
+
function normalizePath(path) {
|
|
5125
|
+
return String(path ?? "").replace(/\\/g, "/").replace(/\/+/g, "/").replace(/\/$/, "").toLowerCase();
|
|
5126
|
+
}
|
|
5127
|
+
/** 在已注册 workspace 中匹配 cwd,优先最长父路径。 */
|
|
5128
|
+
function matchWorkspace(cwd, paths) {
|
|
5129
|
+
if (!cwd) return void 0;
|
|
5130
|
+
const target = normalizePath(cwd);
|
|
5131
|
+
const matches = paths.filter((path) => {
|
|
5132
|
+
const root = normalizePath(path);
|
|
5133
|
+
return target === root || target.startsWith(root + "/");
|
|
5134
|
+
});
|
|
5135
|
+
matches.sort((a, b) => normalizePath(b).length - normalizePath(a).length);
|
|
5136
|
+
return matches[0];
|
|
5137
|
+
}
|
|
5138
|
+
/** 根据 entry id 判断是否为新旧格式项目 MCP(兼容层统一判定)。 */
|
|
5139
|
+
const isProjectEntry = isProjectEntryId;
|
|
5140
|
+
/** 返回一个 agent 不应继承的项目工具名。 */
|
|
5141
|
+
function denyTools(toolProjects, currentPath) {
|
|
5142
|
+
return [...toolProjects.entries()].filter(([, owner]) => owner !== currentPath).map(([name]) => name).sort();
|
|
5143
|
+
}
|
|
5144
|
+
/** 从 loader entry 和工具注册表建立工具到项目路径的映射。 */
|
|
5145
|
+
function projectTools(ctx) {
|
|
5146
|
+
const result = /* @__PURE__ */ new Map();
|
|
5147
|
+
const workspaces = ctx.get("workspaceRegistry")?.list?.() ?? [];
|
|
5148
|
+
const byHash = new Map(workspaces.map((ws) => [hashWorkspace(ws.path), ws.path]));
|
|
5149
|
+
const visible = ctx.get("tools")?.view?.(void 0)?.visible;
|
|
5150
|
+
if (!(visible instanceof Map)) return result;
|
|
3760
5151
|
for (const entry of entriesOf(ctx)) {
|
|
3761
5152
|
const id = String(entry.id ?? entry.options?.id ?? "");
|
|
3762
5153
|
if (!isProjectEntry(id)) continue;
|
|
@@ -3856,54 +5247,1960 @@ function dropFileIndex(cwd) {
|
|
|
3856
5247
|
fileIndex.delete(cwd);
|
|
3857
5248
|
}
|
|
3858
5249
|
//#endregion
|
|
3859
|
-
//#region src/
|
|
5250
|
+
//#region src/lsp/jsonrpc.ts
|
|
3860
5251
|
/**
|
|
3861
|
-
*
|
|
3862
|
-
*
|
|
3863
|
-
*
|
|
3864
|
-
*
|
|
3865
|
-
*
|
|
3866
|
-
* 作者 ddj 2026-08-20
|
|
5252
|
+
* dsh-vscode-mode host — LSP JSON-RPC 帧编解码(纯函数,node 可测)。
|
|
5253
|
+
* LSP over stdio 用 Content-Length 帧:
|
|
5254
|
+
* Content-Length: <n>\r\n\r\n<恰好 n 字节 JSON>
|
|
5255
|
+
* 导出:encodeMessage(对象→帧 Buffer)、createFrameParser(流式粘包/拆包)、parseFrame(单帧)。
|
|
5256
|
+
* 作者 ddj 2026-08-27
|
|
3867
5257
|
*/
|
|
3868
|
-
const
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
"
|
|
3875
|
-
"
|
|
3876
|
-
|
|
3877
|
-
];
|
|
5258
|
+
const HEADER_END = "\r\n\r\n";
|
|
5259
|
+
/** 消息头正则(仅取 Content-Length;其它头忽略)。 */
|
|
5260
|
+
const HEADER_RE = /^Content-Length:\s*(\d+)\s*$/im;
|
|
5261
|
+
/** 将对象编码为带 Content-Length 头的帧。 */
|
|
5262
|
+
function encodeMessage(message) {
|
|
5263
|
+
const body = JSON.stringify(message);
|
|
5264
|
+
const head = "Content-Length: " + Buffer.byteLength(body, "utf8") + "\r\n\r\n";
|
|
5265
|
+
return Buffer.from(head + body, "utf8");
|
|
5266
|
+
}
|
|
3878
5267
|
/**
|
|
3879
|
-
*
|
|
3880
|
-
* @author ddj 2026年08月
|
|
3881
|
-
* @param
|
|
3882
|
-
* @
|
|
5268
|
+
* 尝试从 buffer 解析单个帧;成功返回 { body, rest },数据不足返回 null。
|
|
5269
|
+
* @author ddj 2026年08月27号
|
|
5270
|
+
* @param buffer 已累积的字节
|
|
5271
|
+
* @returns 解析结果(rest 为剩余未消费字节)
|
|
3883
5272
|
*/
|
|
3884
|
-
function
|
|
3885
|
-
const
|
|
3886
|
-
|
|
3887
|
-
const
|
|
3888
|
-
|
|
3889
|
-
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
5273
|
+
function parseFrame(buffer) {
|
|
5274
|
+
const headEnd = buffer.indexOf(HEADER_END);
|
|
5275
|
+
if (headEnd < 0) return null;
|
|
5276
|
+
const header = buffer.subarray(0, headEnd).toString("utf8");
|
|
5277
|
+
const m = HEADER_RE.exec(header);
|
|
5278
|
+
if (!m) {
|
|
5279
|
+
const next = buffer.indexOf("Content-Length:");
|
|
5280
|
+
return next >= 0 ? parseFrame(buffer.subarray(next)) : null;
|
|
5281
|
+
}
|
|
5282
|
+
const length = Number(m[1]);
|
|
5283
|
+
const bodyStart = headEnd + 4;
|
|
5284
|
+
if (buffer.length < bodyStart + length) return null;
|
|
5285
|
+
return {
|
|
5286
|
+
body: buffer.subarray(bodyStart, bodyStart + length).toString("utf8"),
|
|
5287
|
+
rest: buffer.subarray(bodyStart + length)
|
|
5288
|
+
};
|
|
5289
|
+
}
|
|
5290
|
+
/** 帧解析器:push 数据块,吐出完整消息(可能拆包后重组、也可能一包多帧)。 */
|
|
5291
|
+
function createFrameParser() {
|
|
5292
|
+
let buffer = Buffer.alloc(0);
|
|
5293
|
+
return {
|
|
5294
|
+
push(chunk) {
|
|
5295
|
+
if (chunk.length) buffer = Buffer.concat([buffer, chunk]);
|
|
5296
|
+
const out = [];
|
|
5297
|
+
for (;;) {
|
|
5298
|
+
const parsed = parseFrame(buffer);
|
|
5299
|
+
if (!parsed) break;
|
|
5300
|
+
buffer = parsed.rest;
|
|
5301
|
+
try {
|
|
5302
|
+
out.push(JSON.parse(parsed.body));
|
|
5303
|
+
} catch (error) {
|
|
5304
|
+
out.push({
|
|
5305
|
+
jsonrpc: "2.0",
|
|
5306
|
+
error: {
|
|
5307
|
+
code: -32700,
|
|
5308
|
+
message: "Parse error: " + String(error)
|
|
5309
|
+
},
|
|
5310
|
+
id: null
|
|
5311
|
+
});
|
|
5312
|
+
}
|
|
5313
|
+
}
|
|
5314
|
+
return out;
|
|
5315
|
+
},
|
|
5316
|
+
clear() {
|
|
5317
|
+
buffer = Buffer.alloc(0);
|
|
5318
|
+
}
|
|
5319
|
+
};
|
|
5320
|
+
}
|
|
5321
|
+
/** 构造标准 JSON-RPC 请求消息。 */
|
|
5322
|
+
function requestMessage(id, method, params) {
|
|
5323
|
+
return {
|
|
5324
|
+
jsonrpc: "2.0",
|
|
5325
|
+
id,
|
|
5326
|
+
method,
|
|
5327
|
+
params
|
|
5328
|
+
};
|
|
5329
|
+
}
|
|
5330
|
+
/** 构造标准 JSON-RPC 通知消息(无 id,fire-and-forget)。 */
|
|
5331
|
+
function notifyMessage(method, params) {
|
|
5332
|
+
return {
|
|
5333
|
+
jsonrpc: "2.0",
|
|
5334
|
+
method,
|
|
5335
|
+
params
|
|
5336
|
+
};
|
|
5337
|
+
}
|
|
5338
|
+
//#endregion
|
|
5339
|
+
//#region src/lsp/transport.ts
|
|
5340
|
+
/**
|
|
5341
|
+
* dsh-vscode-mode host — LSP 语言服务器进程传输层。
|
|
5342
|
+
* 基于 node:child_process.spawn(stdio pipe 双向流)。刻意不用 DSH ctx.subprocess
|
|
5343
|
+
* 抽象:LSP 需要持续写 stdin + 流式读 stdout,而该抽象 stdin 不可写、输出为一次性收集。
|
|
5344
|
+
* 作者 ddj 2026-08-27
|
|
5345
|
+
*/
|
|
5346
|
+
const STDERR_CAP = 262144;
|
|
5347
|
+
const running = /* @__PURE__ */ new Set();
|
|
5348
|
+
/**
|
|
5349
|
+
* 启动语言服务器进程。
|
|
5350
|
+
* @author ddj 2026年08月27号
|
|
5351
|
+
* @param spec 启动参数(argv 数组,不经 shell,Windows 引号安全)
|
|
5352
|
+
* @param onMessage stdout 消息回调
|
|
5353
|
+
* @param logger 诊断日志(可选)
|
|
5354
|
+
* @returns 传输句柄
|
|
5355
|
+
*/
|
|
5356
|
+
function spawnServer(spec, onMessage, logger) {
|
|
5357
|
+
const { argv, cwd, env } = spec;
|
|
5358
|
+
const child = spawn(argv[0], argv.slice(1), {
|
|
5359
|
+
cwd,
|
|
5360
|
+
env: {
|
|
5361
|
+
...process.env,
|
|
5362
|
+
...env
|
|
5363
|
+
},
|
|
5364
|
+
stdio: [
|
|
5365
|
+
"pipe",
|
|
5366
|
+
"pipe",
|
|
5367
|
+
"pipe"
|
|
5368
|
+
],
|
|
5369
|
+
windowsHide: true
|
|
3893
5370
|
});
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
5371
|
+
running.add(child);
|
|
5372
|
+
let stderrBuf = Buffer.alloc(0);
|
|
5373
|
+
let alive = true;
|
|
5374
|
+
const parser = createFrameParser();
|
|
5375
|
+
child.stderr.on("data", (chunk) => {
|
|
5376
|
+
if (stderrBuf.length < STDERR_CAP) stderrBuf = Buffer.concat([stderrBuf, chunk], Math.min(stderrBuf.length + chunk.length, STDERR_CAP));
|
|
5377
|
+
logger?.(chunk.toString("utf8"));
|
|
5378
|
+
});
|
|
5379
|
+
let exited = false;
|
|
5380
|
+
const emitExit = (code, signal) => {
|
|
5381
|
+
if (exited) return;
|
|
5382
|
+
exited = true;
|
|
5383
|
+
alive = false;
|
|
5384
|
+
running.delete(child);
|
|
5385
|
+
transport.onExit?.(code, signal);
|
|
5386
|
+
};
|
|
5387
|
+
child.on("error", (error) => {
|
|
5388
|
+
logger?.("spawn error: " + String(error));
|
|
5389
|
+
emitExit(null, null);
|
|
5390
|
+
});
|
|
5391
|
+
child.on("exit", (code, signal) => emitExit(code, signal));
|
|
5392
|
+
child.on("close", (code, signal) => emitExit(code, signal));
|
|
5393
|
+
const transport = {
|
|
5394
|
+
write(chunk) {
|
|
5395
|
+
if (!alive || !child.stdin.writable) return false;
|
|
5396
|
+
child.stdin.write(chunk);
|
|
5397
|
+
return true;
|
|
5398
|
+
},
|
|
5399
|
+
onMessage,
|
|
5400
|
+
stderr() {
|
|
5401
|
+
return stderrBuf.toString("utf8");
|
|
5402
|
+
},
|
|
5403
|
+
onExit: null,
|
|
5404
|
+
dispose() {
|
|
5405
|
+
if (!alive) return;
|
|
5406
|
+
child.kill();
|
|
5407
|
+
setTimeout(() => {
|
|
5408
|
+
if (!alive) return;
|
|
5409
|
+
alive = false;
|
|
5410
|
+
running.delete(child);
|
|
5411
|
+
try {
|
|
5412
|
+
if (process.platform === "win32") spawn("taskkill", [
|
|
5413
|
+
"/PID",
|
|
5414
|
+
String(child.pid),
|
|
5415
|
+
"/T",
|
|
5416
|
+
"/F"
|
|
5417
|
+
], {
|
|
5418
|
+
windowsHide: true,
|
|
5419
|
+
stdio: "ignore"
|
|
5420
|
+
});
|
|
5421
|
+
else child.kill("SIGKILL");
|
|
5422
|
+
} catch (error) {
|
|
5423
|
+
logger?.("force kill error: " + String(error));
|
|
5424
|
+
}
|
|
5425
|
+
}, 1500).unref?.();
|
|
5426
|
+
},
|
|
5427
|
+
get alive() {
|
|
5428
|
+
return alive;
|
|
5429
|
+
},
|
|
5430
|
+
get pid() {
|
|
5431
|
+
return child.pid;
|
|
3901
5432
|
}
|
|
5433
|
+
};
|
|
5434
|
+
child.stdout.on("data", (chunk) => {
|
|
5435
|
+
for (const message of parser.push(chunk)) transport.onMessage?.(message);
|
|
3902
5436
|
});
|
|
3903
|
-
|
|
3904
|
-
|
|
3905
|
-
|
|
3906
|
-
|
|
5437
|
+
return transport;
|
|
5438
|
+
}
|
|
5439
|
+
/** 强杀全部运行中的 LSP 子进程(插件卸载/退出用)。 */
|
|
5440
|
+
function disposeAllServers() {
|
|
5441
|
+
for (const child of running) try {
|
|
5442
|
+
if (process.platform === "win32" && child.pid) spawn("taskkill", [
|
|
5443
|
+
"/PID",
|
|
5444
|
+
String(child.pid),
|
|
5445
|
+
"/T",
|
|
5446
|
+
"/F"
|
|
5447
|
+
], {
|
|
5448
|
+
windowsHide: true,
|
|
5449
|
+
stdio: "ignore"
|
|
5450
|
+
});
|
|
5451
|
+
else child.kill("SIGKILL");
|
|
5452
|
+
} catch (error) {}
|
|
5453
|
+
running.clear();
|
|
5454
|
+
}
|
|
5455
|
+
//#endregion
|
|
5456
|
+
//#region src/lsp/client.ts
|
|
5457
|
+
/**
|
|
5458
|
+
* dsh-vscode-mode host — LSP 客户端(JSON-RPC over stdio)。
|
|
5459
|
+
* 职责:请求/通知/握手/超时/服务器端请求处理/生命周期。transport 无关性:
|
|
5460
|
+
* 只依赖 write/onMessage 契约,便于单测时注入假传输。
|
|
5461
|
+
* 作者 ddj 2026-08-27
|
|
5462
|
+
*/
|
|
5463
|
+
const DEFAULT_TIMEOUT_MS = 2e4;
|
|
5464
|
+
var LspError = class extends Error {
|
|
5465
|
+
code;
|
|
5466
|
+
constructor(code, message) {
|
|
5467
|
+
super(message);
|
|
5468
|
+
this.code = code;
|
|
5469
|
+
}
|
|
5470
|
+
};
|
|
5471
|
+
/**
|
|
5472
|
+
* 创建 LSP 客户端。
|
|
5473
|
+
* @author ddj 2026年08月27号
|
|
5474
|
+
* @param transport 传输句柄(write/onMessage/alive/dispose)
|
|
5475
|
+
* @param events 事件回调
|
|
5476
|
+
* @returns LSP 客户端
|
|
5477
|
+
*/
|
|
5478
|
+
function createLspClient(transport, events = {}) {
|
|
5479
|
+
let nextId = 1;
|
|
5480
|
+
let alive = transport.alive;
|
|
5481
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5482
|
+
let serverCapabilities = {
|
|
5483
|
+
definition: false,
|
|
5484
|
+
references: false,
|
|
5485
|
+
documentSymbol: false,
|
|
5486
|
+
workspaceSymbol: false,
|
|
5487
|
+
hover: false
|
|
5488
|
+
};
|
|
5489
|
+
const log = (line) => events.onLog?.(line);
|
|
5490
|
+
createFrameParser();
|
|
5491
|
+
transport.onMessage = (message) => {
|
|
5492
|
+
const msg = message;
|
|
5493
|
+
if (typeof msg.method === "string") {
|
|
5494
|
+
if (msg.method === "$/progress") events.onProgress?.(msg.params);
|
|
5495
|
+
handleServerRequest(msg);
|
|
5496
|
+
return;
|
|
5497
|
+
}
|
|
5498
|
+
if (typeof msg.id === "number" || typeof msg.id === "string") {
|
|
5499
|
+
const entry = pending.get(Number(msg.id));
|
|
5500
|
+
if (!entry) return;
|
|
5501
|
+
pending.delete(Number(msg.id));
|
|
5502
|
+
clearTimeout(entry.timer);
|
|
5503
|
+
if (msg.error) {
|
|
5504
|
+
const err = msg.error;
|
|
5505
|
+
entry.reject(new LspError(err.code ?? -32e3, err.message ?? "LSP error"));
|
|
5506
|
+
} else entry.resolve(msg.result);
|
|
5507
|
+
}
|
|
5508
|
+
};
|
|
5509
|
+
/** 服务器端请求:消息/日志类打日志,配置/能力类回 result:null,其余回 method not found。 */
|
|
5510
|
+
const handleServerRequest = async (msg) => {
|
|
5511
|
+
const { method, params, id } = msg;
|
|
5512
|
+
const respond = (payload) => {
|
|
5513
|
+
transport.write(encodeMessage(payload));
|
|
5514
|
+
};
|
|
5515
|
+
try {
|
|
5516
|
+
if (method === "window/logMessage" || method === "window/showMessage" || method === "window/showMessageRequest") {
|
|
5517
|
+
const p = params;
|
|
5518
|
+
log("[server " + (p?.type ?? "") + "] " + String(p?.message ?? ""));
|
|
5519
|
+
if (id !== void 0) respond({
|
|
5520
|
+
jsonrpc: "2.0",
|
|
5521
|
+
id,
|
|
5522
|
+
result: null
|
|
5523
|
+
});
|
|
5524
|
+
return;
|
|
5525
|
+
}
|
|
5526
|
+
if (method === "workspace/configuration") {
|
|
5527
|
+
const p = params;
|
|
5528
|
+
const items = Array.isArray(p?.items) ? p.items.map(() => null) : [];
|
|
5529
|
+
if (id !== void 0) respond({
|
|
5530
|
+
jsonrpc: "2.0",
|
|
5531
|
+
id,
|
|
5532
|
+
result: items
|
|
5533
|
+
});
|
|
5534
|
+
return;
|
|
5535
|
+
}
|
|
5536
|
+
if (method === "window/workDoneProgress/create" || method === "client/registerCapability" || method === "workspace/workspaceFolders") {
|
|
5537
|
+
if (id !== void 0) respond({
|
|
5538
|
+
jsonrpc: "2.0",
|
|
5539
|
+
id,
|
|
5540
|
+
result: null
|
|
5541
|
+
});
|
|
5542
|
+
return;
|
|
5543
|
+
}
|
|
5544
|
+
if (id !== void 0) respond({
|
|
5545
|
+
jsonrpc: "2.0",
|
|
5546
|
+
id,
|
|
5547
|
+
error: {
|
|
5548
|
+
code: -32601,
|
|
5549
|
+
message: "Method not found: " + method
|
|
5550
|
+
}
|
|
5551
|
+
});
|
|
5552
|
+
} catch (error) {
|
|
5553
|
+
log("server request handler error: " + String(error));
|
|
5554
|
+
if (id !== void 0) respond({
|
|
5555
|
+
jsonrpc: "2.0",
|
|
5556
|
+
id,
|
|
5557
|
+
error: {
|
|
5558
|
+
code: -32603,
|
|
5559
|
+
message: String(error)
|
|
5560
|
+
}
|
|
5561
|
+
});
|
|
5562
|
+
}
|
|
5563
|
+
};
|
|
5564
|
+
const transportExit = transport.onExit;
|
|
5565
|
+
transport.onExit = (code, signal) => {
|
|
5566
|
+
alive = false;
|
|
5567
|
+
for (const entry of pending.values()) {
|
|
5568
|
+
clearTimeout(entry.timer);
|
|
5569
|
+
entry.reject(/* @__PURE__ */ new Error("LSP 服务器已退出(" + String(code) + "/" + String(signal) + ")"));
|
|
5570
|
+
}
|
|
5571
|
+
pending.clear();
|
|
5572
|
+
events.onExit?.(code, signal);
|
|
5573
|
+
transportExit?.(code, signal);
|
|
5574
|
+
};
|
|
5575
|
+
const client = {
|
|
5576
|
+
async request(method, params, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
5577
|
+
if (!alive) throw new Error("LSP 服务器未运行");
|
|
5578
|
+
const id = nextId++;
|
|
5579
|
+
return new Promise((resolve, reject) => {
|
|
5580
|
+
const timer = setTimeout(() => {
|
|
5581
|
+
pending.delete(id);
|
|
5582
|
+
reject(/* @__PURE__ */ new Error("LSP 请求超时:" + method + "(" + timeoutMs + "ms)"));
|
|
5583
|
+
}, timeoutMs);
|
|
5584
|
+
pending.set(id, {
|
|
5585
|
+
resolve,
|
|
5586
|
+
reject,
|
|
5587
|
+
timer
|
|
5588
|
+
});
|
|
5589
|
+
transport.write(encodeMessage(requestMessage(id, method, params)));
|
|
5590
|
+
});
|
|
5591
|
+
},
|
|
5592
|
+
notify(method, params) {
|
|
5593
|
+
if (!alive) return;
|
|
5594
|
+
transport.write(encodeMessage(notifyMessage(method, params)));
|
|
5595
|
+
},
|
|
5596
|
+
async initialize(rootUri, workspaceFolders) {
|
|
5597
|
+
const caps = (await client.request("initialize", {
|
|
5598
|
+
processId: process.pid,
|
|
5599
|
+
rootUri: rootUri || null,
|
|
5600
|
+
rootPath: null,
|
|
5601
|
+
workspaceFolders: workspaceFolders.length ? workspaceFolders.map((uri) => ({
|
|
5602
|
+
uri,
|
|
5603
|
+
name: uri
|
|
5604
|
+
})) : null,
|
|
5605
|
+
capabilities: {
|
|
5606
|
+
window: { workDoneProgress: true },
|
|
5607
|
+
workspace: { configuration: true },
|
|
5608
|
+
textDocument: {
|
|
5609
|
+
synchronization: {
|
|
5610
|
+
dynamicRegistration: false,
|
|
5611
|
+
willSave: false,
|
|
5612
|
+
didSave: true
|
|
5613
|
+
},
|
|
5614
|
+
definition: { dynamicRegistration: false },
|
|
5615
|
+
references: { dynamicRegistration: false },
|
|
5616
|
+
documentSymbol: { dynamicRegistration: false },
|
|
5617
|
+
hover: { dynamicRegistration: false }
|
|
5618
|
+
}
|
|
5619
|
+
}
|
|
5620
|
+
}))?.capabilities ?? {};
|
|
5621
|
+
serverCapabilities = {
|
|
5622
|
+
definition: Boolean(caps.definitionProvider),
|
|
5623
|
+
references: Boolean(caps.referencesProvider),
|
|
5624
|
+
documentSymbol: Boolean(caps.documentSymbolProvider),
|
|
5625
|
+
workspaceSymbol: Boolean(caps.workspaceSymbolProvider),
|
|
5626
|
+
hover: Boolean(caps.hoverProvider)
|
|
5627
|
+
};
|
|
5628
|
+
client.notify("initialized", {});
|
|
5629
|
+
events.onReady?.(serverCapabilities);
|
|
5630
|
+
return serverCapabilities;
|
|
5631
|
+
},
|
|
5632
|
+
async shutdown() {
|
|
5633
|
+
try {
|
|
5634
|
+
await client.request("shutdown", null, 3e3);
|
|
5635
|
+
} catch (error) {
|
|
5636
|
+
log("shutdown: " + String(error));
|
|
5637
|
+
}
|
|
5638
|
+
try {
|
|
5639
|
+
client.notify("exit", null);
|
|
5640
|
+
} catch (error) {
|
|
5641
|
+
log("exit: " + String(error));
|
|
5642
|
+
}
|
|
5643
|
+
},
|
|
5644
|
+
get alive() {
|
|
5645
|
+
return alive;
|
|
5646
|
+
}
|
|
5647
|
+
};
|
|
5648
|
+
return client;
|
|
5649
|
+
}
|
|
5650
|
+
//#endregion
|
|
5651
|
+
//#region src/lsp/uri.ts
|
|
5652
|
+
/**
|
|
5653
|
+
* dsh-vscode-mode host — 路径 ↔ file:// URI 转换(纯函数,node 可测)。
|
|
5654
|
+
* LSP 的 uri 字段必须是 file:// 绝对 URI;客户端 Monaco 用 edrv:///<encodeURI(path)>。
|
|
5655
|
+
* 作者 ddj 2026-08-27
|
|
5656
|
+
*/
|
|
5657
|
+
/**
|
|
5658
|
+
* 绝对路径 → file:// URI(Windows 盘符保留:D:/x → file:///D:/x)。
|
|
5659
|
+
* @author ddj 2026年08月27号
|
|
5660
|
+
* @param absPath 绝对路径(正/反斜杠均可)
|
|
5661
|
+
* @returns file:// URI
|
|
5662
|
+
*/
|
|
5663
|
+
function pathToFileUri(absPath) {
|
|
5664
|
+
const normalized = absPath.replace(/\\/g, "/");
|
|
5665
|
+
const drive = /^([A-Za-z]:)(?:\/(.*))?$/.exec(normalized);
|
|
5666
|
+
if (drive) {
|
|
5667
|
+
const tail = drive[2] ? "/" + drive[2].split("/").map(encodeURIComponent).join("/") : "";
|
|
5668
|
+
return "file:///" + drive[1] + tail;
|
|
5669
|
+
}
|
|
5670
|
+
return pathToFileURL(normalized).toString();
|
|
5671
|
+
}
|
|
5672
|
+
//#endregion
|
|
5673
|
+
//#region src/lsp/server.ts
|
|
5674
|
+
/**
|
|
5675
|
+
* dsh-vscode-mode host — 单语言服务器会话(绑定 工作区根 + 语言)。
|
|
5676
|
+
* 负责:启动传输 + LSP 客户端握手、文档同步(didOpen/didChange/didSave/didClose)、
|
|
5677
|
+
* 请求封装(definition/references/documentSymbol/workspaceSymbol/hover)、状态上报。
|
|
5678
|
+
* 作者 ddj 2026-08-27
|
|
5679
|
+
*/
|
|
5680
|
+
/**
|
|
5681
|
+
* 创建语言服务器会话。
|
|
5682
|
+
* @author ddj 2026年08月27号
|
|
5683
|
+
* @param spec provider 规格(argv/cwd)
|
|
5684
|
+
* @param root 工作区根(绝对路径)
|
|
5685
|
+
* @param languageId 语言 id
|
|
5686
|
+
* @param logger 诊断日志
|
|
5687
|
+
* @returns 会话句柄
|
|
5688
|
+
*/
|
|
5689
|
+
function createLspServer(spec, root, languageId, logger) {
|
|
5690
|
+
const rootUri = pathToFileUri(root);
|
|
5691
|
+
const docs = /* @__PURE__ */ new Map();
|
|
5692
|
+
let phase = "idle";
|
|
5693
|
+
let transport = null;
|
|
5694
|
+
let client = null;
|
|
5695
|
+
let capabilities = {
|
|
5696
|
+
definition: false,
|
|
5697
|
+
references: false,
|
|
5698
|
+
documentSymbol: false,
|
|
5699
|
+
workspaceSymbol: false,
|
|
5700
|
+
hover: false
|
|
5701
|
+
};
|
|
5702
|
+
let disposed = false;
|
|
5703
|
+
let readyWait = Promise.resolve(false);
|
|
5704
|
+
let resolveReady = null;
|
|
5705
|
+
let lastProgress = {};
|
|
5706
|
+
let lastStatus = {
|
|
5707
|
+
languageId,
|
|
5708
|
+
source: spec.kind,
|
|
5709
|
+
phase: "idle",
|
|
5710
|
+
reason: spec.ready ? void 0 : spec.reason,
|
|
5711
|
+
root
|
|
5712
|
+
};
|
|
5713
|
+
const log = (line) => logger?.("[" + languageId + "@" + root + "] " + line);
|
|
5714
|
+
const setPhase = (next, reason) => {
|
|
5715
|
+
phase = next;
|
|
5716
|
+
lastStatus = {
|
|
5717
|
+
languageId,
|
|
5718
|
+
source: spec.kind,
|
|
5719
|
+
phase: next,
|
|
5720
|
+
reason,
|
|
5721
|
+
root,
|
|
5722
|
+
...progressFields()
|
|
5723
|
+
};
|
|
5724
|
+
server.onStateChange?.(lastStatus);
|
|
5725
|
+
};
|
|
5726
|
+
/** 返回当前进度字段,避免空进度污染状态载荷。 */
|
|
5727
|
+
const progressFields = () => ({
|
|
5728
|
+
progress: lastProgress.value,
|
|
5729
|
+
progressMessage: lastProgress.message
|
|
5730
|
+
});
|
|
5731
|
+
/** 处理 LSP $/progress 通知并映射为编辑器状态。 */
|
|
5732
|
+
const applyProgress = (params) => {
|
|
5733
|
+
const report = params?.value;
|
|
5734
|
+
if (report?.kind === "begin") {
|
|
5735
|
+
lastProgress = {
|
|
5736
|
+
value: 0,
|
|
5737
|
+
message: typeof report.message === "string" ? report.message : "正在解析工作区"
|
|
5738
|
+
};
|
|
5739
|
+
if (phase === "ready") phase = "indexing";
|
|
5740
|
+
} else if (report?.kind === "end") {
|
|
5741
|
+
lastProgress = {
|
|
5742
|
+
value: 100,
|
|
5743
|
+
message: typeof report.message === "string" ? report.message : "解析完成"
|
|
5744
|
+
};
|
|
5745
|
+
if (phase === "indexing") phase = "ready";
|
|
5746
|
+
} else if (typeof report?.percentage === "number") {
|
|
5747
|
+
lastProgress = {
|
|
5748
|
+
value: Math.max(0, Math.min(100, report.percentage)),
|
|
5749
|
+
message: typeof report.message === "string" ? report.message : lastProgress.message
|
|
5750
|
+
};
|
|
5751
|
+
if (phase === "ready") phase = "indexing";
|
|
5752
|
+
} else if (typeof report?.message === "string") lastProgress = {
|
|
5753
|
+
...lastProgress,
|
|
5754
|
+
message: report.message
|
|
5755
|
+
};
|
|
5756
|
+
lastStatus = {
|
|
5757
|
+
...lastStatus,
|
|
5758
|
+
phase,
|
|
5759
|
+
...progressFields()
|
|
5760
|
+
};
|
|
5761
|
+
server.onStateChange?.(lastStatus);
|
|
5762
|
+
};
|
|
5763
|
+
const ensureClient = () => {
|
|
5764
|
+
if (!client) throw new Error("LSP 服务器未启动");
|
|
5765
|
+
return client;
|
|
5766
|
+
};
|
|
5767
|
+
const server = {
|
|
5768
|
+
languageId,
|
|
5769
|
+
root,
|
|
5770
|
+
get phase() {
|
|
5771
|
+
return phase;
|
|
5772
|
+
},
|
|
5773
|
+
get capabilities() {
|
|
5774
|
+
return capabilities;
|
|
5775
|
+
},
|
|
5776
|
+
status() {
|
|
5777
|
+
return lastStatus;
|
|
5778
|
+
},
|
|
5779
|
+
onStateChange: null,
|
|
5780
|
+
async start() {
|
|
5781
|
+
if (disposed) return false;
|
|
5782
|
+
if (phase === "starting") return readyWait;
|
|
5783
|
+
if (phase === "ready" || phase === "indexing") return true;
|
|
5784
|
+
readyWait = new Promise((resolve) => {
|
|
5785
|
+
resolveReady = resolve;
|
|
5786
|
+
});
|
|
5787
|
+
lastProgress = {};
|
|
5788
|
+
setPhase("starting");
|
|
5789
|
+
try {
|
|
5790
|
+
transport = spawnServer({
|
|
5791
|
+
argv: spec.argv,
|
|
5792
|
+
cwd: spec.cwd ?? root
|
|
5793
|
+
}, (message) => {}, (line) => log("stderr: " + line));
|
|
5794
|
+
client = createLspClient(transport, {
|
|
5795
|
+
onReady: (caps) => {
|
|
5796
|
+
capabilities = caps;
|
|
5797
|
+
for (const doc of docs.values()) client?.notify("textDocument/didOpen", { textDocument: {
|
|
5798
|
+
uri: doc.uri,
|
|
5799
|
+
languageId,
|
|
5800
|
+
version: doc.version,
|
|
5801
|
+
text: doc.text
|
|
5802
|
+
} });
|
|
5803
|
+
setPhase("ready");
|
|
5804
|
+
resolveReady?.(true);
|
|
5805
|
+
resolveReady = null;
|
|
5806
|
+
log("ready: definition=" + caps.definition + " references=" + caps.references + " symbols=" + caps.documentSymbol);
|
|
5807
|
+
},
|
|
5808
|
+
onProgress: (params) => applyProgress(params),
|
|
5809
|
+
onExit: (code, signal) => {
|
|
5810
|
+
if (disposed) return;
|
|
5811
|
+
resolveReady?.(false);
|
|
5812
|
+
resolveReady = null;
|
|
5813
|
+
log("server exited (" + String(code) + "/" + String(signal) + ")");
|
|
5814
|
+
client = null;
|
|
5815
|
+
transport = null;
|
|
5816
|
+
setPhase("idle", "服务器已退出(" + String(code) + "/" + String(signal) + "),等待重启");
|
|
5817
|
+
},
|
|
5818
|
+
onLog: (line) => log("lsp: " + line)
|
|
5819
|
+
});
|
|
5820
|
+
capabilities = await client.initialize(rootUri, [rootUri]);
|
|
5821
|
+
return true;
|
|
5822
|
+
} catch (error) {
|
|
5823
|
+
log("start failed: " + String(error));
|
|
5824
|
+
setPhase("unavailable", "启动失败:" + String(error));
|
|
5825
|
+
resolveReady?.(false);
|
|
5826
|
+
resolveReady = null;
|
|
5827
|
+
try {
|
|
5828
|
+
await server.dispose();
|
|
5829
|
+
} catch {}
|
|
5830
|
+
return false;
|
|
5831
|
+
}
|
|
5832
|
+
},
|
|
5833
|
+
sync(path, text, version) {
|
|
5834
|
+
const existing = docs.get(path);
|
|
5835
|
+
const uri = existing?.uri ?? pathToFileUri(root + "/" + path.split("/").join("/"));
|
|
5836
|
+
if (existing) {
|
|
5837
|
+
existing.version = version;
|
|
5838
|
+
existing.text = text;
|
|
5839
|
+
if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didChange", {
|
|
5840
|
+
textDocument: {
|
|
5841
|
+
uri,
|
|
5842
|
+
version
|
|
5843
|
+
},
|
|
5844
|
+
contentChanges: [{ text }]
|
|
5845
|
+
});
|
|
5846
|
+
return;
|
|
5847
|
+
}
|
|
5848
|
+
docs.set(path, {
|
|
5849
|
+
path,
|
|
5850
|
+
uri,
|
|
5851
|
+
version,
|
|
5852
|
+
text
|
|
5853
|
+
});
|
|
5854
|
+
if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didOpen", { textDocument: {
|
|
5855
|
+
uri,
|
|
5856
|
+
languageId,
|
|
5857
|
+
version,
|
|
5858
|
+
text
|
|
5859
|
+
} });
|
|
5860
|
+
},
|
|
5861
|
+
close(path) {
|
|
5862
|
+
const doc = docs.get(path);
|
|
5863
|
+
if (!doc) return;
|
|
5864
|
+
docs.delete(path);
|
|
5865
|
+
if (phase === "ready" || phase === "indexing") client?.notify("textDocument/didClose", { textDocument: { uri: doc.uri } });
|
|
5866
|
+
},
|
|
5867
|
+
async definition(path, line, character) {
|
|
5868
|
+
const doc = docs.get(path);
|
|
5869
|
+
if (!doc || !await readyWait) return [];
|
|
5870
|
+
return normalizeLocations(await ensureClient().request("textDocument/definition", {
|
|
5871
|
+
textDocument: { uri: doc.uri },
|
|
5872
|
+
position: {
|
|
5873
|
+
line,
|
|
5874
|
+
character
|
|
5875
|
+
}
|
|
5876
|
+
}));
|
|
5877
|
+
},
|
|
5878
|
+
async references(path, line, character, includeDeclaration) {
|
|
5879
|
+
const doc = docs.get(path);
|
|
5880
|
+
if (!doc || !await readyWait) return [];
|
|
5881
|
+
return normalizeLocations(await ensureClient().request("textDocument/references", {
|
|
5882
|
+
textDocument: { uri: doc.uri },
|
|
5883
|
+
position: {
|
|
5884
|
+
line,
|
|
5885
|
+
character
|
|
5886
|
+
},
|
|
5887
|
+
context: { includeDeclaration }
|
|
5888
|
+
}));
|
|
5889
|
+
},
|
|
5890
|
+
async documentSymbol(path) {
|
|
5891
|
+
const doc = docs.get(path);
|
|
5892
|
+
if (!doc || !await readyWait) return [];
|
|
5893
|
+
const result = await ensureClient().request("textDocument/documentSymbol", { textDocument: { uri: doc.uri } });
|
|
5894
|
+
if (!Array.isArray(result)) return [];
|
|
5895
|
+
return result.map(normalizeSymbol).filter((s) => s !== null);
|
|
5896
|
+
},
|
|
5897
|
+
async workspaceSymbol(query) {
|
|
5898
|
+
if (!await readyWait) return [];
|
|
5899
|
+
const result = await ensureClient().request("workspace/symbol", { query });
|
|
5900
|
+
if (!Array.isArray(result)) return [];
|
|
5901
|
+
return result.map(normalizeSymbol).filter((s) => s !== null);
|
|
5902
|
+
},
|
|
5903
|
+
async hover(path, line, character) {
|
|
5904
|
+
const doc = docs.get(path);
|
|
5905
|
+
if (!doc || !await readyWait) return null;
|
|
5906
|
+
const result = await ensureClient().request("textDocument/hover", {
|
|
5907
|
+
textDocument: { uri: doc.uri },
|
|
5908
|
+
position: {
|
|
5909
|
+
line,
|
|
5910
|
+
character
|
|
5911
|
+
}
|
|
5912
|
+
});
|
|
5913
|
+
if (!result || !result.contents) return null;
|
|
5914
|
+
return { contents: stringifyHoverContents(result.contents) };
|
|
5915
|
+
},
|
|
5916
|
+
async dispose() {
|
|
5917
|
+
disposed = true;
|
|
5918
|
+
resolveReady?.(false);
|
|
5919
|
+
resolveReady = null;
|
|
5920
|
+
if (client) {
|
|
5921
|
+
await client.shutdown().catch(() => {});
|
|
5922
|
+
client = null;
|
|
5923
|
+
}
|
|
5924
|
+
if (transport) {
|
|
5925
|
+
transport.dispose();
|
|
5926
|
+
transport = null;
|
|
5927
|
+
}
|
|
5928
|
+
setPhase("stopped");
|
|
5929
|
+
}
|
|
5930
|
+
};
|
|
5931
|
+
return server;
|
|
5932
|
+
}
|
|
5933
|
+
/** 归一化 definition/references 结果(Location | Location[] | LocationLink[])。 */
|
|
5934
|
+
function normalizeLocations(result) {
|
|
5935
|
+
if (!Array.isArray(result)) return [];
|
|
5936
|
+
const out = [];
|
|
5937
|
+
for (const item of result) {
|
|
5938
|
+
if (!item || typeof item !== "object") continue;
|
|
5939
|
+
const obj = item;
|
|
5940
|
+
const uri = typeof obj.targetUri === "string" ? obj.targetUri : typeof obj.uri === "string" ? obj.uri : "";
|
|
5941
|
+
const range = obj.targetRange ?? obj.range;
|
|
5942
|
+
if (!uri || !range || !range.start || !range.end) continue;
|
|
5943
|
+
out.push({
|
|
5944
|
+
uri,
|
|
5945
|
+
range: {
|
|
5946
|
+
start: {
|
|
5947
|
+
line: toInt(range.start.line),
|
|
5948
|
+
character: toInt(range.start.character)
|
|
5949
|
+
},
|
|
5950
|
+
end: {
|
|
5951
|
+
line: toInt(range.end.line),
|
|
5952
|
+
character: toInt(range.end.character)
|
|
5953
|
+
}
|
|
5954
|
+
}
|
|
5955
|
+
});
|
|
5956
|
+
}
|
|
5957
|
+
return out;
|
|
5958
|
+
}
|
|
5959
|
+
/** 归一化 documentSymbol/workspaceSymbol 结果(DocumentSymbol | SymbolInformation)。 */
|
|
5960
|
+
function normalizeSymbol(item) {
|
|
5961
|
+
if (!item || typeof item !== "object") return null;
|
|
5962
|
+
const obj = item;
|
|
5963
|
+
if (typeof obj.name !== "string") return null;
|
|
5964
|
+
const selection = obj.selectionRange ?? (obj.location && obj.location.range);
|
|
5965
|
+
const range = obj.range ?? selection;
|
|
5966
|
+
if (!range || typeof range !== "object") return null;
|
|
5967
|
+
const r = range;
|
|
5968
|
+
if (!r.start || !r.end) return null;
|
|
5969
|
+
const symbol = {
|
|
5970
|
+
name: obj.name,
|
|
5971
|
+
kind: toInt(obj.kind),
|
|
5972
|
+
range: {
|
|
5973
|
+
start: {
|
|
5974
|
+
line: toInt(r.start.line),
|
|
5975
|
+
character: toInt(r.start.character)
|
|
5976
|
+
},
|
|
5977
|
+
end: {
|
|
5978
|
+
line: toInt(r.end.line),
|
|
5979
|
+
character: toInt(r.end.character)
|
|
5980
|
+
}
|
|
5981
|
+
},
|
|
5982
|
+
selectionRange: selection ? {
|
|
5983
|
+
start: {
|
|
5984
|
+
line: toInt(selection.start?.line ?? toInt(r.start.line)),
|
|
5985
|
+
character: toInt(selection.start?.character ?? toInt(r.start.character))
|
|
5986
|
+
},
|
|
5987
|
+
end: {
|
|
5988
|
+
line: toInt(selection.end?.line ?? toInt(r.end.line)),
|
|
5989
|
+
character: toInt(selection.end?.character ?? toInt(r.end.character))
|
|
5990
|
+
}
|
|
5991
|
+
} : {
|
|
5992
|
+
start: {
|
|
5993
|
+
line: toInt(r.start.line),
|
|
5994
|
+
character: toInt(r.start.character)
|
|
5995
|
+
},
|
|
5996
|
+
end: {
|
|
5997
|
+
line: toInt(r.end.line),
|
|
5998
|
+
character: toInt(r.end.character)
|
|
5999
|
+
}
|
|
6000
|
+
}
|
|
6001
|
+
};
|
|
6002
|
+
if (typeof obj.detail === "string" && obj.detail) symbol.detail = obj.detail;
|
|
6003
|
+
if (typeof obj.containerName === "string" && obj.containerName) symbol.containerName = obj.containerName;
|
|
6004
|
+
if (Array.isArray(obj.children)) {
|
|
6005
|
+
const kids = obj.children.map(normalizeSymbol).filter((s) => s !== null);
|
|
6006
|
+
if (kids.length) symbol.children = kids;
|
|
6007
|
+
}
|
|
6008
|
+
return symbol;
|
|
6009
|
+
}
|
|
6010
|
+
/** hover contents → 文本行(MarkupContent | MarkedString | MarkedString[])。 */
|
|
6011
|
+
function stringifyHoverContents(contents) {
|
|
6012
|
+
if (typeof contents === "string") return [contents];
|
|
6013
|
+
if (Array.isArray(contents)) return contents.map(stringifyHoverContents).flat().filter(Boolean);
|
|
6014
|
+
if (contents && typeof contents === "object") {
|
|
6015
|
+
const value = contents.value;
|
|
6016
|
+
if (typeof value === "string") return [value];
|
|
6017
|
+
}
|
|
6018
|
+
return [];
|
|
6019
|
+
}
|
|
6020
|
+
function toInt(value) {
|
|
6021
|
+
const n = Number(value);
|
|
6022
|
+
return Number.isFinite(n) ? Math.max(0, Math.floor(n)) : 0;
|
|
6023
|
+
}
|
|
6024
|
+
//#endregion
|
|
6025
|
+
//#region src/lsp/manager.ts
|
|
6026
|
+
/**
|
|
6027
|
+
* dsh-vscode-mode host — LSP 服务器管理器。
|
|
6028
|
+
* 实例注册表(key=root|languageId,refcount 共享),崩溃退避重启,session/disposed 清理。
|
|
6029
|
+
* 作者 ddj 2026-08-27
|
|
6030
|
+
*/
|
|
6031
|
+
/**
|
|
6032
|
+
* 创建 LSP 管理器。
|
|
6033
|
+
* @author ddj 2026年08月27号
|
|
6034
|
+
* @param logger 诊断日志
|
|
6035
|
+
* @returns 管理器
|
|
6036
|
+
*/
|
|
6037
|
+
function createLspManager(logger) {
|
|
6038
|
+
const entries = /* @__PURE__ */ new Map();
|
|
6039
|
+
const keyOf = (root, languageId) => root + "|" + languageId;
|
|
6040
|
+
const log = (line) => logger?.("[lsp-manager] " + line);
|
|
6041
|
+
/** 同步摘除注册表项并后台回收(refs 归零/工作区清理用;statusAll 立即可见归零)。 */
|
|
6042
|
+
const dropEntry = (entry) => {
|
|
6043
|
+
if (entry.restartTimer) {
|
|
6044
|
+
clearTimeout(entry.restartTimer);
|
|
6045
|
+
entry.restartTimer = null;
|
|
6046
|
+
}
|
|
6047
|
+
entries.delete(entry.key);
|
|
6048
|
+
entry.server.dispose().catch(() => {});
|
|
6049
|
+
};
|
|
6050
|
+
const manager = {
|
|
6051
|
+
acquire(root, languageId, resolveSpec) {
|
|
6052
|
+
const key = keyOf(root, languageId);
|
|
6053
|
+
let entry = entries.get(key);
|
|
6054
|
+
if (!entry) {
|
|
6055
|
+
const spec = resolveSpec(languageId);
|
|
6056
|
+
const server = createLspServer(spec, root, languageId, log);
|
|
6057
|
+
server.onStateChange = (status) => manager.onStatusChange?.(status);
|
|
6058
|
+
entry = {
|
|
6059
|
+
key,
|
|
6060
|
+
server,
|
|
6061
|
+
refs: 0,
|
|
6062
|
+
failures: 0,
|
|
6063
|
+
restartTimer: null
|
|
6064
|
+
};
|
|
6065
|
+
entries.set(key, entry);
|
|
6066
|
+
if (spec.ready) server.start().then((ok) => {
|
|
6067
|
+
if (!ok && entries.has(key)) entry.failures++;
|
|
6068
|
+
});
|
|
6069
|
+
}
|
|
6070
|
+
entry.refs++;
|
|
6071
|
+
return entry.server;
|
|
6072
|
+
},
|
|
6073
|
+
release(root, languageId) {
|
|
6074
|
+
const entry = entries.get(keyOf(root, languageId));
|
|
6075
|
+
if (!entry) return;
|
|
6076
|
+
entry.refs = Math.max(0, entry.refs - 1);
|
|
6077
|
+
if (entry.refs === 0) dropEntry(entry);
|
|
6078
|
+
},
|
|
6079
|
+
releaseRoot(root) {
|
|
6080
|
+
for (const entry of [...entries.values()]) if (entry.server.root === root) dropEntry(entry);
|
|
6081
|
+
},
|
|
6082
|
+
statusAll() {
|
|
6083
|
+
return [...entries.values()].map((entry) => entry.server.status());
|
|
6084
|
+
},
|
|
6085
|
+
peek(root, languageId) {
|
|
6086
|
+
return entries.get(keyOf(root, languageId))?.server;
|
|
6087
|
+
},
|
|
6088
|
+
async disposeAll() {
|
|
6089
|
+
const list = [...entries.values()];
|
|
6090
|
+
entries.clear();
|
|
6091
|
+
for (const entry of list) {
|
|
6092
|
+
if (entry.restartTimer) clearTimeout(entry.restartTimer);
|
|
6093
|
+
await entry.server.dispose();
|
|
6094
|
+
}
|
|
6095
|
+
},
|
|
6096
|
+
onStatusChange: null
|
|
6097
|
+
};
|
|
6098
|
+
return manager;
|
|
6099
|
+
}
|
|
6100
|
+
//#endregion
|
|
6101
|
+
//#region src/lsp/zip.ts
|
|
6102
|
+
/**
|
|
6103
|
+
* dsh-vscode-mode host — 纯 Node ZIP 解压(零依赖,跨平台)。
|
|
6104
|
+
* 供 extmgr 解包 VSIX 使用;只实现 VSIX 需要的子集:
|
|
6105
|
+
* 读取 EOCD + 中央目录,支持 stored(0)/deflate(8),忽略 zip64(VSIX < 4GB)。
|
|
6106
|
+
* 作者 ddj 2026-08-27
|
|
6107
|
+
*/
|
|
6108
|
+
const EOCD_SIG = 101010256;
|
|
6109
|
+
const CDIR_SIG = 33639248;
|
|
6110
|
+
const LHDR_SIG = 67324752;
|
|
6111
|
+
/**
|
|
6112
|
+
* 解析 ZIP 缓冲区 → 条目列表(不解压内容,仅元数据)。
|
|
6113
|
+
* @author ddj 2026年08月27号
|
|
6114
|
+
* @param buf zip 字节
|
|
6115
|
+
* @returns 条目元数据
|
|
6116
|
+
*/
|
|
6117
|
+
function zipEntries(buf) {
|
|
6118
|
+
const eocd = findEocd(buf);
|
|
6119
|
+
if (eocd < 0) throw new Error("不是有效的 ZIP 文件(找不到 EOCD)");
|
|
6120
|
+
const total = buf.readUInt16LE(eocd + 10);
|
|
6121
|
+
let offset = buf.readUInt32LE(eocd + 16);
|
|
6122
|
+
const out = [];
|
|
6123
|
+
for (let i = 0; i < total; i++) {
|
|
6124
|
+
if (buf.readUInt32LE(offset) !== CDIR_SIG) throw new Error("ZIP 中央目录损坏");
|
|
6125
|
+
const method = buf.readUInt16LE(offset + 10);
|
|
6126
|
+
const compressed = buf.readUInt32LE(offset + 20);
|
|
6127
|
+
const uncompressed = buf.readUInt32LE(offset + 24);
|
|
6128
|
+
const nameLen = buf.readUInt16LE(offset + 28);
|
|
6129
|
+
const extraLen = buf.readUInt16LE(offset + 30);
|
|
6130
|
+
const commentLen = buf.readUInt16LE(offset + 32);
|
|
6131
|
+
const localOffset = buf.readUInt32LE(offset + 42);
|
|
6132
|
+
const name = buf.subarray(offset + 46, offset + 46 + nameLen).toString("utf8");
|
|
6133
|
+
out.push({
|
|
6134
|
+
name,
|
|
6135
|
+
method,
|
|
6136
|
+
compressed,
|
|
6137
|
+
uncompressed,
|
|
6138
|
+
localOffset
|
|
6139
|
+
});
|
|
6140
|
+
offset += 46 + nameLen + extraLen + commentLen;
|
|
6141
|
+
}
|
|
6142
|
+
return out;
|
|
6143
|
+
}
|
|
6144
|
+
/**
|
|
6145
|
+
* 解压 ZIP → 条目列表(目录条目含尾部 /)。
|
|
6146
|
+
* @author ddj 2026年08月27号
|
|
6147
|
+
* @param buf zip 字节
|
|
6148
|
+
* @returns 条目(含内容)
|
|
6149
|
+
*/
|
|
6150
|
+
function unzip(buf) {
|
|
6151
|
+
const metas = zipEntries(buf);
|
|
6152
|
+
const out = [];
|
|
6153
|
+
for (const meta of metas) {
|
|
6154
|
+
const lh = meta.localOffset;
|
|
6155
|
+
if (buf.readUInt32LE(lh) !== LHDR_SIG) throw new Error("ZIP 本地头损坏:" + meta.name);
|
|
6156
|
+
const nameLen = buf.readUInt16LE(lh + 26);
|
|
6157
|
+
const extraLen = buf.readUInt16LE(lh + 28);
|
|
6158
|
+
const dataStart = lh + 30 + nameLen + extraLen;
|
|
6159
|
+
const isDir = /\/$/.test(meta.name);
|
|
6160
|
+
let data = Buffer.alloc(0);
|
|
6161
|
+
if (!isDir && meta.compressed > 0) {
|
|
6162
|
+
const raw = buf.subarray(dataStart, dataStart + meta.compressed);
|
|
6163
|
+
data = meta.method === 8 ? Buffer.from(inflateRawSync(raw)) : meta.method === 0 ? Buffer.from(raw) : Buffer.alloc(0);
|
|
6164
|
+
}
|
|
6165
|
+
out.push({
|
|
6166
|
+
path: meta.name,
|
|
6167
|
+
data,
|
|
6168
|
+
isDirectory: isDir
|
|
6169
|
+
});
|
|
6170
|
+
}
|
|
6171
|
+
return out;
|
|
6172
|
+
}
|
|
6173
|
+
/** 定位 EOCD(从尾部向前扫,容忍尾部注释)。 */
|
|
6174
|
+
function findEocd(buf) {
|
|
6175
|
+
const maxScan = Math.min(buf.length, 65557);
|
|
6176
|
+
for (let i = buf.length - 22; i >= buf.length - maxScan; i--) {
|
|
6177
|
+
if (i < 0) break;
|
|
6178
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) return i;
|
|
6179
|
+
}
|
|
6180
|
+
return -1;
|
|
6181
|
+
}
|
|
6182
|
+
//#endregion
|
|
6183
|
+
//#region src/lsp/extmgr.ts
|
|
6184
|
+
/**
|
|
6185
|
+
* dsh-vscode-mode host — VSIX 扩展管理器(extmgr)。
|
|
6186
|
+
* 管理 DSH 专属扩展目录(~/.dsh/dsh-vscode-mode/extensions/),支持:
|
|
6187
|
+
* 本地 .vsix 安装、Open VSX 市场搜索/下载安装、卸载、更新检测与安装。
|
|
6188
|
+
* 已装扩展作为 LSP provider 的"扩展源"(kind=extension),见 providers.ts。
|
|
6189
|
+
* 作者 ddj 2026-08-27
|
|
6190
|
+
*/
|
|
6191
|
+
const OPEN_VSX = "https://open-vsx.org";
|
|
6192
|
+
const META_FILE = "package.json";
|
|
6193
|
+
/** 当前平台(与 VSCode platform 命名对齐:win32-x64/darwin-arm64/linux-x64)。 */
|
|
6194
|
+
const PLATFORM = (() => {
|
|
6195
|
+
if (process.platform === "win32") return process.arch === "arm64" ? "win32-arm64" : "win32-x64";
|
|
6196
|
+
if (process.platform === "darwin") return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
|
6197
|
+
return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
|
|
6198
|
+
})();
|
|
6199
|
+
/** DSH 专属扩展目录。 */
|
|
6200
|
+
function extensionsRoot(home = dshHome()) {
|
|
6201
|
+
return join(home, PLUGIN_ID, "extensions");
|
|
6202
|
+
}
|
|
6203
|
+
/** 从 VSIX 字节里读 extension/package.json 清单。 */
|
|
6204
|
+
function vsixManifest(vsix) {
|
|
6205
|
+
for (const entry of unzip(vsix)) if (entry.path === "extension/package.json" || entry.path === "extension\\package.json") return JSON.parse(entry.data.toString("utf8"));
|
|
6206
|
+
throw new Error("VSIX 缺少 extension/package.json");
|
|
6207
|
+
}
|
|
6208
|
+
/**
|
|
6209
|
+
* 解包 VSIX 到目录(仅取 extension/ 子树,剥前缀;返回清单)。
|
|
6210
|
+
* @author ddj 2026年08月27号
|
|
6211
|
+
* @param vsix vsix 字节
|
|
6212
|
+
* @param dest 目标目录(已存在则先清空)
|
|
6213
|
+
* @returns 清单对象
|
|
6214
|
+
*/
|
|
6215
|
+
function unpackVsix(vsix, dest) {
|
|
6216
|
+
rmSync(dest, {
|
|
6217
|
+
recursive: true,
|
|
6218
|
+
force: true
|
|
6219
|
+
});
|
|
6220
|
+
mkdirSync(dest, { recursive: true });
|
|
6221
|
+
let manifest = null;
|
|
6222
|
+
for (const entry of unzip(vsix)) {
|
|
6223
|
+
const rel = entry.path.replace(/^extension[\\/]/, "");
|
|
6224
|
+
if (!rel || entry.path.startsWith("..")) continue;
|
|
6225
|
+
const safe = rel.split(/[\\/]/).filter((part) => part && part !== ".." && part !== ".").join("/");
|
|
6226
|
+
if (!safe) continue;
|
|
6227
|
+
const target = join(dest, ...safe.split("/"));
|
|
6228
|
+
if (entry.isDirectory) {
|
|
6229
|
+
mkdirSync(target, { recursive: true });
|
|
6230
|
+
continue;
|
|
6231
|
+
}
|
|
6232
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
6233
|
+
writeFileSync(target, entry.data);
|
|
6234
|
+
if (rel === "package.json") manifest = JSON.parse(entry.data.toString("utf8"));
|
|
6235
|
+
}
|
|
6236
|
+
if (!manifest) throw new Error("VSIX 解包后缺少 package.json");
|
|
6237
|
+
return manifest;
|
|
6238
|
+
}
|
|
6239
|
+
/** 扩展信息(清单 + 目录)。 */
|
|
6240
|
+
function extInfoOf(manifest, dir) {
|
|
6241
|
+
const namespace = String(manifest.publisher ?? "unknown");
|
|
6242
|
+
const name = String(manifest.name ?? "unknown");
|
|
6243
|
+
const version = String(manifest.version ?? "0.0.0");
|
|
6244
|
+
return {
|
|
6245
|
+
id: namespace + "." + name,
|
|
6246
|
+
namespace,
|
|
6247
|
+
name,
|
|
6248
|
+
version,
|
|
6249
|
+
displayName: typeof manifest.displayName === "string" ? manifest.displayName : void 0,
|
|
6250
|
+
description: typeof manifest.description === "string" ? manifest.description : void 0,
|
|
6251
|
+
dirName: basename(dir),
|
|
6252
|
+
dir
|
|
6253
|
+
};
|
|
6254
|
+
}
|
|
6255
|
+
/** 安装本地 .vsix 文件(已存在同 id 则升级替换)。 */
|
|
6256
|
+
async function installVsixFile(vsixPath, home = dshHome()) {
|
|
6257
|
+
return installVsixBuffer(readFileSync(vsixPath), home);
|
|
6258
|
+
}
|
|
6259
|
+
/** 安装 VSIX 字节(解包进 extensionsRoot;同名旧版本先清)。 */
|
|
6260
|
+
function installVsixBuffer(vsix, home = dshHome()) {
|
|
6261
|
+
const info = extInfoOf(vsixManifest(vsix), "");
|
|
6262
|
+
const target = join(extensionsRoot(home), info.id + "-" + info.version);
|
|
6263
|
+
return extInfoOf(unpackVsix(vsix, target), target);
|
|
6264
|
+
}
|
|
6265
|
+
/** 扫描已装扩展目录。 */
|
|
6266
|
+
function listInstalled(home = dshHome()) {
|
|
6267
|
+
const root = extensionsRoot(home);
|
|
6268
|
+
if (!existsSync(root)) return [];
|
|
6269
|
+
const out = [];
|
|
6270
|
+
for (const dir of readdirSync(root)) {
|
|
6271
|
+
const full = join(root, dir);
|
|
6272
|
+
const meta = join(full, META_FILE);
|
|
6273
|
+
if (!existsSync(meta)) continue;
|
|
6274
|
+
try {
|
|
6275
|
+
const manifest = JSON.parse(readFileSync(meta, "utf8"));
|
|
6276
|
+
out.push(extInfoOf(manifest, full));
|
|
6277
|
+
} catch (error) {}
|
|
6278
|
+
}
|
|
6279
|
+
return out;
|
|
6280
|
+
}
|
|
6281
|
+
/** 卸载扩展(按 id)。 */
|
|
6282
|
+
function uninstall(id, home = dshHome()) {
|
|
6283
|
+
const target = listInstalled(home).find((e) => e.id === id)?.dir;
|
|
6284
|
+
if (!target) return false;
|
|
6285
|
+
rmSync(target, {
|
|
6286
|
+
recursive: true,
|
|
6287
|
+
force: true
|
|
6288
|
+
});
|
|
6289
|
+
return true;
|
|
6290
|
+
}
|
|
6291
|
+
/** Open VSX 单扩展元数据(latest + 下载 URL)。 */
|
|
6292
|
+
async function marketGet(namespace, name, version) {
|
|
6293
|
+
const path = version ? namespace + "/" + name + "/" + version : namespace + "/" + name;
|
|
6294
|
+
const res = await fetch(OPEN_VSX + "/api/" + path, { headers: { accept: "application/json" } });
|
|
6295
|
+
if (!res.ok) return null;
|
|
6296
|
+
return await res.json();
|
|
6297
|
+
}
|
|
6298
|
+
/** Open VSX 搜索。 */
|
|
6299
|
+
async function marketSearch(query, size = 12) {
|
|
6300
|
+
const url = OPEN_VSX + "/api/-/search?query=" + encodeURIComponent(query) + "&size=" + size;
|
|
6301
|
+
const res = await fetch(url, { headers: { accept: "application/json" } });
|
|
6302
|
+
if (!res.ok) throw new Error("市场搜索失败:HTTP " + res.status);
|
|
6303
|
+
return ((await res.json()).extensions ?? []).map((item) => ({
|
|
6304
|
+
namespace: String(item.namespace ?? ""),
|
|
6305
|
+
name: String(item.name ?? ""),
|
|
6306
|
+
id: String(item.namespace ?? "") + "." + String(item.name ?? ""),
|
|
6307
|
+
version: String(item.version ?? ""),
|
|
6308
|
+
displayName: typeof item.displayName === "string" ? item.displayName : void 0,
|
|
6309
|
+
description: typeof item.description === "string" ? item.description : void 0,
|
|
6310
|
+
download: typeof item.files?.download === "string" ? item.files.download : void 0
|
|
6311
|
+
}));
|
|
6312
|
+
}
|
|
6313
|
+
/** 平台匹配的 VSIX 下载 URL(探测平台变体,失败回退 API 默认)。 */
|
|
6314
|
+
async function resolveDownload(namespace, name, version) {
|
|
6315
|
+
const candidate = OPEN_VSX + "/api/" + namespace + "/" + name + "/" + PLATFORM + "/" + version + "/file/" + namespace + "." + name + "-" + version + "@" + PLATFORM + ".vsix";
|
|
6316
|
+
try {
|
|
6317
|
+
if ((await fetch(candidate, { method: "HEAD" })).ok) return candidate;
|
|
6318
|
+
} catch (error) {}
|
|
6319
|
+
const download = ((await marketGet(namespace, name, version))?.files)?.download;
|
|
6320
|
+
if (typeof download === "string") return download;
|
|
6321
|
+
throw new Error("市场未提供 " + namespace + "." + name + "@" + version + " 的下载地址");
|
|
6322
|
+
}
|
|
6323
|
+
/** 从 Open VSX 安装/更新扩展。 */
|
|
6324
|
+
async function installFromMarket(namespace, name, version) {
|
|
6325
|
+
const meta = await marketGet(namespace, name, version);
|
|
6326
|
+
if (!meta) throw new Error("市场找不到 " + namespace + "." + name + (version ? "@" + version : ""));
|
|
6327
|
+
const url = await resolveDownload(namespace, name, version ?? String(meta.version ?? ""));
|
|
6328
|
+
const res = await fetch(url);
|
|
6329
|
+
if (!res.ok) throw new Error("下载失败:HTTP " + res.status);
|
|
6330
|
+
return installVsixBuffer(Buffer.from(await res.arrayBuffer()));
|
|
6331
|
+
}
|
|
6332
|
+
/** 更新已装扩展(对比市场最新版;返回 null 表示已最新)。 */
|
|
6333
|
+
async function updateExtension(id) {
|
|
6334
|
+
const installed = listInstalled().find((e) => e.id === id);
|
|
6335
|
+
if (!installed) throw new Error("扩展未安装:" + id);
|
|
6336
|
+
const meta = await marketGet(installed.namespace, installed.name);
|
|
6337
|
+
const latest = String(meta?.version ?? "");
|
|
6338
|
+
if (!latest || latest === installed.version) return null;
|
|
6339
|
+
return installFromMarket(installed.namespace, installed.name, latest);
|
|
6340
|
+
}
|
|
6341
|
+
//#endregion
|
|
6342
|
+
//#region src/lsp/providers.ts
|
|
6343
|
+
/**
|
|
6344
|
+
* dsh-vscode-mode host — 语言服务器 provider 解析。
|
|
6345
|
+
* 优先级:手动配置 > extmgr 扩展源(Phase 2 挂载) > 自动发现(~/.vscode/extensions、
|
|
6346
|
+
* ~/.dsh/dsh-vscode-mode/extensions、PATH)。产物 LspProviderSpec:{ ready, argv, cwd, reason },
|
|
6347
|
+
* 供 manager 启动服务器。
|
|
6348
|
+
* 作者 ddj 2026-08-27
|
|
6349
|
+
*/
|
|
6350
|
+
/** 平台目录名(LuaLS/OmniSharp 的 server/bin/<平台>)。 */
|
|
6351
|
+
function platformServerDir(platform = process.platform) {
|
|
6352
|
+
if (platform === "win32") return "Windows";
|
|
6353
|
+
if (platform === "darwin") return "macOS";
|
|
6354
|
+
return "Linux";
|
|
6355
|
+
}
|
|
6356
|
+
/** exe 后缀(Windows 为 .exe)。 */
|
|
6357
|
+
function exeSuffix(platform = process.platform) {
|
|
6358
|
+
return platform === "win32" ? ".exe" : "";
|
|
6359
|
+
}
|
|
6360
|
+
/** 列出目录下匹配前缀的子目录(如 ~/.vscode/extensions/sumneko.lua-*)。 */
|
|
6361
|
+
function listMatchingDirs(dir, prefix) {
|
|
6362
|
+
if (!existsSync(dir)) return [];
|
|
6363
|
+
let entries;
|
|
6364
|
+
try {
|
|
6365
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
6366
|
+
} catch (error) {
|
|
6367
|
+
return [];
|
|
6368
|
+
}
|
|
6369
|
+
return entries.filter((e) => e.isDirectory() && e.name.startsWith(prefix)).map((e) => join(dir, e.name));
|
|
6370
|
+
}
|
|
6371
|
+
/** VSCode 扩展目录(~/.vscode/extensions、~/.vscode-server/extensions + extmgr 目录;无则空数组)。 */
|
|
6372
|
+
function vscodeExtensionsDirs(home = dshHome()) {
|
|
6373
|
+
const userHome = homedir();
|
|
6374
|
+
return [
|
|
6375
|
+
join(userHome, ".vscode", "extensions"),
|
|
6376
|
+
join(userHome, ".vscode-server", "extensions"),
|
|
6377
|
+
extensionsRoot(home)
|
|
6378
|
+
].filter((dir) => existsSync(dir));
|
|
6379
|
+
}
|
|
6380
|
+
/** 扩展源 provider 注册表:languageId → 启动参数。 */
|
|
6381
|
+
const extProviders = /* @__PURE__ */ new Map();
|
|
6382
|
+
/** 取扩展源 provider spec(未注册返回 null)。 */
|
|
6383
|
+
function extensionProviderFor(languageId) {
|
|
6384
|
+
const p = extProviders.get(languageId);
|
|
6385
|
+
if (!p || !p.argv.length) return null;
|
|
6386
|
+
return {
|
|
6387
|
+
languageId,
|
|
6388
|
+
kind: "extension",
|
|
6389
|
+
argv: p.argv,
|
|
6390
|
+
cwd: p.cwd,
|
|
6391
|
+
ready: true
|
|
6392
|
+
};
|
|
6393
|
+
}
|
|
6394
|
+
/** PATH 中查找可执行文件(不含扩展名校验,交由 spawn 报错)。 */
|
|
6395
|
+
function findInPath(name) {
|
|
6396
|
+
const pathEnv = process.env.PATH ?? "";
|
|
6397
|
+
const exts = process.platform === "win32" ? [
|
|
6398
|
+
"",
|
|
6399
|
+
".exe",
|
|
6400
|
+
".cmd",
|
|
6401
|
+
".bat"
|
|
6402
|
+
] : [""];
|
|
6403
|
+
for (const dir of pathEnv.split(sep === "\\" ? ";" : ":")) {
|
|
6404
|
+
if (!dir) continue;
|
|
6405
|
+
for (const ext of exts) {
|
|
6406
|
+
const candidate = join(dir, name + ext);
|
|
6407
|
+
if (existsSync(candidate)) return candidate;
|
|
6408
|
+
}
|
|
6409
|
+
}
|
|
6410
|
+
return null;
|
|
6411
|
+
}
|
|
6412
|
+
/** 查找 dotnet(PATH + 常见安装路径)。 */
|
|
6413
|
+
function findDotnet() {
|
|
6414
|
+
const inPath = findInPath("dotnet");
|
|
6415
|
+
if (inPath) return inPath;
|
|
6416
|
+
for (const c of ["C:/Program Files/dotnet/dotnet.exe", "C:/Program Files (x86)/dotnet/dotnet.exe"]) if (existsSync(c)) return c;
|
|
6417
|
+
return null;
|
|
6418
|
+
}
|
|
6419
|
+
/** LuaLS 自动发现候选(VSCode 扩展目录 + extmgr 目录下 sumneko.lua-* 的 server/bin 可执行)。 */
|
|
6420
|
+
function candidateLuaServers(home = dshHome()) {
|
|
6421
|
+
const plat = platformServerDir();
|
|
6422
|
+
const suffix = exeSuffix();
|
|
6423
|
+
const out = [];
|
|
6424
|
+
for (const dir of vscodeExtensionsDirs(home)) for (const extDir of listMatchingDirs(dir, "sumneko.lua-")) {
|
|
6425
|
+
const binDir = join(extDir, "server", "bin");
|
|
6426
|
+
const binName = "lua-language-server" + suffix;
|
|
6427
|
+
const bin = existsSync(join(binDir, binName)) ? join(binDir, binName) : join(binDir, plat, binName);
|
|
6428
|
+
if (existsSync(bin) && !out.includes(bin)) out.push(bin);
|
|
6429
|
+
}
|
|
6430
|
+
return out;
|
|
6431
|
+
}
|
|
6432
|
+
/** C# 自动发现候选:Roslyn dll(需 dotnet)或 OmniSharp 可执行。 */
|
|
6433
|
+
function candidateCSharpServers(home = dshHome()) {
|
|
6434
|
+
const out = {};
|
|
6435
|
+
for (const dir of vscodeExtensionsDirs(home)) for (const extDir of listMatchingDirs(dir, "ms-dotnettools.csharp-")) {
|
|
6436
|
+
const roslyn = join(extDir, ".roslyn", "Microsoft.CodeAnalysis.LanguageServer.dll");
|
|
6437
|
+
if (!out.roslynDll && existsSync(roslyn)) out.roslynDll = roslyn;
|
|
6438
|
+
const omni = join(extDir, ".omnisharp", "OmniSharp" + exeSuffix());
|
|
6439
|
+
if (!out.omnisharpExe && existsSync(omni)) out.omnisharpExe = omni;
|
|
6440
|
+
}
|
|
6441
|
+
const dotnet = findDotnet();
|
|
6442
|
+
if (dotnet) out.dotnet = dotnet;
|
|
6443
|
+
return out;
|
|
6444
|
+
}
|
|
6445
|
+
/**
|
|
6446
|
+
* 手动配置归一化:path 指向可执行文件/目录,command 为可执行名(PATH 解析)。
|
|
6447
|
+
* 未配置时返回空 reason(由各 resolve 兜底输出语言相关安装指引文案)。
|
|
6448
|
+
* @author ddj 2026年08月27号
|
|
6449
|
+
*/
|
|
6450
|
+
function manualSpec(manual) {
|
|
6451
|
+
if (!manual) return {
|
|
6452
|
+
argv: [],
|
|
6453
|
+
ready: false
|
|
6454
|
+
};
|
|
6455
|
+
const pathValue = typeof manual.path === "string" && manual.path.trim() ? manual.path.trim() : "";
|
|
6456
|
+
const commandValue = typeof manual.command === "string" && manual.command.trim() ? manual.command.trim() : "";
|
|
6457
|
+
if (!pathValue && !commandValue) return {
|
|
6458
|
+
argv: [],
|
|
6459
|
+
ready: false
|
|
6460
|
+
};
|
|
6461
|
+
if (pathValue) {
|
|
6462
|
+
if (!existsSync(pathValue)) return {
|
|
6463
|
+
argv: [],
|
|
6464
|
+
ready: false,
|
|
6465
|
+
reason: "路径不存在:" + pathValue
|
|
6466
|
+
};
|
|
6467
|
+
return {
|
|
6468
|
+
argv: [pathValue],
|
|
6469
|
+
ready: true
|
|
6470
|
+
};
|
|
6471
|
+
}
|
|
6472
|
+
const found = findInPath(commandValue);
|
|
6473
|
+
if (!found) return {
|
|
6474
|
+
argv: [],
|
|
6475
|
+
ready: false,
|
|
6476
|
+
reason: "PATH 中找不到命令:" + commandValue
|
|
6477
|
+
};
|
|
6478
|
+
return {
|
|
6479
|
+
argv: [found],
|
|
6480
|
+
ready: true
|
|
6481
|
+
};
|
|
6482
|
+
}
|
|
6483
|
+
/**
|
|
6484
|
+
* 解析 Lua provider(优先级:手动 > 扩展源 > 自动发现)。
|
|
6485
|
+
* @author ddj 2026年08月27号
|
|
6486
|
+
* @param manual 手动配置
|
|
6487
|
+
* @param home DSH home(缺省真实;测试可注入临时目录)
|
|
6488
|
+
* @returns provider 规格
|
|
6489
|
+
*/
|
|
6490
|
+
function resolveLuaProvider(manual, home = dshHome()) {
|
|
6491
|
+
const base = { languageId: "lua" };
|
|
6492
|
+
const manualResolved = manualSpec(manual);
|
|
6493
|
+
if (manualResolved.ready) return {
|
|
6494
|
+
...base,
|
|
6495
|
+
kind: "manual",
|
|
6496
|
+
argv: manualResolved.argv,
|
|
6497
|
+
cwd: manualResolved.cwd,
|
|
6498
|
+
ready: true
|
|
6499
|
+
};
|
|
6500
|
+
const ext = extensionProviderFor("lua");
|
|
6501
|
+
if (ext) return ext;
|
|
6502
|
+
const candidates = candidateLuaServers(home);
|
|
6503
|
+
if (candidates.length) return {
|
|
6504
|
+
...base,
|
|
6505
|
+
kind: "discover",
|
|
6506
|
+
argv: [candidates[0]],
|
|
6507
|
+
cwd: void 0,
|
|
6508
|
+
ready: true
|
|
6509
|
+
};
|
|
6510
|
+
const inPath = findInPath("lua-language-server" + exeSuffix());
|
|
6511
|
+
if (inPath) return {
|
|
6512
|
+
...base,
|
|
6513
|
+
kind: "discover",
|
|
6514
|
+
argv: [inPath],
|
|
6515
|
+
ready: true
|
|
6516
|
+
};
|
|
6517
|
+
return {
|
|
6518
|
+
...base,
|
|
6519
|
+
kind: "none",
|
|
6520
|
+
argv: [],
|
|
6521
|
+
ready: false,
|
|
6522
|
+
reason: manualResolved.reason ?? "未发现 lua-language-server(可安装 LuaLS 或手动指定路径)"
|
|
6523
|
+
};
|
|
6524
|
+
}
|
|
6525
|
+
/**
|
|
6526
|
+
* 解析 C# provider(优先级:手动 > 扩展源 > 自动发现;需 dotnet + Roslyn dll;或 OmniSharp)。
|
|
6527
|
+
* @author ddj 2026年08月27号
|
|
6528
|
+
* @param manual 手动配置
|
|
6529
|
+
* @param home DSH home(缺省真实;测试可注入临时目录)
|
|
6530
|
+
* @returns provider 规格
|
|
6531
|
+
*/
|
|
6532
|
+
function resolveCSharpProvider(manual, home = dshHome()) {
|
|
6533
|
+
const base = { languageId: "csharp" };
|
|
6534
|
+
const manualResolved = manualSpec(manual);
|
|
6535
|
+
if (manualResolved.ready) return {
|
|
6536
|
+
...base,
|
|
6537
|
+
kind: "manual",
|
|
6538
|
+
argv: manualResolved.argv,
|
|
6539
|
+
cwd: manualResolved.cwd,
|
|
6540
|
+
ready: true
|
|
6541
|
+
};
|
|
6542
|
+
const ext = extensionProviderFor("csharp");
|
|
6543
|
+
if (ext) return ext;
|
|
6544
|
+
const discovered = candidateCSharpServers(home);
|
|
6545
|
+
if (discovered.roslynDll && discovered.dotnet) return {
|
|
6546
|
+
...base,
|
|
6547
|
+
kind: "discover",
|
|
6548
|
+
argv: [
|
|
6549
|
+
discovered.dotnet,
|
|
6550
|
+
discovered.roslynDll,
|
|
6551
|
+
"--stdio"
|
|
6552
|
+
],
|
|
6553
|
+
ready: true
|
|
6554
|
+
};
|
|
6555
|
+
if (discovered.omnisharpExe) return {
|
|
6556
|
+
...base,
|
|
6557
|
+
kind: "discover",
|
|
6558
|
+
argv: [discovered.omnisharpExe, "--stdio"],
|
|
6559
|
+
ready: true
|
|
6560
|
+
};
|
|
6561
|
+
const reasons = [];
|
|
6562
|
+
if (!discovered.dotnet) reasons.push("未找到 dotnet");
|
|
6563
|
+
if (!discovered.roslynDll && !discovered.omnisharpExe) reasons.push("未发现 ms-dotnettools.csharp 扩展");
|
|
6564
|
+
return {
|
|
6565
|
+
...base,
|
|
6566
|
+
kind: "none",
|
|
6567
|
+
argv: [],
|
|
6568
|
+
ready: false,
|
|
6569
|
+
reason: reasons.join(";") + "(可手动指定 Roslyn/OmniSharp 入口)"
|
|
6570
|
+
};
|
|
6571
|
+
}
|
|
6572
|
+
const PROVIDER_RESOLVERS = {
|
|
6573
|
+
lua: resolveLuaProvider,
|
|
6574
|
+
csharp: resolveCSharpProvider
|
|
6575
|
+
};
|
|
6576
|
+
//#endregion
|
|
6577
|
+
//#region src/lsp/config.ts
|
|
6578
|
+
const LSP_SETTINGS_NS = "dsh-vscode-mode.languageServers";
|
|
6579
|
+
const LSP_LANGUAGES = ["lua", "csharp"];
|
|
6580
|
+
function sanitizeLang(raw) {
|
|
6581
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
6582
|
+
const obj = raw;
|
|
6583
|
+
const out = {};
|
|
6584
|
+
if (typeof obj.enabled === "boolean") out.enabled = obj.enabled;
|
|
6585
|
+
if (typeof obj.command === "string") out.command = obj.command;
|
|
6586
|
+
if (typeof obj.path === "string") out.path = obj.path;
|
|
6587
|
+
return out;
|
|
6588
|
+
}
|
|
6589
|
+
/** 从插件组合配置读取。 */
|
|
6590
|
+
function configFromPlugin(config) {
|
|
6591
|
+
const raw = config?.languageServers;
|
|
6592
|
+
if (!raw || typeof raw !== "object") return {};
|
|
6593
|
+
const out = {};
|
|
6594
|
+
for (const lang of LSP_LANGUAGES) {
|
|
6595
|
+
const v = sanitizeLang(raw[lang]);
|
|
6596
|
+
if (v) out[lang] = v;
|
|
6597
|
+
}
|
|
6598
|
+
return out;
|
|
6599
|
+
}
|
|
6600
|
+
/** 从 settings 描述符读取(无 settings 服务/无该 section → {})。 */
|
|
6601
|
+
function configFromSettings(ctx) {
|
|
6602
|
+
try {
|
|
6603
|
+
const value = (ctx.get("settings")?.describe?.({ redactSecrets: true })?.find((item) => item.ns === LSP_SETTINGS_NS))?.value;
|
|
6604
|
+
if (!value || typeof value !== "object") return {};
|
|
6605
|
+
const out = {};
|
|
6606
|
+
for (const lang of LSP_LANGUAGES) {
|
|
6607
|
+
const v = sanitizeLang(value[lang]);
|
|
6608
|
+
if (v) out[lang] = v;
|
|
6609
|
+
}
|
|
6610
|
+
return out;
|
|
6611
|
+
} catch (error) {
|
|
6612
|
+
return {};
|
|
6613
|
+
}
|
|
6614
|
+
}
|
|
6615
|
+
/** 合并配置:settings 覆盖插件配置。 */
|
|
6616
|
+
function mergeConfig(a, b) {
|
|
6617
|
+
const out = { ...a };
|
|
6618
|
+
for (const lang of LSP_LANGUAGES) {
|
|
6619
|
+
const av = a[lang];
|
|
6620
|
+
const bv = b[lang];
|
|
6621
|
+
if (bv) out[lang] = {
|
|
6622
|
+
...av,
|
|
6623
|
+
...bv
|
|
6624
|
+
};
|
|
6625
|
+
else if (av) out[lang] = av;
|
|
6626
|
+
}
|
|
6627
|
+
return out;
|
|
6628
|
+
}
|
|
6629
|
+
/**
|
|
6630
|
+
* 解析某语言的 provider spec(合并配置后)。
|
|
6631
|
+
* @author ddj 2026年08月27号
|
|
6632
|
+
* @param ctx DSH 上下文
|
|
6633
|
+
* @param pluginConfig 插件组合配置
|
|
6634
|
+
* @param languageId 语言 id
|
|
6635
|
+
* @returns provider 规格
|
|
6636
|
+
*/
|
|
6637
|
+
function resolveProviderSpec(ctx, pluginConfig, languageId) {
|
|
6638
|
+
const resolver = PROVIDER_RESOLVERS[languageId];
|
|
6639
|
+
if (!resolver) return {
|
|
6640
|
+
languageId,
|
|
6641
|
+
kind: "none",
|
|
6642
|
+
argv: [],
|
|
6643
|
+
ready: false,
|
|
6644
|
+
reason: "不支持的语言:" + languageId
|
|
6645
|
+
};
|
|
6646
|
+
const lang = mergeConfig(configFromPlugin(pluginConfig), configFromSettings(ctx))[languageId];
|
|
6647
|
+
if (lang && lang.enabled === false) return {
|
|
6648
|
+
languageId,
|
|
6649
|
+
kind: "none",
|
|
6650
|
+
argv: [],
|
|
6651
|
+
ready: false,
|
|
6652
|
+
reason: "已在设置中禁用"
|
|
6653
|
+
};
|
|
6654
|
+
return resolver({
|
|
6655
|
+
command: lang?.command,
|
|
6656
|
+
path: lang?.path
|
|
6657
|
+
});
|
|
6658
|
+
}
|
|
6659
|
+
/** 语言 → 扩展名(供客户端判断是否触发 LSP)。 */
|
|
6660
|
+
function langOfPath(path) {
|
|
6661
|
+
const base = String(path || "").split(/[\\/]/).pop() || "";
|
|
6662
|
+
const dot = base.lastIndexOf(".");
|
|
6663
|
+
if (dot <= 0) return null;
|
|
6664
|
+
const ext = base.slice(dot + 1).toLowerCase();
|
|
6665
|
+
for (const [lang, exts] of Object.entries({
|
|
6666
|
+
lua: ["lua"],
|
|
6667
|
+
csharp: ["cs", "csx"]
|
|
6668
|
+
})) if (exts.includes(ext)) return lang;
|
|
6669
|
+
return null;
|
|
6670
|
+
}
|
|
6671
|
+
//#endregion
|
|
6672
|
+
//#region src/lsp/rpc.ts
|
|
6673
|
+
/**
|
|
6674
|
+
* 打开文档跟踪:openDocs(docKey→sessionId) + rootLangRefs 计数 + sessionAcquired 归属。
|
|
6675
|
+
* 保证 manager 的 acquire/release 一一配对(多会话共享同一 root|lang 也平衡)。
|
|
6676
|
+
*/
|
|
6677
|
+
function createDocTracker() {
|
|
6678
|
+
const openDocs = /* @__PURE__ */ new Map();
|
|
6679
|
+
const rootLangRefs = /* @__PURE__ */ new Map();
|
|
6680
|
+
const sessionDocs = /* @__PURE__ */ new Map();
|
|
6681
|
+
const keyOf = (root, lang, path) => root + "|" + lang + "|" + path;
|
|
6682
|
+
const rootLangOf = (docKey) => docKey.slice(0, docKey.lastIndexOf("|"));
|
|
6683
|
+
const rememberDoc = (sessionId, docKey) => {
|
|
6684
|
+
const docs = sessionDocs.get(sessionId) ?? /* @__PURE__ */ new Set();
|
|
6685
|
+
docs.add(docKey);
|
|
6686
|
+
sessionDocs.set(sessionId, docs);
|
|
6687
|
+
};
|
|
6688
|
+
const forgetDoc = (sessionId, docKey) => {
|
|
6689
|
+
const docs = sessionDocs.get(sessionId);
|
|
6690
|
+
docs?.delete(docKey);
|
|
6691
|
+
if (docs?.size === 0) sessionDocs.delete(sessionId);
|
|
6692
|
+
};
|
|
6693
|
+
const releaseRef = (rl) => {
|
|
6694
|
+
const next = (rootLangRefs.get(rl) ?? 1) - 1;
|
|
6695
|
+
if (next <= 0) {
|
|
6696
|
+
rootLangRefs.delete(rl);
|
|
6697
|
+
return true;
|
|
6698
|
+
}
|
|
6699
|
+
rootLangRefs.set(rl, next);
|
|
6700
|
+
return false;
|
|
6701
|
+
};
|
|
6702
|
+
return {
|
|
6703
|
+
/** 记录文档打开;同一文档可由多个会话打开,但每个会话只计一次。 */
|
|
6704
|
+
open(root, lang, path, sessionId) {
|
|
6705
|
+
const docKey = keyOf(root, lang, path);
|
|
6706
|
+
const owners = openDocs.get(docKey) ?? /* @__PURE__ */ new Set();
|
|
6707
|
+
if (owners.has(sessionId)) return false;
|
|
6708
|
+
owners.add(sessionId);
|
|
6709
|
+
openDocs.set(docKey, owners);
|
|
6710
|
+
rememberDoc(sessionId, docKey);
|
|
6711
|
+
const rl = root + "|" + lang;
|
|
6712
|
+
const count = rootLangRefs.get(rl) ?? 0;
|
|
6713
|
+
rootLangRefs.set(rl, count + 1);
|
|
6714
|
+
return count === 0;
|
|
6715
|
+
},
|
|
6716
|
+
/** 记录文档关闭;返回 true 表示 root|lang 已无文档(应 release)。 */
|
|
6717
|
+
close(root, lang, path, sessionId) {
|
|
6718
|
+
const docKey = keyOf(root, lang, path);
|
|
6719
|
+
const owners = openDocs.get(docKey);
|
|
6720
|
+
if (!owners?.has(sessionId)) return false;
|
|
6721
|
+
owners.delete(sessionId);
|
|
6722
|
+
if (owners.size === 0) openDocs.delete(docKey);
|
|
6723
|
+
forgetDoc(sessionId, docKey);
|
|
6724
|
+
return releaseRef(root + "|" + lang);
|
|
6725
|
+
},
|
|
6726
|
+
/** 关闭某会话全部文档;返回全局引用归零、需要 release 的 root|lang。 */
|
|
6727
|
+
closeSession(sessionId) {
|
|
6728
|
+
const docs = sessionDocs.get(sessionId);
|
|
6729
|
+
if (!docs) return [];
|
|
6730
|
+
const toRelease = /* @__PURE__ */ new Set();
|
|
6731
|
+
for (const docKey of docs) {
|
|
6732
|
+
const owners = openDocs.get(docKey);
|
|
6733
|
+
owners?.delete(sessionId);
|
|
6734
|
+
if (owners?.size === 0) openDocs.delete(docKey);
|
|
6735
|
+
if (releaseRef(rootLangOf(docKey))) toRelease.add(rootLangOf(docKey));
|
|
6736
|
+
}
|
|
6737
|
+
sessionDocs.delete(sessionId);
|
|
6738
|
+
return [...toRelease];
|
|
6739
|
+
}
|
|
6740
|
+
};
|
|
6741
|
+
}
|
|
6742
|
+
/**
|
|
6743
|
+
* 构造 edrv.lsp.* handlers(一次性创建,tracker 状态跨请求保留)。
|
|
6744
|
+
* @author ddj 2026年08月27号
|
|
6745
|
+
* @param deps 依赖(ctx/pluginConfig/manager)
|
|
6746
|
+
* @returns { handlers, disposeSession } handlers 为局部 map,disposeSession 供 session/disposed 清理
|
|
6747
|
+
*/
|
|
6748
|
+
function createLspRpc(deps) {
|
|
6749
|
+
const { ctx, pluginConfig, manager } = deps;
|
|
6750
|
+
const tracker = createDocTracker();
|
|
6751
|
+
/** 解析会话与工作区根(失败 → null + error)。 */
|
|
6752
|
+
const rootOf = async (sessionId) => {
|
|
6753
|
+
const session = sessionOf(ctx, sessionId);
|
|
6754
|
+
if (!session) return { err: "会话不存在" };
|
|
6755
|
+
if (!cwdOf(session)) return { err: "会话无工作区" };
|
|
6756
|
+
try {
|
|
6757
|
+
return {
|
|
6758
|
+
root: await searchRoot(ctx, session),
|
|
6759
|
+
sessionId: typeof session.id === "string" && session.id ? session.id : sessionId ?? ""
|
|
6760
|
+
};
|
|
6761
|
+
} catch (error) {
|
|
6762
|
+
return { err: String(error) };
|
|
6763
|
+
}
|
|
6764
|
+
};
|
|
6765
|
+
const acquireServer = (root, lang) => manager.acquire(root, lang, (languageId) => resolveProviderSpec(ctx, pluginConfig, languageId));
|
|
6766
|
+
const serverOf = (root, lang) => manager.peek(root, lang);
|
|
6767
|
+
const rootLocations = (locations, root) => locations.map((location) => ({
|
|
6768
|
+
...location,
|
|
6769
|
+
root
|
|
6770
|
+
}));
|
|
6771
|
+
const disposeSession = (sessionId) => {
|
|
6772
|
+
for (const rl of tracker.closeSession(sessionId)) {
|
|
6773
|
+
const sep = rl.lastIndexOf("|");
|
|
6774
|
+
manager.release(rl.slice(0, sep), rl.slice(sep + 1));
|
|
6775
|
+
}
|
|
6776
|
+
};
|
|
6777
|
+
return {
|
|
6778
|
+
handlers: {
|
|
6779
|
+
"edrv.lsp.status": async (args) => {
|
|
6780
|
+
if (!args.sessionId) return {
|
|
6781
|
+
ok: true,
|
|
6782
|
+
servers: manager.statusAll()
|
|
6783
|
+
};
|
|
6784
|
+
const sc = await rootOf(args.sessionId);
|
|
6785
|
+
if ("err" in sc) return {
|
|
6786
|
+
ok: false,
|
|
6787
|
+
error: sc.err
|
|
6788
|
+
};
|
|
6789
|
+
return {
|
|
6790
|
+
ok: true,
|
|
6791
|
+
servers: manager.statusAll().filter((status) => status.root === sc.root)
|
|
6792
|
+
};
|
|
6793
|
+
},
|
|
6794
|
+
"edrv.lsp.configGet": async () => {
|
|
6795
|
+
const settings = configFromSettings(ctx);
|
|
6796
|
+
const plugin = configFromPlugin(pluginConfig);
|
|
6797
|
+
const merged = { ...plugin };
|
|
6798
|
+
for (const lang of Object.keys(settings)) merged[lang] = {
|
|
6799
|
+
...plugin[lang],
|
|
6800
|
+
...settings[lang]
|
|
6801
|
+
};
|
|
6802
|
+
return {
|
|
6803
|
+
ok: true,
|
|
6804
|
+
config: merged
|
|
6805
|
+
};
|
|
6806
|
+
},
|
|
6807
|
+
"edrv.lsp.configUpdate": async (args) => {
|
|
6808
|
+
const lang = args.languageId;
|
|
6809
|
+
if (!lang || !resolveProviderSpec(ctx, pluginConfig, lang).languageId) return {
|
|
6810
|
+
ok: false,
|
|
6811
|
+
error: "不支持的语言:" + lang
|
|
6812
|
+
};
|
|
6813
|
+
const current = configFromSettings(ctx);
|
|
6814
|
+
const target = {
|
|
6815
|
+
...current,
|
|
6816
|
+
[lang]: { ...current[lang] ?? {} }
|
|
6817
|
+
}[lang];
|
|
6818
|
+
if (args.enabled !== void 0) target.enabled = args.enabled;
|
|
6819
|
+
if (args.command !== void 0) target.command = args.command || void 0;
|
|
6820
|
+
if (args.path !== void 0) target.path = args.path || void 0;
|
|
6821
|
+
try {
|
|
6822
|
+
const settings = ctx.get("settings");
|
|
6823
|
+
if (!settings?.update) return {
|
|
6824
|
+
ok: false,
|
|
6825
|
+
error: "设置服务不可用"
|
|
6826
|
+
};
|
|
6827
|
+
await settings.update(LSP_SETTINGS_NS, { [lang]: target });
|
|
6828
|
+
const stored = (settings.describe?.({ redactSecrets: true })?.find((item) => item.ns === LSP_SETTINGS_NS))?.value;
|
|
6829
|
+
return {
|
|
6830
|
+
ok: true,
|
|
6831
|
+
config: {
|
|
6832
|
+
...configFromPlugin(pluginConfig),
|
|
6833
|
+
...stored
|
|
6834
|
+
}
|
|
6835
|
+
};
|
|
6836
|
+
} catch (error) {
|
|
6837
|
+
return {
|
|
6838
|
+
ok: false,
|
|
6839
|
+
error: "配置保存失败:" + String(error)
|
|
6840
|
+
};
|
|
6841
|
+
}
|
|
6842
|
+
},
|
|
6843
|
+
"edrv.lsp.sync": async (args) => {
|
|
6844
|
+
const lang = langOfPath(args.path);
|
|
6845
|
+
if (!lang) return { ok: true };
|
|
6846
|
+
const sc = await rootOf(args.sessionId);
|
|
6847
|
+
if ("err" in sc) return {
|
|
6848
|
+
ok: false,
|
|
6849
|
+
error: sc.err
|
|
6850
|
+
};
|
|
6851
|
+
const server = tracker.open(sc.root, lang, args.path, sc.sessionId) ? acquireServer(sc.root, lang) : serverOf(sc.root, lang);
|
|
6852
|
+
if (!server) return {
|
|
6853
|
+
ok: false,
|
|
6854
|
+
error: "语言服务器未注册"
|
|
6855
|
+
};
|
|
6856
|
+
server.sync(args.path, args.text, args.version);
|
|
6857
|
+
return { ok: true };
|
|
6858
|
+
},
|
|
6859
|
+
"edrv.lsp.close": async (args) => {
|
|
6860
|
+
const lang = langOfPath(args.path);
|
|
6861
|
+
if (!lang) return { ok: true };
|
|
6862
|
+
const sc = await rootOf(args.sessionId);
|
|
6863
|
+
if ("err" in sc) return { ok: true };
|
|
6864
|
+
if (tracker.close(sc.root, lang, args.path, sc.sessionId)) manager.release(sc.root, lang);
|
|
6865
|
+
return { ok: true };
|
|
6866
|
+
},
|
|
6867
|
+
"edrv.lsp.definition": async (args) => {
|
|
6868
|
+
const lang = langOfPath(args.path);
|
|
6869
|
+
if (!lang) return {
|
|
6870
|
+
ok: true,
|
|
6871
|
+
locations: []
|
|
6872
|
+
};
|
|
6873
|
+
const sc = await rootOf(args.sessionId);
|
|
6874
|
+
if ("err" in sc) return {
|
|
6875
|
+
ok: false,
|
|
6876
|
+
error: sc.err
|
|
6877
|
+
};
|
|
6878
|
+
try {
|
|
6879
|
+
const server = serverOf(sc.root, lang);
|
|
6880
|
+
if (!server) return {
|
|
6881
|
+
ok: true,
|
|
6882
|
+
locations: []
|
|
6883
|
+
};
|
|
6884
|
+
const locations = await server.definition(args.path, args.position.line, args.position.character);
|
|
6885
|
+
const rooted = rootLocations(locations, sc.root);
|
|
6886
|
+
const truncated = rooted.length > 500;
|
|
6887
|
+
return {
|
|
6888
|
+
ok: true,
|
|
6889
|
+
locations: truncated ? rooted.slice(0, 500) : rooted,
|
|
6890
|
+
truncated: truncated || void 0
|
|
6891
|
+
};
|
|
6892
|
+
} catch (error) {
|
|
6893
|
+
return {
|
|
6894
|
+
ok: false,
|
|
6895
|
+
error: "LSP 定义查询失败:" + String(error)
|
|
6896
|
+
};
|
|
6897
|
+
}
|
|
6898
|
+
},
|
|
6899
|
+
"edrv.lsp.references": async (args) => {
|
|
6900
|
+
const lang = langOfPath(args.path);
|
|
6901
|
+
if (!lang) return {
|
|
6902
|
+
ok: true,
|
|
6903
|
+
locations: []
|
|
6904
|
+
};
|
|
6905
|
+
const sc = await rootOf(args.sessionId);
|
|
6906
|
+
if ("err" in sc) return {
|
|
6907
|
+
ok: false,
|
|
6908
|
+
error: sc.err
|
|
6909
|
+
};
|
|
6910
|
+
try {
|
|
6911
|
+
const server = serverOf(sc.root, lang);
|
|
6912
|
+
if (!server) return {
|
|
6913
|
+
ok: true,
|
|
6914
|
+
locations: []
|
|
6915
|
+
};
|
|
6916
|
+
const locations = await server.references(args.path, args.position.line, args.position.character, args.includeDeclaration !== false);
|
|
6917
|
+
const rooted = rootLocations(locations, sc.root);
|
|
6918
|
+
const truncated = rooted.length > 500;
|
|
6919
|
+
return {
|
|
6920
|
+
ok: true,
|
|
6921
|
+
locations: truncated ? rooted.slice(0, 500) : rooted,
|
|
6922
|
+
truncated: truncated || void 0
|
|
6923
|
+
};
|
|
6924
|
+
} catch (error) {
|
|
6925
|
+
return {
|
|
6926
|
+
ok: false,
|
|
6927
|
+
error: "LSP 引用查询失败:" + String(error)
|
|
6928
|
+
};
|
|
6929
|
+
}
|
|
6930
|
+
},
|
|
6931
|
+
"edrv.lsp.documentSymbol": async (args) => {
|
|
6932
|
+
const lang = langOfPath(args.path);
|
|
6933
|
+
if (!lang) return {
|
|
6934
|
+
ok: true,
|
|
6935
|
+
symbols: []
|
|
6936
|
+
};
|
|
6937
|
+
const sc = await rootOf(args.sessionId);
|
|
6938
|
+
if ("err" in sc) return {
|
|
6939
|
+
ok: false,
|
|
6940
|
+
error: sc.err
|
|
6941
|
+
};
|
|
6942
|
+
try {
|
|
6943
|
+
const server = serverOf(sc.root, lang);
|
|
6944
|
+
if (!server) return {
|
|
6945
|
+
ok: true,
|
|
6946
|
+
symbols: []
|
|
6947
|
+
};
|
|
6948
|
+
return {
|
|
6949
|
+
ok: true,
|
|
6950
|
+
symbols: await server.documentSymbol(args.path)
|
|
6951
|
+
};
|
|
6952
|
+
} catch (error) {
|
|
6953
|
+
return {
|
|
6954
|
+
ok: false,
|
|
6955
|
+
error: "LSP 大纲查询失败:" + String(error)
|
|
6956
|
+
};
|
|
6957
|
+
}
|
|
6958
|
+
},
|
|
6959
|
+
"edrv.lsp.workspaceSymbol": async (args) => {
|
|
6960
|
+
const sc = await rootOf(args.sessionId);
|
|
6961
|
+
if ("err" in sc) return {
|
|
6962
|
+
ok: false,
|
|
6963
|
+
error: sc.err
|
|
6964
|
+
};
|
|
6965
|
+
const out = { symbols: [] };
|
|
6966
|
+
for (const lang of ["lua", "csharp"]) {
|
|
6967
|
+
const existing = serverOf(sc.root, lang);
|
|
6968
|
+
if (!existing) continue;
|
|
6969
|
+
try {
|
|
6970
|
+
const symbols = await existing.workspaceSymbol(args.query);
|
|
6971
|
+
if (symbols.length) out.symbols.push(...symbols);
|
|
6972
|
+
} catch (error) {}
|
|
6973
|
+
}
|
|
6974
|
+
const truncated = out.symbols.length > 500;
|
|
6975
|
+
return {
|
|
6976
|
+
ok: true,
|
|
6977
|
+
symbols: out.symbols.slice(0, 500),
|
|
6978
|
+
truncated: truncated || void 0
|
|
6979
|
+
};
|
|
6980
|
+
},
|
|
6981
|
+
"edrv.lsp.hover": async (args) => {
|
|
6982
|
+
const lang = langOfPath(args.path);
|
|
6983
|
+
if (!lang) return {
|
|
6984
|
+
ok: true,
|
|
6985
|
+
hover: void 0
|
|
6986
|
+
};
|
|
6987
|
+
const sc = await rootOf(args.sessionId);
|
|
6988
|
+
if ("err" in sc) return {
|
|
6989
|
+
ok: false,
|
|
6990
|
+
error: sc.err
|
|
6991
|
+
};
|
|
6992
|
+
try {
|
|
6993
|
+
const server = serverOf(sc.root, lang);
|
|
6994
|
+
if (!server) return {
|
|
6995
|
+
ok: true,
|
|
6996
|
+
hover: void 0
|
|
6997
|
+
};
|
|
6998
|
+
return {
|
|
6999
|
+
ok: true,
|
|
7000
|
+
hover: await server.hover(args.path, args.position.line, args.position.character) ?? void 0
|
|
7001
|
+
};
|
|
7002
|
+
} catch (error) {
|
|
7003
|
+
return {
|
|
7004
|
+
ok: false,
|
|
7005
|
+
error: "LSP hover 查询失败:" + String(error)
|
|
7006
|
+
};
|
|
7007
|
+
}
|
|
7008
|
+
},
|
|
7009
|
+
"edrv.lsp.ext.list": async () => ({
|
|
7010
|
+
ok: true,
|
|
7011
|
+
extensions: listInstalled()
|
|
7012
|
+
}),
|
|
7013
|
+
"edrv.lsp.ext.market": async (args) => {
|
|
7014
|
+
try {
|
|
7015
|
+
return {
|
|
7016
|
+
ok: true,
|
|
7017
|
+
extensions: await marketSearch(args.query, args.size ?? 12)
|
|
7018
|
+
};
|
|
7019
|
+
} catch (error) {
|
|
7020
|
+
return {
|
|
7021
|
+
ok: false,
|
|
7022
|
+
error: "市场搜索失败:" + String(error)
|
|
7023
|
+
};
|
|
7024
|
+
}
|
|
7025
|
+
},
|
|
7026
|
+
"edrv.lsp.ext.install": async (args) => {
|
|
7027
|
+
try {
|
|
7028
|
+
let info;
|
|
7029
|
+
if (args.vsixPath) info = await installVsixFile(args.vsixPath);
|
|
7030
|
+
else if (args.namespace && args.name) info = await installFromMarket(args.namespace, args.name, args.version);
|
|
7031
|
+
else return {
|
|
7032
|
+
ok: false,
|
|
7033
|
+
error: "缺少安装源(vsixPath 或 namespace+name)"
|
|
7034
|
+
};
|
|
7035
|
+
return {
|
|
7036
|
+
ok: true,
|
|
7037
|
+
extension: info
|
|
7038
|
+
};
|
|
7039
|
+
} catch (error) {
|
|
7040
|
+
return {
|
|
7041
|
+
ok: false,
|
|
7042
|
+
error: "安装失败:" + String(error)
|
|
7043
|
+
};
|
|
7044
|
+
}
|
|
7045
|
+
},
|
|
7046
|
+
"edrv.lsp.ext.uninstall": async (args) => {
|
|
7047
|
+
return uninstall(args.id) ? { ok: true } : {
|
|
7048
|
+
ok: false,
|
|
7049
|
+
error: "扩展未安装:" + args.id
|
|
7050
|
+
};
|
|
7051
|
+
},
|
|
7052
|
+
"edrv.lsp.ext.update": async (args) => {
|
|
7053
|
+
try {
|
|
7054
|
+
const updated = await updateExtension(args.id);
|
|
7055
|
+
if (!updated) {
|
|
7056
|
+
const current = listInstalled().find((e) => e.id === args.id);
|
|
7057
|
+
return current ? {
|
|
7058
|
+
ok: true,
|
|
7059
|
+
extension: current,
|
|
7060
|
+
updated: false
|
|
7061
|
+
} : {
|
|
7062
|
+
ok: false,
|
|
7063
|
+
error: "扩展未安装:" + args.id
|
|
7064
|
+
};
|
|
7065
|
+
}
|
|
7066
|
+
return {
|
|
7067
|
+
ok: true,
|
|
7068
|
+
extension: updated,
|
|
7069
|
+
updated: true
|
|
7070
|
+
};
|
|
7071
|
+
} catch (error) {
|
|
7072
|
+
return {
|
|
7073
|
+
ok: false,
|
|
7074
|
+
error: "更新失败:" + String(error)
|
|
7075
|
+
};
|
|
7076
|
+
}
|
|
7077
|
+
},
|
|
7078
|
+
"edrv.lsp.ext.updates": async () => {
|
|
7079
|
+
try {
|
|
7080
|
+
const updates = [];
|
|
7081
|
+
for (const ext of listInstalled()) {
|
|
7082
|
+
const meta = await marketGet(ext.namespace, ext.name);
|
|
7083
|
+
const latest = String(meta?.version ?? "");
|
|
7084
|
+
if (latest && latest !== ext.version) updates.push({
|
|
7085
|
+
id: ext.id,
|
|
7086
|
+
current: ext.version,
|
|
7087
|
+
latest,
|
|
7088
|
+
displayName: ext.displayName
|
|
7089
|
+
});
|
|
7090
|
+
}
|
|
7091
|
+
return {
|
|
7092
|
+
ok: true,
|
|
7093
|
+
updates
|
|
7094
|
+
};
|
|
7095
|
+
} catch (error) {
|
|
7096
|
+
return {
|
|
7097
|
+
ok: false,
|
|
7098
|
+
error: "检查更新失败:" + String(error)
|
|
7099
|
+
};
|
|
7100
|
+
}
|
|
7101
|
+
}
|
|
7102
|
+
},
|
|
7103
|
+
disposeSession
|
|
7104
|
+
};
|
|
7105
|
+
}
|
|
7106
|
+
//#endregion
|
|
7107
|
+
//#region src/lsp/settings.ts
|
|
7108
|
+
/**
|
|
7109
|
+
* 安装 LSP 设置 section。
|
|
7110
|
+
* @author ddj 2026年08月27号
|
|
7111
|
+
* @param ctx DSH 上下文
|
|
7112
|
+
* @param entry 初始配置
|
|
7113
|
+
* @returns 是否成功安装
|
|
7114
|
+
*/
|
|
7115
|
+
async function installLspSettingsSection(ctx, entry) {
|
|
7116
|
+
const deps = await loadSettingsDeps();
|
|
7117
|
+
if (!deps) return false;
|
|
7118
|
+
try {
|
|
7119
|
+
const z = deps.z;
|
|
7120
|
+
const langShape = {};
|
|
7121
|
+
for (const lang of LSP_LANGUAGES) langShape[lang] = z.object({
|
|
7122
|
+
enabled: z.boolean(),
|
|
7123
|
+
command: z.string(),
|
|
7124
|
+
path: z.string()
|
|
7125
|
+
}).default({});
|
|
7126
|
+
const schema = z.object(langShape).default({});
|
|
7127
|
+
deps.installSettingsSection(ctx, LSP_SETTINGS_NS, schema, entry, {
|
|
7128
|
+
setSource: () => {},
|
|
7129
|
+
onChange: () => {}
|
|
7130
|
+
});
|
|
7131
|
+
return true;
|
|
7132
|
+
} catch (error) {
|
|
7133
|
+
console.error("[dsh-vscode-mode] LSP 设置 section 安装失败:" + String(error));
|
|
7134
|
+
return false;
|
|
7135
|
+
}
|
|
7136
|
+
}
|
|
7137
|
+
//#endregion
|
|
7138
|
+
//#region src/index.ts
|
|
7139
|
+
/**
|
|
7140
|
+
* @dsh-external 生态 → dsh-vscode-mode:DSH 上的类 VSCode 编码体验(Host 半入口)。
|
|
7141
|
+
* 职责:装配 capture(tools/result 捕获 edit/write 差异)、RPC 分发(edrv.* / mcp.* / vscode.* / compat)、
|
|
7142
|
+
* webServer 路由(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)、工作区旁车持久化、
|
|
7143
|
+
* 兼容层装配(依赖守卫 + 重复装配自诊断 + 启动日志兼容性报告)。
|
|
7144
|
+
* 结构:shared/(双面契约) + model/store/capture/workspace/revert/registry/rpc/routes/compat(host 模块)。
|
|
7145
|
+
* 作者 ddj 2026-08-20
|
|
7146
|
+
*/
|
|
7147
|
+
const name = "dsh-vscode-mode";
|
|
7148
|
+
const inject = [
|
|
7149
|
+
"sessions",
|
|
7150
|
+
"fs",
|
|
7151
|
+
"webServer",
|
|
7152
|
+
"loader",
|
|
7153
|
+
"tools",
|
|
7154
|
+
"workspaceRegistry",
|
|
7155
|
+
"agents"
|
|
7156
|
+
];
|
|
7157
|
+
/**
|
|
7158
|
+
* 装配插件:挂事件监听、注册路由、安装兼容层。
|
|
7159
|
+
* @author ddj 2026年08月20号
|
|
7160
|
+
* @param ctx DSH 上下文(sessions/fs/webServer 由 inject 提供;sandboxPolicy/subprocess 惰性获取)
|
|
7161
|
+
* @param config 插件配置(可选 imageDir 覆盖图标目录)
|
|
7162
|
+
*/
|
|
7163
|
+
function apply(ctx, config) {
|
|
7164
|
+
const registry = /* @__PURE__ */ new Map();
|
|
7165
|
+
const searcher = newSearcher(ctx);
|
|
7166
|
+
const contentSearcher = newContentSearcher(ctx);
|
|
7167
|
+
/** LSP 服务器管理器(语言智能:跳转/引用/大纲)。 */
|
|
7168
|
+
const lspManager = createLspManager((line) => ctx.logger?.debug?.("[" + name + "] " + line));
|
|
7169
|
+
/** 兼容性警告收集(route 护栏等写入,启动日志一并输出)。 */
|
|
7170
|
+
const warnings = [];
|
|
7171
|
+
setupOpenSettings(ctx, config, () => {});
|
|
7172
|
+
installLspSettingsSection(ctx, {});
|
|
7173
|
+
/** LSP RPC 与会话清理(一次性创建,tracker 状态跨请求保留)。 */
|
|
7174
|
+
const lspRpc = createLspRpc({
|
|
7175
|
+
ctx,
|
|
7176
|
+
pluginConfig: config,
|
|
7177
|
+
manager: lspManager
|
|
7178
|
+
});
|
|
7179
|
+
const lspHandlers = lspRpc.handlers;
|
|
7180
|
+
ctx.on("tools/result", (exec, result) => {
|
|
7181
|
+
captureToolResult(ctx, registry, exec, result);
|
|
7182
|
+
});
|
|
7183
|
+
ctx.on("session/disposed", (session) => {
|
|
7184
|
+
const cwd = cwdOf(session);
|
|
7185
|
+
if (cwd) {
|
|
7186
|
+
registry.delete(cwd);
|
|
7187
|
+
dropFileIndex(cwd);
|
|
7188
|
+
searcher.dispose(cwd);
|
|
7189
|
+
contentSearcher.dispose(cwd);
|
|
7190
|
+
disposeIndex(cwd);
|
|
7191
|
+
}
|
|
7192
|
+
const sid = session?.id;
|
|
7193
|
+
if (typeof sid === "string") lspRpc.disposeSession(sid);
|
|
7194
|
+
});
|
|
7195
|
+
registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher, lspHandlers), (warning) => warnings.push(warning));
|
|
7196
|
+
installIsolation(ctx);
|
|
7197
|
+
sweepTreeCache();
|
|
7198
|
+
logCompatSummary(ctx, warnings);
|
|
7199
|
+
ctx.effect(() => () => {
|
|
7200
|
+
lspManager.disposeAll().catch(() => {});
|
|
7201
|
+
disposeAllServers();
|
|
7202
|
+
});
|
|
7203
|
+
ctx.logger?.info?.("[" + name + "] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用,语言服务器 LSP 已接入)");
|
|
3907
7204
|
}
|
|
3908
7205
|
/**
|
|
3909
7206
|
* 异步输出兼容性报告摘要(含重复装配/路由冲突自诊断)。
|