@forgeax/interface 0.9.3 → 0.9.5
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.d.ts +4 -0
- package/dist/package/browser/appHostBootstrap.js +17 -12
- package/dist/package/browser/application.d.ts +3 -0
- package/dist/package/browser/application.js +15 -2
- 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/node/appHostBootstrap.js +17 -12
- package/dist/package/node/application.js +15 -2
- 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/package.json +3 -3
|
@@ -11,6 +11,10 @@ export interface AppHostBootstrapOverrides {
|
|
|
11
11
|
export interface AppHostBootstrapResult {
|
|
12
12
|
host: AppHost;
|
|
13
13
|
control: AppHostControl;
|
|
14
|
+
/** Begins terminal shutdown. A rejection may follow successful owner cleanup:
|
|
15
|
+
* the runtime is not safe to resume using and disposal is not atomic. Resolve
|
|
16
|
+
* an explicit cleanup deferral and retry dispose to finish remaining owners;
|
|
17
|
+
* already retired owners are not restored or cleaned up again. */
|
|
14
18
|
dispose: () => Promise<void>;
|
|
15
19
|
}
|
|
16
20
|
export declare function bootstrapAppHost(overrides?: AppHostBootstrapOverrides): Promise<AppHostBootstrapResult>;
|
|
@@ -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 {
|
|
@@ -8,6 +8,9 @@ export interface InterfaceApplicationStartupOptions {
|
|
|
8
8
|
/** Product-selected initial locale. Persisted locale remains authoritative when omitted. */
|
|
9
9
|
readonly locale?: Locale;
|
|
10
10
|
}
|
|
11
|
+
/** Products retain this owner above their replaceable recovery/runtime subtree.
|
|
12
|
+
* Only the explicit pre-destructive deferrals permit a user-requested retry. */
|
|
13
|
+
export declare function createInterfaceApplicationOwner(): import("@forgeax/app-shell/application").ApplicationRuntimeOwner;
|
|
11
14
|
/**
|
|
12
15
|
* Lets a product replace one shell overlay with its own destination. Interface
|
|
13
16
|
* owns observation, clearing and synchronous reentry; the product owns routing.
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import { ApplicationStartupCleanupError, createApplicationRuntimeOwner } from '@forgeax/app-shell/application';
|
|
2
|
+
import { ExtensionCleanupDeferredError, ExtensionUnloadDeferredError } from '@forgeax/extension-platform/extensions';
|
|
3
|
+
import { PageClosePreparationDeferredError } from './core/page-platform/session';
|
|
1
4
|
import { bootstrapAppHost, } from './appHostBootstrap';
|
|
2
5
|
import { bootStageAppMounted } from './boot/driver';
|
|
3
6
|
import { changeLanguage, initI18n } from './i18n';
|
|
4
7
|
import { useShellStore } from './store';
|
|
8
|
+
/** Products retain this owner above their replaceable recovery/runtime subtree.
|
|
9
|
+
* Only the explicit pre-destructive deferrals permit a user-requested retry. */
|
|
10
|
+
export function createInterfaceApplicationOwner() {
|
|
11
|
+
return createApplicationRuntimeOwner({
|
|
12
|
+
isCleanupDeferred: error => error instanceof ExtensionCleanupDeferredError
|
|
13
|
+
|| error instanceof ExtensionUnloadDeferredError
|
|
14
|
+
|| error instanceof PageClosePreparationDeferredError,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
5
17
|
/**
|
|
6
18
|
* Lets a product replace one shell overlay with its own destination. Interface
|
|
7
19
|
* owns observation, clearing and synchronous reentry; the product owns routing.
|
|
@@ -43,8 +55,9 @@ export async function startInterfaceApplication(overrides = {}, options = {}) {
|
|
|
43
55
|
try {
|
|
44
56
|
await runtime.dispose();
|
|
45
57
|
}
|
|
46
|
-
catch {
|
|
47
|
-
//
|
|
58
|
+
catch (cleanupError) {
|
|
59
|
+
// Transfer unfinished ownership instead of dropping the only disposer.
|
|
60
|
+
throw new ApplicationStartupCleanupError(error, runtime, cleanupError);
|
|
48
61
|
}
|
|
49
62
|
throw error;
|
|
50
63
|
}
|
|
@@ -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
|
});
|
|
@@ -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 {
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
+
import { ApplicationStartupCleanupError, createApplicationRuntimeOwner } from '@forgeax/app-shell/application';
|
|
2
|
+
import { ExtensionCleanupDeferredError, ExtensionUnloadDeferredError } from '@forgeax/extension-platform/extensions';
|
|
3
|
+
import { PageClosePreparationDeferredError } from './core/page-platform/session.js';
|
|
1
4
|
import { bootstrapAppHost, } from './appHostBootstrap.js';
|
|
2
5
|
import { bootStageAppMounted } from './boot/driver.js';
|
|
3
6
|
import { changeLanguage, initI18n } from './i18n/index.js';
|
|
4
7
|
import { useShellStore } from './store.js';
|
|
8
|
+
/** Products retain this owner above their replaceable recovery/runtime subtree.
|
|
9
|
+
* Only the explicit pre-destructive deferrals permit a user-requested retry. */
|
|
10
|
+
export function createInterfaceApplicationOwner() {
|
|
11
|
+
return createApplicationRuntimeOwner({
|
|
12
|
+
isCleanupDeferred: error => error instanceof ExtensionCleanupDeferredError
|
|
13
|
+
|| error instanceof ExtensionUnloadDeferredError
|
|
14
|
+
|| error instanceof PageClosePreparationDeferredError,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
5
17
|
/**
|
|
6
18
|
* Lets a product replace one shell overlay with its own destination. Interface
|
|
7
19
|
* owns observation, clearing and synchronous reentry; the product owns routing.
|
|
@@ -43,8 +55,9 @@ export async function startInterfaceApplication(overrides = {}, options = {}) {
|
|
|
43
55
|
try {
|
|
44
56
|
await runtime.dispose();
|
|
45
57
|
}
|
|
46
|
-
catch {
|
|
47
|
-
//
|
|
58
|
+
catch (cleanupError) {
|
|
59
|
+
// Transfer unfinished ownership instead of dropping the only disposer.
|
|
60
|
+
throw new ApplicationStartupCleanupError(error, runtime, cleanupError);
|
|
48
61
|
}
|
|
49
62
|
throw error;
|
|
50
63
|
}
|
|
@@ -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
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forgeax/interface",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
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.90.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",
|