sh3-core 0.19.6 → 0.20.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/app/admin/AuthSettingsView.svelte +3 -9
- package/dist/app/admin/MountsView.svelte +276 -0
- package/dist/app/admin/MountsView.svelte.d.ts +3 -0
- package/dist/app/admin/SystemView.svelte +6 -6
- package/dist/app/admin/UsersView.svelte +103 -7
- package/dist/app/admin/adminApp.js +1 -0
- package/dist/app/admin/adminShard.svelte.js +10 -0
- package/dist/apps/lifecycle.js +1 -0
- package/dist/apps/types.d.ts +7 -0
- package/dist/assets/iconIds.generated.d.ts +1 -1
- package/dist/assets/iconIds.generated.js +1 -0
- package/dist/assets/icons.svg +5 -0
- package/dist/auth/admin-users.svelte.js +2 -1
- package/dist/auth/auth.svelte.d.ts +4 -5
- package/dist/auth/auth.svelte.js +5 -6
- package/dist/auth/types.d.ts +0 -2
- package/dist/chrome/CompactChrome.svelte +25 -6
- package/dist/chrome/FloatsSheet.svelte +7 -32
- package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
- package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
- package/dist/chrome/MenuSheet.svelte +154 -148
- package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
- package/dist/chrome/MenuSheet.svelte.test.js +24 -12
- package/dist/createShell.js +32 -21
- package/dist/createShell.remoteAuth.test.js +9 -3
- package/dist/documents/backends.d.ts +12 -0
- package/dist/documents/backends.js +230 -3
- package/dist/documents/backends.test.js +147 -1
- package/dist/documents/browse.d.ts +18 -1
- package/dist/documents/browse.js +40 -7
- package/dist/documents/browse.test.js +35 -35
- package/dist/documents/config.d.ts +6 -0
- package/dist/documents/config.js +18 -1
- package/dist/documents/handle.js +65 -17
- package/dist/documents/handle.test.js +88 -1
- package/dist/documents/http-backend.d.ts +6 -0
- package/dist/documents/http-backend.js +71 -2
- package/dist/documents/http-backend.test.js +51 -1
- package/dist/documents/index.d.ts +2 -2
- package/dist/documents/index.js +1 -1
- package/dist/documents/picker-api.d.ts +4 -2
- package/dist/documents/picker-api.test.d.ts +1 -1
- package/dist/documents/picker-api.test.js +89 -59
- package/dist/documents/picker-primitive.d.ts +4 -0
- package/dist/documents/picker-primitive.js +27 -29
- package/dist/documents/types.d.ts +93 -19
- package/dist/documents/types.js +6 -0
- package/dist/layout/presets.test.js +4 -4
- package/dist/layout/types.d.ts +1 -1
- package/dist/layouts-shard/LayoutsSection.svelte +3 -16
- package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
- package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
- package/dist/primitives/widgets/DocumentFilePicker.svelte +27 -5
- package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
- package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
- package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
- package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
- package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
- package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
- package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
- package/dist/primitives/widgets/PickerList.svelte +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +419 -35
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
- package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
- package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -0
- package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
- package/dist/projects-shard/ProjectManage.svelte +197 -28
- package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
- package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
- package/dist/projects-shard/ProjectsSection.svelte +3 -16
- package/dist/projects-shard/projectsApi.js +2 -1
- package/dist/registry/permission-descriptions.js +4 -0
- package/dist/server-shard/types.d.ts +21 -0
- package/dist/sh3Api/headless.js +10 -0
- package/dist/sh3core-shard/HomeSection.svelte +107 -0
- package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
- package/dist/sh3core-shard/Sh3Home.svelte +9 -23
- package/dist/shards/activate.svelte.d.ts +4 -0
- package/dist/shards/activate.svelte.js +11 -3
- package/dist/shards/types.d.ts +7 -0
- package/dist/shell-shard/Terminal.svelte +4 -1
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/dispatch.d.ts +2 -0
- package/dist/shell-shard/dispatch.js +2 -0
- package/dist/shell-shard/manifest.js +7 -1
- package/dist/shell-shard/shellShard.svelte.js +1 -1
- package/dist/shell-shard/tenant-fs-client.js +2 -1
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +35 -0
- package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cat.test.js +49 -0
- package/dist/shell-shard/verbs/index.js +12 -0
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +48 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +64 -0
- package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
- package/dist/shell-shard/verbs/mkdir.js +30 -0
- package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mkdir.test.js +48 -0
- package/dist/shell-shard/verbs/mv.d.ts +2 -0
- package/dist/shell-shard/verbs/mv.js +33 -0
- package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mv.test.js +55 -0
- package/dist/shell-shard/verbs/rm.d.ts +2 -0
- package/dist/shell-shard/verbs/rm.js +28 -0
- package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
- package/dist/shell-shard/verbs/rm.test.js +47 -0
- package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
- package/dist/shell-shard/verbs/scope-parse.js +33 -0
- package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
- package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
- package/dist/shell-shard/verbs/xfer.d.ts +2 -0
- package/dist/shell-shard/verbs/xfer.js +101 -0
- package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
- package/dist/shell-shard/verbs/xfer.test.js +96 -0
- package/dist/transport/apiFetch.js +12 -5
- package/dist/verbs/types.d.ts +18 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import { mount, unmount, tick } from 'svelte';
|
|
3
|
+
import ProjectManage from './ProjectManage.svelte';
|
|
4
|
+
import { __resetAdminUsersForTest } from '../auth/admin-users.svelte';
|
|
5
|
+
let host;
|
|
6
|
+
let cmp = null;
|
|
7
|
+
let originalFetch;
|
|
8
|
+
function makeProject(overrides = {}) {
|
|
9
|
+
return Object.assign({ id: 'acme-1234', name: 'Acme', description: '', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 0 }, overrides);
|
|
10
|
+
}
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
__resetAdminUsersForTest();
|
|
13
|
+
host = document.createElement('div');
|
|
14
|
+
document.body.appendChild(host);
|
|
15
|
+
originalFetch = globalThis.fetch;
|
|
16
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
17
|
+
const url = String(input);
|
|
18
|
+
if (url.endsWith('/api/admin/mounts'))
|
|
19
|
+
return new Response('[]', { status: 200 });
|
|
20
|
+
if (url.includes('/api/admin/tenants/'))
|
|
21
|
+
return new Response('[]', { status: 200 });
|
|
22
|
+
if (url.endsWith('/api/admin/users'))
|
|
23
|
+
return new Response('[]', { status: 200 });
|
|
24
|
+
return new Response('null', { status: 200 });
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
if (cmp) {
|
|
29
|
+
unmount(cmp);
|
|
30
|
+
cmp = null;
|
|
31
|
+
}
|
|
32
|
+
host.remove();
|
|
33
|
+
globalThis.fetch = originalFetch;
|
|
34
|
+
});
|
|
35
|
+
describe('ProjectManage tabs', () => {
|
|
36
|
+
it('renders three tabs: Apps, Users, Mounts', async () => {
|
|
37
|
+
cmp = mount(ProjectManage, {
|
|
38
|
+
target: host,
|
|
39
|
+
props: { project: makeProject(), onClose: () => { } },
|
|
40
|
+
});
|
|
41
|
+
await tick();
|
|
42
|
+
const labels = Array.from(host.querySelectorAll('[role="tab"]')).map((el) => { var _a, _b; return (_b = (_a = el.textContent) === null || _a === void 0 ? void 0 : _a.trim()) !== null && _b !== void 0 ? _b : ''; });
|
|
43
|
+
expect(labels).toEqual(['Apps', 'Users', 'Mounts']);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe('ProjectManage mount fetch', () => {
|
|
47
|
+
it('on edit open, fetches /api/admin/mounts and the project attachments', async () => {
|
|
48
|
+
const calls = [];
|
|
49
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
50
|
+
const url = String(input);
|
|
51
|
+
calls.push(url);
|
|
52
|
+
if (url.endsWith('/api/admin/users'))
|
|
53
|
+
return new Response('[]', { status: 200 });
|
|
54
|
+
if (url.endsWith('/api/admin/mounts')) {
|
|
55
|
+
return new Response(JSON.stringify([
|
|
56
|
+
{ id: 'assets', label: 'Assets', status: 'resolved' },
|
|
57
|
+
{ id: 'pkgs', label: 'Packages', status: 'unresolved' },
|
|
58
|
+
]), { status: 200 });
|
|
59
|
+
}
|
|
60
|
+
if (url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
|
|
61
|
+
return new Response(JSON.stringify([{ mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' }]), { status: 200 });
|
|
62
|
+
}
|
|
63
|
+
return new Response('null', { status: 200 });
|
|
64
|
+
});
|
|
65
|
+
cmp = mount(ProjectManage, {
|
|
66
|
+
target: host,
|
|
67
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
68
|
+
});
|
|
69
|
+
await tick();
|
|
70
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
71
|
+
expect(calls).toContain('/api/admin/mounts');
|
|
72
|
+
expect(calls).toContain('/api/admin/tenants/acme-1234/attachments');
|
|
73
|
+
});
|
|
74
|
+
it('on create (no project), does NOT fetch tenant attachments', async () => {
|
|
75
|
+
const calls = [];
|
|
76
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
77
|
+
const url = String(input);
|
|
78
|
+
calls.push(url);
|
|
79
|
+
if (url.endsWith('/api/admin/users'))
|
|
80
|
+
return new Response('[]', { status: 200 });
|
|
81
|
+
if (url.endsWith('/api/admin/mounts'))
|
|
82
|
+
return new Response('[]', { status: 200 });
|
|
83
|
+
return new Response('null', { status: 200 });
|
|
84
|
+
});
|
|
85
|
+
cmp = mount(ProjectManage, {
|
|
86
|
+
target: host,
|
|
87
|
+
props: { project: null, onClose: () => { } },
|
|
88
|
+
});
|
|
89
|
+
await tick();
|
|
90
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
91
|
+
expect(calls).toContain('/api/admin/mounts');
|
|
92
|
+
expect(calls.some((c) => c.includes('/api/admin/tenants/'))).toBe(false);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
describe('ProjectManage mount list', () => {
|
|
96
|
+
it('renders one checkbox per mount with attached state seeded from server', async () => {
|
|
97
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
98
|
+
const url = String(input);
|
|
99
|
+
if (url.endsWith('/api/admin/users'))
|
|
100
|
+
return new Response('[]', { status: 200 });
|
|
101
|
+
if (url.endsWith('/api/admin/mounts')) {
|
|
102
|
+
return new Response(JSON.stringify([
|
|
103
|
+
{ id: 'assets', label: 'Assets', status: 'resolved' },
|
|
104
|
+
{ id: 'pkgs', label: 'Packages', status: 'resolved' },
|
|
105
|
+
]), { status: 200 });
|
|
106
|
+
}
|
|
107
|
+
if (url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
|
|
108
|
+
return new Response(JSON.stringify([
|
|
109
|
+
{ mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' },
|
|
110
|
+
]), { status: 200 });
|
|
111
|
+
}
|
|
112
|
+
return new Response('null', { status: 200 });
|
|
113
|
+
});
|
|
114
|
+
cmp = mount(ProjectManage, {
|
|
115
|
+
target: host,
|
|
116
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
117
|
+
});
|
|
118
|
+
await tick();
|
|
119
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
120
|
+
await tick();
|
|
121
|
+
const tabs = host.querySelectorAll('[role="tab"]');
|
|
122
|
+
tabs[2].click();
|
|
123
|
+
await tick();
|
|
124
|
+
const rows = host.querySelectorAll('[data-mount-row]');
|
|
125
|
+
expect(rows.length).toBe(2);
|
|
126
|
+
const assetsCb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
|
|
127
|
+
const pkgsCb = host.querySelector('[data-mount-row="pkgs"] input[type="checkbox"]');
|
|
128
|
+
expect(assetsCb.checked).toBe(true);
|
|
129
|
+
expect(pkgsCb.checked).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
it('toggling a checkbox mutates mountAttached', async () => {
|
|
132
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
133
|
+
const url = String(input);
|
|
134
|
+
if (url.endsWith('/api/admin/users'))
|
|
135
|
+
return new Response('[]', { status: 200 });
|
|
136
|
+
if (url.endsWith('/api/admin/mounts')) {
|
|
137
|
+
return new Response(JSON.stringify([
|
|
138
|
+
{ id: 'assets', label: 'Assets', status: 'resolved' },
|
|
139
|
+
]), { status: 200 });
|
|
140
|
+
}
|
|
141
|
+
if (url.includes('/api/admin/tenants/'))
|
|
142
|
+
return new Response('[]', { status: 200 });
|
|
143
|
+
return new Response('null', { status: 200 });
|
|
144
|
+
});
|
|
145
|
+
cmp = mount(ProjectManage, {
|
|
146
|
+
target: host,
|
|
147
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
148
|
+
});
|
|
149
|
+
await tick();
|
|
150
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
151
|
+
await tick();
|
|
152
|
+
const tabs = host.querySelectorAll('[role="tab"]');
|
|
153
|
+
tabs[2].click();
|
|
154
|
+
await tick();
|
|
155
|
+
const cb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
|
|
156
|
+
expect(cb.checked).toBe(false);
|
|
157
|
+
cb.click();
|
|
158
|
+
await tick();
|
|
159
|
+
expect(cb.checked).toBe(true);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe('ProjectManage dirty indicators', () => {
|
|
163
|
+
it('shows the dirty dot on the Mounts tab after toggling a checkbox', async () => {
|
|
164
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
165
|
+
const url = String(input);
|
|
166
|
+
if (url.endsWith('/api/admin/users'))
|
|
167
|
+
return new Response('[]', { status: 200 });
|
|
168
|
+
if (url.endsWith('/api/admin/mounts')) {
|
|
169
|
+
return new Response(JSON.stringify([
|
|
170
|
+
{ id: 'assets', label: 'Assets', status: 'resolved' },
|
|
171
|
+
]), { status: 200 });
|
|
172
|
+
}
|
|
173
|
+
if (url.includes('/api/admin/tenants/'))
|
|
174
|
+
return new Response('[]', { status: 200 });
|
|
175
|
+
return new Response('null', { status: 200 });
|
|
176
|
+
});
|
|
177
|
+
cmp = mount(ProjectManage, {
|
|
178
|
+
target: host,
|
|
179
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
180
|
+
});
|
|
181
|
+
await tick();
|
|
182
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
183
|
+
await tick();
|
|
184
|
+
const tabs = host.querySelectorAll('[role="tab"]');
|
|
185
|
+
expect(host.querySelectorAll('.tab-dirty').length).toBe(0);
|
|
186
|
+
tabs[2].click();
|
|
187
|
+
await tick();
|
|
188
|
+
const cb = host.querySelector('[data-mount-row="assets"] input[type="checkbox"]');
|
|
189
|
+
cb.click();
|
|
190
|
+
await tick();
|
|
191
|
+
const dirtyDot = tabs[2].querySelector('.tab-dirty');
|
|
192
|
+
expect(dirtyDot).not.toBeNull();
|
|
193
|
+
expect(tabs[0].querySelector('.tab-dirty')).toBeNull();
|
|
194
|
+
expect(tabs[1].querySelector('.tab-dirty')).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
describe('ProjectManage save mount diff', () => {
|
|
198
|
+
it('on edit save, fires POST for added mounts and DELETE for removed mounts', async () => {
|
|
199
|
+
const calls = [];
|
|
200
|
+
globalThis.fetch = vi.fn(async (input, init) => {
|
|
201
|
+
var _a;
|
|
202
|
+
const url = String(input);
|
|
203
|
+
const method = ((_a = init === null || init === void 0 ? void 0 : init.method) !== null && _a !== void 0 ? _a : 'GET').toUpperCase();
|
|
204
|
+
const body = (init === null || init === void 0 ? void 0 : init.body) ? JSON.parse(init.body) : undefined;
|
|
205
|
+
calls.push({ method, url, body });
|
|
206
|
+
if (url.endsWith('/api/admin/users'))
|
|
207
|
+
return new Response('[]', { status: 200 });
|
|
208
|
+
if (method === 'GET' && url.endsWith('/api/admin/mounts')) {
|
|
209
|
+
return new Response(JSON.stringify([
|
|
210
|
+
{ id: 'assets', label: 'Assets', status: 'resolved' },
|
|
211
|
+
{ id: 'pkgs', label: 'Packages', status: 'resolved' },
|
|
212
|
+
]), { status: 200 });
|
|
213
|
+
}
|
|
214
|
+
if (method === 'GET' && url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
|
|
215
|
+
return new Response(JSON.stringify([
|
|
216
|
+
{ mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' },
|
|
217
|
+
]), { status: 200 });
|
|
218
|
+
}
|
|
219
|
+
if (method === 'PATCH' && url === '/api/projects/acme-1234') {
|
|
220
|
+
return new Response(JSON.stringify({ id: 'acme-1234', name: 'Acme', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 1 }), { status: 200 });
|
|
221
|
+
}
|
|
222
|
+
if (url === '/api/admin/mount-attachments') {
|
|
223
|
+
return new Response('{}', { status: method === 'DELETE' ? 204 : 201 });
|
|
224
|
+
}
|
|
225
|
+
if (method === 'GET' && url === '/api/projects') {
|
|
226
|
+
return new Response(JSON.stringify({ projects: [] }), { status: 200 });
|
|
227
|
+
}
|
|
228
|
+
return new Response('null', { status: 200 });
|
|
229
|
+
});
|
|
230
|
+
cmp = mount(ProjectManage, {
|
|
231
|
+
target: host,
|
|
232
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
233
|
+
});
|
|
234
|
+
await tick();
|
|
235
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
236
|
+
await tick();
|
|
237
|
+
const tabs = host.querySelectorAll('[role="tab"]');
|
|
238
|
+
tabs[2].click();
|
|
239
|
+
await tick();
|
|
240
|
+
host.querySelector('[data-mount-row="assets"] input').click();
|
|
241
|
+
await tick();
|
|
242
|
+
host.querySelector('[data-mount-row="pkgs"] input').click();
|
|
243
|
+
await tick();
|
|
244
|
+
const saveBtn = Array.from(host.querySelectorAll('button.primary'))[0];
|
|
245
|
+
saveBtn.click();
|
|
246
|
+
await tick();
|
|
247
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
248
|
+
await tick();
|
|
249
|
+
const patchIdx = calls.findIndex((c) => c.method === 'PATCH' && c.url === '/api/projects/acme-1234');
|
|
250
|
+
const attachCalls = calls.filter((c) => c.url === '/api/admin/mount-attachments');
|
|
251
|
+
expect(patchIdx).toBeGreaterThanOrEqual(0);
|
|
252
|
+
expect(attachCalls.length).toBe(2);
|
|
253
|
+
const post = attachCalls.find((c) => c.method === 'POST');
|
|
254
|
+
const del = attachCalls.find((c) => c.method === 'DELETE');
|
|
255
|
+
expect(post === null || post === void 0 ? void 0 : post.body).toEqual({ mountId: 'pkgs', tenantId: 'acme-1234' });
|
|
256
|
+
expect(del === null || del === void 0 ? void 0 : del.body).toEqual({ mountId: 'assets', tenantId: 'acme-1234' });
|
|
257
|
+
});
|
|
258
|
+
it('on save with no mount diff, fires zero attachment calls', async () => {
|
|
259
|
+
const calls = [];
|
|
260
|
+
globalThis.fetch = vi.fn(async (input, init) => {
|
|
261
|
+
var _a;
|
|
262
|
+
const url = String(input);
|
|
263
|
+
const method = ((_a = init === null || init === void 0 ? void 0 : init.method) !== null && _a !== void 0 ? _a : 'GET').toUpperCase();
|
|
264
|
+
calls.push({ method, url });
|
|
265
|
+
if (url.endsWith('/api/admin/users'))
|
|
266
|
+
return new Response('[]', { status: 200 });
|
|
267
|
+
if (method === 'GET' && url.endsWith('/api/admin/mounts')) {
|
|
268
|
+
return new Response(JSON.stringify([{ id: 'assets', label: 'Assets', status: 'resolved' }]), { status: 200 });
|
|
269
|
+
}
|
|
270
|
+
if (method === 'GET' && url.endsWith('/api/admin/tenants/acme-1234/attachments')) {
|
|
271
|
+
return new Response(JSON.stringify([{ mountId: 'assets', tenantId: 'acme-1234', attachedAt: '' }]), { status: 200 });
|
|
272
|
+
}
|
|
273
|
+
if (method === 'PATCH' && url === '/api/projects/acme-1234') {
|
|
274
|
+
return new Response(JSON.stringify({ id: 'acme-1234', name: 'Acme', members: ['user-1'], appAllowlist: [], createdBy: 'user-1', createdAt: 0, updatedAt: 1 }), { status: 200 });
|
|
275
|
+
}
|
|
276
|
+
if (method === 'GET' && url === '/api/projects') {
|
|
277
|
+
return new Response(JSON.stringify({ projects: [] }), { status: 200 });
|
|
278
|
+
}
|
|
279
|
+
return new Response('null', { status: 200 });
|
|
280
|
+
});
|
|
281
|
+
cmp = mount(ProjectManage, {
|
|
282
|
+
target: host,
|
|
283
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
284
|
+
});
|
|
285
|
+
await tick();
|
|
286
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
287
|
+
await tick();
|
|
288
|
+
const saveBtn = host.querySelector('button.primary');
|
|
289
|
+
saveBtn.click();
|
|
290
|
+
await tick();
|
|
291
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
292
|
+
await tick();
|
|
293
|
+
expect(calls.some((c) => c.url === '/api/admin/mount-attachments')).toBe(false);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
describe('ProjectManage mount empty state', () => {
|
|
297
|
+
it('shows the empty message when no mounts are configured', async () => {
|
|
298
|
+
globalThis.fetch = vi.fn(async (input) => {
|
|
299
|
+
const url = String(input);
|
|
300
|
+
if (url.endsWith('/api/admin/users'))
|
|
301
|
+
return new Response('[]', { status: 200 });
|
|
302
|
+
if (url.endsWith('/api/admin/mounts'))
|
|
303
|
+
return new Response('[]', { status: 200 });
|
|
304
|
+
if (url.includes('/api/admin/tenants/'))
|
|
305
|
+
return new Response('[]', { status: 200 });
|
|
306
|
+
return new Response('null', { status: 200 });
|
|
307
|
+
});
|
|
308
|
+
cmp = mount(ProjectManage, {
|
|
309
|
+
target: host,
|
|
310
|
+
props: { project: makeProject({ id: 'acme-1234' }), onClose: () => { } },
|
|
311
|
+
});
|
|
312
|
+
await tick();
|
|
313
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
314
|
+
await tick();
|
|
315
|
+
const tabs = host.querySelectorAll('[role="tab"]');
|
|
316
|
+
tabs[2].click();
|
|
317
|
+
await tick();
|
|
318
|
+
expect(host.textContent).toContain('No mounts configured. Create mounts first.');
|
|
319
|
+
});
|
|
320
|
+
});
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { projectsState, openProjectManage } from './projectsShard.svelte';
|
|
12
12
|
import { sessionState, setActiveProjectId } from '../projects/session-state.svelte';
|
|
13
13
|
import { isAdmin } from '../auth/auth.svelte';
|
|
14
|
+
import HomeSection from '../sh3core-shard/HomeSection.svelte';
|
|
14
15
|
|
|
15
16
|
const visible = $derived(projectsState.projects.length > 0);
|
|
16
17
|
const activeId = $derived(sessionState.activeProjectId);
|
|
@@ -28,8 +29,7 @@
|
|
|
28
29
|
</script>
|
|
29
30
|
|
|
30
31
|
{#if visible}
|
|
31
|
-
<
|
|
32
|
-
<h2 class="projects-heading">Projects</h2>
|
|
32
|
+
<HomeSection title="Projects" persistKey="projects">
|
|
33
33
|
<div class="projects-grid">
|
|
34
34
|
{#each projectsState.projects as project (project.id)}
|
|
35
35
|
<div class="project-card-wrap">
|
|
@@ -55,23 +55,10 @@
|
|
|
55
55
|
</div>
|
|
56
56
|
{/each}
|
|
57
57
|
</div>
|
|
58
|
-
</
|
|
58
|
+
</HomeSection>
|
|
59
59
|
{/if}
|
|
60
60
|
|
|
61
61
|
<style>
|
|
62
|
-
.projects-section {
|
|
63
|
-
width: 100%;
|
|
64
|
-
max-width: 720px;
|
|
65
|
-
margin-bottom: 28px;
|
|
66
|
-
}
|
|
67
|
-
.projects-heading {
|
|
68
|
-
font-size: 13px;
|
|
69
|
-
font-weight: 600;
|
|
70
|
-
text-transform: uppercase;
|
|
71
|
-
letter-spacing: 0.06em;
|
|
72
|
-
color: var(--sh3-fg-subtle);
|
|
73
|
-
margin: 0 0 12px;
|
|
74
|
-
}
|
|
75
62
|
.projects-grid {
|
|
76
63
|
display: grid;
|
|
77
64
|
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* Returns plain JSON objects; the shard layer is responsible for keeping
|
|
5
5
|
* a reactive copy and for converting these to actions / views.
|
|
6
6
|
*/
|
|
7
|
+
import { apiFetch } from '../transport/apiFetch';
|
|
7
8
|
async function jsonFetch(url, init) {
|
|
8
9
|
var _a;
|
|
9
|
-
const res = await
|
|
10
|
+
const res = await apiFetch(url, init);
|
|
10
11
|
if (!res.ok)
|
|
11
12
|
throw new Error(`${(_a = init === null || init === void 0 ? void 0 : init.method) !== null && _a !== void 0 ? _a : 'GET'} ${url} failed: ${res.status}`);
|
|
12
13
|
return res.json();
|
|
@@ -27,6 +27,10 @@ export const PERMISSION_DESCRIPTIONS = {
|
|
|
27
27
|
title: 'Sync documents with peers',
|
|
28
28
|
description: 'Participate in cross-peer document synchronization.',
|
|
29
29
|
},
|
|
30
|
+
'documents:mount': {
|
|
31
|
+
title: 'Manage document mounts',
|
|
32
|
+
description: 'Define and manage filesystem mount points that feed into the document API.',
|
|
33
|
+
},
|
|
30
34
|
'sync:peer': {
|
|
31
35
|
title: 'Act as a sync peer',
|
|
32
36
|
description: 'Exchange document updates with remote peers.',
|
|
@@ -95,6 +95,27 @@ export interface ServerShardContext {
|
|
|
95
95
|
* Absent => 'primary' behavior at the store.
|
|
96
96
|
*/
|
|
97
97
|
setPeerRole(tenant: string, role: 'primary' | 'replica'): void;
|
|
98
|
+
/**
|
|
99
|
+
* Translate an SH3 document path to a real filesystem path on the host.
|
|
100
|
+
*
|
|
101
|
+
* Symmetric with `documents(tenant)` — the same `(shardId, path)` pair
|
|
102
|
+
* resolves through both APIs. Use this when a server shard needs to
|
|
103
|
+
* hand the path to a native consumer (spawn, fs.watch, streaming I/O)
|
|
104
|
+
* rather than read bytes via `documents().read()`.
|
|
105
|
+
*
|
|
106
|
+
* - `shardId === 'mounts'` resolves through the host's MountedPathResolver
|
|
107
|
+
* and surfaces the real on-disk path of the mount. Throws on unknown,
|
|
108
|
+
* unattached, or otherwise unresolvable mounts.
|
|
109
|
+
* - Any other `shardId` returns `<dataDir>/docs/<tenant>/<shardId>/<path>`,
|
|
110
|
+
* the canonical native-doc location. The file may or may not exist —
|
|
111
|
+
* use `documents(tenant).exists` to check.
|
|
112
|
+
*
|
|
113
|
+
* Note: once a shard takes the fs path and does its own I/O, the doc store
|
|
114
|
+
* loses its grip on conflict bookkeeping for those operations. Safe for
|
|
115
|
+
* mount-backed assets (read-mostly, outside the sync envelope); use with
|
|
116
|
+
* care for native shard docs that participate in Mode A/B sync.
|
|
117
|
+
*/
|
|
118
|
+
resolveFsPath(tenant: string, shardId: string, path: string): string;
|
|
98
119
|
}
|
|
99
120
|
/**
|
|
100
121
|
* The interface a server shard bundle must default-export.
|
package/dist/sh3Api/headless.js
CHANGED
|
@@ -31,6 +31,8 @@ import { listFields as listFieldsImpl, getField as getFieldImpl, setField as set
|
|
|
31
31
|
import { attachDecoration as attachDecorationImpl } from '../fields/decoration';
|
|
32
32
|
import { onChange as onContributionsChange } from '../contributions';
|
|
33
33
|
import { FIELD_POINT_ID, WALKER_SHARD_ID } from '../fields/types';
|
|
34
|
+
import { getActiveScopeId, getPersonalScopeId } from '../documents/config';
|
|
35
|
+
import { projectsState } from '../projects-shard/projectsShard.svelte';
|
|
34
36
|
const KNOWN_ZONES = ['ephemeral', 'session', 'workspace', 'user'];
|
|
35
37
|
function collectTabEntries(node) {
|
|
36
38
|
if (node.type === 'tabs') {
|
|
@@ -317,6 +319,14 @@ export function makeSh3Api(opts) {
|
|
|
317
319
|
return listViewsImpl();
|
|
318
320
|
},
|
|
319
321
|
fields,
|
|
322
|
+
getActiveScope() {
|
|
323
|
+
const id = getActiveScopeId();
|
|
324
|
+
const personalId = getPersonalScopeId();
|
|
325
|
+
return { id, isProject: id !== personalId, personalId };
|
|
326
|
+
},
|
|
327
|
+
listProjects() {
|
|
328
|
+
return projectsState.projects.map((p) => ({ id: p.id, name: p.name }));
|
|
329
|
+
},
|
|
320
330
|
};
|
|
321
331
|
}
|
|
322
332
|
/** @deprecated Renamed to makeSh3Api(opts?). Kept for one minor cycle. */
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Collapsible section wrapper used by Sh3Home and section components.
|
|
4
|
+
* Title row is a button that toggles the content; expanded state is
|
|
5
|
+
* persisted to localStorage under `sh3:home-section:<persistKey>` so it
|
|
6
|
+
* survives reloads. Heading chrome (typography, spacing, max-width) is
|
|
7
|
+
* centralised here so individual sections only supply their grid/content.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Snippet } from 'svelte';
|
|
11
|
+
import iconsUrl from '../assets/icons.svg';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
title: string;
|
|
15
|
+
persistKey: string;
|
|
16
|
+
defaultExpanded?: boolean;
|
|
17
|
+
children: Snippet;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const { title, persistKey, defaultExpanded = true, children }: Props = $props();
|
|
21
|
+
|
|
22
|
+
const storageKey = $derived(`sh3:home-section:${persistKey}`);
|
|
23
|
+
const sectionId = $derived(`sh3-home-section-${persistKey}`);
|
|
24
|
+
|
|
25
|
+
function readInitial(): boolean {
|
|
26
|
+
if (typeof localStorage === 'undefined') return defaultExpanded;
|
|
27
|
+
const raw = localStorage.getItem(`sh3:home-section:${persistKey}`);
|
|
28
|
+
if (raw === '1') return true;
|
|
29
|
+
if (raw === '0') return false;
|
|
30
|
+
return defaultExpanded;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let expanded = $state(readInitial());
|
|
34
|
+
|
|
35
|
+
function toggle(): void {
|
|
36
|
+
expanded = !expanded;
|
|
37
|
+
if (typeof localStorage !== 'undefined') {
|
|
38
|
+
localStorage.setItem(storageKey, expanded ? '1' : '0');
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
</script>
|
|
42
|
+
|
|
43
|
+
<section class="home-section">
|
|
44
|
+
<button
|
|
45
|
+
type="button"
|
|
46
|
+
class="home-section-header"
|
|
47
|
+
aria-expanded={expanded}
|
|
48
|
+
aria-controls={sectionId}
|
|
49
|
+
onclick={toggle}
|
|
50
|
+
>
|
|
51
|
+
<svg class="home-section-chevron" class:open={expanded} aria-hidden="true">
|
|
52
|
+
<use href="{iconsUrl}#chevron-right" />
|
|
53
|
+
</svg>
|
|
54
|
+
<span class="home-section-title">{title}</span>
|
|
55
|
+
</button>
|
|
56
|
+
{#if expanded}
|
|
57
|
+
<div id={sectionId} class="home-section-body">
|
|
58
|
+
{@render children()}
|
|
59
|
+
</div>
|
|
60
|
+
{/if}
|
|
61
|
+
</section>
|
|
62
|
+
|
|
63
|
+
<style>
|
|
64
|
+
.home-section {
|
|
65
|
+
width: 100%;
|
|
66
|
+
max-width: 720px;
|
|
67
|
+
margin-bottom: 28px;
|
|
68
|
+
}
|
|
69
|
+
.home-section-header {
|
|
70
|
+
display: flex;
|
|
71
|
+
align-items: center;
|
|
72
|
+
gap: 6px;
|
|
73
|
+
width: 100%;
|
|
74
|
+
margin: 0 0 12px;
|
|
75
|
+
padding: 0;
|
|
76
|
+
background: transparent;
|
|
77
|
+
border: none;
|
|
78
|
+
color: var(--sh3-fg-subtle);
|
|
79
|
+
font: inherit;
|
|
80
|
+
font-size: 13px;
|
|
81
|
+
font-weight: 600;
|
|
82
|
+
text-transform: uppercase;
|
|
83
|
+
letter-spacing: 0.06em;
|
|
84
|
+
text-align: left;
|
|
85
|
+
cursor: pointer;
|
|
86
|
+
}
|
|
87
|
+
.home-section-header:hover {
|
|
88
|
+
color: var(--sh3-fg);
|
|
89
|
+
}
|
|
90
|
+
.home-section-header:focus-visible {
|
|
91
|
+
outline: 2px solid var(--sh3-accent);
|
|
92
|
+
outline-offset: 2px;
|
|
93
|
+
border-radius: 2px;
|
|
94
|
+
}
|
|
95
|
+
.home-section-chevron {
|
|
96
|
+
width: 12px;
|
|
97
|
+
height: 12px;
|
|
98
|
+
flex-shrink: 0;
|
|
99
|
+
transition: transform 120ms ease;
|
|
100
|
+
}
|
|
101
|
+
.home-section-chevron.open {
|
|
102
|
+
transform: rotate(90deg);
|
|
103
|
+
}
|
|
104
|
+
.home-section-title {
|
|
105
|
+
flex: 1;
|
|
106
|
+
}
|
|
107
|
+
</style>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
interface Props {
|
|
3
|
+
title: string;
|
|
4
|
+
persistKey: string;
|
|
5
|
+
defaultExpanded?: boolean;
|
|
6
|
+
children: Snippet;
|
|
7
|
+
}
|
|
8
|
+
declare const HomeSection: import("svelte").Component<Props, {}, "">;
|
|
9
|
+
type HomeSection = ReturnType<typeof HomeSection>;
|
|
10
|
+
export default HomeSection;
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { listRegisteredApps, launchApp, isAdmin, VERSION } from '../api';
|
|
11
11
|
import Sh3Title from './Sh3Title.svelte';
|
|
12
|
+
import HomeSection from './HomeSection.svelte';
|
|
12
13
|
import ProjectsSection from '../projects-shard/ProjectsSection.svelte';
|
|
13
14
|
import LayoutsSection from '../layouts-shard/LayoutsSection.svelte';
|
|
14
15
|
import { sessionState } from '../projects/session-state.svelte';
|
|
@@ -56,7 +57,7 @@
|
|
|
56
57
|
apps.filter((m) => !m.admin && matches(m, filter) && inAllowlist(m.id)),
|
|
57
58
|
);
|
|
58
59
|
const adminApps = $derived(apps.filter((m) => m.admin && matches(m, filter)));
|
|
59
|
-
const totalVisible = $derived(userApps.length + (elevated ? adminApps.length : 0));
|
|
60
|
+
const totalVisible = $derived(userApps.length + (elevated && !activeProject ? adminApps.length : 0));
|
|
60
61
|
</script>
|
|
61
62
|
|
|
62
63
|
<div class="sh3-home">
|
|
@@ -83,11 +84,8 @@
|
|
|
83
84
|
|
|
84
85
|
<ProjectsSection />
|
|
85
86
|
|
|
86
|
-
<LayoutsSection />
|
|
87
|
-
|
|
88
87
|
{#if userApps.length > 0}
|
|
89
|
-
<
|
|
90
|
-
<h2 class="sh3-home-section-title">Apps</h2>
|
|
88
|
+
<HomeSection title="Apps" persistKey="apps">
|
|
91
89
|
<div class="sh3-home-grid">
|
|
92
90
|
{#each userApps as manifest (manifest.id)}
|
|
93
91
|
{@const appearance = getAppearance(manifest.id)}
|
|
@@ -107,12 +105,13 @@
|
|
|
107
105
|
</button>
|
|
108
106
|
{/each}
|
|
109
107
|
</div>
|
|
110
|
-
</
|
|
108
|
+
</HomeSection>
|
|
111
109
|
{/if}
|
|
112
110
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
<LayoutsSection />
|
|
112
|
+
|
|
113
|
+
{#if elevated && !activeProject && adminApps.length > 0}
|
|
114
|
+
<HomeSection title="Admin" persistKey="admin">
|
|
116
115
|
<div class="sh3-home-grid">
|
|
117
116
|
{#each adminApps as manifest (manifest.id)}
|
|
118
117
|
{@const appearance = getAppearance(manifest.id)}
|
|
@@ -132,7 +131,7 @@
|
|
|
132
131
|
</button>
|
|
133
132
|
{/each}
|
|
134
133
|
</div>
|
|
135
|
-
</
|
|
134
|
+
</HomeSection>
|
|
136
135
|
{/if}
|
|
137
136
|
|
|
138
137
|
{#if totalVisible === 0}
|
|
@@ -233,19 +232,6 @@
|
|
|
233
232
|
color: var(--sh3-fg-muted);
|
|
234
233
|
font-style: italic;
|
|
235
234
|
}
|
|
236
|
-
.sh3-home-section {
|
|
237
|
-
width: 100%;
|
|
238
|
-
max-width: 720px;
|
|
239
|
-
margin-bottom: 28px;
|
|
240
|
-
}
|
|
241
|
-
.sh3-home-section-title {
|
|
242
|
-
font-size: 13px;
|
|
243
|
-
font-weight: 600;
|
|
244
|
-
text-transform: uppercase;
|
|
245
|
-
letter-spacing: 0.06em;
|
|
246
|
-
color: var(--sh3-fg-subtle);
|
|
247
|
-
margin: 0 0 12px;
|
|
248
|
-
}
|
|
249
235
|
.sh3-home-grid {
|
|
250
236
|
display: grid;
|
|
251
237
|
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
|
@@ -22,6 +22,10 @@ export interface ShardErrorEntry {
|
|
|
22
22
|
timestamp: number;
|
|
23
23
|
}
|
|
24
24
|
export declare const erroredShards: Map<string, ShardErrorEntry>;
|
|
25
|
+
/** Host-only. Register a callback that resolves whether the current scope
|
|
26
|
+
* is a personal tenant or an active project. Wired by createShell after
|
|
27
|
+
* bootstrap. Avoids circular dependencies with session-state. */
|
|
28
|
+
export declare function __setScopeResolver(resolver: (() => 'tenant' | 'project') | null): void;
|
|
25
29
|
/**
|
|
26
30
|
* Register (or re-register) a shard with the framework so it can later be
|
|
27
31
|
* activated. Records the shard in `registeredShards` but does not run
|