dsh-panel-control 0.0.0-stage → 0.3.13

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/client.js ADDED
@@ -0,0 +1,2092 @@
1
+ /**
2
+ * Client half of dsh-panel-control.
3
+ *
4
+ * Adds a manager for the sidebar's global panel navigation — the `sidebar.panellist`
5
+ * list between the "New session" button and the workspaces region.
6
+ *
7
+ * The list is owned by `@deepseek-ai/dsh-client-ui-sidebar`: every row is a
8
+ * registration another plugin made, and the sidebar renders them sorted by the
9
+ * `order` it was registered with. No public API rewrites someone else's
10
+ * registration, so this plugin keeps its own order/hide/group preferences and
11
+ * applies them to the live navigation list:
12
+ *
13
+ * - `order` is a flex ordering value, so rows move without touching React's
14
+ * children (the sidebar re-renders a row's className, never its position);
15
+ * - `display: none` hides a row;
16
+ * - group headings are our own nodes, re-created on every pass, so a React
17
+ * re-render can never leave a stale heading behind.
18
+ *
19
+ * Everything is rediscovered on each pass from `ctx.slots.entriesOfSlot(...)`
20
+ * plus the live DOM, which keeps a plugin reload, a new registration or a
21
+ * collapsed sidebar from leaving the list in a wrong state.
22
+ */
23
+
24
+ function build(require) {
25
+ const React = require('react');
26
+ const h = React.createElement;
27
+
28
+ const PACKAGE = 'dsh-panel-control';
29
+ const NS = 'dsh-panel-control';
30
+ const SLOT_PANELS = 'sidebar.panellist';
31
+ const SLOT_MAIN = 'main';
32
+ const SLOT_SETTINGS = 'settings.section';
33
+ const PANEL_KEY = 'panel-control';
34
+ const STORAGE_KEY = 'dsh.panel-control.v1';
35
+ /** Host route that answers "file these entries into groups" (see index.js). */
36
+ const RPC_CHANNEL = '/api';
37
+ const RPC_ENDPOINT = 'dsh-panel-control/classify';
38
+ /** Rows start at 100 so an unmanaged flex sibling stays at the very end. */
39
+ const ORDER_START = 100;
40
+ const ORDER_STEP = 10;
41
+ /** Set right before a reload so the next mount can say "still missing". */
42
+ const RELOAD_MARK = 'dsh.panel-control.reload-at';
43
+ const NOTICE_SKIP = 'dsh.panel-control.notice-skip';
44
+ /**
45
+ * The gap this page already told the reader about.
46
+ *
47
+ * A page reload does not always fix the gap: when the app hands every page the
48
+ * list it froze at launch, the same plugin stays absent until DSH itself
49
+ * restarts, and a banner that reappears on every load is then pure noise. The
50
+ * set is therefore remembered across loads (a reload keeps `localStorage`),
51
+ * shown once, and afterwards reported only in the diagnostics list.
52
+ */
53
+ const NOTICE_NAGGED = 'dsh.panel-control.notice-nagged';
54
+ // A reload hint that never expires is a lie: it outlives the mismatch it was
55
+ // written for. Ten minutes covers a slow restart, not a whole workday.
56
+ const RELOAD_MARK_TTL = 10 * 60 * 1000;
57
+ /** Where unmanaged rows and the "More" launcher are pushed to. */
58
+ const ORDER_UNMANAGED = 9000;
59
+ /** Below this width the sidebar renders the icon rail, where headings do not fit. */
60
+ const WIDE_MIN_WIDTH = 64;
61
+ const ORDER_UNMANAGED_TAIL = ORDER_UNMANAGED - 500;
62
+ const SAVE_DEBOUNCE_MS = 300;
63
+ const AUTO_DELAY_MS = 1500;
64
+ const AUTO_RETRY_MS = 60000;
65
+
66
+ /* ------------------------------------------------------------------ text */
67
+
68
+ const TEXT = {
69
+ panelTitle: { zh: '面板管理', en: 'Panel manager' },
70
+ subtitle: {
71
+ zh: '排序、隐藏、分组侧栏顶部的全局面板入口。',
72
+ en: 'Reorder, hide and group the global panel entries at the top of the sidebar.',
73
+ },
74
+ sortByName: { zh: '按名称排序', en: 'Sort by name' },
75
+ reset: { zh: '恢复默认', en: 'Reset' },
76
+ showAll: { zh: '全部显示', en: 'Show all' },
77
+ hideIt: { zh: '隐藏', en: 'Hide' },
78
+ showIt: { zh: '显示', en: 'Show' },
79
+ hiddenChip: { zh: '已隐藏', en: 'hidden' },
80
+ groupLabel: { zh: '分组', en: 'Group' },
81
+ ungrouped: { zh: '未分组', en: 'Ungrouped' },
82
+ groupsTitle: { zh: '分组', en: 'Groups' },
83
+ groupsHint: {
84
+ zh: '分组会在侧栏导航区里显示成带标题的分隔线,同一组的入口会被排在一起;这里的上下顺序就是侧栏里的顺序,拖动行可以调整。',
85
+ en: 'A group renders as a titled separator in the sidebar and keeps its entries together. The order here is the order in the sidebar — drag a row to change it.',
86
+ },
87
+ newGroup: { zh: '新建分组', en: 'New group' },
88
+ newGroupName: { zh: '新分组', en: 'New group' },
89
+ noGroups: { zh: '还没有分组。新建一个分组,再把它分配给下面的入口。', en: 'No groups yet. Create one, then assign entries to it.' },
90
+ groupName: { zh: '分组名称', en: 'Group name' },
91
+ deleteGroup: { zh: '删除分组', en: 'Delete group' },
92
+ countFmt: { zh: '{n} 个入口 · {h} 个已隐藏', en: '{n} entries · {h} hidden' },
93
+ missingTitle: {
94
+ zh: '这个页面用的插件清单,已经不是最新的了',
95
+ en: 'This page is running an older plugin list',
96
+ },
97
+ missingBody: { zh: '刷新一次就会加载:{names}', en: 'One reload picks up: {names}' },
98
+ refreshPage: { zh: '刷新页面', en: 'Reload the page' },
99
+ dismiss: { zh: '不再提示', en: 'Dismiss' },
100
+ missingMore: { zh: ' 等 {n} 个', en: ' and {n} more' },
101
+ refreshStillMissing: {
102
+ zh: '刷新后仍然缺:重启 DSH 才会应用新的插件清单。',
103
+ en: 'Still missing after a reload: restart DSH to apply the new plugin list.',
104
+ },
105
+ diagMissingStuck: { zh: '已提示过', en: 'already reported' },
106
+ diagYes: { zh: '是', en: 'yes' },
107
+ diagNo: { zh: '否', en: 'no' },
108
+ hint: {
109
+ zh: '拖行首的把手排序;隐藏的入口仍留在列表里,随时可以放回来。',
110
+ en: 'Drag the handle at the start of a row to reorder it; a hidden entry stays in this list and can come back at any time.',
111
+ },
112
+ dragHint: { zh: '拖动排序', en: 'Drag to reorder' },
113
+ locked: { zh: '固定显示', en: 'always shown' },
114
+ lockedHint: {
115
+ zh: '这一行是本插件的入口,隐藏后仍可从“设置 → 面板管理”打开本页。',
116
+ en: 'This row opens this page; it stays visible and can also be reached from Settings → Panel manager.',
117
+ },
118
+ empty: { zh: '侧栏没有检测到可管理的入口。', en: 'No manageable entries were detected in the sidebar.' },
119
+ diagTitle: { zh: '诊断', en: 'Diagnostics' },
120
+ diagList: { zh: '导航区容器', en: 'Navigation container' },
121
+ diagFound: { zh: '已找到', en: 'found' },
122
+ diagMissing: { zh: '未找到', en: 'not found' },
123
+ diagRows: { zh: 'DOM 行数', en: 'DOM rows' },
124
+ diagMapped: { zh: '已映射', en: 'mapped' },
125
+ diagAt: { zh: '上次应用', en: 'last applied' },
126
+ diagHostRev: { zh: 'Host 清单版本', en: 'host list rev' },
127
+ diagBootRev: { zh: '页面清单版本', en: 'page list rev' },
128
+ diagMissingIds: { zh: '本页缺失', en: 'missing on this page' },
129
+ diagSource: { zh: '入口来源', en: 'entry source' },
130
+ diagNone: { zh: '—', en: '—' },
131
+ diagPrefs: { zh: '设置来源', en: 'settings source' },
132
+ diagFile: { zh: '设置文件', en: 'settings file' },
133
+ diagOrigin: { zh: '页面来源', en: 'page origin' },
134
+ autoTitle: { zh: '自动分类', en: 'Auto classify' },
135
+ autoHint: {
136
+ zh: '让默认模型把入口分成 2–4 组,结果保存在本机。',
137
+ en: 'The default model files the entries into 2-4 groups; the result stays on this machine.',
138
+ },
139
+ autoRunning: { zh: '正在分类…', en: 'Classifying…' },
140
+ autoAgain: { zh: '重新分类', en: 'Classify again' },
141
+ autoDone: { zh: '模型已分成 {n} 组({m})。', en: 'The model filed them into {n} groups ({m}).' },
142
+ autoFiledNew: { zh: '已把 {n} 个新入口归入分组({m})。', en: 'Filed {n} new entries ({m}).' },
143
+ autoFailed: { zh: '分类失败:{m}', en: 'Classification failed: {m}' },
144
+ autoTimeout: { zh: '模型这次超时了,稍后再试一次。', en: 'The model timed out; try again in a moment.' },
145
+ autoModelFailed: { zh: '模型调用失败,稍后再试一次。', en: 'The model call failed; try again in a moment.' },
146
+ autoUnparsable: { zh: '模型这次的回答不能用,再试一次。', en: 'The model answered with something unusable; try again.' },
147
+ autoUnavailable: {
148
+ zh: '这一版客户端拿不到宿主的连接服务,无法调用模型。',
149
+ en: 'This Client build does not expose the Host connection, so the model cannot be called.',
150
+ },
151
+ autoEmpty: { zh: '没有可分类的入口。', en: 'There is nothing to classify.' },
152
+ };
153
+
154
+ let ctxRef = null;
155
+ let engineRef = null;
156
+
157
+ /**
158
+ * Cordis throws when a service is read without being declared in inject().
159
+ * Everything optional therefore goes through this accessor instead.
160
+ */
161
+ function optionalService(ctx, name) {
162
+ if (!ctx) return undefined;
163
+ try {
164
+ if (typeof ctx.get === 'function') {
165
+ const value = ctx.get(name);
166
+ if (value) return value;
167
+ }
168
+ } catch {
169
+ /* not injected here */
170
+ }
171
+ try {
172
+ return ctx[name];
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ }
177
+
178
+ /** The live UI language, read from the Client locale service at call time. */
179
+ function activeLang() {
180
+ try {
181
+ const service = optionalService(ctxRef, 'locale');
182
+ const snapshot = service && typeof service.getLocale === 'function' ? service.getLocale() : null;
183
+ const id = snapshot && typeof snapshot.active === 'string' ? snapshot.active : '';
184
+ if (id) return /^zh/i.test(id) ? 'zh' : 'en';
185
+ } catch {
186
+ /* fall through to the browser language */
187
+ }
188
+ const nav = typeof navigator === 'object' && navigator ? navigator.language : '';
189
+ return /^zh/i.test(String(nav || '')) ? 'zh' : 'en';
190
+ }
191
+
192
+ const pick = (entry) => {
193
+ if (!entry) return undefined;
194
+ const language = activeLang();
195
+ return entry[language] || entry.en || entry.zh;
196
+ };
197
+ const t = (key) => pick(TEXT[key]) || key;
198
+ const fill = (text, values) =>
199
+ String(text).replace(/\{(\w+)\}/g, (whole, name) => (name in values ? String(values[name]) : whole));
200
+
201
+ function flatten(language) {
202
+ const dictionary = {};
203
+ // A key that has not been translated yet falls back rather than rendering
204
+ // "undefined" into a label.
205
+ for (const [key, value] of Object.entries(TEXT)) dictionary[key] = value[language] || value.en || value.zh;
206
+ return dictionary;
207
+ }
208
+
209
+ /* ---------------------------------------------------------------- prefs */
210
+
211
+ const emptyPrefs = () => ({
212
+ version: 2,
213
+ order: [],
214
+ hidden: [],
215
+ groups: [],
216
+ assign: {},
217
+ auto: false,
218
+ autoFiled: [],
219
+ });
220
+
221
+ /**
222
+ * What a stored file may contain.
223
+ *
224
+ * The Host accepts a 256KB file, which is enough for thousands of sections and
225
+ * hundreds of thousands of assignments if nothing trims them; every apply
226
+ * would then build that many separators. The numbers here are far above any
227
+ * real sidebar and turn a hostile or corrupted store into a cheap no-op.
228
+ */
229
+ const LIMITS = { order: 200, hidden: 200, autoFiled: 400, groups: 24, assign: 400, name: 40, key: 120 };
230
+
231
+ /** Dedupe, drop anything that is not a usable id, and stop at `cap`. */
232
+ function uniqueIds(raw, cap) {
233
+ const out = [];
234
+ if (!Array.isArray(raw)) return out;
235
+ const seen = new Set();
236
+ for (const id of raw) {
237
+ if (typeof id !== 'string' || id === '' || id.length > LIMITS.key || seen.has(id)) continue;
238
+ seen.add(id);
239
+ out.push(id);
240
+ if (out.length >= cap) break;
241
+ }
242
+ return out;
243
+ }
244
+
245
+ /** Keep only the shapes this plugin writes, so a hand-edited store cannot crash it. */
246
+ function sanitize(raw) {
247
+ const out = emptyPrefs();
248
+ if (!raw || typeof raw !== 'object') return out;
249
+ out.order = uniqueIds(raw.order, LIMITS.order);
250
+ out.autoFiled = uniqueIds(raw.autoFiled, LIMITS.autoFiled);
251
+ out.hidden = uniqueIds(raw.hidden, LIMITS.hidden);
252
+ if (Array.isArray(raw.groups)) {
253
+ const seen = new Set();
254
+ for (const group of raw.groups) {
255
+ if (out.groups.length >= LIMITS.groups) break;
256
+ if (!group || typeof group.id !== 'string' || group.id === '' || group.id.length > LIMITS.key) continue;
257
+ if (seen.has(group.id)) continue;
258
+ seen.add(group.id);
259
+ const name = typeof group.name === 'string' ? group.name.trim().slice(0, LIMITS.name) : '';
260
+ out.groups.push({ id: group.id, name: name || '—' });
261
+ }
262
+ }
263
+ if (raw.assign && typeof raw.assign === 'object') {
264
+ let kept = 0;
265
+ for (const [panelId, groupId] of Object.entries(raw.assign)) {
266
+ if (kept >= LIMITS.assign) break;
267
+ if (typeof panelId !== 'string' || panelId === '' || panelId.length > LIMITS.key) continue;
268
+ if (typeof groupId !== 'string' || groupId === '' || groupId.length > LIMITS.key) continue;
269
+ out.assign[panelId] = groupId;
270
+ kept += 1;
271
+ }
272
+ }
273
+ out.auto = raw.auto === true;
274
+ return out;
275
+ }
276
+
277
+ function readLocalPrefs() {
278
+ try {
279
+ const raw = window.localStorage.getItem(STORAGE_KEY);
280
+ return raw ? sanitize(JSON.parse(raw)) : null;
281
+ } catch {
282
+ return null;
283
+ }
284
+ }
285
+
286
+ function writeLocalPrefs(prefs) {
287
+ try {
288
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs));
289
+ } catch {
290
+ /* private mode or a full quota: the Host copy below is the real store */
291
+ }
292
+ }
293
+
294
+ const hasContent = (prefs) =>
295
+ Boolean(prefs) &&
296
+ ((prefs.order || []).length > 0 ||
297
+ (prefs.hidden || []).length > 0 ||
298
+ (prefs.groups || []).length > 0 ||
299
+ (prefs.autoFiled || []).length > 0 ||
300
+ prefs.auto === true);
301
+
302
+ /** One call to the Host half, or null when this Client cannot reach it. */
303
+ async function hostCall(method, payload) {
304
+ const rpc = getRpc();
305
+ if (!rpc) return null;
306
+ try {
307
+ const result = await rpc.call(RPC_CHANNEL, RPC_ENDPOINT, { method, payload });
308
+ if (!result || result.ok !== true) return null;
309
+ return result.value;
310
+ } catch {
311
+ return null;
312
+ }
313
+ }
314
+
315
+ /**
316
+ * Is this page running the plugin list the Host is serving right now?
317
+ *
318
+ * A page keeps the boot manifest it was handed at load time. The Host keeps
319
+ * composing a new graph whenever a plugin is added, removed or rebuilt, so the
320
+ * two revisions drift apart and the page quietly runs without the newer
321
+ * plugins. The Host exposes its current revision; comparing it costs one call
322
+ * and needs nothing from any other plugin.
323
+ */
324
+ let inventoryAt = 0;
325
+
326
+ async function checkInventory() {
327
+ // Called on load and on every window focus: keep it cheap.
328
+ if (inventoryAt > 0 && Date.now() - inventoryAt < 5000) return;
329
+ inventoryAt = Date.now();
330
+ const boot = bootSnapshot();
331
+ if (!boot) {
332
+ setState({ inventory: { status: 'unsupported', stale: false, missing: [], at: Date.now() } });
333
+ return;
334
+ }
335
+ const value = await hostCall('inventory', {});
336
+ const rev = value && typeof value.rev === 'string' ? value.rev : '';
337
+ const hostIds = value && Array.isArray(value.ids) ? value.ids : null;
338
+ if (!rev || !hostIds) {
339
+ setState({ inventory: { status: 'unavailable', stale: false, missing: [], at: Date.now() } });
340
+ return;
341
+ }
342
+ const missing = hostIds.filter((id) => !boot.ids.has(id));
343
+ // The Host can hand this very plugin to a page whose list predates it (see
344
+ // `recovery nudge` in the Host half). In that case the page is still missing
345
+ // an entry, but it is this one and it is already on screen: nagging the user
346
+ // to reload for it would be noise. Every other gap still counts.
347
+ const selfOnly = missing.length === 1 && missing[0] === PACKAGE && value.recovered === true;
348
+ // Only a real gap is worth a banner. A revision that merely differs proves
349
+ // nothing: the Host rebuilds that graph whenever any plugin file changes, so
350
+ // comparing revisions nagged people whose page was missing nothing at all.
351
+ // The two revisions still land in the diagnostics list for the curious.
352
+ const gap = missing.join(',');
353
+ // Already said on an earlier load and a reload did not help: keep quiet, the
354
+ // diagnostics still list it.
355
+ const repeated = gap.length > 0 && readNagged() === gap;
356
+ const stale = !selfOnly && missing.length > 0 && !repeated;
357
+ if (stale) rememberNagged(gap);
358
+ else if (!missing.length) rememberNagged('');
359
+ if (!stale) forgetReloadMark();
360
+ setState({
361
+ inventory: { status: 'done', stale, repeated, missing, rev, bootRev: boot.rev, at: Date.now() },
362
+ });
363
+ }
364
+
365
+ /**
366
+ * Settings live in a Host file, not only in `localStorage`.
367
+ *
368
+ * The renderer is served from the Host's loopback address, and that port
369
+ * changes on every launch: `localStorage` is therefore a per-launch scratch
370
+ * area, and settings kept only there look like "the order resets itself".
371
+ * The browser copy stays as a cache and as the thing the harness drives.
372
+ */
373
+ const prefsSource = { value: 'local' };
374
+
375
+ let saveTimer = null;
376
+
377
+ function scheduleSaveToHost() {
378
+ if (saveTimer) clearTimeout(saveTimer);
379
+ saveTimer = setTimeout(() => {
380
+ saveTimer = null;
381
+ void hostCall('save', { prefs: store.state.prefs });
382
+ }, SAVE_DEBOUNCE_MS);
383
+ }
384
+
385
+ function reportHost(detail) {
386
+ void hostCall('report', { ...detail, href: String(locationMode()), at: Date.now() });
387
+ }
388
+
389
+ /** `location.href` minus any query, so one line says which origin we ran on. */
390
+ function locationMode() {
391
+ try {
392
+ return window.location && window.location.origin ? window.location.origin : 'unknown-origin';
393
+ } catch {
394
+ return 'unknown-origin';
395
+ }
396
+ }
397
+
398
+ /** The boot manifest this page was handed: its plugin ids and its revision. */
399
+ function bootSnapshot() {
400
+ try {
401
+ const boot = window.__DSH_BOOT__;
402
+ if (!boot || typeof boot.rev !== 'string' || !Array.isArray(boot.entries)) return null;
403
+ const ids = new Set(boot.entries.map((entry) => (entry && typeof entry.id === 'string' ? entry.id : '')).filter(Boolean));
404
+ return { rev: boot.rev, ids };
405
+ } catch {
406
+ return null;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * A reload is the only way to pick up plugin scripts this page never asked
412
+ * for: the boot list is frozen per load. Everything here is plain web API, so
413
+ * the plugin can heal itself without another plugin's help.
414
+ *
415
+ * `reload()` keeps the current URL, which is what the Host expects. A cache
416
+ * can still answer an older document, so a refusal or an exception falls back
417
+ * to a fresh navigation carrying a one-off query — the Host's boot injection
418
+ * does not read the query, but the browser cannot serve that one from cache.
419
+ */
420
+ function reloadPage() {
421
+ try {
422
+ const missing = (store.state.inventory && store.state.inventory.missing) || [];
423
+ window.sessionStorage.setItem(RELOAD_MARK, JSON.stringify({ at: Date.now(), ids: missing }));
424
+ } catch {
425
+ /* private mode: the mark is a nicety, not a requirement */
426
+ }
427
+ try {
428
+ window.location.reload();
429
+ return true;
430
+ } catch {
431
+ /* fall through to a fresh navigation */
432
+ }
433
+ try {
434
+ const url = new URL(window.location.href);
435
+ url.searchParams.set('dpc', String(Date.now()));
436
+ window.location.assign(url.toString());
437
+ return true;
438
+ } catch {
439
+ return false;
440
+ }
441
+ }
442
+
443
+ /** `{at, ids}` for a reload this page made recently, otherwise null. */
444
+ function readReloadMark() {
445
+ try {
446
+ const raw = window.sessionStorage.getItem(RELOAD_MARK);
447
+ if (!raw) return null;
448
+ const parsed = JSON.parse(raw);
449
+ const at = Number(parsed && parsed.at);
450
+ if (!Number.isFinite(at) || at <= 0) return null;
451
+ if (Date.now() - at > RELOAD_MARK_TTL) {
452
+ window.sessionStorage.removeItem(RELOAD_MARK);
453
+ return null;
454
+ }
455
+ return { at, ids: Array.isArray(parsed.ids) ? parsed.ids.map(String) : [] };
456
+ } catch {
457
+ return null;
458
+ }
459
+ }
460
+
461
+ function forgetReloadMark() {
462
+ try {
463
+ window.sessionStorage.removeItem(RELOAD_MARK);
464
+ window.sessionStorage.removeItem(NOTICE_SKIP);
465
+ } catch {
466
+ /* private mode: nothing to forget */
467
+ }
468
+ }
469
+
470
+ /** ids the reader asked this page not to be nagged about. */
471
+ function readSkip() {
472
+ try {
473
+ return window.sessionStorage.getItem(NOTICE_SKIP) || '';
474
+ } catch {
475
+ return '';
476
+ }
477
+ }
478
+
479
+ function skipNotice(missing) {
480
+ try {
481
+ window.sessionStorage.setItem(NOTICE_SKIP, missing.join(','));
482
+ } catch {
483
+ /* private mode: the banner simply stays */
484
+ }
485
+ }
486
+
487
+ /** The gap this profile was nagged about on an earlier page load. */
488
+ function readNagged() {
489
+ try {
490
+ return window.localStorage.getItem(NOTICE_NAGGED) || '';
491
+ } catch {
492
+ return '';
493
+ }
494
+ }
495
+
496
+ function rememberNagged(ids) {
497
+ try {
498
+ if (ids) window.localStorage.setItem(NOTICE_NAGGED, ids);
499
+ else window.localStorage.removeItem(NOTICE_NAGGED);
500
+ } catch {
501
+ /* private mode: the banner comes back, which is acceptable */
502
+ }
503
+ }
504
+
505
+ const store = {
506
+ state: {
507
+ prefs: readLocalPrefs() || emptyPrefs(),
508
+ entries: [],
509
+ autoState: { status: 'idle', message: '', groups: 0 },
510
+ diag: { prefsSource: 'local', href: 'unknown-origin', hostFile: '' },
511
+ inventory: { status: 'idle', stale: false, missing: [], at: 0 },
512
+ },
513
+ listeners: new Set(),
514
+ };
515
+
516
+ function emit() {
517
+ for (const listener of Array.from(store.listeners)) {
518
+ try {
519
+ listener();
520
+ } catch {
521
+ /* one broken subscriber must not stop the rest */
522
+ }
523
+ }
524
+ }
525
+
526
+ function setState(patch) {
527
+ store.state = { ...store.state, ...patch };
528
+ emit();
529
+ }
530
+
531
+ function useStore() {
532
+ const [, force] = React.useState(0);
533
+ React.useEffect(() => {
534
+ const listener = () => force((value) => value + 1);
535
+ store.listeners.add(listener);
536
+ return () => {
537
+ store.listeners.delete(listener);
538
+ };
539
+ }, []);
540
+ return store.state;
541
+ }
542
+
543
+ /* -------------------------------------------------------------- entries */
544
+
545
+ /** A slot label is a string or a function returning the localized string. */
546
+ function resolveLabel(label, fallback) {
547
+ try {
548
+ if (typeof label === 'function') {
549
+ const value = label();
550
+ if (typeof value === 'string' && value.trim() !== '') return value;
551
+ } else if (typeof label === 'string' && label.trim() !== '') {
552
+ return label;
553
+ }
554
+ } catch {
555
+ /* a label that throws is reported by its id instead */
556
+ }
557
+ return fallback;
558
+ }
559
+
560
+ /**
561
+ * The sidebar's own list: every registered entry, sorted exactly the way the
562
+ * sidebar sorts it (`order`, then registration order).
563
+ */
564
+ function readEntries(slots) {
565
+ try {
566
+ const raw = slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_PANELS) || [] : [];
567
+ const rows = raw
568
+ .map((entry, index) => {
569
+ const options = (entry && entry.options) || {};
570
+ const id = typeof options.id === 'string' ? options.id : '';
571
+ return {
572
+ id,
573
+ index,
574
+ order: typeof options.order === 'number' ? options.order : 0,
575
+ label: resolveLabel(options.label, id),
576
+ };
577
+ })
578
+ .filter((row) => row.id !== '');
579
+ rows.sort((left, right) => left.order - right.order || left.index - right.index);
580
+ return rows.map((row) => ({ id: row.id, label: row.label, order: row.order }));
581
+ } catch {
582
+ return [];
583
+ }
584
+ }
585
+
586
+ const sameEntries = (left, right) =>
587
+ left.length === right.length &&
588
+ left.every((row, index) => row.id === right[index].id && row.label === right[index].label);
589
+
590
+ /** The stored order, extended with every entry that is new since it was saved. */
591
+ function normalize(prefs, entries) {
592
+ const known = new Set(entries.map((entry) => entry.id));
593
+ const order = [];
594
+ const seen = new Set();
595
+ for (const id of prefs.order || []) {
596
+ if (!known.has(id) || seen.has(id)) continue;
597
+ seen.add(id);
598
+ order.push(id);
599
+ }
600
+ for (const entry of entries) {
601
+ if (seen.has(entry.id)) continue;
602
+ seen.add(entry.id);
603
+ order.push(entry.id);
604
+ }
605
+ return order;
606
+ }
607
+
608
+
609
+
610
+ /* --------------------------------------------------------- DOM adapter */
611
+
612
+ function cssEscape(value) {
613
+ try {
614
+ if (window.CSS && typeof window.CSS.escape === 'function') return window.CSS.escape(String(value));
615
+ } catch {
616
+ /* the selector fallback below tolerates a plain value */
617
+ }
618
+ return String(value).replace(/["\\]/g, '\\$&');
619
+ }
620
+
621
+ /**
622
+ * Apply the stored layout to the live navigation list.
623
+ *
624
+ * One pass = read the entries, find the list, map entry -> row button, then
625
+ * write `order` / `display` and re-create the group headings. It is cheap
626
+ * enough to run on every sidebar re-render, and it is a no-op when nothing
627
+ * about the plan changed.
628
+ */
629
+ /**
630
+ * Entry -> row button.
631
+ *
632
+ * A row button carries no id of its own, so the accessible label is the only
633
+ * real key. Position alone is not safe: as soon as a plugin inserts a row, or
634
+ * one row disappears (disabled, filtered, still loading), every position after
635
+ * it shifts by one and a positional fallback would hand row N to entry N+1 --
636
+ * hiding the wrong row. So: match by unique label first, and only fall back to
637
+ * position when the two leftover lists agree label for label. Anything still
638
+ * unmatched is left unmanaged rather than guessed at.
639
+ */
640
+ function mapButtons(entries, buttons) {
641
+ const map = new Map();
642
+ const used = new Set();
643
+ const byLabel = new Map();
644
+ for (const button of buttons) {
645
+ const label = button.getAttribute('aria-label') || '';
646
+ if (!byLabel.has(label)) byLabel.set(label, []);
647
+ byLabel.get(label).push(button);
648
+ }
649
+ const loose = [];
650
+ for (const entry of entries) {
651
+ const hit = (byLabel.get(entry.label) || []).find((button) => !used.has(button));
652
+ if (hit) {
653
+ map.set(entry.id, hit);
654
+ used.add(hit);
655
+ } else {
656
+ loose.push(entry);
657
+ }
658
+ }
659
+ const rest = buttons.filter((button) => !used.has(button));
660
+ if (loose.length > 0 && loose.length === rest.length) {
661
+ // Labels that agree prove the two lists are the same list; a button with no
662
+ // label at all is no evidence either way, so it may be filled positionally.
663
+ const sameOrder = loose.every((entry, index) => {
664
+ const label = rest[index].getAttribute('aria-label') || '';
665
+ return label === '' || label === entry.label;
666
+ });
667
+ // A build that labels rows in wording this plugin never sees ("IM 机器人"
668
+ // vs "IM机器人") matches nothing at all; that is not a shifted list, it is
669
+ // a naming difference, and refusing here would manage no row at all.
670
+ const nothingMatched = map.size === 0;
671
+ if (sameOrder || nothingMatched) {
672
+ loose.forEach((entry, index) => {
673
+ map.set(entry.id, rest[index]);
674
+ used.add(rest[index]);
675
+ });
676
+ }
677
+ }
678
+ return map;
679
+ }
680
+
681
+ function createEngine(ctx) {
682
+ let scheduled = false;
683
+ let destroyed = false;
684
+ let observer = null;
685
+ let observing = false;
686
+ let listEl = null;
687
+ let observed = null;
688
+ let signature = '';
689
+ let pollTimer = null;
690
+ let lastApplyAt = 0;
691
+
692
+ const diag = { found: false, rows: 0, mapped: 0, at: 0, listClass: '', error: null };
693
+
694
+ const readPlan = () => {
695
+ const entries = readEntries(ctx.slots);
696
+ const prefs = store.state.prefs;
697
+ const hidden = new Set(prefs.hidden || []);
698
+ const ordered = normalize(prefs, entries);
699
+ const groups = prefs.groups || [];
700
+ const known = new Set(groups.map((group) => group.id));
701
+ const buckets = new Map();
702
+ for (const id of ordered) {
703
+ const groupId = prefs.assign[id] || '';
704
+ if (!buckets.has(groupId)) buckets.set(groupId, []);
705
+ buckets.get(groupId).push(id);
706
+ }
707
+ // Block order is the settings' own group order, not the order in which a
708
+ // group's first member happens to appear: the sidebar must only change when
709
+ // the user changes it.
710
+ const blocks = [];
711
+ for (const group of groups) {
712
+ const ids = buckets.get(group.id);
713
+ if (!ids || ids.length === 0) continue;
714
+ blocks.push({ id: group.id, name: group.name, ids });
715
+ }
716
+ // Unassigned entries (and entries whose group was deleted) form one
717
+ // trailing block, keeping the order the user gave them.
718
+ const loose = ordered.filter((id) => {
719
+ const groupId = prefs.assign[id] || '';
720
+ return groupId === '' || !known.has(groupId);
721
+ });
722
+ if (loose.length > 0) blocks.push({ id: '', name: groups.length > 0 ? t('ungrouped') : '', ids: loose });
723
+ return { entries, prefs, hidden, blocks };
724
+ };
725
+
726
+ const findList = (entries) => {
727
+ if (listEl && listEl.isConnected && listEl.querySelector(':scope > button')) return listEl;
728
+ listEl = null;
729
+ try {
730
+ for (const candidate of document.querySelectorAll('[class*="panelList"]')) {
731
+ if (candidate.querySelector(':scope > button')) {
732
+ listEl = candidate;
733
+ return listEl;
734
+ }
735
+ }
736
+ } catch {
737
+ /* fall through to the label probe */
738
+ }
739
+ for (const entry of entries) {
740
+ if (!entry.label) continue;
741
+ let button = null;
742
+ try {
743
+ button = document.querySelector(`button[aria-label="${cssEscape(entry.label)}"]`);
744
+ } catch {
745
+ button = null;
746
+ }
747
+ const parent = button && button.parentElement;
748
+ if (parent && parent.querySelectorAll(':scope > button').length > 1) {
749
+ listEl = parent;
750
+ return listEl;
751
+ }
752
+ }
753
+ return null;
754
+ };
755
+
756
+ const makeSeparator = (name, order) => {
757
+ const element = document.createElement('div');
758
+ element.className = 'dpc-sep';
759
+ element.setAttribute('data-dpc-sep', '');
760
+ element.style.order = String(order);
761
+ const label = document.createElement('span');
762
+ label.className = 'dpc-sepLabel';
763
+ label.textContent = name;
764
+ element.appendChild(label);
765
+ return element;
766
+ };
767
+
768
+ const pause = () => {
769
+ if (observer && observing) {
770
+ observer.disconnect();
771
+ observing = false;
772
+ }
773
+ };
774
+
775
+ const resume = () => {
776
+ if (!observer || destroyed || observing) return;
777
+ // Watch the list itself once it is known. Watching `document.body` means
778
+ // every chunk of a streaming reply schedules a full re-plan, and the plan
779
+ // costs a slot read plus a query over every sidebar row.
780
+ const target = listEl && listEl.isConnected ? listEl : document.body;
781
+ try {
782
+ observer.observe(target, {
783
+ childList: true,
784
+ subtree: true,
785
+ attributes: true,
786
+ attributeFilter: ['class', 'style'],
787
+ });
788
+ observed = target;
789
+ observing = true;
790
+ } catch {
791
+ /* observing is an optimisation, not a requirement */
792
+ }
793
+ };
794
+
795
+ const apply = () => {
796
+ if (destroyed) return;
797
+ const plan = readPlan();
798
+ if (!sameEntries(store.state.entries, plan.entries)) setState({ entries: plan.entries });
799
+ const list = findList(plan.entries);
800
+ diag.found = Boolean(list);
801
+ // A different list (the sidebar remounted) needs a new observation target.
802
+ if (list && observed !== list) pause();
803
+ if (!list) {
804
+ diag.rows = 0;
805
+ diag.mapped = 0;
806
+ diag.listClass = '';
807
+ diag.at = Date.now();
808
+ return;
809
+ }
810
+ diag.listClass = typeof list.className === 'string' ? list.className.split(/\s+/)[0] || '' : '';
811
+
812
+ const nextSignature = JSON.stringify({
813
+ blocks: plan.blocks,
814
+ hidden: plan.hidden ? Array.from(plan.hidden).sort() : [],
815
+ entries: plan.entries.map((entry) => entry.id + ':' + entry.label),
816
+ wide: list.getBoundingClientRect().width >= WIDE_MIN_WIDTH,
817
+ });
818
+ const separatorsLeft = list.querySelectorAll('[data-dpc-sep]').length;
819
+ if (nextSignature === signature && (separatorsLeft > 0 || plan.blocks.length <= 1)) {
820
+ diag.at = Date.now();
821
+ return;
822
+ }
823
+ signature = nextSignature;
824
+
825
+ pause();
826
+ try {
827
+ const buttons = Array.from(list.querySelectorAll(':scope > button'));
828
+ const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
829
+ for (const node of Array.from(list.querySelectorAll('[data-dpc-sep]'))) node.remove();
830
+
831
+ const map = mapButtons(plan.entries, buttons);
832
+ const managed = new Set(map.values());
833
+
834
+ // Anything we do not manage (the "More" launcher and its wrapper) keeps
835
+ // the end of the list instead of sorting between our rows on `order: 0`.
836
+ for (const child of Array.from(list.children)) {
837
+ if (child.tagName === 'BUTTON') continue;
838
+ if (child.hasAttribute('data-dpc-sep')) continue;
839
+ child.style.order = String(ORDER_UNMANAGED);
840
+ }
841
+
842
+ let position = ORDER_START;
843
+ for (const block of plan.blocks) {
844
+ const visible = block.ids.filter((id) => !plan.hidden.has(id) && map.has(id));
845
+ if (visible.length === 0) continue;
846
+ if (wide && block.name) {
847
+ list.appendChild(makeSeparator(block.name, position));
848
+ position += ORDER_STEP;
849
+ }
850
+ for (const id of visible) {
851
+ const button = map.get(id);
852
+ button.style.order = String(position);
853
+ button.style.display = '';
854
+ button.removeAttribute('data-dpc-hidden');
855
+ button.setAttribute('data-dpc-group', block.name || '');
856
+ position += ORDER_STEP;
857
+ }
858
+ }
859
+
860
+ for (const id of plan.hidden) {
861
+ const button = map.get(id);
862
+ if (!button) continue;
863
+ button.style.display = 'none';
864
+ button.setAttribute('data-dpc-hidden', '');
865
+ }
866
+
867
+ // A row we could not map is left in place, but kept after every managed row.
868
+ for (const button of buttons) {
869
+ if (managed.has(button)) continue;
870
+ button.style.order = String(ORDER_UNMANAGED_TAIL);
871
+ }
872
+
873
+ diag.rows = buttons.length;
874
+ diag.mapped = map.size;
875
+ diag.error = null;
876
+ } catch (error) {
877
+ diag.error = error && error.message ? String(error.message) : String(error);
878
+ } finally {
879
+ diag.at = Date.now();
880
+ resume();
881
+ }
882
+ };
883
+
884
+ const APPLY_MIN_GAP = 100;
885
+ const schedule = () => {
886
+ if (scheduled || destroyed) return;
887
+ scheduled = true;
888
+ const run = () => {
889
+ scheduled = false;
890
+ apply();
891
+ lastApplyAt = Date.now();
892
+ };
893
+ // Coalesce bursts: a streaming reply fires mutations faster than the plan
894
+ // can run, and nothing in the sidebar changes between two frames anyway.
895
+ const wait = Math.max(0, APPLY_MIN_GAP - (Date.now() - lastApplyAt));
896
+ if (wait > 0) setTimeout(run, wait);
897
+ else if (typeof requestAnimationFrame === 'function') requestAnimationFrame(run);
898
+ else setTimeout(run, 16);
899
+ };
900
+
901
+ const start = () => {
902
+ if (typeof MutationObserver === 'function' && document.body) {
903
+ observer = new MutationObserver(() => schedule());
904
+ resume();
905
+ }
906
+ schedule();
907
+ // The sidebar mounts after this plugin's apply (and a page can restore a
908
+ // panel later), so keep probing instead of giving up after a few seconds.
909
+ for (const delay of [0, 120, 400, 1000, 2500, 5000, 9000, 14000]) setTimeout(schedule, delay);
910
+ let tries = 0;
911
+ pollTimer = setInterval(() => {
912
+ if (destroyed || listEl || tries > 40) {
913
+ clearInterval(pollTimer);
914
+ pollTimer = null;
915
+ return;
916
+ }
917
+ tries += 1;
918
+ schedule();
919
+ }, 3000);
920
+ try {
921
+ window.addEventListener('resize', schedule);
922
+ } catch {
923
+ /* no window: nothing to watch */
924
+ }
925
+ };
926
+
927
+ const destroy = () => {
928
+ destroyed = true;
929
+ pause();
930
+ observer = null;
931
+ observed = null;
932
+ if (pollTimer) {
933
+ clearInterval(pollTimer);
934
+ pollTimer = null;
935
+ }
936
+ try {
937
+ window.removeEventListener('resize', schedule);
938
+ } catch {
939
+ /* ignore */
940
+ }
941
+ if (listEl) {
942
+ for (const node of Array.from(listEl.querySelectorAll('[data-dpc-sep]'))) node.remove();
943
+ for (const node of Array.from(listEl.querySelectorAll('[data-dpc-panel]'))) {
944
+ node.style.removeProperty('order');
945
+ node.style.removeProperty('display');
946
+ }
947
+ }
948
+ listEl = null;
949
+ };
950
+
951
+ return { apply, schedule, start, destroy, diagnostics: () => ({ ...diag }), refresh: schedule };
952
+ }
953
+
954
+ /* --------------------------------------------------------------- actions */
955
+
956
+ /** Bumped by every user-visible edit; used to drop stale Host snapshots. */
957
+ let commitRevision = 0;
958
+
959
+ function commit(nextPrefs) {
960
+ commitRevision += 1;
961
+ const safe = sanitize(nextPrefs);
962
+ store.state = { ...store.state, prefs: safe };
963
+ writeLocalPrefs(safe);
964
+ scheduleSaveToHost();
965
+ emit();
966
+ if (engineRef) engineRef.schedule();
967
+ }
968
+
969
+ function setDiag(patch) {
970
+ setState({ diag: { ...(store.state.diag || {}), ...patch } });
971
+ }
972
+
973
+ /** Pull the Host copy of the settings over the browser cache. */
974
+ async function hydratePrefs() {
975
+ const revision = commitRevision;
976
+ const remote = await hostCall('load', {});
977
+ // The answer can land after the user already moved a row or renamed a group.
978
+ // Their edit is newer than this snapshot, so keep theirs instead of rolling
979
+ // it back to whatever the Host had when the request left.
980
+ if (revision !== commitRevision) return;
981
+ const stored = remote && remote.prefs ? sanitize(remote.prefs) : null;
982
+ const local = readLocalPrefs();
983
+ const hostFile = (remote && remote.file) || '';
984
+ const chosen = stored && (hasContent(stored) || !(local && hasContent(local))) ? stored : local;
985
+ if (chosen) {
986
+ prefsSource.value = stored ? 'host' : 'local';
987
+ if (JSON.stringify(chosen) !== JSON.stringify(store.state.prefs)) {
988
+ store.state = { ...store.state, prefs: chosen };
989
+ writeLocalPrefs(chosen);
990
+ emit();
991
+ if (engineRef) engineRef.schedule();
992
+ }
993
+ // Heal the file when it was missing or older than this version.
994
+ if (!stored || !hasContent(stored)) await hostCall('save', { prefs: chosen });
995
+ }
996
+ setDiag({ prefsSource: prefsSource.value, hostFile });
997
+ }
998
+
999
+ const prefsNow = () => store.state.prefs;
1000
+ const orderNow = () => normalize(prefsNow(), store.state.entries);
1001
+
1002
+ // Move fromId in front of targetId. Shared by the entry list and the group
1003
+ // list so both sections reorder by exactly the same rule.
1004
+ function reorderList(list, fromId, targetId) {
1005
+ const from = list.indexOf(fromId);
1006
+ if (from < 0 || list.indexOf(targetId) < 0 || fromId === targetId) return null;
1007
+ const next = list.slice();
1008
+ const [moved] = next.splice(from, 1);
1009
+ // Dropping onto a row means "put it in front of that row", so the target
1010
+ // index has to be read after the item left the list.
1011
+ next.splice(next.indexOf(targetId), 0, moved);
1012
+ return next;
1013
+ }
1014
+
1015
+ function moveBefore(fromId, targetId) {
1016
+ const order = reorderList(orderNow(), fromId, targetId);
1017
+ if (!order) return;
1018
+ commit({ ...prefsNow(), order });
1019
+ }
1020
+
1021
+ function sortByName() {
1022
+ const labelOf = new Map((store.state.entries || []).map((entry) => [entry.id, entry.label]));
1023
+ const locale = activeLang() === 'zh' ? 'zh-Hans-CN' : 'en';
1024
+ const order = orderNow()
1025
+ .slice()
1026
+ .sort((left, right) => String(labelOf.get(left) || left).localeCompare(String(labelOf.get(right) || right), locale));
1027
+ commit({ ...prefsNow(), order });
1028
+ }
1029
+
1030
+ function showAll() {
1031
+ commit({ ...prefsNow(), hidden: [] });
1032
+ }
1033
+
1034
+ function resetAll() {
1035
+ commit(emptyPrefs());
1036
+ }
1037
+
1038
+ function toggleHidden(id) {
1039
+ if (id === PANEL_KEY) return;
1040
+ const hidden = new Set(prefsNow().hidden || []);
1041
+ if (hidden.has(id)) hidden.delete(id);
1042
+ else hidden.add(id);
1043
+ commit({ ...prefsNow(), hidden: Array.from(hidden) });
1044
+ }
1045
+
1046
+ function setGroup(id, groupId) {
1047
+ const assign = { ...(prefsNow().assign || {}) };
1048
+ if (groupId) assign[id] = groupId;
1049
+ else delete assign[id];
1050
+ commit({ ...prefsNow(), assign });
1051
+ }
1052
+
1053
+ function addGroup() {
1054
+ const groups = (prefsNow().groups || []).slice();
1055
+ const name = `${t('newGroupName')} ${groups.length + 1}`;
1056
+ const id = `g${Date.now().toString(36)}${Math.floor(Math.random() * 1000).toString(36)}`;
1057
+ groups.push({ id, name });
1058
+ commit({ ...prefsNow(), groups });
1059
+ }
1060
+
1061
+ function renameGroup(groupId, name) {
1062
+ const groups = (prefsNow().groups || []).map((group) =>
1063
+ group.id === groupId ? { id: group.id, name: name === '' ? '—' : name } : group,
1064
+ );
1065
+ commit({ ...prefsNow(), groups });
1066
+ }
1067
+
1068
+ function moveGroupBefore(fromId, targetId) {
1069
+ const groups = reorderList((prefsNow().groups || []).map((group) => group.id), fromId, targetId);
1070
+ if (!groups) return;
1071
+ const byId = new Map((prefsNow().groups || []).map((group) => [group.id, group]));
1072
+ commit({ ...prefsNow(), groups: groups.map((id) => byId.get(id)) });
1073
+ }
1074
+
1075
+ function removeGroup(groupId) {
1076
+ const prefs = prefsNow();
1077
+ const groups = (prefs.groups || []).filter((group) => group.id !== groupId);
1078
+ const assign = { ...(prefs.assign || {}) };
1079
+ for (const [panelId, assigned] of Object.entries(assign)) {
1080
+ if (assigned === groupId) delete assign[panelId];
1081
+ }
1082
+ commit({ ...prefs, groups, assign });
1083
+ }
1084
+
1085
+ /* ------------------------------------------------------- auto classify */
1086
+
1087
+ /**
1088
+ * Auto classify asks the Host for one small model call: this half cannot reach
1089
+ * a model, and the Host half exposes exactly one route for it. Both halves
1090
+ * are optional to each other — without the route the switch reports why.
1091
+ */
1092
+ /** Entries the auto switch has already looked at; it never re-files them. */
1093
+ function autoFiledSet() {
1094
+ return new Set(store.state.prefs.autoFiled || []);
1095
+ }
1096
+
1097
+ function setAuto(patch) {
1098
+ const current = store.state.autoState || { status: 'idle', message: '', groups: 0 };
1099
+ setState({ autoState: { ...current, ...patch } });
1100
+ }
1101
+
1102
+ /** The Host connection, however this Client build exposes it. */
1103
+ function getRpc() {
1104
+ const ctx = ctxRef;
1105
+ if (!ctx) return null;
1106
+ let connection = null;
1107
+ try {
1108
+ connection = ctx.connection || null;
1109
+ } catch {
1110
+ connection = null;
1111
+ }
1112
+ if (!connection && typeof ctx.get === 'function') {
1113
+ try {
1114
+ connection = ctx.get('connection') || null;
1115
+ } catch {
1116
+ connection = null;
1117
+ }
1118
+ }
1119
+ const rpc = connection && connection.rpc;
1120
+ return rpc && typeof rpc.call === 'function' ? rpc : null;
1121
+ }
1122
+
1123
+
1124
+
1125
+ const sameGroupName = (left, right) =>
1126
+ String(left || '').trim().toLowerCase() === String(right || '').trim().toLowerCase();
1127
+
1128
+ function nextGroupId(groups) {
1129
+ let id = `g${Date.now().toString(36)}`;
1130
+ while (groups.some((group) => group.id === id)) id = `${id}x`;
1131
+ return id;
1132
+ }
1133
+
1134
+ /**
1135
+ * Write the model's filing into the same preferences the UI edits.
1136
+ *
1137
+ * Two rules keep this from feeling unstable:
1138
+ * - `order` is never touched. Filing decides which section an entry is in,
1139
+ * not where the row sits; reordering rows behind the user's back was the
1140
+ * main reason the list "changed by itself".
1141
+ * - Existing sections keep their position (the settings list is the single
1142
+ * source of section order) and, in `new` mode, keep their members.
1143
+ */
1144
+ function applyClassification(value, mode) {
1145
+ const all = mode === 'all';
1146
+ const entries = store.state.entries || [];
1147
+ const known = new Set(entries.map((entry) => entry.id));
1148
+ const prefs = prefsNow();
1149
+ const groups = (prefs.groups || []).slice();
1150
+ const assign = { ...(prefs.assign || {}) };
1151
+ const knownGroups = new Set(groups.map((group) => group.id));
1152
+ const created = new Set();
1153
+ const assigned = new Set();
1154
+ let count = 0;
1155
+ for (const group of (value && value.groups) || []) {
1156
+ const ids = (group.ids || []).filter((id) => known.has(id));
1157
+ if (ids.length === 0) continue;
1158
+ let target = groups.find((entry) => sameGroupName(entry.name, group.name));
1159
+ if (!target) {
1160
+ target = {
1161
+ id: nextGroupId(groups),
1162
+ name: String(group.name || '').trim().slice(0, 40) || `#${groups.length + 1}`,
1163
+ };
1164
+ groups.push(target);
1165
+ created.add(target.id);
1166
+ }
1167
+ for (const id of ids) {
1168
+ const current = assign[id] || '';
1169
+ if (!all && current && knownGroups.has(current)) continue;
1170
+ assign[id] = target.id;
1171
+ assigned.add(id);
1172
+ }
1173
+ count += 1;
1174
+ }
1175
+ // A section this run invented and left empty is not worth keeping.
1176
+ for (const id of created) {
1177
+ if (Object.values(assign).some((groupId) => groupId === id)) continue;
1178
+ const at = groups.findIndex((group) => group.id === id);
1179
+ if (at >= 0) groups.splice(at, 1);
1180
+ }
1181
+ // Remember only what this run actually filed. An entry the model left out
1182
+ // stays eligible, so a later run can still place it; marking the whole list
1183
+ // as handled made a missed entry permanently invisible to auto filing.
1184
+ const filed = new Set(prefs.autoFiled || []);
1185
+ for (const id of assigned) filed.add(id);
1186
+ commit({ ...prefsNow(), groups, assign, autoFiled: Array.from(filed) });
1187
+ setAuto({
1188
+ status: 'done',
1189
+ groups: count,
1190
+ message: fill(t(all ? 'autoDone' : 'autoFiledNew'), { n: count, m: (value && value.model) || 'model' }),
1191
+ at: Date.now(),
1192
+ });
1193
+ }
1194
+
1195
+ /** A few Host failures deserve a sentence the user can act on. */
1196
+ const AUTO_ERROR_TEXT = {
1197
+ timeout: 'autoTimeout',
1198
+ 'model-failed': 'autoModelFailed',
1199
+ 'unparsable-answer': 'autoUnparsable',
1200
+ };
1201
+
1202
+ let classifyToken = 0;
1203
+
1204
+ async function classifyNow(mode) {
1205
+ const entries = store.state.entries || [];
1206
+ if (entries.length === 0) {
1207
+ setAuto({ status: 'error', message: t('autoEmpty'), at: Date.now() });
1208
+ return;
1209
+ }
1210
+ const rpc = getRpc();
1211
+ if (!rpc) {
1212
+ setAuto({ status: 'error', message: t('autoUnavailable'), at: Date.now() });
1213
+ return;
1214
+ }
1215
+ // Only the newest run may write its result: a second press while the first
1216
+ // call is in flight otherwise lands later and overwrites the newer filing.
1217
+ const token = ++classifyToken;
1218
+ setAuto({ status: 'running', message: t('autoRunning'), groups: 0, at: Date.now() });
1219
+ try {
1220
+ const result = await rpc.call(RPC_CHANNEL, RPC_ENDPOINT, {
1221
+ method: 'classify',
1222
+ payload: {
1223
+ entries: entries.map((entry) => ({ id: entry.id, label: entry.label })),
1224
+ groups: (prefsNow().groups || []).map((group) => ({ name: group.name })),
1225
+ locale: activeLang(),
1226
+ },
1227
+ });
1228
+ if (token !== classifyToken) return;
1229
+ if (!result || result.ok !== true) {
1230
+ const error = (result && result.error) || {};
1231
+ const key = AUTO_ERROR_TEXT[error.code];
1232
+ if (key) {
1233
+ setAuto({ status: 'error', message: t(key), at: Date.now() });
1234
+ return;
1235
+ }
1236
+ throw new Error(error.message || 'unknown error');
1237
+ }
1238
+ reportHost({ kind: 'classified', mode: mode === 'all' ? 'all' : 'new', groups: (result.value && result.value.groups || []).length });
1239
+ applyClassification(result.value, mode === 'all' ? 'all' : 'new');
1240
+ } catch (error) {
1241
+ if (token !== classifyToken) return;
1242
+ reportHost({ kind: 'classify-failed', message: (error && error.message) || String(error) });
1243
+ setAuto({
1244
+ status: 'error',
1245
+ message: fill(t('autoFailed'), { m: (error && error.message) || String(error) }),
1246
+ at: Date.now(),
1247
+ });
1248
+ }
1249
+ }
1250
+
1251
+ function toggleAuto() {
1252
+ const next = !prefsNow().auto;
1253
+ commit({ ...prefsNow(), auto: next });
1254
+ if (next) void classifyNow('new');
1255
+ }
1256
+
1257
+ /* ------------------------------------------------------------ components */
1258
+
1259
+ const ICON_D = {
1260
+ grip: 'M9 7h.01M9 12h.01M9 17h.01M15 7h.01M15 12h.01M15 17h.01',
1261
+ up: 'M12 19V6M6.5 11.5 12 6l5.5 5.5',
1262
+ down: 'M12 5v13M6.5 12.5 12 18l5.5-5.5',
1263
+ close: 'M7 7l10 10M17 7 7 17',
1264
+ plus: 'M12 6v12M6 12h12',
1265
+ eye: 'M2.5 12S6 6.5 12 6.5 21.5 12 21.5 12 18 17.5 12 17.5 2.5 12 2.5 12Z',
1266
+ eyeOff: 'M2.5 12S6 6.5 12 6.5c1.6 0 3 .45 4.2 1.1M21.5 12S18 17.5 12 17.5c-1.6 0-3-.45-4.2-1.1M4 4l16 16',
1267
+ };
1268
+
1269
+ /** One 16px line glyph, one 1.5 stroke, colours inherited from the text role. */
1270
+ function Icon(props) {
1271
+ const d = ICON_D[props.name] || '';
1272
+ const children = [
1273
+ h('path', {
1274
+ key: 'p',
1275
+ d,
1276
+ stroke: 'currentColor',
1277
+ strokeWidth: 1.5,
1278
+ strokeLinecap: 'round',
1279
+ strokeLinejoin: 'round',
1280
+ }),
1281
+ ];
1282
+ if (props.name === 'eye' || props.name === 'eyeOff') {
1283
+ children.push(h('circle', { key: 'c', cx: 12, cy: 12, r: 2.6, stroke: 'currentColor', strokeWidth: 1.5 }));
1284
+ }
1285
+ return h(
1286
+ 'svg',
1287
+ {
1288
+ className: 'dpc-ico',
1289
+ width: 16,
1290
+ height: 16,
1291
+ viewBox: '0 0 24 24',
1292
+ fill: 'none',
1293
+ 'aria-hidden': true,
1294
+ focusable: 'false',
1295
+ },
1296
+ children,
1297
+ );
1298
+ }
1299
+
1300
+ function SlidersIcon(props) {
1301
+ const size = props && typeof props.size === 'number' ? props.size : 18;
1302
+ const active = Boolean(props && props.active);
1303
+ return h(
1304
+ 'svg',
1305
+ {
1306
+ width: size,
1307
+ height: size,
1308
+ viewBox: '0 0 24 24',
1309
+ fill: 'none',
1310
+ 'aria-hidden': true,
1311
+ style: { display: 'block', opacity: active ? 1 : 0.78 },
1312
+ },
1313
+ h('path', {
1314
+ d: 'M3 6.5h7M14 6.5h7M3 12h11M18 12h3M3 17.5h4M11 17.5h10',
1315
+ stroke: 'currentColor',
1316
+ strokeWidth: 1.6,
1317
+ strokeLinecap: 'round',
1318
+ }),
1319
+ h('circle', { cx: 12.5, cy: 6.5, r: 2.1, stroke: 'currentColor', strokeWidth: 1.6 }),
1320
+ h('circle', { cx: 16.5, cy: 12, r: 2.1, stroke: 'currentColor', strokeWidth: 1.6 }),
1321
+ h('circle', { cx: 9.5, cy: 17.5, r: 2.1, stroke: 'currentColor', strokeWidth: 1.6 }),
1322
+ );
1323
+ }
1324
+
1325
+ const PanelIcon = SlidersIcon;
1326
+
1327
+ /** The entry's own glyph, borrowed from the slot it registered. */
1328
+ function Glyph(props) {
1329
+ const [node, setNode] = React.useState(null);
1330
+ React.useEffect(() => {
1331
+ let value = null;
1332
+ try {
1333
+ const slots = ctxRef && ctxRef.slots;
1334
+ if (slots && typeof slots.renderSlot === 'function') {
1335
+ value = slots.renderSlot(SLOT_PANELS, { size: 16, active: false }, { only: props.id });
1336
+ }
1337
+ } catch {
1338
+ value = null;
1339
+ }
1340
+ setNode(value === undefined ? null : value);
1341
+ }, [props.id]);
1342
+ return h('span', { className: 'dpc-glyph', 'aria-hidden': true }, node);
1343
+ }
1344
+
1345
+ // A native <select> gets its option list painted by the OS widget: it ignores
1346
+ // page colors and pops up white-on-white against this theme. This picker draws
1347
+ // the same choice in our own colors.
1348
+ function GroupPicker(props) {
1349
+ const [open, setOpen] = React.useState(false);
1350
+ const rootRef = React.useRef(null);
1351
+ React.useEffect(() => {
1352
+ if (!open) return undefined;
1353
+ const close = (event) => {
1354
+ if (event.type === 'keydown' && event.key !== 'Escape') return;
1355
+ if (event.type === 'mousedown' && rootRef.current && rootRef.current.contains(event.target)) return;
1356
+ setOpen(false);
1357
+ };
1358
+ document.addEventListener('mousedown', close, true);
1359
+ document.addEventListener('keydown', close, true);
1360
+ return () => {
1361
+ document.removeEventListener('mousedown', close, true);
1362
+ document.removeEventListener('keydown', close, true);
1363
+ };
1364
+ }, [open]);
1365
+ const value = props.value || '';
1366
+ const options = [{ value: '', label: props.ungrouped }].concat(
1367
+ (props.groups || []).map((group) => ({ value: group.id, label: group.name })),
1368
+ );
1369
+ const current = options.find((option) => option.value === value) || options[0];
1370
+ return h(
1371
+ 'div',
1372
+ { className: 'dpc-picker', ref: rootRef, 'data-open': open ? 'true' : undefined },
1373
+ h(
1374
+ 'button',
1375
+ {
1376
+ type: 'button',
1377
+ className: 'dpc-pickerBtn',
1378
+ 'aria-haspopup': 'listbox',
1379
+ 'aria-expanded': open ? 'true' : 'false',
1380
+ 'aria-label': props.label,
1381
+ title: current.label,
1382
+ onClick: () => setOpen((was) => !was),
1383
+ },
1384
+ h('span', { className: 'dpc-pickerText' }, current.label),
1385
+ h('span', { className: 'dpc-caret', 'aria-hidden': 'true' }),
1386
+ ),
1387
+ open
1388
+ ? h(
1389
+ 'div',
1390
+ { className: 'dpc-menu', role: 'listbox', 'aria-label': props.label },
1391
+ options.map((option) =>
1392
+ h(
1393
+ 'button',
1394
+ {
1395
+ key: option.value || '__ungrouped',
1396
+ type: 'button',
1397
+ role: 'option',
1398
+ className: 'dpc-option',
1399
+ 'aria-selected': option.value === value ? 'true' : 'false',
1400
+ onClick: () => {
1401
+ setOpen(false);
1402
+ props.onPick(option.value);
1403
+ },
1404
+ },
1405
+ option.label,
1406
+ ),
1407
+ ),
1408
+ )
1409
+ : null,
1410
+ );
1411
+ }
1412
+
1413
+ function PanelManager(props) {
1414
+ // The same body serves the main panel and the settings section: the section
1415
+ // is already inside a framed column, so it opts out of the page frame.
1416
+ const inSection = !!(props && props.variant === 'section');
1417
+ const state = useStore();
1418
+ const prefs = state.prefs;
1419
+ const entries = state.entries;
1420
+ const engine = engineRef;
1421
+ const dragRef = React.useRef(null);
1422
+ const [rowDrag, setRowDrag] = React.useState({ from: null, over: null });
1423
+ // Dismissing the banner is a local decision, not a store update: the same
1424
+ // check runs again on focus, and the answer must not pop back up.
1425
+ const [, forceRender] = React.useReducer((n) => n + 1, 0);
1426
+ const [groupDrag, setGroupDrag] = React.useState({ from: null, over: null });
1427
+ /**
1428
+ * Dragging works the same for a row and for a section: the handle starts it,
1429
+ * the target highlights, dropping commits "move before this one". Only the
1430
+ * commit differs, so the four handlers live here once instead of twice.
1431
+ */
1432
+ const dragProps = (id, state, setDrag, commitMove) => ({
1433
+ draggable: true,
1434
+ 'data-dragging': state.from === id ? 'true' : 'false',
1435
+ 'data-over': state.over === id && state.from !== id ? 'true' : 'false',
1436
+ onDragStart: (event) => {
1437
+ dragRef.current = id;
1438
+ setDrag({ from: id, over: null });
1439
+ try {
1440
+ event.dataTransfer.effectAllowed = 'move';
1441
+ event.dataTransfer.setData('text/plain', id);
1442
+ } catch {
1443
+ /* older engines refuse writes; the ref already holds the id */
1444
+ }
1445
+ },
1446
+ onDragOver: (event) => {
1447
+ event.preventDefault();
1448
+ setDrag((prev) => (prev.over === id ? prev : { ...prev, over: id }));
1449
+ },
1450
+ onDrop: (event) => {
1451
+ event.preventDefault();
1452
+ const from = dragRef.current;
1453
+ dragRef.current = null;
1454
+ setDrag({ from: null, over: null });
1455
+ if (from && from !== id) commitMove(from, id);
1456
+ },
1457
+ onDragEnd: () => {
1458
+ dragRef.current = null;
1459
+ setDrag({ from: null, over: null });
1460
+ },
1461
+ });
1462
+
1463
+ React.useEffect(() => {
1464
+ if (engine) engine.refresh();
1465
+ }, [engine]);
1466
+
1467
+ // While the switch is on, only entries this switch has never seen are filed:
1468
+ // the list must not be re-sorted behind the user's back. A failure backs off
1469
+ // for a minute and then tries once more, so a dead route cannot loop.
1470
+ React.useEffect(() => {
1471
+ if (prefs.auto !== true) return;
1472
+ const auto = state.autoState || {};
1473
+ if (auto.status === 'running') return;
1474
+ if (auto.status === 'error' && Date.now() - (auto.at || 0) < AUTO_RETRY_MS) return;
1475
+ const filed = autoFiledSet();
1476
+ // A settings file written before autoFiled existed already carries the
1477
+ // classification the user accepted: treat it as filed, never re-sort it.
1478
+ if (filed.size === 0 && (prefs.groups || []).length > 0) {
1479
+ for (const entry of entries) filed.add(entry.id);
1480
+ }
1481
+ const unseen = entries.filter((entry) => !filed.has(entry.id));
1482
+ if (unseen.length === 0) return;
1483
+ const timer = setTimeout(() => {
1484
+ void classifyNow('new');
1485
+ }, AUTO_DELAY_MS);
1486
+ return () => clearTimeout(timer);
1487
+ }, [prefs.auto, prefs.autoFiled, entries.length, state.autoState && state.autoState.status]);
1488
+
1489
+ const order = normalize(prefs, entries);
1490
+ const labelOf = new Map(entries.map((entry) => [entry.id, entry.label]));
1491
+ const hidden = new Set(prefs.hidden || []);
1492
+ const groupsOf = prefs.groups || [];
1493
+ const autoOn = prefs.auto === true;
1494
+ const autoState = state.autoState || { status: 'idle', message: '', groups: 0 };
1495
+
1496
+ const head = h(
1497
+ 'header',
1498
+ { className: inSection ? 'dpc-head dpc-head--section' : 'dpc-head' },
1499
+ h(
1500
+ 'div',
1501
+ { className: 'dpc-headLine' },
1502
+ h('h1', { className: 'dpc-title' }, t('panelTitle')),
1503
+ h(
1504
+ 'div',
1505
+ { className: 'dpc-toolbarActions' },
1506
+ h('button', { type: 'button', className: 'dpc-link', onClick: sortByName }, t('sortByName')),
1507
+ h(
1508
+ 'button',
1509
+ {
1510
+ type: 'button',
1511
+ className: 'dpc-link',
1512
+ onClick: showAll,
1513
+ disabled: hidden.size === 0,
1514
+ },
1515
+ t('showAll'),
1516
+ ),
1517
+ h('button', { type: 'button', className: 'dpc-link', onClick: resetAll }, t('reset')),
1518
+ ),
1519
+ ),
1520
+ h(
1521
+ 'p',
1522
+ { className: 'dpc-meta' },
1523
+ h('span', { className: 'dpc-lede' }, t('subtitle')),
1524
+ h('span', { className: 'dpc-sepText', 'aria-hidden': true }, '·'),
1525
+ h('span', { className: 'dpc-count' }, fill(t('countFmt'), { n: order.length, h: hidden.size })),
1526
+ ),
1527
+ h('p', { className: 'dpc-note dpc-hint' }, t('hint')),
1528
+ );
1529
+
1530
+ // The page runs the plugin list it booted with. When the Host has moved on
1531
+ // (a plugin was added, removed or rebuilt), say so and offer the one action
1532
+ // that fixes it — no other plugin is involved in the check or the reload.
1533
+ const inventory = state.inventory || { status: 'idle', stale: false, missing: [] };
1534
+ const missing = Array.isArray(inventory.missing) ? inventory.missing : [];
1535
+ // The banner answers one question: is something this page should show still
1536
+ // absent? It stays dismissed for the exact same gap, and it says "still
1537
+ // missing" only for a reload this page made moments ago that did not help.
1538
+ const skipped = missing.length > 0 && readSkip() === missing.join(',');
1539
+ const mark = readReloadMark();
1540
+ const stillMissing =
1541
+ !!mark && missing.length > 0 && mark.ids.length > 0 && missing.some((id) => mark.ids.includes(id));
1542
+ const notice =
1543
+ inventory.stale !== true || skipped
1544
+ ? null
1545
+ : h(
1546
+ 'section',
1547
+ { className: 'dpc-notice', role: 'status' },
1548
+ h(
1549
+ 'div',
1550
+ { className: 'dpc-noticeText' },
1551
+ h('p', { className: 'dpc-noticeTitle' }, t('missingTitle')),
1552
+ h(
1553
+ 'p',
1554
+ { className: 'dpc-note' },
1555
+ fill(t('missingBody'), {
1556
+ names:
1557
+ missing.slice(0, 3).join('、') +
1558
+ (missing.length > 3 ? fill(t('missingMore'), { n: missing.length - 3 }) : ''),
1559
+ }),
1560
+ ),
1561
+ stillMissing ? h('p', { className: 'dpc-note' }, t('refreshStillMissing')) : null,
1562
+ ),
1563
+ h('button', { type: 'button', className: 'dpc-noticeAction', onClick: reloadPage }, t('refreshPage')),
1564
+ h(
1565
+ 'button',
1566
+ {
1567
+ type: 'button',
1568
+ className: 'dpc-noticeClose',
1569
+ title: t('dismiss'),
1570
+ 'aria-label': t('dismiss'),
1571
+ onClick: () => {
1572
+ skipNotice(missing);
1573
+ forceRender();
1574
+ },
1575
+ },
1576
+ h(Icon, { name: 'close' }),
1577
+ ),
1578
+ );
1579
+
1580
+ const auto = h(
1581
+ 'section',
1582
+ { className: 'dpc-card dpc-auto' },
1583
+ h(
1584
+ 'div',
1585
+ { className: 'dpc-autoMain' },
1586
+ h(
1587
+ 'div',
1588
+ { className: 'dpc-autoText' },
1589
+ h('h2', { className: 'dpc-cardTitle' }, t('autoTitle')),
1590
+ h('p', { className: 'dpc-note dpc-inlineNote' }, t('autoHint')),
1591
+ ),
1592
+ h(
1593
+ 'div',
1594
+ { className: 'dpc-autoActions' },
1595
+ autoOn
1596
+ ? h(
1597
+ 'button',
1598
+ {
1599
+ type: 'button',
1600
+ className: 'dpc-link',
1601
+ onClick: () => classifyNow(),
1602
+ disabled: autoState.status === 'running',
1603
+ },
1604
+ t('autoAgain'),
1605
+ )
1606
+ : null,
1607
+ h(
1608
+ 'button',
1609
+ {
1610
+ type: 'button',
1611
+ className: 'dpc-switch',
1612
+ role: 'switch',
1613
+ 'aria-checked': autoOn ? 'true' : 'false',
1614
+ 'aria-label': t('autoTitle'),
1615
+ onClick: toggleAuto,
1616
+ },
1617
+ h('span', { className: 'dpc-switchKnob', 'aria-hidden': true }),
1618
+ ),
1619
+ ),
1620
+ ),
1621
+ autoState.status === 'idle' || autoState.message === ''
1622
+ ? null
1623
+ : h(
1624
+ 'p',
1625
+ {
1626
+ className: 'dpc-status',
1627
+ 'data-error': autoState.status === 'error' ? 'true' : 'false',
1628
+ 'aria-live': 'polite',
1629
+ },
1630
+ autoState.message,
1631
+ ),
1632
+ );
1633
+
1634
+ const iconButton = (name, label, onClick, disabled, extra) =>
1635
+ h(
1636
+ 'button',
1637
+ Object.assign(
1638
+ {
1639
+ type: 'button',
1640
+ className: 'dpc-icon',
1641
+ onClick,
1642
+ disabled: Boolean(disabled),
1643
+ 'aria-label': label,
1644
+ title: label,
1645
+ },
1646
+ extra || {},
1647
+ ),
1648
+ h(Icon, { name }),
1649
+ );
1650
+
1651
+ const rows = order.map((id, position) => {
1652
+ const label = labelOf.get(id) || id;
1653
+ const isHidden = hidden.has(id);
1654
+ const locked = id === PANEL_KEY;
1655
+ return h(
1656
+ 'li',
1657
+ {
1658
+ key: id,
1659
+ className: 'dpc-row',
1660
+ 'data-hidden': isHidden ? 'true' : 'false',
1661
+ 'data-locked': locked ? 'true' : 'false',
1662
+ ...dragProps(id, rowDrag, setRowDrag, moveBefore),
1663
+ },
1664
+ h('span', { className: 'dpc-grip', title: t('dragHint') }, h(Icon, { name: 'grip' })),
1665
+ h('span', { className: 'dpc-pos' }, String(position + 1)),
1666
+ h(Glyph, { id }),
1667
+ h(
1668
+ 'span',
1669
+ { className: 'dpc-rowText' },
1670
+ h('span', { className: 'dpc-name', title: label }, label),
1671
+ h('code', { className: 'dpc-key', title: id }, id),
1672
+ ),
1673
+ h(
1674
+ 'span',
1675
+ { className: 'dpc-tags' },
1676
+ locked ? h('span', { className: 'dpc-tag' }, t('locked')) : null,
1677
+ isHidden ? h('span', { className: 'dpc-tag' }, t('hiddenChip')) : null,
1678
+ ),
1679
+ groupsOf.length === 0
1680
+ ? null
1681
+ : h(GroupPicker, {
1682
+ id,
1683
+ groups: groupsOf,
1684
+ value: prefs.assign[id] || '',
1685
+ label: t('groupLabel'),
1686
+ ungrouped: t('ungrouped'),
1687
+ onPick: (next) => setGroup(id, next),
1688
+ }),
1689
+ iconButton(
1690
+ isHidden ? 'eyeOff' : 'eye',
1691
+ locked ? t('lockedHint') : isHidden ? t('showIt') : t('hideIt'),
1692
+ () => toggleHidden(id),
1693
+ locked,
1694
+ { 'aria-pressed': isHidden ? 'false' : 'true', className: 'dpc-icon dpc-eye' },
1695
+ ),
1696
+ );
1697
+ });
1698
+
1699
+ const groupRow = (group, index) => {
1700
+ const members = order.filter((id) => (prefs.assign[id] || '') === group.id);
1701
+ return h(
1702
+ 'li',
1703
+ {
1704
+ key: group.id,
1705
+ className: 'dpc-group',
1706
+ ...dragProps(group.id, groupDrag, setGroupDrag, moveGroupBefore),
1707
+ },
1708
+ h('span', { className: 'dpc-grip', title: t('dragHint') }, h(Icon, { name: 'grip' })),
1709
+ h('span', { className: 'dpc-groupDot', 'aria-hidden': true }),
1710
+ h('input', {
1711
+ className: 'dpc-input',
1712
+ value: group.name,
1713
+ 'aria-label': t('groupName'),
1714
+ onChange: (event) => renameGroup(group.id, event.target.value),
1715
+ }),
1716
+ h(
1717
+ 'span',
1718
+ { className: 'dpc-groupActions' },
1719
+ h('span', { className: 'dpc-count' }, String(members.length)),
1720
+ iconButton('close', t('deleteGroup'), () => removeGroup(group.id), false),
1721
+ ),
1722
+ );
1723
+ };
1724
+
1725
+ const groups =
1726
+ groupsOf.length === 0
1727
+ ? h(
1728
+ 'section',
1729
+ { className: 'dpc-card dpc-empty' },
1730
+ h('p', { className: 'dpc-emptyTitle' }, t('noGroups')),
1731
+ h(
1732
+ 'button',
1733
+ { type: 'button', className: 'dpc-link', onClick: addGroup },
1734
+ t('newGroup'),
1735
+ ),
1736
+ )
1737
+ : h(
1738
+ 'section',
1739
+ { className: 'dpc-section' },
1740
+ h(
1741
+ 'div',
1742
+ { className: 'dpc-sectionHead' },
1743
+ h('h2', { className: 'dpc-sectionTitle' }, t('groupsTitle')),
1744
+ h(
1745
+ 'button',
1746
+ { type: 'button', className: 'dpc-link', onClick: addGroup },
1747
+ t('newGroup'),
1748
+ ),
1749
+ ),
1750
+ h('p', { className: 'dpc-note' }, t('groupsHint')),
1751
+ h('ul', { className: 'dpc-groups' }, groupsOf.map(groupRow)),
1752
+ );
1753
+
1754
+ const diagnostics = engine ? engine.diagnostics() : null;
1755
+ const diagRow = (term, value, mono) =>
1756
+ [h('dt', { key: 't' }, term), h('dd', { key: 'd', className: mono ? 'dpc-mono' : undefined }, value)];
1757
+ const diag = h(
1758
+ 'details',
1759
+ { className: 'dpc-diag' },
1760
+ h('summary', { className: 'dpc-diagSummary' }, t('diagTitle')),
1761
+ h(
1762
+ 'dl',
1763
+ { className: 'dpc-diagList' },
1764
+ diagRow(t('diagList'), diagnostics && diagnostics.found ? `${t('diagFound')} · ${diagnostics.listClass || '—'}` : t('diagMissing'), true),
1765
+ diagRow(t('diagRows'), String(diagnostics ? diagnostics.rows : 0), true),
1766
+ diagRow(t('diagMapped'), String(diagnostics ? diagnostics.mapped : 0), true),
1767
+ diagRow(t('diagSource'), String(entries.length), true),
1768
+ diagRow(t('diagPrefs'), (state.diag && state.diag.prefsSource) || '—', true),
1769
+ diagRow(t('diagFile'), (state.diag && state.diag.hostFile) || '—', true),
1770
+ diagRow(t('diagOrigin'), (state.diag && state.diag.href) || '—', true),
1771
+ diagRow(t('diagAt'), diagnostics && diagnostics.at ? new Date(diagnostics.at).toLocaleTimeString() : t('diagNone'), true),
1772
+ diagRow(t('diagHostRev'), (state.inventory && state.inventory.rev) || '—', true),
1773
+ diagRow(t('diagBootRev'), (state.inventory && state.inventory.bootRev) || '—', true),
1774
+ diagRow(t('diagMissingIds'), (state.inventory && (state.inventory.missing || []).join(', ')) || t('diagNone'), true),
1775
+ diagRow(
1776
+ t('diagMissingStuck'),
1777
+ state.inventory && state.inventory.repeated ? t('diagYes') : t('diagNo'),
1778
+ true,
1779
+ ),
1780
+ diagnostics && diagnostics.error ? h('dt', { key: 'et' }, 'error') : null,
1781
+ diagnostics && diagnostics.error
1782
+ ? h('dd', { key: 'ed', className: 'dpc-error' }, diagnostics.error)
1783
+ : null,
1784
+ ),
1785
+ );
1786
+
1787
+ return h(
1788
+ 'div',
1789
+ { className: inSection ? 'dpc-root dpc-root--flush' : 'dpc-root' },
1790
+ h(
1791
+ 'div',
1792
+ { className: inSection ? 'dpc-page dpc-page--flush' : 'dpc-page' },
1793
+ head,
1794
+ notice,
1795
+ auto,
1796
+ order.length === 0
1797
+ ? h(
1798
+ 'section',
1799
+ { className: 'dpc-card dpc-empty' },
1800
+ h('p', { className: 'dpc-emptyTitle' }, t('empty')),
1801
+ h('button', { type: 'button', className: 'dpc-link', onClick: resetAll }, t('reset')),
1802
+ )
1803
+ : h('ul', { className: 'dpc-list' }, rows),
1804
+ groups,
1805
+ diag,
1806
+ ),
1807
+ );
1808
+ }
1809
+
1810
+ // Settings shows the manager itself. Sending the reader to another screen to
1811
+ // do the work they came here for is a detour nobody asked for.
1812
+ function SettingsEntry() {
1813
+ return h('div', { className: 'dpc-settingsBody' }, h(PanelManager, { variant: 'section' }));
1814
+ }
1815
+
1816
+ /* ------------------------------------------------------------------ css */
1817
+
1818
+ const PANEL_CSS = `
1819
+ /* Page frame: scrolling lives on the page, the gutter on the content column. */
1820
+ .dpc-root{position:relative;height:100%;min-height:0;overflow-y:auto;overflow-x:hidden;padding:calc(28px + var(--dsh-frame-top-clearance,0px)) 0 32px;box-sizing:border-box;color:var(--dsw-alias-label-primary,#8a8a8a);font-size:13px;line-height:20px;font-variant-numeric:tabular-nums}
1821
+ .dpc-root *{box-sizing:border-box}
1822
+ .dpc-root :focus-visible{outline:2px solid var(--dsw-focus-ring-color,rgba(127,127,127,.65));outline-offset:2px}
1823
+ .dpc-page{container-type:inline-size;display:flex;flex-direction:column;gap:24px;width:min(960px,100%);min-width:0;margin-inline:auto;animation:dpc-in 180ms cubic-bezier(.16,1,.3,1) both}
1824
+ @keyframes dpc-in{from{opacity:0;transform:translateY(4px)}to{opacity:1;transform:none}}
1825
+
1826
+ .dpc-head{display:flex;flex-direction:column;gap:6px}
1827
+ .dpc-headLine{display:flex;align-items:baseline;justify-content:space-between;gap:16px}
1828
+ .dpc-meta{display:flex;align-items:baseline;gap:8px;margin:0;min-width:0;overflow:hidden;white-space:nowrap}
1829
+ .dpc-title{margin:0;font-size:16px;font-weight:600;line-height:24px;letter-spacing:-.01em;color:var(--dsw-alias-label-primary,#8a8a8a)}
1830
+ .dpc-lede{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8));overflow:hidden;text-overflow:ellipsis}
1831
+ .dpc-sepText{flex:none;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.6))}
1832
+ .dpc-toolbarActions{display:flex;align-items:center;gap:4px}
1833
+ .dpc-count{margin:0;flex:none;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8));white-space:nowrap}
1834
+ .dpc-link{appearance:none;border:0;background:transparent;padding:4px 8px;border-radius:var(--dsw-radius-sm,6px);font:inherit;font-size:12px;line-height:16px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95));cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1),color 120ms cubic-bezier(.16,1,.3,1)}
1835
+ .dpc-link:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));color:var(--dsw-alias-label-primary,#8a8a8a)}
1836
+ .dpc-link:disabled{opacity:.4;cursor:default}
1837
+
1838
+ .dpc-card{padding:16px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.20));border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.06))}
1839
+ .dpc-cardTitle{margin:0;font-size:13px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-primary,#8a8a8a)}
1840
+ .dpc-note{margin:4px 0 0;max-width:none;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
1841
+ .dpc-notice{display:flex;align-items:center;gap:16px;padding:12px 14px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.18));border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-bg-l2,rgba(127,127,127,.08))}
1842
+ .dpc-noticeText{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 auto}
1843
+ .dpc-noticeTitle{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-primary,#ececf1)}
1844
+ .dpc-notice .dpc-note{margin:0}
1845
+ .dpc-noticeAction{appearance:none;flex:none;padding:4px 10px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-primary,#ececf1);font:inherit;font-size:12px;line-height:18px;cursor:pointer}
1846
+ .dpc-noticeAction:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16))}
1847
+ .dpc-noticeClose{appearance:none;flex:none;display:grid;place-items:center;width:24px;height:24px;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8));cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1),color 120ms cubic-bezier(.16,1,.3,1)}
1848
+ .dpc-noticeClose:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));color:var(--dsw-alias-label-primary,#ececf1)}
1849
+ .dpc-auto{display:flex;flex-direction:column;gap:8px}
1850
+ .dpc-autoMain{display:flex;align-items:center;justify-content:space-between;gap:16px}
1851
+ .dpc-autoText{display:flex;align-items:baseline;gap:8px;min-width:0;overflow:hidden}
1852
+ .dpc-autoText .dpc-cardTitle{flex:none;white-space:nowrap}
1853
+ .dpc-inlineNote{margin:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1854
+ .dpc-autoActions{display:flex;flex:none;align-items:center;gap:8px}
1855
+ .dpc-status{margin:0;font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
1856
+ .dpc-status[data-error="true"]{color:var(--dsw-alias-state-error-primary,#b45454)}
1857
+
1858
+ .dpc-switch{position:relative;flex:none;appearance:none;width:36px;height:20px;padding:0;border:0;border-radius:999px;background:var(--dsw-alias-bg-l3,rgba(127,127,127,.24));cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1)}
1859
+ .dpc-switch[aria-checked="true"]{background:var(--dsw-alias-state-business-primary,#5865f2)}
1860
+ .dpc-switchKnob{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;background:var(--dsw-alias-label-primary-inverted,#8a8a8a);transition:transform 120ms cubic-bezier(.16,1,.3,1)}
1861
+ .dpc-switch[aria-checked="true"] .dpc-switchKnob{transform:translateX(16px)}
1862
+
1863
+ .dpc-list{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px;min-width:0}
1864
+ .dpc-row{display:flex;align-items:center;gap:8px;min-height:40px;padding:6px 12px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.20));border-radius:var(--dsw-radius-md,10px);background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.06));transition:background-color 120ms cubic-bezier(.16,1,.3,1),border-color 120ms cubic-bezier(.16,1,.3,1),opacity 120ms cubic-bezier(.16,1,.3,1)}
1865
+ .dpc-row:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.12));border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.28))}
1866
+ .dpc-row:focus-within{border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.32))}
1867
+ .dpc-row[data-dragging="true"],.dpc-group[data-dragging="true"]{opacity:.45}
1868
+ .dpc-row[data-over="true"],.dpc-group[data-over="true"]{border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.32));background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.10))}
1869
+ .dpc-grip{cursor:grab}
1870
+ .dpc-grip:active{cursor:grabbing}
1871
+ .dpc-row[data-hidden="true"]{opacity:.55}
1872
+ .dpc-row[data-hidden="true"] .dpc-name{color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
1873
+ .dpc-row[data-locked="true"]{background:var(--dsw-alias-bg-l2,rgba(127,127,127,.10))}
1874
+ .dpc-grip{display:inline-flex;flex:none;user-select:none;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.6));cursor:grab}
1875
+ .dpc-grip:active{cursor:grabbing}
1876
+ .dpc-pos{flex:none;min-width:16px;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:11px;line-height:16px;text-align:right;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.7))}
1877
+ .dpc-glyph{display:inline-flex;flex:none;align-items:center;justify-content:center;width:16px;height:16px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.9))}
1878
+ .dpc-glyph:empty::before{content:'';width:6px;height:6px;border-radius:50%;background:currentColor;opacity:.4}
1879
+ .dpc-rowText{display:flex;flex:1 1 auto;flex-direction:row;align-items:baseline;gap:8px;min-width:0}
1880
+ .dpc-name{font-size:13px;font-weight:500;line-height:18px;flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1881
+ .dpc-key{font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:11px;line-height:16px;flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.7));overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1882
+ .dpc-tags{display:flex;flex:none;align-items:center;gap:8px}
1883
+ .dpc-tag{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8));white-space:nowrap}
1884
+ .dpc-icon{appearance:none;display:inline-flex;flex:none;align-items:center;justify-content:center;width:24px;height:24px;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.9));cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1),color 120ms cubic-bezier(.16,1,.3,1)}
1885
+ .dpc-icon:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.18));color:var(--dsw-alias-label-primary,#8a8a8a)}
1886
+ .dpc-icon:disabled{opacity:.3;cursor:default}
1887
+ .dpc-ico{display:block}
1888
+
1889
+ .dpc-picker{position:relative;display:flex;flex:none}
1890
+ .dpc-pickerBtn{appearance:none;display:inline-flex;align-items:center;gap:8px;height:24px;max-width:144px;padding:0 8px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-bg-l2,rgba(127,127,127,.10));color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95));font:inherit;font-size:12px;line-height:16px;cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1),border-color 120ms cubic-bezier(.16,1,.3,1),color 120ms cubic-bezier(.16,1,.3,1)}
1891
+ .dpc-pickerBtn:hover,.dpc-picker[data-open="true"] .dpc-pickerBtn{border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.32));background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));color:var(--dsw-alias-label-primary,#8a8a8a)}
1892
+ .dpc-pickerText{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1893
+ .dpc-caret{flex:none;width:6px;height:6px;margin-top:-2px;border-right:1.2px solid currentColor;border-bottom:1.2px solid currentColor;transform:rotate(45deg);opacity:.7;transition:transform 120ms cubic-bezier(.16,1,.3,1)}
1894
+ .dpc-picker[data-open="true"] .dpc-caret{margin-top:2px;transform:rotate(225deg)}
1895
+ .dpc-menu{position:absolute;z-index:50;top:calc(100% + 4px);right:0;display:flex;flex-direction:column;gap:2px;min-width:144px;max-width:240px;padding:4px;background:var(--dsw-alias-bg-elevated,rgba(127,127,127,.18));border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:var(--dsw-radius-md,8px);box-shadow:0 8px 24px rgba(0,0,0,.28)}
1896
+ .dpc-option{appearance:none;border:0;padding:4px 8px;border-radius:var(--dsw-radius-xs,4px);background:transparent;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95));font:inherit;font-size:12px;line-height:16px;text-align:left;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1897
+ .dpc-option:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.18));color:var(--dsw-alias-label-primary,#8a8a8a)}
1898
+ .dpc-option[aria-selected="true"]{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#5865f2) 16%,transparent);color:var(--dsw-alias-label-primary-bluish,#7f8fd8)}
1899
+
1900
+ .dpc-section{display:flex;flex-direction:column;gap:8px;padding-top:16px;border-top:.5px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
1901
+ .dpc-sectionHead{display:flex;align-items:center;justify-content:space-between;gap:16px}
1902
+ .dpc-sectionTitle{margin:0;font-size:13px;font-weight:500;line-height:20px;color:var(--dsw-alias-label-primary,#8a8a8a)}
1903
+ .dpc-groups{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:4px}
1904
+ .dpc-group{display:flex;align-items:center;gap:8px;min-height:40px;padding:6px 12px 6px 8px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.20));border-radius:var(--dsw-radius-md,10px);background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.06));transition:background-color 120ms cubic-bezier(.16,1,.3,1),border-color 120ms cubic-bezier(.16,1,.3,1),opacity 120ms cubic-bezier(.16,1,.3,1)}
1905
+ .dpc-group:hover{border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.28))}
1906
+ .dpc-groupDot{flex:none;width:8px;height:8px;border-radius:2px;background:var(--dsw-alias-state-business-primary,#5865f2);opacity:.8}
1907
+ .dpc-groupActions{display:flex;flex:none;align-items:center;gap:8px;margin-left:auto}
1908
+ .dpc-group .dpc-count{min-width:20px;text-align:right}
1909
+ .dpc-input{flex:0 1 auto;width:auto;min-width:2ch;max-width:320px;field-sizing:content;box-sizing:border-box;height:auto;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l1,rgba(127,127,127,.12));border-radius:6px;background:transparent;color:var(--dsw-alias-label-primary,#8a8a8a);font:inherit;font-size:12px;line-height:18px;transition:border-color 120ms cubic-bezier(.16,1,.3,1),background-color 120ms cubic-bezier(.16,1,.3,1)}
1910
+ .dpc-group:hover .dpc-input{border-color:var(--dsw-alias-border-l2,rgba(127,127,127,.24))}
1911
+ .dpc-input:focus{outline:none;border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.36));background:var(--dsw-alias-bg-l2,rgba(127,127,127,.10))}
1912
+
1913
+ .dpc-empty{display:flex;flex-direction:column;align-items:center;gap:8px;width:100%;max-width:520px;margin:0 auto;padding:56px 32px 40px;text-align:center}
1914
+ .dpc-emptyTitle{margin:0;font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
1915
+
1916
+ .dpc-diag{padding-top:16px;border-top:.5px solid var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
1917
+ .dpc-diagSummary{cursor:pointer;font-size:12px;line-height:18px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
1918
+ .dpc-diagSummary:hover{color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
1919
+ .dpc-diagList{display:grid;grid-template-columns:minmax(0,max-content) minmax(0,1fr);gap:4px 16px;margin:12px 0 0;font-size:11px;line-height:16px}
1920
+ .dpc-diagList dt{color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
1921
+ .dpc-diagList dd{margin:0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
1922
+ .dpc-mono{font-family:var(--dsw-font-family-code,ui-monospace,monospace)}
1923
+ .dpc-error{color:var(--dsw-alias-state-error-primary,#b45454)}
1924
+
1925
+ /* Settings section: no page frame, no scroller — the dialog owns both. */
1926
+ .dpc-root--flush{height:auto;overflow:visible;padding:0}
1927
+ .dpc-page--flush{max-width:none;margin-inline:0;padding-inline:0;gap:16px;animation:none}
1928
+ .dpc-head--section .dpc-title{display:none}
1929
+ .dpc-head--section .dpc-headLine{justify-content:flex-end}
1930
+ .dpc-settingsBody{min-width:0}
1931
+ .dpc-primary{appearance:none;flex:none;border:0;padding:4px 12px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-button-primary-fill,#5865f2);color:var(--dsw-alias-label-primary-inverted,#8a8a8a);font:inherit;font-size:12px;font-weight:500;line-height:20px;cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1)}
1932
+ .dpc-primary:hover{background:var(--dsw-alias-button-primary-hover,#4a56d8)}
1933
+ .dpc-ghost{appearance:none;flex:none;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));padding:3px 10px;border-radius:var(--dsw-radius-sm,6px);background:transparent;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95));font:inherit;font-size:12px;line-height:20px;cursor:pointer;transition:background-color 120ms cubic-bezier(.16,1,.3,1),border-color 120ms cubic-bezier(.16,1,.3,1),color 120ms cubic-bezier(.16,1,.3,1)}
1934
+ .dpc-ghost:hover:not(:disabled){border-color:var(--dsw-alias-border-l3,rgba(127,127,127,.32));background:var(--dsw-alias-interactive-bg-hover,rgba(127,127,127,.16));color:var(--dsw-alias-label-primary,#8a8a8a)}
1935
+ .dpc-ghost:disabled{opacity:.4;cursor:default}
1936
+
1937
+ @media (max-width:1000px){
1938
+ /* Below the column width the frame becomes the gutter instead of pushing
1939
+ content past the window edge. */
1940
+ .dpc-page{width:100%;padding-inline:16px}
1941
+ }
1942
+ @media (max-width:760px){
1943
+ .dpc-row{flex-wrap:wrap;gap:8px}
1944
+ .dpc-rowText{flex:1 1 120px}
1945
+ .dpc-tags{order:8}
1946
+ }
1947
+ /* The settings column is narrower than the page column: drop what is only
1948
+ nice-to-have there instead of letting the row overflow. */
1949
+ @container (max-width:640px){
1950
+ .dpc-hint{display:none}
1951
+ .dpc-pos{display:none}
1952
+ .dpc-key{display:none}
1953
+ .dpc-tags{display:none}
1954
+ }
1955
+ @media (prefers-reduced-motion:reduce){
1956
+ .dpc-page{animation:none}
1957
+ .dpc-row,.dpc-group,.dpc-icon,.dpc-link,.dpc-switch,.dpc-switchKnob,.dpc-pickerBtn,.dpc-caret,.dpc-input,.dpc-ghost,.dpc-primary{transition:none}
1958
+ }
1959
+ `;
1960
+
1961
+ const SIDEBAR_CSS = `
1962
+ .dpc-sep{display:flex;align-items:center;gap:8px;flex:none;padding:12px 8px 4px;font-size:11px;font-weight:500;line-height:14px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.78));white-space:nowrap;overflow:hidden;pointer-events:none}
1963
+ .dpc-sepLabel{min-width:0;overflow:hidden;text-overflow:ellipsis}
1964
+ .dpc-sep::after{content:'';flex:1;height:.5px;background:var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
1965
+ `;
1966
+
1967
+
1968
+ /* ----------------------------------------------------------------- entry */
1969
+
1970
+ function applyInner(ctx) {
1971
+ ctxRef = ctx;
1972
+
1973
+ // A hot-swapped Client half re-runs apply() without reloading the page, so
1974
+ // the previous sheet is still in <head>: drop it, or two revisions of the
1975
+ // same class names fight over every rule.
1976
+ for (const stale of Array.from(document.querySelectorAll('style[data-dpc-style]'))) stale.remove();
1977
+ const style = document.createElement('style');
1978
+ style.setAttribute('data-dpc-style', '');
1979
+ style.textContent = `${PANEL_CSS}\n${SIDEBAR_CSS}`;
1980
+ const attach = () => {
1981
+ if (document.head) document.head.appendChild(style);
1982
+ };
1983
+ if (document.head) attach();
1984
+ else document.addEventListener('DOMContentLoaded', attach, { once: true });
1985
+
1986
+ ctx.effect(
1987
+ () => () => {
1988
+ style.remove();
1989
+ if (engineRef) engineRef.destroy();
1990
+ engineRef = null;
1991
+ ctxRef = null;
1992
+ },
1993
+ `${PACKAGE}: styles`,
1994
+ );
1995
+
1996
+ const locale = optionalService(ctx, 'locale');
1997
+ if (locale && typeof locale.register === 'function') {
1998
+ ctx.effect(() => {
1999
+ try {
2000
+ return locale.register(NS, { zh: flatten('zh'), en: flatten('en') });
2001
+ } catch (error) {
2002
+ console.error(`[${PACKAGE}] locale registration failed`, error);
2003
+ return () => {};
2004
+ }
2005
+ }, `${PACKAGE}: dictionaries`);
2006
+ }
2007
+
2008
+ engineRef = createEngine(ctx);
2009
+ engineRef.start();
2010
+
2011
+ // Settings live in the Host's state directory, not in localStorage: the
2012
+ // renderer origin is a loopback port that changes on every restart.
2013
+ void hydratePrefs().catch(() => {});
2014
+ // A failed probe must never take the panel down with it: the page can load
2015
+ // before the Host answers, and that is a "cannot tell" case, not an error
2016
+ // worth throwing through `apply`.
2017
+ const probe = () => {
2018
+ try {
2019
+ void checkInventory().catch(() => {});
2020
+ } catch {
2021
+ /* the check is advisory; the panel works without it */
2022
+ }
2023
+ };
2024
+ probe();
2025
+ // Coming back to this window is a natural moment to re-check: a plugin may
2026
+ // have been toggled while the page sat in the background.
2027
+ if (typeof window.addEventListener === 'function') {
2028
+ window.addEventListener('focus', probe);
2029
+ ctx.effect(() => () => window.removeEventListener('focus', probe), `${PACKAGE}: inventory`);
2030
+ }
2031
+ reportHost({ kind: 'boot', origin: locationMode(), agent: String(navigator.userAgent || '').slice(0, 120) });
2032
+
2033
+
2034
+ // A registration, a rename or a locale switch changes the list this plugin
2035
+ // lays out, so every one of them re-runs the pass.
2036
+ if (typeof ctx.slots.subscribe === 'function') {
2037
+ ctx.effect(() => ctx.slots.subscribe(SLOT_PANELS, () => engineRef && engineRef.schedule()), `${PACKAGE}: panel entries`);
2038
+ }
2039
+ if (locale && typeof locale.subscribe === 'function') {
2040
+ ctx.effect(() => locale.subscribe(() => engineRef && engineRef.schedule()), `${PACKAGE}: labels`);
2041
+ }
2042
+
2043
+ ctx.slots.inject(SLOT_MAIN, () =>
2044
+ ctx.slots.register({ name: SLOT_MAIN, key: PANEL_KEY, locale: NS }, PanelManager),
2045
+ );
2046
+
2047
+ ctx.slots.inject(SLOT_SETTINGS, () =>
2048
+ ctx.slots.register(
2049
+ { name: SLOT_SETTINGS, id: 'panel-control-settings', order: 60, locale: NS, label: () => t('panelTitle') },
2050
+ SettingsEntry,
2051
+ ),
2052
+ );
2053
+
2054
+ // The entry lives in the very list it manages: it is registered last, and
2055
+ // the manager keeps it visible so the page stays reachable.
2056
+ ctx.slots.inject(SLOT_PANELS, () =>
2057
+ ctx.slots.register(
2058
+ { name: SLOT_PANELS, id: PANEL_KEY, order: 90, locale: NS, label: () => t('panelTitle') },
2059
+ PanelIcon,
2060
+ ),
2061
+ );
2062
+ }
2063
+
2064
+ return {
2065
+ inject: ['slots'],
2066
+ // Pure helpers the browser harness drives directly, without a Cordis context.
2067
+ testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
2068
+ apply(ctx) {
2069
+ try {
2070
+ applyInner(ctx);
2071
+ } catch (error) {
2072
+ console.error(`[${PACKAGE}] apply failed`, error);
2073
+ reportHost({ kind: 'apply-failed', message: (error && error.message) || String(error) });
2074
+ }
2075
+ },
2076
+ };
2077
+ }
2078
+
2079
+ window.__ModuleLoader__.load({
2080
+ id: 'dsh-panel-control',
2081
+ factory(require) {
2082
+ try {
2083
+ return build(require);
2084
+ } catch (error) {
2085
+ // A throw here aborts the whole Web boot, so this half degrades to "absent".
2086
+ console.error('[dsh-panel-control] failed to build the client half', error);
2087
+ return { inject: [], apply() {} };
2088
+ }
2089
+ },
2090
+ });
2091
+
2092
+