sh3-core 0.22.5 → 0.24.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/Sh3.svelte +4 -4
- package/dist/actions/listActive.js +1 -0
- package/dist/actions/listActive.test.js +13 -0
- package/dist/actions/types.d.ts +12 -0
- package/dist/api.d.ts +3 -1
- package/dist/api.js +3 -1
- package/dist/app/admin/adminApp.js +2 -0
- package/dist/app/admin/adminShard.svelte.js +1 -0
- package/dist/app/store/StoreView.svelte +1 -1
- package/dist/app/store/storeApp.js +3 -1
- package/dist/app/store/storeShard.svelte.js +1 -0
- package/dist/app-appearance/appearanceShard.svelte.js +1 -0
- package/dist/apps/lifecycle.js +22 -10
- package/dist/apps/lifecycle.test.js +53 -1
- package/dist/apps/types.d.ts +9 -0
- package/dist/chrome/CompactChrome.svelte +11 -7
- package/dist/chrome/MenuSheet.svelte +19 -6
- package/dist/contributions/contextSource.d.ts +48 -0
- package/dist/contributions/contextSource.js +21 -0
- package/dist/createShell.js +40 -0
- package/dist/documents/picker-api.test.js +40 -0
- package/dist/documents/picker-primitive.d.ts +37 -8
- package/dist/documents/picker-primitive.js +5 -13
- package/dist/host.js +30 -7
- package/dist/layout/slotHostPool.svelte.d.ts +11 -0
- package/dist/layout/slotHostPool.svelte.js +41 -17
- package/dist/layout/slotHostPool.test.js +45 -1
- package/dist/layouts-shard/layoutsShard.svelte.js +1 -0
- package/dist/overlays/OverlayRoots.svelte +15 -4
- package/dist/overlays/__test__/OverlayBindHarness.svelte +20 -0
- package/dist/overlays/__test__/OverlayBindHarness.svelte.d.ts +3 -0
- package/dist/overlays/float-compact-bind.svelte.test.d.ts +1 -0
- package/dist/overlays/float-compact-bind.svelte.test.js +51 -0
- package/dist/overlays/modal.js +3 -0
- package/dist/overlays/modal.test.js +45 -0
- package/dist/overlays/types.d.ts +9 -0
- package/dist/primitives/widgets/DocumentFilePicker.svelte +9 -7
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +44 -27
- package/dist/primitives/widgets/ShardPicker.svelte +38 -0
- package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +15 -7
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +2 -0
- package/dist/projects/scope-gate.d.ts +4 -0
- package/dist/projects/scope-gate.js +51 -0
- package/dist/projects/scope-gate.test.d.ts +1 -0
- package/dist/projects/scope-gate.test.js +92 -0
- package/dist/projects-shard/ProjectManage.svelte +42 -2
- package/dist/projects-shard/ProjectManage.svelte.test.js +10 -9
- package/dist/projects-shard/projectsApi.d.ts +3 -2
- package/dist/projects-shard/projectsApi.test.js +1 -1
- package/dist/projects-shard/projectsShard.svelte.js +1 -0
- package/dist/runtime/runVerb.d.ts +9 -0
- package/dist/runtime/runVerb.js +4 -4
- package/dist/runtime/runVerb.test.js +29 -0
- package/dist/sh3Api/headless.d.ts +7 -0
- package/dist/sh3Api/headless.js +3 -1
- package/dist/sh3Api/headless.svelte.test.js +42 -0
- package/dist/sh3core-shard/Sh3Home.svelte +3 -4
- package/dist/sh3core-shard/sh3coreShard.svelte.js +1 -0
- package/dist/shards/lifecycle.svelte.d.ts +8 -2
- package/dist/shards/lifecycle.svelte.js +65 -7
- package/dist/shards/lifecycle.test.js +110 -1
- package/dist/shards/types.d.ts +13 -0
- package/dist/shell-shard/Terminal.svelte +1 -4
- package/dist/shell-shard/Terminal.svelte.d.ts +0 -2
- package/dist/shell-shard/dispatch.d.ts +0 -2
- package/dist/shell-shard/dispatch.js +0 -2
- package/dist/shell-shard/display-cwd.test.js +4 -4
- package/dist/shell-shard/manifest.js +1 -0
- package/dist/shell-shard/shellShard.svelte.d.ts +1 -1
- package/dist/shell-shard/shellShard.svelte.js +9 -4
- package/dist/shell-shard/verbs/cat.js +3 -3
- package/dist/shell-shard/verbs/cat.test.js +1 -2
- package/dist/shell-shard/verbs/ls.js +2 -2
- package/dist/shell-shard/verbs/ls.test.js +1 -2
- package/dist/shell-shard/verbs/mkdir.js +3 -3
- package/dist/shell-shard/verbs/mkdir.test.js +1 -2
- package/dist/shell-shard/verbs/mv.js +3 -3
- package/dist/shell-shard/verbs/mv.test.js +1 -2
- package/dist/shell-shard/verbs/rm.js +3 -3
- package/dist/shell-shard/verbs/rm.test.js +1 -2
- package/dist/shell-shard/verbs/xfer.js +5 -5
- package/dist/shell-shard/verbs/xfer.test.js +2 -2
- package/dist/transport/apiFetch.js +21 -3
- package/dist/transport/apiFetch.test.js +63 -0
- package/dist/verbs/types.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -2,23 +2,15 @@ import { sh3 } from '../sh3Runtime.svelte';
|
|
|
2
2
|
import DocumentBrowser from '../primitives/widgets/_DocumentBrowser.svelte';
|
|
3
3
|
const BOX_STYLE = 'max-width: min(800px, 95vw);';
|
|
4
4
|
const MODAL_OPTS = { dismissOnBackdrop: true, boxStyle: BOX_STYLE };
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
* The listFn is derived from the shard's document zone + browse permission
|
|
8
|
-
* and baked in at construction time so callers don't pass their own scope.
|
|
9
|
-
*
|
|
10
|
-
* When an `anchor` element is provided the browser opens as a popup
|
|
11
|
-
* (anchored near the element). Without an anchor it opens as a centered
|
|
12
|
-
* modal (the expected default for file-browser dialogs).
|
|
13
|
-
*/
|
|
14
|
-
export function createDocumentPicker(listFn) {
|
|
15
|
-
/** Resolve handle for either popup (anchored) or modal (centered) path. */
|
|
5
|
+
export function createDocumentPicker(listFn, options = {}) {
|
|
6
|
+
const { listFolders, handle, readOnlyShard, initialShardId, lockToShard } = options;
|
|
16
7
|
function openBrowser(browserProps, anchor) {
|
|
8
|
+
const props = Object.assign(Object.assign({}, browserProps), { listFolders, handle, readOnlyShard, initialShardId, lockToShard });
|
|
17
9
|
if (anchor) {
|
|
18
10
|
const rect = anchor.getBoundingClientRect();
|
|
19
|
-
return sh3.popup.show(DocumentBrowser, { anchor: { x: rect.left + rect.width / 2, y: rect.top } },
|
|
11
|
+
return sh3.popup.show(DocumentBrowser, { anchor: { x: rect.left + rect.width / 2, y: rect.top } }, props);
|
|
20
12
|
}
|
|
21
|
-
return sh3.modal.open(DocumentBrowser,
|
|
13
|
+
return sh3.modal.open(DocumentBrowser, props, MODAL_OPTS);
|
|
22
14
|
}
|
|
23
15
|
function wrapHandle(handle, resolve) {
|
|
24
16
|
const origClose = handle.close;
|
package/dist/host.js
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* import-hygiene rule is: shards and apps import from `api.ts`, the host
|
|
16
16
|
* imports from `host.ts`.
|
|
17
17
|
*/
|
|
18
|
-
import { registerShard as registerShardInternal, registerAllShards, } from './shards/lifecycle.svelte';
|
|
18
|
+
import { registerShard as registerShardInternal, registerAllShards, listRegisteredShards, } from './shards/lifecycle.svelte';
|
|
19
19
|
import { registerApp, registeredApps } from './apps/registry.svelte';
|
|
20
20
|
import { launchApp, readLastApp, clearLastApp } from './apps/lifecycle';
|
|
21
21
|
import { sh3coreShard } from './sh3core-shard/sh3coreShard.svelte';
|
|
@@ -35,6 +35,9 @@ import { runModeIdRenameMigration } from './migrations/mode-id-rename';
|
|
|
35
35
|
import { setLifecycleHandlers } from './navigation/back-stack';
|
|
36
36
|
import { installWebEmitter } from './navigation/platform-web';
|
|
37
37
|
import { returnToHome } from './apps/lifecycle';
|
|
38
|
+
import { resolveAllowedShardIds } from './projects/scope-gate';
|
|
39
|
+
import { sessionState } from './projects/session-state.svelte';
|
|
40
|
+
import { projectsState } from './projects-shard/projectsShard.svelte';
|
|
38
41
|
export { __setBackend };
|
|
39
42
|
export { setLocalOwner };
|
|
40
43
|
export { __setActiveScope, __setDocumentBackend } from './documents/config';
|
|
@@ -58,6 +61,7 @@ function createWorkspaceZoneAdapter() {
|
|
|
58
61
|
};
|
|
59
62
|
}
|
|
60
63
|
export async function bootstrap(config) {
|
|
64
|
+
var _a;
|
|
61
65
|
// Run before anything touches the workspace zone so renamed keys are
|
|
62
66
|
// already in place when shards activate.
|
|
63
67
|
if (typeof globalThis.localStorage !== 'undefined') {
|
|
@@ -83,10 +87,22 @@ export async function bootstrap(config) {
|
|
|
83
87
|
}
|
|
84
88
|
// 3. Load any packages installed in a previous session from IndexedDB
|
|
85
89
|
await loadInstalledPackages();
|
|
86
|
-
// 4.
|
|
87
|
-
//
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
+
// 4. Compute the project-scope register gate. Null when in personal
|
|
91
|
+
// scope or when the active project has an empty allowlist; otherwise
|
|
92
|
+
// a Set of shard ids whose register() may run. Shards not in the
|
|
93
|
+
// set are skipped — they remain in `registeredShards` so the apps
|
|
94
|
+
// registry can still display their manifests, but their boot
|
|
95
|
+
// side-effects (verbs, contributions, gestures, network calls)
|
|
96
|
+
// are suppressed.
|
|
97
|
+
const activeProject = sessionState.activeProjectId
|
|
98
|
+
? (_a = projectsState.projects.find((p) => p.id === sessionState.activeProjectId)) !== null && _a !== void 0 ? _a : null
|
|
99
|
+
: null;
|
|
100
|
+
const allowed = resolveAllowedShardIds(activeProject, registeredApps, listRegisteredShards());
|
|
101
|
+
// 5. v3: run register(ctx) on every registered shard (gated by allowed).
|
|
102
|
+
// Lifecycle module handles error isolation; one failing shard does
|
|
103
|
+
// not block boot.
|
|
104
|
+
await registerAllShards(allowed);
|
|
105
|
+
// 6. Read the last-active app from the user zone. If auto-launch fails,
|
|
90
106
|
// clear the slot so the next reload lands on home instead of looping
|
|
91
107
|
// into the same failure. No toast — the user did not initiate this.
|
|
92
108
|
const lastId = readLastApp();
|
|
@@ -99,7 +115,7 @@ export async function bootstrap(config) {
|
|
|
99
115
|
clearLastApp();
|
|
100
116
|
}
|
|
101
117
|
}
|
|
102
|
-
//
|
|
118
|
+
// 7. Wire navigation lifecycle handlers and install the web back/forward
|
|
103
119
|
// emitter. Order: after autostart shards and the optional last-app
|
|
104
120
|
// launch, so the emitter's synthetic history entries don't interleave
|
|
105
121
|
// with boot-time launches. The window/history guard makes bootstrap
|
|
@@ -118,6 +134,7 @@ export async function bootstrap(config) {
|
|
|
118
134
|
* - lastApp auto-launch (satellites have no last-app concept)
|
|
119
135
|
*/
|
|
120
136
|
export async function bootstrapSatellite(config) {
|
|
137
|
+
var _a;
|
|
121
138
|
// 1. Framework-owned shards (same list as bootstrap, no excludes for satellites)
|
|
122
139
|
const frameworkShards = [sh3coreShard, shellShard, storeShard, adminShard, projectsShard, appearanceShard, layoutsShard];
|
|
123
140
|
for (const shard of frameworkShards) {
|
|
@@ -133,7 +150,13 @@ export async function bootstrapSatellite(config) {
|
|
|
133
150
|
// 4. v3: one-pass register sweep replaces the old autostart + satellite
|
|
134
151
|
// passes. `config.activateShardIds` is now a no-op (the field is
|
|
135
152
|
// retained on the interface for back-compat; Phase 6 deletes it).
|
|
153
|
+
// Satellites inherit the host's project scope, so apply the same
|
|
154
|
+
// register gate.
|
|
136
155
|
void config;
|
|
137
|
-
|
|
156
|
+
const activeProject = sessionState.activeProjectId
|
|
157
|
+
? (_a = projectsState.projects.find((p) => p.id === sessionState.activeProjectId)) !== null && _a !== void 0 ? _a : null
|
|
158
|
+
: null;
|
|
159
|
+
const allowed = resolveAllowedShardIds(activeProject, registeredApps, listRegisteredShards());
|
|
160
|
+
await registerAllShards(allowed);
|
|
138
161
|
}
|
|
139
162
|
export { installPackage, listInstalledPackages } from './registry/installer';
|
|
@@ -19,6 +19,17 @@ export declare function acquireSlotHost(slotId: string, viewId: string | null, l
|
|
|
19
19
|
* unconditional detach would yank the host out of its new home.
|
|
20
20
|
*/
|
|
21
21
|
export declare function releaseSlotHost(slotId: string, fromWrapper?: HTMLElement): void;
|
|
22
|
+
/**
|
|
23
|
+
* Synchronously drain `pendingDestroy`: for every slot id queued for
|
|
24
|
+
* deferred destruction, run the destroy body immediately if refcount is
|
|
25
|
+
* 0. Called by `unloadApp` so that all of the unloading app's views are
|
|
26
|
+
* unmounted before `onAppDeactivate` runs — preventing leaked `$derived`s
|
|
27
|
+
* in still-mounted views from tripping on state the shard nullifies.
|
|
28
|
+
*
|
|
29
|
+
* Safe to call when `pendingDestroy` is empty (no-op). Safe to call
|
|
30
|
+
* multiple times in succession.
|
|
31
|
+
*/
|
|
32
|
+
export declare function flushPendingDestroys(): void;
|
|
22
33
|
/**
|
|
23
34
|
* Test / teardown helper — destroys every pooled host immediately. Used
|
|
24
35
|
* by HMR boundaries and tests; not part of normal runtime flow.
|
|
@@ -279,23 +279,47 @@ export function releaseSlotHost(slotId, fromWrapper) {
|
|
|
279
279
|
return;
|
|
280
280
|
}
|
|
281
281
|
pendingDestroy.add(slotId);
|
|
282
|
-
queueMicrotask(() =>
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
282
|
+
queueMicrotask(() => destroyIfPending(slotId));
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Internal: run the destroy body for one slotId if it is still pending
|
|
286
|
+
* and its refcount is 0. Shared between the microtask deferred destroy
|
|
287
|
+
* and the synchronous `flushPendingDestroys`.
|
|
288
|
+
*/
|
|
289
|
+
function destroyIfPending(slotId) {
|
|
290
|
+
var _a, _b;
|
|
291
|
+
if (!pendingDestroy.has(slotId))
|
|
292
|
+
return;
|
|
293
|
+
pendingDestroy.delete(slotId);
|
|
294
|
+
const current = pool.get(slotId);
|
|
295
|
+
if (!current || current.refcount > 0)
|
|
296
|
+
return; // re-acquired, keep
|
|
297
|
+
(_a = current.resizeObserver) === null || _a === void 0 ? void 0 : _a.disconnect();
|
|
298
|
+
__disposeSlotContributions(slotId);
|
|
299
|
+
(_b = current.handle) === null || _b === void 0 ? void 0 : _b.unmount();
|
|
300
|
+
current.cancelPendingMount();
|
|
301
|
+
current.host.remove();
|
|
302
|
+
pool.delete(slotId);
|
|
303
|
+
delete dirtyState[slotId];
|
|
304
|
+
delete closableState[slotId];
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Synchronously drain `pendingDestroy`: for every slot id queued for
|
|
308
|
+
* deferred destruction, run the destroy body immediately if refcount is
|
|
309
|
+
* 0. Called by `unloadApp` so that all of the unloading app's views are
|
|
310
|
+
* unmounted before `onAppDeactivate` runs — preventing leaked `$derived`s
|
|
311
|
+
* in still-mounted views from tripping on state the shard nullifies.
|
|
312
|
+
*
|
|
313
|
+
* Safe to call when `pendingDestroy` is empty (no-op). Safe to call
|
|
314
|
+
* multiple times in succession.
|
|
315
|
+
*/
|
|
316
|
+
export function flushPendingDestroys() {
|
|
317
|
+
// Snapshot the set so destroyIfPending's `pendingDestroy.delete(slotId)`
|
|
318
|
+
// doesn't mutate the iteration source.
|
|
319
|
+
const ids = [...pendingDestroy];
|
|
320
|
+
for (const slotId of ids) {
|
|
321
|
+
destroyIfPending(slotId);
|
|
322
|
+
}
|
|
299
323
|
}
|
|
300
324
|
/**
|
|
301
325
|
* Test / teardown helper — destroys every pooled host immediately. Used
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
2
|
import { tick } from 'svelte';
|
|
3
3
|
import { resetFramework } from '../__test__/reset';
|
|
4
|
-
import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
|
|
4
|
+
import { acquireSlotHost, releaseSlotHost, flushPendingDestroys } from './slotHostPool.svelte';
|
|
5
5
|
import { registerView } from '../shards/registry';
|
|
6
6
|
import SlotContainer from './SlotContainer.svelte';
|
|
7
7
|
import { renderWithShell } from '../__test__/render';
|
|
@@ -102,6 +102,50 @@ describe('slotHostPool — D.5 root swap preserves app slots', () => {
|
|
|
102
102
|
expect(teardown).not.toHaveBeenCalled();
|
|
103
103
|
});
|
|
104
104
|
});
|
|
105
|
+
// ─── D.9 ─────────────────────────────────────────────────────────────────────
|
|
106
|
+
describe('slotHostPool — D.9 flushPendingDestroys synchronously unmounts', () => {
|
|
107
|
+
beforeEach(resetFramework);
|
|
108
|
+
it('synchronously unmounts pooled hosts whose refcount has reached 0', async () => {
|
|
109
|
+
const teardown = vi.fn();
|
|
110
|
+
registerView('flush:view', { mount: () => ({ unmount: teardown }) });
|
|
111
|
+
// Acquire a slot host, then drop refcount to 0 — this enters pendingDestroy
|
|
112
|
+
// but does NOT unmount until the next microtask (existing behavior).
|
|
113
|
+
acquireSlotHost('flush-slot', 'flush:view', 'Flush View');
|
|
114
|
+
// The view-factory mount is itself deferred to a microtask; let it run
|
|
115
|
+
// so the pool entry has a real `handle` to unmount.
|
|
116
|
+
await Promise.resolve();
|
|
117
|
+
releaseSlotHost('flush-slot');
|
|
118
|
+
// The destroy microtask has NOT yet run, so teardown has not been called.
|
|
119
|
+
expect(teardown).not.toHaveBeenCalled();
|
|
120
|
+
// flushPendingDestroys runs the destroy body now, synchronously.
|
|
121
|
+
flushPendingDestroys();
|
|
122
|
+
expect(teardown).toHaveBeenCalledTimes(1);
|
|
123
|
+
});
|
|
124
|
+
it('does not unmount entries whose refcount is still > 0', async () => {
|
|
125
|
+
const teardown = vi.fn();
|
|
126
|
+
registerView('keep:view', { mount: () => ({ unmount: teardown }) });
|
|
127
|
+
acquireSlotHost('keep-slot', 'keep:view', 'Keep View');
|
|
128
|
+
await Promise.resolve();
|
|
129
|
+
// Refcount is 1, never released.
|
|
130
|
+
flushPendingDestroys();
|
|
131
|
+
expect(teardown).not.toHaveBeenCalled();
|
|
132
|
+
// Cleanup
|
|
133
|
+
releaseSlotHost('keep-slot');
|
|
134
|
+
});
|
|
135
|
+
it('clears pendingDestroy so the queued microtask is a no-op', async () => {
|
|
136
|
+
const teardown = vi.fn();
|
|
137
|
+
registerView('once:view', { mount: () => ({ unmount: teardown }) });
|
|
138
|
+
acquireSlotHost('once-slot', 'once:view', 'Once View');
|
|
139
|
+
await Promise.resolve();
|
|
140
|
+
releaseSlotHost('once-slot');
|
|
141
|
+
flushPendingDestroys();
|
|
142
|
+
expect(teardown).toHaveBeenCalledTimes(1);
|
|
143
|
+
// The previously-queued microtask should now find pendingDestroy empty
|
|
144
|
+
// for this slot and do nothing. teardown stays at 1.
|
|
145
|
+
await Promise.resolve();
|
|
146
|
+
expect(teardown).toHaveBeenCalledTimes(1);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
105
149
|
// ─── D.6 ─────────────────────────────────────────────────────────────────────
|
|
106
150
|
describe('slotHostPool — D.6 data-sh3-view attribute', () => {
|
|
107
151
|
beforeEach(resetFramework);
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* they portal in.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { untrack } from 'svelte';
|
|
17
18
|
import DragPreview from '../layout/DragPreview.svelte';
|
|
18
19
|
import FloatLayer from './FloatLayer.svelte';
|
|
19
20
|
import { registerLayerRoot, unregisterLayerRoot } from './roots';
|
|
@@ -47,12 +48,22 @@
|
|
|
47
48
|
};
|
|
48
49
|
});
|
|
49
50
|
|
|
51
|
+
// Re-bind only when the active LayoutTree itself changes (app/preset
|
|
52
|
+
// switch). bindFloatStore iterates `floats` to clamp persisted entries,
|
|
53
|
+
// which would otherwise subscribe this effect to every push/splice from
|
|
54
|
+
// floatManager — and bindFloatStore unconditionally calls
|
|
55
|
+
// compactRootStore.reset() at the end, so a re-fire on push wipes the
|
|
56
|
+
// auto-focus that openFloat() set milliseconds earlier (compact-body
|
|
57
|
+
// would stay on docked after the first ²-press; see float-compact-bind
|
|
58
|
+
// regression test).
|
|
50
59
|
$effect(() => {
|
|
51
60
|
const tree = layoutStore.tree;
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
61
|
+
untrack(() => {
|
|
62
|
+
bindFloatStore(tree.floats, () => ({
|
|
63
|
+
w: window.innerWidth,
|
|
64
|
+
h: window.innerHeight,
|
|
65
|
+
}));
|
|
66
|
+
});
|
|
56
67
|
return () => unbindFloatStore();
|
|
57
68
|
});
|
|
58
69
|
</script>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Test harness — mirrors the $effect block in OverlayRoots.svelte that
|
|
4
|
+
* binds the float manager to the active LayoutTree's floats array.
|
|
5
|
+
* Keeps the `untrack` shape so the harness validates the production
|
|
6
|
+
* fix: the bind should NOT re-fire on float push/splice (which would
|
|
7
|
+
* trip compactRootStore.reset() and wipe auto-focus from openFloat).
|
|
8
|
+
*/
|
|
9
|
+
import { untrack } from 'svelte';
|
|
10
|
+
import { layoutStore } from '../../layout/store.svelte';
|
|
11
|
+
import { bindFloatStore, unbindFloatStore } from '../float';
|
|
12
|
+
|
|
13
|
+
$effect(() => {
|
|
14
|
+
const tree = layoutStore.tree;
|
|
15
|
+
untrack(() => {
|
|
16
|
+
bindFloatStore(tree.floats, () => ({ w: 360, h: 740 }));
|
|
17
|
+
});
|
|
18
|
+
return () => unbindFloatStore();
|
|
19
|
+
});
|
|
20
|
+
</script>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Regression: in compact mode, floatManager.open() must auto-switch the
|
|
3
|
+
* compact body to the new float. The setRoot call inside openFloat is
|
|
4
|
+
* correct, but a reactive $effect that re-binds the float store on every
|
|
5
|
+
* iteration of `tree.floats` re-runs the moment the array is mutated,
|
|
6
|
+
* and bindFloatStore unconditionally calls compactRootStore.reset() —
|
|
7
|
+
* wiping the auto-focus that openFloat just set.
|
|
8
|
+
*
|
|
9
|
+
* This test mounts a tiny wrapper that mirrors the OverlayRoots $effect
|
|
10
|
+
* (read layoutStore.tree, call bindFloatStore on every run) and asserts
|
|
11
|
+
* compactRootStore is still pointing at the new float after open().
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
14
|
+
import { mount, unmount, flushSync } from 'svelte';
|
|
15
|
+
import { floatManager, unbindFloatStore, __resetFloatManagerForTest, } from './float';
|
|
16
|
+
import { compactRootStore, __resetCompactRootStoreForTest, } from '../layout/compact/rootStore.svelte';
|
|
17
|
+
import { __resetLayoutStoreForTest } from '../layout/store.svelte';
|
|
18
|
+
import { viewportStore } from '../viewport/store.svelte';
|
|
19
|
+
import OverlayBindHarness from './__test__/OverlayBindHarness.svelte';
|
|
20
|
+
describe('floatManager.open in compact — preserves setRoot under reactive bind', () => {
|
|
21
|
+
let mounted = null;
|
|
22
|
+
let host = null;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
__resetFloatManagerForTest();
|
|
25
|
+
__resetCompactRootStoreForTest();
|
|
26
|
+
__resetLayoutStoreForTest();
|
|
27
|
+
viewportStore.override('compact');
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
if (mounted) {
|
|
31
|
+
unmount(mounted);
|
|
32
|
+
mounted = null;
|
|
33
|
+
}
|
|
34
|
+
if (host) {
|
|
35
|
+
host.remove();
|
|
36
|
+
host = null;
|
|
37
|
+
}
|
|
38
|
+
viewportStore.override(null);
|
|
39
|
+
unbindFloatStore();
|
|
40
|
+
});
|
|
41
|
+
it('opens a float and the compact body root points to it after the bind effect settles', () => {
|
|
42
|
+
host = document.createElement('div');
|
|
43
|
+
document.body.appendChild(host);
|
|
44
|
+
mounted = mount(OverlayBindHarness, { target: host });
|
|
45
|
+
flushSync();
|
|
46
|
+
const id = floatManager.open('test:view', { title: 'Notes' });
|
|
47
|
+
// Allow any reactive re-bind to run.
|
|
48
|
+
flushSync();
|
|
49
|
+
expect(compactRootStore.current).toEqual({ kind: 'float', floatId: id });
|
|
50
|
+
});
|
|
51
|
+
});
|
package/dist/overlays/modal.js
CHANGED
|
@@ -96,6 +96,7 @@ function removeEscapeListenerIfIdle() {
|
|
|
96
96
|
document.removeEventListener('keydown', onDocumentKeydown, true);
|
|
97
97
|
}
|
|
98
98
|
function removeEntry(entry) {
|
|
99
|
+
var _a;
|
|
99
100
|
const idx = stack.indexOf(entry);
|
|
100
101
|
if (idx < 0)
|
|
101
102
|
return; // already closed — idempotent
|
|
@@ -104,6 +105,7 @@ function removeEntry(entry) {
|
|
|
104
105
|
entry.host.remove();
|
|
105
106
|
syncBackdrop();
|
|
106
107
|
removeEscapeListenerIfIdle();
|
|
108
|
+
(_a = entry.onClose) === null || _a === void 0 ? void 0 : _a.call(entry);
|
|
107
109
|
}
|
|
108
110
|
function openModal(Content, props, options) {
|
|
109
111
|
const root = getLayerRoot('modal');
|
|
@@ -138,6 +140,7 @@ function openModal(Content, props, options) {
|
|
|
138
140
|
entry.host = host;
|
|
139
141
|
entry.frame = frame;
|
|
140
142
|
entry.handle = handle;
|
|
143
|
+
entry.onClose = options === null || options === void 0 ? void 0 : options.onClose;
|
|
141
144
|
stack.push(entry);
|
|
142
145
|
syncBackdrop();
|
|
143
146
|
ensureEscapeListener();
|
|
@@ -88,6 +88,51 @@ describe('modal — back-cascade integration', () => {
|
|
|
88
88
|
expect(layerRoot.querySelectorAll('.sh3-modal-host').length).toBe(0);
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
|
+
describe('modal — onClose callback', () => {
|
|
92
|
+
let layerRoot;
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
layerRoot = makeLayerRoot();
|
|
95
|
+
});
|
|
96
|
+
afterEach(() => {
|
|
97
|
+
modalManager.closeAll();
|
|
98
|
+
teardownLayerRoot(layerRoot);
|
|
99
|
+
});
|
|
100
|
+
it('fires onClose when handle.close() is called', async () => {
|
|
101
|
+
let calls = 0;
|
|
102
|
+
const handle = modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
|
|
103
|
+
await tick();
|
|
104
|
+
handle.close();
|
|
105
|
+
await tick();
|
|
106
|
+
expect(calls).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
it('fires onClose on backdrop dismissal', async () => {
|
|
109
|
+
let calls = 0;
|
|
110
|
+
modalManager.open(DummyFrame, {}, { dismissOnBackdrop: true, onClose: () => calls++ });
|
|
111
|
+
await tick();
|
|
112
|
+
const frame = layerRoot.querySelector('.modal-frame');
|
|
113
|
+
frame.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
114
|
+
await tick();
|
|
115
|
+
expect(calls).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
it('fires onClose on closeAll', async () => {
|
|
118
|
+
let calls = 0;
|
|
119
|
+
modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
|
|
120
|
+
modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
|
|
121
|
+
await tick();
|
|
122
|
+
modalManager.closeAll();
|
|
123
|
+
await tick();
|
|
124
|
+
expect(calls).toBe(2);
|
|
125
|
+
});
|
|
126
|
+
it('does not fire onClose twice when close() is called repeatedly (idempotent)', async () => {
|
|
127
|
+
let calls = 0;
|
|
128
|
+
const handle = modalManager.open(DummyFrame, {}, { onClose: () => calls++ });
|
|
129
|
+
await tick();
|
|
130
|
+
handle.close();
|
|
131
|
+
handle.close();
|
|
132
|
+
await tick();
|
|
133
|
+
expect(calls).toBe(1);
|
|
134
|
+
});
|
|
135
|
+
});
|
|
91
136
|
describe('modal — overlay host marker', () => {
|
|
92
137
|
let layerRoot;
|
|
93
138
|
beforeEach(() => {
|
package/dist/overlays/types.d.ts
CHANGED
|
@@ -53,4 +53,13 @@ export interface ModalOptions {
|
|
|
53
53
|
* palette on touch-only devices).
|
|
54
54
|
*/
|
|
55
55
|
initialFocus?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Invoked after the modal has been torn down — from any dismissal
|
|
58
|
+
* path (handle.close(), Escape, backdrop click, closeAll). Use this
|
|
59
|
+
* to reset caller-side "is open" state instead of wrapping
|
|
60
|
+
* handle.close after open() returns: by then the manager has already
|
|
61
|
+
* passed handle.close by reference to ModalFrame and onBackdropClick,
|
|
62
|
+
* so post-hoc wraps would be bypassed by every real dismissal.
|
|
63
|
+
*/
|
|
64
|
+
onClose?: () => void;
|
|
56
65
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
<script lang="ts">
|
|
1
|
+
<script lang="ts" generics="M extends 'open' | 'save'">
|
|
2
2
|
import type { CommitOnlyEvents } from './_contract';
|
|
3
3
|
import { sh3 } from '../../sh3Runtime.svelte';
|
|
4
4
|
import DocumentBrowser from './_DocumentBrowser.svelte';
|
|
5
5
|
import type { DocumentMeta } from '../../documents/types';
|
|
6
6
|
import type { DocEntry, OpenerValue, SaverValue } from './DocumentFilePicker';
|
|
7
7
|
|
|
8
|
+
type ValueFor<Mode extends 'open' | 'save'> = Mode extends 'open' ? OpenerValue : SaverValue;
|
|
9
|
+
|
|
8
10
|
type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
|
|
9
11
|
type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
|
|
10
12
|
type HandleFn = {
|
|
@@ -17,7 +19,7 @@
|
|
|
17
19
|
|
|
18
20
|
let {
|
|
19
21
|
mode,
|
|
20
|
-
value = $bindable<
|
|
22
|
+
value = $bindable<ValueFor<M>>(null as ValueFor<M>),
|
|
21
23
|
listDocuments,
|
|
22
24
|
listFolders,
|
|
23
25
|
handle,
|
|
@@ -29,8 +31,8 @@
|
|
|
29
31
|
selectable = 'file',
|
|
30
32
|
onchange,
|
|
31
33
|
}: {
|
|
32
|
-
mode:
|
|
33
|
-
value?:
|
|
34
|
+
mode: M;
|
|
35
|
+
value?: ValueFor<M>;
|
|
34
36
|
listDocuments: DocListFn;
|
|
35
37
|
listFolders?: FolderListFn;
|
|
36
38
|
handle?: HandleFn;
|
|
@@ -40,7 +42,7 @@
|
|
|
40
42
|
size?: 'sm' | 'md';
|
|
41
43
|
buttonLabel?: string;
|
|
42
44
|
selectable?: 'file' | 'folder' | 'both';
|
|
43
|
-
} & CommitOnlyEvents<
|
|
45
|
+
} & CommitOnlyEvents<ValueFor<M>> = $props();
|
|
44
46
|
|
|
45
47
|
let trigger = $state<HTMLButtonElement | undefined>(undefined);
|
|
46
48
|
let openFlag = $state(false);
|
|
@@ -56,8 +58,8 @@
|
|
|
56
58
|
);
|
|
57
59
|
|
|
58
60
|
function handleCommit(result: OpenerValue | SaverValue) {
|
|
59
|
-
value = result
|
|
60
|
-
onchange?.(result);
|
|
61
|
+
value = result as ValueFor<M>;
|
|
62
|
+
onchange?.(result as ValueFor<M>);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
65
|
function onOpenClosed() {
|
|
@@ -1,32 +1,49 @@
|
|
|
1
1
|
import type { CommitOnlyEvents } from './_contract';
|
|
2
2
|
import type { DocumentMeta } from '../../documents/types';
|
|
3
3
|
import type { OpenerValue, SaverValue } from './DocumentFilePicker';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
4
|
+
declare function $$render<M extends 'open' | 'save'>(): {
|
|
5
|
+
props: {
|
|
6
|
+
mode: M;
|
|
7
|
+
value?: M extends "open" ? OpenerValue : SaverValue;
|
|
8
|
+
listDocuments: () => Promise<Array<DocumentMeta & {
|
|
9
|
+
shardId: string;
|
|
10
|
+
}>>;
|
|
11
|
+
listFolders?: (shardId: string, prefix: string) => Promise<string[]>;
|
|
12
|
+
handle?: {
|
|
13
|
+
mkdir: (shardId: string, path: string) => Promise<void>;
|
|
14
|
+
rmdir: (shardId: string, path: string, opts: {
|
|
15
|
+
recursive: boolean;
|
|
16
|
+
}) => Promise<void>;
|
|
17
|
+
renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
|
|
18
|
+
rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
|
|
19
|
+
delete: (shardId: string, path: string) => Promise<void>;
|
|
20
|
+
};
|
|
21
|
+
readOnlyShard?: (shardId: string) => boolean;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
invalid?: boolean;
|
|
24
|
+
size?: "sm" | "md";
|
|
25
|
+
buttonLabel?: string;
|
|
26
|
+
selectable?: "file" | "folder" | "both";
|
|
27
|
+
} & CommitOnlyEvents<M extends "open" ? OpenerValue : SaverValue>;
|
|
28
|
+
exports: {};
|
|
29
|
+
bindings: "value";
|
|
30
|
+
slots: {};
|
|
31
|
+
events: {};
|
|
16
32
|
};
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
declare class __sveltets_Render<M extends 'open' | 'save'> {
|
|
34
|
+
props(): ReturnType<typeof $$render<M>>['props'];
|
|
35
|
+
events(): ReturnType<typeof $$render<M>>['events'];
|
|
36
|
+
slots(): ReturnType<typeof $$render<M>>['slots'];
|
|
37
|
+
bindings(): "value";
|
|
38
|
+
exports(): {};
|
|
39
|
+
}
|
|
40
|
+
interface $$IsomorphicComponent {
|
|
41
|
+
new <M extends 'open' | 'save'>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<M>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<M>['props']>, ReturnType<__sveltets_Render<M>['events']>, ReturnType<__sveltets_Render<M>['slots']>> & {
|
|
42
|
+
$$bindings?: ReturnType<__sveltets_Render<M>['bindings']>;
|
|
43
|
+
} & ReturnType<__sveltets_Render<M>['exports']>;
|
|
44
|
+
<M extends 'open' | 'save'>(internal: unknown, props: ReturnType<__sveltets_Render<M>['props']> & {}): ReturnType<__sveltets_Render<M>['exports']>;
|
|
45
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
46
|
+
}
|
|
47
|
+
declare const DocumentFilePicker: $$IsomorphicComponent;
|
|
48
|
+
type DocumentFilePicker<M extends 'open' | 'save'> = InstanceType<typeof DocumentFilePicker<M>>;
|
|
32
49
|
export default DocumentFilePicker;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { CommitOnlyEvents } from './_contract';
|
|
3
|
+
import PickerList from './PickerList.svelte';
|
|
4
|
+
import type { PickerItem } from './PickerList';
|
|
5
|
+
import { listRegisteredShards } from '../../shards/lifecycle.svelte';
|
|
6
|
+
|
|
7
|
+
let {
|
|
8
|
+
value = $bindable<string[]>([]),
|
|
9
|
+
onchange,
|
|
10
|
+
disabled = false,
|
|
11
|
+
size = 'md',
|
|
12
|
+
}: {
|
|
13
|
+
value?: string[];
|
|
14
|
+
disabled?: boolean;
|
|
15
|
+
size?: 'sm' | 'md';
|
|
16
|
+
} & CommitOnlyEvents<string[]> = $props();
|
|
17
|
+
|
|
18
|
+
const items = $derived<PickerItem[]>(
|
|
19
|
+
listRegisteredShards()
|
|
20
|
+
.filter((m) => m.kind === 'service')
|
|
21
|
+
.map((m) => ({ id: m.id, label: m.label, sublabel: m.id }))
|
|
22
|
+
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
function handleChange(next: string[]) {
|
|
26
|
+
value = next;
|
|
27
|
+
onchange?.(next);
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<PickerList
|
|
32
|
+
{items}
|
|
33
|
+
{value}
|
|
34
|
+
onchange={handleChange}
|
|
35
|
+
{disabled}
|
|
36
|
+
{size}
|
|
37
|
+
emptyText="No service shards installed."
|
|
38
|
+
/>
|