dsh-auto-collapse 0.1.4 → 0.1.5
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.en.md +5 -4
- package/README.md +5 -4
- package/lib/client.js +281 -25
- package/lib/index.js +27 -32
- package/lib/types/client.d.ts +13 -0
- package/lib/types/fold.d.ts +264 -0
- package/lib/types/index.d.ts +11 -22
- package/lib/types/settings.d.ts +41 -0
- package/package.json +2 -2
- package/lib/types/client/index.d.ts +0 -16
package/README.en.md
CHANGED
|
@@ -46,9 +46,10 @@ Restart the DSH web service (or trigger plugin HMR), then hard-refresh the page
|
|
|
46
46
|
```
|
|
47
47
|
src/fold.ts core: FoldController (state machine) + findBlocks (block recognition) + collapse/expand logic
|
|
48
48
|
src/client.ts browser entry (plugin registration)
|
|
49
|
-
src/index.ts host half
|
|
50
|
-
build.mjs
|
|
51
|
-
|
|
49
|
+
src/index.ts host half (host-side entry)
|
|
50
|
+
build.mjs build script (generates lib/client.js, lib/index.js, and lib/types/*)
|
|
51
|
+
tsconfig.build.json TypeScript declaration build configuration
|
|
52
|
+
deploy.mjs safe deploy: validate → back up → replace → verified restart → hash check/rollback; Windows uses PowerShell, Linux/macOS use lsof + ps
|
|
52
53
|
cordis.patch.yml profile tree mounting
|
|
53
54
|
test/ fake-DOM contract, race, session-switch, and 40-order permutation regressions
|
|
54
55
|
```
|
|
@@ -67,7 +68,7 @@ Runs TypeScript checking, a fresh build, and the complete regression suite.
|
|
|
67
68
|
npm run deploy
|
|
68
69
|
```
|
|
69
70
|
|
|
70
|
-
Validates the plugin/DSH package identities and the process listening on port 3080, then creates a timestamped backup, replaces the bundle, restarts DSH, and verifies the served hash. Failures restore the old bundle. Override defaults with `DSH_AUTO_COLLAPSE_LIB`, `DSH_DIR`, `DSH_WEB_PORT`, and `DSH_LOG_DIR`.
|
|
71
|
+
Validates the plugin/DSH package identities and the process listening on port 3080, then creates a timestamped backup, replaces the bundle, restarts DSH, and verifies the served hash. Failures restore the old bundle. Windows, Linux, and macOS are supported; Unix systems locate DSH from `npm root -g` by default and use `HOME` for the profile path. Linux/macOS require `lsof`. Override defaults with `DSH_AUTO_COLLAPSE_LIB`, `DSH_DIR`, `DSH_WEB_PORT`, and `DSH_LOG_DIR`.
|
|
71
72
|
|
|
72
73
|
### Publishing a new version
|
|
73
74
|
|
package/README.md
CHANGED
|
@@ -47,9 +47,10 @@ dsh plugin --profile web add "github:a179-sanae/dsh-auto-collapse#main"
|
|
|
47
47
|
```
|
|
48
48
|
src/fold.ts 核心:FoldController(状态机)+ findBlocks(块识别)+ 折叠/展开逻辑
|
|
49
49
|
src/client.ts 浏览器端入口(注册插件)
|
|
50
|
-
src/index.ts host half
|
|
51
|
-
build.mjs
|
|
52
|
-
|
|
50
|
+
src/index.ts host half(宿主端,Host half)
|
|
51
|
+
build.mjs 构建脚本(Build script):生成 lib/client.js、lib/index.js 与 lib/types/*
|
|
52
|
+
tsconfig.build.json TypeScript 声明构建配置(Declaration build config)
|
|
53
|
+
deploy.mjs 安全部署(Safe deploy):校验 → 备份 → 替换 → 身份核验重启 → 哈希验证/回滚(DSH web 输出持久化到 ~/.dsh/logs/web.{out,err}.log);Windows 使用 PowerShell,Linux/macOS 使用 lsof + ps
|
|
53
54
|
cordis.patch.yml profile 树挂载
|
|
54
55
|
test/ fake DOM 契约、竞态、会话切换与 40 组乱序排列回归
|
|
55
56
|
```
|
|
@@ -68,7 +69,7 @@ npm run check
|
|
|
68
69
|
npm run deploy
|
|
69
70
|
```
|
|
70
71
|
|
|
71
|
-
脚本先核验插件/DSH 包名和 3080 监听进程身份,再做时间戳备份、替换、重启与服务端哈希验证;失败自动恢复旧 bundle
|
|
72
|
+
脚本先核验插件/DSH 包名和 3080 监听进程身份,再做时间戳备份、替换、重启与服务端哈希验证;失败自动恢复旧 bundle。支持 Windows、Linux 和 macOS;Unix 系统默认从 `npm root -g` 定位 DSH 包,并使用 `HOME` 定位 profile。Linux/macOS 需要安装 `lsof`。可用 `DSH_AUTO_COLLAPSE_LIB`、`DSH_DIR`、`DSH_WEB_PORT`、`DSH_LOG_DIR` 覆盖默认路径。
|
|
72
73
|
|
|
73
74
|
### 发布新版本
|
|
74
75
|
|
package/lib/client.js
CHANGED
|
@@ -38,6 +38,9 @@ var __dshcfBundle = (() => {
|
|
|
38
38
|
var DEFAULT_STATUS_TEXT = "Deep sleeping...";
|
|
39
39
|
var ANIM_DURATION_MS = 180;
|
|
40
40
|
var ANIM_EASING = "ease-out";
|
|
41
|
+
var DISPLAY_OWNED_PROP = "--dshcf-display-owned";
|
|
42
|
+
var AUDIT_TICK_MS = 1e3;
|
|
43
|
+
var STICK_BOTTOM_THRESHOLD_PX = 24;
|
|
41
44
|
var TOOL_LABELS = {
|
|
42
45
|
bash: "Bash",
|
|
43
46
|
pwsh: "Pwsh",
|
|
@@ -343,8 +346,16 @@ var __dshcfBundle = (() => {
|
|
|
343
346
|
}
|
|
344
347
|
`;
|
|
345
348
|
var FoldController = class {
|
|
346
|
-
constructor(statusTextProvider2) {
|
|
349
|
+
constructor(statusTextProvider2, options) {
|
|
347
350
|
this.observer = null;
|
|
351
|
+
/** 防止同一个控制器重复注册 observer、定时器和可见性监听。 */
|
|
352
|
+
this.started = false;
|
|
353
|
+
/** body 尚未创建时等待 DOMContentLoaded,再补一次启动。 */
|
|
354
|
+
this.waitingForBody = false;
|
|
355
|
+
this.onDomContentLoaded = () => {
|
|
356
|
+
this.waitingForBody = false;
|
|
357
|
+
this.start();
|
|
358
|
+
};
|
|
348
359
|
this.raf = 0;
|
|
349
360
|
this.timer = 0;
|
|
350
361
|
this.disposed = false;
|
|
@@ -372,7 +383,9 @@ var __dshcfBundle = (() => {
|
|
|
372
383
|
/** 插件改写 display 前的精确原值;受控集合用于分类漂移和 stop() 恢复。 */
|
|
373
384
|
this.originalDisplay = /* @__PURE__ */ new WeakMap();
|
|
374
385
|
this.controlledDisplay = /* @__PURE__ */ new Set();
|
|
375
|
-
/**
|
|
386
|
+
/** 元素 → 插件最后确保的 display 值:恢复前与当前内联值比对,漂移即视为
|
|
387
|
+
* 外部介入(镜像 turnStatusTexts 的 original/written 双快照守卫,issue #11)。 */
|
|
388
|
+
this.writtenDisplay = /* @__PURE__ */ new WeakMap();
|
|
376
389
|
/** 被改写为状态提示词的原生状态文本:original = 宿主原文(卸载还原用),
|
|
377
390
|
* written = 插件最后一次写入的值(仅当节点仍等于它时才还原,避免覆盖
|
|
378
391
|
* 宿主在插件写入之后的状态更新)。 */
|
|
@@ -390,14 +403,39 @@ var __dshcfBundle = (() => {
|
|
|
390
403
|
/** segment 点击时只让点击前已存在的 block 播放 reveal;流式中新出现的
|
|
391
404
|
* 临时分裂块直接显示,避免分类收敛时留下半透明 stale chip。 */
|
|
392
405
|
this.animatableSegmentBlocks = /* @__PURE__ */ new Map();
|
|
406
|
+
/** 外部变更对账定时器句柄(自重排 setTimeout 链,见 armAuditLoop)。 */
|
|
407
|
+
this.auditTimer = 0;
|
|
408
|
+
/** 上一轮 pass 记录的关键元素内联 display,用于 audit 轻量检测漂移。
|
|
409
|
+
* audit 只读这份快照,不在页面稳定时重新执行完整 pass。 */
|
|
410
|
+
this.auditDisplays = /* @__PURE__ */ new Map();
|
|
411
|
+
/** 自上次 pass 以来 flow 子树发生过结构变化(childList)或正文判定翻转:
|
|
412
|
+
* 为 true 时 pass 重建分块快照,否则复用 currentBlocks——characterData/
|
|
413
|
+
* attributes 批次(流式文本、data-state 翻转)不改变块结构,跳过全量
|
|
414
|
+
* querySelectorAll 重扫(issue #14:长会话下每轮重扫造成主线程卡顿)。 */
|
|
415
|
+
this.structureDirty = true;
|
|
416
|
+
/** 滚动稳定化(issue #14):flow 最近的滚动容器缓存(按 flow 身份失效)。 */
|
|
417
|
+
this.scrollContainer = null;
|
|
418
|
+
this.scrollContainerFlow = null;
|
|
419
|
+
/** 回到前台立即补一轮对账;后台 tab 由 document.hidden 门控跳过。 */
|
|
420
|
+
this.onVisibilityChange = () => {
|
|
421
|
+
if (typeof document === "undefined" || document.hidden !== true) this.schedule();
|
|
422
|
+
};
|
|
393
423
|
this.statusTextProvider = statusTextProvider2 ?? (() => DEFAULT_STATUS_TEXT);
|
|
424
|
+
this.auditIntervalMs = options?.auditIntervalMs ?? AUDIT_TICK_MS;
|
|
394
425
|
}
|
|
395
426
|
/** 设置变更后重跑一轮,让状态提示词立即生效。 */
|
|
396
427
|
refresh() {
|
|
397
428
|
this.schedule();
|
|
398
429
|
}
|
|
399
430
|
start() {
|
|
400
|
-
if (this.disposed) return;
|
|
431
|
+
if (this.disposed || this.started || this.waitingForBody) return;
|
|
432
|
+
if (typeof document === "undefined") return;
|
|
433
|
+
if (document.body === null) {
|
|
434
|
+
if (typeof document.addEventListener !== "function") return;
|
|
435
|
+
this.waitingForBody = true;
|
|
436
|
+
document.addEventListener("DOMContentLoaded", this.onDomContentLoaded, { once: true });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
401
439
|
injectStyle();
|
|
402
440
|
try {
|
|
403
441
|
this.observer = new MutationObserver((records) => {
|
|
@@ -417,16 +455,86 @@ var __dshcfBundle = (() => {
|
|
|
417
455
|
// 不会自激。
|
|
418
456
|
characterData: true
|
|
419
457
|
});
|
|
458
|
+
this.started = true;
|
|
459
|
+
this.armAuditLoop();
|
|
420
460
|
this.schedule();
|
|
421
461
|
} catch (error) {
|
|
462
|
+
this.observer?.disconnect();
|
|
463
|
+
this.observer = null;
|
|
464
|
+
this.started = false;
|
|
422
465
|
this.reportError(error);
|
|
423
466
|
throw error;
|
|
424
467
|
}
|
|
425
468
|
}
|
|
469
|
+
/** 外部显示变更对账循环(issue #11 Bug B):外部对宿主行的 style 写入不产生
|
|
470
|
+
* observer record(style 不在 attributeFilter 内,监听会因插件自身直写 style
|
|
471
|
+
* 自激),改用低频轻量对账兜底——发现漂移后才由 pass 收敛;
|
|
472
|
+
* 后台 tab 由 document.hidden 门控跳过,回前台由 visibilitychange 立即补一轮。
|
|
473
|
+
* 用自重排 setTimeout 链而非 setInterval:与 schedule 的兜底定时器同源,
|
|
474
|
+
* 测试桩 clearTimers 后链条自然熄灭。 */
|
|
475
|
+
armAuditLoop() {
|
|
476
|
+
if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
|
|
477
|
+
document.addEventListener("visibilitychange", this.onVisibilityChange);
|
|
478
|
+
}
|
|
479
|
+
this.rearmAudit();
|
|
480
|
+
}
|
|
481
|
+
rearmAudit() {
|
|
482
|
+
if (this.disposed || this.auditTimer !== 0) return;
|
|
483
|
+
this.auditTimer = setTimeout(() => {
|
|
484
|
+
this.auditTimer = 0;
|
|
485
|
+
if (this.disposed) return;
|
|
486
|
+
if (typeof document !== "undefined" && document.hidden === true) {
|
|
487
|
+
this.rearmAudit();
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
this.audit();
|
|
491
|
+
this.rearmAudit();
|
|
492
|
+
}, this.auditIntervalMs);
|
|
493
|
+
}
|
|
494
|
+
/** 低成本显示状态对账:只有发现外部漂移时才启动完整 pass。
|
|
495
|
+
*
|
|
496
|
+
* 外部 style.display 写入不会产生当前 observer 的 attribute 记录,
|
|
497
|
+
* 因此仍保留 audit;但稳定页面不应每秒重扫整个 flow。快照只覆盖
|
|
498
|
+
* flow 顶层行、插件控制中的宿主行和插件自有展示行,避免引入布局读取。 */
|
|
499
|
+
audit() {
|
|
500
|
+
if (this.disposed) return;
|
|
501
|
+
const flow = this.flow;
|
|
502
|
+
if (flow === null || !flow.isConnected) {
|
|
503
|
+
if (flow !== null) this.schedule();
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
const current = this.collectAuditDisplays(flow);
|
|
507
|
+
if (current.size !== this.auditDisplays.size) {
|
|
508
|
+
this.schedule();
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
for (const [el, display] of current) {
|
|
512
|
+
if (this.auditDisplays.get(el) !== display) {
|
|
513
|
+
this.schedule();
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
if (this.controlledDisplay.has(el) && this.displayForeign(el)) {
|
|
517
|
+
this.schedule();
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
426
522
|
stop() {
|
|
427
523
|
this.disposed = true;
|
|
524
|
+
this.started = false;
|
|
525
|
+
this.waitingForBody = false;
|
|
526
|
+
if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
|
|
527
|
+
document.removeEventListener("DOMContentLoaded", this.onDomContentLoaded);
|
|
528
|
+
}
|
|
428
529
|
if (this.raf !== 0) cancelAnimationFrame(this.raf);
|
|
429
530
|
if (this.timer !== 0) clearTimeout(this.timer);
|
|
531
|
+
if (this.auditTimer !== 0) {
|
|
532
|
+
clearTimeout(this.auditTimer);
|
|
533
|
+
this.auditTimer = 0;
|
|
534
|
+
}
|
|
535
|
+
if (typeof document !== "undefined" && typeof document.removeEventListener === "function") {
|
|
536
|
+
document.removeEventListener("visibilitychange", this.onVisibilityChange);
|
|
537
|
+
}
|
|
430
538
|
this.observer?.disconnect();
|
|
431
539
|
this.switchFlow(null);
|
|
432
540
|
removeStyle();
|
|
@@ -434,29 +542,50 @@ var __dshcfBundle = (() => {
|
|
|
434
542
|
/** body 级 observer 只负责发现 flow 替换;已有 flow 外的文本变化不再触发全量扫描。 */
|
|
435
543
|
shouldSchedule(records) {
|
|
436
544
|
if (records.length === 0 || this.flow === null || !this.flow.isConnected) return true;
|
|
437
|
-
return records.some((record) =>
|
|
545
|
+
return records.some((record) => this.isRelevantMutation(record));
|
|
546
|
+
}
|
|
547
|
+
/** 判断 mutation 是否会影响宿主 flow;插件自有节点的回写直接忽略,
|
|
548
|
+
* 避免“pass 插入 chip → observer 再开一轮 pass”的自激循环。 */
|
|
549
|
+
isRelevantMutation(record) {
|
|
550
|
+
const flow = this.flow;
|
|
551
|
+
if (flow === null || !flow.isConnected) return true;
|
|
552
|
+
if (!nodeWithin(record.target, flow) && !nodeWithin(flow, record.target)) return false;
|
|
553
|
+
if (record.type === "characterData" && record.target instanceof Text && this.turnStatusTexts.has(record.target)) {
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
const changed = [
|
|
557
|
+
...Array.from(record.addedNodes ?? []),
|
|
558
|
+
...Array.from(record.removedNodes ?? [])
|
|
559
|
+
];
|
|
560
|
+
if (changed.length > 0 && changed.every(isPluginOwnedNode)) return false;
|
|
561
|
+
if (changed.length === 0 && isPluginOwnedNode(record.target)) return false;
|
|
562
|
+
return true;
|
|
438
563
|
}
|
|
439
564
|
/** 记录本批 mutation 命中的 flow 顶层消息,供正文判定缓存定向失效。
|
|
440
|
-
* 从 record.target 沿 parentNode 走到 flow
|
|
441
|
-
*
|
|
442
|
-
*
|
|
565
|
+
* 从 record.target 沿 parentNode 走到 flow 的直接子级即所属消息。
|
|
566
|
+
* 失效粒度(issue #14):只有 childList 使分块快照失效;flow 直挂层的
|
|
567
|
+
* 插件节点/文本节点与 flow 外的混批记录不影响任何消息的正文判定,跳过
|
|
568
|
+
* 而非全量失效——旧逻辑把它们全部放大成 O(全会话) 的 TreeWalker 重扫,
|
|
569
|
+
* 长会话流式期间每帧如此。空批次仍保守全量失效(测试桩的调度通知)。 */
|
|
443
570
|
markDirty(records) {
|
|
444
571
|
const flow = this.flow;
|
|
445
572
|
if (flow === null || !flow.isConnected) return;
|
|
446
573
|
if (records.length === 0) {
|
|
447
574
|
this.bodyTextCache = /* @__PURE__ */ new WeakMap();
|
|
448
575
|
this.dirtyMessages.clear();
|
|
576
|
+
this.structureDirty = true;
|
|
449
577
|
return;
|
|
450
578
|
}
|
|
451
|
-
for (const record of records) {
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
579
|
+
for (const record of records.filter((record2) => this.isRelevantMutation(record2))) {
|
|
580
|
+
if (record.type === "childList") this.structureDirty = true;
|
|
581
|
+
if (record.target === flow) {
|
|
582
|
+
for (const node of record.addedNodes ?? []) {
|
|
583
|
+
if (node instanceof HTMLElement) this.dirtyMessages.add(node);
|
|
584
|
+
}
|
|
585
|
+
continue;
|
|
458
586
|
}
|
|
459
|
-
|
|
587
|
+
const owner = flowChildOwner(record.target, flow);
|
|
588
|
+
if (owner !== null) this.dirtyMessages.add(owner);
|
|
460
589
|
}
|
|
461
590
|
}
|
|
462
591
|
schedule() {
|
|
@@ -510,12 +639,20 @@ var __dshcfBundle = (() => {
|
|
|
510
639
|
if (nextFlow !== this.flow) this.switchFlow(nextFlow);
|
|
511
640
|
const flow = this.flow;
|
|
512
641
|
if (flow === null) return;
|
|
513
|
-
|
|
642
|
+
let bodyFlipped = false;
|
|
643
|
+
for (const el of this.dirtyMessages) {
|
|
644
|
+
const prev = this.bodyTextCache.get(el);
|
|
645
|
+
this.bodyTextCache.delete(el);
|
|
646
|
+
if (prev !== void 0 && this.hasBodyCached(el) !== prev) bodyFlipped = true;
|
|
647
|
+
}
|
|
514
648
|
this.dirtyMessages.clear();
|
|
515
|
-
const
|
|
649
|
+
const rebuildBlocks = this.structureDirty || bodyFlipped || this.currentBlocks.size === 0;
|
|
650
|
+
this.structureDirty = false;
|
|
651
|
+
const blocks = rebuildBlocks ? findBlocks(flow, (el) => this.hasBodyCached(el)) : [...this.currentBlocks.values()];
|
|
516
652
|
this.currentBlocks = new Map(blocks.map((block) => [block.key, block]));
|
|
517
653
|
const segments = buildSegments(flow, blocks, (el) => this.hasBodyCached(el));
|
|
518
654
|
const liveSegmentKeys = new Set(segments.map((segment) => segment.key));
|
|
655
|
+
const scrollAnchor = this.captureScrollAnchor(flow);
|
|
519
656
|
for (const segment of segments) {
|
|
520
657
|
if (!segment.running) continue;
|
|
521
658
|
if (this.completedOnce.has(segment.key)) {
|
|
@@ -584,6 +721,7 @@ var __dshcfBundle = (() => {
|
|
|
584
721
|
this.cleanupStaleChips(seenBlocks);
|
|
585
722
|
this.restoreUnusedDisplays(desiredHidden);
|
|
586
723
|
for (const state of this.segmentStates.values()) this.placeProcessedRow(flow, state);
|
|
724
|
+
this.stabilizeScrollAfterFold(scrollAnchor);
|
|
587
725
|
for (const key of [...this.runningSince.keys()]) {
|
|
588
726
|
if (!liveSegmentKeys.has(key)) this.runningSince.delete(key);
|
|
589
727
|
}
|
|
@@ -602,6 +740,51 @@ var __dshcfBundle = (() => {
|
|
|
602
740
|
} else {
|
|
603
741
|
replaceTurnStatus(flow, this.turnStatusTexts, statusText);
|
|
604
742
|
}
|
|
743
|
+
this.captureAuditDisplays(flow);
|
|
744
|
+
}
|
|
745
|
+
/** flow 最近的滚动容器(issue #14 滚动稳定化的测量基准):沿父链找第一个
|
|
746
|
+
* overflow-y 为 auto/scroll 且实际可滚动的祖先。结果按 flow 身份缓存;
|
|
747
|
+
* 找不到时不缓存(内容增长后祖先可能变为可滚动),每 pass 重探的代价
|
|
748
|
+
* 只是几次 clean-layout 的 computed style / scrollHeight 读取。 */
|
|
749
|
+
findScrollContainer(flow) {
|
|
750
|
+
if (this.scrollContainerFlow === flow && this.scrollContainer !== null && this.scrollContainer.isConnected) {
|
|
751
|
+
return this.scrollContainer;
|
|
752
|
+
}
|
|
753
|
+
let node = flow.parentElement;
|
|
754
|
+
while (node !== null) {
|
|
755
|
+
if (typeof getComputedStyle === "function") {
|
|
756
|
+
const oy = getComputedStyle(node).overflowY;
|
|
757
|
+
if ((oy === "auto" || oy === "scroll") && node.scrollHeight > node.clientHeight) break;
|
|
758
|
+
}
|
|
759
|
+
node = node.parentElement;
|
|
760
|
+
}
|
|
761
|
+
if (node === null) return null;
|
|
762
|
+
this.scrollContainer = node;
|
|
763
|
+
this.scrollContainerFlow = flow;
|
|
764
|
+
return node;
|
|
765
|
+
}
|
|
766
|
+
/** 几何写入前捕捉贴底意图(issue #14):视口距底 ≤ 上游 FOLLOW_THRESHOLD
|
|
767
|
+
* 时返回锚点,stabilizeScrollAfterFold 在写入后同帧钉回底部。远离底部
|
|
768
|
+
* (用户正在滚动浏览)时不干预——视口上方的高度变化由浏览器 scroll
|
|
769
|
+
* anchoring 补偿,视口下方的折叠不可见,插件再写 scrollTop 只会加入
|
|
770
|
+
* 上游吸底回写的拉锯。 */
|
|
771
|
+
captureScrollAnchor(flow) {
|
|
772
|
+
const scroller = this.findScrollContainer(flow);
|
|
773
|
+
if (scroller === null) return null;
|
|
774
|
+
const dist = scroller.scrollHeight - scroller.scrollTop - scroller.clientHeight;
|
|
775
|
+
return dist <= STICK_BOTTOM_THRESHOLD_PX ? { el: scroller } : null;
|
|
776
|
+
}
|
|
777
|
+
/** 几何写入后把贴底视口钉回底部:折叠让 scrollHeight 缩小时若不在此帧
|
|
778
|
+
* 补写 scrollTop,宿主 ChatView 要到下一帧 ResizeObserver 才吸底回写,
|
|
779
|
+
* 中间的空档让触控板惯性滚动乘虚而入,反复折叠时表现为上下抽搐(用户
|
|
780
|
+
* 实测:长会话滚到底部附近无法稳定定位)。同一帧内钉回后,宿主的吸底
|
|
781
|
+
* 回写成为幂等 no-op,不再是第二个 scrollTop 写入方。 */
|
|
782
|
+
stabilizeScrollAfterFold(anchor) {
|
|
783
|
+
if (anchor === null) return;
|
|
784
|
+
const el = anchor.el;
|
|
785
|
+
if (!el.isConnected) return;
|
|
786
|
+
const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
787
|
+
if (dist > STICK_BOTTOM_THRESHOLD_PX) el.scrollTop = el.scrollHeight - el.clientHeight;
|
|
605
788
|
}
|
|
606
789
|
/** flow 元素变化即视为会话切换:完整恢复旧 flow,再从新 DOM 重建。 */
|
|
607
790
|
switchFlow(next) {
|
|
@@ -624,6 +807,8 @@ var __dshcfBundle = (() => {
|
|
|
624
807
|
this.completedOnce.clear();
|
|
625
808
|
this.bodyTextCache = /* @__PURE__ */ new WeakMap();
|
|
626
809
|
this.dirtyMessages.clear();
|
|
810
|
+
this.auditDisplays.clear();
|
|
811
|
+
this.structureDirty = true;
|
|
627
812
|
this.restoreAllDisplays();
|
|
628
813
|
restoreTurnStatus(this.turnStatusTexts);
|
|
629
814
|
this.flow = next;
|
|
@@ -1062,6 +1247,22 @@ var __dshcfBundle = (() => {
|
|
|
1062
1247
|
unpinChipMargin(chip) {
|
|
1063
1248
|
if (chip.style.marginBottom !== "") chip.style.marginBottom = "";
|
|
1064
1249
|
}
|
|
1250
|
+
/** 外部介入检测(issue #11 Bug A):当前内联值 ≠ 插件最后确保值,或所有权
|
|
1251
|
+
* 哨兵被 style 整体改写抹除。返回 true 时调用方放弃本次写回并交还账本——
|
|
1252
|
+
* 属性级改写由值比对捕获,整体改写(cssText / setAttribute('style'))由
|
|
1253
|
+
* 哨兵缺失捕获,两层合起来覆盖外部介入的两种形态。 */
|
|
1254
|
+
displayForeign(el) {
|
|
1255
|
+
const written = this.writtenDisplay.get(el);
|
|
1256
|
+
if (written === void 0) return false;
|
|
1257
|
+
return el.style.getPropertyValue(DISPLAY_OWNED_PROP) === "" || el.style.display !== written;
|
|
1258
|
+
}
|
|
1259
|
+
/** 清空单个元素的显示账本(三账本 + 所有权哨兵)。 */
|
|
1260
|
+
releaseDisplayLedger(el) {
|
|
1261
|
+
this.originalDisplay.delete(el);
|
|
1262
|
+
this.writtenDisplay.delete(el);
|
|
1263
|
+
this.controlledDisplay.delete(el);
|
|
1264
|
+
el.style.removeProperty(DISPLAY_OWNED_PROP);
|
|
1265
|
+
}
|
|
1065
1266
|
/** 返回 true 表示启动了渐隐动画(调用方可据此决定内部元素的处置)。
|
|
1066
1267
|
* settle 在渐隐自然结束时调用(onfinish 链;反向取消不触发)。 */
|
|
1067
1268
|
hideElement(el, desired, animate = false, settle) {
|
|
@@ -1073,7 +1274,11 @@ var __dshcfBundle = (() => {
|
|
|
1073
1274
|
}
|
|
1074
1275
|
if (!this.originalDisplay.has(el) && !isDisplayed(el)) return false;
|
|
1075
1276
|
if (this.hasAnimatingAncestor(el)) return false;
|
|
1076
|
-
if (!this.originalDisplay.has(el)
|
|
1277
|
+
if (!this.originalDisplay.has(el) || this.displayForeign(el)) {
|
|
1278
|
+
this.originalDisplay.set(el, el.style.display);
|
|
1279
|
+
this.writtenDisplay.set(el, el.style.display);
|
|
1280
|
+
el.style.setProperty(DISPLAY_OWNED_PROP, "1");
|
|
1281
|
+
}
|
|
1077
1282
|
this.controlledDisplay.add(el);
|
|
1078
1283
|
if (el.style.display === "none") return false;
|
|
1079
1284
|
if (animate && this.canAnimate(el)) {
|
|
@@ -1081,6 +1286,7 @@ var __dshcfBundle = (() => {
|
|
|
1081
1286
|
return true;
|
|
1082
1287
|
}
|
|
1083
1288
|
el.style.display = "none";
|
|
1289
|
+
this.writtenDisplay.set(el, "none");
|
|
1084
1290
|
return false;
|
|
1085
1291
|
}
|
|
1086
1292
|
restoreElement(el, animate = false) {
|
|
@@ -1090,14 +1296,18 @@ var __dshcfBundle = (() => {
|
|
|
1090
1296
|
this.cancelPendingSync(el);
|
|
1091
1297
|
}
|
|
1092
1298
|
if (!this.originalDisplay.has(el)) return;
|
|
1299
|
+
if (this.displayForeign(el)) {
|
|
1300
|
+
this.releaseDisplayLedger(el);
|
|
1301
|
+
return;
|
|
1302
|
+
}
|
|
1093
1303
|
const original = this.originalDisplay.get(el);
|
|
1094
1304
|
if (!animate || !this.canAnimate(el) || this.hasAnimatingAncestor(el)) {
|
|
1095
1305
|
if (el.style.display !== original) el.style.display = original;
|
|
1096
|
-
this.
|
|
1097
|
-
this.controlledDisplay.delete(el);
|
|
1306
|
+
this.releaseDisplayLedger(el);
|
|
1098
1307
|
return;
|
|
1099
1308
|
}
|
|
1100
1309
|
if (el.style.display !== original) el.style.display = original;
|
|
1310
|
+
this.writtenDisplay.set(el, original);
|
|
1101
1311
|
this.startReveal(el);
|
|
1102
1312
|
}
|
|
1103
1313
|
/** 是否可动画:WAAPI 特性检测 + reduced-motion 门控(均做 typeof 防桩缺失)。 */
|
|
@@ -1122,8 +1332,7 @@ var __dshcfBundle = (() => {
|
|
|
1122
1332
|
anim.onfinish = () => {
|
|
1123
1333
|
if (this.pendingAnims.get(el) !== record) return;
|
|
1124
1334
|
this.pendingAnims.delete(el);
|
|
1125
|
-
this.
|
|
1126
|
-
this.controlledDisplay.delete(el);
|
|
1335
|
+
this.releaseDisplayLedger(el);
|
|
1127
1336
|
this.schedule();
|
|
1128
1337
|
};
|
|
1129
1338
|
anim.oncancel = () => {
|
|
@@ -1174,7 +1383,14 @@ var __dshcfBundle = (() => {
|
|
|
1174
1383
|
anim.onfinish = () => {
|
|
1175
1384
|
if (this.pendingAnims.get(el) !== record) return;
|
|
1176
1385
|
this.pendingAnims.delete(el);
|
|
1386
|
+
if (this.displayForeign(el)) {
|
|
1387
|
+
this.releaseDisplayLedger(el);
|
|
1388
|
+
this.schedule();
|
|
1389
|
+
anim.cancel();
|
|
1390
|
+
return;
|
|
1391
|
+
}
|
|
1177
1392
|
if (el.style.display !== "none") el.style.display = "none";
|
|
1393
|
+
this.writtenDisplay.set(el, "none");
|
|
1178
1394
|
settle?.();
|
|
1179
1395
|
anim.cancel();
|
|
1180
1396
|
this.schedule();
|
|
@@ -1204,10 +1420,31 @@ var __dshcfBundle = (() => {
|
|
|
1204
1420
|
if (!desired.has(el)) this.restoreElement(el);
|
|
1205
1421
|
}
|
|
1206
1422
|
}
|
|
1423
|
+
/** 收集 audit 需要观察的 display 集合;只读取内联样式,不触发布局计算。 */
|
|
1424
|
+
collectAuditDisplays(flow) {
|
|
1425
|
+
const nodes = new Set(flowItems(flow));
|
|
1426
|
+
for (const el of this.controlledDisplay) {
|
|
1427
|
+
if (el.isConnected && nodeWithin(el, flow)) nodes.add(el);
|
|
1428
|
+
}
|
|
1429
|
+
for (const { chip } of this.chips.values()) {
|
|
1430
|
+
if (chip.isConnected && nodeWithin(chip, flow)) nodes.add(chip);
|
|
1431
|
+
}
|
|
1432
|
+
for (const row of this.mergedThinks.values()) {
|
|
1433
|
+
if (row.isConnected && nodeWithin(row, flow)) nodes.add(row);
|
|
1434
|
+
}
|
|
1435
|
+
const displays = /* @__PURE__ */ new Map();
|
|
1436
|
+
for (const el of nodes) displays.set(el, el.style.display);
|
|
1437
|
+
return displays;
|
|
1438
|
+
}
|
|
1439
|
+
/** 在完整 pass 完成后保存 display 基线,供下一次轻量 audit 比对。 */
|
|
1440
|
+
captureAuditDisplays(flow) {
|
|
1441
|
+
this.auditDisplays = this.collectAuditDisplays(flow);
|
|
1442
|
+
}
|
|
1207
1443
|
restoreAllDisplays() {
|
|
1208
1444
|
for (const el of [...this.controlledDisplay]) this.restoreElement(el);
|
|
1209
1445
|
this.controlledDisplay.clear();
|
|
1210
1446
|
this.originalDisplay = /* @__PURE__ */ new WeakMap();
|
|
1447
|
+
this.writtenDisplay = /* @__PURE__ */ new WeakMap();
|
|
1211
1448
|
}
|
|
1212
1449
|
};
|
|
1213
1450
|
function createSpan(cls) {
|
|
@@ -1410,6 +1647,15 @@ var __dshcfBundle = (() => {
|
|
|
1410
1647
|
}
|
|
1411
1648
|
return false;
|
|
1412
1649
|
}
|
|
1650
|
+
function isPluginOwnedNode(node) {
|
|
1651
|
+
const element = node.nodeType === 1 ? node : node.parentElement;
|
|
1652
|
+
return element !== null && element !== void 0 && element.closest(".dshcf-chip, .dshcf-processed, .dshcf-merged-think, .dshcf-merged-body") !== null;
|
|
1653
|
+
}
|
|
1654
|
+
function flowChildOwner(node, flow) {
|
|
1655
|
+
let current = node;
|
|
1656
|
+
while (current !== null && current.parentNode !== flow) current = current.parentNode;
|
|
1657
|
+
return current instanceof HTMLElement ? current : null;
|
|
1658
|
+
}
|
|
1413
1659
|
function flowItems(flow) {
|
|
1414
1660
|
return [...flow.children].filter((el) => el instanceof HTMLElement && !el.classList.contains("dshcf-processed") && !el.classList.contains("dshcf-flow-chip"));
|
|
1415
1661
|
}
|
|
@@ -1799,8 +2045,16 @@ var __dshcfBundle = (() => {
|
|
|
1799
2045
|
function isThinkRow(row) {
|
|
1800
2046
|
return row.matches('[data-variant="think"]') && !row.hasAttribute("data-tool");
|
|
1801
2047
|
}
|
|
2048
|
+
var turnDurationCache = /* @__PURE__ */ new WeakMap();
|
|
1802
2049
|
function parseTurnDuration(boundary) {
|
|
1803
2050
|
const text = boundary.textContent ?? "";
|
|
2051
|
+
const cached = turnDurationCache.get(boundary);
|
|
2052
|
+
if (cached !== void 0 && cached.text === text) return cached.duration;
|
|
2053
|
+
const duration = parseTurnDurationText(boundary, text);
|
|
2054
|
+
turnDurationCache.set(boundary, { text, duration });
|
|
2055
|
+
return duration;
|
|
2056
|
+
}
|
|
2057
|
+
function parseTurnDurationText(boundary, text) {
|
|
1804
2058
|
const m = text.match(/用时\s*(\d+)分(\d+)秒|用时\s*(\d+)秒/);
|
|
1805
2059
|
if (m !== null) {
|
|
1806
2060
|
if (m[1] !== void 0 && m[2] !== void 0) return Number(m[1]) * 6e4 + Number(m[2]) * 1e3;
|
|
@@ -2132,14 +2386,16 @@ var __dshcfBundle = (() => {
|
|
|
2132
2386
|
|
|
2133
2387
|
// src/client.ts
|
|
2134
2388
|
var name = "dsh-auto-collapse";
|
|
2135
|
-
var inject = [
|
|
2389
|
+
var inject = [];
|
|
2136
2390
|
function apply(ctx) {
|
|
2137
2391
|
ctx.effect(() => {
|
|
2138
|
-
const
|
|
2392
|
+
const settingsScope = ctx.get?.("settingsScope");
|
|
2393
|
+
const slots = ctx.get?.("slots");
|
|
2394
|
+
const scope = settingsScope?.bind({ namespace: AUTO_COLLAPSE_NS });
|
|
2139
2395
|
const controller = new FoldController(statusTextProvider(scope));
|
|
2140
2396
|
controller.start();
|
|
2141
2397
|
const offScope = scope?.subscribe(() => controller.refresh());
|
|
2142
|
-
const offSettings =
|
|
2398
|
+
const offSettings = slots === void 0 || scope === void 0 ? void 0 : setupSettingsCard({ slots }, scope);
|
|
2143
2399
|
return () => {
|
|
2144
2400
|
offScope?.();
|
|
2145
2401
|
offSettings?.();
|
package/lib/index.js
CHANGED
|
@@ -1,32 +1,27 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
onChange: () => {
|
|
29
|
-
void current
|
|
30
|
-
},
|
|
31
|
-
})
|
|
32
|
-
}
|
|
1
|
+
import z from "@deepseek-ai/schemastery";
|
|
2
|
+
import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
|
|
3
|
+
const name = "dsh-auto-collapse";
|
|
4
|
+
const inject = [];
|
|
5
|
+
const DEFAULT_STATUS_TEXT = "Deep sleeping...";
|
|
6
|
+
const AUTO_COLLAPSE_SETTINGS_NAMESPACE = settingsNamespace("dsh-auto-collapse");
|
|
7
|
+
const AUTO_COLLAPSE_SETTINGS_SCHEMA = z.object({
|
|
8
|
+
statusText: z.string().default(DEFAULT_STATUS_TEXT)
|
|
9
|
+
});
|
|
10
|
+
function apply(ctx, config = {}) {
|
|
11
|
+
let current = () => ({ statusText: config.statusText ?? DEFAULT_STATUS_TEXT });
|
|
12
|
+
installSettingsSection(ctx, AUTO_COLLAPSE_SETTINGS_NAMESPACE, AUTO_COLLAPSE_SETTINGS_SCHEMA, {
|
|
13
|
+
statusText: config.statusText ?? DEFAULT_STATUS_TEXT
|
|
14
|
+
}, {
|
|
15
|
+
setSource: (source) => {
|
|
16
|
+
current = source;
|
|
17
|
+
},
|
|
18
|
+
onChange: () => {
|
|
19
|
+
void current;
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
export {
|
|
24
|
+
apply,
|
|
25
|
+
inject,
|
|
26
|
+
name
|
|
27
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const name = "dsh-auto-collapse";
|
|
2
|
+
/**
|
|
3
|
+
* 不声明必需服务:slots/settingsScope 只是可选增强能力,不能阻止核心折叠
|
|
4
|
+
* 插件启动。运行时通过 ctx.get() 读取,服务不存在时仅跳过设置卡片与配置订阅。
|
|
5
|
+
*/
|
|
6
|
+
export declare const inject: string[];
|
|
7
|
+
/** 客户端根上下文的最小结构化类型(仅用 cordis 标准 effect,无运行时依赖)。 */
|
|
8
|
+
export interface FoldClientCtx {
|
|
9
|
+
effect(fn: () => unknown, label?: string): unknown;
|
|
10
|
+
/** Cordis 动态客户端上下文提供的可选服务查询。 */
|
|
11
|
+
get?<T>(name: string): T | undefined;
|
|
12
|
+
}
|
|
13
|
+
export declare function apply(ctx: FoldClientCtx): void;
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FoldController —— dsh-auto-collapse 的核心。
|
|
3
|
+
*
|
|
4
|
+
* 把会话流([data-chat-flow])里的“非正文 display”折叠成内联的一行,
|
|
5
|
+
* 折叠行实时显示**当前正在进行的工作**(与 Codex 对齐):
|
|
6
|
+
*
|
|
7
|
+
* - 块里有运行中的工具调用 → 标题 = "Running" + 工具名(Bash/Read/
|
|
8
|
+
* Search…,读 data-tool),摘要 = 正在执行的命令/路径/查询(读工具
|
|
9
|
+
* 卡片的 summary 行);标题与摘要带平滑呼吸动画(Pulse)。
|
|
10
|
+
* - 块里正在思考(think running)→ 标题 = "Thinking",摘要 = 思考的
|
|
11
|
+
* 最新一行(读 [data-follow-end],官方 ReasoningRow 的实时摘要锚点)。
|
|
12
|
+
* - 全部完成 → 标题 = 类型总结(编辑了文件 / 运行了命令 / 已思考 /
|
|
13
|
+
* 上下文注入),摘要清空;出错 → 红色,中断 → 琥珀。
|
|
14
|
+
*
|
|
15
|
+
* 另外把官方 ChatView 尾部的运行状态行文字 "Deep diving..." 替换为
|
|
16
|
+
* 可配置的状态提示词(默认 "Deep sleeping...";流光特效在 CSS 上,
|
|
17
|
+
* 替换文本节点不影响)。React 重渲染会恢复原文,pass() 每轮自愈改回。
|
|
18
|
+
* 设置为空时不替换,等价于恢复官方 "Deep diving..."。
|
|
19
|
+
*
|
|
20
|
+
* 点击一行展开,再点收起;折叠态下若有行被选中(详情联动)自动展开。
|
|
21
|
+
*
|
|
22
|
+
* 折叠规则(沿用 dsh-web-archive 验证过的算法):每个回合合成一块——
|
|
23
|
+
* 某条消息的 Think 推理组与其后紧跟的工具组合并成一块(只有 think 或
|
|
24
|
+
* 只有工具组时各自成块),在块宿主**原位**插入 chip;带正文文本的消息
|
|
25
|
+
* 断开合并。结构保持 文本a - [折叠块] - 文本b - 文本c。
|
|
26
|
+
*
|
|
27
|
+
* 与 React 的关系:chip 插入 React 管理的 flow 子树内,但只做前置插入与
|
|
28
|
+
* style.display 切换(React 的 vdom diff 不会感知也不会清除 CSSOM 上的
|
|
29
|
+
* 手动样式);MutationObserver 每轮把结构变化合并到一次
|
|
30
|
+
* requestAnimationFrame 里重放(自愈:React 重渲染、切换会话、流式新
|
|
31
|
+
* 卡片都会自动跟上)。
|
|
32
|
+
*
|
|
33
|
+
* 零核心改动:不修改任何 slot 注册,不依赖任何 client 服务。
|
|
34
|
+
*/
|
|
35
|
+
export declare class FoldController {
|
|
36
|
+
private observer;
|
|
37
|
+
/** 防止同一个控制器重复注册 observer、定时器和可见性监听。 */
|
|
38
|
+
private started;
|
|
39
|
+
/** body 尚未创建时等待 DOMContentLoaded,再补一次启动。 */
|
|
40
|
+
private waitingForBody;
|
|
41
|
+
private readonly onDomContentLoaded;
|
|
42
|
+
private raf;
|
|
43
|
+
private timer;
|
|
44
|
+
private disposed;
|
|
45
|
+
private lastPassError;
|
|
46
|
+
private flow;
|
|
47
|
+
/** 稳定 block key → 当前 React 渲染中的 chip/host。 */
|
|
48
|
+
private chips;
|
|
49
|
+
private currentBlocks;
|
|
50
|
+
private blockExpanded;
|
|
51
|
+
/** host → 三级合并思考行(展开二级后连续思考合并显示为一个三级行)。 */
|
|
52
|
+
private mergedThinks;
|
|
53
|
+
/** 合并思考行的展开状态(true = 显示合并内容块)。 */
|
|
54
|
+
private mergedExpanded;
|
|
55
|
+
/** 合并内容缓存(首次从原生行读取后保存,pass 重建内容块时不再重新展开原生行)。 */
|
|
56
|
+
private mergedBodyTexts;
|
|
57
|
+
/** 合并行标题缓存(原生行展开态提取不到摘要时保持首次标题,不丢成“思考”)。 */
|
|
58
|
+
private mergedTitles;
|
|
59
|
+
/** 稳定 segment key → 一级折叠行与展开状态。 */
|
|
60
|
+
private segmentStates;
|
|
61
|
+
/** segment 首次观察到 running 的时间,用于没有官方时长的实时回合。 */
|
|
62
|
+
private runningSince;
|
|
63
|
+
/** 曾完成过的 segment key:段恢复运行时据此重开本地计时,防止重新结算
|
|
64
|
+
* 的本地时长吞掉完成间隙。 */
|
|
65
|
+
private completedOnce;
|
|
66
|
+
/** 插件改写 display 前的精确原值;受控集合用于分类漂移和 stop() 恢复。 */
|
|
67
|
+
private originalDisplay;
|
|
68
|
+
private controlledDisplay;
|
|
69
|
+
/** 元素 → 插件最后确保的 display 值:恢复前与当前内联值比对,漂移即视为
|
|
70
|
+
* 外部介入(镜像 turnStatusTexts 的 original/written 双快照守卫,issue #11)。 */
|
|
71
|
+
private writtenDisplay;
|
|
72
|
+
/** 被改写为状态提示词的原生状态文本:original = 宿主原文(卸载还原用),
|
|
73
|
+
* written = 插件最后一次写入的值(仅当节点仍等于它时才还原,避免覆盖
|
|
74
|
+
* 宿主在插件写入之后的状态更新)。 */
|
|
75
|
+
private turnStatusTexts;
|
|
76
|
+
/** 当前状态提示词读取器;返回空串时插件不替换状态行。 */
|
|
77
|
+
private statusTextProvider;
|
|
78
|
+
/** 正文判定缓存(消息元素 → 有无正文):流式期间只有被 mutation 命中的
|
|
79
|
+
* 消息失效重算,历史消息跨 pass 复用,避免每帧全量 TreeWalker。 */
|
|
80
|
+
private bodyTextCache;
|
|
81
|
+
/** 自上次 pass 以来子树发生变化的 flow 顶层消息;pass 开头统一失效。 */
|
|
82
|
+
private dirtyMessages;
|
|
83
|
+
/** 在途显示动画(元素 → 记录):冲突仲裁、记账对齐与生命周期清理的依据。
|
|
84
|
+
* 用 Map 不用 WeakMap——switchFlow/stop 需要遍历全量 cancel。 */
|
|
85
|
+
private pendingAnims;
|
|
86
|
+
/** 手势点击的一次性可动画 block key;segment 级点击另保留中间正文的门控。 */
|
|
87
|
+
private animatableKeys;
|
|
88
|
+
/** segment 点击时只让点击前已存在的 block 播放 reveal;流式中新出现的
|
|
89
|
+
* 临时分裂块直接显示,避免分类收敛时留下半透明 stale chip。 */
|
|
90
|
+
private animatableSegmentBlocks;
|
|
91
|
+
/** 外部变更对账定时器句柄(自重排 setTimeout 链,见 armAuditLoop)。 */
|
|
92
|
+
private auditTimer;
|
|
93
|
+
/** 上一轮 pass 记录的关键元素内联 display,用于 audit 轻量检测漂移。
|
|
94
|
+
* audit 只读这份快照,不在页面稳定时重新执行完整 pass。 */
|
|
95
|
+
private auditDisplays;
|
|
96
|
+
/** 自上次 pass 以来 flow 子树发生过结构变化(childList)或正文判定翻转:
|
|
97
|
+
* 为 true 时 pass 重建分块快照,否则复用 currentBlocks——characterData/
|
|
98
|
+
* attributes 批次(流式文本、data-state 翻转)不改变块结构,跳过全量
|
|
99
|
+
* querySelectorAll 重扫(issue #14:长会话下每轮重扫造成主线程卡顿)。 */
|
|
100
|
+
private structureDirty;
|
|
101
|
+
/** 滚动稳定化(issue #14):flow 最近的滚动容器缓存(按 flow 身份失效)。 */
|
|
102
|
+
private scrollContainer;
|
|
103
|
+
private scrollContainerFlow;
|
|
104
|
+
/** 回到前台立即补一轮对账;后台 tab 由 document.hidden 门控跳过。 */
|
|
105
|
+
private readonly onVisibilityChange;
|
|
106
|
+
constructor(statusTextProvider?: () => string | undefined, options?: {
|
|
107
|
+
auditIntervalMs?: number;
|
|
108
|
+
});
|
|
109
|
+
private readonly auditIntervalMs;
|
|
110
|
+
/** 设置变更后重跑一轮,让状态提示词立即生效。 */
|
|
111
|
+
refresh(): void;
|
|
112
|
+
start(): void;
|
|
113
|
+
/** 外部显示变更对账循环(issue #11 Bug B):外部对宿主行的 style 写入不产生
|
|
114
|
+
* observer record(style 不在 attributeFilter 内,监听会因插件自身直写 style
|
|
115
|
+
* 自激),改用低频轻量对账兜底——发现漂移后才由 pass 收敛;
|
|
116
|
+
* 后台 tab 由 document.hidden 门控跳过,回前台由 visibilitychange 立即补一轮。
|
|
117
|
+
* 用自重排 setTimeout 链而非 setInterval:与 schedule 的兜底定时器同源,
|
|
118
|
+
* 测试桩 clearTimers 后链条自然熄灭。 */
|
|
119
|
+
private armAuditLoop;
|
|
120
|
+
private rearmAudit;
|
|
121
|
+
/** 低成本显示状态对账:只有发现外部漂移时才启动完整 pass。
|
|
122
|
+
*
|
|
123
|
+
* 外部 style.display 写入不会产生当前 observer 的 attribute 记录,
|
|
124
|
+
* 因此仍保留 audit;但稳定页面不应每秒重扫整个 flow。快照只覆盖
|
|
125
|
+
* flow 顶层行、插件控制中的宿主行和插件自有展示行,避免引入布局读取。 */
|
|
126
|
+
private audit;
|
|
127
|
+
stop(): void;
|
|
128
|
+
/** body 级 observer 只负责发现 flow 替换;已有 flow 外的文本变化不再触发全量扫描。 */
|
|
129
|
+
private shouldSchedule;
|
|
130
|
+
/** 判断 mutation 是否会影响宿主 flow;插件自有节点的回写直接忽略,
|
|
131
|
+
* 避免“pass 插入 chip → observer 再开一轮 pass”的自激循环。 */
|
|
132
|
+
private isRelevantMutation;
|
|
133
|
+
/** 记录本批 mutation 命中的 flow 顶层消息,供正文判定缓存定向失效。
|
|
134
|
+
* 从 record.target 沿 parentNode 走到 flow 的直接子级即所属消息。
|
|
135
|
+
* 失效粒度(issue #14):只有 childList 使分块快照失效;flow 直挂层的
|
|
136
|
+
* 插件节点/文本节点与 flow 外的混批记录不影响任何消息的正文判定,跳过
|
|
137
|
+
* 而非全量失效——旧逻辑把它们全部放大成 O(全会话) 的 TreeWalker 重扫,
|
|
138
|
+
* 长会话流式期间每帧如此。空批次仍保守全量失效(测试桩的调度通知)。 */
|
|
139
|
+
private markDirty;
|
|
140
|
+
private schedule;
|
|
141
|
+
/** 异步 observer 异常不能静默杀死协调器;保留非可视诊断并允许后续 mutation 重试。 */
|
|
142
|
+
private runPass;
|
|
143
|
+
private reportError;
|
|
144
|
+
/** 一轮重放:重算堆积 → 应用折叠/展开 → 摆放并更新 chip → 替换状态行。 */
|
|
145
|
+
private pass;
|
|
146
|
+
/** flow 最近的滚动容器(issue #14 滚动稳定化的测量基准):沿父链找第一个
|
|
147
|
+
* overflow-y 为 auto/scroll 且实际可滚动的祖先。结果按 flow 身份缓存;
|
|
148
|
+
* 找不到时不缓存(内容增长后祖先可能变为可滚动),每 pass 重探的代价
|
|
149
|
+
* 只是几次 clean-layout 的 computed style / scrollHeight 读取。 */
|
|
150
|
+
private findScrollContainer;
|
|
151
|
+
/** 几何写入前捕捉贴底意图(issue #14):视口距底 ≤ 上游 FOLLOW_THRESHOLD
|
|
152
|
+
* 时返回锚点,stabilizeScrollAfterFold 在写入后同帧钉回底部。远离底部
|
|
153
|
+
* (用户正在滚动浏览)时不干预——视口上方的高度变化由浏览器 scroll
|
|
154
|
+
* anchoring 补偿,视口下方的折叠不可见,插件再写 scrollTop 只会加入
|
|
155
|
+
* 上游吸底回写的拉锯。 */
|
|
156
|
+
private captureScrollAnchor;
|
|
157
|
+
/** 几何写入后把贴底视口钉回底部:折叠让 scrollHeight 缩小时若不在此帧
|
|
158
|
+
* 补写 scrollTop,宿主 ChatView 要到下一帧 ResizeObserver 才吸底回写,
|
|
159
|
+
* 中间的空档让触控板惯性滚动乘虚而入,反复折叠时表现为上下抽搐(用户
|
|
160
|
+
* 实测:长会话滚到底部附近无法稳定定位)。同一帧内钉回后,宿主的吸底
|
|
161
|
+
* 回写成为幂等 no-op,不再是第二个 scrollTop 写入方。 */
|
|
162
|
+
private stabilizeScrollAfterFold;
|
|
163
|
+
/** flow 元素变化即视为会话切换:完整恢复旧 flow,再从新 DOM 重建。 */
|
|
164
|
+
private switchFlow;
|
|
165
|
+
private createProcessedRow;
|
|
166
|
+
private syncProcessedRow;
|
|
167
|
+
private placeProcessedRow;
|
|
168
|
+
private reconcileBlock;
|
|
169
|
+
private ensureChip;
|
|
170
|
+
private suppressBlock;
|
|
171
|
+
private retainDisplayControl;
|
|
172
|
+
private cleanupStaleChips;
|
|
173
|
+
/** 连续思考合并行:插在第一个思考行前,标题用第一行思考内容;
|
|
174
|
+
* 点击切换显示/隐藏全部原始思考行。 */
|
|
175
|
+
private syncMergedThink;
|
|
176
|
+
/** 展开合并行:直接读各思考行文本合成内容块(不依赖原生行展开:
|
|
177
|
+
* 程序化 click 不触发 React 展开,且后台 tab 的 rAF 不执行)。
|
|
178
|
+
* 返回是否成功——思考行已不可读(parts 为空)时返回 false,调用方
|
|
179
|
+
* 据此保持收起态,避免展开状态与内容块脱节。 */
|
|
180
|
+
private expandMergedBody;
|
|
181
|
+
/** 创建/更新合并内容块(缓存优先,不重新展开原生行)。
|
|
182
|
+
* 返回内容块与其是否为本次新建(新建才走展开动画)。 */
|
|
183
|
+
private ensureMergedBody;
|
|
184
|
+
/** 清理合并 think 行(v12):状态 map 立即清除;DOM 在手势动画路径下
|
|
185
|
+
* 渐隐后移除(settle 回调),其余路径瞬删。渐隐中途被反向取消时元素
|
|
186
|
+
* 保留,由后续 pass 的 syncMergedThink 重建/复用。
|
|
187
|
+
* settle 透传给每个渐隐目标的移除回调之后(chip 间距钉住的结算探测点,
|
|
188
|
+
* AI 评审 P0:merged 行渐隐不走 block.rows,必须纳入同一钉住体系)。 */
|
|
189
|
+
private releaseMergedThink;
|
|
190
|
+
/** merged-body 展开高度动画(机制样板:插件全资 DOM)。
|
|
191
|
+
* 关键帧含 marginBottom 0→16px——其 CSS 有常量 margin-bottom:16px,
|
|
192
|
+
* 高度从 0 起步时这 16px 会先占位产生小跳变。fill:'forwards' 托住终态,
|
|
193
|
+
* onfinish 清内联后 cancel 释放,无闪烁窗口。收起由 collapseMergedBody
|
|
194
|
+
* 做镜像高度卷下(同款账本与身份守卫),开合对称。 */
|
|
195
|
+
private revealMergedBody;
|
|
196
|
+
/** 收起合并行:内容块高度卷下后移除——镜像 revealMergedBody 的唯一几何动画,
|
|
197
|
+
* 开合对称。插件全资静态文本 DOM、无 React 协调竞争,可安全做几何收起
|
|
198
|
+
* (与 seat 级拒绝盲卷的场景不同:那里是 React 混杂多卡片)。
|
|
199
|
+
* reduced-motion / 无 WAAPI / 零高度降级为同步 remove()。 */
|
|
200
|
+
private collapseMergedBody;
|
|
201
|
+
/** 当前宿主内的思考行(现取,React 重渲染后引用仍然有效)。 */
|
|
202
|
+
private currentThinkRows;
|
|
203
|
+
/** 移除合并思考行(二级收起 / 一级收起时),恢复行由 applyRows 控制。
|
|
204
|
+
* 合并内容块(btn 的兄弟节点)一并移除,避免宿主展开后残留文本。 */
|
|
205
|
+
private removeMergedThink;
|
|
206
|
+
/** 正文判定(带缓存):同一消息子树未变时直接复用上次结果。失效由
|
|
207
|
+
* markDirty(mutation 定向)与 switchFlow(整体重置)驱动;缓存的是
|
|
208
|
+
* 纯文本/媒体存在性判定,与 display 状态无关,插件自身的显隐切换
|
|
209
|
+
* 不会产生脏数据。 */
|
|
210
|
+
private hasBodyCached;
|
|
211
|
+
/** 本块是否有在途收起渐隐(rows/containers/merged 行/body 任一)。
|
|
212
|
+
* 基于 pendingAnims 账本无状态判定:onfinish/oncancel 都会即时清账,
|
|
213
|
+
* 取消路径天然解锁(计数器/最后注册者会卡死)。merged 行渐隐时已被
|
|
214
|
+
* releaseMergedThink 摘出 mergedThinks,按 DOM 类名现查。 */
|
|
215
|
+
private hasPendingCollapse;
|
|
216
|
+
/** 钉住 chip 与首行的 16px 间距(收起 fade 期间;内联优先于 aria=false 的 0)。
|
|
217
|
+
* flow-chip(context 等 before-mounted)豁免:其间距由宿主 row-gap 16px
|
|
218
|
+
* 提供、自身 CSS 恒 0,钉住 16px 会叠加成 32px(真机实测:收起上下文
|
|
219
|
+
* 注入时二级与三级间距瞬间扩大)。
|
|
220
|
+
*/
|
|
221
|
+
private pinChipMargin;
|
|
222
|
+
/** 解除钉住(aria=true 的 16px 或 aria=false 的 0 由 CSS 接管)。 */
|
|
223
|
+
private unpinChipMargin;
|
|
224
|
+
/** 外部介入检测(issue #11 Bug A):当前内联值 ≠ 插件最后确保值,或所有权
|
|
225
|
+
* 哨兵被 style 整体改写抹除。返回 true 时调用方放弃本次写回并交还账本——
|
|
226
|
+
* 属性级改写由值比对捕获,整体改写(cssText / setAttribute('style'))由
|
|
227
|
+
* 哨兵缺失捕获,两层合起来覆盖外部介入的两种形态。 */
|
|
228
|
+
private displayForeign;
|
|
229
|
+
/** 清空单个元素的显示账本(三账本 + 所有权哨兵)。 */
|
|
230
|
+
private releaseDisplayLedger;
|
|
231
|
+
/** 返回 true 表示启动了渐隐动画(调用方可据此决定内部元素的处置)。
|
|
232
|
+
* settle 在渐隐自然结束时调用(onfinish 链;反向取消不触发)。 */
|
|
233
|
+
private hideElement;
|
|
234
|
+
private restoreElement;
|
|
235
|
+
/** 是否可动画:WAAPI 特性检测 + reduced-motion 门控(均做 typeof 防桩缺失)。 */
|
|
236
|
+
private canAnimate;
|
|
237
|
+
/** 展开方向淡入(opacity + 4px 微位移):无高度分量、零布局读取。
|
|
238
|
+
* onfinish 按终态可见对齐账本(双删除)并 schedule() 幂等重同步;
|
|
239
|
+
* oncancel 只做身份守卫删除——取消方的终态写入自己负责。 */
|
|
240
|
+
private startReveal;
|
|
241
|
+
/** 同步取消在途动画并清账:收起动画需同时清锁高内联(height/overflow/
|
|
242
|
+
* marginBottom),否则取消方写完终态后元素仍被锁高裁剪一帧以上。 */
|
|
243
|
+
private cancelPendingSync;
|
|
244
|
+
private clearCollapseLock;
|
|
245
|
+
/** 祖先 seat 在途动画检测:沿 parentNode 走到 flow,任一祖先在 pendingAnims
|
|
246
|
+
* 即视为在途。分层规则——同一视觉变化只动画一层。 */
|
|
247
|
+
private hasAnimatingAncestor;
|
|
248
|
+
/** 收起方向渐隐动画(v11 定稿):镜像 reveal 的 opacity + 4px 微位移,
|
|
249
|
+
* 淡完 onfinish 写 display:none 并保持双条目(镜像 hideElement 终态契约)。
|
|
250
|
+
* fill:'forwards' 占位到终态写入后释放;无几何锁、无 gap 补偿。 */
|
|
251
|
+
private startFadeCollapse;
|
|
252
|
+
/** 轻量视觉 reveal(opacity + 4px 微位移):用于插件全资元素的即时显示
|
|
253
|
+
* 路径——chip(一级展开时出现)与 merged-think 行(二级展开时出现)。
|
|
254
|
+
* 这些元素的 display 完全由插件直写、无 React 协调竞争,因此不入
|
|
255
|
+
* pendingAnims 账本、无仲裁;收起同为直写 display:none,无 fill 的在途
|
|
256
|
+
* 动画残留在隐藏元素上自然失效。门控沿用 animate 布尔(手势路径才调)。 */
|
|
257
|
+
private revealVisual;
|
|
258
|
+
private restoreUnusedDisplays;
|
|
259
|
+
/** 收集 audit 需要观察的 display 集合;只读取内联样式,不触发布局计算。 */
|
|
260
|
+
private collectAuditDisplays;
|
|
261
|
+
/** 在完整 pass 完成后保存 display 基线,供下一次轻量 audit 比对。 */
|
|
262
|
+
private captureAuditDisplays;
|
|
263
|
+
private restoreAllDisplays;
|
|
264
|
+
}
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,22 +1,11 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
/** Host 侧不注入额外服务。 */
|
|
13
|
-
export declare const inject: string[]
|
|
14
|
-
|
|
15
|
-
/** 插件配置。 */
|
|
16
|
-
export interface Config {
|
|
17
|
-
/** 自定义状态提示词;留空恢复官方 "Deep diving..."。 */
|
|
18
|
-
statusText?: string
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/** Host 插件体:注册设置命名空间。 */
|
|
22
|
-
export declare function apply(ctx: unknown, config?: Config): void
|
|
1
|
+
/** Host 插件名。 */
|
|
2
|
+
export declare const name = "dsh-auto-collapse";
|
|
3
|
+
/** Host 侧不注入额外服务;settings 是可选的运行能力,由 installSettingsSection 惰性接入。 */
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
/** 插件配置。 */
|
|
6
|
+
export interface Config {
|
|
7
|
+
/** 自定义状态提示词;留空恢复官方 "Deep diving..."。 */
|
|
8
|
+
statusText?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Host 插件体:注册设置命名空间。 */
|
|
11
|
+
export declare function apply(ctx: unknown, config?: Config): void;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-auto-collapse — 插件配置卡片。
|
|
3
|
+
*
|
|
4
|
+
* 在 DSH Web 设置 → 插件 → 插件配置 中追加“状态提示词”卡片,编辑
|
|
5
|
+
* dsh-auto-collapse 的 settings 命名空间。卡片只做暂存与保存,不改动
|
|
6
|
+
* 业务逻辑;运行时文字替换由 FoldController 读取同一 scope 后实时生效。
|
|
7
|
+
*/
|
|
8
|
+
/** settings 命名空间。Host 侧与客户端侧使用同一个值才能配对出现。 */
|
|
9
|
+
export declare const AUTO_COLLAPSE_NS = "dsh-auto-collapse";
|
|
10
|
+
/** 默认状态提示词。 */
|
|
11
|
+
export declare const DEFAULT_STATUS_TEXT = "Deep sleeping...";
|
|
12
|
+
/** 客户端 settings scope 的最小结构化类型。 */
|
|
13
|
+
export interface SettingsScopeLike {
|
|
14
|
+
getSnapshot(): {
|
|
15
|
+
status: 'loading' | 'ready' | 'unavailable';
|
|
16
|
+
value?: Record<string, unknown>;
|
|
17
|
+
base?: Record<string, unknown>;
|
|
18
|
+
user?: Record<string, unknown>;
|
|
19
|
+
writable: boolean;
|
|
20
|
+
};
|
|
21
|
+
subscribe(listener: () => void): () => void;
|
|
22
|
+
set(field: string, value: unknown): Promise<void>;
|
|
23
|
+
unset(field: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/** 客户端 slots 服务的最小结构化类型。 */
|
|
26
|
+
export interface SlotsLike {
|
|
27
|
+
inject(key: string, callback: () => unknown): () => void;
|
|
28
|
+
register(options: {
|
|
29
|
+
name: string;
|
|
30
|
+
key: string;
|
|
31
|
+
inject: () => unknown;
|
|
32
|
+
}, renderer: (props: {
|
|
33
|
+
scope: SettingsScopeLike;
|
|
34
|
+
}) => unknown): unknown;
|
|
35
|
+
}
|
|
36
|
+
/** 从绑定的 settings scope 构造实时状态提示词读取器。 */
|
|
37
|
+
export declare function statusTextProvider(scope: SettingsScopeLike | undefined): () => string | undefined;
|
|
38
|
+
/** 向 DSH 插件配置页注册“状态提示词”卡片。 */
|
|
39
|
+
export declare function setupSettingsCard(ctx: {
|
|
40
|
+
slots: SlotsLike;
|
|
41
|
+
}, scope: SettingsScopeLike): () => void;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-auto-collapse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.5",
|
|
4
4
|
"description": "DeepSeek Harness Web 客户端插件:把会话里的工具卡片与 Think 推理块折叠成一行,折叠态实时显示当前正在进行的工作(工具名 + 正在执行的命令/参数、或思考内容),运行中带流光动画;点击展开/收起。让界面只保留模型说的话。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
"default": "./lib/index.js"
|
|
12
12
|
},
|
|
13
13
|
"./client": {
|
|
14
|
-
"types": "./lib/types/client
|
|
14
|
+
"types": "./lib/types/client.d.ts",
|
|
15
15
|
"default": "./lib/client.js"
|
|
16
16
|
},
|
|
17
17
|
"./package.json": "./package.json"
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* dsh-auto-collapse — browser half 类型声明。
|
|
3
|
-
*
|
|
4
|
-
* 折叠会话里的工具卡片与 Think 推理块;把官方 "Deep diving..." 运行状态行
|
|
5
|
-
* 替换为可配置的状态提示词(默认 "Deep sleeping...",为空时不替换)。
|
|
6
|
-
* 同时注册 DSH 设置 → 插件 → 插件配置的“状态提示词”卡片。
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
/** 客户端根上下文的最小结构化类型(仅用 cordis 标准 effect,运行时通过 slot 注册配置卡片)。 */
|
|
10
|
-
export interface FoldClientCtx {
|
|
11
|
-
effect(fn: () => unknown, label?: string): unknown
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export declare const name: string
|
|
15
|
-
export declare const inject: string[]
|
|
16
|
-
export declare function apply(ctx: FoldClientCtx): void
|