dsh-panel-control 0.7.1 → 0.8.3
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 +109 -229
- package/client.js +540 -8
- package/docs/bridged-settings-pages.md +85 -0
- package/package.json +2 -2
- package/scripts/bridge-smoke.mjs +324 -0
- package/scripts/layout-smoke.mjs +16 -0
package/client.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Everything below therefore lives inside this closure. Re-evaluation must stay possible: when the
|
|
6
6
|
// plugin is switched off the loader drops our factory, so switching it back on re-executes this
|
|
7
7
|
// script and needs the registration that follows.
|
|
8
|
-
const BUILD_STAMP = 'dsh-panel-control/0.
|
|
8
|
+
const BUILD_STAMP = 'dsh-panel-control/0.8.3';
|
|
9
9
|
try { window.__dpcEvaluated = BUILD_STAMP; } catch { /* ignore */ }
|
|
10
10
|
/**
|
|
11
11
|
* Client half of dsh-panel-control.
|
|
@@ -265,6 +265,14 @@ function build(require) {
|
|
|
265
265
|
const SLOT_MAIN = 'main';
|
|
266
266
|
const SLOT_SETTINGS = 'settings.section';
|
|
267
267
|
const PANEL_KEY = 'panel-control';
|
|
268
|
+
/** Bridged pages get their own ids; the prefix also keeps them out of the host's reserved keys. */
|
|
269
|
+
const BRIDGE_PREFIX = 'dpc-bridge-';
|
|
270
|
+
const BRIDGE_SLOT_PREFIX = 'dpc-bridge-slot-';
|
|
271
|
+
/** Between the ordinary entries and this manager's own row (order 90). */
|
|
272
|
+
const BRIDGE_ORDER = 85;
|
|
273
|
+
const BRIDGE_DEBOUNCE_MS = 120;
|
|
274
|
+
/** Boot-time rescans: plugins register their settings page while the page starts. */
|
|
275
|
+
const BRIDGE_RETRY_MS = [350, 900, 2000, 4200, 8000, 15000];
|
|
268
276
|
const STORAGE_KEY = 'dsh.panel-control.v1';
|
|
269
277
|
/** Host route that answers "file these entries into groups" (see index.js). */
|
|
270
278
|
const RPC_CHANNEL = '/api';
|
|
@@ -377,6 +385,16 @@ function build(require) {
|
|
|
377
385
|
layoutCollapseHint: { zh: '点分组标题收起该组,标题与数量仍保留。', en: 'Click a group title to fold it; the title and count stay.' },
|
|
378
386
|
layoutMax: { zh: '高度上限', en: 'Height cap' },
|
|
379
387
|
layoutMaxNone: { zh: '不限', en: 'No cap' },
|
|
388
|
+
layoutBridge: { zh: '补全设置页入口', en: 'Entries for settings pages' },
|
|
389
|
+
layoutBridgeHint: {
|
|
390
|
+
zh: '第三方插件只做了设置页、没做侧栏入口时,替它在侧栏补一个入口。关掉后这些入口全部移除,插件自己的设置页不受影响。',
|
|
391
|
+
en: 'When a third-party plugin ships a settings page but no sidebar entry, add one for it. Turning this off removes only those entries; the plugin keeps its settings page.',
|
|
392
|
+
},
|
|
393
|
+
bridgeBack: { zh: '返回对话', en: 'Back to conversation' },
|
|
394
|
+
bridgeTag: { zh: '设置页', en: 'Settings page' },
|
|
395
|
+
bridgeFrom: { zh: '来自 {p}', en: 'From {p}' },
|
|
396
|
+
bridgeMissing: { zh: '这个设置页已经不可用。', en: 'This settings page is no longer available.' },
|
|
397
|
+
diagBridge: { zh: '补全入口', en: 'bridged entries' },
|
|
380
398
|
layoutCollapseAll: { zh: '全部收起', en: 'Fold all' },
|
|
381
399
|
layoutExpandAll: { zh: '全部展开', en: 'Unfold all' },
|
|
382
400
|
layoutCollapseOne: { zh: '点此收起', en: 'click to fold' },
|
|
@@ -468,6 +486,7 @@ function build(require) {
|
|
|
468
486
|
collapsed: [],
|
|
469
487
|
guard: true,
|
|
470
488
|
collapse: true,
|
|
489
|
+
bridge: true,
|
|
471
490
|
panelMax: 45,
|
|
472
491
|
});
|
|
473
492
|
|
|
@@ -508,6 +527,9 @@ function build(require) {
|
|
|
508
527
|
// explicit false is the only way to turn the CSS off.
|
|
509
528
|
out.guard = raw.guard !== false;
|
|
510
529
|
out.collapse = raw.collapse !== false;
|
|
530
|
+
// Missing in files written before the bridge existed: those installs get the
|
|
531
|
+
// feature by default, exactly like a fresh one.
|
|
532
|
+
out.bridge = raw.bridge !== false;
|
|
511
533
|
const cap = Math.round(Number(raw.panelMax));
|
|
512
534
|
out.panelMax = Number.isFinite(cap) ? Math.min(70, Math.max(0, cap)) : 45;
|
|
513
535
|
if (Array.isArray(raw.groups)) {
|
|
@@ -559,7 +581,13 @@ function build(require) {
|
|
|
559
581
|
(prefs.groups || []).length > 0 ||
|
|
560
582
|
(prefs.collapsed || []).length > 0 ||
|
|
561
583
|
(prefs.autoFiled || []).length > 0 ||
|
|
562
|
-
prefs.auto === true
|
|
584
|
+
prefs.auto === true ||
|
|
585
|
+
// A file that only turned a switch off is still a file the reader edited;
|
|
586
|
+
// without this, the next load treats it as empty and drops the edit.
|
|
587
|
+
prefs.guard === false ||
|
|
588
|
+
prefs.collapse === false ||
|
|
589
|
+
prefs.bridge === false ||
|
|
590
|
+
(typeof prefs.panelMax === 'number' && prefs.panelMax !== 45));
|
|
563
591
|
|
|
564
592
|
/** One call to the Host half, or null when this Client cannot reach it. */
|
|
565
593
|
async function hostCall(method, payload) {
|
|
@@ -900,6 +928,339 @@ function build(require) {
|
|
|
900
928
|
left.length === right.length &&
|
|
901
929
|
left.every((row, index) => row.id === right[index].id && row.label === right[index].label);
|
|
902
930
|
|
|
931
|
+
/* -------------------------------------------------------------- bridges */
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* Settings pages with no panel of their own.
|
|
935
|
+
*
|
|
936
|
+
* A Client plugin addresses its settings page with `settings.section`; the
|
|
937
|
+
* sidebar only lists `sidebar.panellist` entries, and the two registries are
|
|
938
|
+
* unrelated. A plugin that never registers a panel therefore has a reachable
|
|
939
|
+
* settings page and no way to open it from the navigation rail. This block
|
|
940
|
+
* gives each such page a panel of its own: a panellist row plus the `main`
|
|
941
|
+
* entry it addresses, whose body is the host's own renderer for that section.
|
|
942
|
+
*
|
|
943
|
+
* A slot entry's `registrant` names the Cordis fiber that registered it, but
|
|
944
|
+
* the client runner applies many plugins inside one fiber: in practice every
|
|
945
|
+
* plugin except the market reports the same name, so a registrant match cannot
|
|
946
|
+
* tell one plugin from another. Attribution is therefore by ownership of the
|
|
947
|
+
* navigation row: a settings id whose base name (with a trailing `-settings`
|
|
948
|
+
* removed) is an existing panel id, or whose label is an existing panel label,
|
|
949
|
+
* belongs to a plugin that already has a way in. Core settings ids are listed
|
|
950
|
+
* explicitly; they ship without a panel because that is the product decision,
|
|
951
|
+
* not a gap.
|
|
952
|
+
*/
|
|
953
|
+
const CORE_SECTION_IDS = new Set(['account', 'general', 'models', 'agent-presets', 'plugins']);
|
|
954
|
+
|
|
955
|
+
const bridgeState = {
|
|
956
|
+
entries: new Map(),
|
|
957
|
+
labels: new Map(),
|
|
958
|
+
timer: null,
|
|
959
|
+
retries: new Set(),
|
|
960
|
+
retriesStarted: false,
|
|
961
|
+
syncing: false,
|
|
962
|
+
scans: 0,
|
|
963
|
+
unsub: null,
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
function registrantOf(entry) {
|
|
967
|
+
const options = (entry && entry.options) || {};
|
|
968
|
+
const value = (entry && entry.registrant) || options.registrant;
|
|
969
|
+
return typeof value === 'string' ? value : '';
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function readSections(slots) {
|
|
973
|
+
try {
|
|
974
|
+
const raw = slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_SETTINGS) || [] : [];
|
|
975
|
+
return raw
|
|
976
|
+
.map((entry) => {
|
|
977
|
+
const options = (entry && entry.options) || {};
|
|
978
|
+
return {
|
|
979
|
+
id: typeof options.id === 'string' ? options.id : '',
|
|
980
|
+
label: options.label,
|
|
981
|
+
registrant: registrantOf(entry),
|
|
982
|
+
component: entry && entry.component,
|
|
983
|
+
inject: options.inject,
|
|
984
|
+
store: options.store,
|
|
985
|
+
locale: options.locale,
|
|
986
|
+
};
|
|
987
|
+
})
|
|
988
|
+
.filter((section) => section.id !== '');
|
|
989
|
+
} catch {
|
|
990
|
+
return [];
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function panelEntries(slots) {
|
|
995
|
+
try {
|
|
996
|
+
return (slots && typeof slots.entriesOfSlot === 'function' ? slots.entriesOfSlot(SLOT_PANELS) || [] : [])
|
|
997
|
+
.map((entry) => entry && entry.options)
|
|
998
|
+
.filter((options) => options && typeof options.id === 'string' && options.id !== '')
|
|
999
|
+
.map((options) => ({ id: options.id, label: resolveLabel(options.label, options.id) }));
|
|
1000
|
+
} catch {
|
|
1001
|
+
return [];
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function sectionBase(id) {
|
|
1006
|
+
return id.endsWith('-settings') ? id.slice(0, -'-settings'.length) : id;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/**
|
|
1010
|
+
* Every settings page whose plugin owns no sidebar entry.
|
|
1011
|
+
*
|
|
1012
|
+
* A plugin may expose more than one settings page; each becomes its own row,
|
|
1013
|
+
* labelled with the plugin's own section label. A label already used by a real
|
|
1014
|
+
* panel is suffixed with the settings id so two rows never read the same.
|
|
1015
|
+
*/
|
|
1016
|
+
function bridgeCandidates(slots) {
|
|
1017
|
+
const rows = panelEntries(slots).filter((row) => !row.id.startsWith(BRIDGE_PREFIX));
|
|
1018
|
+
const panelIds = new Set(rows.map((row) => row.id));
|
|
1019
|
+
const panelLabels = new Set(rows.map((row) => row.label));
|
|
1020
|
+
// Real panel labels own their names; a bridged row must not steal one, and
|
|
1021
|
+
// must not collide with another bridged row either.
|
|
1022
|
+
const used = new Set(panelLabels);
|
|
1023
|
+
const out = [];
|
|
1024
|
+
const taken = new Set();
|
|
1025
|
+
for (const section of readSections(slots)) {
|
|
1026
|
+
if (CORE_SECTION_IDS.has(section.id)) continue;
|
|
1027
|
+
const base = sectionBase(section.id);
|
|
1028
|
+
let owned = panelIds.has(section.id) || panelIds.has(base);
|
|
1029
|
+
if (!owned) {
|
|
1030
|
+
for (const panelId of panelIds) {
|
|
1031
|
+
if (panelId.endsWith('-' + base)) {
|
|
1032
|
+
owned = true;
|
|
1033
|
+
break;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
}
|
|
1037
|
+
if (owned) continue;
|
|
1038
|
+
const label = resolveLabel(section.label, section.id);
|
|
1039
|
+
if (panelLabels.has(label)) continue;
|
|
1040
|
+
if (typeof section.component !== 'function') continue;
|
|
1041
|
+
const bridgeId = BRIDGE_PREFIX + section.id;
|
|
1042
|
+
if (bridgeId.length > LIMITS.key || taken.has(bridgeId)) continue;
|
|
1043
|
+
taken.add(bridgeId);
|
|
1044
|
+
const chosen = used.has(label) ? `${label} · ${section.id}` : label;
|
|
1045
|
+
used.add(chosen);
|
|
1046
|
+
out.push({
|
|
1047
|
+
bridgeId,
|
|
1048
|
+
sectionId: section.id,
|
|
1049
|
+
label: chosen,
|
|
1050
|
+
component: section.component,
|
|
1051
|
+
inject: section.inject,
|
|
1052
|
+
store: section.store,
|
|
1053
|
+
locale: section.locale,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
return out;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** The layout's live selection, read through the public store when it exposes one. */
|
|
1060
|
+
function activePanelId() {
|
|
1061
|
+
try {
|
|
1062
|
+
const layout = optionalService(ctxRef, 'layout');
|
|
1063
|
+
const info = layout && layout.panelInfo;
|
|
1064
|
+
const snapshot = info && typeof info.getSnapshot === 'function' ? info.getSnapshot() : info;
|
|
1065
|
+
return snapshot && typeof snapshot.activePanelId === 'string' ? snapshot.activePanelId : null;
|
|
1066
|
+
} catch {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function disposeBridge(id, record) {
|
|
1072
|
+
bridgeState.entries.delete(id);
|
|
1073
|
+
for (const dispose of record.disposers.slice().reverse()) {
|
|
1074
|
+
try {
|
|
1075
|
+
dispose();
|
|
1076
|
+
} catch (error) {
|
|
1077
|
+
console.warn(`[${PACKAGE}] bridge cleanup failed for "${id}"`, error);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
// The main key this row addressed is gone, so a reader sitting on it would
|
|
1081
|
+
// stare at an empty centre column. The Conversation is the only safe landing.
|
|
1082
|
+
if (activePanelId() === id) {
|
|
1083
|
+
try {
|
|
1084
|
+
const layout = optionalService(ctxRef, 'layout');
|
|
1085
|
+
if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(null);
|
|
1086
|
+
} catch {
|
|
1087
|
+
/* nothing was selected after all */
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function syncBridges() {
|
|
1093
|
+
const slots = ctxRef && ctxRef.slots;
|
|
1094
|
+
if (!slots || typeof slots.register !== 'function' || bridgeState.syncing) return;
|
|
1095
|
+
bridgeState.syncing = true;
|
|
1096
|
+
bridgeState.scans += 1;
|
|
1097
|
+
let changed = false;
|
|
1098
|
+
try {
|
|
1099
|
+
const enabled = prefsNow().bridge !== false;
|
|
1100
|
+
watchSettings();
|
|
1101
|
+
const sections = readSections(slots);
|
|
1102
|
+
const panelIds = panelEntries(slots).map((row) => row.id);
|
|
1103
|
+
const candidates = enabled ? bridgeCandidates(slots) : [];
|
|
1104
|
+
const desired = new Set(candidates.map((candidate) => candidate.bridgeId));
|
|
1105
|
+
bridgeState.labels = new Map(candidates.map((candidate) => [candidate.bridgeId, candidate.label]));
|
|
1106
|
+
for (const [id, record] of Array.from(bridgeState.entries)) {
|
|
1107
|
+
if (desired.has(id)) continue;
|
|
1108
|
+
disposeBridge(id, record);
|
|
1109
|
+
changed = true;
|
|
1110
|
+
}
|
|
1111
|
+
for (const candidate of candidates) {
|
|
1112
|
+
if (bridgeState.entries.has(candidate.bridgeId)) continue;
|
|
1113
|
+
const slotName = BRIDGE_SLOT_PREFIX + candidate.sectionId;
|
|
1114
|
+
const record = { disposers: [] };
|
|
1115
|
+
try {
|
|
1116
|
+
record.disposers.push(
|
|
1117
|
+
slots.register(
|
|
1118
|
+
{
|
|
1119
|
+
name: SLOT_MAIN,
|
|
1120
|
+
key: candidate.bridgeId,
|
|
1121
|
+
locale: NS,
|
|
1122
|
+
children: { [slotName]: { kind: 'single', scope: 'root' } },
|
|
1123
|
+
},
|
|
1124
|
+
makeBridgePanel(candidate, slotName),
|
|
1125
|
+
),
|
|
1126
|
+
);
|
|
1127
|
+
// The settings shell owns the `settings.section` declaration, so its
|
|
1128
|
+
// props-face renderer is not ours to call. Declaring one child slot of
|
|
1129
|
+
// our own and re-registering the same component into it gives the
|
|
1130
|
+
// section the host kit it expects (inject, locale, store, boundary)
|
|
1131
|
+
// without touching the shell or the foreign registration.
|
|
1132
|
+
record.disposers.push(
|
|
1133
|
+
slots.register(
|
|
1134
|
+
{
|
|
1135
|
+
name: slotName,
|
|
1136
|
+
inject: candidate.inject,
|
|
1137
|
+
store: candidate.store,
|
|
1138
|
+
locale: candidate.locale,
|
|
1139
|
+
},
|
|
1140
|
+
candidate.component,
|
|
1141
|
+
),
|
|
1142
|
+
);
|
|
1143
|
+
record.disposers.push(
|
|
1144
|
+
slots.register(
|
|
1145
|
+
{
|
|
1146
|
+
name: SLOT_PANELS,
|
|
1147
|
+
id: candidate.bridgeId,
|
|
1148
|
+
order: BRIDGE_ORDER,
|
|
1149
|
+
locale: NS,
|
|
1150
|
+
label: () => bridgeState.labels.get(candidate.bridgeId) || candidate.label,
|
|
1151
|
+
},
|
|
1152
|
+
BridgeIcon,
|
|
1153
|
+
),
|
|
1154
|
+
);
|
|
1155
|
+
bridgeState.entries.set(candidate.bridgeId, record);
|
|
1156
|
+
changed = true;
|
|
1157
|
+
} catch (error) {
|
|
1158
|
+
for (const dispose of record.disposers.slice().reverse()) {
|
|
1159
|
+
try {
|
|
1160
|
+
dispose();
|
|
1161
|
+
} catch {
|
|
1162
|
+
/* the pair is already half gone */
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
console.warn(`[${PACKAGE}] could not add a panel for "${candidate.sectionId}"`, error);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// Early scans run while plugins are still registering. Keeping a trace of
|
|
1169
|
+
// exactly what the slot returned tells a later reader whether a missing
|
|
1170
|
+
// row is a timing problem or an ownership guess that did not hold.
|
|
1171
|
+
if (bridgeState.scans <= 8 || changed) {
|
|
1172
|
+
reportHost({
|
|
1173
|
+
kind: 'bridge-scan',
|
|
1174
|
+
enabled,
|
|
1175
|
+
scans: bridgeState.scans,
|
|
1176
|
+
sections: sections.map((section) => `${section.id}:${section.registrant || '?'}`).join('|'),
|
|
1177
|
+
panels: panelIds.join('|'),
|
|
1178
|
+
ids: Array.from(bridgeState.entries.keys()).join(','),
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
if (changed) {
|
|
1182
|
+
reportHost({
|
|
1183
|
+
kind: 'bridge',
|
|
1184
|
+
enabled,
|
|
1185
|
+
ids: Array.from(bridgeState.entries.keys()).join(','),
|
|
1186
|
+
sections: candidates.length,
|
|
1187
|
+
});
|
|
1188
|
+
// The diagnostics line in the settings card reads this count; without a
|
|
1189
|
+
// re-render it would keep showing the value from before the sync.
|
|
1190
|
+
emit();
|
|
1191
|
+
}
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
console.warn(`[${PACKAGE}] bridge sync failed`, error);
|
|
1194
|
+
} finally {
|
|
1195
|
+
bridgeState.syncing = false;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
function scheduleBridgeSync() {
|
|
1200
|
+
if (bridgeState.timer) clearTimeout(bridgeState.timer);
|
|
1201
|
+
bridgeState.timer = setTimeout(() => {
|
|
1202
|
+
bridgeState.timer = null;
|
|
1203
|
+
syncBridges();
|
|
1204
|
+
}, BRIDGE_DEBOUNCE_MS);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Watch the section registry.
|
|
1209
|
+
*
|
|
1210
|
+
* `subscribe` is trustworthy only once the slot is declared. The plugin runs
|
|
1211
|
+
* before some of the plugins it bridges, so an early subscription can resolve
|
|
1212
|
+
* to nothing and never be retried; the declaration callback calls this again.
|
|
1213
|
+
*/
|
|
1214
|
+
function watchSettings() {
|
|
1215
|
+
if (bridgeState.unsub) return;
|
|
1216
|
+
const slots = ctxRef && ctxRef.slots;
|
|
1217
|
+
if (!slots || typeof slots.subscribe !== 'function') return;
|
|
1218
|
+
try {
|
|
1219
|
+
const dispose = slots.subscribe(SLOT_SETTINGS, () => scheduleBridgeSync());
|
|
1220
|
+
bridgeState.unsub = typeof dispose === 'function' ? dispose : () => {};
|
|
1221
|
+
} catch {
|
|
1222
|
+
/* not declared yet; the next scan or declaration retries */
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function unwatchSettings() {
|
|
1227
|
+
if (!bridgeState.unsub) return;
|
|
1228
|
+
try {
|
|
1229
|
+
bridgeState.unsub();
|
|
1230
|
+
} catch {
|
|
1231
|
+
/* the slot is already gone */
|
|
1232
|
+
}
|
|
1233
|
+
bridgeState.unsub = null;
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function scheduleBridgeRetry(delay) {
|
|
1237
|
+
const id = setTimeout(() => {
|
|
1238
|
+
bridgeState.retries.delete(id);
|
|
1239
|
+
if (ctxRef) syncBridges();
|
|
1240
|
+
}, delay);
|
|
1241
|
+
// Browsers return a number; Node returns an object. Letting the harness
|
|
1242
|
+
// exit without waiting out the last boot scan keeps `npm run check` quick.
|
|
1243
|
+
if (id && typeof id.unref === 'function') id.unref();
|
|
1244
|
+
bridgeState.retries.add(id);
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1247
|
+
function scheduleBridgeRetries() {
|
|
1248
|
+
if (bridgeState.retriesStarted) return;
|
|
1249
|
+
bridgeState.retriesStarted = true;
|
|
1250
|
+
for (const delay of BRIDGE_RETRY_MS) scheduleBridgeRetry(delay);
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
function disposeBridges() {
|
|
1254
|
+
if (bridgeState.timer) {
|
|
1255
|
+
clearTimeout(bridgeState.timer);
|
|
1256
|
+
bridgeState.timer = null;
|
|
1257
|
+
}
|
|
1258
|
+
for (const id of Array.from(bridgeState.retries)) clearTimeout(id);
|
|
1259
|
+
bridgeState.retries.clear();
|
|
1260
|
+
unwatchSettings();
|
|
1261
|
+
for (const [id, record] of Array.from(bridgeState.entries)) disposeBridge(id, record);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
903
1264
|
/** The stored order, extended with every entry that is new since it was saved. */
|
|
904
1265
|
function normalize(prefs, entries) {
|
|
905
1266
|
const known = new Set(entries.map((entry) => entry.id));
|
|
@@ -1447,6 +1808,9 @@ function build(require) {
|
|
|
1447
1808
|
scheduleSaveToHost();
|
|
1448
1809
|
emit();
|
|
1449
1810
|
if (engineRef) engineRef.schedule();
|
|
1811
|
+
// The bridge switch lives here too; toggling it must add or drop the rows
|
|
1812
|
+
// right away rather than on the next unrelated registration.
|
|
1813
|
+
scheduleBridgeSync();
|
|
1450
1814
|
}
|
|
1451
1815
|
|
|
1452
1816
|
function setDiag(patch) {
|
|
@@ -1477,6 +1841,8 @@ function build(require) {
|
|
|
1477
1841
|
if (!stored || !hasContent(stored)) await hostCall('save', { prefs: chosen });
|
|
1478
1842
|
}
|
|
1479
1843
|
setDiag({ prefsSource: prefsSource.value, hostFile });
|
|
1844
|
+
// The Host copy may carry the bridge switch with it.
|
|
1845
|
+
scheduleBridgeSync();
|
|
1480
1846
|
}
|
|
1481
1847
|
|
|
1482
1848
|
const prefsNow = () => store.state.prefs;
|
|
@@ -1747,6 +2113,7 @@ function build(require) {
|
|
|
1747
2113
|
plus: 'M12 6v12M6 12h12',
|
|
1748
2114
|
eye: 'M2.5 12S6 6.5 12 6.5 21.5 12 21.5 12 18 17.5 12 17.5 2.5 12 2.5 12Z',
|
|
1749
2115
|
eyeOff: 'M2.5 12S6 6.5 12 6.5c1.6 0 3 .45 4.2 1.1M21.5 12S18 17.5 12 17.5c-1.6 0-3-.45-4.2-1.1M4 4l16 16',
|
|
2116
|
+
back: 'M14.5 6.5 9 12l5.5 5.5',
|
|
1750
2117
|
};
|
|
1751
2118
|
|
|
1752
2119
|
/** One 16px line glyph, one 1.5 stroke, colours inherited from the text role. */
|
|
@@ -1807,6 +2174,105 @@ function build(require) {
|
|
|
1807
2174
|
|
|
1808
2175
|
const PanelIcon = SlidersIcon;
|
|
1809
2176
|
|
|
2177
|
+
/**
|
|
2178
|
+
* A page glyph with text lines: the settings page, not the plugin's own icon
|
|
2179
|
+
* (that one belongs to the plugin and is not available to the bridge).
|
|
2180
|
+
*/
|
|
2181
|
+
function BridgeIcon(props) {
|
|
2182
|
+
const size = props && typeof props.size === 'number' ? props.size : 18;
|
|
2183
|
+
const active = Boolean(props && props.active);
|
|
2184
|
+
return h(
|
|
2185
|
+
'svg',
|
|
2186
|
+
{
|
|
2187
|
+
width: size,
|
|
2188
|
+
height: size,
|
|
2189
|
+
viewBox: '0 0 24 24',
|
|
2190
|
+
fill: 'none',
|
|
2191
|
+
'aria-hidden': true,
|
|
2192
|
+
style: { display: 'block', opacity: active ? 1 : 0.78 },
|
|
2193
|
+
},
|
|
2194
|
+
h('rect', {
|
|
2195
|
+
x: 3.5,
|
|
2196
|
+
y: 4.5,
|
|
2197
|
+
width: 17,
|
|
2198
|
+
height: 15,
|
|
2199
|
+
rx: 2.5,
|
|
2200
|
+
stroke: 'currentColor',
|
|
2201
|
+
strokeWidth: 1.6,
|
|
2202
|
+
}),
|
|
2203
|
+
h('path', {
|
|
2204
|
+
d: 'M8 9.5h8M8 12h5M8 14.5h8',
|
|
2205
|
+
stroke: 'currentColor',
|
|
2206
|
+
strokeWidth: 1.5,
|
|
2207
|
+
strokeLinecap: 'round',
|
|
2208
|
+
}),
|
|
2209
|
+
);
|
|
2210
|
+
}
|
|
2211
|
+
|
|
2212
|
+
/**
|
|
2213
|
+
* One bridged page: a back bar, then the host's renderer for the section.
|
|
2214
|
+
*
|
|
2215
|
+
* The settings shell owns the `settings.section` declaration, so the ctx-level
|
|
2216
|
+
* `slots.renderSlot` cannot reach it: that entry point only renders `root`, and
|
|
2217
|
+
* a child slot is renderable only through the props face of the entry that
|
|
2218
|
+
* declared it. Each bridge therefore declares its own single-child slot and
|
|
2219
|
+
* re-registers the section component into it (see `syncBridges`), and renders
|
|
2220
|
+
* that slot through the `renderSlot` prop the host hands this component. The
|
|
2221
|
+
* host still builds the section's kit — inject, locale, store, boundary and
|
|
2222
|
+
* hooks all stay its own. The owner prop is the same `close` the settings shell
|
|
2223
|
+
* passes, wired to the Conversation because this page has no dialog to close.
|
|
2224
|
+
*/
|
|
2225
|
+
function makeBridgePanel(candidate, slotName) {
|
|
2226
|
+
const back = () => {
|
|
2227
|
+
try {
|
|
2228
|
+
const layout = optionalService(ctxRef, 'layout');
|
|
2229
|
+
if (layout && typeof layout.selectPanel === 'function') layout.selectPanel(null);
|
|
2230
|
+
} catch {
|
|
2231
|
+
/* the Conversation is already showing */
|
|
2232
|
+
}
|
|
2233
|
+
};
|
|
2234
|
+
return function BridgePanel(props) {
|
|
2235
|
+
const renderSlot = props && typeof props.renderSlot === 'function' ? props.renderSlot : null;
|
|
2236
|
+
let node = null;
|
|
2237
|
+
try {
|
|
2238
|
+
if (renderSlot) node = renderSlot(slotName, { close: back }, {});
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
console.warn(`[${PACKAGE}] could not render settings page "${candidate.sectionId}"`, error);
|
|
2241
|
+
}
|
|
2242
|
+
return h(
|
|
2243
|
+
'div',
|
|
2244
|
+
{ className: 'dpc-root dpc-bridge' },
|
|
2245
|
+
h(
|
|
2246
|
+
'div',
|
|
2247
|
+
{ className: 'dpc-page dpc-bridgePage' },
|
|
2248
|
+
h(
|
|
2249
|
+
'div',
|
|
2250
|
+
{ className: 'dpc-bridgeBar' },
|
|
2251
|
+
h(
|
|
2252
|
+
'button',
|
|
2253
|
+
{ type: 'button', className: 'dpc-link dpc-bridgeBack', onClick: back },
|
|
2254
|
+
h(Icon, { name: 'back' }),
|
|
2255
|
+
h('span', null, t('bridgeBack')),
|
|
2256
|
+
),
|
|
2257
|
+
h(
|
|
2258
|
+
'span',
|
|
2259
|
+
{ className: 'dpc-bridgeTags' },
|
|
2260
|
+
h('span', { className: 'dpc-bridgeTag' }, t('bridgeTag')),
|
|
2261
|
+
h('span', { className: 'dpc-bridgeFrom' }, fill(t('bridgeFrom'), { p: candidate.sectionId })),
|
|
2262
|
+
),
|
|
2263
|
+
),
|
|
2264
|
+
node
|
|
2265
|
+
? h('div', { className: 'dpc-bridgeBody' }, node)
|
|
2266
|
+
: h(
|
|
2267
|
+
'section',
|
|
2268
|
+
{ className: 'dpc-card dpc-empty' },
|
|
2269
|
+
h('p', { className: 'dpc-emptyTitle' }, t('bridgeMissing')),
|
|
2270
|
+
),
|
|
2271
|
+
),
|
|
2272
|
+
);
|
|
2273
|
+
};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
1810
2276
|
/** The entry's own glyph, borrowed from the slot it registered. */
|
|
1811
2277
|
function Glyph(props) {
|
|
1812
2278
|
const [node, setNode] = React.useState(null);
|
|
@@ -2209,6 +2675,7 @@ function build(require) {
|
|
|
2209
2675
|
// is the only place the guard, the cap and the fold switch are explained.
|
|
2210
2676
|
const layoutOn = prefs.guard !== false;
|
|
2211
2677
|
const foldingOn = prefs.collapse !== false;
|
|
2678
|
+
const bridgeOn = prefs.bridge !== false;
|
|
2212
2679
|
const capNow = typeof prefs.panelMax === 'number' ? prefs.panelMax : 45;
|
|
2213
2680
|
const setLayout = (patch) => commit({ ...prefsNow(), ...patch });
|
|
2214
2681
|
const layoutSwitch = (on, label, onClick) =>
|
|
@@ -2251,6 +2718,14 @@ function build(require) {
|
|
|
2251
2718
|
t('layoutCollapseHint'),
|
|
2252
2719
|
layoutSwitch(foldingOn, t('layoutCollapse'), () => setLayout({ collapse: !foldingOn })),
|
|
2253
2720
|
),
|
|
2721
|
+
// The switch is the only way back for a reader who does not want the
|
|
2722
|
+
// extra rows: turning it off also disposes the registrations, not just
|
|
2723
|
+
// the display, so the host list and this page agree.
|
|
2724
|
+
layoutRow(
|
|
2725
|
+
t('layoutBridge'),
|
|
2726
|
+
t('layoutBridgeHint'),
|
|
2727
|
+
layoutSwitch(bridgeOn, t('layoutBridge'), () => setLayout({ bridge: !bridgeOn })),
|
|
2728
|
+
),
|
|
2254
2729
|
layoutRow(
|
|
2255
2730
|
t('layoutMax'),
|
|
2256
2731
|
null,
|
|
@@ -2350,6 +2825,7 @@ function build(require) {
|
|
|
2350
2825
|
state.inventory && state.inventory.repeated ? t('diagYes') : t('diagNo'),
|
|
2351
2826
|
true,
|
|
2352
2827
|
),
|
|
2828
|
+
diagRow(t('diagBridge'), String(bridgeState.entries.size), true),
|
|
2353
2829
|
diagnostics && diagnostics.error ? h('dt', { key: 'et' }, 'error') : null,
|
|
2354
2830
|
diagnostics && diagnostics.error
|
|
2355
2831
|
? h('dd', { key: 'ed', className: 'dpc-error' }, diagnostics.error)
|
|
@@ -2525,6 +3001,16 @@ function build(require) {
|
|
|
2525
3001
|
.dpc-segmentBtn--on{background:var(--dsw-alias-settings-card-fill,rgba(127,127,127,.22));color:var(--dsw-alias-label-primary,#8a8a8a)}
|
|
2526
3002
|
.dpc-segmentBtn:disabled{opacity:.4;cursor:default}
|
|
2527
3003
|
|
|
3004
|
+
/* A bridged settings page: its own bar, then the host-rendered section. */
|
|
3005
|
+
.dpc-bridgePage{gap:16px}
|
|
3006
|
+
.dpc-bridgeBar{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:40px;padding-bottom:8px;border-bottom:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.14))}
|
|
3007
|
+
.dpc-bridgeBack{display:inline-flex;align-items:center;gap:6px;margin-left:-8px}
|
|
3008
|
+
.dpc-bridgeBack .dpc-ico{width:14px;height:14px}
|
|
3009
|
+
.dpc-bridgeTags{display:flex;align-items:center;gap:8px;min-width:0}
|
|
3010
|
+
.dpc-bridgeTag{flex:none;padding:2px 8px;border:.5px solid var(--dsw-alias-border-l2,rgba(127,127,127,.24));border-radius:999px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary,rgba(127,127,127,.95))}
|
|
3011
|
+
.dpc-bridgeFrom{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--dsw-font-family-code,ui-monospace,monospace);font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary,rgba(127,127,127,.8))}
|
|
3012
|
+
.dpc-bridgeBody{min-width:0}
|
|
3013
|
+
|
|
2528
3014
|
@media (max-width:1000px){
|
|
2529
3015
|
/* Below the column width the frame becomes the gutter instead of pushing
|
|
2530
3016
|
content past the window edge. */
|
|
@@ -2534,6 +3020,7 @@ function build(require) {
|
|
|
2534
3020
|
.dpc-row{flex-wrap:wrap;gap:8px}
|
|
2535
3021
|
.dpc-rowText{flex:1 1 120px}
|
|
2536
3022
|
.dpc-tags{order:8}
|
|
3023
|
+
.dpc-bridgeFrom{display:none}
|
|
2537
3024
|
}
|
|
2538
3025
|
/* The settings column is narrower than the page column: drop what is only
|
|
2539
3026
|
nice-to-have there instead of letting the row overflow. */
|
|
@@ -2575,7 +3062,7 @@ function build(require) {
|
|
|
2575
3062
|
function applyInner(ctx) {
|
|
2576
3063
|
try { traceBoot('apply'); } catch { /* ignore */ }
|
|
2577
3064
|
try { if (document.title.startsWith('DPC ')) document.title = 'DPC ok'; } catch { /* ignore */ }
|
|
2578
|
-
ctxRef = ctx;
|
|
3065
|
+
ctxRef = ctx;
|
|
2579
3066
|
|
|
2580
3067
|
// A hot-swapped Client half re-runs apply() without reloading the page, so
|
|
2581
3068
|
// the previous sheet is still in <head>: drop it, or two revisions of the
|
|
@@ -2668,14 +3155,28 @@ function build(require) {
|
|
|
2668
3155
|
}
|
|
2669
3156
|
|
|
2670
3157
|
|
|
2671
|
-
try { traceBoot('ready'); } catch { /* ignore */ }
|
|
3158
|
+
try { traceBoot('ready'); } catch { /* ignore */ }
|
|
2672
3159
|
// A registration, a rename or a locale switch changes the list this plugin
|
|
2673
3160
|
// lays out, so every one of them re-runs the pass.
|
|
2674
3161
|
if (typeof ctx.slots.subscribe === 'function') {
|
|
2675
|
-
ctx.effect(
|
|
3162
|
+
ctx.effect(
|
|
3163
|
+
() =>
|
|
3164
|
+
ctx.slots.subscribe(SLOT_PANELS, () => {
|
|
3165
|
+
if (engineRef) engineRef.schedule();
|
|
3166
|
+
scheduleBridgeSync();
|
|
3167
|
+
}),
|
|
3168
|
+
`${PACKAGE}: panel entries`,
|
|
3169
|
+
);
|
|
2676
3170
|
}
|
|
2677
3171
|
if (locale && typeof locale.subscribe === 'function') {
|
|
2678
|
-
ctx.effect(
|
|
3172
|
+
ctx.effect(
|
|
3173
|
+
() =>
|
|
3174
|
+
locale.subscribe(() => {
|
|
3175
|
+
if (engineRef) engineRef.schedule();
|
|
3176
|
+
scheduleBridgeSync();
|
|
3177
|
+
}),
|
|
3178
|
+
`${PACKAGE}: labels`,
|
|
3179
|
+
);
|
|
2679
3180
|
}
|
|
2680
3181
|
|
|
2681
3182
|
ctx.slots.inject(SLOT_MAIN, () => {
|
|
@@ -2714,6 +3215,35 @@ function build(require) {
|
|
|
2714
3215
|
return undefined;
|
|
2715
3216
|
}
|
|
2716
3217
|
});
|
|
3218
|
+
|
|
3219
|
+
// A bridged page follows three registries: the settings sections it
|
|
3220
|
+
// mirrors, the panels it must not duplicate, and the locale that names
|
|
3221
|
+
// both. Each declaration is its own readiness signal, so the sync runs
|
|
3222
|
+
// once the slots exist and again on every later change.
|
|
3223
|
+
const kickBridge = () => {
|
|
3224
|
+
try {
|
|
3225
|
+
watchSettings();
|
|
3226
|
+
scheduleBridgeSync();
|
|
3227
|
+
scheduleBridgeRetries();
|
|
3228
|
+
} catch (error) {
|
|
3229
|
+
console.warn(`[${PACKAGE}] bridge scheduling failed`, error);
|
|
3230
|
+
}
|
|
3231
|
+
};
|
|
3232
|
+
try {
|
|
3233
|
+
// Re-install the watch on every declaration: before the slot exists
|
|
3234
|
+
// `subscribe` cannot observe the section registry, and a stale
|
|
3235
|
+
// subscription would miss the pages that register after us.
|
|
3236
|
+
ctx.slots.inject(SLOT_SETTINGS, () => {
|
|
3237
|
+
unwatchSettings();
|
|
3238
|
+
watchSettings();
|
|
3239
|
+
kickBridge();
|
|
3240
|
+
});
|
|
3241
|
+
ctx.slots.inject(SLOT_PANELS, kickBridge);
|
|
3242
|
+
} catch (error) {
|
|
3243
|
+
console.warn(`[${PACKAGE}] bridge slots unavailable`, error);
|
|
3244
|
+
}
|
|
3245
|
+
ctx.effect(() => () => disposeBridges(), `${PACKAGE}: bridge cleanup`);
|
|
3246
|
+
kickBridge();
|
|
2717
3247
|
try { window.__dpcApplied = true; } catch { /* ignore */ }
|
|
2718
3248
|
}
|
|
2719
3249
|
|
|
@@ -2736,7 +3266,9 @@ function build(require) {
|
|
|
2736
3266
|
}
|
|
2737
3267
|
|
|
2738
3268
|
return {
|
|
2739
|
-
|
|
3269
|
+
// `layout` is only read (never patched): the bridge's back button and its
|
|
3270
|
+
// selection fallback call the public selectPanel action.
|
|
3271
|
+
inject: ['slots', 'layout'],
|
|
2740
3272
|
// Pure helpers the browser harness drives directly, without a Cordis context.
|
|
2741
3273
|
testables: { createEngine, mapButtons, readEntries, normalize, sanitize, store, reorderList, applyInner, setCtx: (value) => { ctxRef = value; } },
|
|
2742
3274
|
apply(ctx) {
|
|
@@ -2753,7 +3285,7 @@ function build(require) {
|
|
|
2753
3285
|
}
|
|
2754
3286
|
applyInner(ctx);
|
|
2755
3287
|
} catch (error) {
|
|
2756
|
-
console.error(`[${PACKAGE}] apply failed`, error);
|
|
3288
|
+
console.error(`[${PACKAGE}] apply failed`, error);
|
|
2757
3289
|
reportHost({ kind: 'apply-failed', message: (error && error.message) || String(error) });
|
|
2758
3290
|
}
|
|
2759
3291
|
},
|