dsh-panel-control 0.4.0 → 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 +45 -15
- package/client.js +727 -30
- package/docs/discussion-bundle-evaluated-twice.md +100 -0
- package/docs/sidebar-layout.md +40 -0
- package/index.js +191 -15
- 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
|
|
|
@@ -377,10 +639,66 @@ function build(require) {
|
|
|
377
639
|
}, SAVE_DEBOUNCE_MS);
|
|
378
640
|
}
|
|
379
641
|
|
|
380
|
-
|
|
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) {
|
|
381
651
|
void hostCall('report', { ...detail, href: String(locationMode()), at: Date.now() });
|
|
382
652
|
}
|
|
383
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
|
+
|
|
384
702
|
/** `location.href` minus any query, so one line says which origin we ran on. */
|
|
385
703
|
function locationMode() {
|
|
386
704
|
try {
|
|
@@ -684,7 +1002,7 @@ function build(require) {
|
|
|
684
1002
|
let pollTimer = null;
|
|
685
1003
|
let lastApplyAt = 0;
|
|
686
1004
|
|
|
687
|
-
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: '' };
|
|
688
1006
|
|
|
689
1007
|
const readPlan = () => {
|
|
690
1008
|
const entries = readEntries(ctx.slots);
|
|
@@ -718,6 +1036,39 @@ function build(require) {
|
|
|
718
1036
|
return { entries, prefs, hidden, blocks };
|
|
719
1037
|
};
|
|
720
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
|
+
|
|
721
1072
|
const findList = (entries) => {
|
|
722
1073
|
if (listEl && listEl.isConnected && listEl.querySelector(':scope > button')) return listEl;
|
|
723
1074
|
listEl = null;
|
|
@@ -748,18 +1099,114 @@ function build(require) {
|
|
|
748
1099
|
return null;
|
|
749
1100
|
};
|
|
750
1101
|
|
|
751
|
-
|
|
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) => {
|
|
752
1111
|
const element = document.createElement('div');
|
|
753
1112
|
element.className = 'dpc-sep';
|
|
754
1113
|
element.setAttribute('data-dpc-sep', '');
|
|
1114
|
+
element.setAttribute('data-dpc-group-id', block.id);
|
|
755
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');
|
|
756
1134
|
const label = document.createElement('span');
|
|
757
1135
|
label.className = 'dpc-sepLabel';
|
|
758
|
-
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);
|
|
759
1141
|
element.appendChild(label);
|
|
1142
|
+
element.appendChild(count);
|
|
760
1143
|
return element;
|
|
761
1144
|
};
|
|
762
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
|
+
|
|
763
1210
|
const pause = () => {
|
|
764
1211
|
if (observer && observing) {
|
|
765
1212
|
observer.disconnect();
|
|
@@ -804,11 +1251,23 @@ function build(require) {
|
|
|
804
1251
|
}
|
|
805
1252
|
diag.listClass = typeof list.className === 'string' ? list.className.split(/\s+/)[0] || '' : '';
|
|
806
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
|
+
|
|
807
1265
|
const nextSignature = JSON.stringify({
|
|
808
1266
|
blocks: plan.blocks,
|
|
809
1267
|
hidden: plan.hidden ? Array.from(plan.hidden).sort() : [],
|
|
810
1268
|
entries: plan.entries.map((entry) => entry.id + ':' + entry.label),
|
|
811
|
-
wide
|
|
1269
|
+
wide,
|
|
1270
|
+
folded: Array.from(folded),
|
|
812
1271
|
});
|
|
813
1272
|
const separatorsLeft = list.querySelectorAll('[data-dpc-sep]').length;
|
|
814
1273
|
if (nextSignature === signature && (separatorsLeft > 0 || plan.blocks.length <= 1)) {
|
|
@@ -816,11 +1275,11 @@ function build(require) {
|
|
|
816
1275
|
return;
|
|
817
1276
|
}
|
|
818
1277
|
signature = nextSignature;
|
|
1278
|
+
dumpPanellist(plan, list);
|
|
819
1279
|
|
|
820
1280
|
pause();
|
|
821
1281
|
try {
|
|
822
1282
|
const buttons = Array.from(list.querySelectorAll(':scope > button'));
|
|
823
|
-
const wide = list.getBoundingClientRect().width >= WIDE_MIN_WIDTH;
|
|
824
1283
|
for (const node of Array.from(list.querySelectorAll('[data-dpc-sep]'))) node.remove();
|
|
825
1284
|
|
|
826
1285
|
const map = mapButtons(plan.entries, buttons);
|
|
@@ -836,17 +1295,25 @@ function build(require) {
|
|
|
836
1295
|
|
|
837
1296
|
let position = ORDER_START;
|
|
838
1297
|
for (const block of plan.blocks) {
|
|
839
|
-
const
|
|
840
|
-
|
|
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.
|
|
841
1304
|
if (wide && block.name) {
|
|
842
|
-
list.appendChild(
|
|
1305
|
+
list.appendChild(
|
|
1306
|
+
makeSeparator({ id: block.id, name: block.name, count: shown.length, folded: foldedHere }, position),
|
|
1307
|
+
);
|
|
843
1308
|
position += ORDER_STEP;
|
|
844
1309
|
}
|
|
845
|
-
|
|
1310
|
+
if (foldedHere) continue;
|
|
1311
|
+
for (const id of shown) {
|
|
846
1312
|
const button = map.get(id);
|
|
847
1313
|
button.style.order = String(position);
|
|
848
1314
|
button.style.display = '';
|
|
849
1315
|
button.removeAttribute('data-dpc-hidden');
|
|
1316
|
+
button.removeAttribute('data-dpc-collapsed');
|
|
850
1317
|
button.setAttribute('data-dpc-group', block.name || '');
|
|
851
1318
|
position += ORDER_STEP;
|
|
852
1319
|
}
|
|
@@ -857,6 +1324,19 @@ function build(require) {
|
|
|
857
1324
|
if (!button) continue;
|
|
858
1325
|
button.style.display = 'none';
|
|
859
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
|
+
}
|
|
860
1340
|
}
|
|
861
1341
|
|
|
862
1342
|
// A row we could not map is left in place, but kept after every managed row.
|
|
@@ -940,6 +1420,14 @@ function build(require) {
|
|
|
940
1420
|
node.style.removeProperty('display');
|
|
941
1421
|
}
|
|
942
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;
|
|
943
1431
|
listEl = null;
|
|
944
1432
|
};
|
|
945
1433
|
|
|
@@ -1717,6 +2205,96 @@ function build(require) {
|
|
|
1717
2205
|
);
|
|
1718
2206
|
};
|
|
1719
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
|
+
|
|
1720
2298
|
const groups =
|
|
1721
2299
|
groupsOf.length === 0
|
|
1722
2300
|
? h(
|
|
@@ -1788,6 +2366,7 @@ function build(require) {
|
|
|
1788
2366
|
head,
|
|
1789
2367
|
notice,
|
|
1790
2368
|
auto,
|
|
2369
|
+
layout,
|
|
1791
2370
|
order.length === 0
|
|
1792
2371
|
? h(
|
|
1793
2372
|
'section',
|
|
@@ -1929,6 +2508,23 @@ function build(require) {
|
|
|
1929
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)}
|
|
1930
2509
|
.dpc-ghost:disabled{opacity:.4;cursor:default}
|
|
1931
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
|
+
|
|
1932
2528
|
@media (max-width:1000px){
|
|
1933
2529
|
/* Below the column width the frame becomes the gutter instead of pushing
|
|
1934
2530
|
content past the window edge. */
|
|
@@ -1954,16 +2550,32 @@ function build(require) {
|
|
|
1954
2550
|
`;
|
|
1955
2551
|
|
|
1956
2552
|
const SIDEBAR_CSS = `
|
|
1957
|
-
|
|
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}
|
|
1958
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}
|
|
1959
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}}
|
|
1960
2570
|
`;
|
|
1961
2571
|
|
|
1962
2572
|
|
|
1963
2573
|
/* ----------------------------------------------------------------- entry */
|
|
1964
2574
|
|
|
1965
2575
|
function applyInner(ctx) {
|
|
1966
|
-
|
|
2576
|
+
try { traceBoot('apply'); } catch { /* ignore */ }
|
|
2577
|
+
try { if (document.title.startsWith('DPC ')) document.title = 'DPC ok'; } catch { /* ignore */ }
|
|
2578
|
+
ctxRef = ctx;
|
|
1967
2579
|
|
|
1968
2580
|
// A hot-swapped Client half re-runs apply() without reloading the page, so
|
|
1969
2581
|
// the previous sheet is still in <head>: drop it, or two revisions of the
|
|
@@ -2024,8 +2636,39 @@ function build(require) {
|
|
|
2024
2636
|
ctx.effect(() => () => window.removeEventListener('focus', probe), `${PACKAGE}: inventory`);
|
|
2025
2637
|
}
|
|
2026
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
|
+
}
|
|
2027
2669
|
|
|
2028
2670
|
|
|
2671
|
+
try { traceBoot('ready'); } catch { /* ignore */ }
|
|
2029
2672
|
// A registration, a rename or a locale switch changes the list this plugin
|
|
2030
2673
|
// lays out, so every one of them re-runs the pass.
|
|
2031
2674
|
if (typeof ctx.slots.subscribe === 'function') {
|
|
@@ -2035,25 +2678,61 @@ function build(require) {
|
|
|
2035
2678
|
ctx.effect(() => locale.subscribe(() => engineRef && engineRef.schedule()), `${PACKAGE}: labels`);
|
|
2036
2679
|
}
|
|
2037
2680
|
|
|
2038
|
-
ctx.slots.inject(SLOT_MAIN, () =>
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
+
});
|
|
2041
2691
|
|
|
2042
|
-
ctx.slots.inject(SLOT_SETTINGS, () =>
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
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
|
+
});
|
|
2048
2703
|
|
|
2049
2704
|
// The entry lives in the very list it manages: it is registered last, and
|
|
2050
2705
|
// the manager keeps it visible so the page stays reachable.
|
|
2051
|
-
ctx.slots.inject(SLOT_PANELS, () =>
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
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
|
+
}
|
|
2057
2736
|
}
|
|
2058
2737
|
|
|
2059
2738
|
return {
|
|
@@ -2062,18 +2741,30 @@ function build(require) {
|
|
|
2062
2741
|
testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
|
|
2063
2742
|
apply(ctx) {
|
|
2064
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
|
+
}
|
|
2065
2754
|
applyInner(ctx);
|
|
2066
2755
|
} catch (error) {
|
|
2067
|
-
console.error(`[${PACKAGE}] apply failed`, error);
|
|
2756
|
+
console.error(`[${PACKAGE}] apply failed`, error);
|
|
2068
2757
|
reportHost({ kind: 'apply-failed', message: (error && error.message) || String(error) });
|
|
2069
2758
|
}
|
|
2070
2759
|
},
|
|
2071
2760
|
};
|
|
2072
2761
|
}
|
|
2073
2762
|
|
|
2763
|
+
try {
|
|
2074
2764
|
window.__ModuleLoader__.load({
|
|
2075
2765
|
id: 'dsh-panel-control',
|
|
2076
2766
|
factory(require) {
|
|
2767
|
+
try { traceBoot('factory'); } catch { /* ignore */ }
|
|
2077
2768
|
try {
|
|
2078
2769
|
return build(require);
|
|
2079
2770
|
} catch (error) {
|
|
@@ -2083,4 +2774,10 @@ window.__ModuleLoader__.load({
|
|
|
2083
2774
|
}
|
|
2084
2775
|
},
|
|
2085
2776
|
});
|
|
2086
|
-
|
|
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
|
+
})();
|