dsh-panel-control 0.3.16 → 0.7.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.md +53 -15
- package/client.js +728 -36
- package/docs/discussion-bundle-evaluated-twice.md +100 -0
- package/docs/sidebar-layout.md +40 -0
- package/index.js +195 -135
- package/package.json +2 -2
- package/scripts/host-smoke.mjs +45 -2
- package/scripts/layout-smoke.mjs +48 -0
- package/scripts/read-page-trace.mjs +104 -0
package/client.js
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
// This bundle is served as a classic script and the page can evaluate it twice in one document
|
|
3
|
+
// (its own startup script plus the composed batch). At top level that is a "const" redeclaration,
|
|
4
|
+
// which is a parse-time SyntaxError - it aborts the boot instead of just skipping a duplicate.
|
|
5
|
+
// Everything below therefore lives inside this closure. Re-evaluation must stay possible: when the
|
|
6
|
+
// plugin is switched off the loader drops our factory, so switching it back on re-executes this
|
|
7
|
+
// script and needs the registration that follows.
|
|
8
|
+
const BUILD_STAMP = 'dsh-panel-control/0.7.1';
|
|
9
|
+
try { window.__dpcEvaluated = BUILD_STAMP; } catch { /* ignore */ }
|
|
1
10
|
/**
|
|
2
11
|
* Client half of dsh-panel-control.
|
|
3
12
|
*
|
|
@@ -21,7 +30,232 @@
|
|
|
21
30
|
* collapsed sidebar from leaving the list in a wrong state.
|
|
22
31
|
*/
|
|
23
32
|
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Page-level error recorder.
|
|
36
|
+
*
|
|
37
|
+
* Another plugin's client half can disappear while the Host still reports it
|
|
38
|
+
* running: the only place that failure is visible is this page's console, and
|
|
39
|
+
* the console is not persisted anywhere. This hook is installed at script
|
|
40
|
+
* evaluation time (before the rest of the boot list activates) and keeps a
|
|
41
|
+
* small ring of errors/warnings so a later report can carry them to the Host
|
|
42
|
+
* log. It must never throw and never change control flow.
|
|
43
|
+
*/
|
|
44
|
+
const PAGE_LOG = '__dpcPageLog';
|
|
45
|
+
(function installPageRecorder() {
|
|
46
|
+
try {
|
|
47
|
+
const box = window[PAGE_LOG] || (window[PAGE_LOG] = { started: Date.now(), events: [] });
|
|
48
|
+
if (box.installed) return;
|
|
49
|
+
box.installed = true;
|
|
50
|
+
const push = (kind, text) => {
|
|
51
|
+
try {
|
|
52
|
+
if (box.events.length >= 80) box.events.shift();
|
|
53
|
+
box.events.push(`${kind} ${String(text).slice(0, 600)}`);
|
|
54
|
+
pageBeacon(`page ${kind} ${String(text).slice(0, 500)}`);
|
|
55
|
+
const all = box.events.map((line) => String(line).replace(/[\r\n]+/g, ' ')).join(' ~ ');
|
|
56
|
+
pageNote(`events(${box.events.length}) ${all.slice(-3000)}`);
|
|
57
|
+
} catch { /* recording is best effort */ }
|
|
58
|
+
};
|
|
59
|
+
const describe = (value) => {
|
|
60
|
+
try {
|
|
61
|
+
if (!value) return '';
|
|
62
|
+
if (typeof value === 'string') return value;
|
|
63
|
+
if (value.stack) return String(value.stack);
|
|
64
|
+
if (value.message) return String(value.message);
|
|
65
|
+
return JSON.stringify(value).slice(0, 400);
|
|
66
|
+
} catch {
|
|
67
|
+
return String(value);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
window.addEventListener('error', (event) => {
|
|
71
|
+
push('error', `${(event && event.message) || 'error'} @${(event && event.filename) || ''}:${(event && event.lineno) || 0} ${describe(event && event.error)}`);
|
|
72
|
+
}, true);
|
|
73
|
+
window.addEventListener('unhandledrejection', (event) => {
|
|
74
|
+
push('rejection', describe(event && event.reason));
|
|
75
|
+
}, true);
|
|
76
|
+
for (const level of ['error', 'warn']) {
|
|
77
|
+
const original = console[level];
|
|
78
|
+
if (typeof original !== 'function') continue;
|
|
79
|
+
console[level] = function patchedConsole(...args) {
|
|
80
|
+
try {
|
|
81
|
+
push(`console.${level}`, args.map(describe).join(' '));
|
|
82
|
+
} catch { /* never break logging */ }
|
|
83
|
+
return original.apply(this, args);
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
} catch { /* the recorder is optional */ }
|
|
87
|
+
})();
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Trace channel that does not depend on the Host.
|
|
92
|
+
*
|
|
93
|
+
* `/plugins/` is answered by another plugin's shim (dsh-unlag) before our Host
|
|
94
|
+
* route runs, so a probe script cannot tell us what the page did — and the Host
|
|
95
|
+
* log cannot tell us whether our module ever executed. `localStorage` is written
|
|
96
|
+
* by this module itself, so reading Chromium's Local Storage LevelDB offline
|
|
97
|
+
* answers it exactly:
|
|
98
|
+
*
|
|
99
|
+
* top - the bundle executed at all (a missing key means it never did)
|
|
100
|
+
* build - the factory ran
|
|
101
|
+
* apply - the runner called our apply
|
|
102
|
+
* ready - our slots are registered
|
|
103
|
+
*
|
|
104
|
+
* `#ids=` is the boot manifest's entry count, `#ml=` says whether the module
|
|
105
|
+
* loader exists, `#errs=` carries the page's last boot errors.
|
|
106
|
+
*/
|
|
107
|
+
const TRACE_KEY = 'dsh.panel-control.trace';
|
|
108
|
+
const PAGE_KEY = 'dsh.panel-control.page';
|
|
109
|
+
/** A short, always-rewritten record ("DPCPAGE|" marks it) for the offline reader. */
|
|
110
|
+
function pageNote(text) {
|
|
111
|
+
try {
|
|
112
|
+
window.localStorage.setItem(PAGE_KEY, `DPCPAGE|${String(text).slice(0, 3500)}`);
|
|
113
|
+
} catch { /* storage is best effort */ }
|
|
114
|
+
}
|
|
115
|
+
/** Talk to the Host before any service exists: a GET the route answers and logs. */
|
|
116
|
+
function pageBeacon(text) {
|
|
117
|
+
try {
|
|
118
|
+
const transport = window.__DSH_TRANSPORT__;
|
|
119
|
+
const base = transport && typeof transport.streamBaseUrl === 'string' ? transport.streamBaseUrl.replace(/\/+$/, '') : '';
|
|
120
|
+
const url = `${base}/api/dsh-panel-control/classify?d=${encodeURIComponent(String(text).slice(0, 700))}`;
|
|
121
|
+
window.fetch(url, { mode: 'no-cors', keepalive: true, cache: 'no-store' }).catch(() => {});
|
|
122
|
+
} catch { /* a beacon must never break the boot */ }
|
|
123
|
+
}
|
|
124
|
+
function traceBoot(tag, silent) {
|
|
125
|
+
try {
|
|
126
|
+
const box = window.__dpcTrace || (window.__dpcTrace = []);
|
|
127
|
+
if (!silent && box[box.length - 1] !== tag) box.push(tag);
|
|
128
|
+
const bootText = window.__dpcBoot || (window.__dpcBoot = describeBoot());
|
|
129
|
+
const log = window.__dpcPageLog;
|
|
130
|
+
const errs = log && Array.isArray(log.events)
|
|
131
|
+
? log.events.slice(-2).map((event) => String((event && (event.detail || event.text || event.message)) || event)).join(' / ')
|
|
132
|
+
: '';
|
|
133
|
+
pageBeacon(`trace ${tag} :: ${box.join('>')} :: ${bootText} :: ${errs.slice(0, 200).replace(/[\r\n]+/g, ' ')}`);
|
|
134
|
+
pageNote(`${box.join('>')} @${new Date().toISOString()} :: ${bootText} :: errs=${errs.slice(0, 500).replace(/[\r\n]+/g, ' ')}`);
|
|
135
|
+
window.localStorage.setItem(
|
|
136
|
+
TRACE_KEY,
|
|
137
|
+
`${box.join('>')} @${new Date().toISOString()} #ml=${typeof window.__ModuleLoader__} #errs=${errs.replace(/[\r\n]+/g, ' ').slice(0, 400)} || ${bootText}`,
|
|
138
|
+
);
|
|
139
|
+
} catch { /* a trace must never break the boot */ }
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** What the boot handed this page. The full list goes under its own key; the
|
|
143
|
+
* one-line trace only says what a person has to be told to read it right. */
|
|
144
|
+
function describeBoot() {
|
|
145
|
+
try {
|
|
146
|
+
const boot = window.__DSH_BOOT__;
|
|
147
|
+
if (!boot) return 'boot=none';
|
|
148
|
+
const keys = Object.keys(boot).slice(0, 20).join(',');
|
|
149
|
+
const list = Array.isArray(boot.entries) ? boot.entries : (Array.isArray(boot.ids) ? boot.ids : []);
|
|
150
|
+
const rows = list.map((item) => (typeof item === 'string' ? { id: item } : item || {}));
|
|
151
|
+
const ids = rows.map((row) => String(row.id || '?'));
|
|
152
|
+
const counts = {};
|
|
153
|
+
for (const id of ids) counts[id] = (counts[id] || 0) + 1;
|
|
154
|
+
const dupes = Object.entries(counts).filter(([, times]) => times > 1).map(([id, times]) => `${id}x${times}`).join(',');
|
|
155
|
+
const immediate = rows.filter((row) => row.immediately === true).map((row) => String(row.id || '?'));
|
|
156
|
+
const batches = Array.isArray(boot.batches) ? boot.batches.map((batch) => String((batch && batch.url) || '')) : [];
|
|
157
|
+
const own = rows.find((row) => row.id === 'dsh-panel-control') || null;
|
|
158
|
+
try {
|
|
159
|
+
window.localStorage.setItem(
|
|
160
|
+
`${TRACE_KEY}.boot`,
|
|
161
|
+
JSON.stringify({ rev: boot.rev, release: boot.release, keys, n: ids.length, ids, dupes, immediate, batches, own }).slice(0, 30_000),
|
|
162
|
+
);
|
|
163
|
+
} catch { /* the summary below still says the part that matters */ }
|
|
164
|
+
return `boot.keys=${keys} n=${ids.length} dupes=${dupes || 'none'} immediate=${immediate.join(',') || 'none'} own=${own ? `${own.id}@${own.rev || '?'}${own.immediately ? '/immediately' : ''}` : 'MISSING'}`;
|
|
165
|
+
} catch (error) {
|
|
166
|
+
return `boot.err=${String((error && error.message) || error).slice(0, 200)}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
try { traceBoot('top'); } catch { /* ignore */ }
|
|
170
|
+
// --- diagnostic self-reload ---------------------------------------------------
|
|
171
|
+
// The desktop window has no reload shortcut, so an updated bundle can only reach a
|
|
172
|
+
// live page when the page reloads itself once. It is capped hard: at most two
|
|
173
|
+
// reloads per ten minutes, and never for a revision this window already loaded.
|
|
174
|
+
// It exists so a fix can be tested without a person pressing the refresh button.
|
|
175
|
+
const SELF_RELOAD_KEY = 'dsh.panel-control.selfreload';
|
|
176
|
+
function ownScriptSrc() {
|
|
177
|
+
try {
|
|
178
|
+
const own = document.currentScript;
|
|
179
|
+
if (own && typeof own.src === 'string' && own.src.includes('dsh-panel-control')) return own.src;
|
|
180
|
+
const all = Array.from(document.scripts || []);
|
|
181
|
+
for (let i = all.length - 1; i >= 0; i -= 1) {
|
|
182
|
+
const src = String(all[i].src || '');
|
|
183
|
+
if (src.includes('dsh-panel-control/client.js')) return src;
|
|
184
|
+
}
|
|
185
|
+
} catch { /* ignore */ }
|
|
186
|
+
return '';
|
|
187
|
+
}
|
|
188
|
+
function maybeSelfReload() {
|
|
189
|
+
try {
|
|
190
|
+
if (!window.__DSH_BOOT__) return;
|
|
191
|
+
const src = ownScriptSrc();
|
|
192
|
+
if (src === '') return;
|
|
193
|
+
const now = Date.now();
|
|
194
|
+
const born = Math.max(0, now - Number((window.performance && window.performance.timeOrigin) || now));
|
|
195
|
+
let state = null;
|
|
196
|
+
try { state = JSON.parse(localStorage.getItem(SELF_RELOAD_KEY) || 'null'); } catch { /* ignore */ }
|
|
197
|
+
if (state === null || typeof state !== 'object') state = { src: '', at: [] };
|
|
198
|
+
const recent = (Array.isArray(state.at) ? state.at : []).filter((t) => typeof t === 'number' && now - t < 120000);
|
|
199
|
+
// One reload per two minutes, for either reason below: a reload that keeps coming back is
|
|
200
|
+
// worse than a plugin that is missing, because nobody can use the window at all.
|
|
201
|
+
if (recent.length > 0) return;
|
|
202
|
+
// Two reasons to reload, and both end at "the page boots from one manifest again":
|
|
203
|
+
// - a newer bundle: this window is running code older than what the Host now serves;
|
|
204
|
+
// - a hot apply: this document was already up for seconds when our script arrived, which
|
|
205
|
+
// is the delta path. DSH composes that delta as a second batch, and a plugin that is
|
|
206
|
+
// also its own startup script then loads twice; the second instance re-registers the
|
|
207
|
+
// slot ids the first one filled and the whole start() aborts. A fresh document has one
|
|
208
|
+
// manifest, one instance per plugin, and none of that.
|
|
209
|
+
const srcChanged = state.src !== src;
|
|
210
|
+
const hotApplied = born > 4000;
|
|
211
|
+
if (!srcChanged && !hotApplied) return;
|
|
212
|
+
recent.push(now);
|
|
213
|
+
try { localStorage.setItem(SELF_RELOAD_KEY, JSON.stringify({ src, at: recent })); } catch { /* ignore */ }
|
|
214
|
+
traceBoot(hotApplied ? 'selfreload-hot' : 'selfreload-new');
|
|
215
|
+
setTimeout(() => { try { window.location.reload(); } catch { /* ignore */ } }, 1200);
|
|
216
|
+
} catch { /* ignore */ }
|
|
217
|
+
}
|
|
218
|
+
try { maybeSelfReload(); } catch { /* ignore */ }
|
|
219
|
+
// --- self-heal ---------------------------------------------------------------
|
|
220
|
+
// A document that boots while the plugin set is being recomposed can end up with two module
|
|
221
|
+
// instances of one plugin: the second apply re-registers the slot ids the first filled, throws,
|
|
222
|
+
// and DSH aborts the whole start() - which is why a working plugin can be missing while its
|
|
223
|
+
// neighbour looks fine. Nothing here can repair that page; a fresh document composes one set
|
|
224
|
+
// and boots clean. So: if this module ran and apply never landed, reload once. Capped at three
|
|
225
|
+
// tries per ten minutes, and never while the page is still loading, so a page that cannot boot
|
|
226
|
+
// this plugin does not turn into a reload loop.
|
|
227
|
+
const HEAL_KEY = 'dsh.panel-control.heal';
|
|
228
|
+
function scheduleHeal() {
|
|
229
|
+
try {
|
|
230
|
+
if (!window.__DSH_BOOT__) return;
|
|
231
|
+
setTimeout(() => {
|
|
232
|
+
try {
|
|
233
|
+
if (window.__dpcApplied === true) return;
|
|
234
|
+
if (document.readyState !== 'complete') return;
|
|
235
|
+
const now = Date.now();
|
|
236
|
+
let seen = [];
|
|
237
|
+
try { seen = JSON.parse(localStorage.getItem(HEAL_KEY) || '[]'); } catch { /* ignore */ }
|
|
238
|
+
if (!Array.isArray(seen)) seen = [];
|
|
239
|
+
seen = seen.filter((t) => typeof t === 'number' && now - t < 600000);
|
|
240
|
+
if (seen.length >= 3) { try { traceBoot('heal-givenup'); } catch { /* ignore */ } return; }
|
|
241
|
+
seen.push(now);
|
|
242
|
+
try { localStorage.setItem(HEAL_KEY, JSON.stringify(seen)); } catch { /* ignore */ }
|
|
243
|
+
try { traceBoot('heal'); } catch { /* ignore */ }
|
|
244
|
+
window.location.reload();
|
|
245
|
+
} catch { /* ignore */ }
|
|
246
|
+
}, 12000);
|
|
247
|
+
} catch { /* ignore */ }
|
|
248
|
+
}
|
|
249
|
+
try { scheduleHeal(); } catch { /* ignore */ }
|
|
250
|
+
// Keep rewriting for half a minute: a late boot error then still lands in the file.
|
|
251
|
+
try {
|
|
252
|
+
for (const delay of [1200, 4000, 9000, 20000, 35000]) {
|
|
253
|
+
setTimeout(() => { try { traceBoot('tick', true); } catch { /* ignore */ } }, delay);
|
|
254
|
+
}
|
|
255
|
+
} catch { /* ignore */ }
|
|
256
|
+
|
|
24
257
|
function build(require) {
|
|
258
|
+
try { traceBoot('build'); } catch { /* ignore */ }
|
|
25
259
|
const React = require('react');
|
|
26
260
|
const h = React.createElement;
|
|
27
261
|
|
|
@@ -132,6 +366,21 @@ function build(require) {
|
|
|
132
366
|
diagFile: { zh: '设置文件', en: 'settings file' },
|
|
133
367
|
diagOrigin: { zh: '页面来源', en: 'page origin' },
|
|
134
368
|
autoTitle: { zh: '自动分类', en: 'Auto classify' },
|
|
369
|
+
layoutTitle: { zh: '侧栏布局', en: 'Sidebar layout' },
|
|
370
|
+
layoutHint: {
|
|
371
|
+
zh: '导航项变多时,让导航区自己收缩并滚动,下面的会话与工作区就不会被挤到看不见。兜底只作用于本页,不改官方包。',
|
|
372
|
+
en: 'When the panel list grows, let it shrink and scroll so sessions and workspaces keep their height. The guard only touches this page; the official package is untouched.',
|
|
373
|
+
},
|
|
374
|
+
layoutGuard: { zh: '高度兜底', en: 'Height guard' },
|
|
375
|
+
layoutGuardHint: { zh: '导航区超过上限后在原地滚动。', en: 'Scroll inside the list once it passes the cap.' },
|
|
376
|
+
layoutCollapse: { zh: '分组可折叠', en: 'Collapsible groups' },
|
|
377
|
+
layoutCollapseHint: { zh: '点分组标题收起该组,标题与数量仍保留。', en: 'Click a group title to fold it; the title and count stay.' },
|
|
378
|
+
layoutMax: { zh: '高度上限', en: 'Height cap' },
|
|
379
|
+
layoutMaxNone: { zh: '不限', en: 'No cap' },
|
|
380
|
+
layoutCollapseAll: { zh: '全部收起', en: 'Fold all' },
|
|
381
|
+
layoutExpandAll: { zh: '全部展开', en: 'Unfold all' },
|
|
382
|
+
layoutCollapseOne: { zh: '点此收起', en: 'click to fold' },
|
|
383
|
+
layoutExpandOne: { zh: '点此展开', en: 'click to unfold' },
|
|
135
384
|
autoHint: {
|
|
136
385
|
zh: '让默认模型把入口分成 2–4 组,结果保存在本机。',
|
|
137
386
|
en: 'The default model files the entries into 2-4 groups; the result stays on this machine.',
|
|
@@ -216,6 +465,10 @@ function build(require) {
|
|
|
216
465
|
assign: {},
|
|
217
466
|
auto: false,
|
|
218
467
|
autoFiled: [],
|
|
468
|
+
collapsed: [],
|
|
469
|
+
guard: true,
|
|
470
|
+
collapse: true,
|
|
471
|
+
panelMax: 45,
|
|
219
472
|
});
|
|
220
473
|
|
|
221
474
|
/**
|
|
@@ -226,7 +479,7 @@ function build(require) {
|
|
|
226
479
|
* would then build that many separators. The numbers here are far above any
|
|
227
480
|
* real sidebar and turn a hostile or corrupted store into a cheap no-op.
|
|
228
481
|
*/
|
|
229
|
-
const LIMITS = { order: 200, hidden: 200, autoFiled: 400, groups: 24, assign: 400, name: 40, key: 120 };
|
|
482
|
+
const LIMITS = { order: 200, hidden: 200, autoFiled: 400, collapsed: 200, groups: 24, assign: 400, name: 40, key: 120 };
|
|
230
483
|
|
|
231
484
|
/** Dedupe, drop anything that is not a usable id, and stop at `cap`. */
|
|
232
485
|
function uniqueIds(raw, cap) {
|
|
@@ -249,6 +502,14 @@ function build(require) {
|
|
|
249
502
|
out.order = uniqueIds(raw.order, LIMITS.order);
|
|
250
503
|
out.autoFiled = uniqueIds(raw.autoFiled, LIMITS.autoFiled);
|
|
251
504
|
out.hidden = uniqueIds(raw.hidden, LIMITS.hidden);
|
|
505
|
+
out.collapsed = uniqueIds(raw.collapsed, LIMITS.collapsed);
|
|
506
|
+
// Files written before the layout guard existed must come back with the
|
|
507
|
+
// guard on: the whole point is that an old sidebar keeps working. An
|
|
508
|
+
// explicit false is the only way to turn the CSS off.
|
|
509
|
+
out.guard = raw.guard !== false;
|
|
510
|
+
out.collapse = raw.collapse !== false;
|
|
511
|
+
const cap = Math.round(Number(raw.panelMax));
|
|
512
|
+
out.panelMax = Number.isFinite(cap) ? Math.min(70, Math.max(0, cap)) : 45;
|
|
252
513
|
if (Array.isArray(raw.groups)) {
|
|
253
514
|
const seen = new Set();
|
|
254
515
|
for (const group of raw.groups) {
|
|
@@ -296,6 +557,7 @@ function build(require) {
|
|
|
296
557
|
((prefs.order || []).length > 0 ||
|
|
297
558
|
(prefs.hidden || []).length > 0 ||
|
|
298
559
|
(prefs.groups || []).length > 0 ||
|
|
560
|
+
(prefs.collapsed || []).length > 0 ||
|
|
299
561
|
(prefs.autoFiled || []).length > 0 ||
|
|
300
562
|
prefs.auto === true);
|
|
301
563
|
|
|
@@ -340,11 +602,6 @@ function build(require) {
|
|
|
340
602
|
return;
|
|
341
603
|
}
|
|
342
604
|
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
605
|
// Only a real gap is worth a banner. A revision that merely differs proves
|
|
349
606
|
// nothing: the Host rebuilds that graph whenever any plugin file changes, so
|
|
350
607
|
// comparing revisions nagged people whose page was missing nothing at all.
|
|
@@ -353,7 +610,7 @@ function build(require) {
|
|
|
353
610
|
// Already said on an earlier load and a reload did not help: keep quiet, the
|
|
354
611
|
// diagnostics still list it.
|
|
355
612
|
const repeated = gap.length > 0 && readNagged() === gap;
|
|
356
|
-
const stale =
|
|
613
|
+
const stale = missing.length > 0 && !repeated;
|
|
357
614
|
if (stale) rememberNagged(gap);
|
|
358
615
|
else if (!missing.length) rememberNagged('');
|
|
359
616
|
if (!stale) forgetReloadMark();
|
|
@@ -382,10 +639,66 @@ function build(require) {
|
|
|
382
639
|
}, SAVE_DEBOUNCE_MS);
|
|
383
640
|
}
|
|
384
641
|
|
|
385
|
-
|
|
642
|
+
/**
|
|
643
|
+
* Prove from the Host side that this half executed at all.
|
|
644
|
+
*
|
|
645
|
+
* The API route can refuse the page (403) and every `hostCall` then returns
|
|
646
|
+
* null in silence, so "ran but muted" is indistinguishable from "never ran".
|
|
647
|
+
* A throwaway bundle request rides the same channel the real bundles use, and
|
|
648
|
+
* the Host logs every bundle it is asked for, refusals included.
|
|
649
|
+
*/
|
|
650
|
+
function reportHost(detail) {
|
|
386
651
|
void hostCall('report', { ...detail, href: String(locationMode()), at: Date.now() });
|
|
387
652
|
}
|
|
388
653
|
|
|
654
|
+
let pageLogSent = 0;
|
|
655
|
+
|
|
656
|
+
/** Ship the page's recent errors/warnings to the Host log, once per change. */
|
|
657
|
+
function dumpPageLog(tag) {
|
|
658
|
+
try {
|
|
659
|
+
const box = window[PAGE_LOG];
|
|
660
|
+
if (!box || !Array.isArray(box.events) || box.events.length === 0) return;
|
|
661
|
+
if (box.events.length === pageLogSent) return;
|
|
662
|
+
pageLogSent = box.events.length;
|
|
663
|
+
reportHost({ kind: 'page-log', tag, total: box.events.length, events: box.events.slice(-40) });
|
|
664
|
+
} catch { /* a dump must never break apply */ }
|
|
665
|
+
}
|
|
666
|
+
/** Every /plugins/ script this page asked for, with the status the browser saw.
|
|
667
|
+
* A plugin that never registered is either a request that failed or a module
|
|
668
|
+
* that failed after arriving; the two need different fixes, so log both. */
|
|
669
|
+
let slotsSent = '';
|
|
670
|
+
|
|
671
|
+
function dumpNet(tag) {
|
|
672
|
+
try {
|
|
673
|
+
const list = typeof performance !== 'undefined' && typeof performance.getEntriesByType === 'function'
|
|
674
|
+
? performance.getEntriesByType('resource')
|
|
675
|
+
: [];
|
|
676
|
+
const rows = list
|
|
677
|
+
.filter((row) => String(row.name || '').includes('/plugins/'))
|
|
678
|
+
.slice(-60)
|
|
679
|
+
.map((row) => {
|
|
680
|
+
const url = String(row.name || '').replace(/^[a-z]+:\/\/[^/]+/i, '');
|
|
681
|
+
const status = row.responseStatus === undefined ? '?' : row.responseStatus;
|
|
682
|
+
return `${status}|${Math.round(row.transferSize || 0)}|${Math.round(row.duration || 0)}ms|${url.slice(0, 130)}`;
|
|
683
|
+
});
|
|
684
|
+
if (rows.length === 0) return;
|
|
685
|
+
reportHost({ kind: 'net', tag, scripts: rows });
|
|
686
|
+
} catch { /* diagnostics only */ }
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/** Which plugins the panel slot holds right now, and every time that changes. */
|
|
690
|
+
function dumpSlots(slots, tag) {
|
|
691
|
+
try {
|
|
692
|
+
if (!slots || typeof slots.entriesOfSlot !== 'function') return;
|
|
693
|
+
const ids = slots.entriesOfSlot('sidebar.panellist')
|
|
694
|
+
.map((entry) => String((entry && entry.options && entry.options.id) || ''))
|
|
695
|
+
.join(',');
|
|
696
|
+
if (tag !== 'change' && ids === slotsSent) return;
|
|
697
|
+
slotsSent = ids;
|
|
698
|
+
reportHost({ kind: 'slots', tag, ids });
|
|
699
|
+
} catch { /* diagnostics only */ }
|
|
700
|
+
}
|
|
701
|
+
|
|
389
702
|
/** `location.href` minus any query, so one line says which origin we ran on. */
|
|
390
703
|
function locationMode() {
|
|
391
704
|
try {
|
|
@@ -689,7 +1002,7 @@ function build(require) {
|
|
|
689
1002
|
let pollTimer = null;
|
|
690
1003
|
let lastApplyAt = 0;
|
|
691
1004
|
|
|
692
|
-
const diag = { found: false, rows: 0, mapped: 0, at: 0, listClass: '', error: null };
|
|
1005
|
+
const diag = { found: false, rows: 0, mapped: 0, at: 0, listClass: '', error: null, guard: true, panelMax: 45, folded: '' };
|
|
693
1006
|
|
|
694
1007
|
const readPlan = () => {
|
|
695
1008
|
const entries = readEntries(ctx.slots);
|
|
@@ -723,6 +1036,39 @@ function build(require) {
|
|
|
723
1036
|
return { entries, prefs, hidden, blocks };
|
|
724
1037
|
};
|
|
725
1038
|
|
|
1039
|
+
/**
|
|
1040
|
+
* One line per real pass naming what the list holds right now.
|
|
1041
|
+
*
|
|
1042
|
+
* A row a sibling plugin registered and the page then lost is invisible from
|
|
1043
|
+
* here: the registration is gone before the DOM is touched. The dump records
|
|
1044
|
+
* both sides (slot entries and buttons), so the side that lost the row says
|
|
1045
|
+
* which half dropped it.
|
|
1046
|
+
*/
|
|
1047
|
+
let dumpedKeys = '';
|
|
1048
|
+
const dumpPanellist = (plan, list) => {
|
|
1049
|
+
try {
|
|
1050
|
+
const buttons = Array.from(list.querySelectorAll(':scope > button'));
|
|
1051
|
+
const rows = buttons.map((button) => {
|
|
1052
|
+
const label = button.getAttribute('aria-label') || '';
|
|
1053
|
+
const order = button.style.order || '';
|
|
1054
|
+
const shown = button.style.display === 'none' ? 'hidden' : 'shown';
|
|
1055
|
+
return `${label}|${shown}|${order}`;
|
|
1056
|
+
});
|
|
1057
|
+
const key = rows.join(';');
|
|
1058
|
+
if (key === dumpedKeys) return;
|
|
1059
|
+
dumpedKeys = key;
|
|
1060
|
+
reportHost({
|
|
1061
|
+
kind: 'panellist',
|
|
1062
|
+
listClass: diag.listClass,
|
|
1063
|
+
entries: plan.entries.map((entry) => `${entry.id}@${entry.order}`).join(','),
|
|
1064
|
+
hidden: Array.from(plan.hidden).join(','),
|
|
1065
|
+
rows: rows.join(' · ').slice(0, 1500),
|
|
1066
|
+
});
|
|
1067
|
+
} catch {
|
|
1068
|
+
/* a dump must never break the pass */
|
|
1069
|
+
}
|
|
1070
|
+
};
|
|
1071
|
+
|
|
726
1072
|
const findList = (entries) => {
|
|
727
1073
|
if (listEl && listEl.isConnected && listEl.querySelector(':scope > button')) return listEl;
|
|
728
1074
|
listEl = null;
|
|
@@ -753,18 +1099,114 @@ function build(require) {
|
|
|
753
1099
|
return null;
|
|
754
1100
|
};
|
|
755
1101
|
|
|
756
|
-
|
|
1102
|
+
/**
|
|
1103
|
+
* A group title, and the fold control for that group.
|
|
1104
|
+
*
|
|
1105
|
+
* The host sidebar has no grouping of its own, so this element is the only
|
|
1106
|
+
* thing here the user can click. It is built inside the host's list, which
|
|
1107
|
+
* means it must survive that list's React re-renders: no listeners on the
|
|
1108
|
+
* list, only on the node itself, replaced on every pass.
|
|
1109
|
+
*/
|
|
1110
|
+
const makeSeparator = (block, order) => {
|
|
757
1111
|
const element = document.createElement('div');
|
|
758
1112
|
element.className = 'dpc-sep';
|
|
759
1113
|
element.setAttribute('data-dpc-sep', '');
|
|
1114
|
+
element.setAttribute('data-dpc-group-id', block.id);
|
|
760
1115
|
element.style.order = String(order);
|
|
1116
|
+
if (block.id !== '') {
|
|
1117
|
+
element.setAttribute('role', 'button');
|
|
1118
|
+
element.setAttribute('tabindex', '0');
|
|
1119
|
+
element.setAttribute('aria-expanded', block.folded ? 'false' : 'true');
|
|
1120
|
+
element.setAttribute('aria-label', block.name + ' · ' + t(block.folded ? 'layoutExpandOne' : 'layoutCollapseOne'));
|
|
1121
|
+
if (block.folded) element.setAttribute('data-dpc-collapsed', '');
|
|
1122
|
+
const toggle = (event) => {
|
|
1123
|
+
if (event) event.preventDefault();
|
|
1124
|
+
toggleCollapse(block.id);
|
|
1125
|
+
};
|
|
1126
|
+
element.addEventListener('click', toggle);
|
|
1127
|
+
element.addEventListener('keydown', (event) => {
|
|
1128
|
+
if (event.key === 'Enter' || event.key === ' ' || event.key === 'Spacebar') toggle(event);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
const caret = document.createElement('span');
|
|
1132
|
+
caret.className = 'dpc-caret';
|
|
1133
|
+
caret.setAttribute('aria-hidden', 'true');
|
|
761
1134
|
const label = document.createElement('span');
|
|
762
1135
|
label.className = 'dpc-sepLabel';
|
|
763
|
-
label.textContent = name;
|
|
1136
|
+
label.textContent = block.name;
|
|
1137
|
+
const count = document.createElement('span');
|
|
1138
|
+
count.className = 'dpc-sepCount';
|
|
1139
|
+
count.textContent = String(block.count);
|
|
1140
|
+
element.appendChild(caret);
|
|
764
1141
|
element.appendChild(label);
|
|
1142
|
+
element.appendChild(count);
|
|
765
1143
|
return element;
|
|
766
1144
|
};
|
|
767
1145
|
|
|
1146
|
+
/**
|
|
1147
|
+
* Mark the host nodes the layout CSS hangs off.
|
|
1148
|
+
*
|
|
1149
|
+
* The sidebar ships `panelList{flex:none}` next to `regionArea{flex:1;
|
|
1150
|
+
* overflow:hidden}`: a long panel list takes whatever height it wants and
|
|
1151
|
+
* the session area collapses to nothing with no scroller. Those class names
|
|
1152
|
+
* are hashed per build, so the hooks are attributes we add here and remove
|
|
1153
|
+
* in destroy(); the CSS in SIDEBAR_CSS keys off them, never off the hashed
|
|
1154
|
+
* names.
|
|
1155
|
+
*/
|
|
1156
|
+
let regionEl = null;
|
|
1157
|
+
const tagLayout = (list, prefs) => {
|
|
1158
|
+
try {
|
|
1159
|
+
const off = prefs.guard === false;
|
|
1160
|
+
if (off) {
|
|
1161
|
+
list.removeAttribute('data-dpc-list');
|
|
1162
|
+
list.style.removeProperty('--dpc-panellist-max');
|
|
1163
|
+
list.removeAttribute('data-dpc-unbounded');
|
|
1164
|
+
if (regionEl) regionEl.removeAttribute('data-dpc-region');
|
|
1165
|
+
regionEl = null;
|
|
1166
|
+
diag.guard = false;
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
list.setAttribute('data-dpc-list', '');
|
|
1170
|
+
const max = Number(prefs.panelMax) || 0;
|
|
1171
|
+
if (max > 0) {
|
|
1172
|
+
list.style.setProperty('--dpc-panellist-max', max + 'vh');
|
|
1173
|
+
list.removeAttribute('data-dpc-unbounded');
|
|
1174
|
+
} else {
|
|
1175
|
+
list.style.removeProperty('--dpc-panellist-max');
|
|
1176
|
+
list.setAttribute('data-dpc-unbounded', '');
|
|
1177
|
+
}
|
|
1178
|
+
const parent = list.parentElement;
|
|
1179
|
+
let region = null;
|
|
1180
|
+
if (parent) {
|
|
1181
|
+
for (const sibling of Array.from(parent.children)) {
|
|
1182
|
+
if (sibling === list) continue;
|
|
1183
|
+
const className = typeof sibling.className === 'string' ? sibling.className : '';
|
|
1184
|
+
if (className.includes('regionArea')) {
|
|
1185
|
+
region = sibling;
|
|
1186
|
+
break;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (regionEl && regionEl !== region) regionEl.removeAttribute('data-dpc-region');
|
|
1191
|
+
if (region) region.setAttribute('data-dpc-region', '');
|
|
1192
|
+
regionEl = region;
|
|
1193
|
+
diag.panelMax = max;
|
|
1194
|
+
} catch {
|
|
1195
|
+
/* a different list shape: leave the sidebar alone this pass */
|
|
1196
|
+
}
|
|
1197
|
+
};
|
|
1198
|
+
|
|
1199
|
+
/** Fold or unfold one group, persisting the choice through commit(). */
|
|
1200
|
+
const toggleCollapse = (id) => {
|
|
1201
|
+
if (!id) return;
|
|
1202
|
+
const prefs = store.state.prefs;
|
|
1203
|
+
if (prefs.collapse === false) return;
|
|
1204
|
+
const next = new Set(prefs.collapsed || []);
|
|
1205
|
+
if (next.has(id)) next.delete(id);
|
|
1206
|
+
else next.add(id);
|
|
1207
|
+
commit({ ...prefs, collapsed: Array.from(next) });
|
|
1208
|
+
};
|
|
1209
|
+
|
|
768
1210
|
const pause = () => {
|
|
769
1211
|
if (observer && observing) {
|
|
770
1212
|
observer.disconnect();
|
|
@@ -809,11 +1251,23 @@ function build(require) {
|
|
|
809
1251
|
}
|
|
810
1252
|
diag.listClass = typeof list.className === 'string' ? list.className.split(/\s+/)[0] || '' : '';
|
|
811
1253
|
|
|
1254
|
+
diag.guard = plan.prefs.guard !== false;
|
|
1255
|
+
diag.panelMax = plan.prefs.panelMax;
|
|
1256
|
+
// Run on every pass, before the signature shortcut: changing the height
|
|
1257
|
+
// cap or the guard must reach the DOM even when the rows did not move.
|
|
1258
|
+
tagLayout(list, plan.prefs);
|
|
1259
|
+
|
|
1260
|
+
const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
|
|
1261
|
+
const folding = wide && plan.prefs.collapse !== false;
|
|
1262
|
+
const folded = folding ? new Set(plan.prefs.collapsed || []) : new Set();
|
|
1263
|
+
diag.folded = Array.from(folded).join(',');
|
|
1264
|
+
|
|
812
1265
|
const nextSignature = JSON.stringify({
|
|
813
1266
|
blocks: plan.blocks,
|
|
814
1267
|
hidden: plan.hidden ? Array.from(plan.hidden).sort() : [],
|
|
815
1268
|
entries: plan.entries.map((entry) => entry.id + ':' + entry.label),
|
|
816
|
-
wide
|
|
1269
|
+
wide,
|
|
1270
|
+
folded: Array.from(folded),
|
|
817
1271
|
});
|
|
818
1272
|
const separatorsLeft = list.querySelectorAll('[data-dpc-sep]').length;
|
|
819
1273
|
if (nextSignature === signature && (separatorsLeft > 0 || plan.blocks.length <= 1)) {
|
|
@@ -821,11 +1275,11 @@ function build(require) {
|
|
|
821
1275
|
return;
|
|
822
1276
|
}
|
|
823
1277
|
signature = nextSignature;
|
|
1278
|
+
dumpPanellist(plan, list);
|
|
824
1279
|
|
|
825
1280
|
pause();
|
|
826
1281
|
try {
|
|
827
1282
|
const buttons = Array.from(list.querySelectorAll(':scope > button'));
|
|
828
|
-
const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
|
|
829
1283
|
for (const node of Array.from(list.querySelectorAll('[data-dpc-sep]'))) node.remove();
|
|
830
1284
|
|
|
831
1285
|
const map = mapButtons(plan.entries, buttons);
|
|
@@ -841,17 +1295,25 @@ function build(require) {
|
|
|
841
1295
|
|
|
842
1296
|
let position = ORDER_START;
|
|
843
1297
|
for (const block of plan.blocks) {
|
|
844
|
-
const
|
|
845
|
-
|
|
1298
|
+
const inBlock = block.ids.filter((id) => map.has(id));
|
|
1299
|
+
const shown = inBlock.filter((id) => !plan.hidden.has(id));
|
|
1300
|
+
if (shown.length === 0) continue;
|
|
1301
|
+
const foldedHere = block.id !== '' && folded.has(block.id);
|
|
1302
|
+
// The title stays while the group is folded: it is the only way back,
|
|
1303
|
+
// and the count keeps the hidden rows discoverable.
|
|
846
1304
|
if (wide && block.name) {
|
|
847
|
-
list.appendChild(
|
|
1305
|
+
list.appendChild(
|
|
1306
|
+
makeSeparator({ id: block.id, name: block.name, count: shown.length, folded: foldedHere }, position),
|
|
1307
|
+
);
|
|
848
1308
|
position += ORDER_STEP;
|
|
849
1309
|
}
|
|
850
|
-
|
|
1310
|
+
if (foldedHere) continue;
|
|
1311
|
+
for (const id of shown) {
|
|
851
1312
|
const button = map.get(id);
|
|
852
1313
|
button.style.order = String(position);
|
|
853
1314
|
button.style.display = '';
|
|
854
1315
|
button.removeAttribute('data-dpc-hidden');
|
|
1316
|
+
button.removeAttribute('data-dpc-collapsed');
|
|
855
1317
|
button.setAttribute('data-dpc-group', block.name || '');
|
|
856
1318
|
position += ORDER_STEP;
|
|
857
1319
|
}
|
|
@@ -862,6 +1324,19 @@ function build(require) {
|
|
|
862
1324
|
if (!button) continue;
|
|
863
1325
|
button.style.display = 'none';
|
|
864
1326
|
button.setAttribute('data-dpc-hidden', '');
|
|
1327
|
+
button.removeAttribute('data-dpc-collapsed');
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
// Folding is a separate axis from "hidden in this plugin": a folded row
|
|
1331
|
+
// is still shown in the settings list and comes back with one click.
|
|
1332
|
+
for (const block of plan.blocks) {
|
|
1333
|
+
if (block.id === '' || !folded.has(block.id)) continue;
|
|
1334
|
+
for (const id of block.ids) {
|
|
1335
|
+
const button = map.get(id);
|
|
1336
|
+
if (!button || plan.hidden.has(id)) continue;
|
|
1337
|
+
button.style.display = 'none';
|
|
1338
|
+
button.setAttribute('data-dpc-collapsed', '');
|
|
1339
|
+
}
|
|
865
1340
|
}
|
|
866
1341
|
|
|
867
1342
|
// A row we could not map is left in place, but kept after every managed row.
|
|
@@ -945,6 +1420,14 @@ function build(require) {
|
|
|
945
1420
|
node.style.removeProperty('display');
|
|
946
1421
|
}
|
|
947
1422
|
}
|
|
1423
|
+
for (const node of Array.from(document.querySelectorAll('[data-dpc-list]'))) {
|
|
1424
|
+
node.removeAttribute('data-dpc-list');
|
|
1425
|
+
node.removeAttribute('data-dpc-unbounded');
|
|
1426
|
+
node.style.removeProperty('--dpc-panellist-max');
|
|
1427
|
+
}
|
|
1428
|
+
for (const node of Array.from(document.querySelectorAll('[data-dpc-collapsed]'))) node.removeAttribute('data-dpc-collapsed');
|
|
1429
|
+
if (regionEl) regionEl.removeAttribute('data-dpc-region');
|
|
1430
|
+
regionEl = null;
|
|
948
1431
|
listEl = null;
|
|
949
1432
|
};
|
|
950
1433
|
|
|
@@ -1722,6 +2205,96 @@ function build(require) {
|
|
|
1722
2205
|
);
|
|
1723
2206
|
};
|
|
1724
2207
|
|
|
2208
|
+
// The sidebar is the host's DOM, but the trade-off is the user's: this card
|
|
2209
|
+
// is the only place the guard, the cap and the fold switch are explained.
|
|
2210
|
+
const layoutOn = prefs.guard !== false;
|
|
2211
|
+
const foldingOn = prefs.collapse !== false;
|
|
2212
|
+
const capNow = typeof prefs.panelMax === 'number' ? prefs.panelMax : 45;
|
|
2213
|
+
const setLayout = (patch) => commit({ ...prefsNow(), ...patch });
|
|
2214
|
+
const layoutSwitch = (on, label, onClick) =>
|
|
2215
|
+
h(
|
|
2216
|
+
'button',
|
|
2217
|
+
{
|
|
2218
|
+
type: 'button',
|
|
2219
|
+
className: 'dpc-switch',
|
|
2220
|
+
role: 'switch',
|
|
2221
|
+
'aria-checked': on ? 'true' : 'false',
|
|
2222
|
+
'aria-label': label,
|
|
2223
|
+
onClick,
|
|
2224
|
+
},
|
|
2225
|
+
h('span', { className: 'dpc-switchKnob', 'aria-hidden': true }),
|
|
2226
|
+
);
|
|
2227
|
+
const layoutRow = (name, hint, control) =>
|
|
2228
|
+
h(
|
|
2229
|
+
'div',
|
|
2230
|
+
{ className: 'dpc-layoutRow' },
|
|
2231
|
+
h(
|
|
2232
|
+
'div',
|
|
2233
|
+
{ className: 'dpc-layoutText' },
|
|
2234
|
+
h('span', { className: 'dpc-layoutName' }, name),
|
|
2235
|
+
hint ? h('p', { className: 'dpc-note' }, hint) : null,
|
|
2236
|
+
),
|
|
2237
|
+
control,
|
|
2238
|
+
);
|
|
2239
|
+
const layout = h(
|
|
2240
|
+
'section',
|
|
2241
|
+
{ className: 'dpc-card dpc-layout' },
|
|
2242
|
+
h('h2', { className: 'dpc-cardTitle' }, t('layoutTitle')),
|
|
2243
|
+
h('p', { className: 'dpc-note' }, t('layoutHint')),
|
|
2244
|
+
layoutRow(
|
|
2245
|
+
t('layoutGuard'),
|
|
2246
|
+
t('layoutGuardHint'),
|
|
2247
|
+
layoutSwitch(layoutOn, t('layoutGuard'), () => setLayout({ guard: !layoutOn })),
|
|
2248
|
+
),
|
|
2249
|
+
layoutRow(
|
|
2250
|
+
t('layoutCollapse'),
|
|
2251
|
+
t('layoutCollapseHint'),
|
|
2252
|
+
layoutSwitch(foldingOn, t('layoutCollapse'), () => setLayout({ collapse: !foldingOn })),
|
|
2253
|
+
),
|
|
2254
|
+
layoutRow(
|
|
2255
|
+
t('layoutMax'),
|
|
2256
|
+
null,
|
|
2257
|
+
h(
|
|
2258
|
+
'div',
|
|
2259
|
+
{ className: 'dpc-segment', role: 'group', 'aria-label': t('layoutMax') },
|
|
2260
|
+
[35, 45, 55, 0].map((value) =>
|
|
2261
|
+
h(
|
|
2262
|
+
'button',
|
|
2263
|
+
{
|
|
2264
|
+
key: String(value),
|
|
2265
|
+
type: 'button',
|
|
2266
|
+
className: value === capNow ? 'dpc-segmentBtn dpc-segmentBtn--on' : 'dpc-segmentBtn',
|
|
2267
|
+
'aria-pressed': value === capNow ? 'true' : 'false',
|
|
2268
|
+
disabled: !layoutOn,
|
|
2269
|
+
onClick: () => setLayout({ panelMax: value }),
|
|
2270
|
+
},
|
|
2271
|
+
value === 0 ? t('layoutMaxNone') : value + 'vh',
|
|
2272
|
+
),
|
|
2273
|
+
),
|
|
2274
|
+
),
|
|
2275
|
+
),
|
|
2276
|
+
layoutOn && groupsOf.length > 1
|
|
2277
|
+
? h(
|
|
2278
|
+
'div',
|
|
2279
|
+
{ className: 'dpc-layoutActions' },
|
|
2280
|
+
h(
|
|
2281
|
+
'button',
|
|
2282
|
+
{
|
|
2283
|
+
type: 'button',
|
|
2284
|
+
className: 'dpc-link',
|
|
2285
|
+
onClick: () => setLayout({ collapsed: groupsOf.map((group) => group.id) }),
|
|
2286
|
+
},
|
|
2287
|
+
t('layoutCollapseAll'),
|
|
2288
|
+
),
|
|
2289
|
+
h(
|
|
2290
|
+
'button',
|
|
2291
|
+
{ type: 'button', className: 'dpc-link', onClick: () => setLayout({ collapsed: [] }) },
|
|
2292
|
+
t('layoutExpandAll'),
|
|
2293
|
+
),
|
|
2294
|
+
)
|
|
2295
|
+
: null,
|
|
2296
|
+
);
|
|
2297
|
+
|
|
1725
2298
|
const groups =
|
|
1726
2299
|
groupsOf.length === 0
|
|
1727
2300
|
? h(
|
|
@@ -1793,6 +2366,7 @@ function build(require) {
|
|
|
1793
2366
|
head,
|
|
1794
2367
|
notice,
|
|
1795
2368
|
auto,
|
|
2369
|
+
layout,
|
|
1796
2370
|
order.length === 0
|
|
1797
2371
|
? h(
|
|
1798
2372
|
'section',
|
|
@@ -1934,6 +2508,23 @@ function build(require) {
|
|
|
1934
2508
|
.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
2509
|
.dpc-ghost:disabled{opacity:.4;cursor:default}
|
|
1936
2510
|
|
|
2511
|
+
/* Sidebar layout card: one row per switch, a segmented control for the cap.
|
|
2512
|
+
Hairlines only sit *between* rows: the first row has none, and the card
|
|
2513
|
+
hint keeps a real gap above it, so the small print never touches a line. */
|
|
2514
|
+
.dpc-layout{display:flex;flex-direction:column}
|
|
2515
|
+
.dpc-layout > .dpc-note{margin:6px 0 10px}
|
|
2516
|
+
.dpc-layoutRow{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 0;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
|
|
2517
|
+
.dpc-layoutRow:first-of-type{padding-top:4px;border-top:0}
|
|
2518
|
+
.dpc-layoutText{display:flex;flex:1 1 auto;flex-direction:column;gap:3px;min-width:0}
|
|
2519
|
+
.dpc-layoutName{font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
|
|
2520
|
+
.dpc-layoutText .dpc-note{margin:0;line-height:17px}
|
|
2521
|
+
.dpc-layoutActions{display:flex;justify-content:flex-end;gap:4px;margin-top:12px;padding-top:12px;border-top:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
|
|
2522
|
+
.dpc-layoutRow .dpc-switch,.dpc-layoutRow .dpc-segment{flex:none}
|
|
2523
|
+
.dpc-segment{display:inline-flex;flex:none;gap:2px;padding:2px;border-radius:var(--dsw-radius-sm,6px);background:var(--dsw-alias-fill-l2,rgba(127,127,127,.10))}
|
|
2524
|
+
.dpc-segmentBtn{appearance:none;border:0;background:transparent;padding:2px 8px;border-radius:4px;font:inherit;font-size:11px;line-height:18px;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)}
|
|
2525
|
+
.dpc-segmentBtn--on{background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.22));color:var(--dsw-alias-label-primary,#8a8a8a)}
|
|
2526
|
+
.dpc-segmentBtn:disabled{opacity:.4;cursor:default}
|
|
2527
|
+
|
|
1937
2528
|
@media (max-width:1000px){
|
|
1938
2529
|
/* Below the column width the frame becomes the gutter instead of pushing
|
|
1939
2530
|
content past the window edge. */
|
|
@@ -1959,16 +2550,32 @@ function build(require) {
|
|
|
1959
2550
|
`;
|
|
1960
2551
|
|
|
1961
2552
|
const SIDEBAR_CSS = `
|
|
1962
|
-
|
|
2553
|
+
/* Fallback for a sidebar that grows past the window: the list scrolls
|
|
2554
|
+
instead of pushing the session area to zero height. The host ships
|
|
2555
|
+
flex:none there and overflow:hidden below it (see #12083). */
|
|
2556
|
+
[data-dpc-list]{flex:0 1 auto;min-height:0;overflow-y:auto;overflow-x:hidden;max-height:max(120px,var(--dpc-panellist-max,45vh));overscroll-behavior:contain;scrollbar-gutter:stable}
|
|
2557
|
+
[data-dpc-list][data-dpc-unbounded]{max-height:none}
|
|
2558
|
+
[data-dpc-list]>button{flex:none}
|
|
2559
|
+
[data-dpc-region]{flex:1 1 auto;min-height:min(120px,18vh)}
|
|
2560
|
+
|
|
2561
|
+
.dpc-sep{display:flex;align-items:center;gap:6px;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;cursor:pointer;border-radius:var(--dsw-radius-sm,6px);user-select:none}
|
|
2562
|
+
.dpc-sep:hover{color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
|
|
2563
|
+
.dpc-sep:focus-visible{outline:2px solid var(--dsw-focus-ring-color,rgba(127,127,127,.65));outline-offset:1px}
|
|
2564
|
+
.dpc-caret{flex:none;width:0;height:0;border-left:4px solid currentColor;border-top:3.5px solid transparent;border-bottom:3.5px solid transparent;opacity:.7;transform:rotate(90deg);transition:transform 120ms cubic-bezier(.16,1,.3,1)}
|
|
2565
|
+
.dpc-sep[data-dpc-collapsed] .dpc-caret{transform:none}
|
|
1963
2566
|
.dpc-sepLabel{min-width:0;overflow:hidden;text-overflow:ellipsis}
|
|
2567
|
+
.dpc-sepCount{flex:none;margin-left:2px;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:10px;opacity:.72}
|
|
1964
2568
|
.dpc-sep::after{content:'';flex:1;height:.5px;background:var(--dsw-alias-separator-primary,rgba(127,127,127,.16))}
|
|
2569
|
+
@media (prefers-reduced-motion:reduce){.dpc-caret{transition:none}}
|
|
1965
2570
|
`;
|
|
1966
2571
|
|
|
1967
2572
|
|
|
1968
2573
|
/* ----------------------------------------------------------------- entry */
|
|
1969
2574
|
|
|
1970
2575
|
function applyInner(ctx) {
|
|
1971
|
-
|
|
2576
|
+
try { traceBoot('apply'); } catch { /* ignore */ }
|
|
2577
|
+
try { if (document.title.startsWith('DPC ')) document.title = 'DPC ok'; } catch { /* ignore */ }
|
|
2578
|
+
ctxRef = ctx;
|
|
1972
2579
|
|
|
1973
2580
|
// A hot-swapped Client half re-runs apply() without reloading the page, so
|
|
1974
2581
|
// the previous sheet is still in <head>: drop it, or two revisions of the
|
|
@@ -2029,8 +2636,39 @@ function build(require) {
|
|
|
2029
2636
|
ctx.effect(() => () => window.removeEventListener('focus', probe), `${PACKAGE}: inventory`);
|
|
2030
2637
|
}
|
|
2031
2638
|
reportHost({ kind: 'boot', origin: locationMode(), agent: String(navigator.userAgent || '').slice(0, 120) });
|
|
2639
|
+
try {
|
|
2640
|
+
const boot = bootSnapshot();
|
|
2641
|
+
if (boot) {
|
|
2642
|
+
reportHost({ kind: 'boot-manifest', rev: boot.rev, entries: boot.ids.size, ids: Array.from(boot.ids).join(',') });
|
|
2643
|
+
}
|
|
2644
|
+
} catch {
|
|
2645
|
+
/* a dump must never break apply */
|
|
2646
|
+
}
|
|
2647
|
+
try {
|
|
2648
|
+
dumpPageLog('boot');
|
|
2649
|
+
dumpNet('boot');
|
|
2650
|
+
try {
|
|
2651
|
+
const slotsService = optionalService(ctx, 'slots');
|
|
2652
|
+
dumpSlots(slotsService, 'boot');
|
|
2653
|
+
if (slotsService && typeof slotsService.subscribe === 'function') {
|
|
2654
|
+
ctx.effect(
|
|
2655
|
+
() => slotsService.subscribe('sidebar.panellist', () => dumpSlots(slotsService, 'change')),
|
|
2656
|
+
`${PACKAGE}: slotwatch`,
|
|
2657
|
+
);
|
|
2658
|
+
}
|
|
2659
|
+
for (const delay of [8000, 20000, 45000]) {
|
|
2660
|
+
setTimeout(() => { dumpNet(`t${delay}`); dumpSlots(slotsService, `t${delay}`); }, delay);
|
|
2661
|
+
}
|
|
2662
|
+
} catch { /* a dump must never break apply */ }
|
|
2663
|
+
for (const delay of [8000, 20000, 45000]) {
|
|
2664
|
+
setTimeout(() => dumpPageLog(`t${delay}`), delay);
|
|
2665
|
+
}
|
|
2666
|
+
} catch {
|
|
2667
|
+
/* a dump must never break apply */
|
|
2668
|
+
}
|
|
2032
2669
|
|
|
2033
2670
|
|
|
2671
|
+
try { traceBoot('ready'); } catch { /* ignore */ }
|
|
2034
2672
|
// A registration, a rename or a locale switch changes the list this plugin
|
|
2035
2673
|
// lays out, so every one of them re-runs the pass.
|
|
2036
2674
|
if (typeof ctx.slots.subscribe === 'function') {
|
|
@@ -2040,25 +2678,61 @@ function build(require) {
|
|
|
2040
2678
|
ctx.effect(() => locale.subscribe(() => engineRef && engineRef.schedule()), `${PACKAGE}: labels`);
|
|
2041
2679
|
}
|
|
2042
2680
|
|
|
2043
|
-
ctx.slots.inject(SLOT_MAIN, () =>
|
|
2044
|
-
|
|
2045
|
-
|
|
2681
|
+
ctx.slots.inject(SLOT_MAIN, () => {
|
|
2682
|
+
try {
|
|
2683
|
+
return ctx.slots.register({ name: SLOT_MAIN, key: PANEL_KEY, locale: NS }, PanelManager);
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
// A second instance of this module may hold the key already. Losing one entry is
|
|
2686
|
+
// survivable; letting the throw escape aborts the page's whole plugin start().
|
|
2687
|
+
console.warn(`[${PACKAGE}] duplicate "${SLOT_MAIN}" entry skipped`, error);
|
|
2688
|
+
return undefined;
|
|
2689
|
+
}
|
|
2690
|
+
});
|
|
2046
2691
|
|
|
2047
|
-
ctx.slots.inject(SLOT_SETTINGS, () =>
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2692
|
+
ctx.slots.inject(SLOT_SETTINGS, () => {
|
|
2693
|
+
try {
|
|
2694
|
+
return ctx.slots.register(
|
|
2695
|
+
{ name: SLOT_SETTINGS, id: 'panel-control-settings', order: 60, locale: NS, label: () => t('panelTitle') },
|
|
2696
|
+
SettingsEntry,
|
|
2697
|
+
);
|
|
2698
|
+
} catch (error) {
|
|
2699
|
+
console.warn(`[${PACKAGE}] duplicate "${SLOT_SETTINGS}" entry skipped`, error);
|
|
2700
|
+
return undefined;
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2053
2703
|
|
|
2054
2704
|
// The entry lives in the very list it manages: it is registered last, and
|
|
2055
2705
|
// the manager keeps it visible so the page stays reachable.
|
|
2056
|
-
ctx.slots.inject(SLOT_PANELS, () =>
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2706
|
+
ctx.slots.inject(SLOT_PANELS, () => {
|
|
2707
|
+
try {
|
|
2708
|
+
return ctx.slots.register(
|
|
2709
|
+
{ name: SLOT_PANELS, id: PANEL_KEY, order: 90, locale: NS, label: () => t('panelTitle') },
|
|
2710
|
+
PanelIcon,
|
|
2711
|
+
);
|
|
2712
|
+
} catch (error) {
|
|
2713
|
+
console.warn(`[${PACKAGE}] duplicate "${SLOT_PANELS}" entry skipped`, error);
|
|
2714
|
+
return undefined;
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
try { window.__dpcApplied = true; } catch { /* ignore */ }
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
/**
|
|
2721
|
+
* True when this document already holds our sidebar entry, i.e. another instance of this module
|
|
2722
|
+
* applied first. Probing the slot table beats a window flag: a hot swap disposes the old instance
|
|
2723
|
+
* but keeps the window, so a flag would lock the replacement out.
|
|
2724
|
+
*/
|
|
2725
|
+
function alreadyRegistered(ctx) {
|
|
2726
|
+
try {
|
|
2727
|
+
const entries = ctx.slots.entriesOfSlot(SLOT_MAIN) || [];
|
|
2728
|
+
return entries.some((entry) => {
|
|
2729
|
+
const options = entry && entry.options;
|
|
2730
|
+
const key = (options && options.key) || (entry && entry.key);
|
|
2731
|
+
return String(key || '') === PANEL_KEY;
|
|
2732
|
+
});
|
|
2733
|
+
} catch {
|
|
2734
|
+
return false;
|
|
2735
|
+
}
|
|
2062
2736
|
}
|
|
2063
2737
|
|
|
2064
2738
|
return {
|
|
@@ -2067,18 +2741,30 @@ function build(require) {
|
|
|
2067
2741
|
testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
|
|
2068
2742
|
apply(ctx) {
|
|
2069
2743
|
try {
|
|
2744
|
+
|
|
2745
|
+
// The runner can lay this module into the page twice - once as its own startup script and
|
|
2746
|
+
// once inside a composed batch - so a second instance applies into the same slot table.
|
|
2747
|
+
// DSH throws on the duplicate key and aborts the whole start(), which is what removes every
|
|
2748
|
+
// plugin scheduled after us. Skipping the second copy keeps the batch alive.
|
|
2749
|
+
if (alreadyRegistered(ctx)) {
|
|
2750
|
+
try { traceBoot('apply-dup-skip'); } catch { /* ignore */ }
|
|
2751
|
+
reportHost({ kind: 'apply-skip', reason: 'this document already holds our slots' });
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2070
2754
|
applyInner(ctx);
|
|
2071
2755
|
} catch (error) {
|
|
2072
|
-
console.error(`[${PACKAGE}] apply failed`, error);
|
|
2756
|
+
console.error(`[${PACKAGE}] apply failed`, error);
|
|
2073
2757
|
reportHost({ kind: 'apply-failed', message: (error && error.message) || String(error) });
|
|
2074
2758
|
}
|
|
2075
2759
|
},
|
|
2076
2760
|
};
|
|
2077
2761
|
}
|
|
2078
2762
|
|
|
2763
|
+
try {
|
|
2079
2764
|
window.__ModuleLoader__.load({
|
|
2080
2765
|
id: 'dsh-panel-control',
|
|
2081
2766
|
factory(require) {
|
|
2767
|
+
try { traceBoot('factory'); } catch { /* ignore */ }
|
|
2082
2768
|
try {
|
|
2083
2769
|
return build(require);
|
|
2084
2770
|
} catch (error) {
|
|
@@ -2088,4 +2774,10 @@ window.__ModuleLoader__.load({
|
|
|
2088
2774
|
}
|
|
2089
2775
|
},
|
|
2090
2776
|
});
|
|
2091
|
-
|
|
2777
|
+
} catch (error) {
|
|
2778
|
+
// A second evaluation of the same build registers a factory that is already there; the loader
|
|
2779
|
+
// throws for it, and that throw is harmless (the first factory stays in place). A disable/enable
|
|
2780
|
+
// cycle removed the factory first, so this same call is how the plugin comes back.
|
|
2781
|
+
console.warn('[dsh-panel-control] the bundle was evaluated with its registration already present; keeping the first one', error);
|
|
2782
|
+
}
|
|
2783
|
+
})();
|