@forgeax/interface 0.9.2 → 0.9.4
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/dist/package/browser/appHostBootstrap.js +17 -12
- package/dist/package/browser/core/app-shell/catalog-page-extensions.js +37 -13
- package/dist/package/browser/core/app-shell/host.js +12 -3
- package/dist/package/browser/core/page-platform/session.d.ts +5 -0
- package/dist/package/browser/core/page-platform/session.js +11 -4
- package/dist/package/browser/core/panels/panel-action-registry.d.ts +4 -6
- package/dist/package/browser/core/panels/panel-action-registry.js +2 -62
- package/dist/package/browser/core/panels/panel-control-registry.d.ts +4 -6
- package/dist/package/browser/core/panels/panel-control-registry.js +2 -54
- package/dist/package/node/appHostBootstrap.js +17 -12
- package/dist/package/node/core/app-shell/catalog-page-extensions.js +37 -13
- package/dist/package/node/core/app-shell/host.js +12 -3
- package/dist/package/node/core/page-platform/session.js +11 -4
- package/dist/package/node/core/panels/panel-action-registry.js +2 -62
- package/dist/package/node/core/panels/panel-control-registry.js +2 -54
- package/package.json +3 -3
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// the extension's cleanup, so a pure-UI extension needs no setup at all.
|
|
16
16
|
import { createAppHost, } from './core/app-shell';
|
|
17
17
|
import { createExtensionLoader } from './core/extension-foundation/loader';
|
|
18
|
+
import { ExtensionCleanupDeferredError, ExtensionSetupRollbackDeferredError } from '@forgeax/extension-platform/extensions';
|
|
18
19
|
import { foundationCommandsExtension } from './core/extensions/foundation-commands';
|
|
19
20
|
import { foundationBusExtension } from './core/extensions/foundation-bus';
|
|
20
21
|
import { foundationStorageExtension } from './core/extensions/foundation-storage';
|
|
@@ -63,6 +64,12 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
63
64
|
control.beginSetup(m);
|
|
64
65
|
const cleanups = [];
|
|
65
66
|
let pageCleanup;
|
|
67
|
+
const rollback = async () => {
|
|
68
|
+
// Page preparation is the barrier before any destructive cleanup.
|
|
69
|
+
await pageCleanup?.();
|
|
70
|
+
for (const cleanup of cleanups.slice().reverse())
|
|
71
|
+
await cleanup();
|
|
72
|
+
};
|
|
66
73
|
try {
|
|
67
74
|
for (const menu of m.contributes?.menus ?? []) {
|
|
68
75
|
cleanups.push(host.menus.register(menu));
|
|
@@ -89,20 +96,18 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
89
96
|
cleanups.push(r);
|
|
90
97
|
if (cleanups.length === 0 && !pageCleanup)
|
|
91
98
|
return undefined;
|
|
92
|
-
return
|
|
93
|
-
// Controllers get the first say. If one vetoes/needs a user decision,
|
|
94
|
-
// no declarative or imperative contribution is torn down underneath it.
|
|
95
|
-
await pageCleanup?.();
|
|
96
|
-
for (const c of cleanups.slice().reverse())
|
|
97
|
-
await c();
|
|
98
|
-
};
|
|
99
|
+
return rollback;
|
|
99
100
|
}
|
|
100
101
|
catch (error) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
try {
|
|
103
|
+
await rollback();
|
|
104
|
+
}
|
|
105
|
+
catch (rollbackError) {
|
|
106
|
+
if (rollbackError instanceof ExtensionCleanupDeferredError) {
|
|
107
|
+
throw new ExtensionSetupRollbackDeferredError({ cause: error, rollback });
|
|
108
|
+
}
|
|
109
|
+
throw rollbackError;
|
|
110
|
+
}
|
|
106
111
|
throw error;
|
|
107
112
|
}
|
|
108
113
|
finally {
|
|
@@ -4,6 +4,7 @@ import { listExtensions, listExtensionsShared, } from '../../lib/extension-api';
|
|
|
4
4
|
import { ExtensionHostPanel } from '../../components/DockShell/ExtensionHostPanel';
|
|
5
5
|
import { isExtensionHostExtension } from '../../components/DockShell/extensionRuntime';
|
|
6
6
|
import { getLocale } from '../../i18n';
|
|
7
|
+
import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
|
|
7
8
|
function title(value) {
|
|
8
9
|
if (typeof value === 'string')
|
|
9
10
|
return value;
|
|
@@ -162,22 +163,42 @@ export function createCatalogPageExtensionRuntime(options) {
|
|
|
162
163
|
let events;
|
|
163
164
|
let chain = Promise.resolve();
|
|
164
165
|
let disposed = false;
|
|
166
|
+
const reportError = (error, id, phase) => {
|
|
167
|
+
try {
|
|
168
|
+
onError(error, id, phase);
|
|
169
|
+
}
|
|
170
|
+
catch { /* Isolate the diagnostic sink. */ }
|
|
171
|
+
};
|
|
172
|
+
const retire = async (id, record) => {
|
|
173
|
+
try {
|
|
174
|
+
await record.cleanup();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
reportError(error, id, 'cleanup');
|
|
178
|
+
if (error instanceof ExtensionCleanupDeferredError)
|
|
179
|
+
throw error;
|
|
180
|
+
// Ordinary teardown may already be destructive; never retry it.
|
|
181
|
+
}
|
|
182
|
+
active.delete(id);
|
|
183
|
+
generation = undefined;
|
|
184
|
+
};
|
|
165
185
|
const reconcile = async (response) => {
|
|
166
186
|
if (generation !== undefined && response.generation === generation)
|
|
167
187
|
return;
|
|
168
188
|
const itemsById = new Map(response.items.map((item) => [item.id, item]));
|
|
169
189
|
const next = new Map(catalogPageExtensions(response.items, options.overriddenIds)
|
|
170
190
|
.map((extension) => [extension.id, extension]));
|
|
171
|
-
for (const [id, record] of [...active]) {
|
|
191
|
+
for (const [id, record] of [...active].reverse()) {
|
|
172
192
|
const item = itemsById.get(id);
|
|
173
193
|
const signature = catalogItemSignature(item);
|
|
174
194
|
if (!next.has(id) || signature !== record.signature) {
|
|
175
195
|
try {
|
|
176
|
-
await record
|
|
177
|
-
active.delete(id);
|
|
196
|
+
await retire(id, record);
|
|
178
197
|
}
|
|
179
198
|
catch (error) {
|
|
180
|
-
|
|
199
|
+
// Keep the barrier and allow a later refresh of this same generation.
|
|
200
|
+
generation = undefined;
|
|
201
|
+
return;
|
|
181
202
|
}
|
|
182
203
|
}
|
|
183
204
|
}
|
|
@@ -195,7 +216,7 @@ export function createCatalogPageExtensionRuntime(options) {
|
|
|
195
216
|
active.set(id, { signature: catalogItemSignature(itemsById.get(id)), cleanup });
|
|
196
217
|
}
|
|
197
218
|
catch (error) {
|
|
198
|
-
|
|
219
|
+
reportError(error, id, 'register');
|
|
199
220
|
}
|
|
200
221
|
}
|
|
201
222
|
generation = response.generation;
|
|
@@ -221,14 +242,17 @@ export function createCatalogPageExtensionRuntime(options) {
|
|
|
221
242
|
}
|
|
222
243
|
}
|
|
223
244
|
},
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
245
|
+
dispose() {
|
|
246
|
+
chain = chain.catch(() => undefined).then(async () => {
|
|
247
|
+
if (disposed)
|
|
248
|
+
return;
|
|
249
|
+
for (const [id, record] of [...active].reverse())
|
|
250
|
+
await retire(id, record);
|
|
251
|
+
disposed = true;
|
|
252
|
+
events?.close();
|
|
253
|
+
events = undefined;
|
|
254
|
+
});
|
|
255
|
+
return chain;
|
|
232
256
|
},
|
|
233
257
|
};
|
|
234
258
|
}
|
|
@@ -7,13 +7,14 @@ import { createCapabilityRegistry } from '../extension-foundation/capabilities';
|
|
|
7
7
|
import { createContributionRegistry } from '../extension-foundation/contribution-registry';
|
|
8
8
|
import { ExtensionConflictError } from '../extension-foundation/errors';
|
|
9
9
|
import { createPanelActionRegistry, createPanelControlRegistry } from '../panels';
|
|
10
|
-
import { createPageRegistry, createPageSession, createActivityRegistry, createResourceEditorResolver, } from '../page-platform';
|
|
10
|
+
import { createPageRegistry, createPageSession, PageClosePreparationDeferredError, createActivityRegistry, createResourceEditorResolver, } from '../page-platform';
|
|
11
11
|
import { consoleLogger } from './logger';
|
|
12
12
|
import { derivePanelRenderers } from './derive-panel-renderers';
|
|
13
13
|
import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers';
|
|
14
14
|
import { installPageNavigation } from '../page-navigation';
|
|
15
15
|
import { createContextualKeybindings } from '../contextual-keybindings';
|
|
16
16
|
import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
17
|
+
import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
|
|
17
18
|
const BUILT_IN_CAPS = [
|
|
18
19
|
'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
19
20
|
'activities', 'resourceEditors',
|
|
@@ -158,7 +159,15 @@ export function createAppHost(deps = {}) {
|
|
|
158
159
|
return;
|
|
159
160
|
// The registry still knows the owner while controllers preflight close.
|
|
160
161
|
// A dirty/vetoed page rejects cleanup and leaves the contribution live.
|
|
161
|
-
|
|
162
|
+
try {
|
|
163
|
+
await pageSession.closeOwnedBy(owner);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error instanceof PageClosePreparationDeferredError) {
|
|
167
|
+
throw new ExtensionCleanupDeferredError(error.message);
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
162
171
|
removeContribution();
|
|
163
172
|
removed = true;
|
|
164
173
|
};
|
|
@@ -181,8 +190,8 @@ export function createAppHost(deps = {}) {
|
|
|
181
190
|
}
|
|
182
191
|
},
|
|
183
192
|
async dispose() {
|
|
184
|
-
removePageNavigation();
|
|
185
193
|
await pageSession.dispose();
|
|
194
|
+
removePageNavigation();
|
|
186
195
|
keybindings.dispose();
|
|
187
196
|
shortcuts.dispose();
|
|
188
197
|
menus.dispose();
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import type { PageCloseDecision, PageCloseReason, PagePort, PageRegistry } from './types';
|
|
2
|
+
import { PagePlatformError } from './types';
|
|
3
|
+
/** Explicit close decision barrier, emitted before any controller teardown. */
|
|
4
|
+
export declare class PageClosePreparationDeferredError extends PagePlatformError {
|
|
5
|
+
constructor(code: 'PAGE_CLOSE_VETOED' | 'PAGE_CLOSE_REQUIRES_DECISION', message: string, details?: Readonly<Record<string, unknown>>);
|
|
6
|
+
}
|
|
2
7
|
export interface PageSession extends PagePort {
|
|
3
8
|
closeOwnedBy(owner: string, reason?: PageCloseReason, decision?: PageCloseDecision): Promise<void>;
|
|
4
9
|
dispose(): Promise<void>;
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { decodePageKey, encodePageKey, } from '@forgeax/types';
|
|
2
2
|
import { PagePlatformError } from './types';
|
|
3
3
|
import { getCurrentProject, subscribeCurrentProject } from '../../lib/project-context';
|
|
4
|
+
/** Explicit close decision barrier, emitted before any controller teardown. */
|
|
5
|
+
export class PageClosePreparationDeferredError extends PagePlatformError {
|
|
6
|
+
constructor(code, message, details) {
|
|
7
|
+
super(code, message, details);
|
|
8
|
+
this.name = 'PageClosePreparationDeferredError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
4
11
|
const inertController = {
|
|
5
12
|
prepareClose: () => ({ status: 'ready' }),
|
|
6
13
|
dispose: () => undefined,
|
|
@@ -103,17 +110,17 @@ export function createPageSession(registry, options = {}) {
|
|
|
103
110
|
if (preparation.status === 'ready')
|
|
104
111
|
return;
|
|
105
112
|
if (preparation.status === 'vetoed') {
|
|
106
|
-
throw new
|
|
113
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', preparation.message ?? 'page refused to close', {
|
|
107
114
|
key: entry.instance.encodedKey,
|
|
108
115
|
reason,
|
|
109
116
|
});
|
|
110
117
|
}
|
|
111
118
|
if (!decision || decision === 'cancel') {
|
|
112
|
-
throw new
|
|
119
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_REQUIRES_DECISION', preparation.message ?? 'page has unsaved changes', { key: entry.instance.encodedKey, reason });
|
|
113
120
|
}
|
|
114
121
|
if (decision === 'save') {
|
|
115
122
|
if (!entry.controller.save) {
|
|
116
|
-
throw new
|
|
123
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot save its dirty state', {
|
|
117
124
|
key: entry.instance.encodedKey,
|
|
118
125
|
reason,
|
|
119
126
|
});
|
|
@@ -122,7 +129,7 @@ export function createPageSession(registry, options = {}) {
|
|
|
122
129
|
}
|
|
123
130
|
else {
|
|
124
131
|
if (!entry.controller.discard) {
|
|
125
|
-
throw new
|
|
132
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot discard its dirty state', {
|
|
126
133
|
key: entry.instance.encodedKey,
|
|
127
134
|
reason,
|
|
128
135
|
});
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { PanelActionContribution
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
export declare function createPanelActionRegistry(): PanelActionRegistry;
|
|
1
|
+
import { type PanelActionRegistry as Registry } from '@forgeax/app-shell/application';
|
|
2
|
+
import type { PanelActionContribution } from './types';
|
|
3
|
+
export type PanelActionRegistry = Registry<PanelActionContribution>;
|
|
4
|
+
export declare const createPanelActionRegistry: () => Registry<PanelActionContribution>;
|
|
@@ -1,62 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const listeners = new Set();
|
|
4
|
-
let cache = null;
|
|
5
|
-
let version = 0;
|
|
6
|
-
const emit = () => {
|
|
7
|
-
cache = null;
|
|
8
|
-
version++;
|
|
9
|
-
for (const listener of [...listeners])
|
|
10
|
-
listener();
|
|
11
|
-
};
|
|
12
|
-
// Batch-deferred notification: data mutations (push/splice) happen
|
|
13
|
-
// immediately so reads (list/all) always return fresh data, but listener
|
|
14
|
-
// notifications are deferred to a microtask. This avoids triggering
|
|
15
|
-
// forceStoreRerender (useSyncExternalStore) during React 19's commit
|
|
16
|
-
// phase, which otherwise causes "Maximum update depth exceeded".
|
|
17
|
-
let emitScheduled = false;
|
|
18
|
-
const scheduleEmit = () => {
|
|
19
|
-
cache = null;
|
|
20
|
-
if (emitScheduled)
|
|
21
|
-
return;
|
|
22
|
-
emitScheduled = true;
|
|
23
|
-
queueMicrotask(() => {
|
|
24
|
-
emitScheduled = false;
|
|
25
|
-
emit();
|
|
26
|
-
});
|
|
27
|
-
};
|
|
28
|
-
const all = () => {
|
|
29
|
-
if (cache)
|
|
30
|
-
return cache;
|
|
31
|
-
cache = entries.flatMap((entry) => entry.actions).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
32
|
-
return cache;
|
|
33
|
-
};
|
|
34
|
-
return {
|
|
35
|
-
contribute(owner, actions) {
|
|
36
|
-
const entry = { owner, actions };
|
|
37
|
-
entries.push(entry);
|
|
38
|
-
scheduleEmit();
|
|
39
|
-
let removed = false;
|
|
40
|
-
return () => {
|
|
41
|
-
if (removed)
|
|
42
|
-
return;
|
|
43
|
-
removed = true;
|
|
44
|
-
const index = entries.indexOf(entry);
|
|
45
|
-
if (index >= 0)
|
|
46
|
-
entries.splice(index, 1);
|
|
47
|
-
scheduleEmit();
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
list(panelId) {
|
|
51
|
-
return all().filter((action) => action.panelId === panelId);
|
|
52
|
-
},
|
|
53
|
-
all,
|
|
54
|
-
onChange(listener) {
|
|
55
|
-
listeners.add(listener);
|
|
56
|
-
return () => { listeners.delete(listener); };
|
|
57
|
-
},
|
|
58
|
-
version() {
|
|
59
|
-
return version;
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
1
|
+
import { createPanelActionRegistry as createRegistry, } from '@forgeax/app-shell/application';
|
|
2
|
+
export const createPanelActionRegistry = (createRegistry);
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import type
|
|
2
|
-
import type { PanelControlContribution
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
}
|
|
6
|
-
export declare function createPanelControlRegistry(): PanelControlRegistry;
|
|
1
|
+
import { type PanelControlRegistry as Registry } from '@forgeax/app-shell/application';
|
|
2
|
+
import type { PanelControlContribution } from './types';
|
|
3
|
+
export type PanelControlRegistry = Registry<PanelControlContribution>;
|
|
4
|
+
export declare const createPanelControlRegistry: () => Registry<PanelControlContribution>;
|
|
@@ -1,54 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const listeners = new Set();
|
|
4
|
-
let version = 0;
|
|
5
|
-
const emit = () => {
|
|
6
|
-
version++;
|
|
7
|
-
for (const listener of [...listeners])
|
|
8
|
-
listener();
|
|
9
|
-
};
|
|
10
|
-
// Batch-deferred notification: data mutations (push/splice) happen
|
|
11
|
-
// immediately so reads (list/get) always return fresh data, but listener
|
|
12
|
-
// notifications are deferred to a microtask. This avoids triggering
|
|
13
|
-
// forceStoreRerender (useSyncExternalStore) during React 19's commit
|
|
14
|
-
// phase, which otherwise causes "Maximum update depth exceeded".
|
|
15
|
-
let emitScheduled = false;
|
|
16
|
-
const scheduleEmit = () => {
|
|
17
|
-
if (emitScheduled)
|
|
18
|
-
return;
|
|
19
|
-
emitScheduled = true;
|
|
20
|
-
queueMicrotask(() => {
|
|
21
|
-
emitScheduled = false;
|
|
22
|
-
emit();
|
|
23
|
-
});
|
|
24
|
-
};
|
|
25
|
-
const list = () => entries.flatMap((entry) => entry.controls);
|
|
26
|
-
return {
|
|
27
|
-
contribute(owner, controls) {
|
|
28
|
-
const entry = { owner, controls };
|
|
29
|
-
entries.push(entry);
|
|
30
|
-
scheduleEmit();
|
|
31
|
-
let removed = false;
|
|
32
|
-
return () => {
|
|
33
|
-
if (removed)
|
|
34
|
-
return;
|
|
35
|
-
removed = true;
|
|
36
|
-
const index = entries.indexOf(entry);
|
|
37
|
-
if (index >= 0)
|
|
38
|
-
entries.splice(index, 1);
|
|
39
|
-
scheduleEmit();
|
|
40
|
-
};
|
|
41
|
-
},
|
|
42
|
-
get(id) {
|
|
43
|
-
return list().find((control) => control.id === id);
|
|
44
|
-
},
|
|
45
|
-
list,
|
|
46
|
-
onChange(listener) {
|
|
47
|
-
listeners.add(listener);
|
|
48
|
-
return () => { listeners.delete(listener); };
|
|
49
|
-
},
|
|
50
|
-
version() {
|
|
51
|
-
return version;
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
}
|
|
1
|
+
import { createPanelControlRegistry as createRegistry, } from '@forgeax/app-shell/application';
|
|
2
|
+
export const createPanelControlRegistry = (createRegistry);
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// the extension's cleanup, so a pure-UI extension needs no setup at all.
|
|
16
16
|
import { createAppHost, } from './core/app-shell/index.js';
|
|
17
17
|
import { createExtensionLoader } from './core/extension-foundation/loader.js';
|
|
18
|
+
import { ExtensionCleanupDeferredError, ExtensionSetupRollbackDeferredError } from '@forgeax/extension-platform/extensions';
|
|
18
19
|
import { foundationCommandsExtension } from './core/extensions/foundation-commands.js';
|
|
19
20
|
import { foundationBusExtension } from './core/extensions/foundation-bus.js';
|
|
20
21
|
import { foundationStorageExtension } from './core/extensions/foundation-storage.js';
|
|
@@ -63,6 +64,12 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
63
64
|
control.beginSetup(m);
|
|
64
65
|
const cleanups = [];
|
|
65
66
|
let pageCleanup;
|
|
67
|
+
const rollback = async () => {
|
|
68
|
+
// Page preparation is the barrier before any destructive cleanup.
|
|
69
|
+
await pageCleanup?.();
|
|
70
|
+
for (const cleanup of cleanups.slice().reverse())
|
|
71
|
+
await cleanup();
|
|
72
|
+
};
|
|
66
73
|
try {
|
|
67
74
|
for (const menu of m.contributes?.menus ?? []) {
|
|
68
75
|
cleanups.push(host.menus.register(menu));
|
|
@@ -89,20 +96,18 @@ export async function bootstrapAppHost(overrides = {}) {
|
|
|
89
96
|
cleanups.push(r);
|
|
90
97
|
if (cleanups.length === 0 && !pageCleanup)
|
|
91
98
|
return undefined;
|
|
92
|
-
return
|
|
93
|
-
// Controllers get the first say. If one vetoes/needs a user decision,
|
|
94
|
-
// no declarative or imperative contribution is torn down underneath it.
|
|
95
|
-
await pageCleanup?.();
|
|
96
|
-
for (const c of cleanups.slice().reverse())
|
|
97
|
-
await c();
|
|
98
|
-
};
|
|
99
|
+
return rollback;
|
|
99
100
|
}
|
|
100
101
|
catch (error) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
try {
|
|
103
|
+
await rollback();
|
|
104
|
+
}
|
|
105
|
+
catch (rollbackError) {
|
|
106
|
+
if (rollbackError instanceof ExtensionCleanupDeferredError) {
|
|
107
|
+
throw new ExtensionSetupRollbackDeferredError({ cause: error, rollback });
|
|
108
|
+
}
|
|
109
|
+
throw rollbackError;
|
|
110
|
+
}
|
|
106
111
|
throw error;
|
|
107
112
|
}
|
|
108
113
|
finally {
|
|
@@ -4,6 +4,7 @@ import { listExtensions, listExtensionsShared, } from '../../lib/extension-api.j
|
|
|
4
4
|
import { ExtensionHostPanel } from '../../components/DockShell/ExtensionHostPanel.js';
|
|
5
5
|
import { isExtensionHostExtension } from '../../components/DockShell/extensionRuntime.js';
|
|
6
6
|
import { getLocale } from '../../i18n/index.js';
|
|
7
|
+
import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
|
|
7
8
|
function title(value) {
|
|
8
9
|
if (typeof value === 'string')
|
|
9
10
|
return value;
|
|
@@ -162,22 +163,42 @@ export function createCatalogPageExtensionRuntime(options) {
|
|
|
162
163
|
let events;
|
|
163
164
|
let chain = Promise.resolve();
|
|
164
165
|
let disposed = false;
|
|
166
|
+
const reportError = (error, id, phase) => {
|
|
167
|
+
try {
|
|
168
|
+
onError(error, id, phase);
|
|
169
|
+
}
|
|
170
|
+
catch { /* Isolate the diagnostic sink. */ }
|
|
171
|
+
};
|
|
172
|
+
const retire = async (id, record) => {
|
|
173
|
+
try {
|
|
174
|
+
await record.cleanup();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
reportError(error, id, 'cleanup');
|
|
178
|
+
if (error instanceof ExtensionCleanupDeferredError)
|
|
179
|
+
throw error;
|
|
180
|
+
// Ordinary teardown may already be destructive; never retry it.
|
|
181
|
+
}
|
|
182
|
+
active.delete(id);
|
|
183
|
+
generation = undefined;
|
|
184
|
+
};
|
|
165
185
|
const reconcile = async (response) => {
|
|
166
186
|
if (generation !== undefined && response.generation === generation)
|
|
167
187
|
return;
|
|
168
188
|
const itemsById = new Map(response.items.map((item) => [item.id, item]));
|
|
169
189
|
const next = new Map(catalogPageExtensions(response.items, options.overriddenIds)
|
|
170
190
|
.map((extension) => [extension.id, extension]));
|
|
171
|
-
for (const [id, record] of [...active]) {
|
|
191
|
+
for (const [id, record] of [...active].reverse()) {
|
|
172
192
|
const item = itemsById.get(id);
|
|
173
193
|
const signature = catalogItemSignature(item);
|
|
174
194
|
if (!next.has(id) || signature !== record.signature) {
|
|
175
195
|
try {
|
|
176
|
-
await record
|
|
177
|
-
active.delete(id);
|
|
196
|
+
await retire(id, record);
|
|
178
197
|
}
|
|
179
198
|
catch (error) {
|
|
180
|
-
|
|
199
|
+
// Keep the barrier and allow a later refresh of this same generation.
|
|
200
|
+
generation = undefined;
|
|
201
|
+
return;
|
|
181
202
|
}
|
|
182
203
|
}
|
|
183
204
|
}
|
|
@@ -195,7 +216,7 @@ export function createCatalogPageExtensionRuntime(options) {
|
|
|
195
216
|
active.set(id, { signature: catalogItemSignature(itemsById.get(id)), cleanup });
|
|
196
217
|
}
|
|
197
218
|
catch (error) {
|
|
198
|
-
|
|
219
|
+
reportError(error, id, 'register');
|
|
199
220
|
}
|
|
200
221
|
}
|
|
201
222
|
generation = response.generation;
|
|
@@ -221,14 +242,17 @@ export function createCatalogPageExtensionRuntime(options) {
|
|
|
221
242
|
}
|
|
222
243
|
}
|
|
223
244
|
},
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
245
|
+
dispose() {
|
|
246
|
+
chain = chain.catch(() => undefined).then(async () => {
|
|
247
|
+
if (disposed)
|
|
248
|
+
return;
|
|
249
|
+
for (const [id, record] of [...active].reverse())
|
|
250
|
+
await retire(id, record);
|
|
251
|
+
disposed = true;
|
|
252
|
+
events?.close();
|
|
253
|
+
events = undefined;
|
|
254
|
+
});
|
|
255
|
+
return chain;
|
|
232
256
|
},
|
|
233
257
|
};
|
|
234
258
|
}
|
|
@@ -7,13 +7,14 @@ import { createCapabilityRegistry } from '../extension-foundation/capabilities.j
|
|
|
7
7
|
import { createContributionRegistry } from '../extension-foundation/contribution-registry.js';
|
|
8
8
|
import { ExtensionConflictError } from '../extension-foundation/errors.js';
|
|
9
9
|
import { createPanelActionRegistry, createPanelControlRegistry } from '../panels/index.js';
|
|
10
|
-
import { createPageRegistry, createPageSession, createActivityRegistry, createResourceEditorResolver, } from '../page-platform/index.js';
|
|
10
|
+
import { createPageRegistry, createPageSession, PageClosePreparationDeferredError, createActivityRegistry, createResourceEditorResolver, } from '../page-platform/index.js';
|
|
11
11
|
import { consoleLogger } from './logger.js';
|
|
12
12
|
import { derivePanelRenderers } from './derive-panel-renderers.js';
|
|
13
13
|
import { DEFAULT_PANEL_RENDERERS } from '../../components/DockShell/panelRenderers.js';
|
|
14
14
|
import { installPageNavigation } from '../page-navigation.js';
|
|
15
15
|
import { createContextualKeybindings } from '../contextual-keybindings.js';
|
|
16
16
|
import { createApplicationShortcutRegistry, createApplicationMenuRegistry } from '@forgeax/app-shell/application';
|
|
17
|
+
import { ExtensionCleanupDeferredError } from '@forgeax/extension-platform/extensions';
|
|
17
18
|
const BUILT_IN_CAPS = [
|
|
18
19
|
'commands', 'keybindings', 'shortcuts', 'menus', 'bus', 'storage', 'panels', 'panelActions', 'panelControls', 'contextKeys', 'pages',
|
|
19
20
|
'activities', 'resourceEditors',
|
|
@@ -158,7 +159,15 @@ export function createAppHost(deps = {}) {
|
|
|
158
159
|
return;
|
|
159
160
|
// The registry still knows the owner while controllers preflight close.
|
|
160
161
|
// A dirty/vetoed page rejects cleanup and leaves the contribution live.
|
|
161
|
-
|
|
162
|
+
try {
|
|
163
|
+
await pageSession.closeOwnedBy(owner);
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
if (error instanceof PageClosePreparationDeferredError) {
|
|
167
|
+
throw new ExtensionCleanupDeferredError(error.message);
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
162
171
|
removeContribution();
|
|
163
172
|
removed = true;
|
|
164
173
|
};
|
|
@@ -181,8 +190,8 @@ export function createAppHost(deps = {}) {
|
|
|
181
190
|
}
|
|
182
191
|
},
|
|
183
192
|
async dispose() {
|
|
184
|
-
removePageNavigation();
|
|
185
193
|
await pageSession.dispose();
|
|
194
|
+
removePageNavigation();
|
|
186
195
|
keybindings.dispose();
|
|
187
196
|
shortcuts.dispose();
|
|
188
197
|
menus.dispose();
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { decodePageKey, encodePageKey, } from '@forgeax/types';
|
|
2
2
|
import { PagePlatformError } from './types.js';
|
|
3
3
|
import { getCurrentProject, subscribeCurrentProject } from '../../lib/project-context.js';
|
|
4
|
+
/** Explicit close decision barrier, emitted before any controller teardown. */
|
|
5
|
+
export class PageClosePreparationDeferredError extends PagePlatformError {
|
|
6
|
+
constructor(code, message, details) {
|
|
7
|
+
super(code, message, details);
|
|
8
|
+
this.name = 'PageClosePreparationDeferredError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
4
11
|
const inertController = {
|
|
5
12
|
prepareClose: () => ({ status: 'ready' }),
|
|
6
13
|
dispose: () => undefined,
|
|
@@ -103,17 +110,17 @@ export function createPageSession(registry, options = {}) {
|
|
|
103
110
|
if (preparation.status === 'ready')
|
|
104
111
|
return;
|
|
105
112
|
if (preparation.status === 'vetoed') {
|
|
106
|
-
throw new
|
|
113
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', preparation.message ?? 'page refused to close', {
|
|
107
114
|
key: entry.instance.encodedKey,
|
|
108
115
|
reason,
|
|
109
116
|
});
|
|
110
117
|
}
|
|
111
118
|
if (!decision || decision === 'cancel') {
|
|
112
|
-
throw new
|
|
119
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_REQUIRES_DECISION', preparation.message ?? 'page has unsaved changes', { key: entry.instance.encodedKey, reason });
|
|
113
120
|
}
|
|
114
121
|
if (decision === 'save') {
|
|
115
122
|
if (!entry.controller.save) {
|
|
116
|
-
throw new
|
|
123
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot save its dirty state', {
|
|
117
124
|
key: entry.instance.encodedKey,
|
|
118
125
|
reason,
|
|
119
126
|
});
|
|
@@ -122,7 +129,7 @@ export function createPageSession(registry, options = {}) {
|
|
|
122
129
|
}
|
|
123
130
|
else {
|
|
124
131
|
if (!entry.controller.discard) {
|
|
125
|
-
throw new
|
|
132
|
+
throw new PageClosePreparationDeferredError('PAGE_CLOSE_VETOED', 'page cannot discard its dirty state', {
|
|
126
133
|
key: entry.instance.encodedKey,
|
|
127
134
|
reason,
|
|
128
135
|
});
|
|
@@ -1,62 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const listeners = new Set();
|
|
4
|
-
let cache = null;
|
|
5
|
-
let version = 0;
|
|
6
|
-
const emit = () => {
|
|
7
|
-
cache = null;
|
|
8
|
-
version++;
|
|
9
|
-
for (const listener of [...listeners])
|
|
10
|
-
listener();
|
|
11
|
-
};
|
|
12
|
-
// Batch-deferred notification: data mutations (push/splice) happen
|
|
13
|
-
// immediately so reads (list/all) always return fresh data, but listener
|
|
14
|
-
// notifications are deferred to a microtask. This avoids triggering
|
|
15
|
-
// forceStoreRerender (useSyncExternalStore) during React 19's commit
|
|
16
|
-
// phase, which otherwise causes "Maximum update depth exceeded".
|
|
17
|
-
let emitScheduled = false;
|
|
18
|
-
const scheduleEmit = () => {
|
|
19
|
-
cache = null;
|
|
20
|
-
if (emitScheduled)
|
|
21
|
-
return;
|
|
22
|
-
emitScheduled = true;
|
|
23
|
-
queueMicrotask(() => {
|
|
24
|
-
emitScheduled = false;
|
|
25
|
-
emit();
|
|
26
|
-
});
|
|
27
|
-
};
|
|
28
|
-
const all = () => {
|
|
29
|
-
if (cache)
|
|
30
|
-
return cache;
|
|
31
|
-
cache = entries.flatMap((entry) => entry.actions).sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
|
32
|
-
return cache;
|
|
33
|
-
};
|
|
34
|
-
return {
|
|
35
|
-
contribute(owner, actions) {
|
|
36
|
-
const entry = { owner, actions };
|
|
37
|
-
entries.push(entry);
|
|
38
|
-
scheduleEmit();
|
|
39
|
-
let removed = false;
|
|
40
|
-
return () => {
|
|
41
|
-
if (removed)
|
|
42
|
-
return;
|
|
43
|
-
removed = true;
|
|
44
|
-
const index = entries.indexOf(entry);
|
|
45
|
-
if (index >= 0)
|
|
46
|
-
entries.splice(index, 1);
|
|
47
|
-
scheduleEmit();
|
|
48
|
-
};
|
|
49
|
-
},
|
|
50
|
-
list(panelId) {
|
|
51
|
-
return all().filter((action) => action.panelId === panelId);
|
|
52
|
-
},
|
|
53
|
-
all,
|
|
54
|
-
onChange(listener) {
|
|
55
|
-
listeners.add(listener);
|
|
56
|
-
return () => { listeners.delete(listener); };
|
|
57
|
-
},
|
|
58
|
-
version() {
|
|
59
|
-
return version;
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
1
|
+
import { createPanelActionRegistry as createRegistry, } from '@forgeax/app-shell/application';
|
|
2
|
+
export const createPanelActionRegistry = (createRegistry);
|
|
@@ -1,54 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
const listeners = new Set();
|
|
4
|
-
let version = 0;
|
|
5
|
-
const emit = () => {
|
|
6
|
-
version++;
|
|
7
|
-
for (const listener of [...listeners])
|
|
8
|
-
listener();
|
|
9
|
-
};
|
|
10
|
-
// Batch-deferred notification: data mutations (push/splice) happen
|
|
11
|
-
// immediately so reads (list/get) always return fresh data, but listener
|
|
12
|
-
// notifications are deferred to a microtask. This avoids triggering
|
|
13
|
-
// forceStoreRerender (useSyncExternalStore) during React 19's commit
|
|
14
|
-
// phase, which otherwise causes "Maximum update depth exceeded".
|
|
15
|
-
let emitScheduled = false;
|
|
16
|
-
const scheduleEmit = () => {
|
|
17
|
-
if (emitScheduled)
|
|
18
|
-
return;
|
|
19
|
-
emitScheduled = true;
|
|
20
|
-
queueMicrotask(() => {
|
|
21
|
-
emitScheduled = false;
|
|
22
|
-
emit();
|
|
23
|
-
});
|
|
24
|
-
};
|
|
25
|
-
const list = () => entries.flatMap((entry) => entry.controls);
|
|
26
|
-
return {
|
|
27
|
-
contribute(owner, controls) {
|
|
28
|
-
const entry = { owner, controls };
|
|
29
|
-
entries.push(entry);
|
|
30
|
-
scheduleEmit();
|
|
31
|
-
let removed = false;
|
|
32
|
-
return () => {
|
|
33
|
-
if (removed)
|
|
34
|
-
return;
|
|
35
|
-
removed = true;
|
|
36
|
-
const index = entries.indexOf(entry);
|
|
37
|
-
if (index >= 0)
|
|
38
|
-
entries.splice(index, 1);
|
|
39
|
-
scheduleEmit();
|
|
40
|
-
};
|
|
41
|
-
},
|
|
42
|
-
get(id) {
|
|
43
|
-
return list().find((control) => control.id === id);
|
|
44
|
-
},
|
|
45
|
-
list,
|
|
46
|
-
onChange(listener) {
|
|
47
|
-
listeners.add(listener);
|
|
48
|
-
return () => { listeners.delete(listener); };
|
|
49
|
-
},
|
|
50
|
-
version() {
|
|
51
|
-
return version;
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
}
|
|
1
|
+
import { createPanelControlRegistry as createRegistry, } from '@forgeax/app-shell/application';
|
|
2
|
+
export const createPanelControlRegistry = (createRegistry);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/interface",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ForgeaX product interface shell and application composition boundary.",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -92,9 +92,9 @@
|
|
|
92
92
|
"lint:dep": "depcruise -c .dependency-cruiser.cjs src"
|
|
93
93
|
},
|
|
94
94
|
"dependencies": {
|
|
95
|
-
"@forgeax/app-shell": "0.
|
|
95
|
+
"@forgeax/app-shell": "0.89.0",
|
|
96
96
|
"@forgeax/extension-host": "0.3.2",
|
|
97
|
-
"@forgeax/extension-platform": "0.
|
|
97
|
+
"@forgeax/extension-platform": "0.7.0",
|
|
98
98
|
"@forgeax/toolkit": "0.1.2",
|
|
99
99
|
"@forgeax/types": "^0.1.3",
|
|
100
100
|
"@google/model-viewer": "^4.2.0",
|