@konnga/dsh-hud 0.0.0-stage → 0.7.0

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/lib/client.js ADDED
@@ -0,0 +1,3437 @@
1
+ /**
2
+ * dsh-hud — 浏览器半(dsh.client bundle)。
3
+ *
4
+ * 两个座位,一个共享 store:
5
+ *
6
+ * - `conversation.input.dock` → HudDockPill:输入框上方那一行里靠左的紧凑状态胶囊
7
+ * (分支摘要,或“非 git 仓库”),点击开关面板。这个座位是**会话作用域**,
8
+ * 框架直接把当前 `sessionId` 交给它,它再从 `useSessions` 快照里取该会话的
9
+ * `cwd` 写进共享 store —— 这是 root 作用域的面板**读不到**的东西。
10
+ * - `shell.overlay` → HudFloatingPanel:收起来时渲染 `null`(不留会挡住输入框的气泡),
11
+ * 展开时是悬浮面板,默认停在输入框上方。
12
+ *
13
+ * 所有 git 工作都在宿主半(`/dsh-hud-rpc`);本文件只负责渲染与取数。
14
+ * 手写对照客户端模块表(只用到种子模块 `react`),因此没有构建步骤。
15
+ */
16
+
17
+ window.__ModuleLoader__.load({
18
+ id: 'dsh-hud',
19
+ factory: (require) => {
20
+ var module = { exports: {} };
21
+ var exports = module.exports;
22
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
23
+
24
+ var React = require("react");
25
+ var h = React.createElement;
26
+
27
+ var CHANNEL = "/dsh-hud-rpc";
28
+ var NS = "dshHud";
29
+ var UI_KEY = "dsh-hud.ui.v1";
30
+ /**
31
+ * 插件自诊断的留存键(最近 24 行):会话归因每一步的事实都往这里留一行。
32
+ *
33
+ * 为什么落在这里:客户端插件的异常与"服务找不到"都不会进宿主日志,界面出问题时往往只有
34
+ * 有/没有两种观察;把每一步的事实留一行在 localStorage,用户不用开 DevTools 也能把它捞出来,
35
+ * 排查完可以随手删掉。
36
+ */
37
+ var DEBUG_KEY = "dsh-hud.debug";
38
+
39
+ /** 轮询间隔:面板与胶囊共用,页面不可见时跳过。 */
40
+ var POLL_MS = 8000;
41
+ /**
42
+ * 侧栏行内 chip 的轮询节奏:比面板慢一档。
43
+ *
44
+ * 那些 chip 是"扫一眼"的信息(哪个会话脏了),不值得跟面板一样频繁;宿主侧还按目录
45
+ * 缓存 + 去重,多会话共用一个目录只跑一次 git。
46
+ */
47
+ var ROWS_POLL_MS = 12000;
48
+ /** 面板默认宽度与“停靠在输入框上方”的锚点高度。 */
49
+ var PANEL_WIDTH = 380;
50
+ var COMPOSER_OFFSET = 168;
51
+ var VIEWPORT_MARGIN = 8;
52
+ /** 超过这个位移才算拖动,避免把头部上的普通点击当成拖动。 */
53
+ var DRAG_THRESHOLD_PX = 3;
54
+ /** 面板与胶囊所在 dock 行之间的间距。 */
55
+ var PANEL_DOCK_GAP = 8;
56
+ /**
57
+ * 中间那一栏(打开提交明细时:这次提交的元信息 + 改动清单)的宽度。
58
+ * 它夹在左栏与差异栏之间,是固定的——右边缘那个抓握仍只调整差异栏。
59
+ */
60
+ var COMMIT_LIST_WIDTH = 240;
61
+
62
+
63
+ /** diff 模式的面板:更宽,且用固定高度(避免 bottom 锚定下"内容变高就跳位")。 */
64
+ /** 右栏(差异)默认宽度、下限,以及面板右边缘抓握的宽度。 */
65
+ var DIFF_DEFAULT_WIDTH = 470;
66
+ var DIFF_MIN_WIDTH = 240;
67
+ var RESIZE_GRIP_WIDTH = 6;
68
+ var DIFF_PANEL_HEIGHT_VH = 0.72;
69
+
70
+ var inject = ["slots", "connection", "locale"];
71
+
72
+ /* ------------------------------------------------------------- 文案 */
73
+
74
+ var zh = {
75
+ "panel.title": "Git",
76
+ "panel.refresh": "刷新",
77
+ "panel.reset": "复位位置",
78
+ "panel.close": "收起面板",
79
+ "panel.dragHint": "拖动标题可移动面板,双击标题复位",
80
+ "panel.loading": "读取中…",
81
+ "panel.clean": "工作区干净,没有未提交的改动",
82
+ "panel.notRepo": "当前会话的工作区不是 Git 仓库",
83
+ "panel.noSession": "还没有绑定到会话的工作区",
84
+ "panel.summary": "改动概览",
85
+ "panel.changes": "改动清单",
86
+ "panel.truncated": "仅显示前 200 条",
87
+ "panel.readonly": "只读",
88
+ "panel.upstream": "上游",
89
+ "panel.updated": "更新于 ",
90
+ "panel.ahead": "领先 ",
91
+ "panel.behind": "落后 ",
92
+ "panel.detached": "detached",
93
+ "panel.clickForDiff": "点击一行查看该文件的差异",
94
+ "panel.rowKeys": "在面板里按过之后再按 ↑/↓ 选行、Enter 打开",
95
+ "panel.commits": "最近提交",
96
+ "time.now": "刚刚",
97
+ "time.minutes": " 分钟前",
98
+ "time.hours": " 小时前",
99
+ "time.days": " 天前",
100
+ "diff.close": "关闭差异",
101
+ "diff.resize": "拖动右边缘调整差异宽度",
102
+ "diff.worktree": "未暂存",
103
+ "diff.index": "已暂存",
104
+ "diff.wrap": "自动换行",
105
+ "diff.copy": "复制差异",
106
+ "diff.copied": "已复制",
107
+ "diff.empty": "这一侧没有差异",
108
+ "diff.binary": "二进制文件,没有可显示的文本差异",
109
+ "diff.truncated": "差异过大,仅显示前面部分",
110
+ "diff.untrackedHint": "未跟踪文件,与空文件对比",
111
+ "diff.commitHint": "这次提交相对其父提交的差异(只读)",
112
+ "diff.hunkToggle": "点这一行折叠 / 展开这个 hunk(滚动到它后按 Space 一样)",
113
+ "commit.files": "这次提交改动",
114
+ "commit.close": "收起提交明细",
115
+ "commit.empty": "这次提交没有改动文件(空提交)",
116
+ "commit.loading": "读取这次提交…",
117
+ "commit.merge": "合并提交,按第一父提交比较",
118
+ "commit.unknown": "找不到这次提交:可能已被 rebase / amend 换掉,刷新面板再看",
119
+ "commit.click": "点击查看这次提交改了什么",
120
+ // ── 会话归因(本会话改过哪些文件) ──
121
+ "session.line": "本会话改过 {n} 个文件",
122
+ "rows.mode.always": "侧栏 常亮",
123
+ "rows.mode.hover": "侧栏 悬停",
124
+ "rows.mode.off": "侧栏 关",
125
+ "rows.modeTitle": "点一下切换:常亮(行首小标记)→ 悬停(动条上完整那枚)→ 关。当前:{mode}",
126
+ "rows.ownNone": "本会话没有改动记录",
127
+ "rows.noRepo": "不在 git 仓库里",
128
+ "rows.error": "读取失败({code})",
129
+ "rows.possible": "另有 {n} 个可能相关(命令旁听)",
130
+ "review.count": "已审 {n}/{total}",
131
+ "review.staleCount": "{n} 个改动已变,需要重看",
132
+ "review.mark": "标成「这一版我审过了」(键盘:v)",
133
+ "review.unmark": "取消「已审」(键盘:v)",
134
+ "review.stale": "改动已变,需要重看(标记的是更早的那一版)",
135
+ "review.clear": "清空这个工作区的「已审」标记",
136
+ "review.clearShort": "清空已审",
137
+ "session.latestTurn": "最近一轮 {n} 个",
138
+ "session.turn": "第 {n} 轮",
139
+ "session.turnWithPrompt": "第 {n} 轮:{prompt}",
140
+ "session.onlyLatest": "只看最近一轮",
141
+ "session.onlyLatestTitle": "把清单收窄到最近这一轮动过的文件(「这一轮改了什么」)",
142
+ "session.possible": "另有 {n} 个可能相关(命令旁听)",
143
+ "session.possibleDropped": "还有 {n} 个超出上限未记",
144
+ "session.possibleTitle": "可能相关(未确认):{reason}",
145
+ "session.commands": "最近跑过 {n} 条命令:",
146
+ "session.commandsMore": "…另有 {n} 条会写文件的命令",
147
+ "session.commandFailed": "(这条失败了)",
148
+ "session.gone": "{n} 个已不在 git 现状里(改回去了 / 被忽略 / 被覆盖)",
149
+ "session.dropped": "另有 {n} 个超出上限未记录",
150
+ "session.only": "只看本会话改的",
151
+ "session.onlyTitle": "把清单过滤成本会话动过的文件(确定的 + 命令旁听到的「可能相关」,与 git 现状求交)",
152
+ "session.mine": "本会话改的",
153
+ "session.other": "不是本会话改的(可能是你自己、编辑器或另一个会话)",
154
+ "session.writes": "写入 {n} 次",
155
+ "session.edits": "编辑 {n} 次",
156
+ "session.since": "自 {time} 起记录(宿主进程启动之前的不算)",
157
+ "session.pill": "本会话改过 {n} 个文件",
158
+ "session.empty": "本会话还没有改动(清掉上面的过滤器看看工作区现状)",
159
+ "pill.loading": "读取中…",
160
+ "pill.notRepo": "非 git 仓库",
161
+ "pill.error": "读取失败",
162
+ "pill.title": "点击展开 Git 面板",
163
+ "count.modified": "已修改",
164
+ "count.added": "已暂存新增",
165
+ "count.deleted": "已删除",
166
+ "count.renamed": "重命名",
167
+ "count.copied": "复制",
168
+ "count.typeChanged": "类型改变",
169
+ "count.untracked": "未跟踪",
170
+ "count.conflicted": "冲突",
171
+ "err.timeout": "git 超时:仓库过大或磁盘繁忙",
172
+ "err.no-cwd": "工作区目录不存在",
173
+ "err.git-missing": "找不到 git 可执行文件",
174
+ "err.invalid-cwd": "无效的工作区路径",
175
+ "err.git-failed": "git 执行失败",
176
+ "err.invalid-path": "无效的文件路径",
177
+ "err.stale-host": "宿主半还没有这个端点:插件刚更新,需要重启 DSH 让宿主半重新加载",
178
+ "err.transport": "无法连接到宿主半",
179
+ };
180
+
181
+ var en = {
182
+ "panel.title": "Git",
183
+ "panel.refresh": "Refresh",
184
+ "panel.reset": "Reset position",
185
+ "panel.close": "Collapse panel",
186
+ "panel.dragHint": "Drag the title to move the panel; double-click it to reset",
187
+ "panel.loading": "Loading…",
188
+ "panel.clean": "Working tree clean — nothing uncommitted",
189
+ "panel.notRepo": "The current session workspace is not a Git repository",
190
+ "panel.noSession": "Not bound to a session workspace yet",
191
+ "panel.summary": "Summary",
192
+ "panel.changes": "Changes",
193
+ "panel.truncated": "Showing the first 200 entries only",
194
+ "panel.readonly": "Read-only",
195
+ "panel.upstream": "upstream",
196
+ "panel.updated": "Updated ",
197
+ "panel.ahead": "ahead ",
198
+ "panel.behind": "behind ",
199
+ "panel.detached": "detached",
200
+ "panel.clickForDiff": "Click a file to see its diff",
201
+ "panel.rowKeys": "Once you have clicked in the panel: ↑/↓ to select a row, Enter to open it",
202
+ "panel.commits": "Recent commits",
203
+ "time.now": "just now",
204
+ "time.minutes": "m ago",
205
+ "time.hours": "h ago",
206
+ "time.days": "d ago",
207
+ "diff.close": "Close diff",
208
+ "diff.resize": "Drag the right edge to resize the diff",
209
+ "diff.worktree": "Unstaged",
210
+ "diff.index": "Staged",
211
+ "diff.wrap": "Wrap lines",
212
+ "diff.copy": "Copy diff",
213
+ "diff.copied": "Copied",
214
+ "diff.empty": "No changes on this side",
215
+ "diff.binary": "Binary file — no text diff to show",
216
+ "diff.truncated": "Diff is large; showing the beginning only",
217
+ "diff.untrackedHint": "Untracked file, diffed against an empty file",
218
+ "diff.commitHint": "What this commit changed against its parent (read-only)",
219
+ "diff.hunkToggle": "Click this row to collapse or expand the hunk (same as Space once it is the current one)",
220
+ "commit.files": "Files in this commit",
221
+ "commit.close": "Close the commit detail",
222
+ "commit.empty": "This commit changes no files (empty commit)",
223
+ "commit.loading": "Reading this commit…",
224
+ "commit.merge": "Merge commit, compared against its first parent",
225
+ "commit.unknown": "Commit not found: it may have been rebased or amended away — refresh the panel",
226
+ "commit.click": "Click to see what this commit changed",
227
+ "session.line": "{n} file(s) changed by this session",
228
+ "rows.mode.always": "Sidebar on",
229
+ "rows.mode.hover": "Sidebar hover",
230
+ "rows.mode.off": "Sidebar off",
231
+ "rows.modeTitle": "Click to cycle: always (leading token) → hover (chip in the action strip) → off. Now: {mode}",
232
+ "rows.ownNone": "this session has no recorded changes",
233
+ "rows.noRepo": "not in a git repository",
234
+ "rows.error": "read failed ({code})",
235
+ "rows.possible": "{n} more possibly related (from commands)",
236
+ "review.count": "{n}/{total} reviewed",
237
+ "review.staleCount": "{n} changed since reviewed",
238
+ "review.mark": "Mark this revision as reviewed (key: v)",
239
+ "review.unmark": "Unmark reviewed (key: v)",
240
+ "review.stale": "Changed since you reviewed it (your mark was an earlier revision)",
241
+ "review.clear": "Clear the reviewed marks for this workspace",
242
+ "review.clearShort": "Clear reviewed",
243
+ "session.latestTurn": "{n} in the latest turn",
244
+ "session.turn": "turn {n}",
245
+ "session.turnWithPrompt": "turn {n}: {prompt}",
246
+ "session.onlyLatest": "Latest turn only",
247
+ "session.onlyLatestTitle": "Narrow the list to the files the latest turn changed",
248
+ "session.possible": "{n} more possibly related (seen in commands)",
249
+ "session.possibleDropped": "{n} more over the cap",
250
+ "session.possibleTitle": "Possibly related (unconfirmed): {reason}",
251
+ "session.commands": "Last {n} command(s) run:",
252
+ "session.commandsMore": "…plus {n} more that write files",
253
+ "session.commandFailed": "(this one failed)",
254
+ "session.gone": "{n} no longer in the git status (reverted, ignored, or overwritten)",
255
+ "session.dropped": "{n} more not recorded (over the cap)",
256
+ "session.only": "Only this session's",
257
+ "session.onlyTitle": "Filter the list to files this session touched (confirmed plus command-inferred, intersected with git)",
258
+ "session.mine": "Changed by this session",
259
+ "session.other": "Not changed by this session (you, your editor, or another session)",
260
+ "session.writes": "{n} write(s)",
261
+ "session.edits": "{n} edit(s)",
262
+ "session.since": "Recorded since {time} (anything before the host process started is not)",
263
+ "session.pill": "{n} file(s) changed by this session",
264
+ "session.empty": "This session has not changed anything yet (clear the filter above to see the workspace)",
265
+ "pill.loading": "Loading…",
266
+ "pill.notRepo": "not a git repo",
267
+ "pill.error": "read failed",
268
+ "pill.title": "Click to open the Git panel",
269
+ "count.modified": "modified",
270
+ "count.added": "added",
271
+ "count.deleted": "deleted",
272
+ "count.renamed": "renamed",
273
+ "count.copied": "copied",
274
+ "count.typeChanged": "type changed",
275
+ "count.untracked": "untracked",
276
+ "count.conflicted": "conflicted",
277
+ "err.timeout": "git timed out: the repository is large or the disk is busy",
278
+ "err.no-cwd": "The workspace directory does not exist",
279
+ "err.git-missing": "git executable not found",
280
+ "err.invalid-cwd": "Invalid workspace path",
281
+ "err.git-failed": "git failed",
282
+ "err.invalid-path": "Invalid file path",
283
+ "err.stale-host": "The host half does not have this endpoint yet: the plugin was just updated, restart DSH so the host half reloads",
284
+ "err.transport": "Cannot reach the host half",
285
+ };
286
+
287
+ /** 改动类型的固定展示顺序,与宿主半的 counts 键一致。 */
288
+ var COUNT_ORDER = [
289
+ "modified",
290
+ "added",
291
+ "deleted",
292
+ "renamed",
293
+ "copied",
294
+ "typeChanged",
295
+ "untracked",
296
+ "conflicted",
297
+ ];
298
+
299
+ /** 每类改动对应的主题色令牌(都带回退色,暗亮色主题都能看)。 */
300
+ var COUNT_COLOR = {
301
+ modified: "var(--dsw-alias-state-warn-primary, #d9a63c)",
302
+ added: "var(--dsw-alias-state-success-primary, #3fb950)",
303
+ deleted: "var(--dsw-alias-state-error-primary, #f06a6a)",
304
+ renamed: "var(--dsw-alias-brand-primary, #4b8bf5)",
305
+ copied: "var(--dsw-alias-brand-primary, #4b8bf5)",
306
+ typeChanged: "var(--dsw-alias-state-idle-primary, #8a8f98)",
307
+ untracked: "var(--dsw-alias-label-secondary, #8a8f98)",
308
+ conflicted: "var(--dsw-alias-state-error-primary, #f06a6a)",
309
+ };
310
+
311
+ /**
312
+ * 每个改动类型在胶囊里的简写符,沿用 codex-hud / 上游 statusline 的 git 标记约定:
313
+ * M 已修改、A 已暂存新增、D 已删除、R 重命名、C 复制、T 类型改变、? 未跟踪、! 冲突。
314
+ */
315
+ var COUNT_MARK = {
316
+ modified: "M",
317
+ added: "A",
318
+ deleted: "D",
319
+ renamed: "R",
320
+ copied: "C",
321
+ typeChanged: "T",
322
+ untracked: "?",
323
+ conflicted: "!",
324
+ };
325
+
326
+ /** 胶囊里分支名的显示上限;超出就中段省略,别把简写符挤出可见区。 */
327
+ var PILL_BRANCH_MAX = 28;
328
+
329
+ /**
330
+ * 差异行的底色:半透明,压在面板表面色之上,因此亮/暗主题都可读
331
+ * (文本与行号仍走主题令牌)。
332
+ */
333
+ var DIFF_ADD_BG = "rgba(63,185,80,0.12)";
334
+ var DIFF_DEL_BG = "rgba(240,106,106,0.13)";
335
+ var DIFF_GUTTER_COLOR = "var(--dsw-alias-label-secondary, #8a8f98)";
336
+
337
+ /* --------------------------------------------------------- 本地 UI 状态 */
338
+
339
+ function readUi() {
340
+ try {
341
+ var raw = window.localStorage.getItem(UI_KEY);
342
+ if (!raw) return {};
343
+ var parsed = JSON.parse(raw);
344
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
345
+ } catch (error) {
346
+ return {};
347
+ }
348
+ }
349
+
350
+ function writeUi(next) {
351
+ try {
352
+ window.localStorage.setItem(UI_KEY, JSON.stringify(next));
353
+ } catch (error) {
354
+ // 隐私模式等写入失败只影响“记住位置/展开状态”,不影响功能。
355
+ }
356
+ }
357
+
358
+ /** 侧栏状态的三个档位(顺序就是点一下的循环顺序)。 */
359
+ var SIDEBAR_MODES = ["always", "hover", "off"];
360
+
361
+ /** 「审阅进度」的存档键:按"工作区 + 文件"记下审过的那一版签名。 */
362
+ var REVIEWED_KEY = "dsh-hud.reviewed.v1";
363
+ /**
364
+ * 存档键的分隔符:NUL。
365
+ *
366
+ * 用 `String.fromCharCode(0)` 而不是 `"\u0000"` 字面量:路径里不可能有 NUL,而转义写法
367
+ * 在源码与测试里极易被多转一层(写成字面量反斜杠),排查起来极其费劲。
368
+ */
369
+ var REVIEW_KEY_SEP = String.fromCharCode(0);
370
+
371
+ /** 存档键:`工作区 NUL 文件`(同一个文件名在别的仓库里是另一码事)。 */
372
+ function reviewKeyOf(cwd, file) {
373
+ return String(cwd || "") + REVIEW_KEY_SEP + file;
374
+ }
375
+ /** 存档上限:超出丢最久没动的那些(不然用久了这个键会一直长)。 */
376
+ var REVIEWED_MAX = 200;
377
+
378
+ /** 读取"我审过哪些版本":坏数据一律当空表(存档不该把插件带崩)。 */
379
+ function readReviewed() {
380
+ try {
381
+ var raw = window.localStorage.getItem(REVIEWED_KEY);
382
+ if (!raw) return {};
383
+ var parsed = JSON.parse(raw);
384
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
385
+ var out = {};
386
+ for (var key in parsed) {
387
+ var item = parsed[key];
388
+ if (!Object.prototype.hasOwnProperty.call(parsed, key) || !item || typeof item !== "object") continue;
389
+ if (typeof item.signature !== "string") continue;
390
+ out[key] = { signature: item.signature, at: typeof item.at === "number" ? item.at : 0 };
391
+ }
392
+ return out;
393
+ } catch (error) {
394
+ return {};
395
+ }
396
+ }
397
+
398
+ /** 写回"我审过哪些版本":超上限就按最后触达时间丢最旧的。 */
399
+ function writeReviewed(next) {
400
+ try {
401
+ var keys = Object.keys(next);
402
+ if (keys.length > REVIEWED_MAX) {
403
+ keys.sort(function (left, right) {
404
+ return (next[left].at || 0) - (next[right].at || 0);
405
+ });
406
+ var trimmed = {};
407
+ var keep = keys.slice(keys.length - REVIEWED_MAX);
408
+ for (var index = 0; index < keep.length; index += 1) trimmed[keep[index]] = next[keep[index]];
409
+ next = trimmed;
410
+ }
411
+ window.localStorage.setItem(REVIEWED_KEY, JSON.stringify(next));
412
+ return next;
413
+ } catch (error) {
414
+ // 写不进去只意味着"这次不记住",不影响功能。
415
+ return next;
416
+ }
417
+ }
418
+
419
+ /* --------------------------------------------------------------- store */
420
+
421
+ /**
422
+ * 插件自诊断的现场记录:只写控制台,不碰界面、不留全局。
423
+ *
424
+ * 客户端插件的异常和"服务找不到"都不会进宿主日志,所以控制台是排查时唯一的通道。
425
+ * 同一个 reason + 同样的事实只报一次(轮询每 8s 会重试),一行 JSON 便于复制。
426
+ */
427
+ function createProbe() {
428
+ var seen = {};
429
+ return {
430
+ /** 记一行现场:一行进控制台,同时留一份在 localStorage(都按 reason+事实去重)。 */
431
+ log: function (reason, detail) {
432
+ var facts = detail === undefined ? {} : detail;
433
+ var line = reason + " " + JSON.stringify(facts);
434
+ if (seen[line] === true) return;
435
+ seen[line] = true;
436
+ console.warn("[dsh-hud] " + line);
437
+ try {
438
+ var history = window.localStorage.getItem(DEBUG_KEY);
439
+ var parsed = history === null ? [] : JSON.parse(history);
440
+ var list = Array.isArray(parsed) ? parsed : [];
441
+ list.push({ at: Date.now(), line: line });
442
+ window.localStorage.setItem(DEBUG_KEY, JSON.stringify(list.slice(-24)));
443
+ } catch (error) {
444
+ // 隐私模式等写不进去只影响这行诊断,不影响功能。
445
+ }
446
+ },
447
+ };
448
+ }
449
+
450
+ /* --------------------------------------------------- 会话归因(对账) */
451
+
452
+ /**
453
+ * 把"本会话改过哪些文件"(宿主半按 fs 事件记的)与 git 现状对起来。
454
+ *
455
+ * - `mine`:本会话改过、且工作区里还有差异(`status.changes` 里能找到)
456
+ * - `gone`:本会话改过、但工作区里已经没有差异(被改回去了 / 被别的会话或你自己覆盖了)
457
+ * - `others`:工作区里有差异、但不是本会话动的
458
+ *
459
+ * 名字能直接对上:宿主报的是 cwd 之内的 posix 相对路径,`status.changes[].file` 也是。
460
+ * 还拿不到归因(老宿主 / 请求还没回来)时返回 `null` —— 界面这时**不打任何标记**,
461
+ * 而不是把所有改动都标成"别人的"。
462
+ */
463
+ function sessionChangeView(state) {
464
+ var slice = state.sessionChanges;
465
+ var files = slice !== undefined && slice !== null && slice.files !== null && slice.files !== undefined ? slice.files : null;
466
+ if (files === null) return null;
467
+ var possibleAll = slice.possible !== undefined && slice.possible !== null ? slice.possible : null;
468
+ var changes = state.status && Array.isArray(state.status.changes) ? state.status.changes : [];
469
+ var mine = {};
470
+ var possible = {};
471
+ var gone = [];
472
+ var index;
473
+ var change;
474
+ // 先认"确定改过"的:一档优先于一档,同一个文件不会被标两次。
475
+ for (index = 0; index < changes.length; index += 1) {
476
+ change = changes[index];
477
+ if (Object.prototype.hasOwnProperty.call(files, change.file)) mine[change.file] = files[change.file];
478
+ }
479
+ // 再看"可能相关":只标没有被确定认领的那些(命令旁听来的,置信度低一档)。
480
+ for (index = 0; index < changes.length; index += 1) {
481
+ change = changes[index];
482
+ if (mine[change.file] !== undefined) continue;
483
+ if (possibleAll !== null && Object.prototype.hasOwnProperty.call(possibleAll, change.file)) {
484
+ possible[change.file] = possibleAll[change.file];
485
+ }
486
+ }
487
+ for (var file in files) {
488
+ if (Object.prototype.hasOwnProperty.call(files, file) && mine[file] === undefined) gone.push(file);
489
+ }
490
+ gone.sort();
491
+ var commands = Array.isArray(slice.commands) ? slice.commands : [];
492
+ var turns = state.turns !== undefined && state.turns !== null && state.turns.available === true ? state.turns : null;
493
+ var latestTurn = turns === null ? null : turns.latest;
494
+ var byTurn = {};
495
+ var latestCount = 0;
496
+ for (var file in mine) {
497
+ if (!Object.prototype.hasOwnProperty.call(mine, file)) continue;
498
+ var turn = typeof mine[file].lastTurn === "number" ? mine[file].lastTurn : null;
499
+ if (turn === null) continue;
500
+ byTurn[turn] = (byTurn[turn] === undefined ? 0 : byTurn[turn]) + 1;
501
+ if (latestTurn !== null && turn === latestTurn) latestCount += 1;
502
+ }
503
+ return {
504
+ mine: mine,
505
+ possible: possible,
506
+ /** 第几轮 → prompt 预览(来自 `turnOutline` 投影;没有就是空表)。 */
507
+ turnPrompts: turns === null ? {} : turns.prompts,
508
+ latestTurn: latestTurn,
509
+ latestCount: latestCount,
510
+ byTurn: byTurn,
511
+ mineCount: Object.keys(mine).length,
512
+ possibleCount: Object.keys(possible).length,
513
+ /** 跑过的命令里"看着会写文件"的条数(含失败的,界面自己再分)。 */
514
+ commandCount: commands.length,
515
+ writingCommands: commands.filter(function (item) { return item && item.readOnly !== true; }).length,
516
+ commands: commands,
517
+ gone: gone,
518
+ /** 本会话记录过的文件总数(宿主那边的集合大小)。 */
519
+ tracked: Object.keys(files).length,
520
+ since: slice.since === undefined ? null : slice.since,
521
+ dropped: slice.dropped === undefined ? 0 : slice.dropped,
522
+ possibleDropped: slice.possibleDropped === undefined ? 0 : slice.possibleDropped,
523
+ others: changes.length - Object.keys(mine).length,
524
+ };
525
+ }
526
+
527
+ /**
528
+ * 审阅进度:`{ marks: {file → 'fresh'|'stale'}, done, total, staleCount }`。
529
+ *
530
+ * 判据是**签名**:宿主 stat 出来的 size+mtimeMs 和存档里的是同一个才算"这一版审过了"。
531
+ * 签名变了(又被改动)就把那条标记降级成 `stale`,界面显示"改动已变,需要重看" ——
532
+ * 而不是继续冒充已审。没有签名的文件(宿主这次没算)不参与统计。
533
+ */
534
+ function reviewView(state) {
535
+ var changes = state.status && Array.isArray(state.status.changes) ? state.status.changes : [];
536
+ var signatures = state.review && typeof state.review === "object" ? state.review : {};
537
+ var reviewed = state.reviewed && typeof state.reviewed === "object" ? state.reviewed : {};
538
+ var prefix = reviewKeyOf(state.cwd, "");
539
+ var marks = {};
540
+ var known = {};
541
+ var done = 0;
542
+ var total = 0;
543
+ var staleCount = 0;
544
+ for (var index = 0; index < changes.length; index += 1) {
545
+ var file = changes[index].file;
546
+ var signature = typeof signatures[file] === "string" ? signatures[file] : null;
547
+ if (signature === null) continue;
548
+ known[file] = true;
549
+ total += 1;
550
+ var mark = reviewed[prefix + file];
551
+ if (mark === undefined) continue;
552
+ if (mark.signature === signature) {
553
+ marks[file] = "fresh";
554
+ done += 1;
555
+ } else {
556
+ marks[file] = "stale";
557
+ staleCount += 1;
558
+ }
559
+ }
560
+ return { marks: marks, known: known, done: done, total: total, staleCount: staleCount };
561
+ }
562
+
563
+ /**
564
+ * 面板左栏那份工作区清单 —— **渲染与键盘导航共用这一份**。
565
+ *
566
+ * 过滤必须发生在一个地方:键盘选中用的是行下标,如果渲染时过滤了而导航没过滤,
567
+ * ↑/↓ 就会落在看不见的行上。
568
+ */
569
+ function visibleChangeRows(state) {
570
+ var changes = state.status && Array.isArray(state.status.changes) ? state.status.changes : [];
571
+ var onlySession = state.onlySession === true;
572
+ var onlyLatest = state.onlyLatest === true;
573
+ if (!onlySession && !onlyLatest) return changes;
574
+ var view = sessionChangeView(state);
575
+ // 归因还没到手时这些开关不该悄悄把清单清空:那时候它们还没有意义。
576
+ if (view === null) return changes;
577
+ var rows = changes;
578
+ if (onlySession) {
579
+ // "可能相关"(命令旁听)也算本会话碰过的:过滤器的承诺是"只看本会话动过的"。
580
+ rows = rows.filter(function (change) {
581
+ return view.mine[change.file] !== undefined || view.possible[change.file] !== undefined;
582
+ });
583
+ }
584
+ if (onlyLatest && view.latestTurn !== null) {
585
+ // "这一轮改了什么":只留最近一轮确定动过的("可能相关"没有可靠的回合归属,不收)。
586
+ rows = rows.filter(function (change) {
587
+ var record = view.mine[change.file];
588
+ return record !== undefined && record.lastTurn === view.latestTurn;
589
+ });
590
+ }
591
+ return rows;
592
+ }
593
+
594
+ /**
595
+ * 从自有样式表里抠出计数芯片的横向间隔。
596
+ *
597
+ * 用来回答"页面上跑着的到底是哪一版 CSS"—— 改完样式没生效时,这一眼就能分清是
598
+ * "bundle 还是旧的"还是"样式规则没生效"。
599
+ */
600
+ function cssChipsGap(css) {
601
+ var match = /\.dshh-chips\{[^}]*gap:([^;}]+)/.exec(css);
602
+ return match === null ? null : match[1].trim();
603
+ }
604
+
605
+ /** 进程内唯一一份探针:两个座位共用,去重也一起算。 */
606
+ var probe = createProbe();
607
+
608
+ /**
609
+ * 两个座位共享的唯一数据源。
610
+ *
611
+ * 胶囊持有会话绑定(只有它知道 `sessionId` 与该会话的 `cwd`),面板只读这条路。
612
+ * `ctx.connection.rpc.call` **不会**为业务失败抛异常:它 resolve 出
613
+ * `{ ok: true, value } | { ok: false, error }` 信封,所以两种结果都要判。
614
+ */
615
+ function createStore(ctx) {
616
+ var ui = readUi();
617
+ var pos = ui.pos && typeof ui.pos === "object" && typeof ui.pos.left === "number" && typeof ui.pos.top === "number"
618
+ ? { left: ui.pos.left, top: ui.pos.top }
619
+ : null;
620
+
621
+ var state = {
622
+ phase: "idle",
623
+ sessionId: null,
624
+ cwd: null,
625
+ status: null,
626
+ errorCode: null,
627
+ updatedAt: null,
628
+ panelOpen: ui.open === true,
629
+ pos: pos,
630
+ /** 胶囊所在 dock 行的视口顶部坐标:面板据此贴到它上方,而不是用写死的偏移。 */
631
+ anchorTop: null,
632
+ /** 正在查看的差异;`null` 表示面板停在改动清单。 */
633
+ diff: null,
634
+ /**
635
+ * 正在查看的提交(点了「最近提交」里的一行);`null` 表示左栏是工作区清单。
636
+ * 打开时左栏换成这次提交的改动清单,右栏打开它第一个文件的差异。
637
+ */
638
+ commit: null,
639
+ /**
640
+ * 键盘高亮的那个文件(清单里的 `file`);`null` 表示还没用键盘选过。
641
+ * 指向 `file` 而不是下标:轮询刷新后清单条目可能增减,下标会指错行。
642
+ */
643
+ cursor: null,
644
+ /** 最近提交;`commitsOid` 记住它是哪个 HEAD 取的,oid 变了才重取。 */
645
+ commits: null,
646
+ commitsOid: null,
647
+ /** 差异栏宽度(面板右边缘可拖),持久化。 */
648
+ diffWidth: typeof ui.diffWidth === "number" ? ui.diffWidth : DIFF_DEFAULT_WIDTH,
649
+ /**
650
+ * 会话归因:本会话改过哪些文件(宿主半按 fs 事件记的)。
651
+ *
652
+ * `files` 为 `null` 表示"还没有这份数据"(老宿主 / 还没回来 / 请求失败)——
653
+ * 界面据此不打标记、也不过这个滤。
654
+ */
655
+ sessionChanges: { phase: "idle", files: null, possible: null, commands: [], since: null, dropped: 0, possibleDropped: 0, errorCode: null },
656
+ /** 「只看本会话改的」开关(持久化,跟"自动换行"一样)。 */
657
+ onlySession: ui.onlySession === true,
658
+ /** 「只看最近一轮」开关:把清单收窄到最近这一轮动过的文件。 */
659
+ onlyLatest: ui.onlyLatest === true,
660
+ /**
661
+ * 侧栏状态显示档位:`always` 常亮(行首 16px 格放紧凑标记)/ `hover` 悬停(动条上放完整那枚)/
662
+ * `off` 一处都不画。默认常亮 —— "哪个会话脏了"这件事本来就该扫一眼看到。
663
+ */
664
+ sidebarMode: SIDEBAR_MODES.indexOf(ui.sidebarMode) === -1 ? "always" : ui.sidebarMode,
665
+ /** 这一版每个改动文件的签名(`file → signature`);空表表示"这次没要"。 */
666
+ review: {},
667
+ /** 侧栏行内 chip 要的摘要:一次批量取回,按 sessionId 索引。 */
668
+ rows: { phase: "idle", byId: {}, unavailable: false, truncated: false, updatedAt: null },
669
+ /**
670
+ * 我审过的版本:`工作区 + NUL + 文件 → { signature, at }`,存在自己的键里
671
+ * (`dsh-hud.reviewed.v1`,不跟界面开关混在一起)。
672
+ */
673
+ reviewed: readReviewed(),
674
+ /**
675
+ * 回合信息(`turnOutline` 投影):`第 N 轮 → 那一轮的请求预览`。
676
+ *
677
+ * `available: false` 表示这个宿主没有这个投影(或还没读到)——界面这时不提回合,
678
+ * 也绝不因为读不到它影响别的部分。
679
+ */
680
+ turns: { available: false, prompts: {}, latest: null },
681
+ };
682
+
683
+ var listeners = new Set();
684
+ var inflight = null;
685
+ var diffInflight = null;
686
+ var commitInflight = null;
687
+ /** 会话归因那次请求(在途合并)。 */
688
+ var sessionInflight = null;
689
+ /** 侧栏摘要那次请求(在途合并)。 */
690
+ var rowsInflight = null;
691
+ /** 回合投影的订阅:`{ sessionId, dispose }`;`null` 表示没接。 */
692
+ var turnsBound = null
693
+ /** `sessions` 客户端服务(动态注入;没到齐就是 null,回合信息不显示)。 */
694
+ var turnSessions = null
695
+
696
+ function getSnapshot() {
697
+ return state;
698
+ }
699
+
700
+ function subscribe(listener) {
701
+ listeners.add(listener);
702
+ return function () {
703
+ listeners.delete(listener);
704
+ };
705
+ }
706
+
707
+ function emit() {
708
+ listeners.forEach(function (listener) {
709
+ listener();
710
+ });
711
+ }
712
+
713
+ function set(patch) {
714
+ state = Object.assign({}, state, patch);
715
+ emit();
716
+ }
717
+
718
+ function persist() {
719
+ writeUi({
720
+ onlySession: state.onlySession === true,
721
+ onlyLatest: state.onlyLatest === true,
722
+ sidebarMode: state.sidebarMode,
723
+ open: state.panelOpen,
724
+ pos: state.pos,
725
+ wrap: state.diff ? state.diff.wrap === true : false,
726
+ diffWidth: state.diffWidth,
727
+ });
728
+ }
729
+
730
+ /** 拖动右边缘时只改内存;松手才落盘(persistLayout)。 */
731
+ function setDiffWidth(width) {
732
+ if (typeof width !== "number" || !Number.isFinite(width)) return;
733
+ var next = Math.max(DIFF_MIN_WIDTH, Math.round(width));
734
+ if (state.diffWidth === next) return;
735
+ set({ diffWidth: next });
736
+ }
737
+
738
+ /**
739
+ * 打开一个改动文件的差异视图:一次取回**两侧**的 rows,
740
+ * 默认落在有内容的那一侧(工作区优先),页签切换因此不需要再发请求。
741
+ */
742
+ function openDiff(change) {
743
+ if (!state.cwd || !change || !change.file) return Promise.resolve();
744
+ var cwd = state.cwd;
745
+ var wrap = state.diff ? state.diff.wrap === true : readUi().wrap === true;
746
+ set({
747
+ // 工作区差异与「某次提交的明细」是互斥的两种右栏内容,换过去就收起明细。
748
+ commit: null,
749
+ cursor: change.file,
750
+ 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 },
751
+ });
752
+
753
+ diffInflight = Promise.resolve()
754
+ .then(function () {
755
+ return ctx.connection.rpc.call(CHANNEL, "diff", {
756
+ args: { cwd: cwd, path: change.file, origPath: change.origFile || null },
757
+ });
758
+ })
759
+ .then(function (result) {
760
+ diffInflight = null;
761
+ var current = state.diff;
762
+ // 期间用户可能已经返回清单或换了文件。
763
+ if (!current || current.file !== change.file || state.cwd !== cwd) return;
764
+ if (result && result.ok === true && result.value) {
765
+ var value = result.value;
766
+ var worktreeRows = value.worktree && value.worktree.rows ? value.worktree.rows.length : 0;
767
+ var indexRows = value.index && value.index.rows ? value.index.rows.length : 0;
768
+ set({
769
+ diff: {
770
+ source: "worktree",
771
+ phase: "ready",
772
+ path: change.path,
773
+ file: change.file,
774
+ origPath: change.origFile || null,
775
+ data: value,
776
+ side: worktreeRows > 0 || indexRows === 0 ? "worktree" : "index",
777
+ wrap: current.wrap,
778
+ hunk: 0,
779
+ collapsed: [],
780
+ errorCode: null,
781
+ },
782
+ });
783
+ return;
784
+ }
785
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
786
+ set({
787
+ diff: {
788
+ source: "worktree",
789
+ phase: "error",
790
+ path: change.path,
791
+ file: change.file,
792
+ origPath: change.origFile || null,
793
+ data: null,
794
+ side: "worktree",
795
+ wrap: current.wrap,
796
+ errorCode: code || "git-failed",
797
+ },
798
+ });
799
+ })
800
+ .catch(function () {
801
+ diffInflight = null;
802
+ var current = state.diff;
803
+ if (!current || current.file !== change.file) return;
804
+ set({ diff: Object.assign({}, current, { phase: "error", errorCode: "transport" }) });
805
+ });
806
+
807
+ return diffInflight;
808
+ }
809
+
810
+ /**
811
+ * 打开「某次提交」的视图:先取这次提交的改动清单,再自动打开第一个文件的差异,
812
+ * 这样一次点击就能看到"这次提交改了什么"。
813
+ *
814
+ * 提交是历史事实,取回来就不再变,因此不参与轮询;只有 HEAD 变了它也不受影响
815
+ * (面板显示的是你点的那一次提交)。
816
+ */
817
+ function openCommit(commit) {
818
+ if (!state.cwd || !commit || !commit.sha) return Promise.resolve();
819
+ var cwd = state.cwd;
820
+ var sha = commit.sha;
821
+
822
+ set({
823
+ commit: {
824
+ phase: "loading",
825
+ sha: sha,
826
+ shortSha: typeof commit.sha === "string" && commit.sha.length > 7 ? commit.sha.slice(0, 7) : sha,
827
+ subject: commit.subject || "",
828
+ author: commit.author || "",
829
+ date: commit.date || "",
830
+ committedAt: typeof commit.committedAt === "number" ? commit.committedAt : null,
831
+ refs: commit.refs || [],
832
+ merge: false,
833
+ files: [],
834
+ truncated: false,
835
+ errorCode: null,
836
+ },
837
+ diff: null,
838
+ });
839
+
840
+ commitInflight = Promise.resolve()
841
+ .then(function () {
842
+ return ctx.connection.rpc.call(CHANNEL, "commit", { args: { cwd: cwd, sha: sha } });
843
+ })
844
+ .then(function (result) {
845
+ commitInflight = null;
846
+ var current = state.commit;
847
+ // 期间用户可能已经返回清单、点了别的提交,或换了工作区。
848
+ if (!current || current.sha !== sha || state.cwd !== cwd) return;
849
+ if (result && result.ok === true && result.value && result.value.commit) {
850
+ var value = result.value;
851
+ var files = Array.isArray(value.files) ? value.files : [];
852
+ var meta = value.commit;
853
+ set({
854
+ commit: {
855
+ phase: "ready",
856
+ sha: meta.sha || sha,
857
+ shortSha: meta.shortSha || sha,
858
+ subject: meta.subject || "",
859
+ author: meta.author || "",
860
+ date: meta.date || "",
861
+ committedAt: typeof meta.committedAt === "number" ? meta.committedAt : null,
862
+ refs: meta.refs || [],
863
+ merge: meta.merge === true,
864
+ files: files,
865
+ truncated: value.truncated === true,
866
+ errorCode: null,
867
+ },
868
+ });
869
+ // 空提交(或者清单被截断到没内容)就停在左栏,不去开一个空差异。
870
+ // 有内容就顺手打开第一个文件——一次点击就看到"改了什么";这里把
871
+ // diff 的 promise 交回去,于是 await openCommit 就是等整次点击完成。
872
+ if (files.length > 0) return openCommitDiff(files[0]);
873
+ return undefined;
874
+ }
875
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
876
+ set({
877
+ commit: Object.assign({}, current, {
878
+ phase: "error",
879
+ files: [],
880
+ errorCode: code || "git-failed",
881
+ }),
882
+ });
883
+ })
884
+ .catch(function () {
885
+ commitInflight = null;
886
+ var current = state.commit;
887
+ if (!current || current.sha !== sha) return;
888
+ set({ commit: Object.assign({}, current, { phase: "error", errorCode: "transport" }) });
889
+ });
890
+
891
+ return commitInflight;
892
+ }
893
+
894
+ /**
895
+ * 打开这次提交里某个文件的差异。与工作区差异共用右栏,但只有一个"侧"
896
+ * (提交相对其父提交),因此工具栏不显示 未暂存/已暂存 两个页签。
897
+ */
898
+ function openCommitDiff(file) {
899
+ var current = state.commit;
900
+ if (!state.cwd || !current || !file || !file.file) return Promise.resolve();
901
+ var cwd = state.cwd;
902
+ var sha = current.sha;
903
+ var wrap = state.diff ? state.diff.wrap === true : readUi().wrap === true;
904
+
905
+ set({
906
+ cursor: file.file,
907
+ diff: {
908
+ source: "commit",
909
+ phase: "loading",
910
+ sha: sha,
911
+ shortSha: current.shortSha,
912
+ subject: current.subject,
913
+ path: file.path || file.file,
914
+ file: file.file,
915
+ origPath: file.origFile || null,
916
+ data: null,
917
+ side: "commit",
918
+ wrap: wrap,
919
+ hunk: 0,
920
+ collapsed: [],
921
+ errorCode: null,
922
+ },
923
+ });
924
+
925
+ diffInflight = Promise.resolve()
926
+ .then(function () {
927
+ return ctx.connection.rpc.call(CHANNEL, "commit-diff", {
928
+ args: { cwd: cwd, sha: sha, path: file.file, origPath: file.origFile || null },
929
+ });
930
+ })
931
+ .then(function (result) {
932
+ diffInflight = null;
933
+ var now = state.diff;
934
+ // 期间可能已经返回清单、点了别的文件,或换了提交。
935
+ if (!now || now.source !== "commit" || now.sha !== sha || now.file !== file.file || state.cwd !== cwd) return;
936
+ if (result && result.ok === true && result.value) {
937
+ set({ diff: Object.assign({}, now, { phase: "ready", data: result.value, errorCode: null }) });
938
+ return;
939
+ }
940
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
941
+ set({ diff: Object.assign({}, now, { phase: "error", data: null, errorCode: code || "git-failed" }) });
942
+ })
943
+ .catch(function () {
944
+ diffInflight = null;
945
+ var now = state.diff;
946
+ if (!now || now.source !== "commit" || now.file !== file.file) return;
947
+ set({ diff: Object.assign({}, now, { phase: "error", errorCode: "transport" }) });
948
+ });
949
+
950
+ return diffInflight;
951
+ }
952
+
953
+ /**
954
+ * 点最近提交里的一行:打开这次提交的明细;点已经打开的那条则收起。
955
+ * (明细占的是右栏,左栏主面板只是高亮那一行。)
956
+ */
957
+ function toggleCommit(commit) {
958
+ if (!commit || !commit.sha) return Promise.resolve();
959
+ if (state.commit !== null && sameCommit(commit.sha, state.commit.sha)) return closeCommit();
960
+ return openCommit(commit);
961
+ }
962
+
963
+ /** 收起提交明细:它带来的差异一起收起来(清单换了,光标也要清)。 */
964
+ function closeCommit() {
965
+ if (state.commit === null) return;
966
+ var keep = state.diff && state.diff.source === "commit" ? null : state.diff;
967
+ set({ commit: null, diff: keep, cursor: null });
968
+ }
969
+
970
+ function closeDiff() {
971
+ if (state.diff === null) return;
972
+ set({ diff: null });
973
+ }
974
+
975
+ /**
976
+ * 收起右栏。提交明细把差异包在里面,所以它优先整体退掉;
977
+ * 只有工作区差异时才只是关掉差异。
978
+ */
979
+ function closeDetail() {
980
+ if (state.commit !== null) return closeCommit();
981
+ return closeDiff();
982
+ }
983
+
984
+ function setDiffSide(side) {
985
+ if (!state.diff || state.diff.source === "commit" || (side !== "worktree" && side !== "index")) return;
986
+ // 换侧等于换了一份 rows:hunk 序号与折叠状态都不再对应得上。
987
+ set({ diff: Object.assign({}, state.diff, { side: side, hunk: 0, collapsed: [] }) });
988
+ }
989
+
990
+ /**
991
+ * 当前能上下选的那份清单:打开提交明细时是这次提交的改动,否则是工作区清单。
992
+ *
993
+ * 清单必须跟视图一致 —— 否则打开着提交明细时按 ↑/↓ 会去动工作区的行,
994
+ * 高亮和打开的文件对不上。
995
+ */
996
+ function visibleFiles() {
997
+ if (state.commit !== null) {
998
+ return Array.isArray(state.commit.files) ? state.commit.files : [];
999
+ }
1000
+ // 与面板渲染**共用同一份**过滤结果:过滤只发生在一个地方,↑/↓ 才不会落到看不见的行上。
1001
+ return visibleChangeRows(state);
1002
+ }
1003
+
1004
+ /** 打开清单里的一条:提交明细打开时走提交差异,否则走工作区差异。 */
1005
+ function openListEntry(file) {
1006
+ if (state.commit !== null) return openCommitDiff(file);
1007
+ return openDiff(file);
1008
+ }
1009
+
1010
+ /**
1011
+ * 在当前清单里上下移动一格(↑/↓ 只移动;`[`/`]` 移动并直接打开)。
1012
+ *
1013
+ * 还没选过时:向下从第一条开始,向上从最后一条开始 —— 这样 ↑ 和 ↓ 都能
1014
+ * "先落在一个合理的位置上",不需要用户先点一行。
1015
+ *
1016
+ * @param delta - 1 向下、-1 向上。
1017
+ * @param open - 移动后是否立刻打开。
1018
+ * @returns 选中的那条,清单为空时返回 null。
1019
+ */
1020
+ function stepFile(delta, open) {
1021
+ var list = visibleFiles();
1022
+ if (list.length === 0) return null;
1023
+
1024
+ var index = -1;
1025
+ for (var cursor = 0; cursor < list.length; cursor += 1) {
1026
+ if (list[cursor].file === state.cursor) {
1027
+ index = cursor;
1028
+ break;
1029
+ }
1030
+ }
1031
+
1032
+ var next = index === -1
1033
+ ? (delta > 0 ? 0 : list.length - 1)
1034
+ : Math.min(Math.max(0, index + delta), list.length - 1);
1035
+ var entry = list[next];
1036
+ if (!entry || !entry.file) return null;
1037
+
1038
+ set({ cursor: entry.file });
1039
+ if (open) openListEntry(entry);
1040
+ return entry;
1041
+ }
1042
+
1043
+ /** Enter:打开键盘选中的那条;还没选过就打开第一条。 */
1044
+ function openCursor() {
1045
+ var list = visibleFiles();
1046
+ if (list.length === 0) return null;
1047
+ for (var index = 0; index < list.length; index += 1) {
1048
+ if (list[index].file === state.cursor) {
1049
+ openListEntry(list[index]);
1050
+ return list[index];
1051
+ }
1052
+ }
1053
+ return stepFile(1, true);
1054
+ }
1055
+
1056
+ /** 当前差异里有几个 hunk —— n / p 的边界就是它。 */
1057
+ function hunkCount() {
1058
+ var payload = diffPayload(state.diff);
1059
+ var rows = payload && payload.rows ? payload.rows : [];
1060
+ var total = 0;
1061
+ for (var index = 0; index < rows.length; index += 1) {
1062
+ if (rows[index].kind === "hunk") total += 1;
1063
+ }
1064
+ return total;
1065
+ }
1066
+
1067
+ /**
1068
+ * n / p:跳到下一个 / 上一个 hunk。跳到折叠着的 hunk 会顺手展开它 ——
1069
+ * 不然按键看起来什么都没发生。
1070
+ */
1071
+ function stepHunk(delta) {
1072
+ if (!state.diff) return;
1073
+ var total = hunkCount();
1074
+ if (total === 0) return;
1075
+ var current = typeof state.diff.hunk === "number" ? state.diff.hunk : 0;
1076
+ var next = Math.min(Math.max(0, current + delta), total - 1);
1077
+ var collapsed = (state.diff.collapsed || []).filter(function (index) { return index !== next });
1078
+ set({ diff: Object.assign({}, state.diff, { hunk: next, collapsed: collapsed }) });
1079
+ }
1080
+
1081
+ /** 折叠 / 展开一个 hunk(点 hunk 头,或 Space 作用于当前 hunk)。 */
1082
+ function toggleHunk(index) {
1083
+ if (!state.diff || typeof index !== "number" || index < 0) return;
1084
+ var collapsed = state.diff.collapsed || [];
1085
+ var next = collapsed.indexOf(index) >= 0
1086
+ ? collapsed.filter(function (item) { return item !== index })
1087
+ : collapsed.concat([index]);
1088
+ set({ diff: Object.assign({}, state.diff, { hunk: index, collapsed: next }) });
1089
+ }
1090
+
1091
+ function toggleDiffWrap() {
1092
+ if (!state.diff) return;
1093
+ set({ diff: Object.assign({}, state.diff, { wrap: state.diff.wrap !== true }) });
1094
+ persist();
1095
+ }
1096
+
1097
+ /** dock 行的位置变了才写,避免每次测量都触发重渲染。 */
1098
+ function setAnchorTop(top) {
1099
+ if (state.anchorTop === top) return;
1100
+ set({ anchorTop: top });
1101
+ }
1102
+
1103
+ /**
1104
+ * 取最近提交。失败只让这一段不显示,不影响面板其余部分——
1105
+ * 提交是背景信息,不该抢走「工作区状态」的注意力。
1106
+ */
1107
+ function loadCommits(cwd, oid) {
1108
+ return Promise.resolve()
1109
+ .then(function () {
1110
+ return ctx.connection.rpc.call(CHANNEL, "log", { args: { cwd: cwd, count: 10 } });
1111
+ })
1112
+ .then(function (result) {
1113
+ if (state.cwd !== cwd) return;
1114
+ var commits = result && result.ok === true && result.value && Array.isArray(result.value.commits)
1115
+ ? result.value.commits
1116
+ : [];
1117
+ set({ commits: commits, commitsOid: oid });
1118
+ })
1119
+ .catch(function () {
1120
+ if (state.cwd !== cwd) return;
1121
+ set({ commits: [], commitsOid: oid });
1122
+ });
1123
+ }
1124
+
1125
+ function refresh() {
1126
+ // 会话归因跟 status 同一条命(两个都是"当前会话 + 当前工作区"的只读事实)。
1127
+ loadSessionChanges();
1128
+ // 回合投影:会话还没物化时这里会安静地不接,下一次刷新再试。
1129
+ bindTurns(state.sessionId);
1130
+ if (!state.cwd) return Promise.resolve();
1131
+ if (inflight) return inflight;
1132
+ var cwd = state.cwd;
1133
+
1134
+ set({ phase: state.status ? "ready" : "loading" });
1135
+
1136
+ inflight = Promise.resolve()
1137
+ .then(function () {
1138
+ // 面板打开时才要"这一版"的签名:胶囊那一路不需要,白付一次 stat。
1139
+ return ctx.connection.rpc.call(CHANNEL, "status", { args: { cwd: cwd, review: state.panelOpen === true } });
1140
+ })
1141
+ .then(function (result) {
1142
+ inflight = null;
1143
+ if (state.cwd !== cwd) return;
1144
+ if (result && result.ok === true) {
1145
+ var statusValue = result.value || null;
1146
+ set({
1147
+ phase: "ready",
1148
+ status: statusValue,
1149
+ // 送回来的"这一版"签名(没要或没改动就是空表)。
1150
+ review: statusValue && statusValue.review && typeof statusValue.review === "object"
1151
+ ? statusValue.review
1152
+ : {},
1153
+ errorCode: null,
1154
+ updatedAt: Date.now(),
1155
+ });
1156
+ // 提交只取一次:面板打开时取,且只有 HEAD 变了(提交过、切分支、拉取)才重取。
1157
+ if (statusValue && state.panelOpen
1158
+ && (state.commits === null || state.commitsOid !== statusValue.oid)) {
1159
+ loadCommits(cwd, statusValue.oid);
1160
+ }
1161
+ return;
1162
+ }
1163
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
1164
+ set({ phase: "error", errorCode: code || "git-failed" });
1165
+ })
1166
+ .catch(function () {
1167
+ inflight = null;
1168
+ if (state.cwd !== cwd) return;
1169
+ set({ phase: "error", errorCode: "transport" });
1170
+ });
1171
+
1172
+ return inflight;
1173
+ }
1174
+
1175
+ /** 胶囊调用:把当前会话的工作区写进 store,变了才重新取数。 */
1176
+ function bindSession(sessionId, cwd) {
1177
+ if (state.sessionId === sessionId && state.cwd === (cwd || null)) return;
1178
+ set({
1179
+ sessionId: sessionId === undefined ? null : sessionId,
1180
+ cwd: cwd || null,
1181
+ status: null,
1182
+ errorCode: null,
1183
+ commits: null,
1184
+ commitsOid: null,
1185
+ // 差异与提交明细都绑定在某个工作区上:换了工作区就把它们收起,
1186
+ // 否则会显示另一个仓库的文件或提交。键盘光标同理,它指向的是清单里的行。
1187
+ diff: null,
1188
+ commit: null,
1189
+ cursor: null,
1190
+ phase: cwd ? "loading" : "idle",
1191
+ });
1192
+ // 归因是"这个会话"的事实:换会话就先清空,别把上一个会话的标记留在新会话的清单上。
1193
+ set({ sessionChanges: { phase: "idle", files: null, possible: null, commands: [], since: null, dropped: 0, possibleDropped: 0, errorCode: null } });
1194
+ // 回合信息同理:换会话就按新会话重新接(旧的先解掉)。
1195
+ bindTurns(sessionId);
1196
+ refresh();
1197
+ }
1198
+
1199
+ /**
1200
+ * 取"本会话改过哪些文件"。
1201
+ *
1202
+ * 与 `status` 同一条命:状态刷新时顺带取一次(都是本进程内的读,很便宜)。失败就保持
1203
+ * `files: null`(界面不打标记、也不过这个滤),不会挡住 git 那半边。
1204
+ */
1205
+ function loadSessionChanges() {
1206
+ var cwd = state.cwd;
1207
+ var sessionId = state.sessionId;
1208
+ if (!cwd || !sessionId) return Promise.resolve();
1209
+ if (sessionInflight) return sessionInflight;
1210
+ if (state.sessionChanges.phase === "idle") set({ sessionChanges: Object.assign({}, state.sessionChanges, { phase: "loading" }) });
1211
+
1212
+ sessionInflight = Promise.resolve()
1213
+ .then(function () {
1214
+ return ctx.connection.rpc.call(CHANNEL, "session-changes", { args: { cwd: cwd, sessionId: sessionId } });
1215
+ })
1216
+ .then(function (result) {
1217
+ sessionInflight = null;
1218
+ if (state.cwd !== cwd || state.sessionId !== sessionId) return;
1219
+ if (result && result.ok === true && result.value && result.value.cwdMismatch === true) {
1220
+ probe.log("session-changes", { cwdMismatch: true });
1221
+ }
1222
+ if (result && result.ok === true && result.value && result.value.cwdMismatch !== true) {
1223
+ var files = {};
1224
+ var list = Array.isArray(result.value.files) ? result.value.files : [];
1225
+ for (var index = 0; index < list.length; index += 1) {
1226
+ var item = list[index];
1227
+ if (item && typeof item.file === "string" && item.file !== "") files[item.file] = item;
1228
+ }
1229
+ var possible = {};
1230
+ var possibleList = Array.isArray(result.value.possible) ? result.value.possible : [];
1231
+ for (var pIndex = 0; pIndex < possibleList.length; pIndex += 1) {
1232
+ var possibleItem = possibleList[pIndex];
1233
+ if (possibleItem && typeof possibleItem.file === "string" && possibleItem.file !== "") {
1234
+ possible[possibleItem.file] = possibleItem;
1235
+ }
1236
+ }
1237
+ var since = typeof result.value.since === "number" ? result.value.since : null;
1238
+ var dropped = typeof result.value.dropped === "number" ? result.value.dropped : 0;
1239
+ probe.log("session-changes", {
1240
+ files: Object.keys(files).length,
1241
+ possible: Object.keys(possible).length,
1242
+ commands: Array.isArray(result.value.commands) ? result.value.commands.length : 0,
1243
+ dropped: dropped,
1244
+ since: since === null ? null : new Date(since).toISOString(),
1245
+ });
1246
+ set({
1247
+ sessionChanges: {
1248
+ phase: "ready",
1249
+ files: files,
1250
+ possible: possible,
1251
+ commands: Array.isArray(result.value.commands) ? result.value.commands : [],
1252
+ since: since,
1253
+ dropped: dropped,
1254
+ possibleDropped: typeof result.value.possibleDropped === "number" ? result.value.possibleDropped : 0,
1255
+ errorCode: null,
1256
+ },
1257
+ });
1258
+ return;
1259
+ }
1260
+ // 宿主不认这个端点(插件刚更新、宿主半还是旧的):保持"没有数据",别打标记。
1261
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
1262
+ probe.log("session-changes-failed", { code: code || "transport" });
1263
+ set({ sessionChanges: { phase: "error", files: null, possible: null, commands: [], since: null, dropped: 0, possibleDropped: 0, errorCode: code || "transport" } });
1264
+ })
1265
+ .catch(function () {
1266
+ sessionInflight = null;
1267
+ if (state.cwd !== cwd) return;
1268
+ probe.log("session-changes-failed", { code: "transport" });
1269
+ set({ sessionChanges: { phase: "error", files: null, possible: null, commands: [], since: null, dropped: 0, possibleDropped: 0, errorCode: "transport" } });
1270
+ });
1271
+
1272
+ return sessionInflight;
1273
+ }
1274
+
1275
+ // 回合信息要的 `sessions` 客户端服务:**动态注入**等它到齐(软依赖)——
1276
+ // 缺它时 git、归因、胶囊一切照旧,只是不显示"第几轮"。到齐后把当前会话接上。
1277
+ if (typeof ctx.inject === "function") {
1278
+ ctx.effect(function () {
1279
+ return ctx.inject(["sessions"], function (scope) {
1280
+ turnSessions = scope.sessions;
1281
+ probe.log("turn-services", { sessions: true });
1282
+ bindTurns(state.sessionId);
1283
+ return function () {
1284
+ turnSessions = null;
1285
+ unbindTurns();
1286
+ };
1287
+ });
1288
+ }, "dsh-hud: turn services");
1289
+ }
1290
+
1291
+ /**
1292
+ * 接上 `turnOutline` 投影("第几轮改的")。
1293
+ *
1294
+ * 软依赖:`sessions` 服务、已物化的会话、这个投影三者缺一个就安静地不接 —— 归因、
1295
+ * git、胶囊一切照旧,只是不显示回合信息。整段包在 try/catch 里,读不到不该把面板带崩。
1296
+ */
1297
+ function unbindTurns() {
1298
+ if (turnsBound === null) return;
1299
+ try {
1300
+ if (typeof turnsBound.dispose === "function") turnsBound.dispose();
1301
+ } catch (error) {
1302
+ // 解绑失败不影响接下来的绑定。
1303
+ }
1304
+ turnsBound = null;
1305
+ set({ turns: { available: false, prompts: {}, latest: null } });
1306
+ }
1307
+
1308
+ function bindTurns(sessionId) {
1309
+ if (turnsBound !== null && turnsBound.sessionId === sessionId) return;
1310
+ unbindTurns();
1311
+ if (!sessionId || turnSessions === null) return;
1312
+ try {
1313
+ var bound = turnSessions.binding(sessionId);
1314
+ var live = bound ? bound.session : undefined;
1315
+ if (!live || !live.projections || typeof live.projections.faceOf !== "function") return;
1316
+ var face = live.projections.faceOf("turnOutline");
1317
+ if (!face || typeof face.getSnapshot !== "function") return;
1318
+ var apply = function () {
1319
+ try {
1320
+ set({ turns: normalizeTurns(face.getSnapshot()) });
1321
+ } catch (error) {
1322
+ probe.log("turns-read-failed", { error: error instanceof Error ? error.message : String(error) });
1323
+ }
1324
+ };
1325
+ apply();
1326
+ var dispose = typeof face.subscribe === "function" ? face.subscribe(apply) : null;
1327
+ turnsBound = { sessionId: sessionId, dispose: dispose };
1328
+ } catch (error) {
1329
+ // 会话还没物化、投影还没注册、注入没到位 —— 都只意味着"这一轮拿不到回合信息"。
1330
+ probe.log("turns-bind-failed", { error: error instanceof Error ? error.message : String(error) });
1331
+ }
1332
+ }
1333
+
1334
+ /** 归一 `turnOutline` 快照:`{ turns: [{ turn, prompt }], draft }`。 */
1335
+ function normalizeTurns(snapshot) {
1336
+ var entries = snapshot !== null && typeof snapshot === "object" && Array.isArray(snapshot.turns) ? snapshot.turns : null;
1337
+ if (entries === null) return { available: false, prompts: {}, latest: null };
1338
+ var prompts = {};
1339
+ var latest = null;
1340
+ for (var index = 0; index < entries.length; index += 1) {
1341
+ var entry = entries[index];
1342
+ if (!entry || typeof entry.turn !== "number") continue;
1343
+ if (typeof entry.prompt === "string" && entry.prompt.length > 0) prompts[entry.turn] = entry.prompt;
1344
+ if (latest === null || entry.turn > latest) latest = entry.turn;
1345
+ }
1346
+ return { available: true, prompts: prompts, latest: latest };
1347
+ }
1348
+
1349
+ /**
1350
+ * 取侧栏每条会话行要的摘要(**一次批量**,不是 N 次 RPC)。
1351
+ *
1352
+ * 会话列表由**宿主**枚举(它有 `sessions` 服务),客户端不用把名单端过去;宿主没有
1353
+ * 那个服务时回 `unavailable: true`,那些 chip 就整片不画(其余功能照旧)。
1354
+ */
1355
+ function refreshRows() {
1356
+ if (rowsInflight) return rowsInflight;
1357
+ rowsInflight = Promise.resolve()
1358
+ .then(function () {
1359
+ return ctx.connection.rpc.call(CHANNEL, "session-statuses", { args: {} });
1360
+ })
1361
+ .then(function (result) {
1362
+ rowsInflight = null;
1363
+ if (result && result.ok === true && result.value) {
1364
+ var byId = {};
1365
+ var list = Array.isArray(result.value.sessions) ? result.value.sessions : [];
1366
+ for (var index = 0; index < list.length; index += 1) {
1367
+ var item = list[index];
1368
+ if (item && typeof item.sessionId === "string") byId[item.sessionId] = item;
1369
+ }
1370
+ probe.log("session-statuses", {
1371
+ sessions: list.length,
1372
+ unavailable: result.value.unavailable === true,
1373
+ worktrees: typeof result.value.worktrees === "number" ? result.value.worktrees : null,
1374
+ truncated: result.value.truncated === true,
1375
+ });
1376
+ set({
1377
+ rows: {
1378
+ phase: "ready",
1379
+ byId: byId,
1380
+ unavailable: result.value.unavailable === true,
1381
+ truncated: result.value.truncated === true,
1382
+ updatedAt: Date.now(),
1383
+ },
1384
+ });
1385
+ return;
1386
+ }
1387
+ var code = result && result.error && result.error.details ? result.error.details.code : null;
1388
+ probe.log("session-statuses-failed", { code: code || "transport" });
1389
+ set({ rows: { phase: "error", byId: {}, unavailable: true, truncated: false, updatedAt: Date.now() } });
1390
+ })
1391
+ .catch(function () {
1392
+ rowsInflight = null;
1393
+ probe.log("session-statuses-failed", { code: "transport" });
1394
+ set({ rows: { phase: "error", byId: {}, unavailable: true, truncated: false, updatedAt: Date.now() } });
1395
+ });
1396
+ return rowsInflight;
1397
+ }
1398
+
1399
+ /** 审阅存档的键:**按工作区分开**,同一个文件名在别的仓库里是另一码事。 */
1400
+ function reviewedKeyOf(file) {
1401
+ return reviewKeyOf(state.cwd, file);
1402
+ }
1403
+
1404
+ /**
1405
+ * 标记 / 取消"这个文件的这一版我审过了"。
1406
+ *
1407
+ * 签名用的是**当前**这一版(宿主 stat 出来的 size+mtimeMs):下次改动一落盘签名就变,
1408
+ * 标记自动降级成"改动已变,需要重看" —— 而不是继续冒充"已审"。
1409
+ */
1410
+ function toggleReviewed(file) {
1411
+ if (typeof file !== "string" || file.length === 0) return;
1412
+ var signature = state.review ? state.review[file] : undefined;
1413
+ if (typeof signature !== "string") {
1414
+ // 没有签名就不能标记:标了也算不出"还是不是这一版",那是假进度。
1415
+ probe.log("review-no-signature", { file: file });
1416
+ return;
1417
+ }
1418
+ var next = {};
1419
+ for (var key in state.reviewed) {
1420
+ if (Object.prototype.hasOwnProperty.call(state.reviewed, key)) next[key] = state.reviewed[key];
1421
+ }
1422
+ var markKey = reviewedKeyOf(file);
1423
+ if (next[markKey] !== undefined && next[markKey].signature === signature) delete next[markKey];
1424
+ else next[markKey] = { signature: signature, at: Date.now() };
1425
+ var written = writeReviewed(next);
1426
+ set({ reviewed: written });
1427
+ }
1428
+
1429
+ /** 清掉**当前工作区**的全部"已审"标记(别的仓库不动)。 */
1430
+ function clearReviewed() {
1431
+ var prefix = reviewKeyOf(state.cwd, "");
1432
+ var next = {};
1433
+ var removed = 0;
1434
+ for (var key in state.reviewed) {
1435
+ if (!Object.prototype.hasOwnProperty.call(state.reviewed, key)) continue;
1436
+ if (key.indexOf(prefix) === 0) {
1437
+ removed += 1;
1438
+ continue;
1439
+ }
1440
+ next[key] = state.reviewed[key];
1441
+ }
1442
+ if (removed === 0) return;
1443
+ set({ reviewed: writeReviewed(next) });
1444
+ }
1445
+
1446
+ /** 侧栏状态档位:常亮 → 悬停 → 不显示,点一下走一档。 */
1447
+ function cycleSidebarMode() {
1448
+ var index = SIDEBAR_MODES.indexOf(state.sidebarMode);
1449
+ var next = SIDEBAR_MODES[(index + 1) % SIDEBAR_MODES.length];
1450
+ set({ sidebarMode: next });
1451
+ persist();
1452
+ }
1453
+
1454
+ /** 「只看最近一轮」开关:过滤发生在 `visibleChangeRows`(渲染与键盘共用)。 */
1455
+ function toggleOnlyLatest() {
1456
+ set({ onlyLatest: state.onlyLatest !== true });
1457
+ persist();
1458
+ }
1459
+
1460
+ /** 「只看本会话改的」开关:过滤发生在 `visibleChangeRows`(渲染与键盘共用)。 */
1461
+ function toggleOnlySession() {
1462
+ set({ onlySession: state.onlySession !== true });
1463
+ persist();
1464
+ }
1465
+
1466
+ function openPanel(open) {
1467
+ if (state.panelOpen === open) return;
1468
+ set({ panelOpen: open });
1469
+ persist();
1470
+ if (open) refresh();
1471
+ }
1472
+
1473
+ function togglePanel() {
1474
+ openPanel(!state.panelOpen);
1475
+ }
1476
+
1477
+ /** 拖动过程中的临时位置:只改内存,不写 localStorage。 */
1478
+ function movePanel(next) {
1479
+ set({ pos: next });
1480
+ }
1481
+
1482
+ /** 松开指针后的位置:落盘。 */
1483
+ function commitPanel(next) {
1484
+ set({ pos: next });
1485
+ persist();
1486
+ }
1487
+
1488
+ function resetPanel() {
1489
+ set({ pos: null });
1490
+ persist();
1491
+ }
1492
+
1493
+ return {
1494
+ getSnapshot: getSnapshot,
1495
+ subscribe: subscribe,
1496
+ bindSession: bindSession,
1497
+ refresh: refresh,
1498
+ openPanel: openPanel,
1499
+ togglePanel: togglePanel,
1500
+ movePanel: movePanel,
1501
+ commitPanel: commitPanel,
1502
+ resetPanel: resetPanel,
1503
+ setAnchorTop: setAnchorTop,
1504
+ setDiffWidth: setDiffWidth,
1505
+ persistLayout: persist,
1506
+ openDiff: openDiff,
1507
+ closeDiff: closeDiff,
1508
+ closeDetail: closeDetail,
1509
+ setDiffSide: setDiffSide,
1510
+ toggleDiffWrap: toggleDiffWrap,
1511
+ openCommit: openCommit,
1512
+ openCommitDiff: openCommitDiff,
1513
+ toggleCommit: toggleCommit,
1514
+ closeCommit: closeCommit,
1515
+ stepFile: stepFile,
1516
+ openCursor: openCursor,
1517
+ stepHunk: stepHunk,
1518
+ toggleHunk: toggleHunk,
1519
+ toggleOnlySession: toggleOnlySession,
1520
+ toggleOnlyLatest: toggleOnlyLatest,
1521
+ refreshRows: refreshRows,
1522
+ cycleSidebarMode: cycleSidebarMode,
1523
+ toggleReviewed: toggleReviewed,
1524
+ clearReviewed: clearReviewed,
1525
+ };
1526
+ }
1527
+
1528
+ /* ---------------------------------------------------------- 订阅 hook */
1529
+
1530
+ /** 订阅共享 store 的经典写法(不依赖种子里的 useSyncExternalStore 构建)。 */
1531
+ function useStore(store) {
1532
+ var pair = React.useState(function () {
1533
+ return store.getSnapshot();
1534
+ });
1535
+ React.useEffect(function () {
1536
+ return store.subscribe(function () {
1537
+ pair[1](store.getSnapshot());
1538
+ });
1539
+ }, [store]);
1540
+ return pair[0];
1541
+ }
1542
+
1543
+ /* ------------------------------------------------------------- 小组件 */
1544
+
1545
+ function CountChips(props) {
1546
+ var counts = props.counts || {};
1547
+ var children = [];
1548
+ for (var index = 0; index < COUNT_ORDER.length; index += 1) {
1549
+ var key = COUNT_ORDER[index];
1550
+ var value = counts[key] || 0;
1551
+ if (value <= 0) continue;
1552
+ children.push(h("span", {
1553
+ key: key,
1554
+ className: "dshh-chip",
1555
+ style: { color: COUNT_COLOR[key], borderColor: "currentColor" },
1556
+ title: props.t("count." + key) + " " + String(value),
1557
+ }, props.t("count." + key) + " " + String(value)));
1558
+ }
1559
+ if (children.length === 0) {
1560
+ return h("span", { className: "dshh-clean" }, props.t("panel.clean"));
1561
+ }
1562
+ return h("div", { className: "dshh-chips" }, children);
1563
+ }
1564
+
1565
+ function ChangeList(props) {
1566
+ var changes = props.changes || [];
1567
+ var activeFile = props.activeFile;
1568
+ var cursorFile = props.cursor || null;
1569
+ // 提交明细打开时,屏幕上会同时有两份清单(左栏工作区 + 右栏这次提交),
1570
+ // 所以行标记必须带上是哪一份,否则"把选中行滚进视野"会滚错栏。
1571
+ var listId = props.listId || "changes";
1572
+
1573
+ // 键盘选中(或正在看)的那行可能被滚出视野:把它带回来。
1574
+ React.useEffect(function () {
1575
+ if (typeof document === "undefined" || typeof document.querySelector !== "function") return;
1576
+ var wanted = cursorFile || activeFile;
1577
+ if (!wanted) return;
1578
+ for (var index = 0; index < changes.length; index += 1) {
1579
+ if (changes[index].file !== wanted) continue;
1580
+ var node = document.querySelector('[data-dsh-hud="row"][data-row-list="' + listId + '"][data-row-index="' + String(index) + '"]');
1581
+ revealRow(node, "dshh-list");
1582
+ return;
1583
+ }
1584
+ }, [cursorFile, activeFile, changes.length, listId]);
1585
+
1586
+ if (changes.length === 0) return null;
1587
+
1588
+ // 会话归因标记:只在这份数据到手时才有(`sessionView` 为 null 就整列不画)。
1589
+ var sessionView = props.sessionView === undefined ? null : props.sessionView;
1590
+ // 审阅进度:`null` 表示这次没要签名(胶囊那一路的轮询就是这样),整列不画。
1591
+ var review = props.review === undefined ? null : props.review;
1592
+
1593
+ var rows = changes.map(function (change, index) {
1594
+ // 整行可点:打开该文件的差异视图(只读,不改动任何东西)。
1595
+ // "正在看的那行"和"键盘选中的那行"可能是两行(选了还没打开),因此两种标记分开。
1596
+ return h("button", {
1597
+ type: "button",
1598
+ className: "dshh-row"
1599
+ + (activeFile && change.file === activeFile ? " dshh-rowActive" : "")
1600
+ + (cursorFile && change.file === cursorFile ? " dshh-rowCursor" : ""),
1601
+ key: String(index) + ":" + change.path,
1602
+ "data-dsh-hud": "row",
1603
+ "data-row-list": listId,
1604
+ "data-row-index": String(index),
1605
+ title: change.path
1606
+ + (sessionView === null ? "" : "\n" + sessionTitle(sessionView, change.file, props.t))
1607
+ + "\n" + props.t("panel.clickForDiff")
1608
+ + "\n" + props.t("panel.rowKeys"),
1609
+ onClick: function () {
1610
+ props.onOpen(change);
1611
+ },
1612
+ },
1613
+ sessionView === null
1614
+ ? null
1615
+ : h("span", {
1616
+ className: "dshh-marker"
1617
+ + (sessionMarkOf(sessionView, change.file) === "mine"
1618
+ ? ""
1619
+ : sessionMarkOf(sessionView, change.file) === "possible" ? " dshh-markerPossible" : " dshh-markerOther"),
1620
+ "data-session-mark": sessionMarkOf(sessionView, change.file),
1621
+ "aria-hidden": "true",
1622
+ }, sessionMarkOf(sessionView, change.file) === "mine"
1623
+ ? "●"
1624
+ : sessionMarkOf(sessionView, change.file) === "possible" ? "◐" : "○"),
1625
+ h("span", {
1626
+ className: "dshh-status",
1627
+ style: { color: COUNT_COLOR[statusKey(change.status)] || "var(--dsw-alias-label-secondary, #8a8f98)" },
1628
+ }, change.status),
1629
+ h("span", { className: "dshh-path" }, change.path),
1630
+ review === null || review.known === undefined || review.known[change.file] !== true
1631
+ ? null
1632
+ : h("span", {
1633
+ className: "dshh-review"
1634
+ + (review.marks[change.file] === "fresh" ? " dshh-reviewOn" : "")
1635
+ + (review.marks[change.file] === "stale" ? " dshh-reviewStale" : ""),
1636
+ "data-review": review.marks[change.file] === undefined ? "none" : review.marks[change.file],
1637
+ title: reviewTitle(review.marks[change.file], props.t),
1638
+ onClick: function (event) {
1639
+ // 行本身是按钮(点开差异):这里必须拦住,否则标记一下就顺手打开了差异。
1640
+ event.stopPropagation();
1641
+ event.preventDefault();
1642
+ props.onToggleReviewed(change.file);
1643
+ },
1644
+ }, review.marks[change.file] === undefined ? "○" : "✓"));
1645
+ });
1646
+
1647
+ var footer = props.truncated
1648
+ ? h("div", { className: "dshh-note" }, props.t("panel.truncated"))
1649
+ : null;
1650
+
1651
+ return h("div", { className: "dshh-section" },
1652
+ h("div", { className: "dshh-sectionTitle" }, props.title || (props.t("panel.changes") + " · " + String(changes.length))),
1653
+ h("div", { className: "dshh-list" }, rows),
1654
+ footer);
1655
+ }
1656
+
1657
+ /**
1658
+ * 清单里那一行的悬停说明:这行是不是本会话改的。
1659
+ *
1660
+ * `mine` 里有这条就把写入/编辑次数与时间区间说清楚;没有就直说"不是这个会话改的"
1661
+ * (可能是你自己、编辑器、或另一个会话)。
1662
+ */
1663
+ /**
1664
+ * 一轮的说明:`第 3 轮:<那一轮的请求预览>`。
1665
+ *
1666
+ * 回合号从宿主的 `turnBoundary` 投影来,请求预览从 `turnOutline` 投影来 —— 拿不到 prompt
1667
+ * 时只说"第 N 轮",不编内容。
1668
+ */
1669
+ function turnLabel(view, turn, t) {
1670
+ var prompt = view.turnPrompts !== undefined && view.turnPrompts !== null ? view.turnPrompts[turn] : undefined;
1671
+ if (typeof prompt !== "string" || prompt.length === 0) return t("session.turn", { n: String(turn + 1) });
1672
+ return t("session.turnWithPrompt", { n: String(turn + 1), prompt: prompt });
1673
+ }
1674
+
1675
+ /** 一个文件属于哪一档:`mine` 确定改过 / `possible` 命令旁听 / `other` 与本会话无关。 */
1676
+ function sessionMarkOf(view, file) {
1677
+ if (view.mine !== undefined && view.mine[file] !== undefined) return "mine";
1678
+ if (view.possible !== undefined && view.possible[file] !== undefined) return "possible";
1679
+ return "other";
1680
+ }
1681
+
1682
+ function sessionTitle(view, file, t) {
1683
+ var record = view.mine !== undefined ? view.mine[file] : undefined;
1684
+ if (record === undefined) {
1685
+ var guess = view.possible !== undefined ? view.possible[file] : undefined;
1686
+ if (guess === undefined) return t("session.other");
1687
+ // 低一档的置信度:说清是"从命令里看出来的",并把那条命令贴出来。
1688
+ return t("session.possibleTitle", { reason: String(guess.reason || "").slice(0, 80) });
1689
+ }
1690
+ var parts = [];
1691
+ if (record.writes > 0) parts.push(t("session.writes", { n: String(record.writes) }));
1692
+ if (record.edits > 0) parts.push(t("session.edits", { n: String(record.edits) }));
1693
+ var when = typeof record.lastAt === "number" ? timeLabel(record.lastAt) : "";
1694
+ var line = t("session.mine") + (parts.length === 0 ? "" : "(" + parts.join("、") + ")") + (when ? " " + when : "");
1695
+ // 回合是"这一轮改了什么"的锚点:有就一并说清是哪一轮、那一轮的请求是什么。
1696
+ var turn = typeof record.lastTurn === "number" ? record.lastTurn : null;
1697
+ if (turn !== null) line += "\n" + turnLabel(view, turn, t);
1698
+ return line;
1699
+ }
1700
+
1701
+ /** 行尾那个标记的悬停说明:三种状态各说清下一步。 */
1702
+ function reviewTitle(state, t) {
1703
+ if (state === "fresh") return t("review.unmark");
1704
+ if (state === "stale") return t("review.stale");
1705
+ return t("review.mark");
1706
+ }
1707
+
1708
+ /**
1709
+ * 那一行的悬停说明:自哪一刻起记录 + 跑过哪些命令。
1710
+ *
1711
+ * "可能相关"是从命令里看出来的,用户要能自己判断 —— 所以把命令原文贴出来,而不是只报一个数。
1712
+ */
1713
+ function sessionLineTitle(view, t) {
1714
+ // 没有归因数据(那一行只剩审阅进度)时没有"自 … 起记录"也没有命令可列。
1715
+ if (view === null) return undefined;
1716
+ var lines = [];
1717
+ if (view.since !== null) lines.push(t("session.since", { time: timeLabel(view.since) }));
1718
+ var commands = (view.commands || []).slice(-5).reverse();
1719
+ if (commands.length > 0) {
1720
+ lines.push(t("session.commands", { n: String(commands.length) }));
1721
+ for (var index = 0; index < commands.length; index += 1) {
1722
+ var item = commands[index] || {};
1723
+ lines.push("· " + String(item.command || "") + (item.failed === true ? " " + t("session.commandFailed") : ""));
1724
+ }
1725
+ if (view.writingCommands > commands.length) {
1726
+ lines.push(t("session.commandsMore", { n: String(view.writingCommands - commands.length) }));
1727
+ }
1728
+ }
1729
+ return lines.length === 0 ? undefined : lines.join("\n");
1730
+ }
1731
+
1732
+ /**
1733
+ * 改动概览下面那一行:本会话改了几个文件 + 「只看本会话改的」开关。
1734
+ *
1735
+ * 归因数据没到手(`view === null`)时整行不渲染 —— 不显示一个"全部都是别人的"的假象。
1736
+ */
1737
+ function SessionChangesLine(props) {
1738
+ var t = props.t;
1739
+ var view = props.view;
1740
+ var review = props.review || null;
1741
+ // 这一行**总是渲染**:它带着三枚开关(只看本会话改的 / 侧栏档位 / 只看最近一轮),
1742
+ // 归因或审阅数据缺失时只是没有计数可报 —— 控件不能因为"数据还没到"就凭空消失。
1743
+ var notes = [];
1744
+ if (view !== null) {
1745
+ notes.push(t("session.line", { n: String(view.mineCount) }));
1746
+ // 回合信息拿得到时,"最近一轮改了几个"是最常问的那个问题,直接写在行上。
1747
+ if (view.latestTurn !== null && view.latestCount > 0) {
1748
+ notes.push(t("session.latestTurn", { n: String(view.latestCount), turn: String(view.latestTurn + 1) }));
1749
+ }
1750
+ if (view.possibleCount > 0) notes.push(t("session.possible", { n: String(view.possibleCount) }));
1751
+ }
1752
+ if (view !== null) {
1753
+ if (view.gone.length > 0) notes.push(t("session.gone", { n: String(view.gone.length) }));
1754
+ if (view.dropped > 0) notes.push(t("session.dropped", { n: String(view.dropped) }));
1755
+ if (view.possibleDropped > 0) notes.push(t("session.possibleDropped", { n: String(view.possibleDropped) }));
1756
+ }
1757
+ if (props.review && props.review.total > 0) {
1758
+ notes.push(t("review.count", { n: String(props.review.done), total: String(props.review.total) }));
1759
+ }
1760
+ if (props.review && props.review.staleCount > 0) notes.push(t("review.staleCount", { n: String(props.review.staleCount) }));
1761
+ return h("div", { className: "dshh-session", "data-dsh-hud": "session" },
1762
+ // 没有任何可报的计数时,文字位置留空(开关照旧在右边)。
1763
+ notes.length === 0
1764
+ ? null
1765
+ : h("span", { className: "dshh-sessionText", title: sessionLineTitle(view, t) }, notes.join(" · ")),
1766
+ h("button", {
1767
+ type: "button",
1768
+ className: "dshh-sessionToggle" + (props.onlySession === true ? " dshh-sessionToggleOn" : ""),
1769
+ "data-session-filter": "only",
1770
+ "aria-pressed": props.onlySession === true ? "true" : "false",
1771
+ title: t("session.onlyTitle"),
1772
+ onClick: props.onToggle,
1773
+ }, props.onlySession === true ? "◉ " : "○ ", t("session.only")),
1774
+ // 已经把某些文件标成"已审"时,给一个"清空"的出口(只清当前工作区)。
1775
+ props.review && props.review.done > 0
1776
+ ? h("button", {
1777
+ type: "button",
1778
+ className: "dshh-sessionToggle",
1779
+ "data-session-review": "clear",
1780
+ title: t("review.clear"),
1781
+ onClick: props.onClearReviewed,
1782
+ }, t("review.clearShort"))
1783
+ : null,
1784
+ // 侧栏状态档位:常亮 → 悬停 → 关(默认常亮)。
1785
+ h("button", {
1786
+ type: "button",
1787
+ className: "dshh-sessionToggle" + (props.sidebarMode === "always" ? " dshh-sessionToggleOn" : ""),
1788
+ "data-session-filter": "sidebar",
1789
+ "data-sidebar-mode": props.sidebarMode,
1790
+ title: t("rows.modeTitle", { mode: t("rows.mode." + props.sidebarMode) }),
1791
+ onClick: props.onCycleSidebarMode,
1792
+ }, props.sidebarMode === "always" ? "◉ " : (props.sidebarMode === "hover" ? "◑ " : "○ "), t("rows.mode." + props.sidebarMode)),
1793
+ // 第二枚开关只在"这一轮的归属拿得到"时出现:拿不到就整枚不画,别给一个按不动的按钮。
1794
+ view === null || view.latestTurn === null
1795
+ ? null
1796
+ : h("button", {
1797
+ type: "button",
1798
+ className: "dshh-sessionToggle" + (props.onlyLatest === true ? " dshh-sessionToggleOn" : ""),
1799
+ "data-session-filter": "latest",
1800
+ "aria-pressed": props.onlyLatest === true ? "true" : "false",
1801
+ title: turnLabel(view, view.latestTurn, t) + "\n" + t("session.onlyLatestTitle"),
1802
+ onClick: props.onToggleLatest,
1803
+ }, props.onlyLatest === true ? "◉ " : "○ ", t("session.onlyLatest")));
1804
+ }
1805
+
1806
+ /** 取当前焦点节点;没有 document / activeElement 时给 null。 */
1807
+ function activeElement() {
1808
+ if (typeof document === "undefined") return null;
1809
+ return document.activeElement || null;
1810
+ }
1811
+
1812
+ /** 事件目标是否落在面板里(走自己的 `data-dsh-hud="panel"` 标记,不碰产品 DOM)。 */
1813
+ function isInsidePanel(node) {
1814
+ return isInsideMarked(node, "panel");
1815
+ }
1816
+
1817
+ /** 事件目标是否落在某个 `data-dsh-hud` 标记的子树里(自己的标记,不碰产品的选择器)。 */
1818
+ function isInsideMarked(node, mark) {
1819
+ var current = node;
1820
+ var depth = 0;
1821
+ while (current && depth < 64) {
1822
+ if (typeof current.getAttribute === "function" && current.getAttribute("data-dsh-hud") === mark) return true;
1823
+ current = current.parentNode;
1824
+ depth += 1;
1825
+ }
1826
+ return false;
1827
+ }
1828
+
1829
+ /**
1830
+ * 事件目标是不是"正在输入"的地方。
1831
+ *
1832
+ * 产品的输入框是 contenteditable,所以不能只看 tagName;两种都认。
1833
+ */
1834
+ function isTypingTarget(node) {
1835
+ var current = node;
1836
+ var depth = 0;
1837
+ while (current && depth < 8) {
1838
+ var tag = typeof current.tagName === "string" ? current.tagName.toLowerCase() : "";
1839
+ if (tag === "input" || tag === "textarea" || tag === "select") return true;
1840
+ if (current.isContentEditable === true) return true;
1841
+ if (typeof current.getAttribute === "function") {
1842
+ var editable = current.getAttribute("contenteditable");
1843
+ if (editable === "" || editable === "true" || editable === "plaintext-only") return true;
1844
+ }
1845
+ current = current.parentNode;
1846
+ depth += 1;
1847
+ }
1848
+ return false;
1849
+ }
1850
+
1851
+ /** 当前这一侧的 rows 载体:提交差异只有一侧,工作区差异要按 side 取。 */
1852
+ function diffPayload(diff) {
1853
+ if (!diff || !diff.data) return null;
1854
+ if (diff.source === "commit") return diff.data;
1855
+ return diff.data[diff.side === "index" ? "index" : "worktree"] || null;
1856
+ }
1857
+
1858
+ /**
1859
+ * 把 rows 整理成"要渲染的行 + 它属于哪个 hunk"。
1860
+ *
1861
+ * hunk 段从 hunk 头开始,到下一个 hunk 头(或结尾)结束;折叠某个 hunk 就是
1862
+ * 把该段里除头以外的行丢掉。hunk 头之前那些行(`diff --git`、`---`、`+++`)
1863
+ * 永远显示——它们是"这是哪个文件"的上下文。
1864
+ *
1865
+ * @returns `[{ row, hunk }]`,`hunk` 是 hunk 序号(头之前的行是 null,收起时是负数)。
1866
+ */
1867
+ function visibleDiffRows(rows, collapsed) {
1868
+ var folded = collapsed || [];
1869
+ var out = [];
1870
+ var ordinal = -1;
1871
+ for (var index = 0; index < rows.length; index += 1) {
1872
+ var row = rows[index];
1873
+ if (row.kind === "hunk") {
1874
+ ordinal += 1;
1875
+ out.push({ row: row, hunk: ordinal });
1876
+ continue;
1877
+ }
1878
+ if (ordinal >= 0 && folded.indexOf(ordinal) >= 0) continue;
1879
+ out.push({ row: row, hunk: ordinal >= 0 ? ordinal : null });
1880
+ }
1881
+ return out;
1882
+ }
1883
+
1884
+ /**
1885
+ * 一行差异:两个行号槽 + 文本。
1886
+ *
1887
+ * hunk 头是**可点**的(折叠 / 展开这一段),因此它第一个行号槽放的是折叠标记,
1888
+ * 不是行号——那个位置本来就空着,放标记不会让文本错位。
1889
+ */
1890
+ function DiffRow(props) {
1891
+ var row = props.row;
1892
+ var style = row.kind === "add" ? { background: DIFF_ADD_BG } : row.kind === "del" ? { background: DIFF_DEL_BG } : null;
1893
+ var gutterStyle = { color: DIFF_GUTTER_COLOR };
1894
+ var text = row.text === "" ? "\u00a0" : row.text;
1895
+
1896
+ if (row.kind === "hunk") {
1897
+ return h("div", {
1898
+ className: "dshh-difrow dshh-difrow-hunk" + (props.active ? " dshh-difrow-hunkActive" : ""),
1899
+ "data-dsh-hud": "hunk",
1900
+ "data-hunk-index": props.hunk === null || props.hunk === undefined ? undefined : String(props.hunk),
1901
+ title: props.hint,
1902
+ onClick: props.onToggle,
1903
+ },
1904
+ h("span", { className: "dshh-difno", style: gutterStyle }, ""),
1905
+ h("span", { className: "dshh-difno dshh-hunkMark", style: gutterStyle }, props.collapsed ? "▸" : "▾"),
1906
+ h("span", { className: "dshh-diftext" + (props.wrap ? " dshh-wrap" : "") }, text));
1907
+ }
1908
+
1909
+ return h("div", { className: "dshh-difrow dshh-difrow-" + row.kind, style: style },
1910
+ h("span", { className: "dshh-difno", style: gutterStyle }, row.oldLine === null ? "" : String(row.oldLine)),
1911
+ h("span", { className: "dshh-difno", style: gutterStyle }, row.newLine === null ? "" : String(row.newLine)),
1912
+ h("span", { className: "dshh-diftext" + (props.wrap ? " dshh-wrap" : "") }, text));
1913
+ }
1914
+
1915
+ /**
1916
+ * 把一个带自己标记的节点滚进它所在滚动容器的视野(最小滚动量)。
1917
+ *
1918
+ * 全走能力判断:假 DOM(测试)里没有 offsetTop / clientHeight,这个函数直接
1919
+ * no-op,测试断言的是状态与结构,不是像素。不用 `scrollIntoView`:那会连带
1920
+ * 滚动祖先容器(面板是浮层,滚了产品的会话列表就不好了)。
1921
+ */
1922
+ function revealRow(node, containerClassName) {
1923
+ if (!node || typeof node.parentNode === "undefined") return;
1924
+ var container = node.parentNode;
1925
+ var depth = 0;
1926
+ while (container && depth < 32) {
1927
+ if (String(container.className || "").indexOf(containerClassName) >= 0) break;
1928
+ container = container.parentNode;
1929
+ depth += 1;
1930
+ }
1931
+ if (!container || typeof container.scrollTop !== "number") return;
1932
+ var top = node.offsetTop;
1933
+ var height = node.offsetHeight;
1934
+ var view = container.clientHeight;
1935
+ if (typeof top !== "number" || typeof height !== "number" || typeof view !== "number") return;
1936
+ if (top < container.scrollTop) container.scrollTop = top;
1937
+ else if (top + height > container.scrollTop + view) container.scrollTop = top + height - view;
1938
+ }
1939
+
1940
+ /** 用自己写在节点上的标记找节点;找不到或不支持就是 null。 */
1941
+ function findMarked(selector) {
1942
+ if (typeof document === "undefined" || typeof document.querySelector !== "function") return null;
1943
+ return document.querySelector(selector);
1944
+ }
1945
+
1946
+ /**
1947
+ * 两个 sha 说的是不是同一次提交。
1948
+ *
1949
+ * 最近提交那份清单里是 **短 sha**,而提交明细里是宿主半解析回来的**完整 sha**,
1950
+ * 直接 `===` 永远配不上;按前缀对一下(git 自己认 rev 也是这个语义)。
1951
+ */
1952
+ function sameCommit(left, right) {
1953
+ if (typeof left !== "string" || typeof right !== "string" || left === "" || right === "") return false;
1954
+ return left === right || right.indexOf(left) === 0 || left.indexOf(right) === 0;
1955
+ }
1956
+
1957
+ /** 相对时间;超过 30 天直接给提交自己的日期。 */
1958
+ function commitWhen(commit, t) {
1959
+ if (typeof commit.committedAt !== "number") return commit.date || "";
1960
+ var seconds = Math.max(0, Math.round((Date.now() - commit.committedAt) / 1000));
1961
+ if (seconds < 60) return t("time.now");
1962
+ var minutes = Math.round(seconds / 60);
1963
+ if (minutes < 60) return String(minutes) + t("time.minutes");
1964
+ var hours = Math.round(minutes / 60);
1965
+ if (hours < 24) return String(hours) + t("time.hours");
1966
+ var days = Math.round(hours / 24);
1967
+ if (days < 30) return String(days) + t("time.days");
1968
+ return commit.date || "";
1969
+ }
1970
+
1971
+ /**
1972
+ * 最近提交:一行一条,sha + 标题 + HEAD 徽标 + 相对时间;整行可点看这次提交改了什么。
1973
+ * 打开的那条会高亮(再点一次收起)——提交明细在右栏,左栏这块主面板不动。
1974
+ */
1975
+ function CommitList(props) {
1976
+ var commits = props.commits || [];
1977
+ if (commits.length === 0) return null;
1978
+ var t = props.t;
1979
+
1980
+ return h("div", { className: "dshh-section" },
1981
+ h("div", { className: "dshh-sectionTitle" }, t("panel.commits")),
1982
+ h("div", { className: "dshh-commits" }, commits.map(function (commit, index) {
1983
+ var refs = commit.refs || [];
1984
+ var isHead = refs.some(function (ref) { return ref.indexOf("HEAD") === 0; });
1985
+ var tip = [t("commit.click"), "", commit.subject, commit.author + " · " + commit.date].concat(refs).join("\n");
1986
+ // 整行是按钮:点了在左栏换成这次提交的改动清单(只读)。
1987
+ var open = sameCommit(commit.sha, props.activeSha);
1988
+ return h("button", {
1989
+ type: "button",
1990
+ className: "dshh-commit" + (open ? " dshh-commitActive" : ""),
1991
+ key: commit.sha + ":" + String(index),
1992
+ title: tip,
1993
+ onClick: function () {
1994
+ if (props.onOpen) props.onOpen(commit);
1995
+ },
1996
+ },
1997
+ h("span", { className: "dshh-csha" }, commit.sha),
1998
+ h("span", { className: "dshh-csubject" }, commit.subject || "—"),
1999
+ isHead ? h("span", { className: "dshh-chead" }, "HEAD") : null,
2000
+ h("span", { className: "dshh-cwhen" }, commitWhen(commit, t)));
2001
+ })));
2002
+ }
2003
+
2004
+ /**
2005
+ * 一次提交的那一栏(**中间**栏,固定宽度):元信息 + 这次提交的改动清单。
2006
+ *
2007
+ * 差异在它右边那一栏(DiffView)。打开提交不改动左栏——左栏始终是那块主面板,
2008
+ * 只是"最近提交"里对应的一行高亮起来。清单复用工作区那套 ChangeList(同一个
2009
+ * 行样式、同一套键盘移动)。
2010
+ */
2011
+ function CommitPane(props) {
2012
+ var t = props.t;
2013
+ var commit = props.commit;
2014
+ var style = { width: props.width };
2015
+
2016
+ var head;
2017
+ if (commit.phase === "loading") {
2018
+ head = h("div", { className: "dshh-commitHead" },
2019
+ h("div", { className: "dshh-commitMeta" }, t("commit.loading")));
2020
+ } else if (commit.phase === "error") {
2021
+ head = h("div", { className: "dshh-commitHead" },
2022
+ h("div", { className: "dshh-commitMeta" }, errorText(commit.errorCode, t)));
2023
+ } else {
2024
+ var lines = [commit.author + " · " + commit.date];
2025
+ if (commit.refs && commit.refs.length > 0) lines.push(commit.refs.join(", "));
2026
+ if (commit.merge) lines.push(t("commit.merge"));
2027
+ head = h("div", { className: "dshh-commitHead" },
2028
+ h("div", { className: "dshh-commitTitle" },
2029
+ h("span", { className: "dshh-csha" }, commit.shortSha),
2030
+ h("span", { className: "dshh-commitSubject", title: commit.subject }, commit.subject || "—")),
2031
+ h("div", { className: "dshh-commitMeta" }, lines.join(" · ")));
2032
+ }
2033
+
2034
+ var files = commit.files || [];
2035
+ var list = commit.phase !== "ready"
2036
+ ? null
2037
+ : files.length === 0
2038
+ ? h("div", { className: "dshh-commitFiles" }, h("div", { className: "dshh-note" }, t("commit.empty")))
2039
+ : h("div", { className: "dshh-commitFiles" },
2040
+ h(ChangeList, {
2041
+ changes: files,
2042
+ truncated: commit.truncated,
2043
+ t: t,
2044
+ title: t("commit.files") + " · " + String(files.length),
2045
+ onOpen: props.onOpenFile,
2046
+ activeFile: props.activeFile,
2047
+ cursor: props.cursor,
2048
+ listId: "commit",
2049
+ }));
2050
+
2051
+ return h("div", { className: "dshh-commitpane", style: style }, head, list);
2052
+ }
2053
+
2054
+ /** 把 rows 还原成 unified diff 文本("复制差异"用)。 */
2055
+ function rowsToText(rows) {
2056
+ return rows.map(function (row) {
2057
+ if (row.kind === "add") return "+" + row.text;
2058
+ if (row.kind === "del") return "-" + row.text;
2059
+ if (row.kind === "ctx") return " " + row.text;
2060
+ return row.text;
2061
+ }).join("\n");
2062
+ }
2063
+
2064
+ /**
2065
+ * 差异视图:两侧页签(宿主半一次把两侧都取回来了,切页签不再发请求)、
2066
+ * 自动换行、复制差异。全部只读——它只是把 git 的输出画出来。
2067
+ */
2068
+ function DiffView(props) {
2069
+ var t = props.t;
2070
+ var diff = props.diff;
2071
+ var store = props.store;
2072
+
2073
+ var copiedRef = React.useState(false);
2074
+ var copied = copiedRef[0];
2075
+ var setCopied = copiedRef[1];
2076
+ var timerRef = React.useState(null);
2077
+
2078
+ var isCommit = diff.source === "commit";
2079
+ // 提交差异没有"侧"这个概念,但只要不是提交,页签就得知道自己亮的是哪个。
2080
+ var side = diff.side === "index" ? "index" : "worktree";
2081
+ var data = diffPayload(diff);
2082
+ var untracked = isCommit ? false : (diff.data ? diff.data.untracked === true : false);
2083
+ var hunk = typeof diff.hunk === "number" ? diff.hunk : 0;
2084
+ var collapsed = diff.collapsed || [];
2085
+ var rows = data && data.rows ? data.rows : [];
2086
+
2087
+ // 换了文件 / 侧 / 提交:回到顶部。否则会停在上一份 diff 的滚动位置
2088
+ // (React 复用同一个容器节点,scrollTop 会留下来)。
2089
+ React.useEffect(function () {
2090
+ var host = findMarked('[data-dsh-hud="diff"]');
2091
+ if (host && typeof host.scrollTop === "number") host.scrollTop = 0;
2092
+ }, [diff.file, diff.sha, diff.side]);
2093
+
2094
+ // 键盘移动 hunk:把当前 hunk 滚进视野(已经看得见就一点都不动)。
2095
+ React.useEffect(function () {
2096
+ var host = findMarked('[data-dsh-hud="diff"]');
2097
+ if (!host || typeof host.querySelector !== "function") return;
2098
+ revealRow(host.querySelector('[data-dsh-hud="hunk"][data-hunk-index="' + String(hunk) + '"]'), "dshh-diff");
2099
+ }, [hunk, diff.file, diff.sha, diff.side, collapsed.length]);
2100
+
2101
+ function onCopy() {
2102
+ if (!data) return;
2103
+ var clipboard = typeof navigator !== "undefined" ? navigator.clipboard : undefined;
2104
+ if (!clipboard || typeof clipboard.writeText !== "function") return;
2105
+ Promise.resolve(clipboard.writeText(rowsToText(data.rows || []))).then(function () {
2106
+ setCopied(true);
2107
+ if (timerRef[0]) window.clearTimeout(timerRef[0]);
2108
+ timerRef[1](window.setTimeout(function () { setCopied(false); }, 1500));
2109
+ }).catch(function () {
2110
+ // 剪贴板被拒绝(无权限/非安全上下文)时不谎报成功。
2111
+ });
2112
+ }
2113
+
2114
+ function tab(kind, label, title) {
2115
+ var active = side === kind;
2116
+ return h("button", {
2117
+ type: "button",
2118
+ className: "dshh-tab" + (active ? " dshh-tabActive" : ""),
2119
+ "aria-pressed": active ? "true" : "false",
2120
+ title: title,
2121
+ onClick: function () { store.setDiffSide(kind); },
2122
+ }, label);
2123
+ }
2124
+
2125
+ var counts = data && data.binary !== true
2126
+ ? h("span", { className: "dshh-difcounts" },
2127
+ h("span", { style: { color: "var(--dsw-alias-state-success-primary, #3fb950)" } }, "+" + String(data.additions)),
2128
+ h("span", { style: { color: "var(--dsw-alias-state-error-primary, #f06a6a)" } }, "-" + String(data.deletions)))
2129
+ : null;
2130
+
2131
+ var body;
2132
+ if (diff.phase === "loading") {
2133
+ body = h("div", { className: "dshh-empty" }, t("panel.loading"));
2134
+ } else if (diff.phase === "error") {
2135
+ body = h("div", { className: "dshh-empty" }, errorText(diff.errorCode, t));
2136
+ } else if (!data) {
2137
+ body = h("div", { className: "dshh-empty" }, t("diff.empty"));
2138
+ } else if (data.binary === true) {
2139
+ body = h("div", { className: "dshh-empty" }, t("diff.binary"));
2140
+ } else if (rows.length === 0) {
2141
+ body = h("div", { className: "dshh-empty" }, t("diff.empty"));
2142
+ } else {
2143
+ var visible = visibleDiffRows(rows, collapsed);
2144
+ body = h("div", { className: "dshh-diff", "data-dsh-hud": "diff" },
2145
+ visible.map(function (entry, index) {
2146
+ var isHunk = entry.row.kind === "hunk";
2147
+ return h(DiffRow, {
2148
+ key: String(index) + ":" + entry.row.kind,
2149
+ row: entry.row,
2150
+ wrap: diff.wrap === true,
2151
+ hunk: entry.hunk,
2152
+ active: isHunk && entry.hunk === hunk && collapsed.indexOf(entry.hunk) < 0,
2153
+ collapsed: isHunk && collapsed.indexOf(entry.hunk) >= 0,
2154
+ hint: isHunk ? t("diff.hunkToggle") : undefined,
2155
+ onToggle: isHunk
2156
+ ? function () { store.toggleHunk(entry.hunk); }
2157
+ : undefined,
2158
+ });
2159
+ }),
2160
+ data.truncated === true ? h("div", { className: "dshh-note" }, t("diff.truncated")) : null);
2161
+ }
2162
+
2163
+ return h("div", { className: "dshh-diffview" },
2164
+ h("div", { className: "dshh-toolbar" },
2165
+ h("span", { className: "dshh-difpath", title: diff.path }, diff.path),
2166
+ counts,
2167
+ h("span", { className: "dshh-spacer" }),
2168
+ // 提交差异只有一个"侧"(相对父提交),因此不给 未暂存/已暂存 页签
2169
+ // (短 sha 与标题在右栏的明细头里,这里不重复)。
2170
+ untracked ? h("span", { className: "dshh-kv" }, t("diff.untrackedHint")) : null,
2171
+ isCommit ? null : tab("worktree", t("diff.worktree"), t("diff.worktree")),
2172
+ isCommit || untracked ? null : tab("index", t("diff.index"), t("diff.index")),
2173
+ h("button", {
2174
+ type: "button",
2175
+ className: "dshh-tab" + (diff.wrap === true ? " dshh-tabActive" : ""),
2176
+ "aria-pressed": diff.wrap === true ? "true" : "false",
2177
+ title: t("diff.wrap"),
2178
+ onClick: function () { store.toggleDiffWrap(); },
2179
+ }, t("diff.wrap")),
2180
+ h("button", {
2181
+ type: "button",
2182
+ className: "dshh-tab",
2183
+ title: t("diff.copy"),
2184
+ onClick: onCopy,
2185
+ }, copied ? t("diff.copied") : t("diff.copy")),
2186
+ h("button", {
2187
+ type: "button",
2188
+ className: "dshh-tab",
2189
+ // 提交明细里这个 ✕ 收的是整块明细(差异是明细的一段),文案跟着改。
2190
+ title: isCommit ? t("commit.close") : t("diff.close"),
2191
+ "aria-label": isCommit ? t("commit.close") : t("diff.close"),
2192
+ onClick: function () { store.closeDetail(); },
2193
+ }, "✕")),
2194
+ body);
2195
+ }
2196
+
2197
+ /** porcelain-v2 的展示字母 → counts 键(用于配色)。 */
2198
+ function statusKey(letter) {
2199
+ if (letter === "M") return "modified";
2200
+ if (letter === "A") return "added";
2201
+ if (letter === "D") return "deleted";
2202
+ if (letter === "R") return "renamed";
2203
+ if (letter === "C") return "copied";
2204
+ if (letter === "T") return "typeChanged";
2205
+ if (letter === "?") return "untracked";
2206
+ if (letter === "!") return "conflicted";
2207
+ return "typeChanged";
2208
+ }
2209
+
2210
+ /** 宿主半的 `details.code` → 本座位的本地化文案(store 只存机器码)。 */
2211
+ function errorText(code, t) {
2212
+ if (code === "timeout") return t("err.timeout");
2213
+ if (code === "no-cwd") return t("err.no-cwd");
2214
+ if (code === "git-missing") return t("err.git-missing");
2215
+ if (code === "invalid-cwd") return t("err.invalid-cwd");
2216
+ if (code === "transport") return t("err.transport");
2217
+ if (code === "invalid-path") return t("err.invalid-path");
2218
+ // 提交可能在两次请求之间被 rebase / amend 换掉:给一句能操作的提示,而不是"git 执行失败"。
2219
+ if (code === "unknown-commit") return t("commit.unknown");
2220
+ // 宿主半是启动时加载的:只刷新页面时,新端点还不存在,这个提示能省下一轮排查。
2221
+ if (code === "unknown-endpoint") return t("err.stale-host");
2222
+ return t("err.git-failed");
2223
+ }
2224
+
2225
+ /** 分支名过长时中段省略,保留头尾信息量。 */
2226
+ function shortBranch(name) {
2227
+ if (name.length <= PILL_BRANCH_MAX) return name;
2228
+ var head = Math.ceil((PILL_BRANCH_MAX - 1) / 2);
2229
+ var tail = PILL_BRANCH_MAX - 1 - head;
2230
+ return name.slice(0, head) + "…" + name.slice(name.length - tail);
2231
+ }
2232
+
2233
+ /**
2234
+ * 改动计数 → 简写符列表 `[{ key, mark, value }]`(只列非零项,顺序固定)。
2235
+ *
2236
+ * 胶囊与侧栏行内 chip 共用这一份:两处各写一套算法一定会漂。
2237
+ */
2238
+ function countShorthands(counts) {
2239
+ var out = [];
2240
+ var source = counts || {};
2241
+ for (var index = 0; index < COUNT_ORDER.length; index += 1) {
2242
+ var key = COUNT_ORDER[index];
2243
+ var value = source[key] || 0;
2244
+ if (value <= 0) continue;
2245
+ out.push({ key: key, mark: COUNT_MARK[key], value: value });
2246
+ }
2247
+ return out;
2248
+ }
2249
+
2250
+ function branchLabel(status, t) {
2251
+ if (!status) return "—";
2252
+ if (status.branch) return status.branch;
2253
+ if (status.oid) return t("panel.detached") + " " + status.oid;
2254
+ return t("panel.detached");
2255
+ }
2256
+
2257
+ function timeLabel(at) {
2258
+ var date = new Date(at);
2259
+ var pad = function (value) {
2260
+ return value < 10 ? "0" + String(value) : String(value);
2261
+ };
2262
+ return pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds());
2263
+ }
2264
+
2265
+ /* --------------------------------------------------------- 胶囊(dock) */
2266
+
2267
+ /**
2268
+ * 会话作用域的座位:框架给出 `sessionId`,`useSessions` 快照给出该会话的 `cwd`,
2269
+ * 因此它是唯一能完成绑定的座位。
2270
+ */
2271
+ /**
2272
+ * 侧栏每条会话行上的状态 chip(座位 `sidebar.workspaces.session.row.action`)。
2273
+ *
2274
+ * 一行只有 32px 高、悬停才露出来,所以只放**最该一眼看到的两件事**:这个工作区脏不脏
2275
+ * (git 简写符)与**这个会话自己改过几个文件**(`●n`,只我们有这个信息)。干净且本会话
2276
+ * 没改过就整枚不画 —— 不占位置、也不制造噪音。完整信息在悬停里。
2277
+ */
2278
+ /**
2279
+ * 一枚侧栏状态的"事实摘要":三种呈现(常亮标记 / 悬停动条 / 卡片详情)共用这一份计算。
2280
+ *
2281
+ * 同一份数据要在三个地方以三种宽度呈现,各写一套一定会漂 —— 所以抽成一处。
2282
+ */
2283
+ function rowSummaryOf(row) {
2284
+ if (row === undefined || row === null || row.pending === true) return null;
2285
+ var mineCount = typeof row.mine === "number" ? row.mine : 0;
2286
+ var possibleCount = typeof row.possible === "number" ? row.possible : 0;
2287
+ var shorthands = row.repo === true ? countShorthands(row.counts) : [];
2288
+ var changed = 0;
2289
+ for (var index = 0; index < shorthands.length; index += 1) changed += shorthands[index].value;
2290
+ return {
2291
+ row: row,
2292
+ mine: mineCount,
2293
+ possible: possibleCount,
2294
+ shorthands: shorthands,
2295
+ changed: changed,
2296
+ dirty: row.repo === true && row.dirty === true,
2297
+ hasContent: changed > 0 || mineCount > 0 || possibleCount > 0 || row.error !== null,
2298
+ };
2299
+ }
2300
+
2301
+ /** 两位数以上收成 `+`(行首那格只容得下两个字符)。 */
2302
+ function compactCount(value) {
2303
+ return value >= 10 ? "+" : String(value);
2304
+ }
2305
+
2306
+ /**
2307
+ * `⋯` 菜单里的只读摘要(座位 `sidebar.workspaces.session.menu.item`)。
2308
+ *
2309
+ * 为什么要有这一处:会话行**行尾的常亮位置归产品**(`time` 自适应宽度 + 悬停才换上的动作条),
2310
+ * 插件在行尾拿不到常亮的座位;行首那格只有 16px,只能放一个双字符标记。所以要"完整信息 +
2311
+ * 不用等悬停卡片那 800ms",`⋯` 菜单是最实在的地方 —— 点一下就有,且不用把鼠标停住。
2312
+ *
2313
+ * 它是**只读**的:不做任何动作,所以做得像一条说明(弱化颜色、不响应 hover 高亮);
2314
+ * 工作区干净时也照实说"干净",而不是消失(那样用户会以为没有这个功能)。
2315
+ */
2316
+ function SessionMenuSummary(props) {
2317
+ var t = props.t || function (key) { return key; };
2318
+ var sessionId = props.sessionId;
2319
+ var store = props.store;
2320
+ var state = useStore(store);
2321
+ if (!sessionId || state.sidebarMode === "off") return null;
2322
+ var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2323
+ var summary = rowSummaryOf(row);
2324
+ if (summary === null) return null;
2325
+
2326
+ var head;
2327
+ if (summary.row.repo === true) {
2328
+ head = branchLabel(summary.row, t);
2329
+ if (summary.dirty === true) head += "*";
2330
+ if (summary.row.ahead > 0) head += " ↑" + String(summary.row.ahead);
2331
+ if (summary.row.behind > 0) head += " ↓" + String(summary.row.behind);
2332
+ var counts = summary.shorthands.map(function (item) {
2333
+ return t("count." + item.key) + " " + String(item.value);
2334
+ }).join(" ");
2335
+ head += counts === "" ? " · " + t("panel.clean") : " · " + counts;
2336
+ } else if (summary.row.error !== null) {
2337
+ head = t("rows.error", { code: String(summary.row.error) });
2338
+ } else {
2339
+ head = t("rows.noRepo");
2340
+ }
2341
+
2342
+ var own = [];
2343
+ if (summary.mine > 0) own.push(t("session.pill", { n: String(summary.mine) }));
2344
+ if (summary.possible > 0) own.push(t("rows.possible", { n: String(summary.possible) }));
2345
+ var ownLine = own.length === 0 ? t("rows.ownNone") : own.join(" · ");
2346
+
2347
+ return h("div", {
2348
+ className: "dshh-menureadout",
2349
+ "data-dsh-hud": "menu-readout",
2350
+ title: summary.row.cwd || undefined,
2351
+ }, [
2352
+ h("div", { key: "git", className: "dshh-menureadoutLine" }, head),
2353
+ h("div", { key: "own", className: "dshh-menureadoutLine" }, ownLine),
2354
+ ]);
2355
+ }
2356
+
2357
+ /**
2358
+ * 常亮那一枚(座位 `sidebar.session.row.leading`)。
2359
+ *
2360
+ * 产品只给 16px 宽(`.slot{width:16px;flex:none}`),所以这里只放**一个双字符标记**;
2361
+ * 优先级 = 价值顺序:先报"本会话改过几个"(只有我们有),没有就报"这个工作区脏不脏"。
2362
+ * 产品自己在那一格显示"会话状态点"时这个座位不会被渲染 —— 那时退回悬停档。
2363
+ */
2364
+ function SessionLeadingToken(props) {
2365
+ var t = props.t || function (key) { return key; };
2366
+ var sessionId = props.sessionId;
2367
+ var store = props.store;
2368
+ var state = useStore(store);
2369
+ if (!sessionId || state.sidebarMode !== "always") return null;
2370
+ var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2371
+ var summary = rowSummaryOf(row);
2372
+ if (summary === null || !summary.hasContent) return null;
2373
+ var token = null;
2374
+ if (summary.mine > 0) token = { text: "●" + compactCount(summary.mine), kind: "mine" };
2375
+ else if (summary.possible > 0) token = { text: "◐" + compactCount(summary.possible), kind: "possible" };
2376
+ else if (summary.dirty) token = { text: "!" + compactCount(summary.changed), kind: "dirty" };
2377
+ if (token === null) return null;
2378
+ probe.log("row-token", { sessionId: sessionId, text: token.text, kind: token.kind });
2379
+ return h("span", {
2380
+ className: "dshh-rowtoken dshh-rowtoken-" + token.kind,
2381
+ "data-dsh-hud": "row-token",
2382
+ title: rowChipTitle(summary.row, t),
2383
+ }, token.text);
2384
+ }
2385
+
2386
+ /**
2387
+ * 悬停动条上那枚(座位 `sidebar.workspaces.session.row.action`)。
2388
+ *
2389
+ * 动作条只有 32px 高、平时就放一两个图标按钮,所以只放"图标级"宽度:
2390
+ * `!` 脏 + 改动总数 + `●n`/`◐n` 本会话改动数;明细在悬停里。
2391
+ */
2392
+ function SessionRowChip(props) {
2393
+ var t = props.t || function (key) { return key; };
2394
+ var sessionId = props.sessionId;
2395
+ var store = props.store;
2396
+ var state = useStore(store);
2397
+ if (!sessionId || state.sidebarMode !== "hover") return null;
2398
+ var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2399
+ var summary = rowSummaryOf(row);
2400
+ if (summary === null || !summary.hasContent) return null;
2401
+
2402
+ var children = [];
2403
+ if (summary.row.repo === true) {
2404
+ var head = summary.dirty ? "!" : "";
2405
+ if (summary.changed > 0) head += String(summary.changed);
2406
+ if (head !== "") children.push(h("span", { key: "git" }, head));
2407
+ }
2408
+ if (summary.mine > 0) children.push(h("span", { key: "mine", className: "dshh-rowchipMine" }, "●" + String(summary.mine)));
2409
+ else if (summary.possible > 0) children.push(h("span", { key: "possible", className: "dshh-rowchipPossible" }, "◐" + String(summary.possible)));
2410
+
2411
+ if (children.length === 0) return null;
2412
+ probe.log("row-chip", {
2413
+ sessionId: sessionId,
2414
+ repo: summary.row.repo === true,
2415
+ dirty: summary.dirty,
2416
+ changed: summary.changed,
2417
+ mine: summary.mine,
2418
+ possible: summary.possible,
2419
+ });
2420
+ return h("span", {
2421
+ className: "dshh-rowchip",
2422
+ "data-dsh-hud": "row-chip",
2423
+ title: rowChipTitle(summary.row, t),
2424
+ }, children);
2425
+ }
2426
+
2427
+ /**
2428
+ * 侧栏会话行**悬停卡片**里的那一块(座位 `sidebar.session.row.hover`)。
2429
+ *
2430
+ * 动作条那一枚只有图标级宽度(`!4 ●3`),明细放不下;悬停卡是宽敞的地方 ——
2431
+ * 分支/上游、分类型计数、这个会话自己的改动,都在这里说清楚。
2432
+ * 没有任何可说的(干净、且本会话没改过)就整块不画,卡片保持原来的样子。
2433
+ */
2434
+ function SessionRowHoverChip(props) {
2435
+ var t = props.t || function (key) { return key; };
2436
+ var sessionId = props.sessionId;
2437
+ var store = props.store;
2438
+ var state = useStore(store);
2439
+ if (!sessionId || state.sidebarMode === "off") return null;
2440
+ var row = state.rows && state.rows.byId ? state.rows.byId[sessionId] : undefined;
2441
+ var summary = rowSummaryOf(row);
2442
+ if (summary === null || !summary.hasContent) return null;
2443
+ var mineCount = summary.mine;
2444
+ var possibleCount = summary.possible;
2445
+ var shorthands = summary.shorthands;
2446
+
2447
+ var lines = [];
2448
+ if (row.repo === true) {
2449
+ var head = branchLabel(row, t);
2450
+ if (row.dirty === true) head += "*";
2451
+ if (row.ahead > 0) head += " ↑" + String(row.ahead);
2452
+ if (row.behind > 0) head += " ↓" + String(row.behind);
2453
+ if (shorthands.length > 0) {
2454
+ head += " · " + shorthands.map(function (item) {
2455
+ return t("count." + item.key) + " " + String(item.value);
2456
+ }).join(" ");
2457
+ }
2458
+ lines.push(head);
2459
+ } else if (row.repo === false && row.error === null) {
2460
+ lines.push(t("rows.noRepo"));
2461
+ }
2462
+ if (row.error) lines.push(t("rows.error", { code: String(row.error) }));
2463
+ var own = [];
2464
+ if (mineCount > 0) own.push(t("session.pill", { n: String(mineCount) }));
2465
+ if (possibleCount > 0) own.push(t("rows.possible", { n: String(possibleCount) }));
2466
+ // 和第二行对齐:没有改动记录时也明说一句 —— 否则卡片上会缺一行,看着像没加载出来。
2467
+ lines.push(own.length === 0 ? t("rows.ownNone") : own.join(" · "));
2468
+ if (lines.length === 0) return null;
2469
+
2470
+ return h("div", {
2471
+ className: "dshh-rowhover",
2472
+ "data-dsh-hud": "row-hover",
2473
+ }, lines.map(function (line, index) {
2474
+ return h("div", { key: String(index), className: "dshh-rowhoverLine" }, line);
2475
+ }));
2476
+ }
2477
+
2478
+ /** 行内 chip 的悬停说明:分支、上游、各类计数、以及这个会话自己的改动。 */
2479
+ function rowChipTitle(row, t) {
2480
+ var lines = [];
2481
+ if (row.repo === true) {
2482
+ var head = branchLabel(row, t);
2483
+ if (row.dirty === true) head += "*";
2484
+ if (row.ahead > 0) head += " ↑" + String(row.ahead);
2485
+ if (row.behind > 0) head += " ↓" + String(row.behind);
2486
+ lines.push(head);
2487
+ var shorthands = countShorthands(row.counts);
2488
+ if (shorthands.length > 0) {
2489
+ lines.push(shorthands.map(function (item) {
2490
+ return t("count." + item.key) + " " + String(item.value);
2491
+ }).join(" · "));
2492
+ }
2493
+ if (row.cwd) lines.push(row.cwd);
2494
+ } else if (row.repo === false && row.error === null) {
2495
+ // 不在仓库里也要说清(不能只丢一个路径 —— 用户看不出"为什么这一行没有分支")。
2496
+ lines.push(t("rows.noRepo"));
2497
+ if (row.cwd) lines.push(row.cwd);
2498
+ }
2499
+ if (row.error) lines.push(t("rows.error", { code: String(row.error) }));
2500
+ var mineCount = typeof row.mine === "number" ? row.mine : 0;
2501
+ var possibleCount = typeof row.possible === "number" ? row.possible : 0;
2502
+ if (mineCount > 0) lines.push(t("session.pill", { n: String(mineCount) }));
2503
+ if (possibleCount > 0) lines.push(t("rows.possible", { n: String(possibleCount) }));
2504
+ return lines.join("\n");
2505
+ }
2506
+
2507
+ function HudDockPill(props) {
2508
+ var t = props.t || function (key) { return key; };
2509
+ var sessionId = props.sessionId;
2510
+ var useSessions = props.useSessions;
2511
+ var store = props.store;
2512
+
2513
+ // useSessions 是选择器 hook:选择器参数是必需的,裸调用会以
2514
+ // "selector is not a function" 崩掉。
2515
+ var sessions = useSessions(function (snapshot) { return snapshot; });
2516
+ var summary = sessionId !== undefined && sessions && sessions.byId ? sessions.byId[sessionId] : undefined;
2517
+ var cwd = summary ? summary.cwd : undefined;
2518
+
2519
+ // 右栏里的嵌入式会话(子会话 chat)自带会话绑定;只有主视图座位可以驱动共享
2520
+ // 面板,否则一个子会话会把它下面的仓库从用户正在看的会话下抽走。
2521
+ // 快照不带这个计数(更旧的宿主)时按主视图放行,胶囊不会因此消失。
2522
+ var mainView = !summary || !summary.retainedBy
2523
+ ? true
2524
+ : (summary.retainedBy.mainView || 0) > 0;
2525
+
2526
+ var state = useStore(store);
2527
+
2528
+ React.useEffect(function () {
2529
+ if (!mainView) return;
2530
+ store.bindSession(sessionId, cwd);
2531
+ }, [sessionId, cwd, mainView, store]);
2532
+
2533
+ if (!cwd || !mainView || state.sessionId !== sessionId) return null;
2534
+
2535
+ // 会话归因的胶囊段:只有"确实改过东西"时才露头(没数据 / 0 个都不显示)。
2536
+ var sessionView = sessionChangeView(state);
2537
+ var sessionSegment = sessionView !== null && sessionView.mineCount > 0
2538
+ ? " │ ●" + String(sessionView.mineCount)
2539
+ : null;
2540
+
2541
+ var children = [];
2542
+ if (state.phase === "error") {
2543
+ children.push("⎇ " + t("pill.error"));
2544
+ } else if (state.phase === "loading" || state.phase === "idle") {
2545
+ children.push("⎇ " + t("pill.loading"));
2546
+ } else if (!state.status || !state.status.repo) {
2547
+ children.push("⎇ " + t("pill.notRepo"));
2548
+ } else {
2549
+ var status = state.status;
2550
+ var head = "⎇ " + shortBranch(branchLabel(status, t)) + (status.dirty ? "*" : "");
2551
+ if (status.ahead > 0) head += " ↑" + String(status.ahead);
2552
+ if (status.behind > 0) head += " ↓" + String(status.behind);
2553
+ children.push(head);
2554
+
2555
+ // 简写符:只列非零项,按固定顺序,各自上色(M 黄 / A 绿 / D 红 / ? 灰 …)。
2556
+ // 与侧栏行内 chip 共用 `countShorthands` —— 两处各写一套算法一定会漂。
2557
+ var shorthands = countShorthands(status.counts);
2558
+ for (var index = 0; index < shorthands.length; index += 1) {
2559
+ children.push(" ");
2560
+ children.push(h("span", {
2561
+ key: shorthands[index].key,
2562
+ style: { color: COUNT_COLOR[shorthands[index].key] },
2563
+ }, shorthands[index].mark + String(shorthands[index].value)));
2564
+ }
2565
+ }
2566
+
2567
+ if (sessionSegment !== null) children.push(sessionSegment);
2568
+
2569
+ // 悬停给出完整信息:总数型的托盘里放不下细节,这里补齐。
2570
+ var titleLines = [t("pill.title")];
2571
+ if (sessionView !== null) titleLines.push(t("session.pill", { n: String(sessionView.mineCount) }));
2572
+ if (cwd) titleLines.push(cwd);
2573
+ if (state.status && state.status.repo) {
2574
+ var tip = branchLabel(state.status, t);
2575
+ if (state.status.upstream) tip += " · " + t("panel.upstream") + " " + state.status.upstream;
2576
+ titleLines.push(tip);
2577
+ var labels = [];
2578
+ var counts2 = state.status.counts || {};
2579
+ for (var i2 = 0; i2 < COUNT_ORDER.length; i2 += 1) {
2580
+ var k2 = COUNT_ORDER[i2];
2581
+ var v2 = counts2[k2] || 0;
2582
+ if (v2 > 0) labels.push(t("count." + k2) + " " + String(v2));
2583
+ }
2584
+ if (labels.length > 0) titleLines.push(labels.join(" · "));
2585
+ }
2586
+
2587
+ // 外层容器复刻框架自己的 dock 行几何(见 queue 公告):仅靠胶囊自身对齐,
2588
+ // 胶囊会飘到整栏的最左边,而输入卡片是居中的。
2589
+ return h("div", { className: "dshh-dock", "data-dsh-hud": "dock" },
2590
+ h("button", {
2591
+ type: "button",
2592
+ className: "dshh-pill",
2593
+ "data-dsh-hud": "pill",
2594
+ title: titleLines.join("\n"),
2595
+ "aria-expanded": state.panelOpen ? "true" : "false",
2596
+ style: { color: state.panelOpen ? "var(--dsw-alias-brand-primary, #4b8bf5)" : undefined },
2597
+ onClick: function () {
2598
+ store.togglePanel();
2599
+ },
2600
+ }, children));
2601
+ }
2602
+
2603
+ /* ------------------------------------------------------- 悬浮面板 */
2604
+
2605
+ /**
2606
+ * 当前右栏里有哪几栏 —— 样式、夹位置、渲染三处必须用同一份判断,抽出来免得漂移。
2607
+ *
2608
+ * @returns `{ diff, commit }`:`diff` = 有工作区/提交差异,`commit` = 打开着提交明细。
2609
+ */
2610
+ function panelLayout(state) {
2611
+ return {
2612
+ diff: state.diff !== null && typeof state.diff === "object",
2613
+ commit: state.commit !== null && typeof state.commit === "object",
2614
+ };
2615
+ }
2616
+
2617
+ /**
2618
+ * 面板宽度:单栏紧凑;打开右栏就按"左栏 + [提交明细栏] + 抓握 + 差异栏"算。
2619
+ *
2620
+ * 右栏(差异)宽度由面板右边缘的抓握拖动调整并记忆;中间那栏是固定宽度。
2621
+ * 视口不够宽时只把弹性的一栏(差异)压小——左栏与提交明细栏是有下限的固定栏。
2622
+ *
2623
+ * @param layout - `panelLayout()` 的结果。
2624
+ * @param diffWidth - 差异栏的目标宽度。
2625
+ */
2626
+ function panelWidthFor(layout, diffWidth) {
2627
+ var middle = layout.commit === true ? COMMIT_LIST_WIDTH : 0;
2628
+ if (layout.diff !== true && layout.commit !== true) return PANEL_WIDTH;
2629
+ var right = layout.diff === true ? RESIZE_GRIP_WIDTH + (typeof diffWidth === "number" ? diffWidth : DIFF_DEFAULT_WIDTH) : 0;
2630
+ var available = window.innerWidth - 2 * VIEWPORT_MARGIN;
2631
+ var min = PANEL_WIDTH + middle + (layout.diff === true ? RESIZE_GRIP_WIDTH + DIFF_MIN_WIDTH : 0);
2632
+ return Math.max(Math.min(min, available), Math.min(PANEL_WIDTH + middle + right, available));
2633
+ }
2634
+
2635
+ /** 把位置夹回视口内,避免拖到屏幕外再也点不到。 */
2636
+ function clampPos(left, top, width) {
2637
+ var panelWidth = typeof width === "number" ? width : PANEL_WIDTH;
2638
+ var maxLeft = Math.max(VIEWPORT_MARGIN, window.innerWidth - panelWidth - VIEWPORT_MARGIN);
2639
+ var maxTop = Math.max(VIEWPORT_MARGIN, window.innerHeight - 48);
2640
+ return {
2641
+ left: Math.min(Math.max(VIEWPORT_MARGIN, left), maxLeft),
2642
+ top: Math.min(Math.max(VIEWPORT_MARGIN, top), maxTop),
2643
+ };
2644
+ }
2645
+
2646
+ /**
2647
+ * 量出胶囊所在 dock 行的视口顶部坐标。
2648
+ *
2649
+ * 这里用**自己的** `data-dsh-hud` 标记,不碰产品的 DOM 选择器。dsh-git 把
2650
+ * "输入框堆叠高 168px"写成了常量并注明那只是一次测量;直接量真实位置可以同时
2651
+ * 适配 hero(空白会话,输入框居中)与 active 两种布局。
2652
+ *
2653
+ * @returns 视口坐标的顶部像素,量不到时返回 null。
2654
+ */
2655
+ function measureDockTop() {
2656
+ if (typeof document === "undefined" || typeof document.querySelector !== "function") return null;
2657
+ var node = document.querySelector('[data-dsh-hud="dock"]');
2658
+ if (!node || typeof node.getBoundingClientRect !== "function") return null;
2659
+ var rect = node.getBoundingClientRect();
2660
+ return typeof rect.top === "number" ? rect.top : null;
2661
+ }
2662
+
2663
+ /**
2664
+ * 根作用域的座位:读不到当前会话,只读共享 store。
2665
+ * 收起时渲染 `null` —— 这样不会在输入框上留一个挡点击的气泡。
2666
+ */
2667
+ function HudFloatingPanel(props) {
2668
+ var t = props.t || function (key) { return key; };
2669
+ var store = props.store;
2670
+ var state = useStore(store);
2671
+
2672
+ var dragRef = React.useState(null);
2673
+ var drag = dragRef[0];
2674
+ var setDrag = dragRef[1];
2675
+
2676
+ var gripRef = React.useState(null);
2677
+ /**
2678
+ * "面板已激活"标记:在面板里按过指针(或 Tab 进去)才允许接管裸按键。
2679
+ *
2680
+ * 用可变对象而不是 React state:事件处理里读的必须是最新值,而重建监听
2681
+ * 只依赖 `panelOpen`,改这个标记不该触发重渲染。
2682
+ * 必须和其它 hook 一起放在下面那个 `!panelOpen → null` 早退之前。
2683
+ */
2684
+ var armedState = React.useState(function () { return { inside: false } });
2685
+ var armed = armedState[0];
2686
+ var grip = gripRef[0];
2687
+ var setGrip = gripRef[1];
2688
+
2689
+ // 从 store 现读,别用可能过期的渲染闭包(模式切换会改宽度)。
2690
+ function currentPanelWidth() {
2691
+ var snapshot = store.getSnapshot();
2692
+ return panelWidthFor(panelLayout(snapshot), snapshot.diffWidth);
2693
+ }
2694
+
2695
+ // Esc 收起面板。用捕获阶段监听:焦点可能在任何输入框里,捕获阶段先拿到。
2696
+ React.useEffect(function () {
2697
+ if (!state.panelOpen) return undefined;
2698
+ var onKeyDown = function (event) {
2699
+ // Esc 任何时候都收(和以前一样:焦点在输入框里也能收到)。
2700
+ if (event.key === "Escape") {
2701
+ // 和 dsh-git 一样"退一层":右栏(差异 / 提交明细)→ 收起面板。
2702
+ var current = store.getSnapshot();
2703
+ if (current.diff || current.commit) store.closeDetail();
2704
+ else store.openPanel(false);
2705
+ return;
2706
+ }
2707
+
2708
+ // 其余按键只在面板被激活时接管:先在面板里点一下(或 Tab 进去)。
2709
+ // 本插件是挂在输入框上方的浮层,抢键的代价(打字、翻菜单、滚会话
2710
+ // 全被吃掉)远大于多几个快捷键,所以宁可要一次"点进来"。
2711
+ if (!armed.inside && !isInsidePanel(activeElement())) return;
2712
+ // 正在输入时一律不管。
2713
+ if (isTypingTarget(event.target)) return;
2714
+ // 带修饰键的组合交给产品与浏览器。
2715
+ if (event.metaKey || event.ctrlKey || event.altKey) return;
2716
+
2717
+ var handled = true;
2718
+ if (event.key === "ArrowDown") store.stepFile(1, false);
2719
+ else if (event.key === "ArrowUp") store.stepFile(-1, false);
2720
+ else if (event.key === "]") store.stepFile(1, true);
2721
+ else if (event.key === "[") store.stepFile(-1, true);
2722
+ else if (event.key === "Enter") store.openCursor();
2723
+ else if (event.key === "n") store.stepHunk(1);
2724
+ else if (event.key === "p") store.stepHunk(-1);
2725
+ else if (event.key === " " || event.code === "Space") store.toggleHunk(store.getSnapshot().diff ? store.getSnapshot().diff.hunk : 0);
2726
+ else if (event.key === "r") store.refresh();
2727
+ // `v`:把当前聚焦的那一行标成"这一版我审过了"(再按一次取消)。
2728
+ // 用键盘而不是给行尾那个标记加 tabindex:行本身是按钮,里面再塞一个按钮是无效结构。
2729
+ else if (event.key === "v") {
2730
+ var snapshot = store.getSnapshot();
2731
+ var rows = visibleChangeRows(snapshot);
2732
+ var target = snapshot.cursor !== null && snapshot.cursor !== undefined ? snapshot.cursor : null;
2733
+ if (target === null) target = rows[0] ? rows[0].file : null;
2734
+ if (target !== null) store.toggleReviewed(target);
2735
+ }
2736
+ else handled = false;
2737
+
2738
+ if (handled) {
2739
+ // 认领了的键就别再让浏览器滚动页面,也别让产品再处理一遍。
2740
+ event.preventDefault();
2741
+ event.stopPropagation();
2742
+ }
2743
+ };
2744
+
2745
+ var onPointerDown = function (event) {
2746
+ armed.inside = isInsidePanel(event.target);
2747
+ };
2748
+
2749
+ window.addEventListener("keydown", onKeyDown, true);
2750
+ window.addEventListener("pointerdown", onPointerDown, true);
2751
+ return function () {
2752
+ window.removeEventListener("keydown", onKeyDown, true);
2753
+ window.removeEventListener("pointerdown", onPointerDown, true);
2754
+ };
2755
+ }, [state.panelOpen, store]);
2756
+
2757
+ // 视口变化后把已记忆的位置夹回来。
2758
+ React.useEffect(function () {
2759
+ if (!state.pos) return undefined;
2760
+ var onResize = function () {
2761
+ var next = clampPos(state.pos.left, state.pos.top, currentPanelWidth());
2762
+ if (next.left !== state.pos.left || next.top !== state.pos.top) store.movePanel(next);
2763
+ };
2764
+ window.addEventListener("resize", onResize);
2765
+ return function () {
2766
+ window.removeEventListener("resize", onResize);
2767
+ };
2768
+ }, [state.pos, store]);
2769
+
2770
+ // 量 dock 行位置:面板据此贴到胶囊上方。会话/状态变化(含每轮轮询)都会重量一次,
2771
+ // 因此首条消息让 hero 变成 active、输入框换位置时,面板会跟着走。
2772
+ React.useEffect(function () {
2773
+ if (!state.panelOpen) return undefined;
2774
+ var measure = function () {
2775
+ store.setAnchorTop(measureDockTop());
2776
+ };
2777
+ measure();
2778
+ window.addEventListener("resize", measure);
2779
+ return function () {
2780
+ window.removeEventListener("resize", measure);
2781
+ };
2782
+ }, [state.panelOpen, state.sessionId, state.status, store]);
2783
+
2784
+ /**
2785
+ * 拖动:只在头部按下、并且**不是**按在按钮上时开始;移动走 window 监听,
2786
+ * 刻意**不用** `setPointerCapture` —— 一旦在头部捕获指针,`pointerup` 会被
2787
+ * 重定向到头部,`click` 便落在按下与松开的公共祖先(头部)上,按钮的
2788
+ * `onClick` 永远不触发,面板就「关不掉」了。
2789
+ */
2790
+ React.useEffect(function () {
2791
+ if (!drag) return undefined;
2792
+ var moved = false;
2793
+ var onMove = function (event) {
2794
+ if (event.pointerId !== drag.pointerId) return;
2795
+ if (!moved
2796
+ && Math.abs(event.clientX - drag.startX) < DRAG_THRESHOLD_PX
2797
+ && Math.abs(event.clientY - drag.startY) < DRAG_THRESHOLD_PX) return;
2798
+ moved = true;
2799
+ store.movePanel(clampPos(event.clientX - drag.offsetX, event.clientY - drag.offsetY, currentPanelWidth()));
2800
+ };
2801
+ var finish = function (event) {
2802
+ if (event.pointerId !== drag.pointerId) return;
2803
+ setDrag(null);
2804
+ // 只有真的拖动过才落盘:头部上的普通点击不该改变位置。
2805
+ if (moved) store.commitPanel(store.getSnapshot().pos);
2806
+ };
2807
+ window.addEventListener("pointermove", onMove);
2808
+ window.addEventListener("pointerup", finish);
2809
+ window.addEventListener("pointercancel", finish);
2810
+ return function () {
2811
+ window.removeEventListener("pointermove", onMove);
2812
+ window.removeEventListener("pointerup", finish);
2813
+ window.removeEventListener("pointercancel", finish);
2814
+ };
2815
+ }, [drag, store]);
2816
+
2817
+ /**
2818
+ * 拖动面板**右边缘**调整差异栏宽度(与 dsh-git 的抓握位置一致):
2819
+ * 往左拖变宽、往右拖变窄;松手才落盘。
2820
+ */
2821
+ React.useEffect(function () {
2822
+ if (!grip) return undefined;
2823
+ var onMove = function (event) {
2824
+ if (event.pointerId !== grip.pointerId) return;
2825
+ store.setDiffWidth(grip.startWidth - (event.clientX - grip.startX));
2826
+ };
2827
+ var finish = function (event) {
2828
+ if (event.pointerId !== grip.pointerId) return;
2829
+ setGrip(null);
2830
+ store.persistLayout();
2831
+ };
2832
+ window.addEventListener("pointermove", onMove);
2833
+ window.addEventListener("pointerup", finish);
2834
+ window.addEventListener("pointercancel", finish);
2835
+ return function () {
2836
+ window.removeEventListener("pointermove", onMove);
2837
+ window.removeEventListener("pointerup", finish);
2838
+ window.removeEventListener("pointercancel", finish);
2839
+ };
2840
+ }, [grip, store]);
2841
+
2842
+ if (!state.panelOpen) return null;
2843
+
2844
+ function onGripPointerDown(event) {
2845
+ if (event.button !== 0) return;
2846
+ var snapshot = store.getSnapshot();
2847
+ event.preventDefault();
2848
+ setGrip({
2849
+ pointerId: event.pointerId,
2850
+ startX: event.clientX,
2851
+ startWidth: typeof snapshot.diffWidth === "number" ? snapshot.diffWidth : DIFF_DEFAULT_WIDTH,
2852
+ });
2853
+ }
2854
+
2855
+ function onHeaderPointerDown(event) {
2856
+ if (event.button !== 0) return;
2857
+ // 头部里的按钮要保持普通点击,否则拖动会吞掉它们。
2858
+ if (event.target && typeof event.target.closest === "function" && event.target.closest("button")) return;
2859
+ var rect = event.currentTarget.parentNode.getBoundingClientRect();
2860
+ setDrag({
2861
+ pointerId: event.pointerId,
2862
+ startX: event.clientX,
2863
+ startY: event.clientY,
2864
+ offsetX: event.clientX - rect.left,
2865
+ offsetY: event.clientY - rect.top,
2866
+ });
2867
+ }
2868
+
2869
+ function onHeaderDoubleClick() {
2870
+ store.resetPanel();
2871
+ }
2872
+
2873
+ var layout = panelLayout(state);
2874
+ var diffMode = layout.diff;
2875
+ // 提交明细在场时右栏也在场(明细是中间那一栏,差异是右边那一栏),
2876
+ // 而当时 `state.diff` 可能还在路上 —— 因此两种情形都算"宽面板"。
2877
+ var commitMode = layout.commit;
2878
+ var wide = diffMode || commitMode;
2879
+ var dockTop = typeof state.anchorTop === "number" ? state.anchorTop : null;
2880
+ // 贴到胶囊所在行的上方;量不到就退回 dsh-git 记录的那次测量(168px)。
2881
+ var anchorBottom = dockTop === null
2882
+ ? COMPOSER_OFFSET
2883
+ : Math.max(VIEWPORT_MARGIN, window.innerHeight - dockTop + PANEL_DOCK_GAP);
2884
+
2885
+ var panelStyle = {
2886
+ position: "fixed",
2887
+ zIndex: 30,
2888
+ boxSizing: "border-box",
2889
+ width: panelWidthFor(layout, state.diffWidth),
2890
+ maxWidth: "calc(100vw - 16px)",
2891
+ display: "flex",
2892
+ flexDirection: "column",
2893
+ border: "1px solid var(--dsw-alias-border-l1, rgba(127,127,127,0.3))",
2894
+ borderRadius: 12,
2895
+ // 表面用"抬升面"令牌:DSH 自己的浮层几乎都用 bg-layer-1,而 bg-overlay
2896
+ // 在暗色下是中灰 bluish-700,拿它当面板底色会发灰、与产品观感不一致。
2897
+ background: "var(--dsw-alias-bg-layer-1, #1f2126)",
2898
+ boxShadow: "var(--dsw-shadow-lv2, 0 4px 12px rgba(0,0,0,0.18), 0 2px 8px rgba(0,0,0,0.12))",
2899
+ overflow: "hidden",
2900
+ pointerEvents: "auto",
2901
+ };
2902
+
2903
+ if (wide) {
2904
+ // 固定高度:面板以 bottom 锚定,若只给 max-height,差异到达时面板会长高并整体跳位。
2905
+ var spaceAbove = (dockTop === null ? window.innerHeight - anchorBottom : dockTop - PANEL_DOCK_GAP) - VIEWPORT_MARGIN;
2906
+ panelStyle.height = Math.max(240, Math.min(Math.round(window.innerHeight * DIFF_PANEL_HEIGHT_VH), spaceAbove));
2907
+ } else {
2908
+ panelStyle.maxHeight = "62vh";
2909
+ }
2910
+
2911
+ // 未拖动过:锚在胶囊上方居中;拖动过:显式坐标。
2912
+ if (state.pos) {
2913
+ panelStyle.left = state.pos.left;
2914
+ panelStyle.top = state.pos.top;
2915
+ } else {
2916
+ panelStyle.left = "50%";
2917
+ panelStyle.bottom = anchorBottom;
2918
+ panelStyle.transform = "translateX(-50%)";
2919
+ }
2920
+
2921
+ // 单栏内容("面板本来就长这样"):两种模式共用同一份,
2922
+ // 打开差异时它原封不动地留在左栏,只有右栏是新出现的。
2923
+ // "正在看的那份差异"与"键盘选中的那行"是两件事:前者是打开的文件,
2924
+ // 后者可能只是选过还没打开(↑/↓ 移动,Enter 才打开)。两个标记都留着。
2925
+ // 左栏那份清单只关心工作区差异:提交明细打开时它不该跟着高亮。
2926
+ var worktreeOpen = diffMode && state.diff.source !== "commit";
2927
+ var openFile = worktreeOpen ? state.diff.file : null;
2928
+ var commitOpenFile = diffMode && state.diff.source === "commit" ? state.diff.file : null;
2929
+
2930
+ // 会话归因视图与过滤后的清单:渲染期只算一次(标记、过滤、概览那行共用)。
2931
+ var sessionView = sessionChangeView(state);
2932
+ var sessionRows = visibleChangeRows(state);
2933
+
2934
+ var emptyBody;
2935
+ if (state.phase === "error") {
2936
+ emptyBody = h("div", { className: "dshh-empty" }, errorText(state.errorCode, t));
2937
+ } else if (!state.cwd) {
2938
+ emptyBody = h("div", { className: "dshh-empty" }, t("panel.noSession"));
2939
+ } else if (state.phase === "loading" && !state.status) {
2940
+ emptyBody = h("div", { className: "dshh-empty" }, t("panel.loading"));
2941
+ } else {
2942
+ emptyBody = h("div", { className: "dshh-empty" }, t("panel.notRepo"));
2943
+ }
2944
+
2945
+ var listBody;
2946
+ if (state.status && state.status.repo) {
2947
+ var status = state.status;
2948
+ var repoChildren = [
2949
+ h("div", { className: "dshh-meta" },
2950
+ h("span", { className: "dshh-repo", title: status.repo }, status.repo),
2951
+ status.upstream
2952
+ ? h("span", { className: "dshh-kv" }, t("panel.upstream") + " " + status.upstream)
2953
+ : null,
2954
+ status.ahead > 0 ? h("span", { className: "dshh-kv" }, "↑ " + t("panel.ahead") + String(status.ahead)) : null,
2955
+ status.behind > 0 ? h("span", { className: "dshh-kv" }, "↓ " + t("panel.behind") + String(status.behind)) : null,
2956
+ status.oid ? h("span", { className: "dshh-kv" }, status.oid) : null),
2957
+ h("div", { className: "dshh-sectionTitle" }, t("panel.summary")),
2958
+ h(CountChips, { counts: status.counts, t: t }),
2959
+ h(SessionChangesLine, {
2960
+ t: t,
2961
+ view: sessionView,
2962
+ onlySession: state.onlySession === true,
2963
+ onToggle: function () { store.toggleOnlySession(); },
2964
+ onlyLatest: state.onlyLatest === true,
2965
+ onToggleLatest: function () { store.toggleOnlyLatest(); },
2966
+ review: reviewView(state),
2967
+ onClearReviewed: function () { store.clearReviewed(); },
2968
+ sidebarMode: state.sidebarMode,
2969
+ onCycleSidebarMode: function () { store.cycleSidebarMode(); },
2970
+ }),
2971
+ // 顺序:改动概览 → **改动清单** → 最近提交。清单是"现在要处理的",
2972
+ // 提交是背景信息;清单放前面,一屏不够时先看到的是眼前的事。
2973
+ sessionRows.length > 0
2974
+ ? h(ChangeList, {
2975
+ changes: sessionRows,
2976
+ truncated: status.truncated,
2977
+ t: t,
2978
+ onOpen: function (change) { store.openDiff(change); },
2979
+ activeFile: openFile,
2980
+ cursor: commitMode ? null : state.cursor,
2981
+ listId: "changes",
2982
+ sessionView: sessionView,
2983
+ review: reviewView(state),
2984
+ onToggleReviewed: function (file) { store.toggleReviewed(file); },
2985
+ })
2986
+ : state.status.changes.length > 0 && state.onlySession === true && sessionView !== null
2987
+ // 过滤器开着、本会话又什么都没改:说一句,而不是让清单整段消失。
2988
+ ? h("div", { className: "dshh-note" }, t("session.empty"))
2989
+ : null,
2990
+ h(CommitList, {
2991
+ commits: state.commits,
2992
+ t: t,
2993
+ activeSha: commitMode ? state.commit.sha : null,
2994
+ // 再点一次打开着的那条 = 收起明细(打开提交不改左栏,只高亮它)。
2995
+ onOpen: function (commit) { store.toggleCommit(commit); },
2996
+ }),
2997
+ ];
2998
+ listBody = h.apply(null, ["div", { className: "dshh-body" }].concat(repoChildren));
2999
+ } else {
3000
+ listBody = emptyBody;
3001
+ }
3002
+
3003
+ // 工作台:左栏**始终**是上面那份单栏内容(宽度也与单栏一致);打开提交明细时
3004
+ // 中间多一栏(这次提交的元信息 + 改动清单),差异固定在右边那一栏。
3005
+ // 左栏一个像素都不动,面板右边缘的抓握仍只调整差异栏——与 dsh-git 的 workbench 同形。
3006
+ var body = wide
3007
+ ? h("div", { className: "dshh-workbench" },
3008
+ h("div", { className: "dshh-leftpane", style: { width: PANEL_WIDTH } }, listBody),
3009
+ commitMode
3010
+ ? h(CommitPane, {
3011
+ t: t,
3012
+ commit: state.commit,
3013
+ onOpenFile: function (file) { store.openCommitDiff(file); },
3014
+ activeFile: commitOpenFile,
3015
+ cursor: state.cursor,
3016
+ width: COMMIT_LIST_WIDTH,
3017
+ })
3018
+ : null,
3019
+ diffMode ? h(DiffView, { t: t, diff: state.diff, store: store }) : null)
3020
+ : listBody;
3021
+
3022
+ return h("div", { className: "dshh-panel", "data-dsh-hud": "panel", style: panelStyle },
3023
+ h("div", {
3024
+ className: "dshh-header",
3025
+ title: t("panel.dragHint"),
3026
+ onPointerDown: onHeaderPointerDown,
3027
+ onDoubleClick: onHeaderDoubleClick,
3028
+ },
3029
+ h("span", { className: "dshh-title" }, t("panel.title")),
3030
+ state.status && state.status.repo
3031
+ ? h("span", { className: "dshh-branch" }, branchLabel(state.status, t))
3032
+ : null,
3033
+ h("span", { className: "dshh-spacer" }),
3034
+ h("button", {
3035
+ type: "button",
3036
+ className: "dshh-icon",
3037
+ title: t("panel.refresh"),
3038
+ "aria-label": t("panel.refresh"),
3039
+ onClick: function () {
3040
+ store.refresh();
3041
+ },
3042
+ }, "⟳"),
3043
+ h("button", {
3044
+ type: "button",
3045
+ className: "dshh-icon",
3046
+ title: t("panel.reset"),
3047
+ "aria-label": t("panel.reset"),
3048
+ onClick: function () {
3049
+ store.resetPanel();
3050
+ },
3051
+ }, "⌖"),
3052
+ h("button", {
3053
+ type: "button",
3054
+ className: "dshh-icon",
3055
+ title: t("panel.close"),
3056
+ "aria-label": t("panel.close"),
3057
+ onClick: function () {
3058
+ store.openPanel(false);
3059
+ },
3060
+ }, "✕")),
3061
+ body,
3062
+ diffMode
3063
+ ? h("div", {
3064
+ className: "dshh-grip",
3065
+ "data-dsh-hud": "grip",
3066
+ title: t("diff.resize"),
3067
+ onPointerDown: onGripPointerDown,
3068
+ })
3069
+ : null,
3070
+ h("div", { className: "dshh-footer" },
3071
+ h("span", null, t("panel.readonly")),
3072
+ h("span", null, state.updatedAt ? t("panel.updated") + timeLabel(state.updatedAt) : "")));
3073
+ }
3074
+
3075
+ /* ---------------------------------------------------------------- 样式 */
3076
+
3077
+ /**
3078
+ * 只覆盖本插件自己的类名,配色一律走主题 CSS 变量,亮/暗主题自动跟随。
3079
+ * 不碰 document.body、window 或产品的 DOM 选择器。
3080
+ */
3081
+ var CSS = [
3082
+ // dock 座位给的是整栏宽的一行,而输入卡片有自己的最大宽度并居中
3083
+ // (--dsh-composer-card-max-width)。外层容器复刻框架自带 dock 占位元素
3084
+ // (队列公告)的几何,胶囊才会真正贴在输入卡片上方左端,而不是飘到栏的最左。
3085
+ ".dshh-dock{box-sizing:border-box;display:flex;justify-content:flex-start;",
3086
+ "width:calc(100% - var(--dsh-composer-side-clearance,16px) - var(--dsh-composer-side-clearance,16px)",
3087
+ " - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));",
3088
+ "max-width:calc(var(--dsh-composer-card-max-width,952px) - var(--dsh-composer-dock-inset,8px) - var(--dsh-composer-dock-inset,8px));",
3089
+ "margin:0 auto;}",
3090
+ ".dshh-pill{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;line-height:1.6;",
3091
+ "width:fit-content;max-width:100%;",
3092
+ "display:inline-flex;align-items:center;gap:4px;",
3093
+ "padding:2px 8px;border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.25));border-radius:999px;",
3094
+ "background:var(--dsw-alias-bg-layer-1,transparent);color:var(--dsw-alias-label-secondary,#8a8f98);",
3095
+ "cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
3096
+ ".dshh-pill:hover{color:var(--dsw-alias-label-primary,#e5e7eb);border-color:var(--dsw-alias-border-l2,rgba(127,127,127,0.45));}",
3097
+ ".dshh-panel{color:var(--dsw-alias-label-primary,#e5e7eb);font-size:12.5px;line-height:1.6;}",
3098
+ ".dshh-header{display:flex;align-items:center;gap:8px;padding:6px 8px;flex:none;cursor:move;line-height:1.4;",
3099
+ "user-select:none;touch-action:none;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
3100
+ ".dshh-title{font-size:13px;font-weight:600;flex:none;}",
3101
+ ".dshh-branch{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;padding:1px 6px;border-radius:6px;",
3102
+ "background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));color:var(--dsw-alias-label-secondary,#8a8f98);",
3103
+ "max-width:170px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
3104
+ ".dshh-spacer{flex:1;}",
3105
+ // 固定盒模型:⟳/⌖/✕ 这类符号可能落到行高夸张的 fallback 字体上,
3106
+ // 不锁死宽高就会把标题栏整个撑高(面板上方出现一片空白)。
3107
+ ".dshh-icon{font:inherit;font-size:12px;line-height:1;width:22px;height:22px;padding:0;border-radius:6px;",
3108
+ "display:inline-flex;align-items:center;justify-content:center;flex:none;",
3109
+ "border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#8a8f98);cursor:pointer;}",
3110
+ ".dshh-icon:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));color:var(--dsw-alias-label-primary,#e5e7eb);}",
3111
+ ".dshh-body{padding:10px;display:flex;flex-direction:column;gap:8px;overflow:auto;}",
3112
+ ".dshh-meta{display:flex;flex-wrap:wrap;gap:4px 10px;align-items:baseline;}",
3113
+ // 不用 direction:rtl 做左省略号:它会把绝对路径开头的 “/” 重排到末尾。
3114
+ ".dshh-repo{font-family:ui-monospace,Consolas,monospace;font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);",
3115
+ "max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
3116
+ ".dshh-kv{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3117
+ ".dshh-sectionTitle{font-size:11px;letter-spacing:.04em;text-transform:uppercase;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3118
+ // 计数芯片之间要留出横向间隔:11.5px 字号的描边胶囊挨在一起时看着是贴住的。
3119
+ ".dshh-chips{display:flex;flex-wrap:wrap;gap:6px 12px;}",
3120
+ ".dshh-chip{display:inline-flex;align-items:center;font-size:11.5px;padding:1px 8px;border-radius:999px;border:1px solid currentColor;}",
3121
+ ".dshh-clean{font-size:11.5px;color:var(--dsw-alias-state-success-primary,#3fb950);}",
3122
+ // 会话归因:概览下面那行 + 「只看本会话改的」开关 + 清单行首的标记。
3123
+ ".dshh-session{display:flex;align-items:baseline;gap:6px 10px;flex-wrap:wrap;font-size:11px;",
3124
+ "color:var(--dsw-alias-label-secondary,#8a8f98);}",
3125
+ ".dshh-sessionText{min-width:0;}",
3126
+ ".dshh-sessionToggle{font:inherit;font-size:11px;padding:0 6px;border-radius:999px;white-space:nowrap;",
3127
+ "border:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.35));background:transparent;",
3128
+ "color:var(--dsw-alias-label-secondary,#8a8f98);cursor:pointer;}",
3129
+ ".dshh-sessionToggle:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.16));",
3130
+ "color:var(--dsw-alias-label-primary,#e5e7eb);}",
3131
+ ".dshh-sessionToggle:focus-visible{outline:1px solid var(--dsw-alias-brand-primary,#4b8bf5);outline-offset:1px;}",
3132
+ ".dshh-sessionToggleOn{border-color:var(--dsw-alias-brand-primary,#4b8bf5);",
3133
+ "color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3134
+ ".dshh-marker{flex:none;width:8px;text-align:center;font-size:9px;line-height:inherit;",
3135
+ "color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3136
+ ".dshh-markerPossible{color:var(--dsw-alias-state-warn-primary,#d9a63c);opacity:.75;}",
3137
+ // 行尾的审阅标记:`○` 未审 / `✓` 这一版已审 / `✓`(黄) 改动已变。
3138
+ // 侧栏会话行里那枚 chip:一行只有 32px 高、悬停才露出来,所以它必须很小。
3139
+ // 侧栏会话行里那枚 chip:一行只有 32px 高、悬停动作条很窄,所以它必须小且不许撑宽。
3140
+ ".dshh-rowchip{display:inline-flex;align-items:center;gap:2px;flex:none;max-width:72px;",
3141
+ "font-family:ui-monospace,Consolas,monospace;font-size:10.5px;line-height:1;",
3142
+ "white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3143
+ ".dshh-rowchipMine{color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3144
+ // 行首常亮那枚:产品给的格子只有 16px,所以最多两个字符、不许换行。
3145
+ ".dshh-rowtoken{font-family:ui-monospace,Consolas,monospace;font-size:10px;line-height:1;",
3146
+ "white-space:nowrap;flex:none;letter-spacing:-0.02em;}",
3147
+ // `⋯` 菜单里那条只读摘要:像一条说明,不像一个可点的菜单项。
3148
+ ".dshh-menureadout{display:flex;flex-direction:column;gap:2px;padding:4px 10px 6px;",
3149
+ "font-size:11px;line-height:1.5;color:var(--dsw-alias-label-tertiary,var(--dsw-alias-label-secondary,#8a8f98));",
3150
+ "cursor:default;user-select:text;max-width:260px;}",
3151
+ ".dshh-menureadoutLine{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
3152
+ ".dshh-rowtoken-mine{color:var(--dsw-alias-brand-primary,#4b8bf5);}",
3153
+ ".dshh-rowtoken-possible{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
3154
+ ".dshh-rowtoken-dirty{color:var(--dsw-alias-state-warn-primary,#d9a63c);opacity:.75;}",
3155
+ // 悬停卡片里那块详情:跟着卡片自己的字号,只做行距与弱化。
3156
+ ".dshh-rowhover{display:flex;flex-direction:column;gap:2px;font-size:11px;",
3157
+ "color:var(--dsw-alias-label-secondary,#8a8f98);min-width:0;}",
3158
+ ".dshh-rowhoverLine{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}",
3159
+ ".dshh-rowchipPossible{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
3160
+ ".dshh-review{flex:none;margin-left:auto;padding:0 2px;font-size:10.5px;cursor:pointer;",
3161
+ "color:var(--dsw-alias-label-dimmed,rgba(138,143,152,0.65));}",
3162
+ ".dshh-review:hover{color:var(--dsw-alias-label-primary,#e5e7eb);}",
3163
+ ".dshh-reviewOn{color:var(--dsw-alias-state-success-primary,#3fb950);}",
3164
+ ".dshh-reviewStale{color:var(--dsw-alias-state-warn-primary,#d9a63c);}",
3165
+ ".dshh-markerOther{color:var(--dsw-alias-label-dimmed,rgba(138,143,152,0.6));}",
3166
+ ".dshh-section{display:flex;flex-direction:column;gap:4px;}",
3167
+ ".dshh-list{display:flex;flex-direction:column;gap:1px;max-height:30vh;overflow:auto;",
3168
+ "border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.16));padding-top:4px;}",
3169
+ // 改动行是按钮(点击看差异),所以要把浏览器的按钮默认样式清掉。
3170
+ ".dshh-row{display:flex;align-items:baseline;gap:8px;padding:2px 3px;border-radius:4px;",
3171
+ "font:inherit;text-align:left;width:100%;box-sizing:border-box;color:inherit;background:transparent;",
3172
+ "border:1px solid transparent;cursor:pointer;}",
3173
+ ".dshh-row:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.16));}",
3174
+ ".dshh-row:focus-visible{outline:1px solid var(--dsw-alias-brand-primary,#4b8bf5);outline-offset:-1px;}",
3175
+ ".dshh-status{flex:none;width:10px;text-align:center;font-family:ui-monospace,Consolas,monospace;font-weight:600;}",
3176
+ ".dshh-path{font-family:ui-monospace,Consolas,monospace;font-size:11.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
3177
+ // 和改动清单一样:标题下面一条分隔线(最近提交是最后一段,用它把两段分开)。
3178
+ ".dshh-commits{display:flex;flex-direction:column;gap:1px;",
3179
+ "border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.16));padding-top:4px;}",
3180
+ // 提交行是按钮(点击看这次提交改了什么),因此要清掉浏览器默认的按钮样式。
3181
+ ".dshh-commit{display:flex;align-items:baseline;gap:8px;padding:1px 2px;border-radius:4px;font-size:11.5px;",
3182
+ "font:inherit;font-size:11.5px;text-align:left;width:100%;box-sizing:border-box;color:inherit;background:transparent;",
3183
+ "border:1px solid transparent;cursor:pointer;}",
3184
+ ".dshh-commit:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.16));}",
3185
+ ".dshh-commit:focus-visible{outline:1px solid var(--dsw-alias-brand-primary,#4b8bf5);outline-offset:-1px;}",
3186
+ // 打开着的那条提交(明细在右栏,左栏只高亮它)。
3187
+ ".dshh-commitActive{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.22));",
3188
+ "box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary,#4b8bf5);}",
3189
+ // 中间那一栏(提交明细,默认 240px):元信息头 + 这次提交的改动清单。
3190
+ // 与左栏同形:各自 border-right 分隔,内部自己滚。
3191
+ // 注意 flex 是 `0 1 auto` 而不是 `none`:窗口太窄时由它和差异栏一起被压缩,
3192
+ // 而不是把左栏挤出去(左栏是主面板,保持固定 380px)。
3193
+ ".dshh-commitpane{flex:0 1 auto;box-sizing:border-box;display:flex;flex-direction:column;",
3194
+ "min-width:0;min-height:0;overflow:hidden;border-right:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
3195
+ ".dshh-commitHead{flex:none;display:flex;flex-direction:column;gap:2px;padding:6px 8px;",
3196
+ "border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
3197
+ ".dshh-commitTitle{display:flex;align-items:baseline;gap:6px;min-width:0;}",
3198
+ ".dshh-commitSubject{font-size:12.5px;line-height:1.5;color:var(--dsw-alias-label-primary,#e5e7eb);",
3199
+ "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
3200
+ ".dshh-commitMeta{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);",
3201
+ "overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
3202
+ // 清单吃掉这一栏剩下的高度(标题跟着一起滚,栏本身不滚动)。
3203
+ ".dshh-commitFiles{flex:1 1 auto;min-height:0;overflow:auto;padding:6px 8px 8px;}",
3204
+ ".dshh-commitFiles .dshh-list{max-height:none;}",
3205
+ ".dshh-csha{flex:none;font-family:ui-monospace,Consolas,monospace;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3206
+ ".dshh-csubject{flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}",
3207
+ ".dshh-chead{flex:none;font-size:10px;line-height:1.4;padding:0 4px;border-radius:4px;",
3208
+ "color:var(--dsw-alias-brand-primary,#4b8bf5);border:1px solid currentColor;}",
3209
+ ".dshh-cwhen{flex:none;font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3210
+ ".dshh-note{font-size:11px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3211
+ ".dshh-empty{padding:14px 12px;font-size:12px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3212
+ ".dshh-footer{display:flex;justify-content:space-between;gap:8px;padding:6px 10px;flex:none;",
3213
+ "border-top:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));",
3214
+ "font-size:10.5px;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3215
+ // ---- 差异视图 ----
3216
+ ".dshh-workbench{display:flex;flex:1 1 auto;min-height:0;align-items:stretch;}",
3217
+ ".dshh-leftpane{flex:none;box-sizing:border-box;display:flex;flex-direction:column;min-height:0;overflow:hidden;",
3218
+ "border-right:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
3219
+ ".dshh-leftpane .dshh-list{max-height:none;}",
3220
+ ".dshh-rowActive{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.2));}",
3221
+ // 键盘选中的那行:选过还没打开时只它一个亮着,所以用描边而不是底色,
3222
+ // 与 .dshh-rowActive(正在看的那份差异)区分得开。
3223
+ ".dshh-rowCursor{outline:1px solid var(--dsw-alias-brand-primary,#4b8bf5);outline-offset:-1px;}",
3224
+ // hunk 头可点(折叠 / 展开),并显示"当前 hunk"——键盘 n/p 与 Space 作用在它身上。
3225
+ ".dshh-difrow-hunk{cursor:pointer;user-select:none;}",
3226
+ ".dshh-difrow-hunk:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.22));}",
3227
+ ".dshh-difrow-hunkActive{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.2));",
3228
+ "box-shadow:inset 2px 0 0 var(--dsw-alias-brand-primary,#4b8bf5);}",
3229
+ ".dshh-hunkMark{text-align:right;}",
3230
+ ".dshh-grip{position:absolute;top:0;right:0;bottom:0;width:6px;cursor:col-resize;z-index:5;}",
3231
+ ".dshh-grip:hover{background:var(--dsw-alias-border-l2,rgba(127,127,127,0.35));}",
3232
+ ".dshh-diffview{display:flex;flex-direction:column;flex:1 1 auto;min-width:0;min-height:0;}",
3233
+ ".dshh-toolbar{display:flex;align-items:center;gap:4px;padding:5px 8px;flex:none;overflow:hidden;",
3234
+ "border-bottom:1px solid var(--dsw-alias-border-l1,rgba(127,127,127,0.22));}",
3235
+ ".dshh-difpath{font-family:ui-monospace,Consolas,monospace;font-size:11px;",
3236
+ "color:var(--dsw-alias-label-secondary,#8a8f98);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:38%;}",
3237
+ ".dshh-difcounts{display:inline-flex;gap:6px;flex:none;font-family:ui-monospace,Consolas,monospace;font-size:11px;}",
3238
+ ".dshh-tab{font:inherit;font-size:11.5px;padding:2px 7px;border-radius:6px;flex:none;cursor:pointer;",
3239
+ "border:1px solid transparent;background:transparent;color:var(--dsw-alias-label-secondary,#8a8f98);}",
3240
+ ".dshh-tab:hover{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.16));color:var(--dsw-alias-label-primary,#e5e7eb);}",
3241
+ ".dshh-tabActive{color:var(--dsw-alias-label-primary,#e5e7eb);background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.18));}",
3242
+ ".dshh-diff{flex:1 1 auto;min-height:0;overflow:auto;padding:4px 0 8px;",
3243
+ "font-family:ui-monospace,Consolas,monospace;font-size:11.5px;line-height:1.5;}",
3244
+ ".dshh-difrow{display:flex;align-items:flex-start;white-space:pre;}",
3245
+ ".dshh-difrow-hunk{background:var(--dsw-alias-bg-layer-2,rgba(127,127,127,0.14));",
3246
+ "color:var(--dsw-alias-label-secondary,#8a8f98);}",
3247
+ ".dshh-difrow-meta{color:var(--dsw-alias-label-secondary,#8a8f98);opacity:.85;}",
3248
+ ".dshh-difno{flex:none;width:46px;text-align:right;padding-right:8px;user-select:none;",
3249
+ "color:var(--dsw-alias-label-secondary,#8a8f98);font-variant-numeric:tabular-nums;}",
3250
+ ".dshh-diftext{flex:1 1 auto;min-width:0;white-space:pre;overflow:hidden;text-overflow:ellipsis;}",
3251
+ ".dshh-diftext.dshh-wrap{white-space:pre-wrap;word-break:break-word;overflow:visible;text-overflow:clip;}",
3252
+ ].join("");
3253
+
3254
+ /* ---------------------------------------------------------------- 样式注入 */
3255
+
3256
+ /** 自有样式表的稳定 tag id:`data-plugin-css` 用它做幂等插入。 */
3257
+ var CSS_TAG_ID = "dsh-hud/styles.css";
3258
+
3259
+ /**
3260
+ * 幂等注入自有样式表,并**自己打上 `data-plugin`**。
3261
+ *
3262
+ * 框架的 client-modules 会在每次模块物化后 `claimStyles(id)`:把当时所有
3263
+ * **没有 `data-plugin`** 的 `<style>` 认领给刚物化的那个模块,然后在 HMR 的
3264
+ * replace / invalidate / prune 路径上 `removeOwnedStyles(id)` 按 `data-plugin`
3265
+ * 删掉。不打标的表会被别的模块领走,在那次 rebuild 里连同别人的表一起删掉 ——
3266
+ * 而我们的注入只在插件激活时跑一次,于是页面永久退回骨架(原生 `button`、
3267
+ * 没有间隙 / 省略号 / 居中几何)。
3268
+ *
3269
+ * 框架自己的包与同门的 dsh-git-graph 都按这个约定打标;tag 存在时复用同一节点
3270
+ * (HMR 会先把旧表删掉,再由新的 fiber 重新调用本函数)。
3271
+ *
3272
+ * @returns 该样式节点;没有 `document` 时返回 `null`。
3273
+ */
3274
+ function ensureStyles() {
3275
+ if (typeof document === "undefined") return null;
3276
+ var selector = 'style[data-plugin-css="' + CSS_TAG_ID + '"]';
3277
+ var tag = typeof document.querySelector === "function" ? document.querySelector(selector) : null;
3278
+ if (!tag) {
3279
+ tag = document.createElement("style");
3280
+ tag.setAttribute("data-plugin", "dsh-hud");
3281
+ tag.setAttribute("data-plugin-css", CSS_TAG_ID);
3282
+ tag.setAttribute("data-dsh-hud", "styles");
3283
+ document.head.appendChild(tag);
3284
+ }
3285
+ tag.textContent = CSS;
3286
+ return tag;
3287
+ }
3288
+
3289
+ /* ---------------------------------------------------------------- 插件 */
3290
+
3291
+ /**
3292
+ * 客户端插件主体。
3293
+ * @param ctx - 客户端根上下文(slots、connection、locale)。
3294
+ */
3295
+ function apply(ctx) {
3296
+ ctx.effect(function () {
3297
+ return ctx.locale.register(NS, { zh: zh, en: en });
3298
+ }, "dsh-hud: dictionaries");
3299
+
3300
+ ctx.effect(function () {
3301
+ var element = ensureStyles();
3302
+ return function () {
3303
+ if (element && element.parentNode) element.parentNode.removeChild(element);
3304
+ };
3305
+ }, "dsh-hud: styles");
3306
+
3307
+ var store = createStore(ctx);
3308
+
3309
+ // 起手留一行:诊断里**有没有这一行**就足以区分"客户端半压根没加载"与"加载了但
3310
+ // 哪一环没接上"(这一行由 apply 发,与后面的投影/名单无关)。
3311
+ probe.log("apply", {
3312
+ inject: typeof ctx.inject === "function",
3313
+ slots: typeof ctx.slots === "object",
3314
+ // 跑着的那一版自有样式表里,计数芯片的间隔是多少:改完样式没生效时用它分辨
3315
+ // "bundle 还是旧的"还是"规则没生效"。
3316
+ chips: cssChipsGap(CSS),
3317
+ });
3318
+
3319
+ // 面板与胶囊都要能反映磁盘上的实时变化;页面不可见时不打请求。
3320
+ // 顺手补一次样式表:框架的 HMR 记账会把插件的 `<style>` 删掉,删掉就自愈。
3321
+ ctx.effect(function () {
3322
+ var id = window.setInterval(function () {
3323
+ if (document.visibilityState === "hidden") return;
3324
+ ensureStyles();
3325
+ store.refresh();
3326
+ }, POLL_MS);
3327
+ return function () {
3328
+ window.clearInterval(id);
3329
+ };
3330
+ }, "dsh-hud: status poll");
3331
+
3332
+ // 从别处回到页面时立刻补一次(定时器在隐藏期间被跳过)。
3333
+ ctx.effect(function () {
3334
+ var onFocus = function () {
3335
+ ensureStyles();
3336
+ store.refresh();
3337
+ };
3338
+ window.addEventListener("focus", onFocus);
3339
+ return function () {
3340
+ window.removeEventListener("focus", onFocus);
3341
+ };
3342
+ }, "dsh-hud: focus refresh");
3343
+
3344
+ ctx.slots.inject("shell.overlay", function () {
3345
+ return ctx.slots.register({
3346
+ name: "shell.overlay",
3347
+ id: "dsh-hud-panel",
3348
+ order: 20,
3349
+ locale: NS,
3350
+ inject: function () {
3351
+ return { store: store };
3352
+ },
3353
+ }, HudFloatingPanel);
3354
+ });
3355
+
3356
+ // 侧栏行内 chip 的数据:起手取一次,之后按自己的节奏更新(页面隐藏时跳过)。
3357
+ ctx.effect(function () {
3358
+ store.refreshRows();
3359
+ var id = window.setInterval(function () {
3360
+ if (document.visibilityState === "hidden") return;
3361
+ store.refreshRows();
3362
+ }, ROWS_POLL_MS);
3363
+ return function () {
3364
+ window.clearInterval(id);
3365
+ };
3366
+ }, "dsh-hud: session rows poll");
3367
+
3368
+ // `⋯` 菜单里的一条只读摘要:行尾拿不到常亮座位,这里是最实在的"完整信息"落点。
3369
+ ctx.slots.inject("sidebar.workspaces.session.menu.item", function () {
3370
+ return ctx.slots.register({
3371
+ name: "sidebar.workspaces.session.menu.item",
3372
+ id: "dsh-hud-session-menu",
3373
+ order: 30,
3374
+ locale: NS,
3375
+ inject: function () {
3376
+ return { store: store };
3377
+ },
3378
+ }, SessionMenuSummary);
3379
+ });
3380
+
3381
+ // 行首那格(常亮档):产品只给 16px,所以是一枚双字符标记。
3382
+ ctx.slots.inject("sidebar.session.row.leading", function () {
3383
+ return ctx.slots.register({
3384
+ name: "sidebar.session.row.leading",
3385
+ id: "dsh-hud-session-leading",
3386
+ order: 30,
3387
+ locale: NS,
3388
+ inject: function () {
3389
+ return { store: store };
3390
+ },
3391
+ }, SessionLeadingToken);
3392
+ });
3393
+
3394
+ // 侧栏每条会话行悬停时的那组动作:产品自己的动作也只是普通条目,这里挂一枚只读状态。
3395
+ ctx.slots.inject("sidebar.workspaces.session.row.action", function () {
3396
+ return ctx.slots.register({
3397
+ name: "sidebar.workspaces.session.row.action",
3398
+ id: "dsh-hud-session-status",
3399
+ order: 30,
3400
+ locale: NS,
3401
+ inject: function () {
3402
+ return { store: store };
3403
+ },
3404
+ }, SessionRowChip);
3405
+ });
3406
+
3407
+ // 悬停卡片里那块详情(宽敞的地方;动作条那枚放不下的东西都在这儿)。
3408
+ ctx.slots.inject("sidebar.session.row.hover", function () {
3409
+ return ctx.slots.register({
3410
+ name: "sidebar.session.row.hover",
3411
+ id: "dsh-hud-session-hover",
3412
+ order: 30,
3413
+ locale: NS,
3414
+ inject: function () {
3415
+ return { store: store };
3416
+ },
3417
+ }, SessionRowHoverChip);
3418
+ });
3419
+
3420
+ ctx.slots.inject("conversation.input.dock", function () {
3421
+ return ctx.slots.register({
3422
+ name: "conversation.input.dock",
3423
+ id: "dsh-hud-pill",
3424
+ order: 20,
3425
+ locale: NS,
3426
+ inject: function (sessionId) {
3427
+ return { store: store, sessionId: sessionId };
3428
+ },
3429
+ }, HudDockPill);
3430
+ });
3431
+ }
3432
+
3433
+ exports.apply = apply;
3434
+ exports.inject = inject;
3435
+ return module.exports;
3436
+ },
3437
+ });