@morlay/dsh-client-ui-primitives 0.0.2-alpha.8 → 0.0.2-alpha.9

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.
@@ -0,0 +1,334 @@
1
+ /**
2
+ * 行模型:把字段树(`walkFields` 的结果)排成**编辑器的行**——结构行(`{` / `[` / `}` / `]`)、字段行、注释行、
3
+ * 以及容器尾部的添加行。
4
+ *
5
+ * 渲染层因此只按行画,不必自己递归:行序 = 视觉顺序,行号连续。折叠在这里生效(折叠的容器只留开启行)。
6
+ */
7
+
8
+ import { fieldKey, type AddableProperty, type SchemaFormState } from "./controller.ts";
9
+ import type { DraftFieldState } from "./draft.ts";
10
+ import type { ResolveText } from "./labels.ts";
11
+ import { fieldHint } from "./labels.ts";
12
+ import type { SchemaFormTranslate } from "./slot-contract.ts";
13
+ import {
14
+ unionOf,
15
+ unwrapLazy,
16
+ variantChoices,
17
+ variantOf,
18
+ type FieldNode,
19
+ type VariantChoice,
20
+ type WalkedField,
21
+ } from "./schema-node.ts";
22
+
23
+ /** 一个容器的闭合行后面能加什么。 */
24
+ export type AddLineSpec =
25
+ /** 数组:追加一个空项。 */
26
+ | { kind: "item" }
27
+ /** 字典:敲键名,或从候选键里选(业务注册的读数)。 */
28
+ | { kind: "key"; options: readonly AddableProperty[] }
29
+ /** 对象:从「schema 声明了、值里还没有」的字段里选(没有候选项时不给输入框)。 */
30
+ | { kind: "prop"; options: readonly AddableProperty[] };
31
+
32
+ /** 一行上的变体切换控件。 */
33
+ export interface VariantControl {
34
+ /** 可以切到哪几支;顺序即声明顺序。 */
35
+ choices: readonly VariantChoice[];
36
+ /** 当前落在第几支。 */
37
+ selected: number;
38
+ }
39
+
40
+ /** 容器类型:结构行用 `{` 还是 `[`。 */
41
+ export type ContainerShape = "object" | "array";
42
+
43
+ /** 一行。 */
44
+ export type EditorLine =
45
+ /** 容器的开启行:`key: {` 或 `[`。 */
46
+ | {
47
+ kind: "open";
48
+ path: readonly string[];
49
+ node: FieldNode;
50
+ shape: ContainerShape;
51
+ depth: number;
52
+ collapsed: boolean;
53
+ member: WalkedField["member"];
54
+ /** 这一层是 union 时,可以在哪几支之间切(写哪个值)。 */
55
+ variants: VariantControl | undefined;
56
+ }
57
+ /** 容器的关闭行:`}` / `]`(添加行挂在它后面同一行)。 */
58
+ | {
59
+ kind: "close";
60
+ path: readonly string[];
61
+ node: FieldNode;
62
+ shape: ContainerShape;
63
+ depth: number;
64
+ /** 这一层能加什么;`undefined` 表示加不了(对象的声明字段都配齐了)。 */
65
+ add: AddLineSpec | undefined;
66
+ member: WalkedField["member"];
67
+ }
68
+ /** 字段行:`key: value`。 */
69
+ | {
70
+ kind: "field";
71
+ path: readonly string[];
72
+ node: FieldNode;
73
+ depth: number;
74
+ field: DraftFieldState;
75
+ member: WalkedField["member"];
76
+ /** 这个字段是 union 时,可以在哪几支之间切。 */
77
+ variants: VariantControl | undefined;
78
+ /**
79
+ * 变体触发**代替这一行的值**:判别式 union 的标签行就是这样——`type: "sqlite" ▾` 里的值就是那个触发,
80
+ * 不再另画一份值。
81
+ */
82
+ variantsStandIn: boolean;
83
+ }
84
+ /** 注释行:schema 的说明与业务给的文案,画在字段行上方;这一行有问题时它装的是那条消息。 */
85
+ | {
86
+ kind: "comment";
87
+ path: readonly string[];
88
+ depth: number;
89
+ text: string;
90
+ /** 这一行有校验消息(草稿解析失败或整段校验落下来):画成错误,而不是普通说明。 */
91
+ invalid: boolean;
92
+ };
93
+
94
+ /**
95
+ * 容器的形状:对象类用 `{}`,序列类用 `[]`。
96
+ *
97
+ * union 没有自己的形状——它跟着**当前选中那一支**走(`access` 是数组就在页面上画 `[`),这也是
98
+ * `access: string | string[]` 这类字段以前被画成对象容器的原因。
99
+ * @param node - 字段节点。
100
+ * @param value - 这一层的值(选支判据)。
101
+ * @returns 形状;叶子节点是 `undefined`。
102
+ */
103
+ export function containerShape(node: FieldNode, value?: unknown): ContainerShape | undefined {
104
+ switch (variantOf(node, value).type) {
105
+ case "object":
106
+ case "dict":
107
+ case "intersect":
108
+ return "object";
109
+ case "array":
110
+ case "tuple":
111
+ return "array";
112
+ default:
113
+ return undefined;
114
+ }
115
+ }
116
+
117
+ /** 折叠状态与可见性:记「用户切换过的路径」,开合 = 切换过就反转 schema 的默认值。 */
118
+ export interface FoldState {
119
+ collapsed(path: readonly string[]): boolean;
120
+ toggle(path: readonly string[]): void;
121
+ }
122
+
123
+ /** 渲染顺序里可见的字段:折叠的容器把整棵子树滤掉,只留它自己的开启行。 */
124
+ export function visibleFields(state: SchemaFormState, fold: FoldState): readonly WalkedField[] {
125
+ const containers = new Set(
126
+ state.walked
127
+ .filter((item) => containerShape(item.node, valueAt(state, item.path)) !== undefined)
128
+ .map((item) => fieldKey(item.path)),
129
+ );
130
+ return state.walked.filter((item) => {
131
+ for (let depth = 1; depth < item.path.length; depth++) {
132
+ const ancestor = item.path.slice(0, depth);
133
+ if (containers.has(fieldKey(ancestor)) && fold.collapsed(ancestor)) return false;
134
+ }
135
+ return true;
136
+ });
137
+ }
138
+
139
+ /** 这一层的当前值:与字段行显示的是同一份读数(草稿优先)。 */
140
+ function valueAt(state: SchemaFormState, path: readonly string[]): unknown {
141
+ return state.fields.get(fieldKey(path))?.value;
142
+ }
143
+
144
+ /** 这一层能加什么:数组追加空项,字典敲键名(可来自候选),对象从还没配的声明字段里选。 */
145
+ function addOf(
146
+ node: FieldNode,
147
+ shape: ContainerShape,
148
+ options: readonly AddableProperty[],
149
+ ): AddLineSpec | undefined {
150
+ if (shape === "array") return { kind: "item" };
151
+ if (unwrapLazy(node)?.type === "dict") return { kind: "key", options };
152
+ return options.length === 0 ? undefined : { kind: "prop", options };
153
+ }
154
+
155
+ /** 一个节点上的变体切换控件:不是 union、或只有一支、或成员全是常量(字面量集合是选择器)时没有。 */
156
+ function variantsOf(node: FieldNode, value: unknown): VariantControl | undefined {
157
+ const union = unionOf(node);
158
+ if (union === undefined || union.choices !== undefined || union.variants.length < 2)
159
+ return undefined;
160
+ return {
161
+ choices: variantChoices(union),
162
+ selected: union.variants.indexOf(variantOf(union, value)),
163
+ };
164
+ }
165
+
166
+ /**
167
+ * 排成行序列。
168
+ * @param state - 页面读数。
169
+ * @param fold - 折叠状态。
170
+ * @param resolveText - `description` 的本地化。
171
+ * @param t - 本包字典。
172
+ * @returns 行序列(行号即下标)。
173
+ */
174
+ export function editorLines(
175
+ state: SchemaFormState,
176
+ fold: FoldState,
177
+ resolveText: ResolveText,
178
+ t: SchemaFormTranslate,
179
+ ): EditorLine[] {
180
+ const lines: EditorLine[] = [];
181
+ const open: {
182
+ path: readonly string[];
183
+ node: FieldNode;
184
+ shape: ContainerShape;
185
+ depth: number;
186
+ add: AddLineSpec | undefined;
187
+ member: WalkedField["member"];
188
+ }[] = [];
189
+ const closeOpenUntil = (path: readonly string[]): void => {
190
+ while (open.length > 0) {
191
+ const top = open[open.length - 1]!;
192
+ if (isPrefix(top.path, path)) return;
193
+ open.pop();
194
+ lines.push({
195
+ kind: "close",
196
+ path: top.path,
197
+ node: top.node,
198
+ shape: top.shape,
199
+ depth: top.depth,
200
+ add: top.add,
201
+ member: top.member,
202
+ });
203
+ }
204
+ };
205
+ for (const item of visibleFields(state, fold)) {
206
+ closeOpenUntil(item.path);
207
+ const node = unwrapLazy(item.node) ?? item.node;
208
+ const value = valueAt(state, item.path);
209
+ // 层级从根算起:根的 `{` 是 0,第一层字段就是 1——第一层也缩进一格。
210
+ const depth = item.path.length;
211
+ const comment = commentFor(state, item, resolveText, t);
212
+ if (comment !== undefined) {
213
+ lines.push({
214
+ kind: "comment",
215
+ path: item.path,
216
+ depth,
217
+ text: comment.text,
218
+ invalid: comment.invalid,
219
+ });
220
+ }
221
+ const variants = variantsOf(node, value);
222
+ const shape = containerShape(node, value);
223
+ if (shape !== undefined) {
224
+ const collapsed = fold.collapsed(item.path);
225
+ const add = addOf(node, shape, state.addable.get(fieldKey(item.path)) ?? []);
226
+ lines.push({
227
+ kind: "open",
228
+ path: item.path,
229
+ node,
230
+ shape,
231
+ depth,
232
+ collapsed,
233
+ member: item.member,
234
+ variants,
235
+ });
236
+ // 折叠的容器用 `{…}` 收在一行里:它不再有子行,也没有自己的关闭行。
237
+ if (!collapsed) {
238
+ open.push({ path: item.path, node, shape, depth, add, member: item.member });
239
+ }
240
+ continue;
241
+ }
242
+ lines.push({
243
+ kind: "field",
244
+ path: item.path,
245
+ node,
246
+ depth,
247
+ field: state.fields.get(fieldKey(item.path)) ?? {
248
+ value: undefined,
249
+ text: undefined,
250
+ invalid: undefined,
251
+ overridden: false,
252
+ staged: false,
253
+ },
254
+ member: item.member,
255
+ variants,
256
+ variantsStandIn: false,
257
+ });
258
+ }
259
+ while (open.length > 0) {
260
+ const top = open.pop()!;
261
+ lines.push({
262
+ kind: "close",
263
+ path: top.path,
264
+ node: top.node,
265
+ shape: top.shape,
266
+ depth: top.depth,
267
+ add: top.add,
268
+ member: top.member,
269
+ });
270
+ }
271
+ return moveVariantControlToTag(lines);
272
+ }
273
+
274
+ /**
275
+ * 判别式 union 的切换控件挪到**标签字段那一行**上:`session-rdb` 的页面上用户改的就是 `type`,把它放在容器
276
+ * 行上等于让人在两处看同一件事。没有标签字段(`access` 这种按值形状选的)就留在 union 自己那一行。
277
+ */
278
+ function moveVariantControlToTag(lines: EditorLine[]): EditorLine[] {
279
+ for (const line of lines) {
280
+ if (line.kind === "comment" || line.kind === "close") continue;
281
+ if (line.variants === undefined) continue;
282
+ const tagKey = unionOf(line.node)?.tagKey;
283
+ if (tagKey === undefined) continue;
284
+ const tagPath = [...line.path, tagKey];
285
+ const tagLine = lines.find(
286
+ (candidate) =>
287
+ candidate.kind === "field" &&
288
+ candidate.path.length === tagPath.length &&
289
+ candidate.path.every((segment, index) => segment === tagPath[index]),
290
+ );
291
+ if (tagLine === undefined || tagLine.kind !== "field") continue;
292
+ // 标签行:值位置直接画那个切换控件(值是 const,本来就改不了)。
293
+ tagLine.variants = line.variants;
294
+ tagLine.variantsStandIn = true;
295
+ line.variants = undefined;
296
+ }
297
+ return lines;
298
+ }
299
+
300
+ /**
301
+ * 一行的注释:业务给的文案(字段槽的 label/hint)优先,其次 schema 的说明。
302
+ *
303
+ * 这一行有问题时**注释位让给那条消息**(参考实现也是把错误画在行尾那一段的位置上)——说明换成"哪里不对",
304
+ * 错误因此不必挤在值后面。
305
+ */
306
+ function commentFor(
307
+ state: SchemaFormState,
308
+ item: WalkedField,
309
+ resolveText: ResolveText,
310
+ t: SchemaFormTranslate,
311
+ ): { text: string; invalid: boolean } | undefined {
312
+ const key = fieldKey(item.path);
313
+ const failure = state.fields.get(key)?.invalid ?? state.invalidAt.get(key);
314
+ if (failure !== undefined) return { text: failure, invalid: true };
315
+ const node = unwrapLazy(item.node) ?? item.node;
316
+ const parts: string[] = [];
317
+ // 注释放"这是什么"(说明)与只读、徽标这类一眼看不出的状态,不放"必填"这类结构信息——那是 schema 的常规。
318
+ // 业务经提示面给的文案优先,schema 的 `description` / `comment` 兜底。
319
+ const text = state.texts.get(fieldKey(item.path));
320
+ const hint =
321
+ [text?.label, text?.hint].filter((part) => part !== undefined && part !== "").join(":") ||
322
+ fieldHint(node, resolveText);
323
+ if (hint !== undefined && hint !== "") parts.push(hint);
324
+ // 只读的字段(装配事实这类)要说清楚为什么改不了,否则看起来只是"点不开"。
325
+ if (node.meta.disabled) parts.push(t("readOnlyField"));
326
+ if (node.meta.badges.length > 0) parts.push(node.meta.badges.join(" / "));
327
+ return parts.length === 0 ? undefined : { text: parts.join(" · "), invalid: false };
328
+ }
329
+
330
+ /** `path` 是不是 `prefix` 的后代(含自身)。 */
331
+ function isPrefix(prefix: readonly string[], path: readonly string[]): boolean {
332
+ if (prefix.length > path.length) return false;
333
+ return prefix.every((segment, index) => path[index] === segment);
334
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * 本包字典(命名空间 `settings.schema-form`):表单壳、字段控件与只读呈现的文案。
3
+ *
4
+ * 字段自身的人话标签不在这里(那是业务方经字段槽或 schema `description` 给的),这里只放**机制**文案。
5
+ */
6
+
7
+ /** 中文文案(部署语言偏好是 zh)。 */
8
+ export const zh = {
9
+ unavailable: "该插件当前未加载,暂时无法配置。",
10
+ readOnly: "本部署的设置为只读。",
11
+ save: "保存",
12
+ confirmEdit: "确认",
13
+ cancelEdit: "取消",
14
+ saving: "保存中…",
15
+ saveFailed: "本部署没有接受这些值,已保留供你修改。",
16
+ notSaved: "没有保存:{message}",
17
+ noSchema: "这一行没有可自动生成的配置项。",
18
+
19
+ reset: "恢复默认",
20
+ revert: "撤回",
21
+ invalid: "这个值不被接受。",
22
+ invalidNumber: "需要一个数字。",
23
+ invalidInteger: "需要一个整数。",
24
+ invalidJson: "需要一段合法的 JSON。",
25
+ invalidBoolean: "需要 true 或 false。",
26
+ invalidConst: "这个字段只接受固定值。",
27
+ invalidPattern: "要匹配 {pattern}。",
28
+ invalidRange: "要在 {min} 与 {max} 之间。",
29
+ invalidMin: "不能小于 {min}。",
30
+ invalidMax: "不能大于 {max}。",
31
+ invalidChoice: "只能是 {choices} 之一。",
32
+ unknownProperty: "这一层没有这个字段:从上面的候选里选,或换个键名。",
33
+ empty: "(空)",
34
+
35
+ toggleGroup: "展开 / 收起这一层",
36
+ switchVariant: "切换这一层的形状或分支",
37
+ copyValue: "复制值",
38
+ copied: "已复制",
39
+ addProperty: "添加属性",
40
+ addItemPaste: "添加数组项(可粘贴 JSON 字符串)",
41
+ addItem: "添加一项",
42
+ removeItem: "移除",
43
+ addKey: "添加键(可粘贴 JSON 字符串)",
44
+ keyName: "键名",
45
+ noKeys: "还没有键。",
46
+ noItems: "还没有项。",
47
+
48
+ deprecated: "已废弃",
49
+ experimental: "试验中",
50
+ secretSet: "已配置",
51
+ secretUnset: "未配置",
52
+ secretHint: "留空即保留已存的值。",
53
+
54
+ constValue: "固定值",
55
+ notEditable: "这一项由插件在运行时提供,不能在页面上编辑。",
56
+ recursive: "递归结构:展开到这一层为止。",
57
+ fixedLength: "固定长度",
58
+ bitsetHint: "可多选。",
59
+ unconfigured: "未配置",
60
+ readOnlyField: "只读",
61
+ noDescription: "(无说明)",
62
+ required: "必填",
63
+ duplicateItem: "重复的 {key}:{value}",
64
+ missingItem: "每一项都需要一个 {key}",
65
+ } as const;
66
+
67
+ /** 英文文案。 */
68
+ export const en = {
69
+ unavailable: "This plugin is not loaded, so it cannot be configured right now.",
70
+ readOnly: "This deployment stores settings read-only.",
71
+ save: "Save",
72
+ confirmEdit: "Confirm",
73
+ cancelEdit: "Cancel",
74
+ saving: "Saving…",
75
+ saveFailed: "This deployment did not accept these values; they are kept for you to fix.",
76
+ notSaved: "Not saved: {message}",
77
+ noSchema: "This row has no configuration to generate.",
78
+
79
+ reset: "Reset",
80
+ revert: "Revert",
81
+ invalid: "This value is not accepted.",
82
+ invalidNumber: "Enter a number.",
83
+ invalidInteger: "Enter a whole number.",
84
+ invalidJson: "Enter valid JSON.",
85
+ invalidBoolean: "Enter true or false.",
86
+ invalidConst: "This field only accepts its fixed value.",
87
+ invalidPattern: "Must match {pattern}.",
88
+ invalidRange: "Must be between {min} and {max}.",
89
+ invalidMin: "Must not be less than {min}.",
90
+ invalidMax: "Must not be greater than {max}.",
91
+ invalidChoice: "Must be one of {choices}.",
92
+ unknownProperty: "No such field at this level: pick one of the candidates above.",
93
+ empty: "(empty)",
94
+
95
+ toggleGroup: "Expand or collapse this level",
96
+ switchVariant: "Switch this level's shape or branch",
97
+ copyValue: "Copy value",
98
+ copied: "Copied",
99
+ addProperty: "Add property",
100
+ addItemPaste: "Add item (a JSON string can be pasted)",
101
+ addItem: "Add item",
102
+ removeItem: "Remove",
103
+ addKey: "Add key (a JSON string can be pasted)",
104
+ keyName: "Key",
105
+ noKeys: "No keys yet.",
106
+ noItems: "No items yet.",
107
+
108
+ deprecated: "deprecated",
109
+ experimental: "experimental",
110
+ secretSet: "configured",
111
+ secretUnset: "not set",
112
+ secretHint: "Leave blank to keep the stored value.",
113
+
114
+ constValue: "fixed",
115
+ notEditable: "This entry is provided by the plugin at runtime and cannot be edited here.",
116
+ recursive: "Recursive structure: expanded down to this level.",
117
+ fixedLength: "fixed length",
118
+ bitsetHint: "Multiple choices.",
119
+ unconfigured: "not configured",
120
+ readOnlyField: "read-only",
121
+ noDescription: "(no description)",
122
+ required: "required",
123
+ duplicateItem: "Duplicate {key}: {value}",
124
+ missingItem: "Every item needs a {key}",
125
+ } as const;
126
+
127
+ /** 本命名空间的字典键。 */
128
+ export type SchemaFormLocaleKey = keyof typeof zh;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * 自动注册面:把 describe 出来的命名空间与 bundle 的行清单配对,为每个能渲染的行注册本行的配置入口。
3
+ *
4
+ * 注册用 `<bundle 包名>#<行 id>`(页面按同一个 key 打开这一行的配置页)与兜底 priority:手写卡片用默认
5
+ * priority 0 注册,按 slots 的 cell winner 规则自然遮住这里的自动项,与加载顺序无关。
6
+ */
7
+
8
+ import type { BundleInfo, SettingsNamespaceView } from "@deepseek-ai/dsh-api-remotes/client";
9
+ import type { SettingsDescribeFace } from "@deepseek-ai/dsh-client-ui-settings/client";
10
+
11
+ /** 一个该自动长出配置页的行。 */
12
+ export interface RowRegistrationPlan {
13
+ /** `plugins.row.config` 的 key:`<bundle 包名>#<行 id>`。 */
14
+ key: string;
15
+ /** settings 命名空间(行 id)。 */
16
+ ns: string;
17
+ }
18
+
19
+ /**
20
+ * 配对命名空间与 bundle 行清单。
21
+ * @param namespaces - host 发来的命名空间视图。
22
+ * @param bundles - host 的 bundle 清单(含各 bundle 声明的行 id)。
23
+ * @returns 该自动注册的行,按命名空间视图的顺序。
24
+ */
25
+ export function rowRegistrations(
26
+ namespaces: readonly SettingsNamespaceView[],
27
+ bundles: readonly BundleInfo[],
28
+ ): RowRegistrationPlan[] {
29
+ const owners = new Map<string, string>();
30
+ for (const bundle of bundles) {
31
+ for (const row of bundle.rows) owners.set(row.rowId, bundle.name);
32
+ }
33
+ const plans: RowRegistrationPlan[] = [];
34
+ for (const view of namespaces) {
35
+ if (view.autoGenerate !== true) continue;
36
+ const owner = owners.get(view.ns);
37
+ if (owner === undefined) continue;
38
+ plans.push({ key: `${owner}#${view.ns}`, ns: view.ns });
39
+ }
40
+ return plans;
41
+ }
42
+
43
+ /** 注册器要的外部面。 */
44
+ export interface RowRegistrationDeps {
45
+ /** 槽位注册:返回注销函数。 */
46
+ slots: {
47
+ register(
48
+ options: {
49
+ name: "plugins.row.config";
50
+ key: string;
51
+ priority: number;
52
+ locale: string;
53
+ },
54
+ component: unknown,
55
+ ): () => void;
56
+ };
57
+ /** describe 读面:命名空间清单与变更通知。 */
58
+ describe: SettingsDescribeFace;
59
+ /** 读 bundle 清单。 */
60
+ bundles(): Promise<readonly BundleInfo[]>;
61
+ /** 订阅 bundle 清单变化。 */
62
+ subscribeBundles(listener: () => void): () => void;
63
+ /**
64
+ * 一行的注册项组件;`undefined` 表示这一行渲染不了(schema rehydrate 不了或段根不是对象)。
65
+ * 每次调用返回同一个实例(控制器按命名空间缓存)。
66
+ */
67
+ entryFor(ns: string): { component: unknown } | undefined;
68
+ /** 槽项文案的命名空间。 */
69
+ locale: string;
70
+ /** 一行注销时释放它的控制器。 */
71
+ release(ns: string): void;
72
+ }
73
+
74
+ /** 自动项的兜底优先级:手写卡片(默认 0)遮住它。 */
75
+ const FALLBACK_PRIORITY = 100;
76
+
77
+ /**
78
+ * 自动注册的差分器:按 describe 与 bundle 清单把「该有的行」与「已在的行」对齐。
79
+ */
80
+ export class RowRegistration {
81
+ readonly #deps: RowRegistrationDeps;
82
+ readonly #live = new Map<string, () => void>();
83
+ #pending: Promise<void> | undefined;
84
+ #disposed = false;
85
+
86
+ /**
87
+ * @param deps - 槽位、清单读面与每行的槽项工厂。
88
+ */
89
+ constructor(deps: RowRegistrationDeps) {
90
+ this.#deps = deps;
91
+ }
92
+
93
+ /**
94
+ * 对齐一次:新增该有的行,注销不该有的行。
95
+ *
96
+ * 并发调用折叠成一次(bundle 清单只读一趟),后到的调用等同一趟结束。
97
+ * @returns 对齐完成。
98
+ */
99
+ sync(): Promise<void> {
100
+ if (this.#disposed) return Promise.resolve();
101
+ this.#pending ??= this.#run().finally(() => {
102
+ this.#pending = undefined;
103
+ });
104
+ return this.#pending;
105
+ }
106
+
107
+ /** 收掉自己:注销还活着的全部注册并释放它们的控制器。 */
108
+ dispose(): void {
109
+ this.#disposed = true;
110
+ this.#release();
111
+ }
112
+
113
+ async #run(): Promise<void> {
114
+ const bundles = await this.#deps.bundles();
115
+ if (this.#disposed) return;
116
+ const namespaces = this.#deps.describe.getSnapshot().view?.namespaces ?? [];
117
+ const wanted = new Map<string, RowRegistrationPlan>();
118
+ for (const plan of rowRegistrations(namespaces, bundles)) {
119
+ if (this.#deps.entryFor(plan.ns) === undefined) continue;
120
+ wanted.set(plan.ns, plan);
121
+ }
122
+ for (const [ns, dispose] of this.#live) {
123
+ if (wanted.has(ns)) continue;
124
+ dispose();
125
+ this.#live.delete(ns);
126
+ this.#deps.release(ns);
127
+ }
128
+ for (const [ns, plan] of wanted) {
129
+ if (this.#live.has(ns)) continue;
130
+ const entry = this.#deps.entryFor(ns);
131
+ if (entry === undefined) continue;
132
+ const dispose = this.#deps.slots.register(
133
+ {
134
+ name: "plugins.row.config",
135
+ key: plan.key,
136
+ priority: FALLBACK_PRIORITY,
137
+ locale: this.#deps.locale,
138
+ },
139
+ entry.component,
140
+ );
141
+ this.#live.set(ns, dispose);
142
+ }
143
+ }
144
+
145
+ /** 注销全部并释放控制器。 */
146
+ #release(): void {
147
+ for (const [ns, dispose] of this.#live) {
148
+ dispose();
149
+ this.#live.delete(ns);
150
+ this.#deps.release(ns);
151
+ }
152
+ }
153
+ }