dsh-vscode-mode 0.1.30 → 0.1.32
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/README.md +30 -0
- package/lib/client.js +591 -42
- package/lib/client.js.map +1 -1
- package/lib/index.js +1513 -123
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/capture.ts +3 -0
- package/src/client/index.ts +5 -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 +1 -0
- package/src/client/styles/mcp.css +48 -0
- package/src/client/ui/EditorView.ts +6 -5
- package/src/client/ui/McpSettings.ts +3 -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/index.ts +5 -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 +173 -34
- package/src/search/content.ts +14 -7
- package/src/search/ripgrep.ts +54 -1
- package/src/search/types.ts +4 -1
- package/src/shared/rpc.ts +70 -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,8 +1,8 @@
|
|
|
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 { fileURLToPath } from "node:url";
|
|
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";
|
|
5
4
|
import { existsSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
8
8
|
//#region src/mcp.ts
|
|
@@ -368,6 +368,258 @@ async function runPnpmInstall(ctx, profileDir) {
|
|
|
368
368
|
}
|
|
369
369
|
}
|
|
370
370
|
//#endregion
|
|
371
|
+
//#region src/shared/rpc.ts
|
|
372
|
+
/** webServer 精确路由。 */
|
|
373
|
+
const RPC_PATH = "/edrv/rpc";
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/paths.ts
|
|
376
|
+
/**
|
|
377
|
+
* dsh-vscode-mode host — 路径常量与动态路径统一出口(PathConst)。
|
|
378
|
+
* 所有 host 侧文件路径/缓存键/路由前缀从这里取,避免散落内联:
|
|
379
|
+
* - 静态:插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 工作区级与
|
|
380
|
+
* user/ 用户级)、日志根、工作区侧车名、路由前缀、静态资源目录(import.meta.url 派生)
|
|
381
|
+
* - 动态:每工作区树缓存文件(workspace/<id>/tree.v<schema>.json)、home 解析
|
|
382
|
+
* (DSH_HOME → ~/.dsh)
|
|
383
|
+
* - 清理:缓存 sweep(分级:工作区级按版本/TTL/空目录、用户级按版本/保留期、根级残留)
|
|
384
|
+
* 纯函数可单测;raw fs 写入与引擎自身插件(settings-file 用 node:fs + DSH_HOME)一致。
|
|
385
|
+
* 作者 ddj 2026-09-01
|
|
386
|
+
*/
|
|
387
|
+
/** 插件 id(也是 .dsh 下插件工作区目录名)。 */
|
|
388
|
+
const PLUGIN_ID = "dsh-vscode-mode";
|
|
389
|
+
/** DSH 工作区目录名(默认 home 下)。 */
|
|
390
|
+
const DSH_DIR_NAME = ".dsh";
|
|
391
|
+
/** home 覆盖环境变量(与引擎 resolveDshHome 一致)。 */
|
|
392
|
+
const DSH_HOME_ENV = "DSH_HOME";
|
|
393
|
+
/** 插件缓存目录总预算:超限按 mtime 从旧到新删文件,防任意增长撑爆磁盘(最后兜底)。 */
|
|
394
|
+
const CACHE_TOTAL_CAP = 209715200;
|
|
395
|
+
/** 工作区根侧车:审查记录(用户数据,随项目走,不迁移)。 */
|
|
396
|
+
const SIDECAR = ".dsh-edit-review.json";
|
|
397
|
+
/** 工作区根侧车:归档(用户数据)。 */
|
|
398
|
+
const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
|
|
399
|
+
/** 旧 debug 日志文件名(工作区,迁移后由插件清理)。 */
|
|
400
|
+
const DEBUG_LOG = ".dsh-edit-review-debug.log";
|
|
401
|
+
/** 旧树缓存 sidecar 名(工作区,迁移后由插件清理;缓存可重建,删除无损)。 */
|
|
402
|
+
const LEGACY_TREE_SIDECARS = [".dsh-edit-review-tree.json", ".dsh-vscode-mode-tree.json"];
|
|
403
|
+
/** 路由前缀(兼容性检查用)。 */
|
|
404
|
+
const ROUTE_PREFIX = "/edrv";
|
|
405
|
+
/** Monaco 静态资源前缀。 */
|
|
406
|
+
const VENDOR_PREFIX = "/edrv/vendor";
|
|
407
|
+
/** 稳定短哈希(cwd → 缓存文件名段;sha1 截断,跨运行稳定)。 */
|
|
408
|
+
function hashOf(text) {
|
|
409
|
+
return createHash("sha1").update(String(text)).digest("hex").slice(0, 16);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* 可读安全路径段编码:非安全码位 → ~XXXX 大写十六进制(`~` 本身也转义)。
|
|
413
|
+
* 与 DSH 引擎 dsh-session-persistence-jsonl 的 encodeSegment 逐字节一致,
|
|
414
|
+
* 用于从 cwd/id 反推 `~/.dsh/sessions` 下的会话目录,纯函数可单测。
|
|
415
|
+
* @author ddj 2026年09月02号
|
|
416
|
+
* @param raw 原始路径段(非空)
|
|
417
|
+
* @returns 路径安全段(`.`/`..` 特判为 ~002E / ~002E~002E)
|
|
418
|
+
*/
|
|
419
|
+
function encodeSegment(raw) {
|
|
420
|
+
if (raw.length === 0) throw new Error("cannot encode an empty path segment");
|
|
421
|
+
if (raw === ".") return "~002E";
|
|
422
|
+
if (raw === "..") return "~002E~002E";
|
|
423
|
+
let out = "";
|
|
424
|
+
for (let i = 0; i < raw.length; i++) {
|
|
425
|
+
const code = raw.charCodeAt(i);
|
|
426
|
+
const ch = String.fromCharCode(code);
|
|
427
|
+
if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) out += ch;
|
|
428
|
+
else out += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
429
|
+
}
|
|
430
|
+
return out;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* DSH 工作区目录键:cwd → `--<可读键>--`。分隔符(/ \ :)折叠为单个 `-`,
|
|
434
|
+
* 其余非安全码位转 ~XXXX,`--` 包裹、251 截断。与引擎 projectKey 一致。
|
|
435
|
+
* @author ddj 2026年09月02号
|
|
436
|
+
* @param cwd 会话工作区绝对路径(非空)
|
|
437
|
+
* @returns `~/.dsh/sessions` 下的工作区目录名
|
|
438
|
+
*/
|
|
439
|
+
function sessionWorkspaceKey(cwd) {
|
|
440
|
+
if (cwd.length === 0) throw new Error("cannot encode an empty project path");
|
|
441
|
+
let readable = "";
|
|
442
|
+
let separatorRun = false;
|
|
443
|
+
for (let i = 0; i < cwd.length; i++) {
|
|
444
|
+
const code = cwd.charCodeAt(i);
|
|
445
|
+
const ch = String.fromCharCode(code);
|
|
446
|
+
if (ch === "/" || ch === "\\" || ch === ":") {
|
|
447
|
+
if (!separatorRun) readable += "-";
|
|
448
|
+
separatorRun = true;
|
|
449
|
+
} else if (ch !== "~" && /^[A-Za-z0-9._-]$/.test(ch)) {
|
|
450
|
+
readable += ch;
|
|
451
|
+
separatorRun = false;
|
|
452
|
+
} else {
|
|
453
|
+
readable += "~" + code.toString(16).toUpperCase().padStart(4, "0");
|
|
454
|
+
separatorRun = false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return `--${(readable.replace(/^-+/, "") || "root").slice(0, 251)}--`;
|
|
458
|
+
}
|
|
459
|
+
/** DSH 会话目录段:id → 路径安全段(与引擎 encodeSegment 一致)。 */
|
|
460
|
+
function sessionIdSegment(id) {
|
|
461
|
+
return encodeSegment(id);
|
|
462
|
+
}
|
|
463
|
+
/** DSH 会话根目录(~/.dsh/sessions;引擎 dsh-base 默认 root)。 */
|
|
464
|
+
function sessionsRoot(home = dshHome()) {
|
|
465
|
+
return join(home, "sessions");
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* 解析 DSH home:DSH_HOME(去空白)→ ~/.dsh。
|
|
469
|
+
* @author ddj 2026年09月01号
|
|
470
|
+
* @param env 环境映射(缺省 process.env;测试可注入)
|
|
471
|
+
* @returns 归一化的 DSH home 绝对路径
|
|
472
|
+
*/
|
|
473
|
+
function dshHome(env = process.env) {
|
|
474
|
+
const value = env[DSH_HOME_ENV];
|
|
475
|
+
if (value && value.trim()) return value;
|
|
476
|
+
return join(homedir(), DSH_DIR_NAME);
|
|
477
|
+
}
|
|
478
|
+
/** 插件缓存根(~/.dsh/dsh-vscode-mode/cache,下分 workspace/ 与 user/ 两级)。 */
|
|
479
|
+
function pluginCacheRoot(home = dshHome()) {
|
|
480
|
+
return join(home, PLUGIN_ID, "cache");
|
|
481
|
+
}
|
|
482
|
+
/** 工作区级缓存目录:按工作区 id(cwd hash)隔离,换工作区即失效。 */
|
|
483
|
+
function workspaceCacheDir(workspaceId, home = dshHome()) {
|
|
484
|
+
return join(pluginCacheRoot(home), "workspace", workspaceId);
|
|
485
|
+
}
|
|
486
|
+
/** 插件日志根(~/.dsh/dsh-vscode-mode/logs,用户级)。 */
|
|
487
|
+
function pluginLogRoot(home = dshHome()) {
|
|
488
|
+
return join(home, PLUGIN_ID, "logs");
|
|
489
|
+
}
|
|
490
|
+
/** 每工作区树缓存文件(工作区级:workspace/<id>/tree.v<schema>.json,文件名含版本便于 sweep)。 */
|
|
491
|
+
function treeCacheFile(cwd, home = dshHome(), schema = 1) {
|
|
492
|
+
return join(workspaceCacheDir(hashOf(cwd), home), "tree.v" + schema + ".json");
|
|
493
|
+
}
|
|
494
|
+
/** 每工作区 debug 日志文件(用户级日志根下,按 cwd hash 隔离)。 */
|
|
495
|
+
function debugLogFile(cwd, home = dshHome()) {
|
|
496
|
+
return join(pluginLogRoot(home), "debug." + hashOf(cwd) + ".log");
|
|
497
|
+
}
|
|
498
|
+
/** 插件包 assets 目录(import.meta.url 派生;config.imageDir 可覆盖,见 imageDirOf)。 */
|
|
499
|
+
function assetsDirOf(moduleUrl) {
|
|
500
|
+
return join(dirname(fileURLToPath(moduleUrl)), "..", "assets");
|
|
501
|
+
}
|
|
502
|
+
/** Monaco AMD 静态目录(随包发布)。 */
|
|
503
|
+
function vendorDirOf(moduleUrl) {
|
|
504
|
+
return join(assetsDirOf(moduleUrl), "vendor");
|
|
505
|
+
}
|
|
506
|
+
/** 图标目录:config.imageDir 覆盖优先,否则插件包 assets/。 */
|
|
507
|
+
function imageDirOf(config, moduleUrl) {
|
|
508
|
+
const cfg = config;
|
|
509
|
+
if (cfg && typeof cfg.imageDir === "string" && cfg.imageDir) return cfg.imageDir.replace(/\\/g, "/");
|
|
510
|
+
return assetsDirOf(moduleUrl);
|
|
511
|
+
}
|
|
512
|
+
/** 树缓存文件名 → schema 版本(不匹配返回 null)。 */
|
|
513
|
+
function treeSchemaOf(filename) {
|
|
514
|
+
const m = /^tree\.v(\d+)\./.exec(filename);
|
|
515
|
+
return m ? Number(m[1]) : null;
|
|
516
|
+
}
|
|
517
|
+
/** 按「schema 版本 + 保留期」清扫某目录内文件(目录级,返回相对路径清单)。 */
|
|
518
|
+
async function sweepDirFiles(dir, removed, now, dropAll) {
|
|
519
|
+
const names = await readdir(dir).catch(() => []);
|
|
520
|
+
for (const name of names) {
|
|
521
|
+
const full = join(dir, name);
|
|
522
|
+
let drop = dropAll;
|
|
523
|
+
if (!drop) {
|
|
524
|
+
const schema = treeSchemaOf(name);
|
|
525
|
+
if (schema === null) drop = true;
|
|
526
|
+
else if (schema !== 1) drop = true;
|
|
527
|
+
else try {
|
|
528
|
+
if (now - (await stat(full)).mtimeMs > 2592e6) drop = true;
|
|
529
|
+
} catch (error) {
|
|
530
|
+
drop = true;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
if (!drop) continue;
|
|
534
|
+
try {
|
|
535
|
+
await rm(full, { force: true });
|
|
536
|
+
removed.push(name);
|
|
537
|
+
} catch (error) {}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
/** 清扫工作区级缓存:逐工作区目录按版本/TTL 清文件,空目录删除。 */
|
|
541
|
+
async function sweepWorkspaceTier(dir, removed, now) {
|
|
542
|
+
const ids = await readdir(dir).catch(() => []);
|
|
543
|
+
for (const id of ids) {
|
|
544
|
+
const wsDir = join(dir, id);
|
|
545
|
+
await sweepDirFiles(wsDir, removed, now, false);
|
|
546
|
+
if (!(await readdir(wsDir).catch(() => [])).length) await rm(wsDir, {
|
|
547
|
+
recursive: true,
|
|
548
|
+
force: true
|
|
549
|
+
}).catch(() => {});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
/** 总预算兜底:递归收集全部缓存文件,按 mtime 从旧到新删,直到总大小 ≤ 预算。 */
|
|
553
|
+
async function sweepByTotalCap(root, removed, cap) {
|
|
554
|
+
const files = [];
|
|
555
|
+
const walk = async (dir) => {
|
|
556
|
+
const names = await readdir(dir).catch(() => []);
|
|
557
|
+
for (const name of names) {
|
|
558
|
+
const full = join(dir, name);
|
|
559
|
+
try {
|
|
560
|
+
const info = await stat(full);
|
|
561
|
+
if (info.isDirectory()) await walk(full);
|
|
562
|
+
else files.push({
|
|
563
|
+
path: full,
|
|
564
|
+
mtime: info.mtimeMs,
|
|
565
|
+
size: info.size
|
|
566
|
+
});
|
|
567
|
+
} catch (error) {}
|
|
568
|
+
}
|
|
569
|
+
};
|
|
570
|
+
await walk(root);
|
|
571
|
+
let total = files.reduce((sum, f) => sum + f.size, 0);
|
|
572
|
+
files.sort((a, b) => a.mtime - b.mtime);
|
|
573
|
+
for (const f of files) {
|
|
574
|
+
if (total <= cap) break;
|
|
575
|
+
try {
|
|
576
|
+
await rm(f.path, { force: true });
|
|
577
|
+
total -= f.size;
|
|
578
|
+
removed.push(f.path.slice(root.length + 1));
|
|
579
|
+
} catch (error) {}
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* 清理插件缓存根(分级 + 总预算兜底):
|
|
584
|
+
* - 根级残留文件(旧扁平格式/未知)→ 删
|
|
585
|
+
* - workspace/ 工作区级 → 按「schema 版本 + 保留期」清文件,空目录删除
|
|
586
|
+
* - user/ 用户级 → 按「schema 版本 + 保留期」清文件
|
|
587
|
+
* - 总大小超预算 → 按最旧先删(最后防线,防任意增长撑爆磁盘)
|
|
588
|
+
* best-effort 静默。
|
|
589
|
+
* @author ddj 2026年09月01号
|
|
590
|
+
* @param home DSH home(测试可注入)
|
|
591
|
+
* @param cap 总预算字节数(缺省 CACHE_TOTAL_CAP;测试可注入小预算)
|
|
592
|
+
* @returns 删除的文件相对路径清单
|
|
593
|
+
*/
|
|
594
|
+
async function sweepTreeCache(home = dshHome(), cap = CACHE_TOTAL_CAP) {
|
|
595
|
+
const dir = pluginCacheRoot(home);
|
|
596
|
+
const names = await readdir(dir).catch(() => []);
|
|
597
|
+
if (!names.length) return [];
|
|
598
|
+
const removed = [];
|
|
599
|
+
const now = Date.now();
|
|
600
|
+
await Promise.all(names.map(async (name) => {
|
|
601
|
+
const full = join(dir, name);
|
|
602
|
+
try {
|
|
603
|
+
if ((await stat(full)).isDirectory()) {
|
|
604
|
+
if (name === "workspace") await sweepWorkspaceTier(full, removed, now);
|
|
605
|
+
else if (name === "user") await sweepDirFiles(full, removed, now, false);
|
|
606
|
+
else {
|
|
607
|
+
await rm(full, {
|
|
608
|
+
recursive: true,
|
|
609
|
+
force: true
|
|
610
|
+
});
|
|
611
|
+
removed.push(name);
|
|
612
|
+
}
|
|
613
|
+
} else {
|
|
614
|
+
await rm(full, { force: true });
|
|
615
|
+
removed.push(name);
|
|
616
|
+
}
|
|
617
|
+
} catch (error) {}
|
|
618
|
+
}));
|
|
619
|
+
await sweepByTotalCap(dir, removed, cap);
|
|
620
|
+
return removed;
|
|
621
|
+
}
|
|
622
|
+
//#endregion
|
|
371
623
|
//#region src/compat.ts
|
|
372
624
|
/**
|
|
373
625
|
* dsh-vscode-mode host — 兼容层:与其他插件 / DSH 版本的统一探测、适配、护栏与自诊断。
|
|
@@ -381,8 +633,6 @@ const PLUGIN_NAME = "dsh-vscode-mode";
|
|
|
381
633
|
const MCP_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
|
382
634
|
/** 项目级 MCP 的 loader entry id 固定前缀(区分 profile 全局 MCP;loader id 不能用 ':',故用 '.')。 */
|
|
383
635
|
const PROJECT_ENTRY_PREFIX = "vsm-mcp.";
|
|
384
|
-
/** webServer 路由前缀(RPC 精确路由 + 静态资源前缀共用)。 */
|
|
385
|
-
const ROUTE_PREFIX = "/edrv";
|
|
386
636
|
/** 判断新旧格式项目 MCP 条目 id(旧残留不被视为全局 MCP)。 */
|
|
387
637
|
function isProjectEntryId(id) {
|
|
388
638
|
return id.startsWith("vsm-mcp.") || id.startsWith("vsm-mcp:");
|
|
@@ -508,10 +758,6 @@ async function buildReport(ctx, options) {
|
|
|
508
758
|
};
|
|
509
759
|
}
|
|
510
760
|
//#endregion
|
|
511
|
-
//#region src/shared/rpc.ts
|
|
512
|
-
/** webServer 精确路由。 */
|
|
513
|
-
const RPC_PATH = "/edrv/rpc";
|
|
514
|
-
//#endregion
|
|
515
761
|
//#region src/routes.ts
|
|
516
762
|
/**
|
|
517
763
|
* dsh-vscode-mode host — webServer 路由注册(/edrv/rpc、/edrv/assets/*、/edrv/vendor/*)。
|
|
@@ -526,7 +772,6 @@ const IMG_ROUTES = [{
|
|
|
526
772
|
file: "compare_select.png"
|
|
527
773
|
}];
|
|
528
774
|
/** Monaco AMD 构建静态资源(随包发布,离线可用;前缀路由 /edrv/vendor/*)。 */
|
|
529
|
-
const VENDOR_PREFIX = "/edrv/vendor";
|
|
530
775
|
const VENDOR_MIME = {
|
|
531
776
|
".js": "text/javascript; charset=utf-8",
|
|
532
777
|
".css": "text/css; charset=utf-8",
|
|
@@ -543,14 +788,10 @@ const VENDOR_MIME = {
|
|
|
543
788
|
".html": "text/html; charset=utf-8",
|
|
544
789
|
".md": "text/markdown"
|
|
545
790
|
};
|
|
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");
|
|
791
|
+
/** 图标目录(paths.ts 统一解析:config.imageDir 覆盖优先,否则插件包 assets/)。 */
|
|
792
|
+
const imgDirOf = (config) => imageDirOf(config, import.meta.url);
|
|
793
|
+
/** Monaco AMD 静态目录(随包发布,paths.ts 统一解析)。 */
|
|
794
|
+
const vendorDir = vendorDirOf(import.meta.url);
|
|
554
795
|
/**
|
|
555
796
|
* 注册全部 webServer 路由(带兼容护栏:前缀冲突预警 + 注册失败降级跳过)。
|
|
556
797
|
* @author ddj 2026年08月20号
|
|
@@ -560,7 +801,7 @@ const vendorDir = join(dirname(fileURLToPath(import.meta.url)), "..", "assets",
|
|
|
560
801
|
* @param onWarning 兼容性警告收集(可选)
|
|
561
802
|
*/
|
|
562
803
|
function registerRoutes(ctx, config, handleRpc, onWarning) {
|
|
563
|
-
const imgDir =
|
|
804
|
+
const imgDir = imgDirOf(config);
|
|
564
805
|
const web = ctx.get("webServer");
|
|
565
806
|
if (!web) return;
|
|
566
807
|
resetOwnRoutes();
|
|
@@ -1068,10 +1309,6 @@ function archiveEntryFor(recs, cwd, reason) {
|
|
|
1068
1309
|
}))
|
|
1069
1310
|
};
|
|
1070
1311
|
}
|
|
1071
|
-
//#endregion
|
|
1072
|
-
//#region src/store.ts
|
|
1073
|
-
const SIDECAR = ".dsh-edit-review.json";
|
|
1074
|
-
const SIDECAR_ARCHIVE = ".dsh-edit-review-archive.json";
|
|
1075
1312
|
/** 会话沙箱策略(policyOf):可能不存在,调用方需容忍 undefined。 */
|
|
1076
1313
|
function policyOf(ctx, session) {
|
|
1077
1314
|
const svc = ctx.get("sandboxPolicy");
|
|
@@ -1333,6 +1570,459 @@ function bucketOf(registry, ctx, cwd) {
|
|
|
1333
1570
|
});
|
|
1334
1571
|
}
|
|
1335
1572
|
//#endregion
|
|
1573
|
+
//#region src/tree.ts
|
|
1574
|
+
/** 目录在文件前、名称按小写字母序的比较器(对齐 VSCode 资源管理器习惯)。 */
|
|
1575
|
+
function entryOrder(a, b) {
|
|
1576
|
+
const aDir = a.type === "directory" ? 0 : 1;
|
|
1577
|
+
const bDir = b.type === "directory" ? 0 : 1;
|
|
1578
|
+
if (aDir !== bDir) return aDir - bDir;
|
|
1579
|
+
const la = a.name.toLocaleLowerCase("en-US");
|
|
1580
|
+
const lb = b.name.toLocaleLowerCase("en-US");
|
|
1581
|
+
return la < lb ? -1 : la > lb ? 1 : 0;
|
|
1582
|
+
}
|
|
1583
|
+
/**
|
|
1584
|
+
* 归一化目录相对路径:去空白、反斜杠→斜杠、去首尾斜杠、`.`→''。
|
|
1585
|
+
* 任一路径段为 `..` 返回 null(拒绝越界)。
|
|
1586
|
+
* @author ddj 2026年08月26号
|
|
1587
|
+
* @param path 原始相对路径('' 表示根目录)
|
|
1588
|
+
* @returns 归一化相对路径('' = 根),非法返回 null
|
|
1589
|
+
*/
|
|
1590
|
+
function normalizeRel(path) {
|
|
1591
|
+
const rel = String(path ?? "").trim().replace(/\\/g, "/").replace(/^\/+|\/+$/g, "");
|
|
1592
|
+
if (!rel) return "";
|
|
1593
|
+
const segs = rel.split("/").filter((seg) => seg !== ".");
|
|
1594
|
+
if (!segs.length) return "";
|
|
1595
|
+
for (const seg of segs) if (seg === "..") return null;
|
|
1596
|
+
return segs.join("/");
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* fs.listDir 结果 → TreeEntry 数组:相对路径拼接 + 目录优先排序。
|
|
1600
|
+
* @author ddj 2026年08月26号
|
|
1601
|
+
* @param rel 父目录相对路径('' = 根)
|
|
1602
|
+
* @param children 目录子项
|
|
1603
|
+
* @returns 排序后的树条目
|
|
1604
|
+
*/
|
|
1605
|
+
function toTreeEntries(rel, children) {
|
|
1606
|
+
const out = [];
|
|
1607
|
+
for (const child of children) {
|
|
1608
|
+
if (!child || typeof child.name !== "string" || !child.name) continue;
|
|
1609
|
+
out.push({
|
|
1610
|
+
name: child.name,
|
|
1611
|
+
path: rel ? rel + "/" + child.name : child.name,
|
|
1612
|
+
type: child.type === "directory" ? "directory" : child.type === "file" ? "file" : "other",
|
|
1613
|
+
size: typeof child.size === "number" ? child.size : void 0
|
|
1614
|
+
});
|
|
1615
|
+
}
|
|
1616
|
+
return out.sort(entryOrder);
|
|
1617
|
+
}
|
|
1618
|
+
/** 列取失败(带稳定语义 code,调用方转文案)。 */
|
|
1619
|
+
var DirListError = class extends Error {
|
|
1620
|
+
code;
|
|
1621
|
+
constructor(code, message) {
|
|
1622
|
+
super(message);
|
|
1623
|
+
this.code = code;
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
/** node fs 错误码 → 稳定语义错误。 */
|
|
1627
|
+
function codeOf(err) {
|
|
1628
|
+
const code = err?.code;
|
|
1629
|
+
if (code === "ENOENT") return new DirListError("not-found", "目录不存在");
|
|
1630
|
+
if (code === "ENOTDIR") return new DirListError("not-dir", "不是目录");
|
|
1631
|
+
if (code === "EACCES" || code === "EPERM" || code === "EBUSY") return new DirListError("permission", "权限不足");
|
|
1632
|
+
return new DirListError("io", String(err?.message ?? err));
|
|
1633
|
+
}
|
|
1634
|
+
/** 并行 stat 一批绝对路径 → 目录/文件/other(follow symlink,失败按 other)。 */
|
|
1635
|
+
async function statTypes(paths, out) {
|
|
1636
|
+
const cap = Math.min(paths.length, 256);
|
|
1637
|
+
for (let i = 0; i < cap; i += 32) {
|
|
1638
|
+
const chunk = paths.slice(i, i + 32);
|
|
1639
|
+
await Promise.all(chunk.map(async (p) => {
|
|
1640
|
+
try {
|
|
1641
|
+
const info = await stat(p);
|
|
1642
|
+
out.set(p, info.isDirectory() ? "directory" : info.isFile() ? "file" : "other");
|
|
1643
|
+
} catch (error) {
|
|
1644
|
+
out.set(p, "other");
|
|
1645
|
+
}
|
|
1646
|
+
}));
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* 快速列取目录:一次 readdir(withFileTypes) 判型;symlink 并行 follow-stat。
|
|
1651
|
+
* @author ddj 2026年08月31号
|
|
1652
|
+
* @param absDir 绝对目录路径(fs.resolve 后的 targetKey)
|
|
1653
|
+
* @returns 子项列表(name/type;size 缺省,树界面不展示大小)
|
|
1654
|
+
* @throws DirListError 目录不存在/不是目录/权限不足/IO 失败
|
|
1655
|
+
*/
|
|
1656
|
+
async function listDirCheap(absDir) {
|
|
1657
|
+
let dirents;
|
|
1658
|
+
try {
|
|
1659
|
+
dirents = await readdir(absDir, {
|
|
1660
|
+
withFileTypes: true,
|
|
1661
|
+
encoding: "utf8"
|
|
1662
|
+
});
|
|
1663
|
+
} catch (error) {
|
|
1664
|
+
throw codeOf(error);
|
|
1665
|
+
}
|
|
1666
|
+
const out = [];
|
|
1667
|
+
const syms = [];
|
|
1668
|
+
for (const d of dirents) if (d.isDirectory()) out.push({
|
|
1669
|
+
name: d.name,
|
|
1670
|
+
type: "directory"
|
|
1671
|
+
});
|
|
1672
|
+
else if (d.isFile()) out.push({
|
|
1673
|
+
name: d.name,
|
|
1674
|
+
type: "file"
|
|
1675
|
+
});
|
|
1676
|
+
else if (d.isSymbolicLink()) syms.push(d.name);
|
|
1677
|
+
else out.push({
|
|
1678
|
+
name: d.name,
|
|
1679
|
+
type: "other"
|
|
1680
|
+
});
|
|
1681
|
+
if (syms.length) {
|
|
1682
|
+
const symAbs = syms.map((name) => join(absDir, name));
|
|
1683
|
+
const types = /* @__PURE__ */ new Map();
|
|
1684
|
+
await statTypes(symAbs, types);
|
|
1685
|
+
for (let i = 0; i < syms.length; i++) out.push({
|
|
1686
|
+
name: syms[i],
|
|
1687
|
+
type: types.get(symAbs[i]) ?? "other"
|
|
1688
|
+
});
|
|
1689
|
+
}
|
|
1690
|
+
return out;
|
|
1691
|
+
}
|
|
1692
|
+
/** 落盘单目录条目上限:超出截断并标 full=false(截断仍可即时渲染,客户端后台刷新补全)。 */
|
|
1693
|
+
const PERSIST_DIR_ENTRIES = 1500;
|
|
1694
|
+
/** 落盘防抖:变更后静默 10s 再写一次。 */
|
|
1695
|
+
const PERSIST_DEBOUNCE_MS = 1e4;
|
|
1696
|
+
/** re-heal 防抖:写入密集期合并为 3s 后一轮。 */
|
|
1697
|
+
const HEAL_DEBOUNCE_MS = 3e3;
|
|
1698
|
+
/** cwd → 索引状态(随会话销毁清理)。 */
|
|
1699
|
+
const states = /* @__PURE__ */ new Map();
|
|
1700
|
+
/** 单调时钟:连续 put 的 ts 严格递增(同毫秒不并列,逐出/裁剪顺序可预期)。 */
|
|
1701
|
+
let clock = 0;
|
|
1702
|
+
/** 取下一时间戳(不小于真实时间且严格递增)。 */
|
|
1703
|
+
function nextTs() {
|
|
1704
|
+
clock = Math.max(Date.now(), clock + 1);
|
|
1705
|
+
return clock;
|
|
1706
|
+
}
|
|
1707
|
+
/** 条目原始值 → 合法条目(非法返回 null)。 */
|
|
1708
|
+
function entryOf(raw) {
|
|
1709
|
+
if (!raw || typeof raw !== "object") return null;
|
|
1710
|
+
const e = raw;
|
|
1711
|
+
if (typeof e.name !== "string" || !e.name) return null;
|
|
1712
|
+
const type = e.type === "directory" ? "directory" : e.type === "file" ? "file" : "other";
|
|
1713
|
+
return {
|
|
1714
|
+
name: e.name,
|
|
1715
|
+
type
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
/** 写入路径 → 相对工作区根的 rel(绝对路径按 cwd 前缀裁剪;越界/非法 → null)。 */
|
|
1719
|
+
function relOf(cwd, path) {
|
|
1720
|
+
let rel = String(path ?? "").trim().replace(/\\/g, "/");
|
|
1721
|
+
const cl = cwd.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
1722
|
+
if (rel.toLowerCase().startsWith(cl + "/")) rel = rel.slice(cl.length + 1);
|
|
1723
|
+
else if (rel.startsWith("/") || /^[a-z]:\//i.test(rel)) return null;
|
|
1724
|
+
rel = rel.replace(/^\/+|\/+$/g, "");
|
|
1725
|
+
if (!rel) return null;
|
|
1726
|
+
const segs = rel.split("/").filter((s) => s !== "" && s !== ".");
|
|
1727
|
+
if (!segs.length) return null;
|
|
1728
|
+
for (const s of segs) if (s === "..") return null;
|
|
1729
|
+
return segs.join("/");
|
|
1730
|
+
}
|
|
1731
|
+
/** rel 的父目录 + 全部祖先('a/b/c' → ['a/b','a',''];'a' → [''])。 */
|
|
1732
|
+
function ancestorsOf(rel) {
|
|
1733
|
+
const segs = rel.split("/");
|
|
1734
|
+
const out = [];
|
|
1735
|
+
for (let i = segs.length - 1; i >= 1; i--) out.push(segs.slice(0, i).join("/"));
|
|
1736
|
+
out.push("");
|
|
1737
|
+
return out;
|
|
1738
|
+
}
|
|
1739
|
+
/** 序列化索引数据。 */
|
|
1740
|
+
function indexSerialize(data) {
|
|
1741
|
+
const lean = {
|
|
1742
|
+
v: 1,
|
|
1743
|
+
root: data.root,
|
|
1744
|
+
dirs: {}
|
|
1745
|
+
};
|
|
1746
|
+
for (const [rel, dir] of Object.entries(data.dirs)) lean.dirs[rel] = {
|
|
1747
|
+
ts: dir.ts,
|
|
1748
|
+
full: dir.full,
|
|
1749
|
+
entries: dir.entries.map((e) => ({
|
|
1750
|
+
name: e.name,
|
|
1751
|
+
type: e.type
|
|
1752
|
+
}))
|
|
1753
|
+
};
|
|
1754
|
+
return JSON.stringify(lean);
|
|
1755
|
+
}
|
|
1756
|
+
/** 解析索引文本;损坏/格式不符 → null。 */
|
|
1757
|
+
function indexParse(text) {
|
|
1758
|
+
if (!text) return null;
|
|
1759
|
+
try {
|
|
1760
|
+
const raw = JSON.parse(text);
|
|
1761
|
+
if (!raw || raw.v !== 1 || !raw.dirs || typeof raw.dirs !== "object") return null;
|
|
1762
|
+
const dirs = {};
|
|
1763
|
+
for (const [rel, d] of Object.entries(raw.dirs)) {
|
|
1764
|
+
if (!d || typeof d !== "object") continue;
|
|
1765
|
+
const dir = d;
|
|
1766
|
+
if (!Array.isArray(dir.entries)) continue;
|
|
1767
|
+
const entries = [];
|
|
1768
|
+
for (const rawEntry of dir.entries) {
|
|
1769
|
+
const e = entryOf(rawEntry);
|
|
1770
|
+
if (!e) continue;
|
|
1771
|
+
entries.push({
|
|
1772
|
+
name: e.name,
|
|
1773
|
+
path: rel ? rel + "/" + e.name : e.name,
|
|
1774
|
+
type: e.type
|
|
1775
|
+
});
|
|
1776
|
+
}
|
|
1777
|
+
if (!entries.length) continue;
|
|
1778
|
+
dirs[rel] = {
|
|
1779
|
+
ts: typeof dir.ts === "number" ? dir.ts : 0,
|
|
1780
|
+
full: dir.full === true,
|
|
1781
|
+
entries
|
|
1782
|
+
};
|
|
1783
|
+
}
|
|
1784
|
+
return {
|
|
1785
|
+
v: 1,
|
|
1786
|
+
root: typeof raw.root === "string" ? raw.root : null,
|
|
1787
|
+
dirs
|
|
1788
|
+
};
|
|
1789
|
+
} catch (error) {
|
|
1790
|
+
return null;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
/** 落盘裁剪:目录按 ts 取新裁旧、单目录超限截断标 full=false(内存不受影响)。 */
|
|
1794
|
+
function trimPersist(data, maxDirs = 400, maxEntries = PERSIST_DIR_ENTRIES) {
|
|
1795
|
+
const dirs = {};
|
|
1796
|
+
const sorted = Object.entries(data.dirs).sort((a, b) => b[1].ts - a[1].ts);
|
|
1797
|
+
for (const [rel, dir] of sorted) {
|
|
1798
|
+
if (Object.keys(dirs).length >= maxDirs) break;
|
|
1799
|
+
const full = dir.entries.length <= maxEntries;
|
|
1800
|
+
dirs[rel] = {
|
|
1801
|
+
ts: dir.ts,
|
|
1802
|
+
full,
|
|
1803
|
+
entries: full ? dir.entries : dir.entries.slice(0, maxEntries)
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
return {
|
|
1807
|
+
v: 1,
|
|
1808
|
+
root: data.root,
|
|
1809
|
+
dirs
|
|
1810
|
+
};
|
|
1811
|
+
}
|
|
1812
|
+
/** 取(或建)某 cwd 的索引状态,并惰性加载落盘快照(合并:文件 ts 更新才采纳)。 */
|
|
1813
|
+
function ensureState(ctx, cwd) {
|
|
1814
|
+
const existing = states.get(cwd);
|
|
1815
|
+
if (existing) return existing;
|
|
1816
|
+
const state = {
|
|
1817
|
+
ctx,
|
|
1818
|
+
cwd,
|
|
1819
|
+
data: {
|
|
1820
|
+
v: 1,
|
|
1821
|
+
root: null,
|
|
1822
|
+
dirs: {}
|
|
1823
|
+
},
|
|
1824
|
+
stale: /* @__PURE__ */ new Set(),
|
|
1825
|
+
dirty: false,
|
|
1826
|
+
persistTimer: null,
|
|
1827
|
+
healTimer: null,
|
|
1828
|
+
pending: /* @__PURE__ */ new Map(),
|
|
1829
|
+
loading: false,
|
|
1830
|
+
loadPromise: null
|
|
1831
|
+
};
|
|
1832
|
+
states.set(cwd, state);
|
|
1833
|
+
state.loadPromise = loadIndex(state);
|
|
1834
|
+
removeLegacyTreeSidecars(state.cwd);
|
|
1835
|
+
return state;
|
|
1836
|
+
}
|
|
1837
|
+
/** 清理旧工作区树 sidecar(.dsh-edit-review-tree.json/.dsh-vscode-mode-tree.json)。 */
|
|
1838
|
+
async function removeLegacyTreeSidecars(cwd) {
|
|
1839
|
+
await Promise.all(LEGACY_TREE_SIDECARS.map(async (name) => {
|
|
1840
|
+
try {
|
|
1841
|
+
await rm(join(cwd, name), { force: true });
|
|
1842
|
+
} catch (error) {}
|
|
1843
|
+
}));
|
|
1844
|
+
}
|
|
1845
|
+
/** 惰性加载落盘快照一次(缺失/损坏 → 空索引;失败静默)。 */
|
|
1846
|
+
async function loadIndex(state) {
|
|
1847
|
+
if (state.loading) return;
|
|
1848
|
+
state.loading = true;
|
|
1849
|
+
try {
|
|
1850
|
+
const parsed = indexParse(await readFile(treeCacheFile(state.cwd), "utf8"));
|
|
1851
|
+
if (!parsed) return;
|
|
1852
|
+
for (const [rel, dir] of Object.entries(parsed.dirs)) {
|
|
1853
|
+
const cur = state.data.dirs[rel];
|
|
1854
|
+
if (!cur || dir.ts > cur.ts) state.data.dirs[rel] = dir;
|
|
1855
|
+
}
|
|
1856
|
+
if (parsed.root && !state.data.root) state.data.root = parsed.root;
|
|
1857
|
+
} catch (error) {} finally {
|
|
1858
|
+
state.loading = false;
|
|
1859
|
+
}
|
|
1860
|
+
}
|
|
1861
|
+
/** 命中:新鲜且未失效 → 条目(并刷新 ts 保活);否则 null。 */
|
|
1862
|
+
function hitIndex(cwd, rel) {
|
|
1863
|
+
const state = states.get(cwd);
|
|
1864
|
+
if (!state) return null;
|
|
1865
|
+
const dir = state.data.dirs[rel];
|
|
1866
|
+
if (!dir || state.stale.has(rel)) return null;
|
|
1867
|
+
const now = Date.now();
|
|
1868
|
+
if (now - dir.ts > 6e4) return null;
|
|
1869
|
+
dir.ts = now;
|
|
1870
|
+
return dir.entries;
|
|
1871
|
+
}
|
|
1872
|
+
/** 更新某目录条目:写内存 + 打脏 + 防抖落盘;stale 撤销。 */
|
|
1873
|
+
function putIndex(ctx, cwd, rel, entries) {
|
|
1874
|
+
const state = ensureState(ctx, cwd);
|
|
1875
|
+
const dirs = state.data.dirs;
|
|
1876
|
+
if (!dirs[rel] && Object.keys(dirs).length >= 2e3) {
|
|
1877
|
+
let oldest = rel;
|
|
1878
|
+
let oldestTs = Infinity;
|
|
1879
|
+
for (const [k, v] of Object.entries(dirs)) if (v.ts < oldestTs) {
|
|
1880
|
+
oldestTs = v.ts;
|
|
1881
|
+
oldest = k;
|
|
1882
|
+
}
|
|
1883
|
+
delete dirs[oldest];
|
|
1884
|
+
}
|
|
1885
|
+
dirs[rel] = {
|
|
1886
|
+
ts: nextTs(),
|
|
1887
|
+
full: entries.length <= PERSIST_DIR_ENTRIES,
|
|
1888
|
+
entries
|
|
1889
|
+
};
|
|
1890
|
+
state.stale.delete(rel);
|
|
1891
|
+
state.dirty = true;
|
|
1892
|
+
schedulePersist(state);
|
|
1893
|
+
}
|
|
1894
|
+
/** 写入路径失效:父目录+祖先进 stale,并调度后台 re-heal。 */
|
|
1895
|
+
function invalidateIndex(ctx, cwd, path) {
|
|
1896
|
+
const rel = relOf(cwd, path);
|
|
1897
|
+
if (rel === null) return;
|
|
1898
|
+
const state = ensureState(ctx, cwd);
|
|
1899
|
+
for (const anc of ancestorsOf(rel)) state.stale.add(anc);
|
|
1900
|
+
scheduleHeal(state);
|
|
1901
|
+
}
|
|
1902
|
+
/** 调度 re-heal(防抖合并;已有定时器则复用)。 */
|
|
1903
|
+
function scheduleHeal(state) {
|
|
1904
|
+
if (state.healTimer) return;
|
|
1905
|
+
state.healTimer = setTimeout(() => {
|
|
1906
|
+
state.healTimer = null;
|
|
1907
|
+
healRound(state);
|
|
1908
|
+
}, HEAL_DEBOUNCE_MS);
|
|
1909
|
+
}
|
|
1910
|
+
/** 后台自愈一轮:并行重列 ≤HEAL_ROUND_MAX 个 stale 目录;失败移出(交给 TTL/展开路径自愈)。 */
|
|
1911
|
+
async function healRound(state) {
|
|
1912
|
+
if (!state.stale.size) return;
|
|
1913
|
+
const targets = [...state.stale].slice(0, 20);
|
|
1914
|
+
await Promise.all(targets.map(async (rel) => {
|
|
1915
|
+
if ("error" in await listDirCached(state.ctx, state.cwd, rel, true)) state.stale.delete(rel);
|
|
1916
|
+
}));
|
|
1917
|
+
if (state.stale.size) scheduleHeal(state);
|
|
1918
|
+
}
|
|
1919
|
+
/** 调度落盘(防抖;已有定时器则重置)。 */
|
|
1920
|
+
function schedulePersist(state) {
|
|
1921
|
+
if (state.persistTimer) clearTimeout(state.persistTimer);
|
|
1922
|
+
state.persistTimer = setTimeout(() => {
|
|
1923
|
+
state.persistTimer = null;
|
|
1924
|
+
persistNow(state);
|
|
1925
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
1926
|
+
}
|
|
1927
|
+
/** 落盘快照(裁剪 + 序列化 + 超限跳过;raw fs 写 ~/.dsh 缓存根,失败静默)。 */
|
|
1928
|
+
async function persistNow(state) {
|
|
1929
|
+
if (!state.dirty) return;
|
|
1930
|
+
try {
|
|
1931
|
+
const json = indexSerialize(trimPersist(state.data));
|
|
1932
|
+
if (json.length > 8388608) {
|
|
1933
|
+
state.dirty = false;
|
|
1934
|
+
return;
|
|
1935
|
+
}
|
|
1936
|
+
await mkdir(dirname(treeCacheFile(state.cwd)), { recursive: true });
|
|
1937
|
+
await writeFile(treeCacheFile(state.cwd), json, "utf8");
|
|
1938
|
+
state.dirty = false;
|
|
1939
|
+
} catch (error) {}
|
|
1940
|
+
}
|
|
1941
|
+
/** 清理某 cwd 索引(会话销毁时):清定时器,dirty 则尽力补写一次。 */
|
|
1942
|
+
function disposeIndex(cwd) {
|
|
1943
|
+
const state = states.get(cwd);
|
|
1944
|
+
if (!state) return;
|
|
1945
|
+
if (state.persistTimer) {
|
|
1946
|
+
clearTimeout(state.persistTimer);
|
|
1947
|
+
state.persistTimer = null;
|
|
1948
|
+
}
|
|
1949
|
+
if (state.healTimer) {
|
|
1950
|
+
clearTimeout(state.healTimer);
|
|
1951
|
+
state.healTimer = null;
|
|
1952
|
+
}
|
|
1953
|
+
if (state.dirty) persistNow(state);
|
|
1954
|
+
states.delete(cwd);
|
|
1955
|
+
}
|
|
1956
|
+
/** 错误 → 用户文案(DirListError 用自身 message,其余带上下文)。 */
|
|
1957
|
+
function errorText(error) {
|
|
1958
|
+
if (error instanceof DirListError) return error.message;
|
|
1959
|
+
return "读取目录失败:" + String(error);
|
|
1960
|
+
}
|
|
1961
|
+
/**
|
|
1962
|
+
* 目录列取统一入口(RPC handler 用):命中索引直出;失效/force 走快路径
|
|
1963
|
+
* (resolve + listDirCheap + putIndex);同 (cwd,rel) 在途请求复用同一 Promise。
|
|
1964
|
+
* @author ddj 2026年08月31号 / 2026年09月01号
|
|
1965
|
+
* @param ctx DSH 上下文(仅用于工作区 IO)
|
|
1966
|
+
* @param cwd 工作区根
|
|
1967
|
+
* @param rel 归一化相对路径('' = 根)
|
|
1968
|
+
* @param force 跳过索引命中,强制实列
|
|
1969
|
+
* @returns 目录条目与工作区根,或错误文案
|
|
1970
|
+
*/
|
|
1971
|
+
async function listDirCached(ctx, cwd, rel, force) {
|
|
1972
|
+
const state = ensureState(ctx, cwd);
|
|
1973
|
+
if (state.loadPromise) await state.loadPromise;
|
|
1974
|
+
const fs = ctx.get("fs");
|
|
1975
|
+
if (!fs) return { error: "缺少 fs" };
|
|
1976
|
+
if (!state.data.root) try {
|
|
1977
|
+
state.data.root = String(fs.processPath(await fs.resolve(".", { cwd })));
|
|
1978
|
+
} catch (error) {
|
|
1979
|
+
return { error: errorText(error) };
|
|
1980
|
+
}
|
|
1981
|
+
const root = state.data.root;
|
|
1982
|
+
if (!force) {
|
|
1983
|
+
const hit = hitIndex(cwd, rel);
|
|
1984
|
+
if (hit) return {
|
|
1985
|
+
entries: hit,
|
|
1986
|
+
root
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
const inflight = state.pending.get(rel);
|
|
1990
|
+
if (inflight) {
|
|
1991
|
+
const res = await inflight;
|
|
1992
|
+
return "entries" in res ? {
|
|
1993
|
+
entries: res.entries,
|
|
1994
|
+
root
|
|
1995
|
+
} : res;
|
|
1996
|
+
}
|
|
1997
|
+
const task = (async () => {
|
|
1998
|
+
try {
|
|
1999
|
+
const entries = toTreeEntries(rel, await listDirCheap((await fs.resolve(rel || ".", { cwd })).targetKey));
|
|
2000
|
+
putIndex(ctx, cwd, rel, entries);
|
|
2001
|
+
return { entries };
|
|
2002
|
+
} catch (error) {
|
|
2003
|
+
if (error instanceof DirListError && error.code === "not-found") {
|
|
2004
|
+
if (state.data.dirs[rel]) {
|
|
2005
|
+
delete state.data.dirs[rel];
|
|
2006
|
+
state.dirty = true;
|
|
2007
|
+
schedulePersist(state);
|
|
2008
|
+
}
|
|
2009
|
+
state.stale.delete(rel);
|
|
2010
|
+
}
|
|
2011
|
+
return { error: errorText(error) };
|
|
2012
|
+
}
|
|
2013
|
+
})();
|
|
2014
|
+
state.pending.set(rel, task);
|
|
2015
|
+
try {
|
|
2016
|
+
const res = await task;
|
|
2017
|
+
return "entries" in res ? {
|
|
2018
|
+
entries: res.entries,
|
|
2019
|
+
root
|
|
2020
|
+
} : res;
|
|
2021
|
+
} finally {
|
|
2022
|
+
if (state.pending.get(rel) === task) state.pending.delete(rel);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
//#endregion
|
|
1336
2026
|
//#region src/capture.ts
|
|
1337
2027
|
/** 校验并提取工具 result meta 中的文件 hunk。 */
|
|
1338
2028
|
function validHunks(raw) {
|
|
@@ -1432,6 +2122,7 @@ async function captureToolResult(ctx, registry, exec, result) {
|
|
|
1432
2122
|
bucket.set(exec.callId, record);
|
|
1433
2123
|
prune(bucket);
|
|
1434
2124
|
await saveBucket(ctx, cwd, bucket, session);
|
|
2125
|
+
invalidateIndex(ctx, cwd, path);
|
|
1435
2126
|
} catch (error) {
|
|
1436
2127
|
console.error("edrv capture failed", error);
|
|
1437
2128
|
}
|
|
@@ -1579,6 +2270,65 @@ function parseOutput(handle, maxResults) {
|
|
|
1579
2270
|
};
|
|
1580
2271
|
}
|
|
1581
2272
|
/**
|
|
2273
|
+
* 读取已收集的 stderr(无收集器 → 空文本)。
|
|
2274
|
+
* @author ddj 2026年08月27号
|
|
2275
|
+
* @param handle subprocess handle
|
|
2276
|
+
* @returns stderr 文本
|
|
2277
|
+
*/
|
|
2278
|
+
function readStderr(handle) {
|
|
2279
|
+
const reader = handle.collected?.stderr;
|
|
2280
|
+
return reader ? reader.readFrom(0) : { text: "" };
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* 取 stderr 首条非空行(去 \r,截断 300 字符防撑爆 UI)。
|
|
2284
|
+
* @author ddj 2026年08月27号
|
|
2285
|
+
* @param text stderr 全文
|
|
2286
|
+
* @returns 首条诊断行(无则空串)
|
|
2287
|
+
*/
|
|
2288
|
+
function firstStderrLine(text) {
|
|
2289
|
+
for (const raw of String(text ?? "").split(/\r?\n/)) {
|
|
2290
|
+
const line = raw.trim();
|
|
2291
|
+
if (!line) continue;
|
|
2292
|
+
return line.length > 300 ? line.slice(0, 300) + "…" : line;
|
|
2293
|
+
}
|
|
2294
|
+
return "";
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* 分类 rg 非 0/1 退出:pattern=模式错误(正则/glob 无效,无结果),
|
|
2298
|
+
* partial=遍历错误(rg 已输出的命中仍有效,结果不完整),hard=硬失败(其他退出码/超时被杀)。
|
|
2299
|
+
* @author ddj 2026年08月27号
|
|
2300
|
+
* @param code 退出码(null/undefined = 被终止,通常为 20s 超时)
|
|
2301
|
+
* @param stderrText stderr 全文
|
|
2302
|
+
* @returns 分类与中文提示文案(partial 的 message 即 warning 内容)
|
|
2303
|
+
*/
|
|
2304
|
+
function rgExitFailure(code, stderrText) {
|
|
2305
|
+
const detail = firstStderrLine(stderrText);
|
|
2306
|
+
if (/regex parse error/i.test(detail)) return {
|
|
2307
|
+
kind: "pattern",
|
|
2308
|
+
message: "正则表达式无效:" + detail
|
|
2309
|
+
};
|
|
2310
|
+
if (/error parsing glob/i.test(detail)) return {
|
|
2311
|
+
kind: "pattern",
|
|
2312
|
+
message: "文件过滤模式无效:" + detail
|
|
2313
|
+
};
|
|
2314
|
+
if (code === 2) {
|
|
2315
|
+
const count = String(stderrText ?? "").split(/\r?\n/).filter((line) => line.trim()).length;
|
|
2316
|
+
const suffix = count > 1 ? "(共 " + count + " 处)" : "";
|
|
2317
|
+
return {
|
|
2318
|
+
kind: "partial",
|
|
2319
|
+
message: detail ? "部分路径无法访问,结果可能不完整:" + detail + suffix : "部分路径无法访问,结果可能不完整"
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
if (code === null || code === void 0) return {
|
|
2323
|
+
kind: "hard",
|
|
2324
|
+
message: "搜索超时(20 秒内未完成,已终止)"
|
|
2325
|
+
};
|
|
2326
|
+
return {
|
|
2327
|
+
kind: "hard",
|
|
2328
|
+
message: "ripgrep 退出码:" + String(code) + (detail ? "(" + detail + ")" : "")
|
|
2329
|
+
};
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
1582
2332
|
* 使用打包 ripgrep 搜索工作区文件。
|
|
1583
2333
|
* @author ddj 2026年08月24号
|
|
1584
2334
|
* @param input provider 输入
|
|
@@ -1621,7 +2371,16 @@ async function searchRipgrep(input) {
|
|
|
1621
2371
|
throw new Error("ripgrep 启动失败:" + String(error));
|
|
1622
2372
|
}
|
|
1623
2373
|
const code = outcome.exitCode ?? outcome.code;
|
|
1624
|
-
if (code !== 0 && code !== 1)
|
|
2374
|
+
if (code !== 0 && code !== 1) {
|
|
2375
|
+
const failure = rgExitFailure(code, readStderr(handle).text);
|
|
2376
|
+
if (failure.kind !== "partial") throw new Error(failure.message);
|
|
2377
|
+
return {
|
|
2378
|
+
...parseOutput(handle, input.maxResults),
|
|
2379
|
+
warning: failure.message,
|
|
2380
|
+
complete: false,
|
|
2381
|
+
source: "ripgrep"
|
|
2382
|
+
};
|
|
2383
|
+
}
|
|
1625
2384
|
const parsed = parseOutput(handle, input.maxResults);
|
|
1626
2385
|
if (!handle.collected?.stdout) throw new Error("ripgrep stdout 不可用");
|
|
1627
2386
|
return {
|
|
@@ -2239,17 +2998,23 @@ async function searchRipgrepContent(input) {
|
|
|
2239
2998
|
throw new Error("ripgrep 启动失败:" + String(error));
|
|
2240
2999
|
}
|
|
2241
3000
|
const code = outcome.exitCode ?? outcome.code;
|
|
2242
|
-
|
|
3001
|
+
const failure = code !== 0 && code !== 1 ? rgExitFailure(code, readStderr(handle).text) : null;
|
|
3002
|
+
if (failure && failure.kind !== "partial") throw new Error(failure.message);
|
|
2243
3003
|
const reader = handle.collected?.stdout;
|
|
2244
3004
|
if (!reader) throw new Error("ripgrep stdout 不可用");
|
|
2245
3005
|
const output = reader.readFrom(0);
|
|
2246
3006
|
const capped = applyCaps(parseRgJsonLines(output.text, root), Math.max(1, Math.min(input.maxResults ?? DEFAULT_MAX_MATCHES, DEFAULT_MAX_MATCHES)), DEFAULT_MAX_FILES);
|
|
2247
3007
|
const truncated = Boolean(output.lossy) || capped.truncated;
|
|
2248
|
-
|
|
3008
|
+
const result = {
|
|
2249
3009
|
...capped,
|
|
2250
3010
|
complete: !truncated,
|
|
2251
3011
|
source: "ripgrep"
|
|
2252
3012
|
};
|
|
3013
|
+
if (failure) {
|
|
3014
|
+
result.complete = false;
|
|
3015
|
+
result.warning = failure.message;
|
|
3016
|
+
}
|
|
3017
|
+
return result;
|
|
2253
3018
|
}
|
|
2254
3019
|
/**
|
|
2255
3020
|
* 内容搜索编排器:短期缓存 + 同根在途取消 + 会话清理。
|
|
@@ -2314,7 +3079,8 @@ var ContentSearcher = class {
|
|
|
2314
3079
|
const cached = this.cache.get(key);
|
|
2315
3080
|
if (cached) return {
|
|
2316
3081
|
matches: cached.matches,
|
|
2317
|
-
truncated: cached.truncated
|
|
3082
|
+
truncated: cached.truncated,
|
|
3083
|
+
warning: cached.warning
|
|
2318
3084
|
};
|
|
2319
3085
|
this.inflight.get(root)?.abort();
|
|
2320
3086
|
const controller = new AbortController();
|
|
@@ -2341,7 +3107,8 @@ var ContentSearcher = class {
|
|
|
2341
3107
|
this.cache.set(key, result);
|
|
2342
3108
|
return {
|
|
2343
3109
|
matches: result.matches,
|
|
2344
|
-
truncated: result.truncated
|
|
3110
|
+
truncated: result.truncated,
|
|
3111
|
+
warning: result.warning
|
|
2345
3112
|
};
|
|
2346
3113
|
} catch (error) {
|
|
2347
3114
|
if (controller.signal.aborted) return {
|
|
@@ -2813,53 +3580,7 @@ async function projectRefresh(ctx, workspacePath, serverName) {
|
|
|
2813
3580
|
return projectOf(ctx, workspacePath, workspace.title ?? "");
|
|
2814
3581
|
}
|
|
2815
3582
|
//#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
|
|
3583
|
+
//#region src/reveal.ts
|
|
2863
3584
|
/**
|
|
2864
3585
|
* dsh-vscode-mode host — 在 OS 文件浏览器中打开/定位路径(reveal 能力)。
|
|
2865
3586
|
* 纯函数 revealCommand 平台分发 + revealInExplorer 经 ctx.subprocess.spawn 发射
|
|
@@ -2926,6 +3647,462 @@ async function revealInExplorer(ctx, absPath, isDir) {
|
|
|
2926
3647
|
}
|
|
2927
3648
|
}
|
|
2928
3649
|
//#endregion
|
|
3650
|
+
//#region src/perf.ts
|
|
3651
|
+
/**
|
|
3652
|
+
* dsh-vscode-mode host — DSH 会话性能管理(会话卫生 + 体积 stat + 侧车摘要)。
|
|
3653
|
+
* 职责:
|
|
3654
|
+
* - 盘点 ~/.dsh/sessions 全部工作区会话(每会话仅 stat 日志文件,不解压,轻量);
|
|
3655
|
+
* - 移出/恢复/清除:会话目录在 ~/.dsh/sessions 与 ~/.dsh/sessions-archive 间可逆搬迁;
|
|
3656
|
+
* - 当前会话体积 stat(对话头部指示器用,纯 stat);
|
|
3657
|
+
* - 侧车摘要:agent/脚本取关键字段,避免整份读 .dsh-edit-review.json 进对话。
|
|
3658
|
+
* 纯函数可单测(路径编码/移出规划);fs 操作 best-effort 返回逐项结果不抛。
|
|
3659
|
+
* 作者 ddj 2026-09-02
|
|
3660
|
+
*/
|
|
3661
|
+
/** 会话日志文件名(zstd 优先,降级纯 jsonl)。 */
|
|
3662
|
+
const LOG_NAMES = ["session.jsonl.zstd", "session.jsonl"];
|
|
3663
|
+
/** 归档根(~/.dsh/sessions-archive,与 sessions 同级,可逆移出的落点)。 */
|
|
3664
|
+
function sessionsArchiveRoot(home = dshHome()) {
|
|
3665
|
+
return join(home, "sessions-archive");
|
|
3666
|
+
}
|
|
3667
|
+
/** 路径段校验:非空、非 . / ..、不含分隔符与 Windows 非法字符(防 join 逃逸)。 */
|
|
3668
|
+
function validDirName(name) {
|
|
3669
|
+
return typeof name === "string" && name.length > 0 && name !== "." && name !== ".." && !/[/\\]/.test(name) && !/[:*?"<>|]/.test(name);
|
|
3670
|
+
}
|
|
3671
|
+
/** target 必须落在 root 内(自身或其子路径),防路径穿越。 */
|
|
3672
|
+
function withinDir(target, root) {
|
|
3673
|
+
const rel = resolve(target);
|
|
3674
|
+
const base = resolve(root);
|
|
3675
|
+
return rel === base || rel.startsWith(base + sep);
|
|
3676
|
+
}
|
|
3677
|
+
/** stat 一个会话目录里的日志文件(首个存在的),缺失 → null。 */
|
|
3678
|
+
async function statSessionLog(sessionDir) {
|
|
3679
|
+
for (const name of LOG_NAMES) try {
|
|
3680
|
+
const info = await stat(join(sessionDir, name));
|
|
3681
|
+
if (info.isFile()) return {
|
|
3682
|
+
size: info.size,
|
|
3683
|
+
mtimeMs: info.mtimeMs
|
|
3684
|
+
};
|
|
3685
|
+
} catch (error) {}
|
|
3686
|
+
return null;
|
|
3687
|
+
}
|
|
3688
|
+
/**
|
|
3689
|
+
* 盘点 ~/.dsh/sessions:按工作区分组,每会话仅 stat 日志文件体积与 mtime。
|
|
3690
|
+
* 不解压日志(V8 展开成本高),活跃标记由调用方传入后置。
|
|
3691
|
+
* @author ddj 2026年09月02号
|
|
3692
|
+
* @param home DSH home(测试可注入)
|
|
3693
|
+
* @param archive 归档根(默认 sessions-archive;测试可注入)
|
|
3694
|
+
* @returns 工作区聚合(按体积降序)+ 会话明细(按体积降序)+ 汇总
|
|
3695
|
+
*/
|
|
3696
|
+
async function scanSessionInventory(home = dshHome(), archive = sessionsArchiveRoot(home)) {
|
|
3697
|
+
const root = sessionsRoot(home);
|
|
3698
|
+
const workspaces = [];
|
|
3699
|
+
const sessions = [];
|
|
3700
|
+
let totalBytes = 0;
|
|
3701
|
+
let totalCount = 0;
|
|
3702
|
+
const wsDirs = await readdir(root, { withFileTypes: true }).catch(() => []);
|
|
3703
|
+
for (const ws of wsDirs) {
|
|
3704
|
+
if (!ws.isDirectory() || !validDirName(ws.name)) continue;
|
|
3705
|
+
const wsDir = join(root, ws.name);
|
|
3706
|
+
const wsSessions = [];
|
|
3707
|
+
let wsBytes = 0;
|
|
3708
|
+
let minMtime = Number.POSITIVE_INFINITY;
|
|
3709
|
+
let maxMtime = 0;
|
|
3710
|
+
const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => []);
|
|
3711
|
+
for (const sd of sessionDirs) {
|
|
3712
|
+
if (!sd.isDirectory()) continue;
|
|
3713
|
+
const info = await statSessionLog(join(wsDir, sd.name));
|
|
3714
|
+
if (!info) continue;
|
|
3715
|
+
const row = {
|
|
3716
|
+
sessionId: sd.name,
|
|
3717
|
+
workspaceKey: ws.name,
|
|
3718
|
+
bytes: info.size,
|
|
3719
|
+
mtime: info.mtimeMs,
|
|
3720
|
+
active: false
|
|
3721
|
+
};
|
|
3722
|
+
wsSessions.push(row);
|
|
3723
|
+
wsBytes += info.size;
|
|
3724
|
+
totalBytes += info.size;
|
|
3725
|
+
totalCount += 1;
|
|
3726
|
+
if (info.mtimeMs < minMtime) minMtime = info.mtimeMs;
|
|
3727
|
+
if (info.mtimeMs > maxMtime) maxMtime = info.mtimeMs;
|
|
3728
|
+
}
|
|
3729
|
+
wsSessions.sort((a, b) => b.bytes - a.bytes);
|
|
3730
|
+
sessions.push(...wsSessions);
|
|
3731
|
+
workspaces.push({
|
|
3732
|
+
workspaceKey: ws.name,
|
|
3733
|
+
sessionCount: wsSessions.length,
|
|
3734
|
+
totalBytes: wsBytes,
|
|
3735
|
+
minMtime: Number.isFinite(minMtime) ? minMtime : 0,
|
|
3736
|
+
maxMtime
|
|
3737
|
+
});
|
|
3738
|
+
}
|
|
3739
|
+
workspaces.sort((a, b) => b.totalBytes - a.totalBytes);
|
|
3740
|
+
return {
|
|
3741
|
+
workspaces,
|
|
3742
|
+
sessions,
|
|
3743
|
+
totals: {
|
|
3744
|
+
workspaces: workspaces.length,
|
|
3745
|
+
sessions: totalCount,
|
|
3746
|
+
totalBytes
|
|
3747
|
+
}
|
|
3748
|
+
};
|
|
3749
|
+
}
|
|
3750
|
+
/** 标记活跃会话(live id 与目录段名双向匹配,兼容编码差异)。 */
|
|
3751
|
+
function markActiveSessions(sessions, activeIds) {
|
|
3752
|
+
const raw = new Set(activeIds);
|
|
3753
|
+
const encoded = /* @__PURE__ */ new Set();
|
|
3754
|
+
for (const id of raw) encoded.add(sessionIdSegment(id));
|
|
3755
|
+
for (const s of sessions) if (raw.has(s.sessionId) || encoded.has(s.sessionId)) s.active = true;
|
|
3756
|
+
}
|
|
3757
|
+
/**
|
|
3758
|
+
* 移出规划(纯函数):从盘点中按「显式集合」或「规则(minBytes / olderThanDays)」
|
|
3759
|
+
* 圈选可移出会话。活跃会话一律排除;无显式集合且无规则时不选(防误移)。
|
|
3760
|
+
* @author ddj 2026年09月02号
|
|
3761
|
+
* @param inventory 盘点结果
|
|
3762
|
+
* @param criteria 圈选条件
|
|
3763
|
+
* @returns 待移出清单与释放字节
|
|
3764
|
+
*/
|
|
3765
|
+
function planMoveOut(inventory, criteria) {
|
|
3766
|
+
const items = [];
|
|
3767
|
+
const explicit = Array.isArray(criteria.sessionIds) && criteria.sessionIds.length > 0;
|
|
3768
|
+
const cutoff = criteria.olderThanDays ? Date.now() - criteria.olderThanDays * 24 * 60 * 60 * 1e3 : 0;
|
|
3769
|
+
for (const s of inventory.sessions) {
|
|
3770
|
+
if (s.active) continue;
|
|
3771
|
+
if (criteria.workspaceKey && s.workspaceKey !== criteria.workspaceKey) continue;
|
|
3772
|
+
if (explicit) {
|
|
3773
|
+
if (!criteria.sessionIds.includes(s.sessionId)) continue;
|
|
3774
|
+
} else {
|
|
3775
|
+
if (criteria.minBytes && s.bytes < criteria.minBytes) continue;
|
|
3776
|
+
if (criteria.olderThanDays && s.mtime > cutoff) continue;
|
|
3777
|
+
if (!criteria.minBytes && !criteria.olderThanDays) continue;
|
|
3778
|
+
}
|
|
3779
|
+
items.push({
|
|
3780
|
+
workspaceKey: s.workspaceKey,
|
|
3781
|
+
sessionId: s.sessionId,
|
|
3782
|
+
bytes: s.bytes
|
|
3783
|
+
});
|
|
3784
|
+
}
|
|
3785
|
+
return {
|
|
3786
|
+
items,
|
|
3787
|
+
reclaimedBytes: items.reduce((sum, i) => sum + i.bytes, 0)
|
|
3788
|
+
};
|
|
3789
|
+
}
|
|
3790
|
+
/**
|
|
3791
|
+
* 移出会话目录到归档区(同卷 rename,可逆)。活跃会话拒绝;路径越界拒绝;
|
|
3792
|
+
* 逐项 best-effort,成功项写归档 manifest。默认不 dry-run(dryRun 时只校验不搬迁)。
|
|
3793
|
+
* @author ddj 2026年09月02号
|
|
3794
|
+
* @param home DSH home
|
|
3795
|
+
* @param archive 归档根
|
|
3796
|
+
* @param items 待移出清单(来自 planMoveOut 或用户确认)
|
|
3797
|
+
* @param activeIds 活跃会话 id 集合(执行期再校验一次)
|
|
3798
|
+
* @param dryRun 仅校验不搬迁
|
|
3799
|
+
*/
|
|
3800
|
+
async function moveOutSessions(home, archive, items, activeIds, dryRun = false) {
|
|
3801
|
+
const root = sessionsRoot(home);
|
|
3802
|
+
const rawActive = new Set(activeIds);
|
|
3803
|
+
const encodedActive = /* @__PURE__ */ new Set();
|
|
3804
|
+
for (const id of rawActive) encodedActive.add(sessionIdSegment(id));
|
|
3805
|
+
const isActive = (id) => rawActive.has(id) || encodedActive.has(id);
|
|
3806
|
+
const moved = [];
|
|
3807
|
+
const failures = [];
|
|
3808
|
+
for (const item of items) {
|
|
3809
|
+
if (!validDirName(item.workspaceKey)) {
|
|
3810
|
+
failures.push({
|
|
3811
|
+
workspaceKey: item.workspaceKey,
|
|
3812
|
+
sessionId: item.sessionId,
|
|
3813
|
+
error: "工作区键不合法"
|
|
3814
|
+
});
|
|
3815
|
+
continue;
|
|
3816
|
+
}
|
|
3817
|
+
if (isActive(item.sessionId)) {
|
|
3818
|
+
failures.push({
|
|
3819
|
+
workspaceKey: item.workspaceKey,
|
|
3820
|
+
sessionId: item.sessionId,
|
|
3821
|
+
error: "活跃会话,拒绝移出"
|
|
3822
|
+
});
|
|
3823
|
+
continue;
|
|
3824
|
+
}
|
|
3825
|
+
const seg = sessionIdSegment(item.sessionId);
|
|
3826
|
+
const src = join(root, item.workspaceKey, seg);
|
|
3827
|
+
const dst = join(archive, item.workspaceKey, seg);
|
|
3828
|
+
if (!withinDir(src, root) || !withinDir(dst, archive)) {
|
|
3829
|
+
failures.push({
|
|
3830
|
+
workspaceKey: item.workspaceKey,
|
|
3831
|
+
sessionId: item.sessionId,
|
|
3832
|
+
error: "路径越界,拒绝执行"
|
|
3833
|
+
});
|
|
3834
|
+
continue;
|
|
3835
|
+
}
|
|
3836
|
+
if (dryRun) {
|
|
3837
|
+
try {
|
|
3838
|
+
if ((await stat(src)).isDirectory()) moved.push({
|
|
3839
|
+
...item,
|
|
3840
|
+
bytes: (await statSessionLog(src))?.size ?? item.bytes
|
|
3841
|
+
});
|
|
3842
|
+
else failures.push({
|
|
3843
|
+
workspaceKey: item.workspaceKey,
|
|
3844
|
+
sessionId: item.sessionId,
|
|
3845
|
+
error: "源目录不存在"
|
|
3846
|
+
});
|
|
3847
|
+
} catch (error) {
|
|
3848
|
+
failures.push({
|
|
3849
|
+
workspaceKey: item.workspaceKey,
|
|
3850
|
+
sessionId: item.sessionId,
|
|
3851
|
+
error: "源目录不存在"
|
|
3852
|
+
});
|
|
3853
|
+
}
|
|
3854
|
+
continue;
|
|
3855
|
+
}
|
|
3856
|
+
try {
|
|
3857
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
3858
|
+
await rename(src, dst);
|
|
3859
|
+
const bytes = (await statSessionLog(dst))?.size ?? item.bytes;
|
|
3860
|
+
moved.push({
|
|
3861
|
+
...item,
|
|
3862
|
+
bytes
|
|
3863
|
+
});
|
|
3864
|
+
} catch (error) {
|
|
3865
|
+
failures.push({
|
|
3866
|
+
workspaceKey: item.workspaceKey,
|
|
3867
|
+
sessionId: item.sessionId,
|
|
3868
|
+
error: String(error)
|
|
3869
|
+
});
|
|
3870
|
+
}
|
|
3871
|
+
}
|
|
3872
|
+
if (moved.length && !dryRun) await appendMoveManifest(archive, moved);
|
|
3873
|
+
return {
|
|
3874
|
+
moved,
|
|
3875
|
+
failures,
|
|
3876
|
+
reclaimedBytes: moved.reduce((sum, i) => sum + i.bytes, 0)
|
|
3877
|
+
};
|
|
3878
|
+
}
|
|
3879
|
+
/** 恢复单个会话:归档 → sessions。目标已存在则拒绝(不覆盖)。 */
|
|
3880
|
+
async function restoreSession(home, archive, workspaceKey, sessionId) {
|
|
3881
|
+
if (!validDirName(workspaceKey)) return {
|
|
3882
|
+
ok: false,
|
|
3883
|
+
error: "工作区键不合法"
|
|
3884
|
+
};
|
|
3885
|
+
const seg = sessionIdSegment(sessionId);
|
|
3886
|
+
const root = sessionsRoot(home);
|
|
3887
|
+
const src = join(archive, workspaceKey, seg);
|
|
3888
|
+
const dst = join(root, workspaceKey, seg);
|
|
3889
|
+
if (!withinDir(src, archive) || !withinDir(dst, root)) return {
|
|
3890
|
+
ok: false,
|
|
3891
|
+
error: "路径越界,拒绝执行"
|
|
3892
|
+
};
|
|
3893
|
+
try {
|
|
3894
|
+
await stat(src);
|
|
3895
|
+
} catch (error) {
|
|
3896
|
+
return {
|
|
3897
|
+
ok: false,
|
|
3898
|
+
error: "归档中不存在该会话"
|
|
3899
|
+
};
|
|
3900
|
+
}
|
|
3901
|
+
try {
|
|
3902
|
+
await stat(dst);
|
|
3903
|
+
return {
|
|
3904
|
+
ok: false,
|
|
3905
|
+
error: "目标位置已存在同名会话"
|
|
3906
|
+
};
|
|
3907
|
+
} catch (error) {}
|
|
3908
|
+
try {
|
|
3909
|
+
await mkdir(dirname(dst), { recursive: true });
|
|
3910
|
+
await rename(src, dst);
|
|
3911
|
+
return { ok: true };
|
|
3912
|
+
} catch (error) {
|
|
3913
|
+
return {
|
|
3914
|
+
ok: false,
|
|
3915
|
+
error: String(error)
|
|
3916
|
+
};
|
|
3917
|
+
}
|
|
3918
|
+
}
|
|
3919
|
+
/** 清除归档区早于 N 天的会话(破坏性,仅限归档区)。 */
|
|
3920
|
+
async function purgeArchive(archive, olderThanDays) {
|
|
3921
|
+
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1e3;
|
|
3922
|
+
let removed = 0;
|
|
3923
|
+
let reclaimedBytes = 0;
|
|
3924
|
+
const wsDirs = await readdir(archive, { withFileTypes: true }).catch(() => []);
|
|
3925
|
+
for (const ws of wsDirs) {
|
|
3926
|
+
if (!ws.isDirectory()) continue;
|
|
3927
|
+
const wsDir = join(archive, ws.name);
|
|
3928
|
+
const sessionDirs = await readdir(wsDir, { withFileTypes: true }).catch(() => []);
|
|
3929
|
+
for (const sd of sessionDirs) {
|
|
3930
|
+
if (!sd.isDirectory()) continue;
|
|
3931
|
+
const info = await statSessionLog(join(wsDir, sd.name));
|
|
3932
|
+
if (!info || info.mtimeMs > cutoff) continue;
|
|
3933
|
+
try {
|
|
3934
|
+
await rm(join(wsDir, sd.name), {
|
|
3935
|
+
recursive: true,
|
|
3936
|
+
force: true
|
|
3937
|
+
});
|
|
3938
|
+
removed += 1;
|
|
3939
|
+
reclaimedBytes += info.size;
|
|
3940
|
+
} catch (error) {}
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
return {
|
|
3944
|
+
removed,
|
|
3945
|
+
reclaimedBytes
|
|
3946
|
+
};
|
|
3947
|
+
}
|
|
3948
|
+
/** 当前会话持久化体积(对话头部指示器用):仅 stat,近零成本。 */
|
|
3949
|
+
async function sessionSizeOf(home, cwd, sessionId) {
|
|
3950
|
+
const dir = join(sessionsRoot(home), sessionWorkspaceKey(cwd), sessionIdSegment(sessionId));
|
|
3951
|
+
for (const name of LOG_NAMES) try {
|
|
3952
|
+
const info = await stat(join(dir, name));
|
|
3953
|
+
if (info.isFile()) return {
|
|
3954
|
+
bytes: info.size,
|
|
3955
|
+
exists: true
|
|
3956
|
+
};
|
|
3957
|
+
} catch (error) {}
|
|
3958
|
+
return {
|
|
3959
|
+
bytes: 0,
|
|
3960
|
+
exists: false
|
|
3961
|
+
};
|
|
3962
|
+
}
|
|
3963
|
+
/**
|
|
3964
|
+
* 侧车摘要:返回活跃记录数、每文件 pending 计数、归档体积。
|
|
3965
|
+
* agent/脚本取关键字段即可,不必整份读 .dsh-edit-review.json(避免大对象进对话)。
|
|
3966
|
+
* @author ddj 2026年09月02号
|
|
3967
|
+
* @param ctx DSH host 上下文(fs 服务)
|
|
3968
|
+
* @param cwd 工作区路径(null 时返回空摘要)
|
|
3969
|
+
*/
|
|
3970
|
+
async function sidecarSummaryOf(ctx, cwd) {
|
|
3971
|
+
const fs = ctx.get("fs");
|
|
3972
|
+
const summary = {
|
|
3973
|
+
active: 0,
|
|
3974
|
+
pendingByFile: [],
|
|
3975
|
+
archiveBytes: 0
|
|
3976
|
+
};
|
|
3977
|
+
if (!fs || !cwd) return summary;
|
|
3978
|
+
try {
|
|
3979
|
+
const recs = parseSidecar(await fs.readText(await fs.resolve(SIDECAR, { cwd })))?.workspaces?.[cwd]?.records;
|
|
3980
|
+
if (recs) {
|
|
3981
|
+
const byFile = /* @__PURE__ */ new Map();
|
|
3982
|
+
let active = 0;
|
|
3983
|
+
for (const rec of Object.values(recs)) {
|
|
3984
|
+
if (rec.archived) continue;
|
|
3985
|
+
active += 1;
|
|
3986
|
+
const sm = recSummary(rec);
|
|
3987
|
+
if (sm.pending > 0 && rec.path) byFile.set(rec.path, (byFile.get(rec.path) ?? 0) + sm.pending);
|
|
3988
|
+
}
|
|
3989
|
+
summary.active = active;
|
|
3990
|
+
summary.pendingByFile = [...byFile.entries()].map(([path, pending]) => ({
|
|
3991
|
+
path,
|
|
3992
|
+
pending
|
|
3993
|
+
})).sort((a, b) => b.pending - a.pending);
|
|
3994
|
+
}
|
|
3995
|
+
try {
|
|
3996
|
+
summary.archiveBytes = (await fs.stat(await fs.resolve(".dsh-edit-review-archive.json", { cwd })))?.size ?? 0;
|
|
3997
|
+
} catch (error) {}
|
|
3998
|
+
} catch (error) {}
|
|
3999
|
+
return summary;
|
|
4000
|
+
}
|
|
4001
|
+
/** 解析归档 manifest(损坏/缺失 → 空)。 */
|
|
4002
|
+
function parseMoveManifest(text) {
|
|
4003
|
+
if (!text) return {
|
|
4004
|
+
version: 1,
|
|
4005
|
+
items: []
|
|
4006
|
+
};
|
|
4007
|
+
try {
|
|
4008
|
+
const data = JSON.parse(text);
|
|
4009
|
+
return {
|
|
4010
|
+
version: 1,
|
|
4011
|
+
items: Array.isArray(data.items) ? data.items : []
|
|
4012
|
+
};
|
|
4013
|
+
} catch (error) {
|
|
4014
|
+
return {
|
|
4015
|
+
version: 1,
|
|
4016
|
+
items: []
|
|
4017
|
+
};
|
|
4018
|
+
}
|
|
4019
|
+
}
|
|
4020
|
+
/** 追加移出记录到归档 manifest(best-effort,失败不影响主流程)。 */
|
|
4021
|
+
async function appendMoveManifest(archive, items) {
|
|
4022
|
+
try {
|
|
4023
|
+
const file = join(archive, ".manifest.json");
|
|
4024
|
+
const prev = parseMoveManifest(await readFile(file, "utf8").catch(() => null));
|
|
4025
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
4026
|
+
for (const item of items) prev.items.push({
|
|
4027
|
+
at,
|
|
4028
|
+
workspaceKey: item.workspaceKey,
|
|
4029
|
+
sessionId: item.sessionId
|
|
4030
|
+
});
|
|
4031
|
+
await mkdir(archive, { recursive: true });
|
|
4032
|
+
await writeFile(file, JSON.stringify(prev, null, 2), "utf8");
|
|
4033
|
+
} catch (error) {}
|
|
4034
|
+
}
|
|
4035
|
+
//#endregion
|
|
4036
|
+
//#region src/perfPatch.ts
|
|
4037
|
+
/**
|
|
4038
|
+
* dsh-vscode-mode host — profile patch 压缩调优(文本行级,零 YAML 依赖)。
|
|
4039
|
+
* 在 ~/.dsh/profiles/<profile>/cordis.patch.yml 末尾追加/移除一段带标记注释的
|
|
4040
|
+
* id 定向覆盖块(compaction-basic / tool-result-pruner 更低阈值)。
|
|
4041
|
+
* 纯文本函数可单测:幂等追加、整段移除、无块原样返回。
|
|
4042
|
+
* 作者 ddj 2026-09-02
|
|
4043
|
+
*/
|
|
4044
|
+
/** 性能块起始标记注释(文件内唯一锚点)。 */
|
|
4045
|
+
const PERF_MARK_START = "# dsh-vscode-mode perf tuning (start)";
|
|
4046
|
+
/** 性能块结束标记注释。 */
|
|
4047
|
+
const PERF_MARK_END = "# dsh-vscode-mode perf tuning (end)";
|
|
4048
|
+
/**
|
|
4049
|
+
* 建议的压缩调优 YAML 块(顶层 loader patch 条目,id 定向覆盖)。
|
|
4050
|
+
* - compaction-basic:thresholdRatio 0.8→0.6、retainRatio 0.16→0.12(更早触发压缩);
|
|
4051
|
+
* - tool-result-pruner:thresholdChars 8192→4096、head/tail 同步收窄(大 tool result 提前截断)。
|
|
4052
|
+
* 注意:压缩只降低模型可见上下文压力,不重写已持久化会话日志(append-only)。
|
|
4053
|
+
* @author ddj 2026年09月02号
|
|
4054
|
+
* @returns 带前后标记注释与尾换行的 YAML 块
|
|
4055
|
+
*/
|
|
4056
|
+
function perfConfigBlock() {
|
|
4057
|
+
return [
|
|
4058
|
+
PERF_MARK_START,
|
|
4059
|
+
"# 更早触发会话内压缩:降低模型可见上下文压力(仅影响后续轮次,不缩小已持久化日志)。",
|
|
4060
|
+
"- id: compaction-basic",
|
|
4061
|
+
" config:",
|
|
4062
|
+
" thresholdRatio: 0.6",
|
|
4063
|
+
" retainRatio: 0.12",
|
|
4064
|
+
"- id: tool-result-pruner",
|
|
4065
|
+
" config:",
|
|
4066
|
+
" thresholdChars: 4096",
|
|
4067
|
+
" headChars: 2048",
|
|
4068
|
+
" tailChars: 512",
|
|
4069
|
+
PERF_MARK_END,
|
|
4070
|
+
""
|
|
4071
|
+
].join("\n");
|
|
4072
|
+
}
|
|
4073
|
+
/** 当前文本是否已包含性能块。 */
|
|
4074
|
+
function patchHasPerfConfig(text) {
|
|
4075
|
+
return text.includes(PERF_MARK_START);
|
|
4076
|
+
}
|
|
4077
|
+
/**
|
|
4078
|
+
* 移除性能块(起止标记注释之间的整段,含所在行),无块时原样返回。
|
|
4079
|
+
* 移除后压缩多余空行、确保以单个换行结尾。
|
|
4080
|
+
* @author ddj 2026年09月02号
|
|
4081
|
+
* @param text 原 patch 文本
|
|
4082
|
+
* @returns 移除后的文本
|
|
4083
|
+
*/
|
|
4084
|
+
function patchRemovePerfConfig(text) {
|
|
4085
|
+
const start = text.indexOf(PERF_MARK_START);
|
|
4086
|
+
if (start === -1) return text;
|
|
4087
|
+
const end = text.indexOf(PERF_MARK_END, start);
|
|
4088
|
+
const lineStart = text.lastIndexOf("\n", start - 1) + 1;
|
|
4089
|
+
if (end === -1) return text.slice(0, lineStart).replace(/\n+$/, "\n");
|
|
4090
|
+
const after = (() => {
|
|
4091
|
+
const nl = text.indexOf("\n", end);
|
|
4092
|
+
return nl === -1 ? text.length : nl + 1;
|
|
4093
|
+
})();
|
|
4094
|
+
return (text.slice(0, lineStart) + text.slice(after)).replace(/\n{3,}/g, "\n\n").replace(/\n+$/, "\n");
|
|
4095
|
+
}
|
|
4096
|
+
/**
|
|
4097
|
+
* 幂等追加性能块:已有块先整段移除再追加到文件末尾(保证全文件只有一份、位置固定)。
|
|
4098
|
+
* @author ddj 2026年09月02号
|
|
4099
|
+
* @param text 原 patch 文本
|
|
4100
|
+
* @returns 追加后的文本
|
|
4101
|
+
*/
|
|
4102
|
+
function patchInsertPerfConfig(text) {
|
|
4103
|
+
return patchRemovePerfConfig(text).replace(/\n+$/, "") + "\n" + perfConfigBlock();
|
|
4104
|
+
}
|
|
4105
|
+
//#endregion
|
|
2929
4106
|
//#region src/rpc.ts
|
|
2930
4107
|
/** cwd → 内存缓冲:行数组 + 累计字节数 + 待触发 flush 定时器(攒批落盘,避免每条日志全文件读改写)。 */
|
|
2931
4108
|
const debugBuffers = /* @__PURE__ */ new Map();
|
|
@@ -2944,13 +4121,12 @@ const STALE_CHECK_MIN_MS = 1e4;
|
|
|
2944
4121
|
/**
|
|
2945
4122
|
* 调试日志入队:先攒内存缓冲(行 + 字节数),满 DEBUG_BUF_CAP 立即落盘,
|
|
2946
4123
|
* 否则空闲 DEBUG_FLUSH_IDLE_MS 后落盘;落盘 = 读旧文件 + 追加整批 + 超限截断 + 一次写入。
|
|
2947
|
-
* @author ddj 2026年08月26号
|
|
4124
|
+
* @author ddj 2026年08月26号 / 2026年09月01号
|
|
2948
4125
|
* @param ctx DSH 上下文
|
|
2949
|
-
* @param cwd
|
|
2950
|
-
* @param policy 会话沙箱策略(入队时刻捕获)
|
|
4126
|
+
* @param cwd 工作区(日志按 cwd hash 存 ~/.dsh/dsh-vscode-mode/logs/)
|
|
2951
4127
|
* @param line 单条日志文本(不含换行)
|
|
2952
4128
|
*/
|
|
2953
|
-
function enqueueDebug(ctx, cwd,
|
|
4129
|
+
function enqueueDebug(ctx, cwd, line) {
|
|
2954
4130
|
const st = debugBuffers.get(cwd) ?? {
|
|
2955
4131
|
lines: [],
|
|
2956
4132
|
len: 0,
|
|
@@ -2963,7 +4139,7 @@ function enqueueDebug(ctx, cwd, policy, line) {
|
|
|
2963
4139
|
const batch = st.lines;
|
|
2964
4140
|
st.lines = [];
|
|
2965
4141
|
st.len = 0;
|
|
2966
|
-
flushDebug(
|
|
4142
|
+
flushDebug(cwd, batch);
|
|
2967
4143
|
};
|
|
2968
4144
|
if (st.len >= DEBUG_BUF_CAP) {
|
|
2969
4145
|
if (st.timer) clearTimeout(st.timer);
|
|
@@ -2971,24 +4147,27 @@ function enqueueDebug(ctx, cwd, policy, line) {
|
|
|
2971
4147
|
} else if (!st.timer) st.timer = setTimeout(flush, DEBUG_FLUSH_IDLE_MS);
|
|
2972
4148
|
debugBuffers.set(cwd, st);
|
|
2973
4149
|
}
|
|
4150
|
+
/** cwd → 已清理旧工作区 debug 日志标记(一次性)。 */
|
|
4151
|
+
const debugLegacyCleaned = /* @__PURE__ */ new Set();
|
|
2974
4152
|
/**
|
|
2975
4153
|
* 批量落盘一条 debug 日志缓冲:读旧文件 → 追加 → 超上限截断保留尾部 → 一次写回。
|
|
2976
4154
|
* 串行链保证同一 cwd 的读改写不交错丢行;写失败静默忽略(调试日志不阻塞业务)。
|
|
2977
|
-
* @author ddj 2026年08月26号
|
|
2978
|
-
* @param
|
|
2979
|
-
* @param cwd 工作区
|
|
2980
|
-
* @param policy 会话沙箱策略(enqueue 时刻捕获)
|
|
4155
|
+
* @author ddj 2026年08月26号 / 2026年09月01号
|
|
4156
|
+
* @param cwd 工作区(日志落 ~/.dsh/dsh-vscode-mode/logs/debug.<cwdHash>.log)
|
|
2981
4157
|
* @param batch 本批日志行
|
|
2982
4158
|
*/
|
|
2983
|
-
function flushDebug(
|
|
4159
|
+
function flushDebug(cwd, batch) {
|
|
2984
4160
|
const task = (debugWriteQueues.get(cwd) ?? Promise.resolve()).then(async () => {
|
|
2985
4161
|
try {
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
const
|
|
2989
|
-
const appended = await fs.readText(target).catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
|
|
4162
|
+
await mkdir(pluginLogRoot(), { recursive: true });
|
|
4163
|
+
const target = debugLogFile(cwd);
|
|
4164
|
+
const appended = await readFile(target, "utf8").catch(() => "") + batch.map((l) => (/* @__PURE__ */ new Date()).toISOString() + " " + l + "\n").join("");
|
|
2990
4165
|
const next = appended.length > DEBUG_LOG_CAP ? appended.slice(-Math.floor(DEBUG_LOG_CAP / 2)) : appended;
|
|
2991
|
-
await
|
|
4166
|
+
await writeFile(target, next, "utf8");
|
|
4167
|
+
if (!debugLegacyCleaned.has(cwd)) {
|
|
4168
|
+
debugLegacyCleaned.add(cwd);
|
|
4169
|
+
await rm(join(cwd, DEBUG_LOG), { force: true }).catch(() => {});
|
|
4170
|
+
}
|
|
2992
4171
|
} catch (e) {}
|
|
2993
4172
|
});
|
|
2994
4173
|
debugWriteQueues.set(cwd, task);
|
|
@@ -3073,6 +4252,27 @@ async function applyDecisions(ctx, session, cwd, bucket, items) {
|
|
|
3073
4252
|
if (changed) await saveBucket(ctx, cwd, bucket, session);
|
|
3074
4253
|
return results;
|
|
3075
4254
|
}
|
|
4255
|
+
/** 当前活跃会话 id 集合(live sessions,用于移出/恢复护栏)。 */
|
|
4256
|
+
function activeSessionIds(ctx) {
|
|
4257
|
+
const sessions = ctx.get("sessions");
|
|
4258
|
+
if (!sessions || typeof sessions.list !== "function") return /* @__PURE__ */ new Set();
|
|
4259
|
+
return new Set(sessions.list().map((s) => s.id).filter(Boolean));
|
|
4260
|
+
}
|
|
4261
|
+
/** profile patch 文件定位(依赖本插件的 profile,同 devForm)。 */
|
|
4262
|
+
async function patchFileInfo() {
|
|
4263
|
+
const profileDir = findProfileDir();
|
|
4264
|
+
if (!profileDir) return {};
|
|
4265
|
+
return {
|
|
4266
|
+
profileDir,
|
|
4267
|
+
patchPath: join(profileDir, "cordis.patch.yml")
|
|
4268
|
+
};
|
|
4269
|
+
}
|
|
4270
|
+
/** 最新一次压缩配置备份文件名(cordis.patch.yml.bak-<ts>,按名倒序取新)。 */
|
|
4271
|
+
async function latestPatchBackup(patchPath) {
|
|
4272
|
+
const dir = join(patchPath, "..");
|
|
4273
|
+
const base = basename(patchPath);
|
|
4274
|
+
return (await readdir(dir).catch(() => [])).filter((n) => n.startsWith(base + ".bak-")).sort().reverse()[0];
|
|
4275
|
+
}
|
|
3076
4276
|
/** 各方法 handler 表(类型由 shared/rpc 的 RpcHandlerMap 约束)。 */
|
|
3077
4277
|
function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearcher = newContentSearcher(ctx)) {
|
|
3078
4278
|
return {
|
|
@@ -3261,6 +4461,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3261
4461
|
try {
|
|
3262
4462
|
const target = await resolveTarget(ctx, sc.session, args.path);
|
|
3263
4463
|
await fs.writeText(target, args.content, void 0, void 0, policyOf(ctx, sc.session));
|
|
4464
|
+
invalidateIndex(ctx, sc.cwd, args.path);
|
|
3264
4465
|
const bucket = await bucketOf(registry, ctx, sc.cwd);
|
|
3265
4466
|
let changed = false;
|
|
3266
4467
|
for (const rec of bucket.values()) if (rec.path === args.path && rec.superseded !== true) {
|
|
@@ -3397,7 +4598,7 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3397
4598
|
error: sc.err
|
|
3398
4599
|
};
|
|
3399
4600
|
const text = String(args.text ?? "");
|
|
3400
|
-
enqueueDebug(ctx, sc.cwd,
|
|
4601
|
+
enqueueDebug(ctx, sc.cwd, text);
|
|
3401
4602
|
console.error("[edrv-debug] " + text);
|
|
3402
4603
|
return { ok: true };
|
|
3403
4604
|
},
|
|
@@ -3454,40 +4655,22 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3454
4655
|
ok: false,
|
|
3455
4656
|
error: sc.err
|
|
3456
4657
|
};
|
|
3457
|
-
const
|
|
3458
|
-
if (
|
|
4658
|
+
const rel = normalizeRel(args.path);
|
|
4659
|
+
if (rel === null) return {
|
|
3459
4660
|
ok: false,
|
|
3460
|
-
error: "
|
|
4661
|
+
error: "路径不合法"
|
|
4662
|
+
};
|
|
4663
|
+
const res = await listDirCached(ctx, sc.cwd, rel, args.force === true);
|
|
4664
|
+
if ("error" in res) return {
|
|
4665
|
+
ok: false,
|
|
4666
|
+
error: res.error
|
|
4667
|
+
};
|
|
4668
|
+
return {
|
|
4669
|
+
ok: true,
|
|
4670
|
+
root: res.root,
|
|
4671
|
+
path: rel,
|
|
4672
|
+
entries: res.entries
|
|
3461
4673
|
};
|
|
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
4674
|
},
|
|
3492
4675
|
"edrv.revealInExplorer": async (args) => {
|
|
3493
4676
|
const sc = await requireSession(ctx, args.sessionId);
|
|
@@ -3705,6 +4888,211 @@ function buildHandlers(ctx, registry, searcher = newSearcher(ctx), contentSearch
|
|
|
3705
4888
|
error: String(error)
|
|
3706
4889
|
};
|
|
3707
4890
|
}
|
|
4891
|
+
},
|
|
4892
|
+
"edrv.perf.inventory": async () => {
|
|
4893
|
+
try {
|
|
4894
|
+
const home = dshHome();
|
|
4895
|
+
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
|
|
4896
|
+
const active = activeSessionIds(ctx);
|
|
4897
|
+
markActiveSessions(inventory.sessions, active);
|
|
4898
|
+
return {
|
|
4899
|
+
ok: true,
|
|
4900
|
+
...inventory,
|
|
4901
|
+
activeIds: [...active]
|
|
4902
|
+
};
|
|
4903
|
+
} catch (error) {
|
|
4904
|
+
return {
|
|
4905
|
+
ok: false,
|
|
4906
|
+
error: "盘点失败:" + String(error)
|
|
4907
|
+
};
|
|
4908
|
+
}
|
|
4909
|
+
},
|
|
4910
|
+
"edrv.perf.sessionSize": async (args) => {
|
|
4911
|
+
try {
|
|
4912
|
+
return {
|
|
4913
|
+
ok: true,
|
|
4914
|
+
...await sessionSizeOf(dshHome(), args.cwd, args.sessionId ?? "")
|
|
4915
|
+
};
|
|
4916
|
+
} catch (error) {
|
|
4917
|
+
return {
|
|
4918
|
+
ok: false,
|
|
4919
|
+
error: "读取会话体积失败:" + String(error)
|
|
4920
|
+
};
|
|
4921
|
+
}
|
|
4922
|
+
},
|
|
4923
|
+
"edrv.perf.movePlan": async (args) => {
|
|
4924
|
+
try {
|
|
4925
|
+
const home = dshHome();
|
|
4926
|
+
const inventory = await scanSessionInventory(home, sessionsArchiveRoot(home));
|
|
4927
|
+
markActiveSessions(inventory.sessions, activeSessionIds(ctx));
|
|
4928
|
+
return {
|
|
4929
|
+
ok: true,
|
|
4930
|
+
...planMoveOut(inventory, {
|
|
4931
|
+
workspaceKey: args.workspaceKey,
|
|
4932
|
+
sessionIds: args.sessionIds,
|
|
4933
|
+
minBytes: args.minBytes,
|
|
4934
|
+
olderThanDays: args.olderThanDays
|
|
4935
|
+
})
|
|
4936
|
+
};
|
|
4937
|
+
} catch (error) {
|
|
4938
|
+
return {
|
|
4939
|
+
ok: false,
|
|
4940
|
+
error: "移出规划失败:" + String(error)
|
|
4941
|
+
};
|
|
4942
|
+
}
|
|
4943
|
+
},
|
|
4944
|
+
"edrv.perf.moveOut": async (args) => {
|
|
4945
|
+
try {
|
|
4946
|
+
const home = dshHome();
|
|
4947
|
+
const items = (args.sessionIds ?? []).map((sessionId) => ({
|
|
4948
|
+
workspaceKey: args.workspaceKey,
|
|
4949
|
+
sessionId,
|
|
4950
|
+
bytes: 0
|
|
4951
|
+
}));
|
|
4952
|
+
return {
|
|
4953
|
+
ok: true,
|
|
4954
|
+
...await moveOutSessions(home, sessionsArchiveRoot(home), items, activeSessionIds(ctx), args.dryRun === true)
|
|
4955
|
+
};
|
|
4956
|
+
} catch (error) {
|
|
4957
|
+
return {
|
|
4958
|
+
ok: false,
|
|
4959
|
+
error: "移出失败:" + String(error)
|
|
4960
|
+
};
|
|
4961
|
+
}
|
|
4962
|
+
},
|
|
4963
|
+
"edrv.perf.restore": async (args) => {
|
|
4964
|
+
try {
|
|
4965
|
+
const result = await restoreSession(dshHome(), sessionsArchiveRoot(dshHome()), args.workspaceKey, args.sessionId);
|
|
4966
|
+
return result.ok ? {
|
|
4967
|
+
ok: true,
|
|
4968
|
+
restored: true
|
|
4969
|
+
} : {
|
|
4970
|
+
ok: false,
|
|
4971
|
+
error: result.error ?? "恢复失败"
|
|
4972
|
+
};
|
|
4973
|
+
} catch (error) {
|
|
4974
|
+
return {
|
|
4975
|
+
ok: false,
|
|
4976
|
+
error: "恢复失败:" + String(error)
|
|
4977
|
+
};
|
|
4978
|
+
}
|
|
4979
|
+
},
|
|
4980
|
+
"edrv.perf.purgeArchive": async (args) => {
|
|
4981
|
+
try {
|
|
4982
|
+
return {
|
|
4983
|
+
ok: true,
|
|
4984
|
+
...await purgeArchive(sessionsArchiveRoot(dshHome()), args.olderThanDays)
|
|
4985
|
+
};
|
|
4986
|
+
} catch (error) {
|
|
4987
|
+
return {
|
|
4988
|
+
ok: false,
|
|
4989
|
+
error: "清除失败:" + String(error)
|
|
4990
|
+
};
|
|
4991
|
+
}
|
|
4992
|
+
},
|
|
4993
|
+
"edrv.perf.sidecarSummary": async (args) => {
|
|
4994
|
+
const sc = await requireSession(ctx, args.sessionId);
|
|
4995
|
+
const cwd = "err" in sc ? null : sc.cwd;
|
|
4996
|
+
try {
|
|
4997
|
+
return {
|
|
4998
|
+
ok: true,
|
|
4999
|
+
...await sidecarSummaryOf(ctx, cwd)
|
|
5000
|
+
};
|
|
5001
|
+
} catch (error) {
|
|
5002
|
+
return {
|
|
5003
|
+
ok: false,
|
|
5004
|
+
error: "读取侧车摘要失败:" + String(error)
|
|
5005
|
+
};
|
|
5006
|
+
}
|
|
5007
|
+
},
|
|
5008
|
+
"edrv.perf.configGet": async () => {
|
|
5009
|
+
try {
|
|
5010
|
+
const { profileDir, patchPath } = await patchFileInfo();
|
|
5011
|
+
const block = perfConfigBlock();
|
|
5012
|
+
if (!patchPath) return {
|
|
5013
|
+
ok: true,
|
|
5014
|
+
applied: false,
|
|
5015
|
+
block
|
|
5016
|
+
};
|
|
5017
|
+
const text = await readFile(patchPath, "utf8").catch(() => "");
|
|
5018
|
+
const backup = await latestPatchBackup(patchPath);
|
|
5019
|
+
return {
|
|
5020
|
+
ok: true,
|
|
5021
|
+
profileDir,
|
|
5022
|
+
patchPath,
|
|
5023
|
+
applied: patchHasPerfConfig(text),
|
|
5024
|
+
block,
|
|
5025
|
+
backup
|
|
5026
|
+
};
|
|
5027
|
+
} catch (error) {
|
|
5028
|
+
return {
|
|
5029
|
+
ok: false,
|
|
5030
|
+
error: "读取压缩配置失败:" + String(error)
|
|
5031
|
+
};
|
|
5032
|
+
}
|
|
5033
|
+
},
|
|
5034
|
+
"edrv.perf.configApply": async () => {
|
|
5035
|
+
try {
|
|
5036
|
+
const { patchPath } = await patchFileInfo();
|
|
5037
|
+
if (!patchPath) return {
|
|
5038
|
+
ok: false,
|
|
5039
|
+
error: "未找到依赖本插件的 profile(检查 DSH_HOME/profiles)"
|
|
5040
|
+
};
|
|
5041
|
+
const text = await readFile(patchPath, "utf8").catch(() => "");
|
|
5042
|
+
const backup = patchPath + ".bak-" + Date.now();
|
|
5043
|
+
if (text) await writeFile(backup, text, "utf8");
|
|
5044
|
+
await mkdir(join(patchPath, ".."), { recursive: true });
|
|
5045
|
+
await writeFile(patchPath, patchInsertPerfConfig(text), "utf8");
|
|
5046
|
+
return {
|
|
5047
|
+
ok: true,
|
|
5048
|
+
applied: true,
|
|
5049
|
+
backup,
|
|
5050
|
+
restart: true
|
|
5051
|
+
};
|
|
5052
|
+
} catch (error) {
|
|
5053
|
+
return {
|
|
5054
|
+
ok: false,
|
|
5055
|
+
error: "写入压缩配置失败:" + String(error)
|
|
5056
|
+
};
|
|
5057
|
+
}
|
|
5058
|
+
},
|
|
5059
|
+
"edrv.perf.configUndo": async () => {
|
|
5060
|
+
try {
|
|
5061
|
+
const { patchPath } = await patchFileInfo();
|
|
5062
|
+
if (!patchPath) return {
|
|
5063
|
+
ok: false,
|
|
5064
|
+
error: "未找到依赖本插件的 profile(检查 DSH_HOME/profiles)"
|
|
5065
|
+
};
|
|
5066
|
+
const backup = await latestPatchBackup(patchPath);
|
|
5067
|
+
if (backup) {
|
|
5068
|
+
const backupPath = join(join(patchPath, ".."), backup);
|
|
5069
|
+
const saved = await readFile(backupPath, "utf8").catch(() => null);
|
|
5070
|
+
if (saved !== null) {
|
|
5071
|
+
await writeFile(patchPath, saved, "utf8");
|
|
5072
|
+
await rm(backupPath, { force: true }).catch(() => {});
|
|
5073
|
+
return {
|
|
5074
|
+
ok: true,
|
|
5075
|
+
restored: true,
|
|
5076
|
+
backup
|
|
5077
|
+
};
|
|
5078
|
+
}
|
|
5079
|
+
}
|
|
5080
|
+
const text = await readFile(patchPath, "utf8").catch(() => "");
|
|
5081
|
+
if (!patchHasPerfConfig(text)) return {
|
|
5082
|
+
ok: false,
|
|
5083
|
+
error: "没有可撤销的压缩配置"
|
|
5084
|
+
};
|
|
5085
|
+
await writeFile(patchPath, patchRemovePerfConfig(text), "utf8");
|
|
5086
|
+
return {
|
|
5087
|
+
ok: true,
|
|
5088
|
+
restored: true
|
|
5089
|
+
};
|
|
5090
|
+
} catch (error) {
|
|
5091
|
+
return {
|
|
5092
|
+
ok: false,
|
|
5093
|
+
error: "撤销压缩配置失败:" + String(error)
|
|
5094
|
+
};
|
|
5095
|
+
}
|
|
3708
5096
|
}
|
|
3709
5097
|
};
|
|
3710
5098
|
}
|
|
@@ -3898,10 +5286,12 @@ function apply(ctx, config) {
|
|
|
3898
5286
|
dropFileIndex(cwd);
|
|
3899
5287
|
searcher.dispose(cwd);
|
|
3900
5288
|
contentSearcher.dispose(cwd);
|
|
5289
|
+
disposeIndex(cwd);
|
|
3901
5290
|
}
|
|
3902
5291
|
});
|
|
3903
5292
|
registerRoutes(ctx, config, (method, args) => handleRpc(ctx, registry, method, args, searcher, contentSearcher), (warning) => warnings.push(warning));
|
|
3904
5293
|
installIsolation(ctx);
|
|
5294
|
+
sweepTreeCache();
|
|
3905
5295
|
logCompatSummary(ctx, warnings);
|
|
3906
5296
|
ctx.logger?.info?.("[dsh-vscode-mode] 编辑差异审查已装配(/edrv/rpc 路由就绪,项目 MCP 隔离已启用)");
|
|
3907
5297
|
}
|