sh3-core 0.22.5 → 0.23.2
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/api.d.ts +1 -1
- package/dist/api.js +1 -1
- package/dist/app/admin/adminApp.js +2 -0
- package/dist/app/admin/adminShard.svelte.js +1 -0
- 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/createShell.js +40 -0
- package/dist/documents/picker-api.test.js +40 -0
- package/dist/documents/picker-primitive.d.ts +39 -1
- package/dist/documents/picker-primitive.js +5 -4
- 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/ShardPicker.svelte +38 -0
- package/dist/primitives/widgets/ShardPicker.svelte.d.ts +9 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +11 -3
- 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 -3
- 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/verbs/types.d.ts +10 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
}
|
|
@@ -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
|
+
/>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CommitOnlyEvents } from './_contract';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
value?: string[];
|
|
4
|
+
disabled?: boolean;
|
|
5
|
+
size?: 'sm' | 'md';
|
|
6
|
+
} & CommitOnlyEvents<string[]>;
|
|
7
|
+
declare const ShardPicker: import("svelte").Component<$$ComponentProps, {}, "value">;
|
|
8
|
+
type ShardPicker = ReturnType<typeof ShardPicker>;
|
|
9
|
+
export default ShardPicker;
|
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
listFolders,
|
|
27
27
|
handle,
|
|
28
28
|
readOnlyShard,
|
|
29
|
+
initialShardId = null,
|
|
30
|
+
lockToShard = false,
|
|
29
31
|
}: {
|
|
30
32
|
mode: 'open' | 'save';
|
|
31
33
|
docs: DocEntry[];
|
|
@@ -43,6 +45,8 @@
|
|
|
43
45
|
delete: (shardId: string, path: string) => Promise<void>;
|
|
44
46
|
};
|
|
45
47
|
readOnlyShard?: (shardId: string) => boolean;
|
|
48
|
+
initialShardId?: string | null;
|
|
49
|
+
lockToShard?: boolean;
|
|
46
50
|
} = $props();
|
|
47
51
|
|
|
48
52
|
type Selected =
|
|
@@ -50,7 +54,7 @@
|
|
|
50
54
|
| { kind: 'folder'; fullPath: string; name: string }
|
|
51
55
|
| null;
|
|
52
56
|
|
|
53
|
-
let shardId = $state<string | null>(
|
|
57
|
+
let shardId = $state<string | null>(untrack(() => initialShardId));
|
|
54
58
|
let prefix = $state('');
|
|
55
59
|
let selected = $state<Selected>(null);
|
|
56
60
|
let filename = $state(untrack(() => suggestedName));
|
|
@@ -76,7 +80,11 @@
|
|
|
76
80
|
});
|
|
77
81
|
|
|
78
82
|
const items = $derived(buildTree(docs, folders, shardId, prefix));
|
|
79
|
-
const crumbs = $derived(
|
|
83
|
+
const crumbs = $derived(
|
|
84
|
+
lockToShard
|
|
85
|
+
? breadcrumbSegments(shardId, prefix).filter((c) => c.level > 0)
|
|
86
|
+
: breadcrumbSegments(shardId, prefix),
|
|
87
|
+
);
|
|
80
88
|
|
|
81
89
|
$effect(() => {
|
|
82
90
|
items;
|
|
@@ -352,7 +360,7 @@
|
|
|
352
360
|
parts.pop();
|
|
353
361
|
prefix = parts.join('/');
|
|
354
362
|
activeIdx = 0;
|
|
355
|
-
} else if (shardId) {
|
|
363
|
+
} else if (shardId && !lockToShard) {
|
|
356
364
|
e.preventDefault();
|
|
357
365
|
shardId = null;
|
|
358
366
|
activeIdx = 0;
|
|
@@ -18,6 +18,8 @@ type $$ComponentProps = {
|
|
|
18
18
|
delete: (shardId: string, path: string) => Promise<void>;
|
|
19
19
|
};
|
|
20
20
|
readOnlyShard?: (shardId: string) => boolean;
|
|
21
|
+
initialShardId?: string | null;
|
|
22
|
+
lockToShard?: boolean;
|
|
21
23
|
};
|
|
22
24
|
declare const DocumentBrowser: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
23
25
|
type DocumentBrowser = ReturnType<typeof DocumentBrowser>;
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { App } from '../apps/types';
|
|
2
|
+
import type { ProjectRecord } from '../projects-shard/projectsApi';
|
|
3
|
+
import type { ShardManifest } from '../shards/types';
|
|
4
|
+
export declare function resolveAllowedShardIds(project: ProjectRecord | null, apps: ReadonlyMap<string, App>, shardManifests: readonly ShardManifest[]): Set<string> | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Project-scope register() gate — pure resolution of the shard ids that
|
|
3
|
+
* may register at boot under a given project's allowlist. Mirrors the
|
|
4
|
+
* server-side `project-allowlist` middleware so client and server agree
|
|
5
|
+
* on the closure.
|
|
6
|
+
*
|
|
7
|
+
* Returns `null` (no gating) only when the project is null (personal scope)
|
|
8
|
+
* OR when both `appAllowlist` and `shardAllowlist` are empty. Otherwise the
|
|
9
|
+
* allowed set is the union of:
|
|
10
|
+
* - system-kind shards (always allowed)
|
|
11
|
+
* - the resolved `requiredShards`/`bundledShards` of each allowlisted app
|
|
12
|
+
* - service-kind shards listed in `project.shardAllowlist`
|
|
13
|
+
*
|
|
14
|
+
* The set of system-kind shards is derived at call time from the
|
|
15
|
+
* registered-shards map; mark framework shards with `kind: 'system'` in
|
|
16
|
+
* their manifest to keep them reachable inside any project.
|
|
17
|
+
*/
|
|
18
|
+
export function resolveAllowedShardIds(project, apps, shardManifests) {
|
|
19
|
+
var _a;
|
|
20
|
+
if (!project)
|
|
21
|
+
return null;
|
|
22
|
+
if (project.appAllowlist.length === 0 && project.shardAllowlist.length === 0)
|
|
23
|
+
return null;
|
|
24
|
+
const allowed = new Set();
|
|
25
|
+
const systemShardIds = new Set();
|
|
26
|
+
const serviceShardIds = new Set();
|
|
27
|
+
for (const m of shardManifests) {
|
|
28
|
+
if (m.kind === 'system') {
|
|
29
|
+
allowed.add(m.id);
|
|
30
|
+
systemShardIds.add(m.id);
|
|
31
|
+
}
|
|
32
|
+
else if (m.kind === 'service') {
|
|
33
|
+
serviceShardIds.add(m.id);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
for (const appId of project.appAllowlist) {
|
|
37
|
+
const app = apps.get(appId);
|
|
38
|
+
if (!app)
|
|
39
|
+
continue;
|
|
40
|
+
allowed.add(appId);
|
|
41
|
+
for (const s of app.manifest.requiredShards)
|
|
42
|
+
allowed.add(s);
|
|
43
|
+
for (const s of (_a = app.manifest.bundledShards) !== null && _a !== void 0 ? _a : [])
|
|
44
|
+
allowed.add(s);
|
|
45
|
+
}
|
|
46
|
+
for (const shardId of project.shardAllowlist) {
|
|
47
|
+
if (serviceShardIds.has(shardId))
|
|
48
|
+
allowed.add(shardId);
|
|
49
|
+
}
|
|
50
|
+
return allowed;
|
|
51
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolveAllowedShardIds } from './scope-gate';
|
|
3
|
+
function makeApp(id, requiredShards = [], bundledShards) {
|
|
4
|
+
const manifest = Object.assign({ id, label: id, version: '0.0.0', requiredShards, layoutVersion: 1 }, (bundledShards ? { bundledShards } : {}));
|
|
5
|
+
return { manifest, initialLayout: { kind: 'leaf', viewId: 'x' } };
|
|
6
|
+
}
|
|
7
|
+
function makeProject(appAllowlist, shardAllowlist = []) {
|
|
8
|
+
return {
|
|
9
|
+
id: 'acme-1234',
|
|
10
|
+
name: 'Acme',
|
|
11
|
+
members: ['u1'],
|
|
12
|
+
appAllowlist,
|
|
13
|
+
shardAllowlist,
|
|
14
|
+
createdBy: 'u1',
|
|
15
|
+
createdAt: 0,
|
|
16
|
+
updatedAt: 0,
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
const SYSTEM_SHARDS = [
|
|
20
|
+
{ id: 'shell', label: 'Shell', version: '0', kind: 'system', views: [] },
|
|
21
|
+
{ id: '__sh3core__', label: 'SH3 Core', version: '0', kind: 'system', views: [] },
|
|
22
|
+
];
|
|
23
|
+
describe('resolveAllowedShardIds', () => {
|
|
24
|
+
it('returns null (no gating) when project is null', () => {
|
|
25
|
+
expect(resolveAllowedShardIds(null, new Map(), SYSTEM_SHARDS)).toBeNull();
|
|
26
|
+
});
|
|
27
|
+
it('returns null (no gating) when both allowlists are empty', () => {
|
|
28
|
+
const project = makeProject([], []);
|
|
29
|
+
expect(resolveAllowedShardIds(project, new Map(), SYSTEM_SHARDS)).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('returns system-kind shards only when allowlist contains no resolvable apps', () => {
|
|
32
|
+
const project = makeProject(['ghost-app']);
|
|
33
|
+
const apps = new Map();
|
|
34
|
+
const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
|
|
35
|
+
expect(allowed.has('shell')).toBe(true);
|
|
36
|
+
expect(allowed.has('__sh3core__')).toBe(true);
|
|
37
|
+
expect(allowed.has('ghost-app')).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
it('includes app id + requiredShards for each resolved app, plus system shards', () => {
|
|
40
|
+
const project = makeProject(['notes']);
|
|
41
|
+
const apps = new Map([
|
|
42
|
+
['notes', makeApp('notes', ['notes-shard', 'editor'])],
|
|
43
|
+
]);
|
|
44
|
+
const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
|
|
45
|
+
expect(allowed.has('shell')).toBe(true);
|
|
46
|
+
expect(allowed.has('__sh3core__')).toBe(true);
|
|
47
|
+
expect(allowed.has('notes')).toBe(true);
|
|
48
|
+
expect(allowed.has('notes-shard')).toBe(true);
|
|
49
|
+
expect(allowed.has('editor')).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
it('includes bundledShards when the app declares them', () => {
|
|
52
|
+
const project = makeProject(['guml-ide']);
|
|
53
|
+
const apps = new Map([
|
|
54
|
+
['guml-ide', makeApp('guml-ide', ['guml.core'], ['guml.preview'])],
|
|
55
|
+
]);
|
|
56
|
+
const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
|
|
57
|
+
expect(allowed.has('guml.core')).toBe(true);
|
|
58
|
+
expect(allowed.has('guml.preview')).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
it('unions across multiple allowlisted apps', () => {
|
|
61
|
+
const project = makeProject(['notes', 'files']);
|
|
62
|
+
const apps = new Map([
|
|
63
|
+
['notes', makeApp('notes', ['notes-shard'])],
|
|
64
|
+
['files', makeApp('files', ['files-shard'])],
|
|
65
|
+
]);
|
|
66
|
+
const allowed = resolveAllowedShardIds(project, apps, SYSTEM_SHARDS);
|
|
67
|
+
expect(allowed.has('notes-shard')).toBe(true);
|
|
68
|
+
expect(allowed.has('files-shard')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
it('includes service-kind shards listed in shardAllowlist', () => {
|
|
71
|
+
const project = makeProject([], ['svc-a']);
|
|
72
|
+
const manifests = [
|
|
73
|
+
...SYSTEM_SHARDS,
|
|
74
|
+
{ id: 'svc-a', label: 'Svc A', version: '0', kind: 'service', views: [] },
|
|
75
|
+
{ id: 'svc-b', label: 'Svc B', version: '0', kind: 'service', views: [] },
|
|
76
|
+
];
|
|
77
|
+
const allowed = resolveAllowedShardIds(project, new Map(), manifests);
|
|
78
|
+
expect(allowed.has('svc-a')).toBe(true);
|
|
79
|
+
expect(allowed.has('svc-b')).toBe(false);
|
|
80
|
+
expect(allowed.has('shell')).toBe(true);
|
|
81
|
+
});
|
|
82
|
+
it('ignores shardAllowlist entries that are not service-kind', () => {
|
|
83
|
+
const project = makeProject([], ['not-a-service']);
|
|
84
|
+
const manifests = [
|
|
85
|
+
...SYSTEM_SHARDS,
|
|
86
|
+
{ id: 'not-a-service', label: 'X', version: '0', views: [] },
|
|
87
|
+
];
|
|
88
|
+
const allowed = resolveAllowedShardIds(project, new Map(), manifests);
|
|
89
|
+
expect(allowed.has('not-a-service')).toBe(false);
|
|
90
|
+
expect(allowed.has('shell')).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
import { refreshProjects } from './projectsShard.svelte';
|
|
14
14
|
import { getUser } from '../auth/auth.svelte';
|
|
15
15
|
import AppPicker from '../primitives/widgets/AppPicker.svelte';
|
|
16
|
+
import ShardPicker from '../primitives/widgets/ShardPicker.svelte';
|
|
16
17
|
import UserPicker from '../primitives/widgets/UserPicker.svelte';
|
|
18
|
+
import { listRegisteredShards } from '../shards/lifecycle.svelte';
|
|
17
19
|
import TabbedPanel from '../primitives/TabbedPanel.svelte';
|
|
18
20
|
import { modalManager } from '../overlays/modal';
|
|
19
21
|
import DeleteProjectDialog from './DeleteProjectDialog.svelte';
|
|
@@ -50,6 +52,9 @@
|
|
|
50
52
|
let appAllowlist = $state<string[]>(
|
|
51
53
|
untrack(() => (project ? [...project.appAllowlist] : [])),
|
|
52
54
|
);
|
|
55
|
+
let shardAllowlist = $state<string[]>(
|
|
56
|
+
untrack(() => (project ? [...(project.shardAllowlist ?? [])] : [])),
|
|
57
|
+
);
|
|
53
58
|
let saving = $state(false);
|
|
54
59
|
let error = $state<string | null>(null);
|
|
55
60
|
let activeTab = $state(0);
|
|
@@ -58,6 +63,9 @@
|
|
|
58
63
|
let mountsLoading = $state(true);
|
|
59
64
|
let mountsLoadError = $state<string | null>(null);
|
|
60
65
|
let baselineApps = $state<string[]>(untrack(() => (project ? [...project.appAllowlist] : [])));
|
|
66
|
+
let baselineShards = $state<string[]>(
|
|
67
|
+
untrack(() => (project ? [...(project.shardAllowlist ?? [])] : [])),
|
|
68
|
+
);
|
|
61
69
|
let baselineMembers = $state<string[]>(
|
|
62
70
|
untrack(() => {
|
|
63
71
|
if (project) return [...project.members];
|
|
@@ -80,9 +88,16 @@
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
const appsDirty = $derived(!arrayEq(appAllowlist, baselineApps));
|
|
91
|
+
const shardsDirty = $derived(!arrayEq(shardAllowlist, baselineShards));
|
|
83
92
|
const membersDirty = $derived(!arrayEq(members, baselineMembers));
|
|
84
93
|
const mountsDirty = $derived(!setEq(mountAttached, baselineMounts));
|
|
85
94
|
|
|
95
|
+
const systemShards = $derived(
|
|
96
|
+
listRegisteredShards()
|
|
97
|
+
.filter((m) => m.kind === 'system')
|
|
98
|
+
.sort((a, b) => a.label.localeCompare(b.label)),
|
|
99
|
+
);
|
|
100
|
+
|
|
86
101
|
async function save() {
|
|
87
102
|
if (!name.trim()) {
|
|
88
103
|
error = 'Name is required';
|
|
@@ -95,6 +110,7 @@
|
|
|
95
110
|
description: description.trim() || undefined,
|
|
96
111
|
members,
|
|
97
112
|
appAllowlist,
|
|
113
|
+
shardAllowlist,
|
|
98
114
|
};
|
|
99
115
|
try {
|
|
100
116
|
const saved = isEdit && project
|
|
@@ -234,10 +250,10 @@
|
|
|
234
250
|
|
|
235
251
|
<div class="tabs">
|
|
236
252
|
<TabbedPanel
|
|
237
|
-
labels={['Apps', 'Users', 'Mounts']}
|
|
253
|
+
labels={['Apps', 'Shards', 'Users', 'Mounts']}
|
|
238
254
|
{activeTab}
|
|
239
255
|
onActiveChange={(i) => (activeTab = i)}
|
|
240
|
-
dirty={[appsDirty, membersDirty, mountsDirty]}
|
|
256
|
+
dirty={[appsDirty, shardsDirty, membersDirty, mountsDirty]}
|
|
241
257
|
body={tabBody}
|
|
242
258
|
/>
|
|
243
259
|
</div>
|
|
@@ -265,6 +281,19 @@
|
|
|
265
281
|
<AppPicker bind:value={appAllowlist} disabled={saving} />
|
|
266
282
|
</label>
|
|
267
283
|
{:else if i === 1}
|
|
284
|
+
<label class="field">
|
|
285
|
+
<span>Services</span>
|
|
286
|
+
<ShardPicker bind:value={shardAllowlist} disabled={saving} />
|
|
287
|
+
</label>
|
|
288
|
+
<details class="system-block">
|
|
289
|
+
<summary>System (always allowed)</summary>
|
|
290
|
+
<ul class="system-list">
|
|
291
|
+
{#each systemShards as m (m.id)}
|
|
292
|
+
<li><span class="system-label">{m.label}</span><span class="system-id">{m.id}</span></li>
|
|
293
|
+
{/each}
|
|
294
|
+
</ul>
|
|
295
|
+
</details>
|
|
296
|
+
{:else if i === 2}
|
|
268
297
|
<label class="field">
|
|
269
298
|
<span>Members</span>
|
|
270
299
|
<UserPicker bind:value={members} disabled={saving} />
|
|
@@ -385,4 +414,15 @@
|
|
|
385
414
|
}
|
|
386
415
|
.actions button.danger { margin-left: auto; color: var(--sh3-error, #c33); }
|
|
387
416
|
.actions button:disabled { opacity: 0.5; cursor: not-allowed; }
|
|
417
|
+
.system-block { margin-top: 16px; font-size: 13px; }
|
|
418
|
+
.system-block summary {
|
|
419
|
+
cursor: pointer;
|
|
420
|
+
color: var(--sh3-fg-muted);
|
|
421
|
+
padding: 4px 0;
|
|
422
|
+
user-select: none;
|
|
423
|
+
}
|
|
424
|
+
.system-list { list-style: none; margin: 4px 0 0; padding: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
425
|
+
.system-list li { display: flex; justify-content: space-between; gap: 8px; color: var(--sh3-fg-muted); padding: 4px 0; }
|
|
426
|
+
.system-id { font-size: 11px; font-family: var(--sh3-font-mono, monospace); }
|
|
427
|
+
.system-label { font-weight: 500; }
|
|
388
428
|
</style>
|