@cowwo/dsh-icon-custom 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.zh.md +22 -0
- package/client/client.js +1178 -13
- package/lib/index.js +71 -0
- package/lib/typert.host.js +92 -8
- package/lib/unread.js +131 -0
- package/package.json +1 -1
package/client/client.js
CHANGED
|
@@ -33,7 +33,36 @@ window.__ModuleLoader__.load({
|
|
|
33
33
|
logoHint: "把网页左上角品牌 logo 里的鲸鱼图标换成这张(不影响 deepseek HARNESS 文字)。",
|
|
34
34
|
successLogo: "页面 Logo 已更新",
|
|
35
35
|
fail: "操作失败",
|
|
36
|
-
noFile: "请先选择图标文件"
|
|
36
|
+
noFile: "请先选择图标文件",
|
|
37
|
+
badgeTest: "未读红点",
|
|
38
|
+
badgeSourceLabel: "数据来源:",
|
|
39
|
+
badgeSourceReal: "真实未读",
|
|
40
|
+
badgeSourceManual: "手动测试",
|
|
41
|
+
badgeTestUnit: "0 = 不显示",
|
|
42
|
+
badgeSizeLabel: "红点大小:",
|
|
43
|
+
badgeSizeSmall: "小",
|
|
44
|
+
badgeSizeMedium: "中",
|
|
45
|
+
badgeSizeLarge: "大",
|
|
46
|
+
unreadReasonsLabel: "哪些情况算未读(勾选即生效):",
|
|
47
|
+
reasonCompleted: "干完一轮",
|
|
48
|
+
reasonError: "报错",
|
|
49
|
+
reasonBlocked: "被卡住",
|
|
50
|
+
reasonMaxTokens: "输出超限",
|
|
51
|
+
reasonInterrupted: "崩溃中断",
|
|
52
|
+
reasonAbortedUser: "我自己停止",
|
|
53
|
+
reasonAbortedOther: "其他中断",
|
|
54
|
+
reasonPending: "有人在等我(审批/提问/计划)",
|
|
55
|
+
unreadPanelShort: "待处理",
|
|
56
|
+
unreadPanelCount: "{n} 个待处理",
|
|
57
|
+
unreadPanelNone: "暂无待处理",
|
|
58
|
+
unreadPanelTitle: "待处理的会话",
|
|
59
|
+
unreadPanelEmpty: "现在没有需要你处理的事情",
|
|
60
|
+
unreadWaiting: "在等你",
|
|
61
|
+
unreadEnded: "刚结束,还没看",
|
|
62
|
+
unreadPanelOther: "其他",
|
|
63
|
+
workspaceDotLabel: "在工作区和会话行上显示红点(实验)",
|
|
64
|
+
workspaceDotHint: "在侧栏工作区那一行的文件夹图标上点一个小红点。这一项是贴着页面结构做的,DSH 升级后可能失效;失效时只会变成\"不显示\",不会影响红点数字、清单、跳转这些功能,随时可以关掉它。",
|
|
65
|
+
badgeTestHint: "真实未读 = 有会话发生了上面勾选的情况、而且你还没看过它(打开该会话即视为已读)。正在看的会话和子代理不计。手动模式:自己填数字试看效果。刷新页面后回到真实未读。"
|
|
37
66
|
};
|
|
38
67
|
const en = {
|
|
39
68
|
nav: "Favicon",
|
|
@@ -59,7 +88,36 @@ window.__ModuleLoader__.load({
|
|
|
59
88
|
logoHint: "Replaces the whale mark beside the brand name in the top-left (the deepseek HARNESS text stays).",
|
|
60
89
|
successLogo: "Page logo updated",
|
|
61
90
|
fail: "Operation failed",
|
|
62
|
-
noFile: "Choose a file first"
|
|
91
|
+
noFile: "Choose a file first",
|
|
92
|
+
badgeTest: "Unread badge",
|
|
93
|
+
badgeSourceLabel: "Number source:",
|
|
94
|
+
badgeSourceReal: "Real unread",
|
|
95
|
+
badgeSourceManual: "Manual",
|
|
96
|
+
badgeTestUnit: "0 = hidden",
|
|
97
|
+
badgeSizeLabel: "Badge size:",
|
|
98
|
+
badgeSizeSmall: "Small",
|
|
99
|
+
badgeSizeMedium: "Medium",
|
|
100
|
+
badgeSizeLarge: "Large",
|
|
101
|
+
unreadReasonsLabel: "Count these as unread:",
|
|
102
|
+
reasonCompleted: "Turn finished",
|
|
103
|
+
reasonError: "Error",
|
|
104
|
+
reasonBlocked: "Blocked",
|
|
105
|
+
reasonMaxTokens: "Hit token cap",
|
|
106
|
+
reasonInterrupted: "Interrupted",
|
|
107
|
+
reasonAbortedUser: "I stopped it",
|
|
108
|
+
reasonAbortedOther: "Other cancel",
|
|
109
|
+
reasonPending: "Someone is waiting",
|
|
110
|
+
unreadPanelShort: "Pending",
|
|
111
|
+
unreadPanelCount: "{n} pending",
|
|
112
|
+
unreadPanelNone: "Nothing pending",
|
|
113
|
+
unreadPanelTitle: "Sessions needing you",
|
|
114
|
+
unreadPanelEmpty: "Nothing needs you right now",
|
|
115
|
+
unreadWaiting: "waiting for you",
|
|
116
|
+
unreadEnded: "ended, not seen yet",
|
|
117
|
+
unreadPanelOther: "Other",
|
|
118
|
+
workspaceDotLabel: "Dots on workspace and session rows (experimental)",
|
|
119
|
+
workspaceDotHint: "Adds a small red dot to the folder icon of each workspace row. This one reads the page structure, so a DSH upgrade may break it; when it does it simply stops showing, never affecting the counts, the list, or navigation. Turn it off any time.",
|
|
120
|
+
badgeTestHint: "Real unread = a session ended for one of the checked reasons and you have not looked at it yet (opening a session marks it read). The session you are viewing and sub-agents never count. Manual = type a number to preview. Resets to Real on reload."
|
|
63
121
|
};
|
|
64
122
|
//#endregion
|
|
65
123
|
|
|
@@ -113,6 +171,8 @@ window.__ModuleLoader__.load({
|
|
|
113
171
|
img.src = url;
|
|
114
172
|
});
|
|
115
173
|
}
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region logo bridge
|
|
116
176
|
// In-package brand-mark bridge: the settings section pushes the current
|
|
117
177
|
// logo state here; the sidebar brand-mark component subscribes to it.
|
|
118
178
|
// Both live in this same module — no RPC loop, no cross-package events.
|
|
@@ -124,16 +184,843 @@ window.__ModuleLoader__.load({
|
|
|
124
184
|
logoListeners.add(fn);
|
|
125
185
|
return () => { logoListeners.delete(fn); };
|
|
126
186
|
}
|
|
127
|
-
function
|
|
187
|
+
function syncStatus(status) {
|
|
188
|
+
const known = status !== null && typeof status === "object";
|
|
128
189
|
emitLogo({
|
|
129
|
-
enabled:
|
|
130
|
-
rev:
|
|
131
|
-
png:
|
|
190
|
+
enabled: known && status.active === true && status.logo === true,
|
|
191
|
+
rev: known && status.rev ? status.rev : null,
|
|
192
|
+
png: known && status.png === true
|
|
193
|
+
});
|
|
194
|
+
// The plugin's own status — not the served HTML — decides which icon the
|
|
195
|
+
// page should show. A cached/proxied page, a stale HTML rewrite, or a
|
|
196
|
+
// browser extension can all leave the wrong link behind; this makes the
|
|
197
|
+
// client authoritative about the icon it composites onto.
|
|
198
|
+
iconState = {
|
|
199
|
+
active: known && status.active === true,
|
|
200
|
+
rev: known && typeof status.rev === "string" && status.rev !== "" ? status.rev : null,
|
|
201
|
+
png: known && status.png === true,
|
|
202
|
+
mime: known && typeof status.mime === "string" ? status.mime : null
|
|
203
|
+
};
|
|
204
|
+
reconcileFavicon();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
//#endregion
|
|
208
|
+
//#region unread state
|
|
209
|
+
// ---------------------------------------------------------------------
|
|
210
|
+
// Unread badge: which endings count, and what this browser has seen.
|
|
211
|
+
//
|
|
212
|
+
// The host folds every session's last `turn/end` into an official session
|
|
213
|
+
// projection (`lastTurnEnd`); it travels to the browser inside the ordinary
|
|
214
|
+
// session list, so this half never asks the host per session. Two local
|
|
215
|
+
// inputs finish the job:
|
|
216
|
+
// * the rule — which reasons light the badge. Persisted by the host,
|
|
217
|
+
// mirrored here so a toggle applies without a round trip;
|
|
218
|
+
// * the seen watermark — per browser, in localStorage, because "have I
|
|
219
|
+
// already looked at this session" is a fact about this browser.
|
|
220
|
+
// ---------------------------------------------------------------------
|
|
221
|
+
const LAST_TURN_END_KEY = "lastTurnEnd";
|
|
222
|
+
/** Reason key → locale key, for the settings checkboxes. */
|
|
223
|
+
const REASON_LABEL_KEYS = {
|
|
224
|
+
completed: "reasonCompleted",
|
|
225
|
+
error: "reasonError",
|
|
226
|
+
blocked: "reasonBlocked",
|
|
227
|
+
"max-tokens": "reasonMaxTokens",
|
|
228
|
+
interrupted: "reasonInterrupted",
|
|
229
|
+
"aborted:user": "reasonAbortedUser",
|
|
230
|
+
"aborted:other": "reasonAbortedOther"
|
|
231
|
+
};
|
|
232
|
+
/** Mirror of the host defaults; only used before the first status arrives. */
|
|
233
|
+
const UNREAD_FALLBACK_REASONS = { completed: true, error: true, blocked: true, "max-tokens": true, interrupted: true, "aborted:user": false, "aborted:other": true };
|
|
234
|
+
let unreadConfig = { reasons: { ...UNREAD_FALLBACK_REASONS }, pending: true, workspaceDot: true };
|
|
235
|
+
const unreadConfigListeners = new Set();
|
|
236
|
+
function normalizeUnreadConfig(input) {
|
|
237
|
+
const source = input !== null && typeof input === "object" ? input : {};
|
|
238
|
+
const raw = source.reasons !== null && typeof source.reasons === "object" ? source.reasons : {};
|
|
239
|
+
const reasons = {};
|
|
240
|
+
for (const key of Object.keys(UNREAD_FALLBACK_REASONS)) {
|
|
241
|
+
reasons[key] = typeof raw[key] === "boolean" ? raw[key] : UNREAD_FALLBACK_REASONS[key];
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
reasons,
|
|
245
|
+
pending: typeof source.pending === "boolean" ? source.pending : true,
|
|
246
|
+
workspaceDot: typeof source.workspaceDot === "boolean" ? source.workspaceDot : true
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
function emitUnreadConfig(input) {
|
|
250
|
+
unreadConfig = normalizeUnreadConfig(input);
|
|
251
|
+
unreadConfigListeners.forEach((fn) => { try { fn(unreadConfig); } catch {} });
|
|
252
|
+
}
|
|
253
|
+
function subscribeUnreadConfig(fn) {
|
|
254
|
+
unreadConfigListeners.add(fn);
|
|
255
|
+
return () => { unreadConfigListeners.delete(fn); };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const SEEN_STORE_KEY = "dsh-icon-custom.unread-seen.v1";
|
|
259
|
+
const SEEN_MAX = 400;
|
|
260
|
+
function loadSeenState() {
|
|
261
|
+
try {
|
|
262
|
+
const raw = JSON.parse(window.localStorage.getItem(SEEN_STORE_KEY) || "null");
|
|
263
|
+
if (raw === null || typeof raw !== "object") return { lastActiveAt: 0, seen: {} };
|
|
264
|
+
const seen = raw.seen !== null && typeof raw.seen === "object" ? raw.seen : {};
|
|
265
|
+
return { lastActiveAt: typeof raw.lastActiveAt === "number" ? raw.lastActiveAt : 0, seen };
|
|
266
|
+
} catch { return { lastActiveAt: 0, seen: {} }; }
|
|
267
|
+
}
|
|
268
|
+
const seenState = loadSeenState();
|
|
269
|
+
/** When this browser last had the app open before this page load. */
|
|
270
|
+
const seenBootAt = seenState.lastActiveAt > 0 ? seenState.lastActiveAt : 0;
|
|
271
|
+
function saveSeenState() {
|
|
272
|
+
try { window.localStorage.setItem(SEEN_STORE_KEY, JSON.stringify(seenState)); } catch {}
|
|
273
|
+
}
|
|
274
|
+
function pruneSeenState() {
|
|
275
|
+
const ids = Object.keys(seenState.seen);
|
|
276
|
+
if (ids.length <= SEEN_MAX) return;
|
|
277
|
+
ids.sort((a, b) => seenState.seen[b] - seenState.seen[a]);
|
|
278
|
+
for (const id of ids.slice(SEEN_MAX)) delete seenState.seen[id];
|
|
279
|
+
}
|
|
280
|
+
/** Record that this browser is looking at a session right now. */
|
|
281
|
+
function noteSeen(sessionId) {
|
|
282
|
+
if (typeof sessionId !== "string" || sessionId === "") return false;
|
|
283
|
+
seenState.seen[sessionId] = Date.now();
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* First sight of a session is watermarked at the last time this browser was
|
|
288
|
+
* here — so an ending that happened while the app was closed still counts as
|
|
289
|
+
* unread, while endings from before the plugin was installed do not.
|
|
290
|
+
* @returns whether anything was learned (callers persist only then).
|
|
291
|
+
*/
|
|
292
|
+
function ensureSeenBaseline(ids) {
|
|
293
|
+
const fallback = seenBootAt > 0 ? seenBootAt : Date.now();
|
|
294
|
+
let changed = false;
|
|
295
|
+
for (const id of ids) {
|
|
296
|
+
if (typeof id !== "string" || id === "") continue;
|
|
297
|
+
if (typeof seenState.seen[id] === "number") continue;
|
|
298
|
+
seenState.seen[id] = fallback;
|
|
299
|
+
changed = true;
|
|
300
|
+
}
|
|
301
|
+
return changed;
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* How many sessions deserve the badge right now: endings whose reason is
|
|
305
|
+
* enabled and that this browser has not seen since, plus sessions waiting
|
|
306
|
+
* for you (when that source is enabled). Sub-agent sessions and the session
|
|
307
|
+
* you are looking at never count.
|
|
308
|
+
* @param list - the session list snapshot (may be absent).
|
|
309
|
+
* @param pending - the pending-interaction map (may be absent).
|
|
310
|
+
* @param config - the unread rule to apply.
|
|
311
|
+
* @returns the badge number.
|
|
312
|
+
*/
|
|
313
|
+
function collectUnread(list, pending, config, seen) {
|
|
314
|
+
const reasons = config !== null && typeof config === "object" && config.reasons !== null && typeof config.reasons === "object" ? config.reasons : {};
|
|
315
|
+
// The watermark is a parameter, not module state: this stays a pure
|
|
316
|
+
// decision function that a test can drive with any marks it likes.
|
|
317
|
+
const marks = seen !== null && typeof seen === "object" ? seen : {};
|
|
318
|
+
/** { id, title, waiting, kind, at } — newest first, "in wait" ahead of "ended". */
|
|
319
|
+
const hits = [];
|
|
320
|
+
const ids = new Set();
|
|
321
|
+
const current = list !== null && typeof list === "object" ? list.current : undefined;
|
|
322
|
+
const byId = list !== null && typeof list === "object" && list.byId !== null && typeof list.byId === "object" ? list.byId : {};
|
|
323
|
+
const titleOf = (id, entry) => (entry !== null && typeof entry === "object" && typeof entry.displayTitle === "string" && entry.displayTitle !== "" ? entry.displayTitle : id);
|
|
324
|
+
// Which workspace the session lives in. The sidebar's own workspace rows are
|
|
325
|
+
// labelled with this cwd basename, so the list points at the same place the
|
|
326
|
+
// reader sees — the sidebar group cannot be expanded for us (that state is
|
|
327
|
+
// private to the shipped browser), so naming it is the honest substitute.
|
|
328
|
+
const whereOf = (entry) => {
|
|
329
|
+
const cwd = entry !== null && typeof entry === "object" && typeof entry.cwd === "string" ? entry.cwd : "";
|
|
330
|
+
if (cwd === "") return "";
|
|
331
|
+
const parts = cwd.replace(/[\\/]+$/, "").split(/[\\/]/);
|
|
332
|
+
return parts[parts.length - 1] || cwd;
|
|
333
|
+
};
|
|
334
|
+
if (list !== null && typeof list === "object") {
|
|
335
|
+
const order = Array.isArray(list.ids) ? list.ids : Object.keys(byId);
|
|
336
|
+
for (const id of order) {
|
|
337
|
+
const entry = byId[id];
|
|
338
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
339
|
+
if (entry.origin === "subagent") continue;
|
|
340
|
+
if (id === current) continue;
|
|
341
|
+
const values = entry.projectionValues;
|
|
342
|
+
const value = values !== null && typeof values === "object" ? values[LAST_TURN_END_KEY] : undefined;
|
|
343
|
+
if (value === null || typeof value !== "object") continue;
|
|
344
|
+
const endAt = typeof value.endAt === "number" ? value.endAt : 0;
|
|
345
|
+
const seenAt = marks[id];
|
|
346
|
+
if (endAt <= 0 || typeof seenAt !== "number" || endAt <= seenAt) continue;
|
|
347
|
+
// Unknown reasons (a newer DSH adds one) stay hidden until the user
|
|
348
|
+
// turns them on, so a vocabulary growth can never surprise the badge.
|
|
349
|
+
if (reasons[value.reason] !== true) continue;
|
|
350
|
+
ids.add(id);
|
|
351
|
+
hits.push({ id, title: titleOf(id, entry), where: whereOf(entry), waiting: false, kind: value.reason, at: endAt });
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
if (config === null || typeof config !== "object" || config.pending !== false) {
|
|
355
|
+
if (pending !== null && pending !== undefined && typeof pending.forEach === "function") {
|
|
356
|
+
pending.forEach((value, id) => {
|
|
357
|
+
if (id === current) return;
|
|
358
|
+
const kind = value !== null && typeof value === "object" && typeof value.kind === "string" ? value.kind : "";
|
|
359
|
+
if (ids.has(id)) {
|
|
360
|
+
// Already unread by its last turn: it is waiting too, which wins.
|
|
361
|
+
const hit = hits.find((candidate) => candidate.id === id);
|
|
362
|
+
if (hit !== undefined) { hit.waiting = true; hit.kind = kind; }
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
ids.add(id);
|
|
366
|
+
hits.push({ id, title: titleOf(id, byId[id]), where: whereOf(byId[id]), waiting: true, kind, at: Date.now() });
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
hits.sort((a, b) => (b.waiting === true ? 1 : 0) - (a.waiting === true ? 1 : 0) || b.at - a.at);
|
|
371
|
+
return hits;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region badge bridge
|
|
376
|
+
// In-package badge bridge. Two number sources:
|
|
377
|
+
// "real" — DSH's own pending-interaction registry (the same source the
|
|
378
|
+
// sidebar session-row marker uses): how many sessions are
|
|
379
|
+
// waiting for you right now. This is the default.
|
|
380
|
+
// "manual" — the settings test input, for eyeballing sizes and numbers.
|
|
381
|
+
// `md` is the default size; `sm` keeps the original 1.0 multiplier.
|
|
382
|
+
const BADGE_SCALES = { sm: 1, md: 1.15, lg: 1.3 };
|
|
383
|
+
let badgeSource = "real";
|
|
384
|
+
let realCount = 0;
|
|
385
|
+
/** The sessions behind `realCount`, for the sidebar list (newest first). */
|
|
386
|
+
let realItems = [];
|
|
387
|
+
let manualCount = 0;
|
|
388
|
+
let badgeSize = "md";
|
|
389
|
+
const badgeListeners = new Set();
|
|
390
|
+
function normalizeCount(value) {
|
|
391
|
+
const n = Number(value);
|
|
392
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 0;
|
|
393
|
+
}
|
|
394
|
+
function effectiveCount() {
|
|
395
|
+
return badgeSource === "manual" ? manualCount : realCount;
|
|
396
|
+
}
|
|
397
|
+
function badgeSnapshot() {
|
|
398
|
+
return { count: effectiveCount(), size: badgeSize, source: badgeSource, items: badgeSource === "manual" ? [] : realItems };
|
|
399
|
+
}
|
|
400
|
+
function notifyBadge() {
|
|
401
|
+
const snapshot = badgeSnapshot();
|
|
402
|
+
badgeListeners.forEach((fn) => { try { fn(snapshot); } catch {} });
|
|
403
|
+
}
|
|
404
|
+
/** Manual test input. */
|
|
405
|
+
function emitBadge(value) {
|
|
406
|
+
manualCount = normalizeCount(value);
|
|
407
|
+
notifyBadge();
|
|
408
|
+
}
|
|
409
|
+
/** The real source: the sessions needing you, and which they are. */
|
|
410
|
+
function emitRealBadge(value, items) {
|
|
411
|
+
realCount = normalizeCount(value);
|
|
412
|
+
realItems = Array.isArray(items) ? items : [];
|
|
413
|
+
notifyBadge();
|
|
414
|
+
}
|
|
415
|
+
function emitBadgeSize(value) {
|
|
416
|
+
badgeSize = Object.prototype.hasOwnProperty.call(BADGE_SCALES, value) ? value : "md";
|
|
417
|
+
notifyBadge();
|
|
418
|
+
}
|
|
419
|
+
function emitBadgeSource(value) {
|
|
420
|
+
badgeSource = value === "manual" ? "manual" : "real";
|
|
421
|
+
notifyBadge();
|
|
422
|
+
}
|
|
423
|
+
function subscribeBadge(fn) {
|
|
424
|
+
badgeListeners.add(fn);
|
|
425
|
+
return () => { badgeListeners.delete(fn); };
|
|
426
|
+
}
|
|
427
|
+
function badgeScale(size) {
|
|
428
|
+
return BADGE_SCALES[size] || BADGE_SCALES.md;
|
|
429
|
+
}
|
|
430
|
+
function badgeLabel(count) {
|
|
431
|
+
return count > 99 ? "99+" : String(count);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
//#endregion
|
|
435
|
+
//#region favicon compositing
|
|
436
|
+
// The browser accepts exactly ONE image per <link rel="icon">, so a tab
|
|
437
|
+
// badge cannot be layered: base icon and badge are redrawn together into a
|
|
438
|
+
// canvas and the merged bitmap is handed back to the browser. Count 0 (and
|
|
439
|
+
// teardown) restores the pristine platform/custom href.
|
|
440
|
+
const FAVICON_SIZE = 64;
|
|
441
|
+
let faviconBase = null;
|
|
442
|
+
let composeSeq = 0;
|
|
443
|
+
let faviconComposing = false;
|
|
444
|
+
/** Last compose failure, so a repeated failure warns once instead of spamming. */
|
|
445
|
+
let lastComposeError = null;
|
|
446
|
+
/** Which icon the plugin considers current (filled from the status RPC). */
|
|
447
|
+
let iconState = { active: false, rev: null, png: false, mime: null };
|
|
448
|
+
/** The icon URL this page SHOULD be showing, per the plugin's own status. */
|
|
449
|
+
function desiredIconHref() {
|
|
450
|
+
if (iconState.active === true && typeof iconState.rev === "string" && iconState.rev !== "") {
|
|
451
|
+
return iconState.png === true ? `/icon-custom-192.png?v=${iconState.rev}` : `/icon-custom.svg?v=${iconState.rev}`;
|
|
452
|
+
}
|
|
453
|
+
return "/favicon.svg";
|
|
454
|
+
}
|
|
455
|
+
function desiredIconType() {
|
|
456
|
+
if (iconState.active !== true) return "image/svg+xml";
|
|
457
|
+
return iconState.png === true ? "image/png" : (iconState.mime || "image/svg+xml");
|
|
458
|
+
}
|
|
459
|
+
function iconLink() {
|
|
460
|
+
let link = document.querySelector('link[rel="icon"]');
|
|
461
|
+
if (!link) {
|
|
462
|
+
link = document.createElement("link");
|
|
463
|
+
link.rel = "icon";
|
|
464
|
+
link.type = "image/svg+xml";
|
|
465
|
+
link.href = "/favicon.svg";
|
|
466
|
+
document.head.appendChild(link);
|
|
467
|
+
}
|
|
468
|
+
return link;
|
|
469
|
+
}
|
|
470
|
+
function roundRect(c2d, x, y, w, h) {
|
|
471
|
+
const r = Math.min(h / 2, w / 2);
|
|
472
|
+
c2d.beginPath();
|
|
473
|
+
c2d.moveTo(x + r, y);
|
|
474
|
+
c2d.arcTo(x + w, y, x + w, y + h, r);
|
|
475
|
+
c2d.arcTo(x + w, y + h, x, y + h, r);
|
|
476
|
+
c2d.arcTo(x, y + h, x, y, r);
|
|
477
|
+
c2d.arcTo(x, y, x + w, y, r);
|
|
478
|
+
c2d.closePath();
|
|
479
|
+
}
|
|
480
|
+
function drawFaviconBadge(c2d, size, count, scale) {
|
|
481
|
+
const text = badgeLabel(count);
|
|
482
|
+
const r = size * 0.28 * (scale || 1);
|
|
483
|
+
const wide = text.length > 1;
|
|
484
|
+
const w = wide ? r * 2 + size * 0.16 * (text.length - 1) : r * 2;
|
|
485
|
+
const h = r * 2;
|
|
486
|
+
const cx = size - w / 2 - size * 0.02;
|
|
487
|
+
const cy = h / 2 + size * 0.02;
|
|
488
|
+
c2d.save();
|
|
489
|
+
// White ring so the badge stays readable on any base artwork.
|
|
490
|
+
c2d.fillStyle = "#ffffff";
|
|
491
|
+
roundRect(c2d, cx - w / 2 - size * 0.03, cy - h / 2 - size * 0.03, w + size * 0.06, h + size * 0.06);
|
|
492
|
+
c2d.fill();
|
|
493
|
+
c2d.fillStyle = "#e5484d";
|
|
494
|
+
roundRect(c2d, cx - w / 2, cy - h / 2, w, h);
|
|
495
|
+
c2d.fill();
|
|
496
|
+
c2d.fillStyle = "#ffffff";
|
|
497
|
+
c2d.font = `700 ${Math.round(h * (wide ? 0.56 : 0.68))}px system-ui, -apple-system, sans-serif`;
|
|
498
|
+
c2d.textAlign = "center";
|
|
499
|
+
c2d.textBaseline = "middle";
|
|
500
|
+
c2d.fillText(text, cx, cy + size * 0.01);
|
|
501
|
+
c2d.restore();
|
|
502
|
+
}
|
|
503
|
+
function loadIconImage(href) {
|
|
504
|
+
return new Promise((resolve, reject) => {
|
|
505
|
+
const img = new Image();
|
|
506
|
+
img.onload = () => resolve(img);
|
|
507
|
+
img.onerror = () => reject(new Error("icon-load"));
|
|
508
|
+
img.src = href;
|
|
132
509
|
});
|
|
133
510
|
}
|
|
511
|
+
async function applyBadgeToFavicon(count, size) {
|
|
512
|
+
const link = iconLink();
|
|
513
|
+
const base = desiredIconHref();
|
|
514
|
+
if (count <= 0) {
|
|
515
|
+
// Nothing to show: make sure the page carries the plugin's own icon
|
|
516
|
+
// (this also repairs a stale/proxied link that still points elsewhere).
|
|
517
|
+
if (link.getAttribute("href") !== base) {
|
|
518
|
+
link.setAttribute("href", base);
|
|
519
|
+
link.setAttribute("type", desiredIconType());
|
|
520
|
+
}
|
|
521
|
+
faviconBase = null;
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
// The base is resolved from the plugin's status, never from whatever the
|
|
525
|
+
// page happens to carry, and never from our own previous bitmap.
|
|
526
|
+
faviconBase = { href: base, type: desiredIconType() };
|
|
527
|
+
const seq = ++composeSeq;
|
|
528
|
+
faviconComposing = true;
|
|
529
|
+
try {
|
|
530
|
+
const img = await loadIconImage(new URL(faviconBase.href, document.baseURI).href);
|
|
531
|
+
if (seq !== composeSeq) return;
|
|
532
|
+
const canvas = document.createElement("canvas");
|
|
533
|
+
canvas.width = FAVICON_SIZE;
|
|
534
|
+
canvas.height = FAVICON_SIZE;
|
|
535
|
+
const c2d = canvas.getContext("2d");
|
|
536
|
+
const iw = img.naturalWidth || img.width || FAVICON_SIZE;
|
|
537
|
+
const ih = img.naturalHeight || img.height || FAVICON_SIZE;
|
|
538
|
+
const scale = Math.min(FAVICON_SIZE / iw, FAVICON_SIZE / ih);
|
|
539
|
+
const dw = iw * scale;
|
|
540
|
+
const dh = ih * scale;
|
|
541
|
+
c2d.drawImage(img, (FAVICON_SIZE - dw) / 2, (FAVICON_SIZE - dh) / 2, dw, dh);
|
|
542
|
+
drawFaviconBadge(c2d, FAVICON_SIZE, count, badgeScale(size));
|
|
543
|
+
link.setAttribute("href", canvas.toDataURL("image/png"));
|
|
544
|
+
link.setAttribute("type", "image/png");
|
|
545
|
+
} catch (error) {
|
|
546
|
+
// Never silent: a failure here is invisible in the UI (the tab simply
|
|
547
|
+
// keeps its previous icon), which once cost a whole debugging session.
|
|
548
|
+
// Warn once per distinct reason so the 5s watchdog cannot spam.
|
|
549
|
+
const why = String((error && error.message) || error);
|
|
550
|
+
if (why !== lastComposeError) {
|
|
551
|
+
lastComposeError = why;
|
|
552
|
+
try { console.warn("dsh-icon-custom: 标签页红点合成失败 / favicon badge compose failed:", why); } catch {}
|
|
553
|
+
}
|
|
554
|
+
} finally {
|
|
555
|
+
if (seq === composeSeq) faviconComposing = false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
559
|
+
* Re-assert the tab badge after anything outside this plugin disturbed the
|
|
560
|
+
* icon link — a cached page, a browser extension, a tab move, or a plugin
|
|
561
|
+
* reload. Our own output is a `data:` URL, so any other href means the
|
|
562
|
+
* composite was replaced and has to be rebuilt from the new base.
|
|
563
|
+
*/
|
|
564
|
+
function reconcileFavicon() {
|
|
565
|
+
const link = document.querySelector('link[rel="icon"]');
|
|
566
|
+
const href = link === null ? null : link.getAttribute("href");
|
|
567
|
+
const ours = typeof href === "string" && href.slice(0, 5) === "data:";
|
|
568
|
+
const wanted = desiredIconHref();
|
|
569
|
+
if (effectiveCount() <= 0) {
|
|
570
|
+
// No badge to draw, but the page must still carry the plugin's icon.
|
|
571
|
+
if (ours || href !== wanted) applyBadgeToFavicon(0);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (ours || faviconComposing) return; // already showing, or a build is running
|
|
575
|
+
applyBadgeToFavicon(effectiveCount(), badgeSize);
|
|
576
|
+
}
|
|
577
|
+
// A freshly applied base icon replaces whatever the browser holds, so the
|
|
578
|
+
// composite must be rebuilt from the NEW base instead of the stale one.
|
|
579
|
+
function refreshFaviconBase() {
|
|
580
|
+
faviconBase = null;
|
|
581
|
+
applyBadgeToFavicon(effectiveCount(), badgeSize);
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
|
|
585
|
+
//#region unread panel
|
|
586
|
+
/**
|
|
587
|
+
* Whether the "which sessions?" list is open, and where its button sits.
|
|
588
|
+
* The foot entry and the floating list live in two different slots, so they
|
|
589
|
+
* cannot share React state — this tiny store is what they agree through.
|
|
590
|
+
*/
|
|
591
|
+
let unreadPanelOpen = false;
|
|
592
|
+
let unreadPanelAnchor = null;
|
|
593
|
+
const unreadPanelListeners = new Set();
|
|
594
|
+
function emitUnreadPanel(open, anchor) {
|
|
595
|
+
unreadPanelOpen = open === true;
|
|
596
|
+
if (anchor !== undefined) unreadPanelAnchor = anchor;
|
|
597
|
+
unreadPanelListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
598
|
+
}
|
|
599
|
+
function subscribeUnreadPanel(fn) {
|
|
600
|
+
unreadPanelListeners.add(fn);
|
|
601
|
+
return () => { unreadPanelListeners.delete(fn); };
|
|
602
|
+
}
|
|
603
|
+
function toggleUnreadPanel(anchor) {
|
|
604
|
+
emitUnreadPanel(!unreadPanelOpen, anchor === undefined ? null : anchor);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Roll the pending sessions up to one dot per workspace.
|
|
608
|
+
*
|
|
609
|
+
* Pure: `titles` maps sessionId → the workspace title the sidebar shows
|
|
610
|
+
* (assembled from the official workspace snapshot); a session missing from it
|
|
611
|
+
* falls back to the cwd basename `collectUnread` already carries.
|
|
612
|
+
* @returns `[{ name, count }]`, busiest workspace first.
|
|
613
|
+
*/
|
|
614
|
+
function dotsFromItems(items, titles) {
|
|
615
|
+
const counts = new Map();
|
|
616
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
617
|
+
if (item === null || typeof item !== "object") continue;
|
|
618
|
+
const mapped = titles !== null && typeof titles === "object" ? titles[item.id] : undefined;
|
|
619
|
+
const name = typeof mapped === "string" && mapped !== "" ? mapped : (typeof item.where === "string" ? item.where : "");
|
|
620
|
+
if (name === "") continue;
|
|
621
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
622
|
+
}
|
|
623
|
+
return [...counts.entries()]
|
|
624
|
+
.map(([name, count]) => ({ name, count }))
|
|
625
|
+
.sort((a, b) => b.count - a.count || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Group the pending rows by workspace for the sidebar list.
|
|
630
|
+
*
|
|
631
|
+
* Pure and order-preserving: rows arrive already sorted by urgency, so the
|
|
632
|
+
* group holding the most urgent row naturally comes first, and rows that
|
|
633
|
+
* carry no workspace label fall into their own unlabelled bucket.
|
|
634
|
+
* @param items - rows from `collectUnread`.
|
|
635
|
+
* @returns `[{ where, items }]`.
|
|
636
|
+
*/
|
|
637
|
+
function groupUnreadByWorkspace(items) {
|
|
638
|
+
const groups = [];
|
|
639
|
+
const index = new Map();
|
|
640
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
641
|
+
const where = item !== null && typeof item === "object" && typeof item.where === "string" ? item.where : "";
|
|
642
|
+
let group = index.get(where);
|
|
643
|
+
if (group === undefined) {
|
|
644
|
+
group = { where, items: [] };
|
|
645
|
+
index.set(where, group);
|
|
646
|
+
groups.push(group);
|
|
647
|
+
}
|
|
648
|
+
group.items.push(item);
|
|
649
|
+
}
|
|
650
|
+
return groups;
|
|
651
|
+
}
|
|
652
|
+
//#endregion
|
|
653
|
+
|
|
654
|
+
//#region workspace dots
|
|
655
|
+
/**
|
|
656
|
+
* One dot on each workspace row's folder icon.
|
|
657
|
+
*
|
|
658
|
+
* This is the only part of the plugin that reaches into the shipped DOM: the
|
|
659
|
+
* browsing region is a single opaque seat (`sidebar.workspaces`, kind
|
|
660
|
+
* `single`) with no per-row slots, so a marker can only be injected. It is
|
|
661
|
+
* therefore built to fail quietly — see `noteWorkspaceDotFailure`, the
|
|
662
|
+
* give-up switch, and the `workspaceDot` setting that removes it entirely.
|
|
663
|
+
*/
|
|
664
|
+
const WS_DOT_ATTR = "data-icon-custom-wsdot";
|
|
665
|
+
const ROW_DOT_ATTR = "data-icon-custom-rowdot";
|
|
666
|
+
const WS_ROW_MIN_H = 18;
|
|
667
|
+
const WS_ROW_MAX_H = 56;
|
|
668
|
+
const WS_ROW_MIN_W = 80;
|
|
669
|
+
const WS_MAX_FAILURES = 3;
|
|
670
|
+
let workspaceDots = [];
|
|
671
|
+
/** One entry per unread session: `{ id, title }`, matched against the row title. */
|
|
672
|
+
let sessionDots = [];
|
|
673
|
+
let workspaceDotFailures = 0;
|
|
674
|
+
let workspaceDotGivenUp = false;
|
|
675
|
+
let workspaceDotPainting = false;
|
|
676
|
+
/** When the last paint ran, so the observer can tell our churn from everyone else's. */
|
|
677
|
+
let workspaceDotPaintedAt = 0;
|
|
678
|
+
/** Pending "did our markers survive?" check, one shot per creation burst. */
|
|
679
|
+
let workspaceDotVerify = 0;
|
|
680
|
+
/** Rows whose inline `position` we changed, and what it was before. */
|
|
681
|
+
let workspaceDotRestores = [];
|
|
682
|
+
const workspaceDotListeners = new Set();
|
|
683
|
+
function emitSidebarMarks(marks) {
|
|
684
|
+
const next = marks !== null && typeof marks === "object" ? marks : {};
|
|
685
|
+
workspaceDots = Array.isArray(next.workspaces) ? next.workspaces : [];
|
|
686
|
+
sessionDots = Array.isArray(next.sessions) ? next.sessions : [];
|
|
687
|
+
workspaceDotListeners.forEach((fn) => { try { fn(); } catch {} });
|
|
688
|
+
}
|
|
689
|
+
function subscribeSidebarMarks(fn) {
|
|
690
|
+
workspaceDotListeners.add(fn);
|
|
691
|
+
return () => { workspaceDotListeners.delete(fn); };
|
|
692
|
+
}
|
|
693
|
+
/** The shipped browsing region, by the official slot marker (never by class name). */
|
|
694
|
+
function workspaceDotContainer() {
|
|
695
|
+
return document.querySelector('[data-slot="sidebar.workspaces"]')
|
|
696
|
+
|| document.querySelector('[data-slot="sidebar"]')
|
|
697
|
+
|| null;
|
|
698
|
+
}
|
|
699
|
+
function clearWorkspaceDots() {
|
|
700
|
+
try { document.querySelectorAll("[" + WS_DOT_ATTR + "],[" + ROW_DOT_ATTR + "]").forEach((node) => node.remove()); } catch {}
|
|
701
|
+
for (const entry of workspaceDotRestores) { try { entry.el.style.position = entry.position; } catch {} }
|
|
702
|
+
workspaceDotRestores = [];
|
|
703
|
+
}
|
|
704
|
+
function noteWorkspaceDotFailure(why) {
|
|
705
|
+
workspaceDotFailures++;
|
|
706
|
+
if (workspaceDotFailures === 1) {
|
|
707
|
+
try { console.warn("dsh-icon-custom: 工作区红点定位失败,已降级(不影响其他功能):", why); } catch {}
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
if (workspaceDotFailures < WS_MAX_FAILURES || workspaceDotGivenUp) return;
|
|
711
|
+
workspaceDotGivenUp = true;
|
|
712
|
+
try { console.warn("dsh-icon-custom: 工作区红点连续失败,本次会话内已自动关闭,可在设置里关掉这一项。"); } catch {}
|
|
713
|
+
}
|
|
714
|
+
/** The marker already sitting in this host for that key, or null. */
|
|
715
|
+
function existingMarker(host, attribute, key) {
|
|
716
|
+
for (const child of host.children) {
|
|
717
|
+
if (child.getAttribute(attribute) === key) return child;
|
|
718
|
+
}
|
|
719
|
+
return null;
|
|
720
|
+
}
|
|
721
|
+
/** The one element whose trimmed text is exactly this name, or null. */
|
|
722
|
+
function labelElementFor(container, name) {
|
|
723
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, null);
|
|
724
|
+
let hit = null;
|
|
725
|
+
let hits = 0;
|
|
726
|
+
let node = walker.nextNode();
|
|
727
|
+
while (node !== null) {
|
|
728
|
+
if (node.nodeValue !== null && node.nodeValue.trim() === name) { hits++; hit = node.parentElement; }
|
|
729
|
+
node = walker.nextNode();
|
|
730
|
+
}
|
|
731
|
+
return hits === 1 ? hit : null;
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* The row box + folder icon a marker should hang off.
|
|
735
|
+
*
|
|
736
|
+
* Walks up from the label until it finds a row-sized box that ALSO contains an
|
|
737
|
+
* icon sitting left of the text — the only shape that means "this is the
|
|
738
|
+
* workspace row". Requiring that icon is load-bearing: without it the label
|
|
739
|
+
* itself (or any wider wrapper) qualifies as the row, and the marker lands at
|
|
740
|
+
* the end of the text instead of on the icon.
|
|
741
|
+
* @returns `{ row, icon }`, or null when this row cannot be trusted.
|
|
742
|
+
*/
|
|
743
|
+
function markerTargetFor(label) {
|
|
744
|
+
const labelRect = label.getBoundingClientRect();
|
|
745
|
+
let element = label;
|
|
746
|
+
for (let step = 0; step < 6 && element !== null && element !== undefined; step++) {
|
|
747
|
+
const icon = element.querySelector("svg");
|
|
748
|
+
if (icon !== null) {
|
|
749
|
+
const rect = element.getBoundingClientRect();
|
|
750
|
+
const iconRect = icon.getBoundingClientRect();
|
|
751
|
+
if (rect.height >= WS_ROW_MIN_H && rect.height <= WS_ROW_MAX_H && rect.width >= WS_ROW_MIN_W && iconRect.left < labelRect.left) {
|
|
752
|
+
return { row: element, icon };
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
element = element.parentElement;
|
|
756
|
+
}
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* The title element a session marker hangs off.
|
|
761
|
+
*
|
|
762
|
+
* A session row has no icon to anchor on, so its marker is inserted inline right
|
|
763
|
+
* after the title. That only works when `label` is the TIGHT holder of the title
|
|
764
|
+
* text: a wrapper that merely contains it would push the marker past the
|
|
765
|
+
* timestamp, and a row holding the text directly has no inline slot at all.
|
|
766
|
+
* @returns `{ title, row }`, or null when this row cannot be trusted.
|
|
767
|
+
*/
|
|
768
|
+
function titleTargetFor(label, title) {
|
|
769
|
+
if (label === null || label === undefined) return null;
|
|
770
|
+
if (typeof label.textContent !== "string" || label.textContent.trim() !== title) return null;
|
|
771
|
+
const row = label.parentElement;
|
|
772
|
+
if (row === null || row === undefined || row === label) return null;
|
|
773
|
+
const rect = row.getBoundingClientRect();
|
|
774
|
+
if (rect.height < WS_ROW_MIN_H || rect.height > WS_ROW_MAX_H || rect.width < WS_ROW_MIN_W) return null;
|
|
775
|
+
return { title: label, row };
|
|
776
|
+
}
|
|
777
|
+
function paintWorkspaceDots() {
|
|
778
|
+
if (workspaceDotGivenUp) return;
|
|
779
|
+
const enabled = unreadConfig.workspaceDot !== false;
|
|
780
|
+
const wanted = enabled ? workspaceDots : [];
|
|
781
|
+
const wantedRows = enabled ? sessionDots : [];
|
|
782
|
+
const container = workspaceDotContainer();
|
|
783
|
+
workspaceDotPainting = true;
|
|
784
|
+
workspaceDotPaintedAt = Date.now();
|
|
785
|
+
let placed = 0;
|
|
786
|
+
let created = 0;
|
|
787
|
+
let missed = 0;
|
|
788
|
+
try {
|
|
789
|
+
// Reconcile, never wipe-and-redraw: a marker that is already correct is
|
|
790
|
+
// left untouched, so one unlucky lookup (a re-render in flight, a name
|
|
791
|
+
// matched twice for a moment) can no longer blink an existing dot away.
|
|
792
|
+
const wantedNames = new Set(wanted.map((entry) => entry.name));
|
|
793
|
+
const wantedIds = new Set(wantedRows.map((entry) => entry.id));
|
|
794
|
+
document.querySelectorAll("[" + WS_DOT_ATTR + "]").forEach((node) => {
|
|
795
|
+
if (!node.isConnected || !wantedNames.has(node.getAttribute(WS_DOT_ATTR))) node.remove();
|
|
796
|
+
});
|
|
797
|
+
document.querySelectorAll("[" + ROW_DOT_ATTR + "]").forEach((node) => {
|
|
798
|
+
if (!node.isConnected || !wantedIds.has(node.getAttribute(ROW_DOT_ATTR))) node.remove();
|
|
799
|
+
});
|
|
800
|
+
if (container === null) { workspaceDotPainting = false; return; } // start-up race
|
|
801
|
+
for (const entry of wanted) {
|
|
802
|
+
const label = labelElementFor(container, entry.name);
|
|
803
|
+
const target = label === null ? null : markerTargetFor(label);
|
|
804
|
+
if (target === null) { missed++; continue; }
|
|
805
|
+
const row = target.row;
|
|
806
|
+
if (existingMarker(row, WS_DOT_ATTR, entry.name) !== null) { placed++; continue; }
|
|
807
|
+
const icon = target.icon;
|
|
808
|
+
const rowRect = row.getBoundingClientRect();
|
|
809
|
+
const anchorRect = icon.getBoundingClientRect();
|
|
810
|
+
if (window.getComputedStyle(row).position === "static") {
|
|
811
|
+
workspaceDotRestores.push({ el: row, position: row.style.position });
|
|
812
|
+
row.style.position = "relative";
|
|
813
|
+
}
|
|
814
|
+
const dot = document.createElement("span");
|
|
815
|
+
dot.setAttribute(WS_DOT_ATTR, entry.name);
|
|
816
|
+
dot.setAttribute("aria-hidden", "true");
|
|
817
|
+
dot.title = entry.name + " · " + entry.count;
|
|
818
|
+
dot.style.cssText = "position:absolute;pointer-events:none;width:8px;height:8px;border-radius:50%;"
|
|
819
|
+
+ "background:#e5484d;box-shadow:0 0 0 1.5px var(--dsw-alias-bg-layer-1,#fff);"
|
|
820
|
+
+ "left:" + Math.round(anchorRect.right - rowRect.left - 4) + "px;"
|
|
821
|
+
+ "top:" + Math.round(anchorRect.top - rowRect.top - 3) + "px;";
|
|
822
|
+
row.appendChild(dot);
|
|
823
|
+
placed++;
|
|
824
|
+
created++;
|
|
825
|
+
}
|
|
826
|
+
// Session rows: the marker rides inline right after the title, so it stays
|
|
827
|
+
// beside the name whatever the timestamp happens to say.
|
|
828
|
+
for (const entry of wantedRows) {
|
|
829
|
+
const label = labelElementFor(container, entry.title);
|
|
830
|
+
const target = titleTargetFor(label, entry.title);
|
|
831
|
+
if (target === null) { missed++; continue; }
|
|
832
|
+
if (existingMarker(target.row, ROW_DOT_ATTR, entry.id) !== null) { placed++; continue; }
|
|
833
|
+
const dot = document.createElement("span");
|
|
834
|
+
dot.setAttribute(ROW_DOT_ATTR, entry.id);
|
|
835
|
+
dot.setAttribute("aria-hidden", "true");
|
|
836
|
+
dot.title = entry.title;
|
|
837
|
+
// Tight to the title on the left, and kept off the timestamp on the
|
|
838
|
+
// right: with no right margin the dot reads as part of "24 分钟".
|
|
839
|
+
dot.style.cssText = "pointer-events:none;flex:none;width:8px;height:8px;border-radius:50%;"
|
|
840
|
+
+ "background:#e5484d;margin:0 4px 0 6px;";
|
|
841
|
+
target.title.insertAdjacentElement("afterend", dot);
|
|
842
|
+
placed++;
|
|
843
|
+
created++;
|
|
844
|
+
}
|
|
845
|
+
} catch (error) {
|
|
846
|
+
missed++;
|
|
847
|
+
noteWorkspaceDotFailure(String((error && error.message) || error));
|
|
848
|
+
} finally {
|
|
849
|
+
workspaceDotPainting = false;
|
|
850
|
+
}
|
|
851
|
+
// The breaker watches the MECHANISM, not one unlucky row: only a run that
|
|
852
|
+
// placed nothing at all counts as a failure.
|
|
853
|
+
if (placed > 0) workspaceDotFailures = 0;
|
|
854
|
+
else if (missed > 0) noteWorkspaceDotFailure("这一轮没有任何工作区行能定位:" + missed + " 个");
|
|
855
|
+
// If the shipped UI committed a re-render inside our quiet window it could
|
|
856
|
+
// have taken a fresh marker with it, and we would have ignored that
|
|
857
|
+
// mutation. One settle check shortly after closes that hole.
|
|
858
|
+
if (created > 0) {
|
|
859
|
+
if (workspaceDotVerify !== 0) window.clearTimeout(workspaceDotVerify);
|
|
860
|
+
workspaceDotVerify = window.setTimeout(() => { workspaceDotVerify = 0; paintWorkspaceDots(); }, 700);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
134
863
|
//#endregion
|
|
135
864
|
|
|
136
865
|
//#region components
|
|
866
|
+
/**
|
|
867
|
+
* Headless occupant of the frame-wide `shell.overlay` slot. It reads DSH's
|
|
868
|
+
* own pending-interaction registry — the very same source that paints the
|
|
869
|
+
* sidebar session-row marker — and feeds "how many sessions are waiting for
|
|
870
|
+
* you" into the badge bridge. Renders nothing.
|
|
871
|
+
*
|
|
872
|
+
* This is the entire integration: an official standard prop goes in, a
|
|
873
|
+
* number comes out. The icon plugin never learns which plugin published an
|
|
874
|
+
* interaction, so swapping or removing any of them cannot break it — the
|
|
875
|
+
* count simply drops back to 0.
|
|
876
|
+
*/
|
|
877
|
+
function BadgeSource(props) {
|
|
878
|
+
// Both hooks are official root-scope standard props: the session list
|
|
879
|
+
// (which carries every session's `lastTurnEnd` projection value) and the
|
|
880
|
+
// pending-interaction registry the sidebar row marker already uses.
|
|
881
|
+
const list = typeof props.useSessions === "function" ? props.useSessions((state) => state) : undefined;
|
|
882
|
+
const pending = typeof props.useSessionPendingInteraction === "function" ? props.useSessionPendingInteraction((map) => map) : undefined;
|
|
883
|
+
const workspaces = typeof props.useWorkspaces === "function" ? props.useWorkspaces((state) => state) : undefined;
|
|
884
|
+
const [config, setConfig] = React.useState(unreadConfig);
|
|
885
|
+
React.useEffect(() => subscribeUnreadConfig(setConfig), []);
|
|
886
|
+
// Baseline new sessions; persists only when it actually learns something.
|
|
887
|
+
React.useEffect(() => {
|
|
888
|
+
const ids = list !== null && typeof list === "object" && Array.isArray(list.ids) ? list.ids : [];
|
|
889
|
+
if (!ensureSeenBaseline(ids)) return;
|
|
890
|
+
pruneSeenState();
|
|
891
|
+
saveSeenState();
|
|
892
|
+
}, [list]);
|
|
893
|
+
// Looking at a session marks it seen; the count also excludes it outright.
|
|
894
|
+
const currentId = list !== null && typeof list === "object" ? list.current : undefined;
|
|
895
|
+
React.useEffect(() => {
|
|
896
|
+
if (noteSeen(currentId)) saveSeenState();
|
|
897
|
+
}, [currentId]);
|
|
898
|
+
const items = collectUnread(list, pending, config, seenState.seen);
|
|
899
|
+
const count = items.length;
|
|
900
|
+
React.useEffect(() => { emitRealBadge(count, items); }, [count, items]);
|
|
901
|
+
// The workspace rows are labelled with the workspace TITLE, which is not
|
|
902
|
+
// always the cwd basename, so map session → title from the official snapshot.
|
|
903
|
+
React.useEffect(() => {
|
|
904
|
+
const titles = {};
|
|
905
|
+
const list = workspaces !== null && typeof workspaces === "object" && Array.isArray(workspaces.items) ? workspaces.items : [];
|
|
906
|
+
for (const workspace of list) {
|
|
907
|
+
if (workspace === null || typeof workspace !== "object") continue;
|
|
908
|
+
const title = typeof workspace.title === "string" ? workspace.title : "";
|
|
909
|
+
if (title === "" || !Array.isArray(workspace.sessionIds)) continue;
|
|
910
|
+
for (const id of workspace.sessionIds) titles[id] = title;
|
|
911
|
+
}
|
|
912
|
+
emitSidebarMarks({
|
|
913
|
+
workspaces: dotsFromItems(items, titles),
|
|
914
|
+
sessions: items.map((item) => ({ id: item.id, title: item.title }))
|
|
915
|
+
});
|
|
916
|
+
}, [items, workspaces]);
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* The sidebar-foot entry: a bell carrying the unread count, beside the
|
|
922
|
+
* shipped Settings and Cordis buttons. Both that seat and the overlay list
|
|
923
|
+
* are additive (`replaceRisk: none`) — a fresh id is added beside the others,
|
|
924
|
+
* nothing shipped is shadowed.
|
|
925
|
+
*/
|
|
926
|
+
function UnreadFooterButton(props) {
|
|
927
|
+
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
928
|
+
const [open, setOpen] = React.useState(unreadPanelOpen);
|
|
929
|
+
const ref = React.useRef(null);
|
|
930
|
+
React.useEffect(() => subscribeBadge(setSnapshot), []);
|
|
931
|
+
React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
|
|
932
|
+
const t = props.t;
|
|
933
|
+
const count = snapshot.count;
|
|
934
|
+
const wide = props.wide !== false;
|
|
935
|
+
const label = count > 0 ? t("unreadPanelCount").replace("{n}", String(count)) : t("unreadPanelNone");
|
|
936
|
+
const onClick = () => {
|
|
937
|
+
const node = ref.current;
|
|
938
|
+
const rect = node !== null && typeof node.getBoundingClientRect === "function" ? node.getBoundingClientRect() : null;
|
|
939
|
+
toggleUnreadPanel(rect === null ? null : { left: rect.left, top: rect.top });
|
|
940
|
+
};
|
|
941
|
+
return React.createElement("button", {
|
|
942
|
+
ref, type: "button", onClick, title: label, "aria-label": label, "aria-expanded": open,
|
|
943
|
+
style: {
|
|
944
|
+
position: "relative", display: "inline-flex", alignItems: "center", justifyContent: "center", gap: "6px",
|
|
945
|
+
height: "32px", width: wide ? "auto" : "32px", padding: wide ? "0 10px" : "0",
|
|
946
|
+
border: "1px solid var(--dsw-alias-border-l2)", borderRadius: "8px", background: "transparent",
|
|
947
|
+
color: "var(--dsw-alias-label-primary)", cursor: "pointer", font: "inherit", fontSize: "12px",
|
|
948
|
+
opacity: count > 0 ? 1 : 0.55
|
|
949
|
+
}
|
|
950
|
+
},
|
|
951
|
+
React.createElement("svg", { width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", "aria-hidden": "true", style: { flex: "none" } },
|
|
952
|
+
React.createElement("path", { d: "M8 2.2a3.6 3.6 0 0 0-3.6 3.6v2.4L3.2 11h9.6l-1.2-2.8V5.8A3.6 3.6 0 0 0 8 2.2Z", stroke: "currentColor", strokeWidth: 1.3, strokeLinejoin: "round" }),
|
|
953
|
+
React.createElement("path", { d: "M6.4 12.8a1.6 1.6 0 0 0 3.2 0", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round" })
|
|
954
|
+
),
|
|
955
|
+
wide ? React.createElement("span", null, t("unreadPanelShort")) : null,
|
|
956
|
+
count > 0 ? React.createElement("span", {
|
|
957
|
+
"aria-hidden": "true",
|
|
958
|
+
style: { position: "absolute", top: "-6px", right: "-6px", minWidth: "16px", height: "16px", padding: "0 4px", boxSizing: "border-box", borderRadius: "999px", background: "#e5484d", color: "#fff", fontSize: "10px", fontWeight: 700, lineHeight: 1, display: "flex", alignItems: "center", justifyContent: "center", fontVariantNumeric: "tabular-nums", pointerEvents: "none" }
|
|
959
|
+
}, badgeLabel(count)) : null
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The list behind that number, in the frame-wide overlay layer so it escapes
|
|
965
|
+
* the sidebar's clipping and scroll container. Clicking a row opens that
|
|
966
|
+
* session — which is also what marks it read.
|
|
967
|
+
*/
|
|
968
|
+
function UnreadPopup(props) {
|
|
969
|
+
const [snapshot, setSnapshot] = React.useState(badgeSnapshot);
|
|
970
|
+
const [open, setOpen] = React.useState(unreadPanelOpen);
|
|
971
|
+
React.useEffect(() => subscribeBadge(setSnapshot), []);
|
|
972
|
+
React.useEffect(() => subscribeUnreadPanel(() => setOpen(unreadPanelOpen)), []);
|
|
973
|
+
React.useEffect(() => {
|
|
974
|
+
if (!open) return undefined;
|
|
975
|
+
const onKey = (event) => { if (event.key === "Escape") emitUnreadPanel(false); };
|
|
976
|
+
const onDown = (event) => {
|
|
977
|
+
const node = event.target;
|
|
978
|
+
if (node !== null && typeof node.closest === "function" && node.closest('[data-icon-custom-unread="1"]') !== null) return;
|
|
979
|
+
emitUnreadPanel(false);
|
|
980
|
+
};
|
|
981
|
+
window.addEventListener("keydown", onKey);
|
|
982
|
+
document.addEventListener("mousedown", onDown, true);
|
|
983
|
+
return () => {
|
|
984
|
+
window.removeEventListener("keydown", onKey);
|
|
985
|
+
document.removeEventListener("mousedown", onDown, true);
|
|
986
|
+
};
|
|
987
|
+
}, [open]);
|
|
988
|
+
if (!open) return null;
|
|
989
|
+
const t = props.t;
|
|
990
|
+
const anchor = unreadPanelAnchor;
|
|
991
|
+
const style = {
|
|
992
|
+
position: "fixed", left: "12px", bottom: "52px", zIndex: 60, pointerEvents: "auto",
|
|
993
|
+
minWidth: "240px", maxWidth: "340px", padding: "8px", borderRadius: "12px",
|
|
994
|
+
border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-1)",
|
|
995
|
+
boxShadow: "0 10px 30px rgba(0,0,0,.18)", color: "var(--dsw-alias-label-primary)", fontSize: "12px"
|
|
996
|
+
};
|
|
997
|
+
if (anchor !== null && typeof anchor === "object") {
|
|
998
|
+
style.left = Math.max(8, Math.round(anchor.left)) + "px";
|
|
999
|
+
style.bottom = Math.max(8, Math.round(window.innerHeight - anchor.top + 6)) + "px";
|
|
1000
|
+
}
|
|
1001
|
+
const items = Array.isArray(snapshot.items) ? snapshot.items : [];
|
|
1002
|
+
return React.createElement("div", { "data-icon-custom-unread": "1", style },
|
|
1003
|
+
React.createElement("div", { style: { fontWeight: 600, padding: "2px 6px 8px" } }, t("unreadPanelTitle")),
|
|
1004
|
+
items.length === 0
|
|
1005
|
+
? React.createElement("div", { style: { padding: "2px 6px 8px", opacity: 0.7 } }, t("unreadPanelEmpty"))
|
|
1006
|
+
: React.createElement("div", { style: { display: "flex", flexDirection: "column", maxHeight: "320px", overflowY: "auto" } },
|
|
1007
|
+
groupUnreadByWorkspace(items).map((group) => React.createElement("div", { key: group.where === "" ? "\u0000none" : group.where },
|
|
1008
|
+
React.createElement("div", { style: { padding: "7px 8px 3px", fontSize: "11px", fontWeight: 600, opacity: 0.55, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } },
|
|
1009
|
+
group.where === "" ? t("unreadPanelOther") : group.where
|
|
1010
|
+
),
|
|
1011
|
+
group.items.map((item) => React.createElement("button", {
|
|
1012
|
+
key: item.id, type: "button",
|
|
1013
|
+
onClick: () => { emitUnreadPanel(false); props.onOpen(item.id); },
|
|
1014
|
+
style: { display: "block", width: "100%", textAlign: "left", padding: "6px 8px", border: "0", borderRadius: "8px", background: "transparent", color: "inherit", font: "inherit", cursor: "pointer" }
|
|
1015
|
+
},
|
|
1016
|
+
React.createElement("span", { style: { display: "block", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }, item.title),
|
|
1017
|
+
React.createElement("span", { style: { display: "block", opacity: 0.6, fontSize: "11px" } }, item.waiting === true ? t("unreadWaiting") : t("unreadEnded"))
|
|
1018
|
+
))
|
|
1019
|
+
))
|
|
1020
|
+
)
|
|
1021
|
+
);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
137
1024
|
/**
|
|
138
1025
|
* Sidebar brand-mark occupant (registered on the official
|
|
139
1026
|
* `sidebar.brand.mark` single seat). Shows the custom icon when the
|
|
@@ -143,26 +1030,62 @@ window.__ModuleLoader__.load({
|
|
|
143
1030
|
*/
|
|
144
1031
|
function BrandMark(props) {
|
|
145
1032
|
const [state, setState] = React.useState(null);
|
|
1033
|
+
const [badge, setBadge] = React.useState(badgeSnapshot);
|
|
146
1034
|
React.useEffect(() => {
|
|
147
1035
|
const unsubscribe = subscribeLogo(setState);
|
|
148
1036
|
// Pull the current state on mount too, so the mark is correct
|
|
149
1037
|
// even when the settings panel was never opened this session.
|
|
150
1038
|
props.rpc.call("/api", "iconCustom/getStatus", { args: {} })
|
|
151
|
-
.then((resp) => { if (resp && resp.ok === true)
|
|
1039
|
+
.then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
|
|
152
1040
|
.catch(() => {});
|
|
153
1041
|
return unsubscribe;
|
|
154
1042
|
}, [props.rpc]);
|
|
1043
|
+
React.useEffect(() => subscribeBadge(setBadge), []);
|
|
155
1044
|
const size = (props && props.size) || 24;
|
|
156
1045
|
const custom = state && state.enabled === true && state.rev;
|
|
157
1046
|
const style = { width: size, height: size, objectFit: "contain", display: "block" };
|
|
158
1047
|
const src = custom
|
|
159
1048
|
? (state.png === true ? `/icon-custom-192.png?v=${state.rev}` : `/icon-custom.svg?v=${state.rev}`)
|
|
160
1049
|
: "/favicon.svg";
|
|
161
|
-
|
|
1050
|
+
const img = React.createElement("img", {
|
|
162
1051
|
src,
|
|
163
1052
|
alt: "",
|
|
164
1053
|
style
|
|
165
1054
|
});
|
|
1055
|
+
// No badge: keep the exact original element (no wrapper, no layout risk).
|
|
1056
|
+
if (badge.count <= 0) return img;
|
|
1057
|
+
const edge = Math.max(12, Math.round(size * 0.5 * badgeScale(badge.size)));
|
|
1058
|
+
const label = `${badgeLabel(badge.count)} pending (test)`;
|
|
1059
|
+
return React.createElement("span", {
|
|
1060
|
+
style: { position: "relative", display: "inline-flex", width: size, height: size, flex: "none" }
|
|
1061
|
+
},
|
|
1062
|
+
img,
|
|
1063
|
+
React.createElement("span", {
|
|
1064
|
+
title: label,
|
|
1065
|
+
"aria-label": label,
|
|
1066
|
+
style: {
|
|
1067
|
+
position: "absolute",
|
|
1068
|
+
top: 0,
|
|
1069
|
+
right: 0,
|
|
1070
|
+
minWidth: edge,
|
|
1071
|
+
height: edge,
|
|
1072
|
+
padding: "0 2px",
|
|
1073
|
+
boxSizing: "border-box",
|
|
1074
|
+
borderRadius: 999,
|
|
1075
|
+
background: "#e5484d",
|
|
1076
|
+
color: "#fff",
|
|
1077
|
+
border: "1.5px solid var(--dsw-alias-bg-layer-1, #fff)",
|
|
1078
|
+
fontSize: Math.max(8, Math.round(edge * 0.62)),
|
|
1079
|
+
fontWeight: 700,
|
|
1080
|
+
lineHeight: 1,
|
|
1081
|
+
display: "flex",
|
|
1082
|
+
alignItems: "center",
|
|
1083
|
+
justifyContent: "center",
|
|
1084
|
+
fontVariantNumeric: "tabular-nums",
|
|
1085
|
+
pointerEvents: "none"
|
|
1086
|
+
}
|
|
1087
|
+
}, badgeLabel(badge.count))
|
|
1088
|
+
);
|
|
166
1089
|
}
|
|
167
1090
|
|
|
168
1091
|
function FaviconSection(props) {
|
|
@@ -174,6 +1097,11 @@ window.__ModuleLoader__.load({
|
|
|
174
1097
|
const [busy, setBusy] = React.useState(false);
|
|
175
1098
|
const [error, setError] = React.useState("");
|
|
176
1099
|
const [notice, setNotice] = React.useState("");
|
|
1100
|
+
const [badgeInput, setBadgeInput] = React.useState(() => (manualCount > 0 ? String(manualCount) : ""));
|
|
1101
|
+
const [badgeSizeValue, setBadgeSizeValue] = React.useState(badgeSize);
|
|
1102
|
+
const [badgeSourceValue, setBadgeSourceValue] = React.useState(badgeSource);
|
|
1103
|
+
const [unread, setUnread] = React.useState(unreadConfig);
|
|
1104
|
+
React.useEffect(() => subscribeUnreadConfig(setUnread), []);
|
|
177
1105
|
const inputRef = React.useRef(null);
|
|
178
1106
|
|
|
179
1107
|
const load = React.useCallback(async () => {
|
|
@@ -183,7 +1111,7 @@ window.__ModuleLoader__.load({
|
|
|
183
1111
|
setStatus(resp.value);
|
|
184
1112
|
setPwaEnabled(resp.value.pwa === true);
|
|
185
1113
|
setLogoEnabled(resp.value.logo === true);
|
|
186
|
-
|
|
1114
|
+
syncStatus(resp.value);
|
|
187
1115
|
} else setError(reason(resp, t("fail")));
|
|
188
1116
|
} catch (e) {
|
|
189
1117
|
setError(String((e && e.message) || e || t("fail")));
|
|
@@ -211,6 +1139,8 @@ window.__ModuleLoader__.load({
|
|
|
211
1139
|
el.href = href;
|
|
212
1140
|
document.head.appendChild(el);
|
|
213
1141
|
}
|
|
1142
|
+
// The base icon just changed: rebuild the composite from it.
|
|
1143
|
+
refreshFaviconBase();
|
|
214
1144
|
}, []);
|
|
215
1145
|
|
|
216
1146
|
// When cleared, point the icon back at the platform default.
|
|
@@ -220,10 +1150,62 @@ window.__ModuleLoader__.load({
|
|
|
220
1150
|
link.setAttribute("href", "/favicon.svg");
|
|
221
1151
|
link.setAttribute("type", "image/svg+xml");
|
|
222
1152
|
}
|
|
1153
|
+
refreshFaviconBase();
|
|
223
1154
|
}, []);
|
|
224
1155
|
|
|
225
1156
|
const pick = React.useCallback(() => inputRef.current && inputRef.current.click(), []);
|
|
226
1157
|
|
|
1158
|
+
// Test-only: type a number to push it into the badge bridge. Empty or 0
|
|
1159
|
+
// clears the badge. Not persisted — a reload starts from 0 again.
|
|
1160
|
+
const onBadgeInput = React.useCallback((event) => {
|
|
1161
|
+
const raw = String(event.target.value || "").replace(/[^0-9]/g, "");
|
|
1162
|
+
setBadgeInput(raw);
|
|
1163
|
+
emitBadge(raw === "" ? 0 : Math.min(999, parseInt(raw, 10) || 0));
|
|
1164
|
+
}, []);
|
|
1165
|
+
const onBadgeSize = React.useCallback((value) => {
|
|
1166
|
+
setBadgeSizeValue(value);
|
|
1167
|
+
emitBadgeSize(value);
|
|
1168
|
+
}, []);
|
|
1169
|
+
// "real" = DSH's pending-interaction count; "manual" = the input below.
|
|
1170
|
+
const onBadgeSource = React.useCallback((value) => {
|
|
1171
|
+
setBadgeSourceValue(value);
|
|
1172
|
+
emitBadgeSource(value);
|
|
1173
|
+
}, []);
|
|
1174
|
+
// Toggle one unread reason. Applies locally at once; the host keeps the
|
|
1175
|
+
// durable copy so the rule survives a restart and other browsers.
|
|
1176
|
+
const onUnreadToggle = React.useCallback((key, checked) => {
|
|
1177
|
+
const current = unreadConfig;
|
|
1178
|
+
const reasons = Object.assign({}, current.reasons);
|
|
1179
|
+
let pending = current.pending;
|
|
1180
|
+
if (key === "pending") pending = checked;
|
|
1181
|
+
else reasons[key] = checked;
|
|
1182
|
+
const next = normalizeUnreadConfig({ reasons, pending, workspaceDot: current.workspaceDot });
|
|
1183
|
+
emitUnreadConfig(next);
|
|
1184
|
+
rpc.call("/api", "iconCustom/setUnreadRule", {
|
|
1185
|
+
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
|
|
1186
|
+
}).catch(() => {});
|
|
1187
|
+
}, [rpc]);
|
|
1188
|
+
// The fuse for the one fragile piece (see the workspace-dots region).
|
|
1189
|
+
const onWorkspaceDotToggle = React.useCallback((checked) => {
|
|
1190
|
+
const next = normalizeUnreadConfig({ reasons: unreadConfig.reasons, pending: unreadConfig.pending, workspaceDot: checked });
|
|
1191
|
+
emitUnreadConfig(next);
|
|
1192
|
+
rpc.call("/api", "iconCustom/setUnreadRule", {
|
|
1193
|
+
args: { request: { reasons: next.reasons, pending: next.pending, workspaceDot: next.workspaceDot } }
|
|
1194
|
+
}).catch(() => {});
|
|
1195
|
+
}, [rpc]);
|
|
1196
|
+
/** One unread checkbox; `key === "pending"` is the "someone is waiting" row. */
|
|
1197
|
+
const renderReasonCheckbox = (key, labelKey) => React.createElement("label", {
|
|
1198
|
+
key,
|
|
1199
|
+
style: { display: "inline-flex", alignItems: "center", gap: "6px", marginRight: "14px", marginTop: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" }
|
|
1200
|
+
},
|
|
1201
|
+
React.createElement("input", {
|
|
1202
|
+
type: "checkbox",
|
|
1203
|
+
checked: key === "pending" ? unread.pending === true : unread.reasons[key] === true,
|
|
1204
|
+
onChange: (event) => onUnreadToggle(key, event.target.checked === true)
|
|
1205
|
+
}),
|
|
1206
|
+
React.createElement("span", null, t(labelKey))
|
|
1207
|
+
);
|
|
1208
|
+
|
|
227
1209
|
const onFileChange = React.useCallback(async (event) => {
|
|
228
1210
|
const file = event.target.files && event.target.files[0];
|
|
229
1211
|
event.target.value = "";
|
|
@@ -264,7 +1246,7 @@ window.__ModuleLoader__.load({
|
|
|
264
1246
|
setLogoEnabled(resp.value.logo === true);
|
|
265
1247
|
setNotice(t("successUpload"));
|
|
266
1248
|
applyLive(resp.value);
|
|
267
|
-
|
|
1249
|
+
syncStatus(resp.value);
|
|
268
1250
|
} else {
|
|
269
1251
|
setError(reason(resp, t("fail")));
|
|
270
1252
|
}
|
|
@@ -290,7 +1272,7 @@ window.__ModuleLoader__.load({
|
|
|
290
1272
|
setLogoEnabled(false);
|
|
291
1273
|
setNotice(t("successReset"));
|
|
292
1274
|
applyDefault();
|
|
293
|
-
|
|
1275
|
+
syncStatus(resp.value);
|
|
294
1276
|
} else {
|
|
295
1277
|
setError(reason(resp, t("fail")));
|
|
296
1278
|
}
|
|
@@ -340,7 +1322,7 @@ window.__ModuleLoader__.load({
|
|
|
340
1322
|
setStatus(resp.value);
|
|
341
1323
|
setLogoEnabled(resp.value.logo === true);
|
|
342
1324
|
setNotice(t("successLogo"));
|
|
343
|
-
|
|
1325
|
+
syncStatus(resp.value);
|
|
344
1326
|
} else {
|
|
345
1327
|
setLogoEnabled(!enabled);
|
|
346
1328
|
setError(reason(resp, t("fail")));
|
|
@@ -404,17 +1386,95 @@ window.__ModuleLoader__.load({
|
|
|
404
1386
|
React.createElement("div", { style: style.hint }, t("logoHint")),
|
|
405
1387
|
React.createElement("div", { style: style.hint }, t("uploadHint")),
|
|
406
1388
|
React.createElement("div", { style: style.hint }, t("resetHint")),
|
|
1389
|
+
React.createElement("div", { style: { borderTop: "1px solid var(--dsw-alias-border-l2)", marginTop: "12px", paddingTop: "12px" } },
|
|
1390
|
+
React.createElement("div", { style: style.label }, t("badgeTest")),
|
|
1391
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "6px" } },
|
|
1392
|
+
React.createElement("span", { style: style.desc }, t("badgeSourceLabel")),
|
|
1393
|
+
["real", "manual"].map((key) => React.createElement("button", {
|
|
1394
|
+
key,
|
|
1395
|
+
type: "button",
|
|
1396
|
+
onClick: () => onBadgeSource(key),
|
|
1397
|
+
"aria-pressed": badgeSourceValue === key,
|
|
1398
|
+
style: { ...(badgeSourceValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
|
|
1399
|
+
}, t(key === "real" ? "badgeSourceReal" : "badgeSourceManual")))
|
|
1400
|
+
),
|
|
1401
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "10px" } },
|
|
1402
|
+
React.createElement("input", {
|
|
1403
|
+
type: "number",
|
|
1404
|
+
min: 0,
|
|
1405
|
+
max: 999,
|
|
1406
|
+
value: badgeInput,
|
|
1407
|
+
onChange: onBadgeInput,
|
|
1408
|
+
disabled: badgeSourceValue !== "manual",
|
|
1409
|
+
"aria-label": t("badgeTest"),
|
|
1410
|
+
style: { width: 88, padding: "5px 8px", fontSize: 12, borderRadius: 8, border: "1px solid var(--dsw-alias-border-l2)", background: "var(--dsw-alias-bg-layer-2)", color: "var(--dsw-alias-label-primary)", opacity: badgeSourceValue === "manual" ? 1 : 0.45, cursor: badgeSourceValue === "manual" ? "text" : "not-allowed" }
|
|
1411
|
+
}),
|
|
1412
|
+
React.createElement("span", { style: style.desc }, t("badgeTestUnit"))
|
|
1413
|
+
),
|
|
1414
|
+
React.createElement("div", { style: { display: "flex", alignItems: "center", gap: "8px", marginTop: "10px" } },
|
|
1415
|
+
React.createElement("span", { style: style.desc }, t("badgeSizeLabel")),
|
|
1416
|
+
["sm", "md", "lg"].map((key) => React.createElement("button", {
|
|
1417
|
+
key,
|
|
1418
|
+
type: "button",
|
|
1419
|
+
onClick: () => onBadgeSize(key),
|
|
1420
|
+
"aria-pressed": badgeSizeValue === key,
|
|
1421
|
+
style: { ...(badgeSizeValue === key ? style.btnPrimary : style.btn), padding: "4px 10px" }
|
|
1422
|
+
}, t(key === "sm" ? "badgeSizeSmall" : key === "md" ? "badgeSizeMedium" : "badgeSizeLarge")))
|
|
1423
|
+
),
|
|
1424
|
+
React.createElement("div", { style: { marginTop: "12px" } },
|
|
1425
|
+
React.createElement("div", { style: style.desc }, t("unreadReasonsLabel")),
|
|
1426
|
+
React.createElement("div", { style: { display: "flex", flexWrap: "wrap", alignItems: "center" } },
|
|
1427
|
+
["completed", "error", "blocked", "max-tokens", "interrupted", "aborted:user", "aborted:other"].map((key) => renderReasonCheckbox(key, REASON_LABEL_KEYS[key])),
|
|
1428
|
+
renderReasonCheckbox("pending", "reasonPending")
|
|
1429
|
+
)
|
|
1430
|
+
),
|
|
1431
|
+
React.createElement("div", { style: { marginTop: "10px" } },
|
|
1432
|
+
React.createElement("label", { style: { display: "inline-flex", alignItems: "center", gap: "6px", fontSize: 12, color: "var(--dsw-alias-label-primary)", cursor: "pointer" } },
|
|
1433
|
+
React.createElement("input", {
|
|
1434
|
+
type: "checkbox",
|
|
1435
|
+
checked: unread.workspaceDot !== false,
|
|
1436
|
+
onChange: (event) => onWorkspaceDotToggle(event.target.checked === true)
|
|
1437
|
+
}),
|
|
1438
|
+
React.createElement("span", null, t("workspaceDotLabel"))
|
|
1439
|
+
),
|
|
1440
|
+
React.createElement("div", { style: style.hint }, t("workspaceDotHint"))
|
|
1441
|
+
),
|
|
1442
|
+
React.createElement("div", { style: style.hint }, t("badgeTestHint"))
|
|
1443
|
+
),
|
|
407
1444
|
notice ? React.createElement("div", { style: style.notice }, notice) : null,
|
|
408
1445
|
error ? React.createElement("div", { style: style.error }, error) : null
|
|
409
1446
|
);
|
|
410
1447
|
}
|
|
411
1448
|
//#endregion
|
|
412
1449
|
|
|
413
|
-
const inject = ["slots", "locale", "connection"];
|
|
1450
|
+
const inject = ["slots", "locale", "connection", "sessions"];
|
|
414
1451
|
function apply(ctx) {
|
|
415
1452
|
ctx.effect(() => ctx.locale.register(NS, { zh, en }), "dsh-icon-custom: dictionaries");
|
|
416
1453
|
const t = ctx.locale.bind(NS);
|
|
417
1454
|
const rpc = ctx.connection.rpc;
|
|
1455
|
+
// Pull the host state once, so the brand mark and the badge are correct
|
|
1456
|
+
// before the settings section is ever opened. The unread rule rides its
|
|
1457
|
+
// own endpoint: the icon RPCs keep their pre-badge response shape.
|
|
1458
|
+
rpc.call("/api", "iconCustom/getStatus", { args: {} })
|
|
1459
|
+
.then((resp) => { if (resp && resp.ok === true) syncStatus(resp.value); })
|
|
1460
|
+
.catch(() => {});
|
|
1461
|
+
rpc.call("/api", "iconCustom/getUnreadRule", { args: {} })
|
|
1462
|
+
.then((resp) => { if (resp && resp.ok === true) emitUnreadConfig(resp.value); })
|
|
1463
|
+
.catch(() => {});
|
|
1464
|
+
// Keep "the last moment this browser was here" fresh, so an ending that
|
|
1465
|
+
// happens while the app is closed still counts as unread when you return.
|
|
1466
|
+
ctx.effect(() => {
|
|
1467
|
+
const touch = () => { seenState.lastActiveAt = Date.now(); saveSeenState(); };
|
|
1468
|
+
const interval = window.setInterval(touch, 20000);
|
|
1469
|
+
window.addEventListener("visibilitychange", touch);
|
|
1470
|
+
window.addEventListener("beforeunload", touch);
|
|
1471
|
+
return () => {
|
|
1472
|
+
window.clearInterval(interval);
|
|
1473
|
+
window.removeEventListener("visibilitychange", touch);
|
|
1474
|
+
window.removeEventListener("beforeunload", touch);
|
|
1475
|
+
touch();
|
|
1476
|
+
};
|
|
1477
|
+
}, "dsh-icon-custom: unread watermark");
|
|
418
1478
|
// Brand-mark seat: replaces only the whale mark (verified: a
|
|
419
1479
|
// third-party registration wins over the official occupant and the
|
|
420
1480
|
// official package's mark steps aside). The brand NAME text is a
|
|
@@ -431,6 +1491,111 @@ window.__ModuleLoader__.load({
|
|
|
431
1491
|
locale: NS,
|
|
432
1492
|
inject: () => ({ t })
|
|
433
1493
|
}, (props) => React.createElement(FaviconSection, { ...props, t, rpc })));
|
|
1494
|
+
|
|
1495
|
+
// Additive seats for "where exactly?" — a foot entry beside the shipped
|
|
1496
|
+
// Settings/Cordis buttons, and the frame-wide overlay for its list. A fresh
|
|
1497
|
+
// id is added beside the others; nothing shipped is shadowed.
|
|
1498
|
+
const openSession = (id) => {
|
|
1499
|
+
try { ctx.sessions.open(id); } catch { /* navigation is best effort */ }
|
|
1500
|
+
};
|
|
1501
|
+
ctx.slots.inject("sidebar.footer.action", () => ctx.slots.register(
|
|
1502
|
+
{ name: "sidebar.footer.action", id: "icon-custom-unread", order: 20, label: () => t("unreadPanelShort") },
|
|
1503
|
+
(props) => React.createElement(UnreadFooterButton, { ...props, t, onOpen: openSession })
|
|
1504
|
+
));
|
|
1505
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
1506
|
+
{ name: "shell.overlay", id: "icon-custom-unread-popup", order: 30 },
|
|
1507
|
+
() => React.createElement(UnreadPopup, { t, onOpen: openSession })
|
|
1508
|
+
));
|
|
1509
|
+
|
|
1510
|
+
// Real number source: a headless occupant of the frame-wide overlay
|
|
1511
|
+
// layer reads DSH's official pending-interaction registry and pushes it
|
|
1512
|
+
// into the badge bridge. No plugin is addressed by name.
|
|
1513
|
+
ctx.slots.inject("shell.overlay", () => ctx.slots.register(
|
|
1514
|
+
{ name: "shell.overlay", id: "icon-custom-badge-source" },
|
|
1515
|
+
(props) => React.createElement(BadgeSource, props)
|
|
1516
|
+
));
|
|
1517
|
+
|
|
1518
|
+
// Test-only badge wiring: the settings input drives the in-package
|
|
1519
|
+
// bridge, the brand mark reads it through its own subscription, and
|
|
1520
|
+
// this effect keeps the tab icon in sync. Teardown restores the
|
|
1521
|
+
// pristine platform/custom icon.
|
|
1522
|
+
ctx.effect(() => {
|
|
1523
|
+
const unsubscribe = subscribeBadge((snapshot) => { applyBadgeToFavicon(snapshot.count, snapshot.size); });
|
|
1524
|
+
// Level-triggered on purpose: (re)applying this half must adopt the
|
|
1525
|
+
// value that is already on the bridge. The brand mark reads it at
|
|
1526
|
+
// mount; the tab icon has to be told, or a plugin hot-reload would
|
|
1527
|
+
// leave it restored (the old effect's cleanup) until the next change.
|
|
1528
|
+
applyBadgeToFavicon(effectiveCount(), badgeSize);
|
|
1529
|
+
return () => {
|
|
1530
|
+
unsubscribe();
|
|
1531
|
+
applyBadgeToFavicon(0);
|
|
1532
|
+
};
|
|
1533
|
+
}, "dsh-icon-custom: favicon badge");
|
|
1534
|
+
// Watchdog. The tab icon is the one surface other parties can disturb
|
|
1535
|
+
// (cached HTML, browser extensions, tab moves, plugin reloads), so keep
|
|
1536
|
+
// re-asserting the composite instead of trusting one-shot application.
|
|
1537
|
+
ctx.effect(() => {
|
|
1538
|
+
let queued = false;
|
|
1539
|
+
const schedule = () => {
|
|
1540
|
+
if (queued) return;
|
|
1541
|
+
queued = true;
|
|
1542
|
+
window.setTimeout(() => { queued = false; reconcileFavicon(); }, 150);
|
|
1543
|
+
};
|
|
1544
|
+
const observer = new MutationObserver(schedule);
|
|
1545
|
+
observer.observe(document.head, { childList: true, subtree: true, attributes: true, attributeFilter: ["href", "rel"] });
|
|
1546
|
+
window.addEventListener("visibilitychange", schedule);
|
|
1547
|
+
window.addEventListener("focus", schedule);
|
|
1548
|
+
const interval = window.setInterval(reconcileFavicon, 5000);
|
|
1549
|
+
return () => {
|
|
1550
|
+
observer.disconnect();
|
|
1551
|
+
window.removeEventListener("visibilitychange", schedule);
|
|
1552
|
+
window.removeEventListener("focus", schedule);
|
|
1553
|
+
window.clearInterval(interval);
|
|
1554
|
+
};
|
|
1555
|
+
}, "dsh-icon-custom: favicon watchdog");
|
|
1556
|
+
// Workspace dots: the one fragile piece. It waits for the sidebar, keeps a
|
|
1557
|
+
// marker on each workspace row whose folder icon it can find, and gives up
|
|
1558
|
+
// on its own after repeated failures. It lives entirely outside React, so a
|
|
1559
|
+
// throw here can never reach anyone's render tree.
|
|
1560
|
+
ctx.effect(() => {
|
|
1561
|
+
let observer = null;
|
|
1562
|
+
let retry = 0;
|
|
1563
|
+
let queued = false;
|
|
1564
|
+
const schedule = () => {
|
|
1565
|
+
if (queued) return;
|
|
1566
|
+
queued = true;
|
|
1567
|
+
window.setTimeout(() => { queued = false; paintWorkspaceDots(); }, 200);
|
|
1568
|
+
};
|
|
1569
|
+
const attach = () => {
|
|
1570
|
+
const container = workspaceDotContainer();
|
|
1571
|
+
if (container === null) return false;
|
|
1572
|
+
observer = new MutationObserver(() => {
|
|
1573
|
+
if (workspaceDotPainting) return;
|
|
1574
|
+
// Ignore only the settle window right after our own paint. Anything
|
|
1575
|
+
// later — including the shipped UI re-rendering a row and taking our
|
|
1576
|
+
// marker with it — must trigger a reconcile, or the dot stays gone.
|
|
1577
|
+
if (Date.now() - workspaceDotPaintedAt < 250) return;
|
|
1578
|
+
schedule();
|
|
1579
|
+
});
|
|
1580
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
1581
|
+
schedule();
|
|
1582
|
+
return true;
|
|
1583
|
+
};
|
|
1584
|
+
if (!attach()) retry = window.setInterval(() => { if (attach()) window.clearInterval(retry); }, 1000);
|
|
1585
|
+
const offDots = subscribeSidebarMarks(schedule);
|
|
1586
|
+
const offConfig = subscribeUnreadConfig(schedule);
|
|
1587
|
+
return () => {
|
|
1588
|
+
if (retry !== 0) window.clearInterval(retry);
|
|
1589
|
+
if (workspaceDotVerify !== 0) { window.clearTimeout(workspaceDotVerify); workspaceDotVerify = 0; }
|
|
1590
|
+
if (observer !== null) observer.disconnect();
|
|
1591
|
+
offDots();
|
|
1592
|
+
offConfig();
|
|
1593
|
+
clearWorkspaceDots();
|
|
1594
|
+
};
|
|
1595
|
+
}, "dsh-icon-custom: workspace dots");
|
|
1596
|
+
// The platform favicon.svg repaints itself for dark mode through
|
|
1597
|
+
// prefers-color-scheme; a composited bitmap cannot, so rebuild it.
|
|
1598
|
+
ctx.on("theme/change", () => { applyBadgeToFavicon(effectiveCount(), badgeSize); });
|
|
434
1599
|
}
|
|
435
1600
|
exports.apply = apply;
|
|
436
1601
|
exports.inject = inject;
|