sh3-core 0.11.7 → 0.12.0
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/actions/contextMenuModel.d.ts +5 -4
- package/dist/actions/contextMenuModel.js +26 -12
- package/dist/actions/contextMenuModel.test.js +49 -24
- package/dist/actions/listeners.d.ts +2 -0
- package/dist/actions/listeners.js +65 -6
- package/dist/actions/listeners.test.js +96 -8
- package/dist/actions/scope-helpers.d.ts +23 -0
- package/dist/actions/scope-helpers.js +47 -0
- package/dist/actions/scope-helpers.test.js +56 -1
- package/dist/actions/types.d.ts +1 -0
- package/dist/api.d.ts +2 -1
- package/dist/api.js +1 -1
- package/dist/app/store/InstalledView.svelte +2 -1
- package/dist/app/store/StoreView.svelte +2 -1
- package/dist/apps/lifecycle.d.ts +7 -0
- package/dist/apps/lifecycle.js +22 -5
- package/dist/apps/lifecycle.test.js +50 -0
- package/dist/documents/browse.d.ts +15 -0
- package/dist/documents/browse.js +7 -0
- package/dist/documents/browse.test.js +41 -0
- package/dist/documents/handle.js +3 -1
- package/dist/documents/handle.test.js +23 -0
- package/dist/host.js +18 -4
- package/dist/layout/LayoutRenderer.svelte +5 -1
- package/dist/layout/LayoutRenderer.test.js +42 -0
- package/dist/layout/SlotContainer.svelte +11 -2
- package/dist/layout/SlotContainer.svelte.d.ts +1 -0
- package/dist/layout/slotHostPool.svelte.js +10 -3
- package/dist/layout/slotHostPool.test.js +15 -0
- package/dist/shards/activate-error-isolation.test.d.ts +1 -0
- package/dist/shards/activate-error-isolation.test.js +98 -0
- package/dist/shards/activate.svelte.d.ts +30 -2
- package/dist/shards/activate.svelte.js +62 -17
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -38,7 +38,7 @@ export { COLOR_PICKER_POINT } from './color/api';
|
|
|
38
38
|
// and tooling shards that need to visualize framework state. Phase 9
|
|
39
39
|
// addition: diagnostic used to reach `activate.svelte` directly via $lib;
|
|
40
40
|
// the package boundary requires routing through the public surface.
|
|
41
|
-
export { registeredShards, activeShards } from './shards/activate.svelte';
|
|
41
|
+
export { registeredShards, activeShards, erroredShards } from './shards/activate.svelte';
|
|
42
42
|
export { fetchRegistries, fetchBundle, buildPackageMeta } from './registry/client';
|
|
43
43
|
export { validateRegistryIndex } from './registry/schema';
|
|
44
44
|
// Key mint/revoke types — client shards that declare `keys:mint` get ctx.keys.
|
|
@@ -278,7 +278,8 @@
|
|
|
278
278
|
}
|
|
279
279
|
.installed-update-btn {
|
|
280
280
|
padding: 4px 12px;
|
|
281
|
-
background: var(--shell-warning, #
|
|
281
|
+
background: var(--shell-warning, #fbbf24);
|
|
282
|
+
color: var(--shell-fg-on-warning, #1a1b1e);
|
|
282
283
|
font-size: 0.8125rem;
|
|
283
284
|
}
|
|
284
285
|
.installed-update-btn:hover:not(:disabled) {
|
|
@@ -594,7 +594,8 @@
|
|
|
594
594
|
}
|
|
595
595
|
.store-update-btn {
|
|
596
596
|
padding: 5px 14px;
|
|
597
|
-
background: var(--shell-warning, #
|
|
597
|
+
background: var(--shell-warning, #fbbf24);
|
|
598
|
+
color: var(--shell-fg-on-warning, #1a1b1e);
|
|
598
599
|
font-size: 0.8125rem;
|
|
599
600
|
}
|
|
600
601
|
.store-update-btn:hover:not(:disabled) {
|
package/dist/apps/lifecycle.d.ts
CHANGED
|
@@ -4,6 +4,13 @@
|
|
|
4
4
|
* returned to home or no app has ever been launched.
|
|
5
5
|
*/
|
|
6
6
|
export declare function readLastApp(): string | null;
|
|
7
|
+
/**
|
|
8
|
+
* Public reset for the last-active-app user-zone slot. Used by the host's
|
|
9
|
+
* boot sequence to recover from a sticky last-app launch failure: if the
|
|
10
|
+
* auto-launch fails, this clears the slot so the next reload lands on home
|
|
11
|
+
* instead of looping into the same failure.
|
|
12
|
+
*/
|
|
13
|
+
export declare function clearLastApp(): void;
|
|
7
14
|
/**
|
|
8
15
|
* Launch an app by id. Activates all required shards (idempotent for
|
|
9
16
|
* already-active shards), attaches the app's layout, calls `App.activate`,
|
package/dist/apps/lifecycle.js
CHANGED
|
@@ -20,6 +20,7 @@ import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
|
20
20
|
import { setActiveApp, setUserBindings } from '../actions/state.svelte';
|
|
21
21
|
import { clearSelectionUnconditional } from '../actions/selection.svelte';
|
|
22
22
|
import { loadUserBindings } from '../actions/bindings-store';
|
|
23
|
+
import { toastManager } from '../overlays/toast';
|
|
23
24
|
// ---------- last-active-app user zone ------------------------------------
|
|
24
25
|
/**
|
|
25
26
|
* Framework-reserved user-zone slot storing which app to boot into on
|
|
@@ -41,6 +42,15 @@ export function readLastApp() {
|
|
|
41
42
|
function writeLastApp(id) {
|
|
42
43
|
lastAppState.user.id = id;
|
|
43
44
|
}
|
|
45
|
+
/**
|
|
46
|
+
* Public reset for the last-active-app user-zone slot. Used by the host's
|
|
47
|
+
* boot sequence to recover from a sticky last-app launch failure: if the
|
|
48
|
+
* auto-launch fails, this clears the slot so the next reload lands on home
|
|
49
|
+
* instead of looping into the same failure.
|
|
50
|
+
*/
|
|
51
|
+
export function clearLastApp() {
|
|
52
|
+
writeLastApp(null);
|
|
53
|
+
}
|
|
44
54
|
// ---------- app-context state factories ----------------------------------
|
|
45
55
|
const appContexts = new Map();
|
|
46
56
|
function getOrCreateAppContext(appId) {
|
|
@@ -72,7 +82,7 @@ function getOrCreateAppContext(appId) {
|
|
|
72
82
|
* @throws If the app is not registered or a required shard is not registered.
|
|
73
83
|
*/
|
|
74
84
|
export async function launchApp(id) {
|
|
75
|
-
var _a, _b, _c, _d, _e, _f, _g;
|
|
85
|
+
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
76
86
|
const app = getRegisteredApp(id);
|
|
77
87
|
if (!app) {
|
|
78
88
|
throw new Error(`Cannot launch app "${id}": not registered`);
|
|
@@ -118,23 +128,30 @@ export async function launchApp(id) {
|
|
|
118
128
|
attachApp(app);
|
|
119
129
|
try {
|
|
120
130
|
for (const shardId of app.manifest.requiredShards) {
|
|
121
|
-
await activateShard(shardId);
|
|
131
|
+
await activateShard(shardId, { phase: 'launch' });
|
|
122
132
|
}
|
|
123
133
|
}
|
|
124
134
|
catch (err) {
|
|
125
135
|
detachApp();
|
|
136
|
+
try {
|
|
137
|
+
toastManager.notify(`Couldn't launch "${(_e = app.manifest.label) !== null && _e !== void 0 ? _e : id}": ${err instanceof Error ? err.message : String(err)}`, { level: 'error', duration: 6000 });
|
|
138
|
+
}
|
|
139
|
+
catch (_j) {
|
|
140
|
+
// Toast layer not mounted (e.g. early boot, tests without Shell).
|
|
141
|
+
// Best-effort UX — original error must still propagate.
|
|
142
|
+
}
|
|
126
143
|
throw err;
|
|
127
144
|
}
|
|
128
145
|
// Shards have registered their view factories — safe to take the
|
|
129
146
|
// refcount holds on the app's slots now (pool's factory lookup
|
|
130
147
|
// happens in a microtask from this call).
|
|
131
148
|
acquireAppSlotHolds();
|
|
132
|
-
void ((
|
|
149
|
+
void ((_f = app.activate) === null || _f === void 0 ? void 0 : _f.call(app, getOrCreateAppContext(id)));
|
|
133
150
|
activeApp.id = id;
|
|
134
|
-
setActiveApp(id, new Set((
|
|
151
|
+
setActiveApp(id, new Set((_g = app.manifest.requiredShards) !== null && _g !== void 0 ? _g : []));
|
|
135
152
|
void loadUserBindings(id).then(setUserBindings);
|
|
136
153
|
switchToApp();
|
|
137
|
-
void ((
|
|
154
|
+
void ((_h = app.onAppReady) === null || _h === void 0 ? void 0 : _h.call(app, getOrCreateAppContext(id)));
|
|
138
155
|
writeLastApp(id);
|
|
139
156
|
breadcrumbApp.id = id;
|
|
140
157
|
}
|
|
@@ -517,3 +517,53 @@ describe('breadcrumbAppId', () => {
|
|
|
517
517
|
expect(getBreadcrumbAppId()).toBe('app-bc3b');
|
|
518
518
|
});
|
|
519
519
|
});
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
// launchApp — error toast on required-shard activation failure
|
|
522
|
+
// ---------------------------------------------------------------------------
|
|
523
|
+
describe('launchApp — error toast on shard failure', () => {
|
|
524
|
+
beforeEach(resetFramework);
|
|
525
|
+
it('fires an error-level toast when a required shard fails to activate', async () => {
|
|
526
|
+
const { toastManager } = await import('../overlays/toast');
|
|
527
|
+
const notifySpy = vi
|
|
528
|
+
.spyOn(toastManager, 'notify')
|
|
529
|
+
.mockImplementation(() => ({ close: () => { } }));
|
|
530
|
+
const badShard = makeShard({
|
|
531
|
+
manifest: makeShardManifest({ id: 'bad-toast' }),
|
|
532
|
+
activate: () => {
|
|
533
|
+
throw new Error('shard "other" not registered');
|
|
534
|
+
},
|
|
535
|
+
});
|
|
536
|
+
registerShard(badShard);
|
|
537
|
+
registerApp(makeApp({
|
|
538
|
+
manifest: makeAppManifest({
|
|
539
|
+
id: 'app-toast',
|
|
540
|
+
label: 'Toast App',
|
|
541
|
+
requiredShards: ['bad-toast'],
|
|
542
|
+
}),
|
|
543
|
+
}));
|
|
544
|
+
await expect(launchApp('app-toast')).rejects.toThrow('shard "other" not registered');
|
|
545
|
+
expect(notifySpy).toHaveBeenCalledTimes(1);
|
|
546
|
+
const [message, options] = notifySpy.mock.calls[0];
|
|
547
|
+
expect(message).toContain('Toast App');
|
|
548
|
+
expect(message).toContain('shard "other" not registered');
|
|
549
|
+
expect(options === null || options === void 0 ? void 0 : options.level).toBe('error');
|
|
550
|
+
notifySpy.mockRestore();
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
// ---------------------------------------------------------------------------
|
|
554
|
+
// clearLastApp — public reset for the last-active-app user-zone slot
|
|
555
|
+
// ---------------------------------------------------------------------------
|
|
556
|
+
describe('clearLastApp', () => {
|
|
557
|
+
beforeEach(resetFramework);
|
|
558
|
+
it('writes null to the last-app user zone', async () => {
|
|
559
|
+
const { clearLastApp, readLastApp } = await import('./lifecycle');
|
|
560
|
+
registerShard(makeShard({ manifest: makeShardManifest({ id: 's-cla' }) }));
|
|
561
|
+
registerApp(makeApp({
|
|
562
|
+
manifest: makeAppManifest({ id: 'app-cla', requiredShards: ['s-cla'] }),
|
|
563
|
+
}));
|
|
564
|
+
await launchApp('app-cla');
|
|
565
|
+
expect(readLastApp()).toBe('app-cla');
|
|
566
|
+
clearLastApp();
|
|
567
|
+
expect(readLastApp()).toBeNull();
|
|
568
|
+
});
|
|
569
|
+
});
|
|
@@ -76,6 +76,21 @@ export interface BrowseCapability {
|
|
|
76
76
|
renameFrom?(shardId: string, oldPath: string, newPath: string, opts?: {
|
|
77
77
|
newShardId?: string;
|
|
78
78
|
}): Promise<void>;
|
|
79
|
+
/**
|
|
80
|
+
* Delete a document in another shard's namespace within the active
|
|
81
|
+
* tenant. Available only when the caller declares both
|
|
82
|
+
* `documents:browse` and `documents:write`. Emits a `'delete'`
|
|
83
|
+
* `DocumentChange` so other shards and the file-explorer pick up
|
|
84
|
+
* the removal. Tenant-scoped — cannot cross tenants.
|
|
85
|
+
*
|
|
86
|
+
* Idempotent: deleting a non-existent path resolves successfully
|
|
87
|
+
* and emits no change event.
|
|
88
|
+
*
|
|
89
|
+
* Absent (undefined) on the capability object when `documents:write`
|
|
90
|
+
* is not declared; feature-detect with
|
|
91
|
+
* `typeof ctx.browse.deleteFrom === 'function'`.
|
|
92
|
+
*/
|
|
93
|
+
deleteFrom?(shardId: string, path: string): Promise<void>;
|
|
79
94
|
}
|
|
80
95
|
export interface BrowseCapabilityOptions {
|
|
81
96
|
/** When true, the returned capability exposes `readFrom`. */
|
package/dist/documents/browse.js
CHANGED
|
@@ -59,6 +59,13 @@ export function createBrowseCapability(tenantId, backend, options = { canRead: f
|
|
|
59
59
|
shardId,
|
|
60
60
|
});
|
|
61
61
|
};
|
|
62
|
+
capability.deleteFrom = async (shardId, path) => {
|
|
63
|
+
const existed = await backend.exists(tenantId, shardId, path);
|
|
64
|
+
await backend.delete(tenantId, shardId, path);
|
|
65
|
+
if (existed) {
|
|
66
|
+
documentChanges.emit({ type: 'delete', path, tenantId, shardId });
|
|
67
|
+
}
|
|
68
|
+
};
|
|
62
69
|
}
|
|
63
70
|
return capability;
|
|
64
71
|
}
|
|
@@ -263,4 +263,45 @@ describe('BrowseCapability', () => {
|
|
|
263
263
|
.rejects.toThrow(/does not support resolveConflict/);
|
|
264
264
|
});
|
|
265
265
|
});
|
|
266
|
+
describe('deleteFrom (documents:write gate)', () => {
|
|
267
|
+
it('absent when canWrite is false', () => {
|
|
268
|
+
const be = new MemoryDocumentBackend();
|
|
269
|
+
const browse = createBrowseCapability('t1', be, { canRead: true, canWrite: false });
|
|
270
|
+
expect(browse.deleteFrom).toBeUndefined();
|
|
271
|
+
});
|
|
272
|
+
it('present when canWrite is true', () => {
|
|
273
|
+
const be = new MemoryDocumentBackend();
|
|
274
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
275
|
+
expect(typeof browse.deleteFrom).toBe('function');
|
|
276
|
+
});
|
|
277
|
+
it('deletes from the target shard namespace and emits a delete event', async () => {
|
|
278
|
+
const be = new MemoryDocumentBackend();
|
|
279
|
+
await be.write('t1', 'target-shard', 'a.txt', 'hello');
|
|
280
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
281
|
+
const events = [];
|
|
282
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
283
|
+
await browse.deleteFrom('target-shard', 'a.txt');
|
|
284
|
+
expect(await be.read('t1', 'target-shard', 'a.txt')).toBeNull();
|
|
285
|
+
expect(events).toEqual([
|
|
286
|
+
{ type: 'delete', path: 'a.txt', tenantId: 't1', shardId: 'target-shard' },
|
|
287
|
+
]);
|
|
288
|
+
unsub();
|
|
289
|
+
});
|
|
290
|
+
it('is idempotent on missing paths and emits no event', async () => {
|
|
291
|
+
const be = new MemoryDocumentBackend();
|
|
292
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
293
|
+
const events = [];
|
|
294
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
295
|
+
await expect(browse.deleteFrom('target-shard', 'nope.txt')).resolves.toBeUndefined();
|
|
296
|
+
expect(events).toEqual([]);
|
|
297
|
+
unsub();
|
|
298
|
+
});
|
|
299
|
+
it('never crosses tenants: a t1 capability cannot delete t2 docs', async () => {
|
|
300
|
+
const be = new MemoryDocumentBackend();
|
|
301
|
+
await be.write('t2', 's', 'secret.txt', 'hidden');
|
|
302
|
+
const browse = createBrowseCapability('t1', be, { canRead: false, canWrite: true });
|
|
303
|
+
await browse.deleteFrom('s', 'secret.txt');
|
|
304
|
+
expect(await be.read('t2', 's', 'secret.txt')).toBe('hidden');
|
|
305
|
+
});
|
|
306
|
+
});
|
|
266
307
|
});
|
package/dist/documents/handle.js
CHANGED
|
@@ -55,8 +55,10 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
55
55
|
emitChange(existed ? 'update' : 'create', path);
|
|
56
56
|
},
|
|
57
57
|
async delete(path) {
|
|
58
|
+
const existed = await backend.exists(tenantId, shardId, path);
|
|
58
59
|
await backend.delete(tenantId, shardId, path);
|
|
59
|
-
|
|
60
|
+
if (existed)
|
|
61
|
+
emitChange('delete', path);
|
|
60
62
|
},
|
|
61
63
|
async rename(oldPath, newPath) {
|
|
62
64
|
if (!matchesExtensions(newPath)) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
2
|
import { MemoryDocumentBackend } from './backends';
|
|
3
3
|
import { createDocumentHandle } from './handle';
|
|
4
|
+
import { documentChanges } from './notifications';
|
|
4
5
|
function harness() {
|
|
5
6
|
const backend = new MemoryDocumentBackend();
|
|
6
7
|
const handle = createDocumentHandle('tenant1', 'shard1', backend, { format: 'text' });
|
|
@@ -141,3 +142,25 @@ describe('DocumentHandle.rename', () => {
|
|
|
141
142
|
.rejects.toThrow(/extensions/);
|
|
142
143
|
});
|
|
143
144
|
});
|
|
145
|
+
describe('DocumentHandle.delete()', () => {
|
|
146
|
+
it('emits a delete event when the path existed', async () => {
|
|
147
|
+
const { backend, handle } = harness();
|
|
148
|
+
await backend.write('tenant1', 'shard1', 'a.txt', 'hi');
|
|
149
|
+
const events = [];
|
|
150
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
151
|
+
await handle.delete('a.txt');
|
|
152
|
+
expect(await backend.read('tenant1', 'shard1', 'a.txt')).toBeNull();
|
|
153
|
+
expect(events).toEqual([
|
|
154
|
+
{ type: 'delete', path: 'a.txt', tenantId: 'tenant1', shardId: 'shard1' },
|
|
155
|
+
]);
|
|
156
|
+
unsub();
|
|
157
|
+
});
|
|
158
|
+
it('emits no event when the path did not exist', async () => {
|
|
159
|
+
const { handle } = harness();
|
|
160
|
+
const events = [];
|
|
161
|
+
const unsub = documentChanges.subscribe((c) => events.push(c));
|
|
162
|
+
await expect(handle.delete('nope.txt')).resolves.toBeUndefined();
|
|
163
|
+
expect(events).toEqual([]);
|
|
164
|
+
unsub();
|
|
165
|
+
});
|
|
166
|
+
});
|
package/dist/host.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
import { registerShard as registerShardInternal, activateShard, registeredShards, } from './shards/activate.svelte';
|
|
19
19
|
import { addAutostartShard } from './actions/state.svelte';
|
|
20
20
|
import { registerApp, registeredApps } from './apps/registry.svelte';
|
|
21
|
-
import { launchApp, readLastApp } from './apps/lifecycle';
|
|
21
|
+
import { launchApp, readLastApp, clearLastApp } from './apps/lifecycle';
|
|
22
22
|
import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
|
|
23
23
|
import { shellShard } from './shell-shard/shellShard.svelte';
|
|
24
24
|
import { storeShard } from './app/store/storeShard.svelte';
|
|
@@ -77,13 +77,27 @@ export async function bootstrap(config) {
|
|
|
77
77
|
for (const [id, shard] of registeredShards) {
|
|
78
78
|
if (shard.autostart) {
|
|
79
79
|
addAutostartShard(id);
|
|
80
|
-
|
|
80
|
+
try {
|
|
81
|
+
await activateShard(id, { phase: 'autostart' });
|
|
82
|
+
}
|
|
83
|
+
catch (_a) {
|
|
84
|
+
// Already logged + recorded in erroredShards by activateShard.
|
|
85
|
+
// One bad self-starting shard must not prevent the shell from booting.
|
|
86
|
+
}
|
|
81
87
|
}
|
|
82
88
|
}
|
|
83
|
-
// 5. Read the last-active app from the user zone
|
|
89
|
+
// 5. Read the last-active app from the user zone. If auto-launch fails,
|
|
90
|
+
// clear the slot so the next reload lands on home instead of looping
|
|
91
|
+
// into the same failure. No toast — the user did not initiate this.
|
|
84
92
|
const lastId = readLastApp();
|
|
85
93
|
if (lastId && registeredApps.has(lastId)) {
|
|
86
|
-
|
|
94
|
+
try {
|
|
95
|
+
await launchApp(lastId);
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
console.error(`[sh3] Auto-launch of "${lastId}" failed:`, err);
|
|
99
|
+
clearLastApp();
|
|
100
|
+
}
|
|
87
101
|
}
|
|
88
102
|
}
|
|
89
103
|
export { installPackage, listInstalledPackages } from './registry/installer';
|
|
@@ -202,7 +202,11 @@
|
|
|
202
202
|
{@const entry = tabs?.tabs[i]}
|
|
203
203
|
{#if entry}
|
|
204
204
|
<div class="tab-slot-wrapper">
|
|
205
|
-
<SlotContainer
|
|
205
|
+
<SlotContainer
|
|
206
|
+
node={{ type: 'slot', slotId: entry.slotId, viewId: entry.viewId }}
|
|
207
|
+
label={entry.label}
|
|
208
|
+
meta={entry.meta}
|
|
209
|
+
/>
|
|
206
210
|
<SlotDropZone {rootRef} path={path} />
|
|
207
211
|
</div>
|
|
208
212
|
{/if}
|
|
@@ -119,6 +119,48 @@ describe('LayoutRenderer — C.4 tabless preset', () => {
|
|
|
119
119
|
expect(container.querySelector('[role="tab"]')).toBeNull();
|
|
120
120
|
});
|
|
121
121
|
});
|
|
122
|
+
describe('LayoutRenderer — C.6 TabEntry.meta threading', () => {
|
|
123
|
+
beforeEach(resetFramework);
|
|
124
|
+
it('forwards meta on a tab added post-launch (e.g. via floatManager.open)', async () => {
|
|
125
|
+
let captured;
|
|
126
|
+
registerView('test:meta-view', {
|
|
127
|
+
mount(_container, ctx) {
|
|
128
|
+
captured = ctx.meta;
|
|
129
|
+
return { unmount: () => { } };
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
// Launch with a placeholder tab — its slotId differs from the one we
|
|
133
|
+
// push later, so acquireAppSlotHolds takes no hold for the new tab.
|
|
134
|
+
// The new tab's first acquire is therefore SlotContainer's $effect,
|
|
135
|
+
// which is the gap the bug lives in.
|
|
136
|
+
registerApp(makeApp({
|
|
137
|
+
manifest: makeAppManifest({ id: 'c6' }),
|
|
138
|
+
initialLayout: [
|
|
139
|
+
{
|
|
140
|
+
name: 'default',
|
|
141
|
+
tree: makeTree(makeTabsNode([makeTabEntry({ slotId: 'placeholder', viewId: 'test:view' })])),
|
|
142
|
+
},
|
|
143
|
+
],
|
|
144
|
+
}));
|
|
145
|
+
await launchApp('c6');
|
|
146
|
+
renderWithShell(LayoutRenderer, { path: [] });
|
|
147
|
+
await tick();
|
|
148
|
+
const root = layoutStore.root;
|
|
149
|
+
if ((root === null || root === void 0 ? void 0 : root.type) !== 'tabs')
|
|
150
|
+
throw new Error('expected tabs root');
|
|
151
|
+
root.tabs.push({
|
|
152
|
+
slotId: 'meta-slot',
|
|
153
|
+
viewId: 'test:meta-view',
|
|
154
|
+
label: 'M',
|
|
155
|
+
meta: { source: 'data:url', isBlob: false, label: 'pic.png' },
|
|
156
|
+
});
|
|
157
|
+
root.activeTab = 1;
|
|
158
|
+
await tick();
|
|
159
|
+
// acquireSlotHost defers factory.mount via queueMicrotask — flush it.
|
|
160
|
+
await Promise.resolve();
|
|
161
|
+
expect(captured).toEqual({ source: 'data:url', isBlob: false, label: 'pic.png' });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
122
164
|
describe('LayoutRenderer — C.5 invalid path', () => {
|
|
123
165
|
beforeEach(resetFramework);
|
|
124
166
|
it('renders nothing when the path no longer resolves to a node', async () => {
|
|
@@ -38,7 +38,11 @@
|
|
|
38
38
|
import { getView } from '../shards/registry';
|
|
39
39
|
import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
|
|
40
40
|
|
|
41
|
-
let {
|
|
41
|
+
let {
|
|
42
|
+
node,
|
|
43
|
+
label = '',
|
|
44
|
+
meta,
|
|
45
|
+
}: { node: SlotNode; label?: string; meta?: Record<string, unknown> } = $props();
|
|
42
46
|
|
|
43
47
|
let wrapper: HTMLDivElement | undefined = $state();
|
|
44
48
|
let width = $state(0);
|
|
@@ -62,7 +66,12 @@
|
|
|
62
66
|
// detach if still in our wrapper" guard.
|
|
63
67
|
const currentSlotId = node.slotId;
|
|
64
68
|
const wrapperEl = wrapper;
|
|
65
|
-
const host = acquireSlotHost(
|
|
69
|
+
const host = acquireSlotHost(
|
|
70
|
+
currentSlotId,
|
|
71
|
+
node.viewId,
|
|
72
|
+
label || node.viewId || currentSlotId,
|
|
73
|
+
meta,
|
|
74
|
+
);
|
|
66
75
|
wrapperEl.appendChild(host);
|
|
67
76
|
|
|
68
77
|
// Local observer exists only to drive the placeholder's dims text;
|
|
@@ -2,6 +2,7 @@ import type { SlotNode } from './types';
|
|
|
2
2
|
type $$ComponentProps = {
|
|
3
3
|
node: SlotNode;
|
|
4
4
|
label?: string;
|
|
5
|
+
meta?: Record<string, unknown>;
|
|
5
6
|
};
|
|
6
7
|
declare const SlotContainer: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
8
|
type SlotContainer = ReturnType<typeof SlotContainer>;
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
import { getView, __addViewRegistrationListener } from '../shards/registry';
|
|
36
36
|
import { locateSlotIn } from './ops';
|
|
37
37
|
import { activeLayout } from './store.svelte';
|
|
38
|
+
import { scopeToString } from '../actions/scope-helpers';
|
|
38
39
|
const pool = new Map();
|
|
39
40
|
const pendingDestroy = new Set();
|
|
40
41
|
/**
|
|
@@ -134,8 +135,10 @@ function createHost(slotId, viewId, label, meta) {
|
|
|
134
135
|
const host = document.createElement('div');
|
|
135
136
|
host.className = 'slot-host';
|
|
136
137
|
host.dataset.slotId = slotId;
|
|
137
|
-
if (viewId)
|
|
138
|
+
if (viewId) {
|
|
138
139
|
host.setAttribute('data-sh3-view', viewId);
|
|
140
|
+
host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
|
|
141
|
+
}
|
|
139
142
|
// Position:absolute inset:0 so the host fills whichever wrapper it is
|
|
140
143
|
// attached to. The wrapper is what the layout engine sizes; the host
|
|
141
144
|
// just tracks it. Styles are set inline (not in a class) so consumers
|
|
@@ -220,10 +223,14 @@ export function acquireSlotHost(slotId, viewId, label, meta) {
|
|
|
220
223
|
`but existing pooled entry has viewId "${entry.viewId}". Attribute synced; ` +
|
|
221
224
|
`view handle unchanged.`);
|
|
222
225
|
entry.viewId = viewId;
|
|
223
|
-
if (viewId)
|
|
226
|
+
if (viewId) {
|
|
224
227
|
entry.host.setAttribute('data-sh3-view', viewId);
|
|
225
|
-
|
|
228
|
+
entry.host.setAttribute('data-sh3-scope', scopeToString(`focus:${viewId}`));
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
226
231
|
entry.host.removeAttribute('data-sh3-view');
|
|
232
|
+
entry.host.removeAttribute('data-sh3-scope');
|
|
233
|
+
}
|
|
227
234
|
}
|
|
228
235
|
entry.refcount++;
|
|
229
236
|
return entry.host;
|
|
@@ -116,3 +116,18 @@ describe('slotHostPool — D.6 data-sh3-view attribute', () => {
|
|
|
116
116
|
releaseSlotHost('slot-2');
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
// ─── D.7 ─────────────────────────────────────────────────────────────────────
|
|
120
|
+
describe('slotHostPool — D.7 data-sh3-scope attribute', () => {
|
|
121
|
+
beforeEach(resetFramework);
|
|
122
|
+
it('pooled host has data-sh3-scope="focus:<viewId>" alongside data-sh3-view', () => {
|
|
123
|
+
const host = acquireSlotHost('slot-3', 'editor', 'Editor');
|
|
124
|
+
expect(host.getAttribute('data-sh3-view')).toBe('editor');
|
|
125
|
+
expect(host.getAttribute('data-sh3-scope')).toBe('focus:editor');
|
|
126
|
+
releaseSlotHost('slot-3');
|
|
127
|
+
});
|
|
128
|
+
it('pooled host has no data-sh3-scope when viewId is null', () => {
|
|
129
|
+
const host = acquireSlotHost('slot-4', null, 'Empty');
|
|
130
|
+
expect(host.hasAttribute('data-sh3-scope')).toBe(false);
|
|
131
|
+
releaseSlotHost('slot-4');
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { MemoryDocumentBackend } from '../documents/backends';
|
|
3
|
+
import { __setDocumentBackend, __setTenantId } from '../documents/config';
|
|
4
|
+
import { registerShard, activateShard, registeredShards, activeShards, __resetShardRegistryForTest, erroredShards, } from './activate.svelte';
|
|
5
|
+
describe('erroredShards map', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
__resetShardRegistryForTest();
|
|
8
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
9
|
+
__setTenantId('tenant-a');
|
|
10
|
+
});
|
|
11
|
+
it('is empty after reset', () => {
|
|
12
|
+
expect(erroredShards.size).toBe(0);
|
|
13
|
+
});
|
|
14
|
+
it('supports the Map read API used by callers', () => {
|
|
15
|
+
expect(typeof erroredShards.has).toBe('function');
|
|
16
|
+
expect(typeof erroredShards.get).toBe('function');
|
|
17
|
+
expect(erroredShards.has('anything')).toBe(false);
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
describe('activateShard — unwind on activation failure', () => {
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
__resetShardRegistryForTest();
|
|
23
|
+
__setDocumentBackend(new MemoryDocumentBackend());
|
|
24
|
+
__setTenantId('tenant-a');
|
|
25
|
+
});
|
|
26
|
+
it('unwinds partial state and records the error when activate throws', async () => {
|
|
27
|
+
const shard = {
|
|
28
|
+
manifest: {
|
|
29
|
+
id: 'broken',
|
|
30
|
+
label: 'Broken',
|
|
31
|
+
version: '0.0.0',
|
|
32
|
+
views: [],
|
|
33
|
+
},
|
|
34
|
+
activate(ctx) {
|
|
35
|
+
ctx.registerView('broken:view', { mount: () => ({ unmount() { } }) });
|
|
36
|
+
throw new Error('dependency missing');
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
registerShard(shard);
|
|
40
|
+
await expect(activateShard('broken')).rejects.toThrow('dependency missing');
|
|
41
|
+
expect(activeShards.has('broken')).toBe(false);
|
|
42
|
+
expect(registeredShards.has('broken')).toBe(true);
|
|
43
|
+
const entry = erroredShards.get('broken');
|
|
44
|
+
expect(entry).toBeDefined();
|
|
45
|
+
expect(entry === null || entry === void 0 ? void 0 : entry.id).toBe('broken');
|
|
46
|
+
expect(entry === null || entry === void 0 ? void 0 : entry.phase).toBe('launch');
|
|
47
|
+
expect(entry === null || entry === void 0 ? void 0 : entry.error).toBeInstanceOf(Error);
|
|
48
|
+
expect(typeof (entry === null || entry === void 0 ? void 0 : entry.timestamp)).toBe('number');
|
|
49
|
+
const { getView } = await import('./registry');
|
|
50
|
+
expect(getView('broken:view')).toBeUndefined();
|
|
51
|
+
});
|
|
52
|
+
it('records phase "autostart" when called with that option', async () => {
|
|
53
|
+
var _a;
|
|
54
|
+
const shard = {
|
|
55
|
+
manifest: { id: 'broken-auto', label: 'B', version: '0.0.0', views: [] },
|
|
56
|
+
activate() {
|
|
57
|
+
throw new Error('no');
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
registerShard(shard);
|
|
61
|
+
await expect(activateShard('broken-auto', { phase: 'autostart' })).rejects.toThrow('no');
|
|
62
|
+
expect((_a = erroredShards.get('broken-auto')) === null || _a === void 0 ? void 0 : _a.phase).toBe('autostart');
|
|
63
|
+
});
|
|
64
|
+
it('clears the error entry when the shard is re-registered', async () => {
|
|
65
|
+
const broken = {
|
|
66
|
+
manifest: { id: 'reborn', label: 'R', version: '0.0.0', views: [] },
|
|
67
|
+
activate() {
|
|
68
|
+
throw new Error('first try');
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
registerShard(broken);
|
|
72
|
+
await expect(activateShard('reborn')).rejects.toThrow('first try');
|
|
73
|
+
expect(erroredShards.has('reborn')).toBe(true);
|
|
74
|
+
const fixed = {
|
|
75
|
+
manifest: { id: 'reborn', label: 'R', version: '0.0.1', views: [] },
|
|
76
|
+
activate() { },
|
|
77
|
+
};
|
|
78
|
+
registerShard(fixed);
|
|
79
|
+
expect(erroredShards.has('reborn')).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it('clears the error entry when activation eventually succeeds', async () => {
|
|
82
|
+
let shouldFail = true;
|
|
83
|
+
const shard = {
|
|
84
|
+
manifest: { id: 'flaky', label: 'F', version: '0.0.0', views: [] },
|
|
85
|
+
activate() {
|
|
86
|
+
if (shouldFail)
|
|
87
|
+
throw new Error('first try');
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
registerShard(shard);
|
|
91
|
+
await expect(activateShard('flaky')).rejects.toThrow('first try');
|
|
92
|
+
expect(erroredShards.has('flaky')).toBe(true);
|
|
93
|
+
shouldFail = false;
|
|
94
|
+
await activateShard('flaky');
|
|
95
|
+
expect(erroredShards.has('flaky')).toBe(false);
|
|
96
|
+
expect(activeShards.has('flaky')).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -9,6 +9,19 @@ import type { Shard, ShardContext } from './types';
|
|
|
9
9
|
*/
|
|
10
10
|
export declare const registeredShards: Map<string, Shard>;
|
|
11
11
|
export declare const activeShards: Map<string, Shard>;
|
|
12
|
+
/**
|
|
13
|
+
* Reactive map of shard ids that failed to activate. Populated by
|
|
14
|
+
* `activateShard`'s catch block; cleared when the shard is successfully
|
|
15
|
+
* re-registered or activated. Read-only for shards; intended for diagnostic
|
|
16
|
+
* and admin tooling that wants to surface broken shards to the user.
|
|
17
|
+
*/
|
|
18
|
+
export interface ShardErrorEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
error: unknown;
|
|
21
|
+
phase: 'autostart' | 'launch';
|
|
22
|
+
timestamp: number;
|
|
23
|
+
}
|
|
24
|
+
export declare const erroredShards: Map<string, ShardErrorEntry>;
|
|
12
25
|
/**
|
|
13
26
|
* Register (or re-register) a shard with the framework so it can later be
|
|
14
27
|
* activated. Records the shard in `registeredShards` but does not run
|
|
@@ -20,16 +33,31 @@ export declare const activeShards: Map<string, Shard>;
|
|
|
20
33
|
* activated on next launch.
|
|
21
34
|
*/
|
|
22
35
|
export declare function registerShard(shard: Shard): void;
|
|
36
|
+
export interface ActivateShardOpts {
|
|
37
|
+
/**
|
|
38
|
+
* Where this activation was initiated from. Determines the `phase` field
|
|
39
|
+
* recorded in `erroredShards` if activation fails. Defaults to 'launch'
|
|
40
|
+
* (the common case — required by an app being launched).
|
|
41
|
+
*/
|
|
42
|
+
phase?: 'autostart' | 'launch';
|
|
43
|
+
}
|
|
23
44
|
/**
|
|
24
45
|
* Activate a registered shard. Builds a `ShardContext`, calls `shard.activate`,
|
|
25
46
|
* verifies that every view declared in the manifest received a factory, then
|
|
26
47
|
* calls `shard.autostart` if defined. Idempotent — calling on an already-active
|
|
27
48
|
* shard is a no-op.
|
|
28
49
|
*
|
|
50
|
+
* If `shard.activate` throws, partial state (registered views, verbs,
|
|
51
|
+
* contributions, document handles, actions, env subscription) is unwound
|
|
52
|
+
* and the failure is recorded in `erroredShards` before the error is
|
|
53
|
+
* re-thrown. Callers in `host.ts` (autostart loop) and `launchApp`
|
|
54
|
+
* (required-shard loop) decide how to react.
|
|
55
|
+
*
|
|
29
56
|
* @param id - The `ShardManifest.id` of the shard to activate. Must be registered.
|
|
30
|
-
* @
|
|
57
|
+
* @param opts - Optional. `phase` is recorded in `erroredShards` on failure (default 'launch').
|
|
58
|
+
* @throws If the shard is not registered, if `shard.activate` throws, or if a manifest view has no factory after activation.
|
|
31
59
|
*/
|
|
32
|
-
export declare function activateShard(id: string): Promise<void>;
|
|
60
|
+
export declare function activateShard(id: string, opts?: ActivateShardOpts): Promise<void>;
|
|
33
61
|
/**
|
|
34
62
|
* Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
|
|
35
63
|
* all document handles, unregisters all view factories, and removes the shard
|