@konnga/dsh-hud 0.8.0 → 0.9.1
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 +176 -0
- package/README.md +103 -55
- package/docs/images/capsules.webp +0 -0
- package/docs/images/image-diff.webp +0 -0
- package/docs/images/pill-tip.webp +0 -0
- package/docs/images/turn-images.webp +0 -0
- package/lib/client.js +1226 -17
- package/lib/index.js +463 -2
- package/package.json +11 -2
package/lib/client.js
CHANGED
|
@@ -120,6 +120,37 @@ window.__ModuleLoader__.load({
|
|
|
120
120
|
"diff.copied": "已复制",
|
|
121
121
|
"diff.empty": "这一侧没有差异",
|
|
122
122
|
"diff.binary": "二进制文件,没有可显示的文本差异",
|
|
123
|
+
// ── 图片预览(二进制改动里认得出的那几种) ──
|
|
124
|
+
"image.loading": "读取图片…",
|
|
125
|
+
"image.pair": "并排",
|
|
126
|
+
"image.flip": "切换",
|
|
127
|
+
"image.beforeWorktree": "索引",
|
|
128
|
+
"image.afterWorktree": "工作区",
|
|
129
|
+
"image.beforeIndex": "HEAD",
|
|
130
|
+
"image.afterIndex": "索引",
|
|
131
|
+
"image.beforeCommit": "父提交",
|
|
132
|
+
"image.afterCommit": "这次提交",
|
|
133
|
+
"image.dims": "{w}×{h}",
|
|
134
|
+
"image.dimsChanged": "尺寸 {from} → {to}",
|
|
135
|
+
"image.tooLarge": "超过 {limit} MB 预览上限({size})",
|
|
136
|
+
"image.notImage": "不是可预览的图片({size})",
|
|
137
|
+
"image.missing": "这一侧没有这个文件",
|
|
138
|
+
"image.zoomOut": "缩小",
|
|
139
|
+
"image.zoomIn": "放大",
|
|
140
|
+
"image.fitWidth": "适应宽度",
|
|
141
|
+
"image.zoomValue": "{percent}%",
|
|
142
|
+
// ── 会话计时胶囊 ──
|
|
143
|
+
"timer.title": "本次会话运行时长",
|
|
144
|
+
"timer.created": "创建于 {time}",
|
|
145
|
+
"timer.work": "其中模型 {llm}、工具 {tool}",
|
|
146
|
+
"timer.turns": "{turns} 轮 / {steps} 步",
|
|
147
|
+
// ── Session timer capsule ──
|
|
148
|
+
// ── 轮尾的「本轮图片改动」条 ──
|
|
149
|
+
"turnImages.title": "本轮图片改动",
|
|
150
|
+
"turnImages.titleSession": "本会话图片改动",
|
|
151
|
+
"turnImages.open": "在内置预览里打开",
|
|
152
|
+
"turnImages.compare": "在 dsh-hud 里前后对比",
|
|
153
|
+
"turnImages.more": "还有 {n} 个",
|
|
123
154
|
"diff.truncated": "差异过大,仅显示前面部分",
|
|
124
155
|
"diff.untrackedHint": "未跟踪文件,与空文件对比",
|
|
125
156
|
"diff.commitHint": "这次提交相对其父提交的差异(只读)",
|
|
@@ -231,6 +262,36 @@ window.__ModuleLoader__.load({
|
|
|
231
262
|
"diff.copied": "Copied",
|
|
232
263
|
"diff.empty": "No changes on this side",
|
|
233
264
|
"diff.binary": "Binary file — no text diff to show",
|
|
265
|
+
// ── Image preview (binary changes we can recognise) ──
|
|
266
|
+
"image.loading": "Reading image…",
|
|
267
|
+
"image.pair": "Side by side",
|
|
268
|
+
"image.flip": "Flip",
|
|
269
|
+
"image.beforeWorktree": "index",
|
|
270
|
+
"image.afterWorktree": "worktree",
|
|
271
|
+
"image.beforeIndex": "HEAD",
|
|
272
|
+
"image.afterIndex": "index",
|
|
273
|
+
"image.beforeCommit": "parent",
|
|
274
|
+
"image.afterCommit": "this commit",
|
|
275
|
+
"image.dims": "{w}×{h}",
|
|
276
|
+
"image.dimsChanged": "size {from} → {to}",
|
|
277
|
+
"image.tooLarge": "over the {limit} MB preview limit ({size})",
|
|
278
|
+
"image.notImage": "not a previewable image ({size})",
|
|
279
|
+
"image.missing": "not present on this side",
|
|
280
|
+
"image.zoomOut": "Zoom out",
|
|
281
|
+
"image.zoomIn": "Zoom in",
|
|
282
|
+
"image.fitWidth": "Fit width",
|
|
283
|
+
"image.zoomValue": "{percent}%",
|
|
284
|
+
// ── Session timer capsule ──
|
|
285
|
+
"timer.title": "This session's running time",
|
|
286
|
+
"timer.created": "started {time}",
|
|
287
|
+
"timer.work": "model {llm}, tools {tool}",
|
|
288
|
+
"timer.turns": "{turns} turns / {steps} steps",
|
|
289
|
+
// ── Turn-tail "images this turn" strip ──
|
|
290
|
+
"turnImages.title": "Images this turn",
|
|
291
|
+
"turnImages.titleSession": "Images this session",
|
|
292
|
+
"turnImages.open": "Open in the built-in preview",
|
|
293
|
+
"turnImages.compare": "Compare before/after in dsh-hud",
|
|
294
|
+
"turnImages.more": "{n} more",
|
|
234
295
|
"diff.truncated": "Diff is large; showing the beginning only",
|
|
235
296
|
"diff.untrackedHint": "Untracked file, diffed against an empty file",
|
|
236
297
|
"diff.commitHint": "What this commit changed against its parent (read-only)",
|
|
@@ -321,11 +382,14 @@ window.__ModuleLoader__.load({
|
|
|
321
382
|
added: "var(--dsw-alias-state-success-primary, #3fb950)",
|
|
322
383
|
deleted: "var(--dsw-alias-state-error-primary, #f06a6a)",
|
|
323
384
|
// 与 codex-hud 的 `statEntries` 一一对应:R→cyan、C→brightBlue(我们的品牌蓝就是这个位)、
|
|
324
|
-
// T→magenta
|
|
385
|
+
// T→magenta、?→灰。M/A/D/! 本来就一致(yellow/green/red/critical)。
|
|
386
|
+
// `?` 用 `label-secondary` 而不是 `label-dimmed`:后者是**反相的表面色**(浅 #e1e5ee、深 #43454a),
|
|
387
|
+
// 当文字压在面板底色上只有 1.26:1 / 1.64:1 —— 看不见(issue:未跟踪计数读不出来)。
|
|
388
|
+
// secondary 仍是中性灰、比彩色状态弱一档,但浅 5.8:1 / 深 10.4:1 都读得清。
|
|
325
389
|
renamed: "var(--dsh-hud-cyan, #22d3ee)",
|
|
326
390
|
copied: "var(--dsw-alias-brand-primary, #4b8bf5)",
|
|
327
391
|
typeChanged: "var(--dsh-hud-magenta, #d946ef)",
|
|
328
|
-
untracked: "var(--dsw-alias-label-
|
|
392
|
+
untracked: "var(--dsw-alias-label-secondary, #8a8f98)",
|
|
329
393
|
conflicted: "var(--dsw-alias-state-error-primary, #f06a6a)",
|
|
330
394
|
};
|
|
331
395
|
|
|
@@ -359,6 +423,350 @@ window.__ModuleLoader__.load({
|
|
|
359
423
|
|
|
360
424
|
/* --------------------------------------------------------- 本地 UI 状态 */
|
|
361
425
|
|
|
426
|
+
/** prompt 历史的条数上限(与宿主半的 `PROMPT_HISTORY_MAX` 同一个数)。 */
|
|
427
|
+
var PROMPT_HISTORY_MAX = 100;
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* 光标此刻允许上下翻历史吗?—— 照抄 OpenCode Desktop 的 `canNavigateHistoryAtCursor`。
|
|
431
|
+
*
|
|
432
|
+
* 它的规矩比 Codex 保守得多:**不在历史里时,`↑` 只在输入框完全为空时才接管**。
|
|
433
|
+
* 于是多行草稿里 `↑` 永远是光标移动,跟内置的 `/` 命令、`@` 提及弹窗也天然不冲突
|
|
434
|
+
* (弹窗必然带着文字)。进了历史之后,只要光标在两端就能继续翻。
|
|
435
|
+
*/
|
|
436
|
+
function canNavigatePromptHistory(direction, text, caret, inHistory) {
|
|
437
|
+
var length = typeof text === "string" ? text.length : 0;
|
|
438
|
+
var position = Math.max(0, Math.min(typeof caret === "number" ? caret : 0, length));
|
|
439
|
+
if (inHistory) return position === 0 || position === length;
|
|
440
|
+
if (direction === "up") return position === 0 && length === 0;
|
|
441
|
+
return position === length;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* `↑` / `↓` 翻 prompt 历史的**纯状态机**(语义照抄 OpenCode Desktop 的 `navigatePromptHistory`)。
|
|
446
|
+
*
|
|
447
|
+
* `index === -1` 表示"不在历史里";`entries[0]` 是最新一条。往旧翻时先把当前草稿存进
|
|
448
|
+
* `draft`,一直翻到最新再往下就把草稿**原样放回**(连光标位置都按方向摆好:往旧放开头、
|
|
449
|
+
* 往新放末尾)。没接管的情况一律回 `handled: false`,调用方据此**不**调用 preventDefault。
|
|
450
|
+
*/
|
|
451
|
+
function promptHistoryStep(state, direction, input) {
|
|
452
|
+
var entries = (input && input.entries) || [];
|
|
453
|
+
var text = input && typeof input.text === "string" ? input.text : "";
|
|
454
|
+
var caret = input && typeof input.caret === "number" ? input.caret : 0;
|
|
455
|
+
var index = state.index;
|
|
456
|
+
var draft = state.draft;
|
|
457
|
+
|
|
458
|
+
if (!canNavigatePromptHistory(direction, text, caret, index >= 0)) return { handled: false, state: state };
|
|
459
|
+
|
|
460
|
+
if (direction === "up") {
|
|
461
|
+
if (entries.length === 0) return { handled: false, state: state };
|
|
462
|
+
if (index === -1) return { handled: true, state: { index: 0, draft: text }, text: entries[0], caret: "start" };
|
|
463
|
+
if (index < entries.length - 1) {
|
|
464
|
+
return { handled: true, state: { index: index + 1, draft: draft }, text: entries[index + 1], caret: "start" };
|
|
465
|
+
}
|
|
466
|
+
return { handled: false, state: state }; // 已经到最旧一条:把 ↑ 还给编辑器
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (index > 0) return { handled: true, state: { index: index - 1, draft: draft }, text: entries[index - 1], caret: "end" };
|
|
470
|
+
if (index === 0) {
|
|
471
|
+
return { handled: true, state: { index: -1, draft: null }, text: draft === null || draft === undefined ? "" : draft, caret: "end" };
|
|
472
|
+
}
|
|
473
|
+
return { handled: false, state: state };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* -------------------------------------------- 输入框的 ↑ 召回(纯文本) */
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 从一个事件目标往上找"输入框根节点"。
|
|
480
|
+
*
|
|
481
|
+
* DSH 的输入框是 **Lexical** 的 contenteditable(根节点带 `data-lexical-editor="true"`),
|
|
482
|
+
* 不是 textarea —— 所以不能读 `value`。手写 parentElement 上溯(不用 `closest`):
|
|
483
|
+
* 一来少依赖一个 API,二来深度有上限,绝不会误判到页面别处。
|
|
484
|
+
*/
|
|
485
|
+
function composerRootOf(target) {
|
|
486
|
+
var node = target;
|
|
487
|
+
for (var depth = 0; depth < 6 && node !== null && node !== undefined; depth += 1) {
|
|
488
|
+
if (typeof node.getAttribute === "function") {
|
|
489
|
+
var marker = node.getAttribute("data-lexical-editor");
|
|
490
|
+
var editable = typeof node.isContentEditable === "boolean" ? node.isContentEditable : node.getAttribute("contenteditable") === "true";
|
|
491
|
+
if (marker === "true" || editable) return node;
|
|
492
|
+
}
|
|
493
|
+
node = node.parentElement;
|
|
494
|
+
}
|
|
495
|
+
return null;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** 输入框里的纯文本(Lexical 会把内容画在 DOM 里,`innerText` 最贴近用户看到的)。 */
|
|
499
|
+
function composerText(root) {
|
|
500
|
+
if (root === null || root === undefined) return "";
|
|
501
|
+
var text = typeof root.innerText === "string" ? root.innerText : (typeof root.textContent === "string" ? root.textContent : "");
|
|
502
|
+
return text.replace(/\u200b/g, "");
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* "输入框实际上是空的吗"用的归一化文本。
|
|
507
|
+
*
|
|
508
|
+
* Lexical 的空根节点在 DOM 里通常是 `<p><br></p>`,`innerText` 会给出一个换行符 ——
|
|
509
|
+
* 拿它当"有内容"就会让 `↑` 永远排不上队(整个功能静默失效)。所以去掉零宽字符与
|
|
510
|
+
* **只在末尾的空白**:开头的空格是用户真的敲进去的,不能吞。
|
|
511
|
+
*/
|
|
512
|
+
function composerTextKey(root) {
|
|
513
|
+
return composerText(root).replace(/\s+$/, "");
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** 写入是否成功:两边都归一化再比 —— Lexical 可能补一个尾换行,那不是"没写进去"。 */
|
|
517
|
+
function composerTextMatches(text, expected) {
|
|
518
|
+
return text.replace(/\s+$/, "") === expected.replace(/\s+$/, "");
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** 光标在输入框里的字符偏移;没有选区、有选中内容、或拿不到选区时回 null(一律不接管)。 */
|
|
522
|
+
function composerCaretOffset(root) {
|
|
523
|
+
var selection = typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
524
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed !== true) return null;
|
|
525
|
+
var node = selection.focusNode;
|
|
526
|
+
if (!node) return null;
|
|
527
|
+
var range = document.createRange();
|
|
528
|
+
range.selectNodeContents(root);
|
|
529
|
+
var before = range.cloneRange();
|
|
530
|
+
try {
|
|
531
|
+
before.setEnd(node, selection.focusOffset);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
return before.toString().length;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** 把光标放到开头或末尾(`↑` 往旧翻放开头,`↓` 往回翻放末尾 —— 与 OpenCode 一致)。 */
|
|
539
|
+
function placeCaretInComposer(root, position) {
|
|
540
|
+
var selection = typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
541
|
+
if (!selection) return;
|
|
542
|
+
try {
|
|
543
|
+
var range = document.createRange();
|
|
544
|
+
range.selectNodeContents(root);
|
|
545
|
+
range.collapse(position !== "end");
|
|
546
|
+
selection.removeAllRanges();
|
|
547
|
+
selection.addRange(range);
|
|
548
|
+
} catch (error) {
|
|
549
|
+
// 光标没摆好不影响内容已经写进去了
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** 全选输入框内容(模拟粘贴前先选中,粘贴才能"替换"而不是"追加")。 */
|
|
554
|
+
function selectComposerContents(root) {
|
|
555
|
+
var selection = typeof window.getSelection === "function" ? window.getSelection() : null;
|
|
556
|
+
if (!selection) return;
|
|
557
|
+
try {
|
|
558
|
+
var range = document.createRange();
|
|
559
|
+
range.selectNodeContents(root);
|
|
560
|
+
selection.removeAllRanges();
|
|
561
|
+
selection.addRange(range);
|
|
562
|
+
} catch (error) {
|
|
563
|
+
// 选不中也还能走 execCommand 那条退路
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** 等"写入落地":微任务总有,有 rAF 的浏览器里再多等一帧(Lexical 的粘贴是帧后落 DOM)。 */
|
|
568
|
+
function settleComposer() {
|
|
569
|
+
var chain = Promise.resolve();
|
|
570
|
+
if (typeof window.requestAnimationFrame === "function") {
|
|
571
|
+
chain = chain.then(function () {
|
|
572
|
+
return new Promise(function (resolve) {
|
|
573
|
+
window.requestAnimationFrame(function () { resolve(); });
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
return chain;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* 把一段纯文本写进 Lexical 输入框 —— **三层依次降级,每层都等落地了再回读**。
|
|
582
|
+
*
|
|
583
|
+
* 为什么要等:Lexical 的粘贴处理是**异步**的(它得先把剪贴板读出来)。写完立刻回读会读到
|
|
584
|
+
* 旧内容、误判成失败,于是下一层再插一次 —— 实测表现就是**文字出现两遍**(第一次真机上
|
|
585
|
+
* 就是这样:粘贴插了一遍,`insertText` 又追加了一遍)。
|
|
586
|
+
*
|
|
587
|
+
* 三层次序:① 模拟粘贴(Lexical 的正规插入通道)② `execCommand("insertText")`
|
|
588
|
+
* ③ 直接改文本 + 派发 `input`。**每次降级前都重新全选**,让重试是"替换"而不是"追加"。
|
|
589
|
+
*/
|
|
590
|
+
function writeComposerText(root, value, caret) {
|
|
591
|
+
function satisfied() {
|
|
592
|
+
return composerTextMatches(composerText(root), value);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return settleComposer()
|
|
596
|
+
.then(function () {
|
|
597
|
+
if (typeof window.ClipboardEvent !== "function" || typeof window.DataTransfer !== "function") return false;
|
|
598
|
+
if (typeof root.dispatchEvent !== "function") return false;
|
|
599
|
+
try {
|
|
600
|
+
selectComposerContents(root);
|
|
601
|
+
var data = new window.DataTransfer();
|
|
602
|
+
data.setData("text/plain", value);
|
|
603
|
+
root.dispatchEvent(new window.ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
|
|
604
|
+
} catch (error) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
return settleComposer().then(satisfied);
|
|
608
|
+
})
|
|
609
|
+
.then(function (wrote) {
|
|
610
|
+
if (wrote) return true;
|
|
611
|
+
try {
|
|
612
|
+
if (typeof document.execCommand === "function") {
|
|
613
|
+
selectComposerContents(root);
|
|
614
|
+
if (document.execCommand("insertText", false, value) === true) {
|
|
615
|
+
return settleComposer().then(satisfied);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
} catch (error) {
|
|
619
|
+
// 落到最后一层
|
|
620
|
+
}
|
|
621
|
+
return settleComposer().then(function () {
|
|
622
|
+
if (satisfied()) return true;
|
|
623
|
+
try {
|
|
624
|
+
selectComposerContents(root);
|
|
625
|
+
root.textContent = value;
|
|
626
|
+
if (typeof window.Event === "function") root.dispatchEvent(new window.Event("input", { bubbles: true }));
|
|
627
|
+
return true;
|
|
628
|
+
} catch (error) {
|
|
629
|
+
return false;
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
})
|
|
633
|
+
.then(function (wrote) {
|
|
634
|
+
if (!wrote) return false;
|
|
635
|
+
placeCaretInComposer(root, caret);
|
|
636
|
+
// 重写内容会让编辑器失焦/滚动跳,下一帧再摆一次光标(OpenCode 也是这么做的)。
|
|
637
|
+
if (typeof window.requestAnimationFrame === "function") {
|
|
638
|
+
window.requestAnimationFrame(function () { placeCaretInComposer(root, caret); });
|
|
639
|
+
}
|
|
640
|
+
return true;
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* 把"输入框里按 ↑/↓ 召回上一条 prompt"接到窗口上 —— 只接线,判断与翻页逻辑都在纯函数里。
|
|
646
|
+
*
|
|
647
|
+
* 门禁(任一不满足就**完全不介入**,`↑`/`↓` 行为与不装插件时一模一样):
|
|
648
|
+
* 默认行为没被别处吃掉、不是输入法组合中、无修饰键、目标是输入框、历史非空、
|
|
649
|
+
* 选区收起、光标位置通过 `canNavigatePromptHistory`。**接管了才 preventDefault**。
|
|
650
|
+
*/
|
|
651
|
+
function bindPromptHistory(store) {
|
|
652
|
+
var nav = { sessionId: null, index: -1, draft: null, writing: false };
|
|
653
|
+
|
|
654
|
+
function onKeyDown(event) {
|
|
655
|
+
if (event === null || event === undefined) return;
|
|
656
|
+
if (event.defaultPrevented === true || event.isComposing === true) return;
|
|
657
|
+
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
|
|
658
|
+
if (event.altKey === true || event.ctrlKey === true || event.metaKey === true) return;
|
|
659
|
+
if (typeof event.preventDefault !== "function") return;
|
|
660
|
+
|
|
661
|
+
var root = composerRootOf(event.target);
|
|
662
|
+
if (root === null) return;
|
|
663
|
+
|
|
664
|
+
var snapshot = store.getSnapshot().promptHistory;
|
|
665
|
+
var value = snapshot !== null && snapshot !== undefined && snapshot.sessionId !== null ? snapshot.value : null;
|
|
666
|
+
var entries = value !== null && value !== undefined && value.available === true && Array.isArray(value.prompts) ? value.prompts : [];
|
|
667
|
+
if (entries.length === 0) return;
|
|
668
|
+
|
|
669
|
+
if (nav.sessionId !== snapshot.sessionId) {
|
|
670
|
+
nav.sessionId = snapshot.sessionId;
|
|
671
|
+
nav.index = -1;
|
|
672
|
+
nav.draft = null;
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
var caret = composerCaretOffset(root);
|
|
676
|
+
if (caret === null) return;
|
|
677
|
+
var direction = event.key === "ArrowUp" ? "up" : "down";
|
|
678
|
+
// 归一化后再判"空不空"与"光标在哪端":Lexical 空输入框里的那个换行不算内容。
|
|
679
|
+
var text = composerTextKey(root);
|
|
680
|
+
var step = promptHistoryStep({ index: nav.index, draft: nav.draft }, direction, {
|
|
681
|
+
entries: entries,
|
|
682
|
+
text: text,
|
|
683
|
+
caret: Math.max(0, Math.min(caret, text.length)),
|
|
684
|
+
});
|
|
685
|
+
if (!step.handled) return;
|
|
686
|
+
|
|
687
|
+
// 到这里才确定要接管:先挡住默认行为(别让 ↑ 先把光标移走),再写内容。
|
|
688
|
+
event.preventDefault();
|
|
689
|
+
if (typeof event.stopPropagation === "function") event.stopPropagation();
|
|
690
|
+
|
|
691
|
+
// 写入是异步的(粘贴要等落地),但翻页状态先记下:连按 ↑ 时不会两次都从同一条开始。
|
|
692
|
+
// 真写不进去再回滚 —— 宁可状态与画面都对不上时退回一步,也不要悄悄漂移。
|
|
693
|
+
var previous = { index: nav.index, draft: nav.draft };
|
|
694
|
+
nav.index = step.state.index;
|
|
695
|
+
nav.draft = step.state.draft;
|
|
696
|
+
nav.writing = true;
|
|
697
|
+
|
|
698
|
+
writeComposerText(root, step.text, step.caret).then(function (wrote) {
|
|
699
|
+
nav.writing = false;
|
|
700
|
+
if (wrote === true) {
|
|
701
|
+
probe.log("prompt-history", { index: nav.index, entries: entries.length, direction: direction });
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
nav.index = previous.index;
|
|
705
|
+
nav.draft = previous.draft;
|
|
706
|
+
probe.log("prompt-history: 写不进输入框(三层都失败),已回滚并放弃接管", { entries: entries.length });
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/** 用户自己动了内容(不是我们写进去的)→ 退出历史、草稿作废,免得把草稿还原到新内容上。 */
|
|
711
|
+
function onInput(event) {
|
|
712
|
+
if (nav.writing) return;
|
|
713
|
+
if (composerRootOf(event && event.target) === null) return;
|
|
714
|
+
nav.index = -1;
|
|
715
|
+
nav.draft = null;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
if (typeof window.addEventListener !== "function") return function () {};
|
|
719
|
+
window.addEventListener("keydown", onKeyDown, true);
|
|
720
|
+
window.addEventListener("input", onInput, true);
|
|
721
|
+
return function () {
|
|
722
|
+
window.removeEventListener("keydown", onKeyDown, true);
|
|
723
|
+
window.removeEventListener("input", onInput, true);
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* 时长格式化:**与 codex-hud 同一套口径**。
|
|
729
|
+
*
|
|
730
|
+
* 不足一分钟给 `<1m`;然后按 `Xd Yh Zm` 只列非零项(`45m` / `2h 14m` / `3d 2h`)。
|
|
731
|
+
* 取的是 floor —— 计时器宁可比真实少一点,也不要"明明刚过 1 分钟就写 2m"。
|
|
732
|
+
*/
|
|
733
|
+
function formatDuration(ms) {
|
|
734
|
+
var safe = typeof ms === "number" && isFinite(ms) ? Math.max(0, ms) : 0;
|
|
735
|
+
if (safe < 60000) return "<1m";
|
|
736
|
+
var minutes = Math.floor(safe / 60000);
|
|
737
|
+
var days = Math.floor(minutes / 1440);
|
|
738
|
+
var hours = Math.floor((minutes % 1440) / 60);
|
|
739
|
+
var rest = minutes % 60;
|
|
740
|
+
var parts = [];
|
|
741
|
+
if (days > 0) parts.push(String(days) + "d");
|
|
742
|
+
if (hours > 0) parts.push(String(hours) + "h");
|
|
743
|
+
if (rest > 0) parts.push(String(rest) + "m");
|
|
744
|
+
return parts.join(" ");
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** 固定缩放档位:与内置文档预览的菜单一致(25% / 50% / 100% / 150% / 200%)。 */
|
|
748
|
+
var ZOOM_STEPS = [0.25, 0.5, 1, 1.5, 2];
|
|
749
|
+
|
|
750
|
+
/** 落盘/读回的缩放值:只认档位里的数,其他一律当"适应宽度"。 */
|
|
751
|
+
function readZoom(ui) {
|
|
752
|
+
return ui && ZOOM_STEPS.indexOf(ui.zoom) >= 0 ? ui.zoom : null;
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* 走一档缩放。
|
|
757
|
+
*
|
|
758
|
+
* `null`(适应宽度)出发:`+` 从 100% 开始、`−` 从 50% 开始 —— 和"适应宽度"的直觉一致,
|
|
759
|
+
* 不用先经过 25% 才知道按了有反应。
|
|
760
|
+
*/
|
|
761
|
+
function stepZoom(current, direction) {
|
|
762
|
+
if (current === null || current === undefined) return direction > 0 ? 1 : 0.5;
|
|
763
|
+
var index = ZOOM_STEPS.indexOf(current);
|
|
764
|
+
if (index < 0) return direction > 0 ? 1 : 0.5;
|
|
765
|
+
var next = index + (direction > 0 ? 1 : -1);
|
|
766
|
+
if (next < 0 || next >= ZOOM_STEPS.length) return current;
|
|
767
|
+
return ZOOM_STEPS[next];
|
|
768
|
+
}
|
|
769
|
+
|
|
362
770
|
function readUi() {
|
|
363
771
|
try {
|
|
364
772
|
var raw = window.localStorage.getItem(UI_KEY);
|
|
@@ -694,6 +1102,14 @@ window.__ModuleLoader__.load({
|
|
|
694
1102
|
* 实时秒数由客户端本地算(`active.startedAt`),不靠把轮询压到亚秒级。
|
|
695
1103
|
*/
|
|
696
1104
|
tools: { phase: "idle", active: null, recent: [], counts: [], skills: [], skillsDropped: 0, total: 0, updatedAt: null },
|
|
1105
|
+
// 会话时长(计时胶囊用):`{ sessionId, value }`;取数写进快照,渲染那边就是纯函数。
|
|
1106
|
+
sessionTime: { sessionId: null, value: null },
|
|
1107
|
+
// prompt 历史(输入框 `↑` 召回用):`{ sessionId, value }`,value 里 prompts 是**新的在前**。
|
|
1108
|
+
promptHistory: { sessionId: null, value: null },
|
|
1109
|
+
// 胶囊悬停提示:`{ key, text, rect }` 或 null。**自己画**,不用浏览器原生 tooltip ——
|
|
1110
|
+
// 原生那个在真机上按了没反应(试过三种节点/容器查证,都不是被谁挡住),
|
|
1111
|
+
// 而它延迟、样式、能否出现我们都控制不了;自己画则位置、换行、颜色全说了算。
|
|
1112
|
+
tip: null,
|
|
697
1113
|
/** 侧栏行内 chip 要的摘要:一次批量取回,按 sessionId 索引。 */
|
|
698
1114
|
rows: { phase: "idle", byId: {}, unavailable: false, truncated: false, updatedAt: null },
|
|
699
1115
|
/**
|
|
@@ -754,6 +1170,7 @@ window.__ModuleLoader__.load({
|
|
|
754
1170
|
open: state.panelOpen,
|
|
755
1171
|
pos: state.pos,
|
|
756
1172
|
wrap: state.diff ? state.diff.wrap === true : false,
|
|
1173
|
+
zoom: state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : null,
|
|
757
1174
|
diffWidth: state.diffWidth,
|
|
758
1175
|
commitWidth: state.commitWidth,
|
|
759
1176
|
});
|
|
@@ -783,11 +1200,12 @@ window.__ModuleLoader__.load({
|
|
|
783
1200
|
if (!state.cwd || !change || !change.file) return Promise.resolve();
|
|
784
1201
|
var cwd = state.cwd;
|
|
785
1202
|
var wrap = state.diff ? state.diff.wrap === true : readUi().wrap === true;
|
|
1203
|
+
var zoom = state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : readZoom(readUi());
|
|
786
1204
|
set({
|
|
787
1205
|
// 工作区差异与「某次提交的明细」是互斥的两种右栏内容,换过去就收起明细。
|
|
788
1206
|
commit: null,
|
|
789
1207
|
cursor: change.file,
|
|
790
|
-
diff: { source: "worktree", phase: "loading", path: change.path, file: change.file, origPath: change.origFile || null, data: null, side: "worktree", wrap: wrap, hunk: 0, collapsed: [], errorCode: null },
|
|
1208
|
+
diff: { source: "worktree", phase: "loading", path: change.path, file: change.file, origPath: change.origFile || null, data: null, side: "worktree", wrap: wrap, zoom: zoom, hunk: 0, collapsed: [], errorCode: null },
|
|
791
1209
|
});
|
|
792
1210
|
|
|
793
1211
|
diffInflight = Promise.resolve()
|
|
@@ -815,6 +1233,7 @@ window.__ModuleLoader__.load({
|
|
|
815
1233
|
data: value,
|
|
816
1234
|
side: worktreeRows > 0 || indexRows === 0 ? "worktree" : "index",
|
|
817
1235
|
wrap: current.wrap,
|
|
1236
|
+
zoom: current.zoom,
|
|
818
1237
|
hunk: 0,
|
|
819
1238
|
collapsed: [],
|
|
820
1239
|
errorCode: null,
|
|
@@ -956,6 +1375,7 @@ window.__ModuleLoader__.load({
|
|
|
956
1375
|
data: null,
|
|
957
1376
|
side: "commit",
|
|
958
1377
|
wrap: wrap,
|
|
1378
|
+
zoom: state.diff && ZOOM_STEPS.indexOf(state.diff.zoom) >= 0 ? state.diff.zoom : readZoom(readUi()),
|
|
959
1379
|
hunk: 0,
|
|
960
1380
|
collapsed: [],
|
|
961
1381
|
errorCode: null,
|
|
@@ -1012,6 +1432,170 @@ window.__ModuleLoader__.load({
|
|
|
1012
1432
|
set({ diff: null });
|
|
1013
1433
|
}
|
|
1014
1434
|
|
|
1435
|
+
/* --------------------------------------------------- 图片预览(二进制) */
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* 图片两侧的缓存:键与请求一一对应。
|
|
1439
|
+
*
|
|
1440
|
+
* 一张图的 base64 可能上兆,所以命中就不重取;条目数有上限(超了整表清空,
|
|
1441
|
+
* 和宿主的 `CACHE_MAX_ENTRIES` 同一个思路:宁可少缓存,也不要无界增长)。
|
|
1442
|
+
*/
|
|
1443
|
+
var imagePairs = new Map();
|
|
1444
|
+
var IMAGE_CACHE_MAX = 8;
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* 会话运行时长的缓存:每分钟最多问一次(统计数字不需要更密),同会话复用。
|
|
1448
|
+
*
|
|
1449
|
+
* 与图片那种"重"缓存分开存:这个条目只有几十字节,TTL 到期直接覆盖。
|
|
1450
|
+
*/
|
|
1451
|
+
var sessionTimes = new Map();
|
|
1452
|
+
/** 拿到数的缓存时长。 */
|
|
1453
|
+
var SESSION_TIME_TTL_MS = 60000;
|
|
1454
|
+
/**
|
|
1455
|
+
* **没拿到**时的重问间隔。
|
|
1456
|
+
*
|
|
1457
|
+
* 会话是异步恢复到宿主 `sessions` 存储里的:重启后头几百毫秒问 `session-time` 得到的是
|
|
1458
|
+
* "这个会话还不存在"(实测启动 0.6 秒时 live=0,30 秒后才 live=1)。
|
|
1459
|
+
* 把这种答案按 60 秒缓存,就会让计时胶囊在你重启后一两分钟才出现 —— 所以负面结果只记 3 秒。
|
|
1460
|
+
*/
|
|
1461
|
+
var SESSION_TIME_RETRY_MS = 3000;
|
|
1462
|
+
|
|
1463
|
+
/** 显示某枚胶囊的悬停提示(`rect` 由调用方从 `getBoundingClientRect()` 给)。 */
|
|
1464
|
+
function showTip(key, text, rect) {
|
|
1465
|
+
if (typeof text !== "string" || text.length === 0) return;
|
|
1466
|
+
var at = rect && typeof rect === "object"
|
|
1467
|
+
? { left: rect.left, top: rect.top, right: rect.right }
|
|
1468
|
+
: { left: 0, top: 0, right: 0 };
|
|
1469
|
+
set({ tip: { key: key, text: text, rect: at } });
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
/** 收起悬停提示。带 `key` 时只在"还是这一枚"的情况下收 —— 免得旧的 leave 把新的提示关掉。 */
|
|
1473
|
+
function hideTip(key) {
|
|
1474
|
+
var current = state.tip;
|
|
1475
|
+
if (current === null || current === undefined) return;
|
|
1476
|
+
if (typeof key === "string" && current.key !== key) return;
|
|
1477
|
+
set({ tip: null });
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* 一枚胶囊的悬停接线:算出锚点矩形,把文案交给 store。
|
|
1482
|
+
*
|
|
1483
|
+
* 为什么走 store 而不是就地渲染气泡:气泡必须**逃出行内盒**(不被 dock 那行的裁切与
|
|
1484
|
+
* 堆叠上下文限制),所以它由 `shell.overlay` 里一枚单独的座位画,位置用 `position:fixed`。
|
|
1485
|
+
*/
|
|
1486
|
+
function tipHandlers(key, textOf) {
|
|
1487
|
+
return {
|
|
1488
|
+
onPointerEnter: function (event) {
|
|
1489
|
+
var target = event && event.currentTarget ? event.currentTarget : null;
|
|
1490
|
+
var rect = target && typeof target.getBoundingClientRect === "function" ? target.getBoundingClientRect() : null;
|
|
1491
|
+
showTip(key, textOf(), rect);
|
|
1492
|
+
},
|
|
1493
|
+
onPointerLeave: function () { hideTip(key); },
|
|
1494
|
+
onPointerDown: function () { hideTip(key); },
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/** 取一个会话的运行时长(含内置 `sessionStats` 的累计数字);拿不到回 `available: false`。 */
|
|
1499
|
+
function loadSessionTime(sessionId) {
|
|
1500
|
+
if (typeof sessionId !== "string" || sessionId === "") return Promise.resolve({ available: false });
|
|
1501
|
+
var cached = sessionTimes.get(sessionId);
|
|
1502
|
+
if (cached !== undefined) {
|
|
1503
|
+
var ttl = cached.value && cached.value.available === true ? SESSION_TIME_TTL_MS : SESSION_TIME_RETRY_MS;
|
|
1504
|
+
if (Date.now() - cached.at < ttl) return Promise.resolve(cached.value);
|
|
1505
|
+
}
|
|
1506
|
+
return Promise.resolve()
|
|
1507
|
+
.then(function () {
|
|
1508
|
+
return ctx.connection.rpc.call(CHANNEL, "session-time", { args: { sessionId: sessionId } });
|
|
1509
|
+
})
|
|
1510
|
+
.then(function (result) {
|
|
1511
|
+
var value = result && result.ok === true && result.value ? result.value : { available: false, sessionId: sessionId };
|
|
1512
|
+
if (sessionTimes.size >= 16) sessionTimes.clear();
|
|
1513
|
+
sessionTimes.set(sessionId, { at: Date.now(), value: value });
|
|
1514
|
+
var previous = state.sessionTime && state.sessionTime.sessionId === sessionId ? state.sessionTime.value : null;
|
|
1515
|
+
// 深度比较没必要:这两个字段(会话创建时刻 + 累计墙钟)就是整份值里会变的部分。
|
|
1516
|
+
var same = previous !== null
|
|
1517
|
+
&& previous.available === value.available
|
|
1518
|
+
&& previous.createdAt === value.createdAt
|
|
1519
|
+
&& previous.llmMs === value.llmMs
|
|
1520
|
+
&& previous.toolMs === value.toolMs
|
|
1521
|
+
&& previous.turns === value.turns
|
|
1522
|
+
&& previous.steps === value.steps;
|
|
1523
|
+
if (!same) set({ sessionTime: { sessionId: sessionId, value: value } });
|
|
1524
|
+
return value;
|
|
1525
|
+
})
|
|
1526
|
+
.catch(function () {
|
|
1527
|
+
return { available: false, sessionId: sessionId };
|
|
1528
|
+
});
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* prompt 历史:宿主从内置 `turnOutline` 投影里取,比客户端自己记一份可靠得多
|
|
1533
|
+
* (分页、压实、重启、换设备都不影响)。prompt 只在"一整轮发出去"之后才会变,
|
|
1534
|
+
* 所以 5 秒 TTL 足够,而且顺手把值写进快照 —— 按键那侧只读快照,不必再 await。
|
|
1535
|
+
*/
|
|
1536
|
+
var promptHistories = new Map();
|
|
1537
|
+
var PROMPT_HISTORY_TTL_MS = 5000;
|
|
1538
|
+
|
|
1539
|
+
function loadPromptHistory(sessionId) {
|
|
1540
|
+
if (typeof sessionId !== "string" || sessionId === "") return Promise.resolve({ available: false, prompts: [] });
|
|
1541
|
+
var cached = promptHistories.get(sessionId);
|
|
1542
|
+
if (cached !== undefined && Date.now() - cached.at < PROMPT_HISTORY_TTL_MS) return Promise.resolve(cached.value);
|
|
1543
|
+
return Promise.resolve()
|
|
1544
|
+
.then(function () {
|
|
1545
|
+
return ctx.connection.rpc.call(CHANNEL, "prompt-history", { args: { sessionId: sessionId, limit: PROMPT_HISTORY_MAX } });
|
|
1546
|
+
})
|
|
1547
|
+
.then(function (result) {
|
|
1548
|
+
var value = result && result.ok === true && result.value ? result.value : { available: false, sessionId: sessionId, prompts: [] };
|
|
1549
|
+
if (promptHistories.size >= 16) promptHistories.clear();
|
|
1550
|
+
promptHistories.set(sessionId, { at: Date.now(), value: value });
|
|
1551
|
+
var previous = state.promptHistory && state.promptHistory.sessionId === sessionId ? state.promptHistory.value : null;
|
|
1552
|
+
var same = previous !== null
|
|
1553
|
+
&& previous.available === value.available
|
|
1554
|
+
&& (previous.prompts || []).length === (value.prompts || []).length
|
|
1555
|
+
&& (previous.prompts || []).every(function (item, index) { return item === value.prompts[index]; });
|
|
1556
|
+
if (!same) set({ promptHistory: { sessionId: sessionId, value: value } });
|
|
1557
|
+
return value;
|
|
1558
|
+
})
|
|
1559
|
+
.catch(function () {
|
|
1560
|
+
return { available: false, sessionId: sessionId, prompts: [] };
|
|
1561
|
+
});
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
/**
|
|
1565
|
+
* 同步看一眼缓存:命中就返回条目,否则 null。
|
|
1566
|
+
*
|
|
1567
|
+
* 有了它,`ImageDiff` 的**第一次渲染**就能把已经取过的图画出来 ——
|
|
1568
|
+
* 关掉再打开同一张图不该先闪一下"读取图片…",也不该再发一次请求。
|
|
1569
|
+
*/
|
|
1570
|
+
function cachedImagePair(request) {
|
|
1571
|
+
if (request === null || request === undefined) return null;
|
|
1572
|
+
var entry = imagePairs.get(imagePairKey(request));
|
|
1573
|
+
return entry === undefined ? null : entry;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** 供 `ImageDiff` 用:取一个二进制改动两侧的字节与尺寸(同一个请求只发一次)。 */
|
|
1577
|
+
function loadImagePair(request) {
|
|
1578
|
+
var key = imagePairKey(request);
|
|
1579
|
+
var cached = imagePairs.get(key);
|
|
1580
|
+
if (cached !== undefined) return Promise.resolve(cached);
|
|
1581
|
+
|
|
1582
|
+
return Promise.resolve()
|
|
1583
|
+
.then(function () {
|
|
1584
|
+
return ctx.connection.rpc.call(CHANNEL, "image", { args: request });
|
|
1585
|
+
})
|
|
1586
|
+
.then(function (result) {
|
|
1587
|
+
var entry = result && result.ok === true && result.value
|
|
1588
|
+
? { phase: "ready", value: result.value }
|
|
1589
|
+
: { phase: "error", code: result && result.error && result.error.details ? result.error.details.code : null };
|
|
1590
|
+
if (imagePairs.size >= IMAGE_CACHE_MAX) imagePairs.clear();
|
|
1591
|
+
imagePairs.set(key, entry);
|
|
1592
|
+
return entry;
|
|
1593
|
+
})
|
|
1594
|
+
.catch(function () {
|
|
1595
|
+
return { phase: "error", code: "transport" };
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
|
|
1015
1599
|
/**
|
|
1016
1600
|
* 收起右栏。提交明细把差异包在里面,所以它优先整体退掉;
|
|
1017
1601
|
* 只有工作区差异时才只是关掉差异。
|
|
@@ -1134,6 +1718,19 @@ window.__ModuleLoader__.load({
|
|
|
1134
1718
|
persist();
|
|
1135
1719
|
}
|
|
1136
1720
|
|
|
1721
|
+
/**
|
|
1722
|
+
* 图片缩放的档位(`null` = 适应宽度)。
|
|
1723
|
+
*
|
|
1724
|
+
* 放在 store 而不是组件里:一来记住(和"自动换行"一样落盘),二来组件的 hooks
|
|
1725
|
+
* 在这个假 React 里驱动不了嵌套状态 —— store 的状态任何位置都读得到。
|
|
1726
|
+
*/
|
|
1727
|
+
function setZoom(value) {
|
|
1728
|
+
if (!state.diff) return;
|
|
1729
|
+
var next = value === null || ZOOM_STEPS.indexOf(value) >= 0 ? value : null;
|
|
1730
|
+
set({ diff: Object.assign({}, state.diff, { zoom: next }) });
|
|
1731
|
+
persist();
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1137
1734
|
/** dock 行的位置变了才写,避免每次测量都触发重渲染。 */
|
|
1138
1735
|
function setAnchorTop(top) {
|
|
1139
1736
|
if (state.anchorTop === top) return;
|
|
@@ -1624,10 +2221,19 @@ window.__ModuleLoader__.load({
|
|
|
1624
2221
|
setCommitWidth: setCommitWidth,
|
|
1625
2222
|
persistLayout: persist,
|
|
1626
2223
|
openDiff: openDiff,
|
|
2224
|
+
loadImagePair: loadImagePair,
|
|
2225
|
+
cachedImagePair: cachedImagePair,
|
|
2226
|
+
loadSessionChanges: loadSessionChanges,
|
|
2227
|
+
loadSessionTime: loadSessionTime,
|
|
2228
|
+
loadPromptHistory: loadPromptHistory,
|
|
2229
|
+
showTip: showTip,
|
|
2230
|
+
hideTip: hideTip,
|
|
2231
|
+
tipHandlers: tipHandlers,
|
|
1627
2232
|
closeDiff: closeDiff,
|
|
1628
2233
|
closeDetail: closeDetail,
|
|
1629
2234
|
setDiffSide: setDiffSide,
|
|
1630
2235
|
toggleDiffWrap: toggleDiffWrap,
|
|
2236
|
+
setZoom: setZoom,
|
|
1631
2237
|
openCommit: openCommit,
|
|
1632
2238
|
openCommitDiff: openCommitDiff,
|
|
1633
2239
|
toggleCommit: toggleCommit,
|
|
@@ -2237,10 +2843,432 @@ window.__ModuleLoader__.load({
|
|
|
2237
2843
|
* 差异视图:两侧页签(宿主半一次把两侧都取回来了,切页签不再发请求)、
|
|
2238
2844
|
* 自动换行、复制差异。全部只读——它只是把 git 的输出画出来。
|
|
2239
2845
|
*/
|
|
2846
|
+
/* ------------------------------------------------ 图片预览(二进制改动) */
|
|
2847
|
+
|
|
2848
|
+
/** 图片请求的缓存键:把请求的每个字段都带上,免得两侧共用一个键。 */
|
|
2849
|
+
function imagePairKey(request) {
|
|
2850
|
+
return [request.cwd || "", request.path || "", request.origPath || "", request.side || "", request.sha || ""].join("\u0000");
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
/**
|
|
2854
|
+
* 由 diff 状态推出 `image` 请求;推不出(没有 cwd / 文件)返回 null。
|
|
2855
|
+
*
|
|
2856
|
+
* 页签语义与文本 diff 完全一致:未暂存看「索引 → 工作区」,已暂存看「HEAD → 索引」,
|
|
2857
|
+
* 提交明细按 sha 取,所以两个视图里"未暂存 / 已暂存"指的是同一件事。
|
|
2858
|
+
*/
|
|
2859
|
+
function imageRequest(diff, cwd) {
|
|
2860
|
+
if (!diff || !diff.file || !cwd) return null;
|
|
2861
|
+
var request = { cwd: cwd, path: diff.file };
|
|
2862
|
+
if (diff.origPath) request.origPath = diff.origPath;
|
|
2863
|
+
if (diff.source === "commit" && diff.sha) request.sha = diff.sha;
|
|
2864
|
+
else request.side = diff.side === "index" ? "index" : "worktree";
|
|
2865
|
+
return request;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
/** 两侧的标签跟着"看的到底是哪两条记录"走。 */
|
|
2869
|
+
function imageLabels(diff, t) {
|
|
2870
|
+
if (diff.source === "commit") return { before: t("image.beforeCommit"), after: t("image.afterCommit") };
|
|
2871
|
+
if (diff.side === "index") return { before: t("image.beforeIndex"), after: t("image.afterIndex") };
|
|
2872
|
+
return { before: t("image.beforeWorktree"), after: t("image.afterWorktree") };
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2875
|
+
/** 字节数:十进制 KB/MB,和文件管理器里看到的一致。 */
|
|
2876
|
+
function formatBytes(bytes) {
|
|
2877
|
+
if (typeof bytes !== "number" || !isFinite(bytes) || bytes < 0) return "?";
|
|
2878
|
+
if (bytes < 1000) return String(bytes) + " B";
|
|
2879
|
+
if (bytes < 1000 * 1000) return (bytes / 1000).toFixed(1) + " KB";
|
|
2880
|
+
return (bytes / 1000 / 1000).toFixed(2) + " MB";
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/** 一侧画不出来时的原因文案(每一侧各有各的原因)。 */
|
|
2884
|
+
function sideReasonText(side, t) {
|
|
2885
|
+
if (side.kind === "too-large") {
|
|
2886
|
+
return t("image.tooLarge", {
|
|
2887
|
+
limit: String(Math.round((typeof side.limit === "number" ? side.limit : 0) / 1024 / 1024)),
|
|
2888
|
+
size: formatBytes(side.bytes),
|
|
2889
|
+
});
|
|
2890
|
+
}
|
|
2891
|
+
if (side.kind === "not-image") return t("image.notImage", { size: formatBytes(side.bytes) });
|
|
2892
|
+
return t("image.missing");
|
|
2893
|
+
}
|
|
2894
|
+
|
|
2895
|
+
/**
|
|
2896
|
+
* 两张都画不出来时说什么:能说清原因就说原因,否则退回原来那句话。
|
|
2897
|
+
* "太大"优先于"不是图" —— 前者才是用户会想处理的那件事。
|
|
2898
|
+
*/
|
|
2899
|
+
function imageFallbackText(t, before, after) {
|
|
2900
|
+
var sides = [before, after];
|
|
2901
|
+
var tooLarge = sides.filter(function (side) { return side.kind === "too-large"; })[0];
|
|
2902
|
+
if (tooLarge !== undefined) return sideReasonText(tooLarge, t);
|
|
2903
|
+
var notImage = sides.filter(function (side) { return side.kind === "not-image"; })[0];
|
|
2904
|
+
if (notImage !== undefined) return sideReasonText(notImage, t);
|
|
2905
|
+
return t("diff.binary");
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
/**
|
|
2909
|
+
* 二进制(图片)改动的预览:并排两张图,或按需在两张之间切换。
|
|
2910
|
+
*
|
|
2911
|
+
* 只在文本 diff 报 `binary` 时挂上来。字节来自宿主的 `image` 端点(只读),
|
|
2912
|
+
* 这里只负责画:单侧画不出来就画那一侧的原因,两侧都不行才退回原来那句话。
|
|
2913
|
+
*/
|
|
2914
|
+
function ImageDiff(props) {
|
|
2915
|
+
var t = props.t;
|
|
2916
|
+
var store = props.store;
|
|
2917
|
+
var diff = props.diff;
|
|
2918
|
+
var request = imageRequest(diff, props.cwd);
|
|
2919
|
+
var key = request === null ? null : imagePairKey(request);
|
|
2920
|
+
// 先同步看缓存:已经取过的图第一帧就画出来,不闪"读取图片…"。
|
|
2921
|
+
var viewRef = React.useState(function () {
|
|
2922
|
+
return store.cachedImagePair ? store.cachedImagePair(request) : null;
|
|
2923
|
+
});
|
|
2924
|
+
var modeRef = React.useState("pair");
|
|
2925
|
+
var flipRef = React.useState("after");
|
|
2926
|
+
|
|
2927
|
+
React.useEffect(function () {
|
|
2928
|
+
if (key === null) return undefined;
|
|
2929
|
+
var cancelled = false;
|
|
2930
|
+
var cached = store.cachedImagePair ? store.cachedImagePair(request) : null;
|
|
2931
|
+
if (cached !== null) {
|
|
2932
|
+
viewRef[1](cached);
|
|
2933
|
+
return undefined;
|
|
2934
|
+
}
|
|
2935
|
+
viewRef[1](null);
|
|
2936
|
+
store.loadImagePair(request).then(function (entry) {
|
|
2937
|
+
if (!cancelled) viewRef[1](entry);
|
|
2938
|
+
});
|
|
2939
|
+
return function () { cancelled = true; };
|
|
2940
|
+
}, [key]);
|
|
2941
|
+
|
|
2942
|
+
if (key === null) return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, t("diff.binary"));
|
|
2943
|
+
|
|
2944
|
+
var view = viewRef[0];
|
|
2945
|
+
if (view === null) return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, t("image.loading"));
|
|
2946
|
+
if (view.phase === "error") return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, errorText(view.code, t));
|
|
2947
|
+
|
|
2948
|
+
var value = view.value || {};
|
|
2949
|
+
var before = value.before || { kind: "missing" };
|
|
2950
|
+
var after = value.after || { kind: "missing" };
|
|
2951
|
+
if (before.kind !== "image" && after.kind !== "image") {
|
|
2952
|
+
return h("div", { className: "dshh-empty", "data-dsh-hud": "image" }, imageFallbackText(t, before, after));
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
var labels = imageLabels(diff, t);
|
|
2956
|
+
var mode = modeRef[0];
|
|
2957
|
+
var flip = flipRef[0];
|
|
2958
|
+
// 缩放住在 store 里(和"自动换行"一样记住):`null` = 适应宽度,数字 = 固定档位。
|
|
2959
|
+
var zoom = diff.zoom === undefined ? null : diff.zoom;
|
|
2960
|
+
var cells = mode === "pair"
|
|
2961
|
+
? [{ key: "before", label: labels.before, side: before }, { key: "after", label: labels.after, side: after }]
|
|
2962
|
+
: [{ key: flip, label: flip === "after" ? labels.after : labels.before, side: flip === "after" ? after : before }];
|
|
2963
|
+
|
|
2964
|
+
function cell(entry) {
|
|
2965
|
+
var side = entry.side;
|
|
2966
|
+
var body = side.kind === "image"
|
|
2967
|
+
? h("img", {
|
|
2968
|
+
className: "dshh-img",
|
|
2969
|
+
alt: entry.label,
|
|
2970
|
+
// 缩放:适应宽度交给 CSS(`max-width:100%`);固定档位按**固有像素**算宽度,
|
|
2971
|
+
// 因为宿主已经把宽高一起给我们了 —— 不用等图片加载就能算对。
|
|
2972
|
+
style: zoom === null || typeof side.width !== "number"
|
|
2973
|
+
? undefined
|
|
2974
|
+
: { width: Math.round(side.width * zoom) + "px", maxWidth: "none" },
|
|
2975
|
+
src: "data:" + side.mime + ";base64," + String(side.base64 || ""),
|
|
2976
|
+
})
|
|
2977
|
+
: h("div", { className: "dshh-imgNote" }, sideReasonText(side, t));
|
|
2978
|
+
return h("div", { className: "dshh-imgCell", key: entry.key },
|
|
2979
|
+
h("div", { className: "dshh-imgLabel" }, entry.label),
|
|
2980
|
+
body);
|
|
2981
|
+
}
|
|
2982
|
+
|
|
2983
|
+
function tab(id, label) {
|
|
2984
|
+
var active = mode === id;
|
|
2985
|
+
return h("button", {
|
|
2986
|
+
type: "button",
|
|
2987
|
+
className: "dshh-tab" + (active ? " dshh-tabActive" : ""),
|
|
2988
|
+
"aria-pressed": active ? "true" : "false",
|
|
2989
|
+
onClick: function () { modeRef[1](id); },
|
|
2990
|
+
}, label);
|
|
2991
|
+
}
|
|
2992
|
+
|
|
2993
|
+
// 尺寸变了才是这张图最值得一眼看到的信息(改图最常见的就是换尺寸)。
|
|
2994
|
+
var dimsChanged = before.kind === "image" && after.kind === "image"
|
|
2995
|
+
&& typeof before.width === "number" && typeof after.width === "number"
|
|
2996
|
+
&& (before.width !== after.width || before.height !== after.height)
|
|
2997
|
+
? h("span", { className: "dshh-imgDims" }, t("image.dimsChanged", {
|
|
2998
|
+
from: t("image.dims", { w: String(before.width), h: String(before.height) }),
|
|
2999
|
+
to: t("image.dims", { w: String(after.width), h: String(after.height) }),
|
|
3000
|
+
}))
|
|
3001
|
+
: null;
|
|
3002
|
+
var bytes = before.kind === "image" && after.kind === "image"
|
|
3003
|
+
? h("span", { className: "dshh-imgBytes" }, formatBytes(before.bytes) + " → " + formatBytes(after.bytes))
|
|
3004
|
+
: null;
|
|
3005
|
+
|
|
3006
|
+
// 缩放按钮:内置那个文档预览把缩放藏在"预览区底部 72px 的悬停感应带"里
|
|
3007
|
+
// (CSS 默认 opacity:0,还得 Ctrl+滚轮),这里给一排看得见、点的着的。
|
|
3008
|
+
var zoomControls = h("span", { className: "dshh-imgZoom", "data-dsh-hud": "image-zoom" }, [
|
|
3009
|
+
h("button", {
|
|
3010
|
+
key: "out",
|
|
3011
|
+
type: "button",
|
|
3012
|
+
className: "dshh-imgZoomBtn",
|
|
3013
|
+
title: t("image.zoomOut"),
|
|
3014
|
+
"data-dsh-hud": "image-zoom-out",
|
|
3015
|
+
onClick: function () { store.setZoom(stepZoom(zoom, -1)); },
|
|
3016
|
+
}, "−"),
|
|
3017
|
+
h("button", {
|
|
3018
|
+
key: "value",
|
|
3019
|
+
type: "button",
|
|
3020
|
+
className: "dshh-imgZoomBtn dshh-imgZoomValue",
|
|
3021
|
+
title: t("image.fitWidth"),
|
|
3022
|
+
"data-dsh-hud": "image-zoom-value",
|
|
3023
|
+
onClick: function () { store.setZoom(null); },
|
|
3024
|
+
}, zoom === null ? t("image.fitWidth") : t("image.zoomValue", { percent: String(Math.round(zoom * 100)) })),
|
|
3025
|
+
h("button", {
|
|
3026
|
+
key: "in",
|
|
3027
|
+
type: "button",
|
|
3028
|
+
className: "dshh-imgZoomBtn",
|
|
3029
|
+
title: t("image.zoomIn"),
|
|
3030
|
+
"data-dsh-hud": "image-zoom-in",
|
|
3031
|
+
onClick: function () { store.setZoom(stepZoom(zoom, 1)); },
|
|
3032
|
+
}, "+"),
|
|
3033
|
+
]);
|
|
3034
|
+
|
|
3035
|
+
return h("div", { className: "dshh-imgWrap", "data-dsh-hud": "image" },
|
|
3036
|
+
h("div", { className: "dshh-imgMeta" },
|
|
3037
|
+
tab("pair", t("image.pair")),
|
|
3038
|
+
mode === "flip"
|
|
3039
|
+
? h("button", {
|
|
3040
|
+
type: "button",
|
|
3041
|
+
className: "dshh-tab",
|
|
3042
|
+
title: t("image.flip"),
|
|
3043
|
+
"data-dsh-hud": "image-flip",
|
|
3044
|
+
onClick: function () { flipRef[1](flip === "after" ? "before" : "after"); },
|
|
3045
|
+
}, t("image.flip"))
|
|
3046
|
+
: null,
|
|
3047
|
+
zoomControls,
|
|
3048
|
+
dimsChanged,
|
|
3049
|
+
bytes),
|
|
3050
|
+
h("div", {
|
|
3051
|
+
className: "dshh-imgPair" + (mode === "flip" ? " dshh-imgPairSingle" : "") + (zoom === null ? "" : " dshh-imgPairZoomed"),
|
|
3052
|
+
}, cells.map(cell)));
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
/* ----------------------------------------- 轮尾:本轮改过的图片(一条门) */
|
|
3056
|
+
|
|
3057
|
+
/** 图片类文件的扩展名。这里只做粗筛 —— 真正"是不是图"由宿主按文件头判断。 */
|
|
3058
|
+
var IMAGE_FILE_EXTENSIONS = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico", "avif"];
|
|
3059
|
+
|
|
3060
|
+
/** 按扩展名粗筛"这看起来是张图"。 */
|
|
3061
|
+
function looksLikeImage(file) {
|
|
3062
|
+
if (typeof file !== "string") return false;
|
|
3063
|
+
var dot = file.lastIndexOf(".");
|
|
3064
|
+
if (dot < 0 || dot === file.length - 1) return false;
|
|
3065
|
+
return IMAGE_FILE_EXTENSIONS.indexOf(file.slice(dot + 1).toLowerCase()) >= 0;
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
/** 只取文件名(chip 上放不下整条路径)。 */
|
|
3069
|
+
function baseNameOf(file) {
|
|
3070
|
+
var parts = String(file).split("/");
|
|
3071
|
+
return parts[parts.length - 1] || String(file);
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
/**
|
|
3075
|
+
* 槽位给的是宿主的 `TurnLocation`。
|
|
3076
|
+
*
|
|
3077
|
+
* 形状属于宿主内部约定(槽位文档只说"receives the Turn"),所以这里宽松取值:
|
|
3078
|
+
* 取不到就回 null,由调用方退到"本会话"的说法,而不是整条不显示。
|
|
3079
|
+
*/
|
|
3080
|
+
function turnNumberOf(turn) {
|
|
3081
|
+
if (typeof turn === "number" && isFinite(turn)) return turn;
|
|
3082
|
+
if (turn && typeof turn === "object" && typeof turn.turn === "number") return turn.turn;
|
|
3083
|
+
return null;
|
|
3084
|
+
}
|
|
3085
|
+
|
|
3086
|
+
/**
|
|
3087
|
+
* 从会话改动里挑出"这一轮改过的图片",最近的在前。
|
|
3088
|
+
*
|
|
3089
|
+
* **两档都收**:`●` 确定改过(写文件 / 编辑工具)与 `◐` 命令旁听(`>`、`tee`、`cp`、`rm`…)。
|
|
3090
|
+
* 只收确定档不够用 —— 图几乎都是脚本/命令产出的(`python gen.py`、`cp`、`curl -o`),
|
|
3091
|
+
* 那些永远进不了确定档,而"点一下看图"对它们一样有用。`◐` 是猜的,所以 chip 上带 `◐`、
|
|
3092
|
+
* 悬停给出那条命令(措辞与面板里改动清单一致)。
|
|
3093
|
+
*
|
|
3094
|
+
* 回合号拿不到(`turnNumberOf` 回 null)就不按回合收窄,退回"本会话最近的图片改动"。
|
|
3095
|
+
*
|
|
3096
|
+
* @returns `{ files, exact }`;`exact` 为 false 表示标题该说"本会话"而不是"本轮"。
|
|
3097
|
+
*/
|
|
3098
|
+
function imageChangesOf(changes, turnNumber) {
|
|
3099
|
+
if (changes === null || changes === undefined) return { files: [], exact: turnNumber !== null };
|
|
3100
|
+
var confirmed = changes.files ? changes.files : {};
|
|
3101
|
+
var guessed = changes.possible ? changes.possible : {};
|
|
3102
|
+
var picked = [];
|
|
3103
|
+
var seen = {};
|
|
3104
|
+
|
|
3105
|
+
for (var file in confirmed) {
|
|
3106
|
+
if (!Object.prototype.hasOwnProperty.call(confirmed, file)) continue;
|
|
3107
|
+
if (!looksLikeImage(file)) continue;
|
|
3108
|
+
var record = confirmed[file] || {};
|
|
3109
|
+
if (turnNumber !== null) {
|
|
3110
|
+
var turns = Array.isArray(record.turns) ? record.turns : [];
|
|
3111
|
+
if (turns.indexOf(turnNumber) < 0 && record.lastTurn !== turnNumber) continue;
|
|
3112
|
+
}
|
|
3113
|
+
seen[file] = true;
|
|
3114
|
+
picked.push({
|
|
3115
|
+
file: file,
|
|
3116
|
+
name: baseNameOf(file),
|
|
3117
|
+
tier: "confirmed",
|
|
3118
|
+
reason: "",
|
|
3119
|
+
at: typeof record.lastAt === "number" ? record.lastAt : 0,
|
|
3120
|
+
});
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
for (var guess in guessed) {
|
|
3124
|
+
if (!Object.prototype.hasOwnProperty.call(guessed, guess)) continue;
|
|
3125
|
+
if (seen[guess] === true) continue;
|
|
3126
|
+
if (!looksLikeImage(guess)) continue;
|
|
3127
|
+
var item = guessed[guess] || {};
|
|
3128
|
+
// 命令旁听只记"最近一次"的回合号(没有 turns 列表),所以只能按 lastTurn 收窄。
|
|
3129
|
+
if (turnNumber !== null && item.lastTurn !== turnNumber) continue;
|
|
3130
|
+
seen[guess] = true;
|
|
3131
|
+
picked.push({
|
|
3132
|
+
file: guess,
|
|
3133
|
+
name: baseNameOf(guess),
|
|
3134
|
+
tier: "possible",
|
|
3135
|
+
reason: typeof item.reason === "string" ? item.reason : "",
|
|
3136
|
+
at: typeof item.lastAt === "number" ? item.lastAt : 0,
|
|
3137
|
+
});
|
|
3138
|
+
}
|
|
3139
|
+
|
|
3140
|
+
picked.sort(function (left, right) { return right.at - left.at; });
|
|
3141
|
+
return { files: picked, exact: turnNumber !== null };
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
/** 缩略图用哪一侧的字节:优先工作区,其次基线(新增/删除时只有一侧有)。 */
|
|
3145
|
+
function thumbSideOf(entry) {
|
|
3146
|
+
if (entry === null || entry.phase !== "ready" || !entry.value) return null;
|
|
3147
|
+
var pair = entry.value;
|
|
3148
|
+
if (pair.after && pair.after.kind === "image") return pair.after;
|
|
3149
|
+
if (pair.before && pair.before.kind === "image") return pair.before;
|
|
3150
|
+
return null;
|
|
3151
|
+
}
|
|
3152
|
+
|
|
3153
|
+
/**
|
|
3154
|
+
* 轮尾那条「本轮图片改动」:每项一张缩略图 + 文件名。
|
|
3155
|
+
*
|
|
3156
|
+
* 内置的改动卡片点一行是打开 review tab(图片在那儿看不见),这条是补上的门:
|
|
3157
|
+
* **点缩略图 → 槽位给的 `openFile(path)`**,即内置的右侧文档预览(图片原生渲染、可缩放);
|
|
3158
|
+
* 旁边那个 `⇄` 才打开 dsh-hud 面板看前后两张 —— 前后对比只有我们有。
|
|
3159
|
+
*/
|
|
3160
|
+
function TurnImages(props) {
|
|
3161
|
+
var t = props.t;
|
|
3162
|
+
var store = props.store;
|
|
3163
|
+
var sessionId = props.sessionId;
|
|
3164
|
+
var useSessions = props.useSessions;
|
|
3165
|
+
var openFile = props.openFile;
|
|
3166
|
+
var turnNumber = turnNumberOf(props.turn);
|
|
3167
|
+
|
|
3168
|
+
// useSessions 是选择器 hook:选择器参数是必需的。
|
|
3169
|
+
var sessions = typeof useSessions === "function" ? useSessions(function (snapshot) { return snapshot; }) : null;
|
|
3170
|
+
var summary = sessionId !== undefined && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
|
|
3171
|
+
var cwd = summary ? summary.cwd : undefined;
|
|
3172
|
+
|
|
3173
|
+
var state = useStore(store);
|
|
3174
|
+
// 缩略图要等字节;缓存本身不在快照里,所以取完自增一下触发重画。
|
|
3175
|
+
var tick = React.useState(0);
|
|
3176
|
+
var bump = tick[1];
|
|
3177
|
+
|
|
3178
|
+
// 轮尾每个已完成的回合都会挂一次:绑定会话 + 没取过会话改动就取一次(宿主侧是内存读,很便宜)。
|
|
3179
|
+
React.useEffect(function () {
|
|
3180
|
+
if (sessionId === undefined || cwd === undefined) return undefined;
|
|
3181
|
+
store.bindSession(sessionId, cwd);
|
|
3182
|
+
if (state.sessionChanges === undefined || state.sessionChanges.phase === "idle") store.loadSessionChanges();
|
|
3183
|
+
return undefined;
|
|
3184
|
+
}, [sessionId, cwd]);
|
|
3185
|
+
|
|
3186
|
+
var picked = imageChangesOf(state.sessionChanges, turnNumber);
|
|
3187
|
+
var shown = picked.files.slice(0, 4);
|
|
3188
|
+
var keys = shown.map(function (entry) { return entry.file; }).join("\u0000");
|
|
3189
|
+
|
|
3190
|
+
React.useEffect(function () {
|
|
3191
|
+
if (keys === "" || cwd === undefined) return undefined;
|
|
3192
|
+
var cancelled = false;
|
|
3193
|
+
var wanted = shown.map(function (entry) {
|
|
3194
|
+
return { cwd: cwd, path: entry.file, side: "worktree" };
|
|
3195
|
+
}).filter(function (request) {
|
|
3196
|
+
return store.cachedImagePair(request) === null;
|
|
3197
|
+
});
|
|
3198
|
+
if (wanted.length === 0) return undefined;
|
|
3199
|
+
Promise.all(wanted.map(function (request) { return store.loadImagePair(request); })).then(function () {
|
|
3200
|
+
if (!cancelled) bump(function (count) { return count + 1; });
|
|
3201
|
+
});
|
|
3202
|
+
return function () { cancelled = true; };
|
|
3203
|
+
}, [keys, cwd]);
|
|
3204
|
+
|
|
3205
|
+
// 会话改动还没到、或这一轮没动过图:整条不渲染(不在每条轮尾留个空壳)。
|
|
3206
|
+
if (state.sessionChanges === undefined || state.sessionChanges.phase !== "ready") return null;
|
|
3207
|
+
if (shown.length === 0) return null;
|
|
3208
|
+
|
|
3209
|
+
var rest = picked.files.length - shown.length;
|
|
3210
|
+
|
|
3211
|
+
function chip(entry) {
|
|
3212
|
+
var thumb = thumbSideOf(cwd === undefined ? null : store.cachedImagePair({ cwd: cwd, path: entry.file, side: "worktree" }));
|
|
3213
|
+
var dot = entry.name.lastIndexOf(".");
|
|
3214
|
+
var extension = dot > 0 ? entry.name.slice(dot + 1).toUpperCase() : "?";
|
|
3215
|
+
// `◐` 是命令旁听来的(猜的):说清这一点,并把那条命令放进悬停里。
|
|
3216
|
+
var hint = entry.file + " · " + t("turnImages.open");
|
|
3217
|
+
if (entry.tier === "possible" && entry.reason !== "") hint += " · " + t("session.possibleTitle", { reason: entry.reason });
|
|
3218
|
+
return h("span", {
|
|
3219
|
+
key: entry.file,
|
|
3220
|
+
className: "dshh-imgstripItem",
|
|
3221
|
+
"data-dsh-hud": "turn-image",
|
|
3222
|
+
"data-file": entry.file,
|
|
3223
|
+
"data-tier": entry.tier,
|
|
3224
|
+
}, [
|
|
3225
|
+
h("button", {
|
|
3226
|
+
key: "open",
|
|
3227
|
+
type: "button",
|
|
3228
|
+
className: "dshh-imgstripOpen",
|
|
3229
|
+
title: hint,
|
|
3230
|
+
onClick: function () { if (typeof openFile === "function") openFile(entry.file); },
|
|
3231
|
+
}, [
|
|
3232
|
+
thumb === null
|
|
3233
|
+
? h("span", { key: "ph", className: "dshh-imgstripThumb dshh-imgstripThumbEmpty" }, extension)
|
|
3234
|
+
: h("img", {
|
|
3235
|
+
key: "img",
|
|
3236
|
+
className: "dshh-imgstripThumb",
|
|
3237
|
+
alt: entry.name,
|
|
3238
|
+
src: "data:" + thumb.mime + ";base64," + String(thumb.base64 || ""),
|
|
3239
|
+
}),
|
|
3240
|
+
entry.tier === "possible"
|
|
3241
|
+
? h("span", { key: "tier", className: "dshh-imgstripTier", title: hint }, "◐")
|
|
3242
|
+
: null,
|
|
3243
|
+
h("span", { key: "name", className: "dshh-imgstripName" }, entry.name),
|
|
3244
|
+
]),
|
|
3245
|
+
h("button", {
|
|
3246
|
+
key: "compare",
|
|
3247
|
+
type: "button",
|
|
3248
|
+
className: "dshh-imgstripCompare",
|
|
3249
|
+
title: t("turnImages.compare"),
|
|
3250
|
+
"data-dsh-hud": "turn-image-compare",
|
|
3251
|
+
onClick: function () {
|
|
3252
|
+
store.openPanel(true);
|
|
3253
|
+
store.openDiff({ file: entry.file, path: entry.file, origFile: null });
|
|
3254
|
+
},
|
|
3255
|
+
}, "⇄"),
|
|
3256
|
+
]);
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
return h("div", { className: "dshh-imgstrip", "data-dsh-hud": "turn-images" }, [
|
|
3260
|
+
h("span", { key: "title", className: "dshh-imgstripTitle" }, picked.exact ? t("turnImages.title") : t("turnImages.titleSession")),
|
|
3261
|
+
h("span", { key: "items", className: "dshh-imgstripItems" }, shown.map(chip)),
|
|
3262
|
+
rest > 0 ? h("span", { key: "more", className: "dshh-imgstripMore" }, t("turnImages.more", { n: String(rest) })) : null,
|
|
3263
|
+
]);
|
|
3264
|
+
}
|
|
3265
|
+
|
|
2240
3266
|
function DiffView(props) {
|
|
2241
3267
|
var t = props.t;
|
|
2242
3268
|
var diff = props.diff;
|
|
2243
3269
|
var store = props.store;
|
|
3270
|
+
// 图片预览要按工作区发请求;文本 diff 用不到它。
|
|
3271
|
+
var cwd = props.cwd;
|
|
2244
3272
|
|
|
2245
3273
|
var copiedRef = React.useState(false);
|
|
2246
3274
|
var copied = copiedRef[0];
|
|
@@ -2308,7 +3336,8 @@ window.__ModuleLoader__.load({
|
|
|
2308
3336
|
} else if (!data) {
|
|
2309
3337
|
body = h("div", { className: "dshh-empty" }, t("diff.empty"));
|
|
2310
3338
|
} else if (data.binary === true) {
|
|
2311
|
-
|
|
3339
|
+
// 二进制里最常被改的就是图:能认出来就画出来,认不出才退回那句话。
|
|
3340
|
+
body = h(ImageDiff, { t: t, store: store, diff: diff, cwd: cwd });
|
|
2312
3341
|
} else if (rows.length === 0) {
|
|
2313
3342
|
body = h("div", { className: "dshh-empty" }, t("diff.empty"));
|
|
2314
3343
|
} else {
|
|
@@ -2634,6 +3663,24 @@ window.__ModuleLoader__.load({
|
|
|
2634
3663
|
var toolsTickKey = state.tools && state.tools.active !== null && state.tools.active !== undefined
|
|
2635
3664
|
? String(state.tools.active.callId)
|
|
2636
3665
|
: null;
|
|
3666
|
+
// 会话计时 + 输入框 ↑ 召回的数据(两个 loader 各自带 TTL,这里按最密的节奏问就行)。
|
|
3667
|
+
React.useEffect(function () {
|
|
3668
|
+
if (sessionId === undefined) return undefined;
|
|
3669
|
+
var load = function () {
|
|
3670
|
+
if (store.loadSessionTime !== undefined) store.loadSessionTime(sessionId);
|
|
3671
|
+
if (store.loadPromptHistory !== undefined) store.loadPromptHistory(sessionId);
|
|
3672
|
+
};
|
|
3673
|
+
load();
|
|
3674
|
+
var poll = window.setInterval(load, 3000);
|
|
3675
|
+
return function () { window.clearInterval(poll); };
|
|
3676
|
+
}, [sessionId]);
|
|
3677
|
+
|
|
3678
|
+
// 挂钟每 4 秒跳一次:计时胶囊的显示粒度是分钟,但跨分钟不用等下一次取数。
|
|
3679
|
+
React.useEffect(function () {
|
|
3680
|
+
var clock = window.setInterval(function () { tickSet(Date.now()); }, 4000);
|
|
3681
|
+
return function () { window.clearInterval(clock); };
|
|
3682
|
+
}, []);
|
|
3683
|
+
|
|
2637
3684
|
React.useEffect(function () {
|
|
2638
3685
|
if (toolsTickKey === null) return undefined;
|
|
2639
3686
|
var timer = window.setInterval(function () { tickSet(Date.now()); }, 500);
|
|
@@ -2714,27 +3761,55 @@ window.__ModuleLoader__.load({
|
|
|
2714
3761
|
// 外层容器复刻框架自己的 dock 行几何(见 queue 公告):仅靠胶囊自身对齐,
|
|
2715
3762
|
// 胶囊会飘到整栏的最左边,而输入卡片是居中的。
|
|
2716
3763
|
return h("div", { className: "dshh-dock", "data-dsh-hud": "dock" },
|
|
2717
|
-
h("button", {
|
|
3764
|
+
h("button", Object.assign({
|
|
2718
3765
|
type: "button",
|
|
2719
3766
|
className: "dshh-pill",
|
|
2720
3767
|
"data-dsh-hud": "pill",
|
|
2721
|
-
title: titleLines.join("\n"),
|
|
2722
3768
|
"aria-expanded": state.panelOpen ? "true" : "false",
|
|
2723
3769
|
style: { color: state.panelOpen ? "var(--dsw-alias-brand-primary, #4b8bf5)" : undefined },
|
|
2724
3770
|
onClick: function () {
|
|
2725
3771
|
store.togglePanel();
|
|
2726
3772
|
},
|
|
2727
|
-
}, children),
|
|
3773
|
+
}, store.tipHandlers("pill", function () { return titleLines.join("\n"); })), children),
|
|
2728
3774
|
// 工具活动那一枚并排在后面 —— dock 是竖列,独立注册会另起一整行(用户实测"换行了")。
|
|
2729
|
-
toolPillNode(state, { t: t, now: props.now === undefined ? tick[0] : props.now })
|
|
3775
|
+
toolPillNode(state, { t: t, store: store, now: props.now === undefined ? tick[0] : props.now }),
|
|
3776
|
+
// 计时胶囊放在**最后**(三枚同在一条注册里 → 同一行;独立注册会另起一整行)。
|
|
3777
|
+
timerPillNode(state, { t: t, store: store, sessionId: sessionId, now: props.now === undefined ? tick[0] : props.now }));
|
|
2730
3778
|
}
|
|
2731
3779
|
|
|
2732
3780
|
/**
|
|
2733
|
-
*
|
|
3781
|
+
* 胶囊悬停提示的气泡。**自己画**,不用浏览器原生 `title`。
|
|
3782
|
+
*
|
|
3783
|
+
* 由一个独立的 `shell.overlay` 座位渲染、`position:fixed` 定位 —— 这样它不受 dock 那行的
|
|
3784
|
+
* 裁切与堆叠上下文限制。位置只用锚点矩形的 `top` 与 `right`:
|
|
3785
|
+
* 贴在那枚胶囊**上方**,横向按视口夹住(不量自己的宽度,避免"先渲染再回填"的抖动)。
|
|
3786
|
+
*/
|
|
3787
|
+
function PillTip(props) {
|
|
3788
|
+
var store = props.store;
|
|
3789
|
+
var state = useStore(store);
|
|
3790
|
+
var tip = state.tip;
|
|
3791
|
+
|
|
3792
|
+
if (tip === null || tip === undefined) return null;
|
|
3793
|
+
var innerWidth = typeof window.innerWidth === "number" ? window.innerWidth : 1200;
|
|
3794
|
+
var innerHeight = typeof window.innerHeight === "number" ? window.innerHeight : 800;
|
|
3795
|
+
var margin = 8;
|
|
3796
|
+
var left = Math.max(margin, Math.min(typeof tip.rect.left === "number" ? tip.rect.left : margin, innerWidth - 428));
|
|
3797
|
+
var bottom = Math.max(margin, innerHeight - (typeof tip.rect.top === "number" ? tip.rect.top : 0) + 6);
|
|
3798
|
+
|
|
3799
|
+
return h("div", {
|
|
3800
|
+
className: "dshh-tip",
|
|
3801
|
+
"data-dsh-hud": "pill-tip",
|
|
3802
|
+
role: "tooltip",
|
|
3803
|
+
style: { left: String(left) + "px", bottom: String(bottom) + "px" },
|
|
3804
|
+
}, tip.text);
|
|
3805
|
+
}
|
|
3806
|
+
|
|
3807
|
+
/**
|
|
3808
|
+
* 本会话工具活动的胶囊(**与 Git 胶囊同一行**:dock 是竖列,独立注册会另起一行,
|
|
3809
|
+
* 所以它只是那一条注册里的第二个节点)。
|
|
2734
3810
|
*
|
|
2735
3811
|
* 照 codex-hud 那条 `Tools:` 的口径:**正在跑**就报工具名与实时秒数(秒数本地算,不靠轮询);
|
|
2736
|
-
*
|
|
2737
|
-
* 空白注册会白占一行(dock 是竖列,一条注册就是一整行)。
|
|
3812
|
+
* 空闲就报最近一次是哪个工具、本会话一共几次(失败打叉)。完全没跑过工具时整枚不画。
|
|
2738
3813
|
*/
|
|
2739
3814
|
/**
|
|
2740
3815
|
* 工具活动那一枚(**与 Git 胶囊同一行**,所以它只是那一行里的第二个节点,不是独立注册)。
|
|
@@ -2805,12 +3880,47 @@ window.__ModuleLoader__.load({
|
|
|
2805
3880
|
active: tools.active === null ? null : String(tools.active.name),
|
|
2806
3881
|
total: tools.total,
|
|
2807
3882
|
});
|
|
2808
|
-
return h("span", {
|
|
3883
|
+
return h("span", Object.assign({
|
|
2809
3884
|
className: "dshh-pill dshh-toolsPill",
|
|
2810
3885
|
"data-dsh-hud": "tools-pill",
|
|
2811
3886
|
key: "tools",
|
|
2812
|
-
|
|
2813
|
-
|
|
3887
|
+
}, options.store.tipHandlers("tools-pill", function () { return toolsTitle(tools, t); })), children);
|
|
3888
|
+
}
|
|
3889
|
+
|
|
3890
|
+
/**
|
|
3891
|
+
* 会话计时胶囊(**与 Git / 工具两枚同一行、排在最后**,所以它只是那一条注册里的第三个节点 ——
|
|
3892
|
+
* dock 是竖列,独立注册会另起一整行,工具胶囊与计时胶囊都踩过这个坑)。
|
|
3893
|
+
*
|
|
3894
|
+
* 口径与 codex-hud 的会话时长一致:挂钟 = 现在 − 会话创建时刻;悬停补上模型/工具墙钟与轮次。
|
|
3895
|
+
* 数据来自 store 快照(`sessionTime`),所以这里是纯函数,没有 hooks。
|
|
3896
|
+
*/
|
|
3897
|
+
function timerPillNode(state, options) {
|
|
3898
|
+
var t = options.t;
|
|
3899
|
+
var sessionId = options.sessionId;
|
|
3900
|
+
var now = options.now;
|
|
3901
|
+
var snapshot = state.sessionTime;
|
|
3902
|
+
var value = snapshot !== null && snapshot !== undefined && snapshot.sessionId === sessionId ? snapshot.value : null;
|
|
3903
|
+
// 会话不在(还没恢复进存储、已归档、子会话已回收)、宿主服务缺失:不画这枚胶囊,也不占位。
|
|
3904
|
+
if (value === null || value.available !== true || typeof value.createdAt !== "number") return null;
|
|
3905
|
+
|
|
3906
|
+
var age = Math.max(0, now - value.createdAt);
|
|
3907
|
+
var hints = [t("timer.title"), t("timer.created", { time: fullWhen(value.createdAt) })];
|
|
3908
|
+
if (typeof value.llmMs === "number" && typeof value.toolMs === "number") {
|
|
3909
|
+
hints.push(t("timer.work", { llm: formatDuration(value.llmMs), tool: formatDuration(value.toolMs) }));
|
|
3910
|
+
}
|
|
3911
|
+
if (typeof value.turns === "number" && typeof value.steps === "number") {
|
|
3912
|
+
hints.push(t("timer.turns", { turns: String(value.turns), steps: String(value.steps) }));
|
|
3913
|
+
}
|
|
3914
|
+
probe.log("timer-pill", { age: Math.round(age / 60000) });
|
|
3915
|
+
var tipKey = "session-timer";
|
|
3916
|
+
return h("span", Object.assign({
|
|
3917
|
+
className: "dshh-pill dshh-timer",
|
|
3918
|
+
"data-dsh-hud": "session-timer",
|
|
3919
|
+
key: "timer",
|
|
3920
|
+
}, options.store.tipHandlers(tipKey, function () { return hints.join(" · "); })), [
|
|
3921
|
+
h("span", { key: "glyph", className: "dshh-timerGlyph" }, "⏱"),
|
|
3922
|
+
h("span", { key: "value", className: "dshh-timerValue" }, formatDuration(age)),
|
|
3923
|
+
]);
|
|
2814
3924
|
}
|
|
2815
3925
|
|
|
2816
3926
|
/** 已经跑了多久:`1.2s` / `12s`(拿不到开始时刻就返回 null,不编一个数)。 */
|
|
@@ -3310,7 +4420,7 @@ window.__ModuleLoader__.load({
|
|
|
3310
4420
|
onPointerDown: function (event) { onGripPointerDown("commit", event); },
|
|
3311
4421
|
})
|
|
3312
4422
|
: null,
|
|
3313
|
-
diffMode ? h(DiffView, { t: t, diff: state.diff, store: store }) : null)
|
|
4423
|
+
diffMode ? h(DiffView, { t: t, diff: state.diff, store: store, cwd: state.cwd }) : null)
|
|
3314
4424
|
: listBody;
|
|
3315
4425
|
|
|
3316
4426
|
return h("div", { className: "dshh-panel", "data-dsh-hud": "panel", style: panelStyle },
|
|
@@ -3396,6 +4506,16 @@ window.__ModuleLoader__.load({
|
|
|
3396
4506
|
"background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-secondary,#8a8f98);",
|
|
3397
4507
|
"cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
|
|
3398
4508
|
".dshh-pill:hover{color:var(--dsw-alias-label-primary,#e5e7eb);border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
|
|
4509
|
+
// 会话计时胶囊:只读读数,所以指针不该是"可点";数字用等宽数字,跳动时宽度不抖。
|
|
4510
|
+
".dshh-timer{cursor:default;gap:5px;}",
|
|
4511
|
+
// 悬停气泡:`pointer-events:none` 保证它永远不挡指针;`pre-wrap` 保留调用方给的多行结构。
|
|
4512
|
+
".dshh-tip{position:fixed;z-index:60;box-sizing:border-box;max-width:420px;padding:6px 10px;border-radius:8px;"
|
|
4513
|
+
+ "background:var(--dsw-specific-menu,var(--dsw-alias-bg-layer-2,#2c2c2e));"
|
|
4514
|
+
+ "border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));"
|
|
4515
|
+
+ "box-shadow:0 6px 20px rgba(0,0,0,0.28);color:var(--dsw-alias-label-primary,#f9fafb);"
|
|
4516
|
+
+ "font-size:11.5px;line-height:1.55;white-space:pre-wrap;word-break:break-word;pointer-events:none;}",
|
|
4517
|
+
".dshh-timerGlyph{flex:none;opacity:.85;}",
|
|
4518
|
+
".dshh-timerValue{font-variant-numeric:tabular-nums;}",
|
|
3399
4519
|
".dshh-panel{color:var(--dsw-alias-label-primary,#e5e7eb);font-size:12.5px;line-height:1.6;}",
|
|
3400
4520
|
".dshh-header{display:flex;align-items:center;gap:8px;padding:6px 8px;flex:none;cursor:move;line-height:1.4;",
|
|
3401
4521
|
"user-select:none;touch-action:none;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
|
|
@@ -3446,14 +4566,17 @@ window.__ModuleLoader__.load({
|
|
|
3446
4566
|
".dshh-rowhover{display:flex;flex-direction:column;gap:2px;font-size:11px;",
|
|
3447
4567
|
"color:var(--dsw-alias-label-secondary,#8a8f98);min-width:0;}",
|
|
3448
4568
|
".dshh-rowhoverLine{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
|
|
4569
|
+
// 「已审 x/y」是个可点控件:同样不能用 label-dimmed(反相表面色,1.26:1 / 1.64:1),
|
|
4570
|
+
// 平时用次级文字色,悬停才升到正文色。
|
|
3449
4571
|
".dshh-review{flex:none;margin-left:auto;padding:0 2px;font-size:10.5px;cursor:pointer;",
|
|
3450
|
-
"color:var(--dsw-alias-label-
|
|
4572
|
+
"color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3451
4573
|
".dshh-review:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
|
|
3452
4574
|
".dshh-reviewOn{color:var(--dsw-alias-state-success-primary,#3fb950);}",
|
|
3453
4575
|
".dshh-reviewStale{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
|
|
3454
4576
|
".dshh-ctag{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;",
|
|
3455
4577
|
"font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
|
|
3456
|
-
"
|
|
4578
|
+
// 行首的 `○`(与本会话无关):要"最不显眼",但同样得看得见 —— 与上面同一个理由。
|
|
4579
|
+
".dshh-markerOther{color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3457
4580
|
// 工具活动胶囊:与 Git 胶囊同形,但它是只读读数,指针不该是“可点”。
|
|
3458
4581
|
// 与 codex-hud 对齐的几支色(它的默认配色表:git=magenta、gitBranch=cyan、project=yellow):
|
|
3459
4582
|
// 产品没有青/品红的语义变量(只有 button-info-*,那是按钮用的),所以这两支写死值,
|
|
@@ -3520,6 +4643,57 @@ window.__ModuleLoader__.load({
|
|
|
3520
4643
|
".dshh-cwhen{flex:none;font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3521
4644
|
".dshh-note{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
3522
4645
|
".dshh-empty{padding:14px 12px;font-size:12px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4646
|
+
// ---- 图片预览(二进制改动) ----
|
|
4647
|
+
".dshh-imgWrap{flex:1 1 auto;min-height:0;overflow:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}",
|
|
4648
|
+
".dshh-imgMeta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;",
|
|
4649
|
+
"color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4650
|
+
".dshh-imgDims{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
|
|
4651
|
+
".dshh-imgBytes{font-family:ui-monospace,Consolas,monospace;}",
|
|
4652
|
+
".dshh-imgPair{display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;}",
|
|
4653
|
+
".dshh-imgPairSingle{flex-wrap:nowrap;}",
|
|
4654
|
+
// 固定档位(非"适应宽度")时:图片按固有像素算宽度,容器随内容撑开,外层横向滚动。
|
|
4655
|
+
".dshh-imgPairZoomed{flex-wrap:nowrap;width:max-content;align-items:flex-start;}",
|
|
4656
|
+
".dshh-imgPairZoomed .dshh-imgCell{flex:0 0 auto;}",
|
|
4657
|
+
".dshh-imgPairZoomed .dshh-img{max-height:none;}",
|
|
4658
|
+
".dshh-imgZoom{display:inline-flex;align-items:center;gap:2px;margin-left:auto;}",
|
|
4659
|
+
".dshh-imgZoomBtn{border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));",
|
|
4660
|
+
"background:var(--dsw-alias-bg-layer-1,transparent);color:inherit;cursor:pointer;font:inherit;",
|
|
4661
|
+
"min-width:22px;height:20px;padding:0 6px;border-radius:5px;display:inline-flex;align-items:center;justify-content:center;}",
|
|
4662
|
+
".dshh-imgZoomBtn:hover{color:var(--dsw-alias-label-primary,#e5e7eb);",
|
|
4663
|
+
"border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
|
|
4664
|
+
".dshh-imgZoomValue{min-width:74px;font-variant-numeric:tabular-nums;}",
|
|
4665
|
+
".dshh-imgCell{flex:1 1 150px;min-width:0;display:flex;flex-direction:column;gap:4px;}",
|
|
4666
|
+
".dshh-imgLabel{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4667
|
+
".dshh-imgNote{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);padding:6px 0;}",
|
|
4668
|
+
// 透明像素要有底色才看得出边界:棋盘格由四段半透明渐变拼出来,两种主题都不用另配颜色。
|
|
4669
|
+
".dshh-img{max-width:100%;max-height:420px;object-fit:contain;border-radius:6px;",
|
|
4670
|
+
"border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.28));",
|
|
4671
|
+
"background-image:linear-gradient(45deg,rgba(127,127,127,0.12) 25%,transparent 25%),",
|
|
4672
|
+
"linear-gradient(-45deg,rgba(127,127,127,0.12) 25%,transparent 25%),",
|
|
4673
|
+
"linear-gradient(45deg,transparent 75%,rgba(127,127,127,0.12) 75%),",
|
|
4674
|
+
"linear-gradient(-45deg,transparent 75%,rgba(127,127,127,0.12) 75%);",
|
|
4675
|
+
"background-size:16px 16px;background-position:0 0,0 8px,8px -8px,-8px 0;}",
|
|
4676
|
+
// ---- 轮尾「本轮图片改动」条 ----
|
|
4677
|
+
".dshh-imgstrip{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;",
|
|
4678
|
+
"color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4679
|
+
".dshh-imgstripTitle{flex:none;}",
|
|
4680
|
+
".dshh-imgstripItems{display:flex;gap:6px;flex-wrap:wrap;}",
|
|
4681
|
+
".dshh-imgstripItem{display:inline-flex;align-items:center;gap:2px;padding:2px 4px 2px 2px;",
|
|
4682
|
+
"border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));border-radius:8px;",
|
|
4683
|
+
"background:var(--dsw-alias-bg-layer-1,transparent);}",
|
|
4684
|
+
".dshh-imgstripOpen{display:inline-flex;align-items:center;gap:6px;border:0;background:transparent;",
|
|
4685
|
+
"color:inherit;cursor:pointer;padding:0;font:inherit;}",
|
|
4686
|
+
".dshh-imgstripOpen:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
|
|
4687
|
+
".dshh-imgstripThumb{width:28px;height:28px;object-fit:cover;border-radius:5px;display:block;",
|
|
4688
|
+
"background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));}",
|
|
4689
|
+
".dshh-imgstripThumbEmpty{align-items:center;justify-content:center;font-size:8px;letter-spacing:0.02em;",
|
|
4690
|
+
"display:flex;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
4691
|
+
".dshh-imgstripName{max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
|
|
4692
|
+
".dshh-imgstripTier{flex:none;opacity:.7;}",
|
|
4693
|
+
".dshh-imgstripCompare{border:0;background:transparent;color:inherit;cursor:pointer;padding:0 2px;",
|
|
4694
|
+
"font:inherit;opacity:.7;}",
|
|
4695
|
+
".dshh-imgstripCompare:hover{opacity:1;color:var(--dsw-alias-brand-primary,#4b8bf5);}",
|
|
4696
|
+
".dshh-imgstripMore{flex:none;}",
|
|
3523
4697
|
".dshh-footer{display:flex;justify-content:space-between;gap:8px;padding:6px 10px;flex:none;",
|
|
3524
4698
|
"border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));",
|
|
3525
4699
|
"font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
|
|
@@ -3656,6 +4830,20 @@ window.__ModuleLoader__.load({
|
|
|
3656
4830
|
};
|
|
3657
4831
|
}, "dsh-hud: focus refresh");
|
|
3658
4832
|
|
|
4833
|
+
// 悬停气泡单独一枚 overlay 座位:它要浮在 dock 行之外(行内会被裁切/堆叠上下文限制)。
|
|
4834
|
+
// order 比面板小 —— 面板开着时仍然压在最上层。
|
|
4835
|
+
ctx.slots.inject("shell.overlay", function () {
|
|
4836
|
+
return ctx.slots.register({
|
|
4837
|
+
name: "shell.overlay",
|
|
4838
|
+
id: "dsh-hud-pill-tip",
|
|
4839
|
+
order: 10,
|
|
4840
|
+
locale: NS,
|
|
4841
|
+
inject: function () {
|
|
4842
|
+
return { store: store };
|
|
4843
|
+
},
|
|
4844
|
+
}, PillTip);
|
|
4845
|
+
});
|
|
4846
|
+
|
|
3659
4847
|
ctx.slots.inject("shell.overlay", function () {
|
|
3660
4848
|
return ctx.slots.register({
|
|
3661
4849
|
name: "shell.overlay",
|
|
@@ -3730,6 +4918,27 @@ window.__ModuleLoader__.load({
|
|
|
3730
4918
|
},
|
|
3731
4919
|
}, HudDockPill);
|
|
3732
4920
|
});
|
|
4921
|
+
|
|
4922
|
+
// 轮尾(内置「已编辑 N 个文件」卡片所在的那条尾巴)的一扇门:本轮动过图,
|
|
4923
|
+
// 点缩略图就在内置文档预览里打开;`⇄` 才进本面板看前后对比。
|
|
4924
|
+
ctx.slots.inject("conversation.chat.turnTail", function () {
|
|
4925
|
+
return ctx.slots.register({
|
|
4926
|
+
name: "conversation.chat.turnTail",
|
|
4927
|
+
id: "dsh-hud-turn-images",
|
|
4928
|
+
order: 30,
|
|
4929
|
+
locale: NS,
|
|
4930
|
+
inject: function (sessionId) {
|
|
4931
|
+
return { store: store, sessionId: sessionId };
|
|
4932
|
+
},
|
|
4933
|
+
}, TurnImages);
|
|
4934
|
+
});
|
|
4935
|
+
|
|
4936
|
+
// 输入框里的 ↑ 召回(纯文本):**最后接线** —— 逻辑全在纯函数里,这一层只负责
|
|
4937
|
+
// 读值 / 写值 / 摆光标,且**真接管了才拦默认行为**。刻意排在最后注册,好让
|
|
4938
|
+
// "第一个 keydown 监听是面板那条"这个既有事实保持不变。
|
|
4939
|
+
ctx.effect(function () {
|
|
4940
|
+
return bindPromptHistory(store);
|
|
4941
|
+
}, "dsh-hud: prompt history (↑ 召回)");
|
|
3733
4942
|
}
|
|
3734
4943
|
|
|
3735
4944
|
exports.apply = apply;
|