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.
Files changed (123) hide show
  1. package/dist/app/admin/AuthSettingsView.svelte +3 -9
  2. package/dist/app/admin/MountsView.svelte +276 -0
  3. package/dist/app/admin/MountsView.svelte.d.ts +3 -0
  4. package/dist/app/admin/SystemView.svelte +6 -6
  5. package/dist/app/admin/UsersView.svelte +103 -7
  6. package/dist/app/admin/adminApp.js +1 -0
  7. package/dist/app/admin/adminShard.svelte.js +10 -0
  8. package/dist/apps/lifecycle.js +1 -0
  9. package/dist/apps/types.d.ts +7 -0
  10. package/dist/assets/iconIds.generated.d.ts +1 -1
  11. package/dist/assets/iconIds.generated.js +1 -0
  12. package/dist/assets/icons.svg +5 -0
  13. package/dist/auth/admin-users.svelte.js +2 -1
  14. package/dist/auth/auth.svelte.d.ts +4 -5
  15. package/dist/auth/auth.svelte.js +5 -6
  16. package/dist/auth/types.d.ts +0 -2
  17. package/dist/chrome/CompactChrome.svelte +25 -6
  18. package/dist/chrome/FloatsSheet.svelte +7 -32
  19. package/dist/chrome/FloatsSheet.svelte.d.ts +1 -2
  20. package/dist/chrome/FloatsSheet.svelte.test.js +8 -14
  21. package/dist/chrome/MenuSheet.svelte +154 -148
  22. package/dist/chrome/MenuSheet.svelte.d.ts +1 -2
  23. package/dist/chrome/MenuSheet.svelte.test.js +24 -12
  24. package/dist/createShell.js +32 -21
  25. package/dist/createShell.remoteAuth.test.js +9 -3
  26. package/dist/documents/backends.d.ts +12 -0
  27. package/dist/documents/backends.js +230 -3
  28. package/dist/documents/backends.test.js +147 -1
  29. package/dist/documents/browse.d.ts +18 -1
  30. package/dist/documents/browse.js +40 -7
  31. package/dist/documents/browse.test.js +35 -35
  32. package/dist/documents/config.d.ts +6 -0
  33. package/dist/documents/config.js +18 -1
  34. package/dist/documents/handle.js +65 -17
  35. package/dist/documents/handle.test.js +88 -1
  36. package/dist/documents/http-backend.d.ts +6 -0
  37. package/dist/documents/http-backend.js +71 -2
  38. package/dist/documents/http-backend.test.js +51 -1
  39. package/dist/documents/index.d.ts +2 -2
  40. package/dist/documents/index.js +1 -1
  41. package/dist/documents/picker-api.d.ts +4 -2
  42. package/dist/documents/picker-api.test.d.ts +1 -1
  43. package/dist/documents/picker-api.test.js +89 -59
  44. package/dist/documents/picker-primitive.d.ts +4 -0
  45. package/dist/documents/picker-primitive.js +27 -29
  46. package/dist/documents/types.d.ts +93 -19
  47. package/dist/documents/types.js +6 -0
  48. package/dist/layout/presets.test.js +4 -4
  49. package/dist/layout/types.d.ts +1 -1
  50. package/dist/layouts-shard/LayoutsSection.svelte +3 -16
  51. package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
  52. package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
  53. package/dist/primitives/widgets/DocumentFilePicker.svelte +27 -5
  54. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
  55. package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
  56. package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
  57. package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
  58. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
  59. package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
  60. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
  61. package/dist/primitives/widgets/PickerList.svelte +1 -0
  62. package/dist/primitives/widgets/_DocumentBrowser.svelte +419 -35
  63. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
  64. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
  65. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
  66. package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
  67. package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -0
  68. package/dist/projects-shard/DeleteProjectDialog.svelte +32 -1
  69. package/dist/projects-shard/ProjectManage.svelte +197 -28
  70. package/dist/projects-shard/ProjectManage.svelte.test.d.ts +1 -0
  71. package/dist/projects-shard/ProjectManage.svelte.test.js +320 -0
  72. package/dist/projects-shard/ProjectsSection.svelte +3 -16
  73. package/dist/projects-shard/projectsApi.js +2 -1
  74. package/dist/registry/permission-descriptions.js +4 -0
  75. package/dist/server-shard/types.d.ts +21 -0
  76. package/dist/sh3Api/headless.js +10 -0
  77. package/dist/sh3core-shard/HomeSection.svelte +107 -0
  78. package/dist/sh3core-shard/HomeSection.svelte.d.ts +10 -0
  79. package/dist/sh3core-shard/Sh3Home.svelte +9 -23
  80. package/dist/shards/activate.svelte.d.ts +4 -0
  81. package/dist/shards/activate.svelte.js +11 -3
  82. package/dist/shards/types.d.ts +7 -0
  83. package/dist/shell-shard/Terminal.svelte +4 -1
  84. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  85. package/dist/shell-shard/dispatch.d.ts +2 -0
  86. package/dist/shell-shard/dispatch.js +2 -0
  87. package/dist/shell-shard/manifest.js +7 -1
  88. package/dist/shell-shard/shellShard.svelte.js +1 -1
  89. package/dist/shell-shard/tenant-fs-client.js +2 -1
  90. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  91. package/dist/shell-shard/verbs/cat.js +35 -0
  92. package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
  93. package/dist/shell-shard/verbs/cat.test.js +49 -0
  94. package/dist/shell-shard/verbs/index.js +12 -0
  95. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  96. package/dist/shell-shard/verbs/ls.js +48 -0
  97. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  98. package/dist/shell-shard/verbs/ls.test.js +64 -0
  99. package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
  100. package/dist/shell-shard/verbs/mkdir.js +30 -0
  101. package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
  102. package/dist/shell-shard/verbs/mkdir.test.js +48 -0
  103. package/dist/shell-shard/verbs/mv.d.ts +2 -0
  104. package/dist/shell-shard/verbs/mv.js +33 -0
  105. package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
  106. package/dist/shell-shard/verbs/mv.test.js +55 -0
  107. package/dist/shell-shard/verbs/rm.d.ts +2 -0
  108. package/dist/shell-shard/verbs/rm.js +28 -0
  109. package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
  110. package/dist/shell-shard/verbs/rm.test.js +47 -0
  111. package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
  112. package/dist/shell-shard/verbs/scope-parse.js +33 -0
  113. package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
  114. package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
  115. package/dist/shell-shard/verbs/xfer.d.ts +2 -0
  116. package/dist/shell-shard/verbs/xfer.js +101 -0
  117. package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
  118. package/dist/shell-shard/verbs/xfer.test.js +96 -0
  119. package/dist/transport/apiFetch.js +12 -5
  120. package/dist/verbs/types.d.ts +18 -0
  121. package/dist/version.d.ts +1 -1
  122. package/dist/version.js +1 -1
  123. package/package.json +1 -1
@@ -0,0 +1,277 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { render, fireEvent, screen } from '@testing-library/svelte';
3
+ import DocumentBrowser from './_DocumentBrowser.svelte';
4
+ function makeHandle() {
5
+ return {
6
+ mkdir: vi.fn().mockResolvedValue(undefined),
7
+ rmdir: vi.fn().mockResolvedValue(undefined),
8
+ renameFolder: vi.fn().mockResolvedValue(undefined),
9
+ rename: vi.fn().mockResolvedValue(undefined),
10
+ delete: vi.fn().mockResolvedValue(undefined),
11
+ };
12
+ }
13
+ const sampleDocs = [
14
+ { shardId: 'sh1', path: 'a.md', size: 100, lastModified: 0 },
15
+ { shardId: 'sh1', path: 'sub/b.md', size: 200, lastModified: 0 },
16
+ ];
17
+ function baseProps(extra = {}) {
18
+ return Object.assign({ mode: 'open', docs: sampleDocs, onCommit: vi.fn(), onCancel: vi.fn(), close: vi.fn() }, extra);
19
+ }
20
+ describe('_DocumentBrowser toolbar visibility', () => {
21
+ it('toolbar is hidden at the shard-list view (shardId === null)', async () => {
22
+ render(DocumentBrowser, { props: baseProps({ handle: makeHandle() }) });
23
+ expect(document.querySelector('.sh3-doc-browser__toolbar')).toBeNull();
24
+ });
25
+ it('toolbar is visible when inside a shard and handle is provided', async () => {
26
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle: makeHandle() }) });
27
+ await fireEvent.click(screen.getByText('sh1'));
28
+ expect(container.querySelector('.sh3-doc-browser__toolbar')).not.toBeNull();
29
+ });
30
+ it('toolbar is hidden when readOnlyShard returns true', async () => {
31
+ const { container } = render(DocumentBrowser, {
32
+ props: baseProps({ handle: makeHandle(), readOnlyShard: () => true }),
33
+ });
34
+ await fireEvent.click(screen.getByText('sh1'));
35
+ expect(container.querySelector('.sh3-doc-browser__toolbar')).toBeNull();
36
+ });
37
+ it('toolbar is hidden when no handle is provided', async () => {
38
+ const { container } = render(DocumentBrowser, { props: baseProps() });
39
+ await fireEvent.click(screen.getByText('sh1'));
40
+ expect(container.querySelector('.sh3-doc-browser__toolbar')).toBeNull();
41
+ });
42
+ });
43
+ describe('_DocumentBrowser navigation', () => {
44
+ it('clicking a shard navigates into it and shows its contents', async () => {
45
+ render(DocumentBrowser, { props: baseProps() });
46
+ expect(screen.getByText('sh1')).not.toBeNull();
47
+ await fireEvent.click(screen.getByText('sh1'));
48
+ expect(screen.getByText('a.md')).not.toBeNull();
49
+ expect(screen.getByText('sub')).not.toBeNull();
50
+ });
51
+ it('double-clicking a file commits it in open mode', async () => {
52
+ const onCommit = vi.fn();
53
+ const close = vi.fn();
54
+ render(DocumentBrowser, { props: baseProps({ onCommit, close }) });
55
+ await fireEvent.click(screen.getByText('sh1'));
56
+ await fireEvent.dblClick(screen.getByText('a.md'));
57
+ expect(onCommit).toHaveBeenCalledWith({ shardId: 'sh1', path: 'a.md', kind: 'file' });
58
+ expect(close).toHaveBeenCalled();
59
+ });
60
+ it('double-clicking a folder navigates into it', async () => {
61
+ render(DocumentBrowser, { props: baseProps() });
62
+ await fireEvent.click(screen.getByText('sh1'));
63
+ await fireEvent.dblClick(screen.getByText('sub'));
64
+ expect(screen.getByText('b.md')).not.toBeNull();
65
+ });
66
+ it('clicking a folder with selectable=folder selects it, not navigates', async () => {
67
+ const onCommit = vi.fn();
68
+ render(DocumentBrowser, {
69
+ props: baseProps({ onCommit, selectable: 'folder' }),
70
+ });
71
+ // Use dblClick to navigate into sh1 (dblClick always navigates)
72
+ await fireEvent.dblClick(screen.getByText('sh1'));
73
+ // Now inside sh1: clicking sub should select it, not navigate
74
+ await fireEvent.click(screen.getByText('sub'));
75
+ expect(screen.queryByText('b.md')).toBeNull(); // not navigated
76
+ // Open button should be enabled since a folder is selected
77
+ const openBtn = screen.getByText('Open');
78
+ expect(openBtn.disabled).toBe(false);
79
+ });
80
+ });
81
+ describe('_DocumentBrowser commit and cancel', () => {
82
+ it('Open button is disabled when nothing is selected', async () => {
83
+ render(DocumentBrowser, { props: baseProps() });
84
+ await fireEvent.click(screen.getByText('sh1'));
85
+ const openBtn = screen.getByText('Open');
86
+ expect(openBtn.disabled).toBe(true);
87
+ });
88
+ it('Open button is enabled when a file is selected', async () => {
89
+ render(DocumentBrowser, { props: baseProps() });
90
+ await fireEvent.click(screen.getByText('sh1'));
91
+ await fireEvent.click(screen.getByText('a.md'));
92
+ const openBtn = screen.getByText('Open');
93
+ expect(openBtn.disabled).toBe(false);
94
+ });
95
+ it('clicking Open commits the selected file', async () => {
96
+ const onCommit = vi.fn();
97
+ const close = vi.fn();
98
+ render(DocumentBrowser, { props: baseProps({ onCommit, close }) });
99
+ await fireEvent.click(screen.getByText('sh1'));
100
+ await fireEvent.click(screen.getByText('a.md'));
101
+ await fireEvent.click(screen.getByText('Open'));
102
+ expect(onCommit).toHaveBeenCalledWith({ shardId: 'sh1', path: 'a.md', kind: 'file' });
103
+ expect(close).toHaveBeenCalled();
104
+ });
105
+ it('Cancel calls onCancel and close', async () => {
106
+ const onCancel = vi.fn();
107
+ const close = vi.fn();
108
+ render(DocumentBrowser, { props: baseProps({ onCancel, close }) });
109
+ await fireEvent.click(screen.getByText('Cancel'));
110
+ expect(onCancel).toHaveBeenCalled();
111
+ expect(close).toHaveBeenCalled();
112
+ });
113
+ });
114
+ describe('_DocumentBrowser new folder', () => {
115
+ beforeEach(() => { vi.clearAllMocks(); });
116
+ it('clicking New folder shows an inline editable row', async () => {
117
+ const handle = makeHandle();
118
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
119
+ await fireEvent.click(screen.getByText('sh1'));
120
+ const newFolderBtn = container.querySelector('[title="New folder"]');
121
+ await fireEvent.click(newFolderBtn);
122
+ const input = container.querySelector('.sh3-doc-browser__rename-input');
123
+ expect(input).not.toBeNull();
124
+ });
125
+ it('entering a name and pressing Enter calls handle.mkdir', async () => {
126
+ const handle = makeHandle();
127
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
128
+ await fireEvent.click(screen.getByText('sh1'));
129
+ const newFolderBtn = container.querySelector('[title="New folder"]');
130
+ await fireEvent.click(newFolderBtn);
131
+ const input = container.querySelector('.sh3-doc-browser__rename-input');
132
+ await fireEvent.input(input, { target: { value: 'newdir' } });
133
+ await fireEvent.keyDown(input, { key: 'Enter' });
134
+ expect(handle.mkdir).toHaveBeenCalledWith('sh1', 'newdir');
135
+ });
136
+ it('Esc cancels new folder without calling mkdir', async () => {
137
+ const handle = makeHandle();
138
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
139
+ await fireEvent.click(screen.getByText('sh1'));
140
+ const newFolderBtn = container.querySelector('[title="New folder"]');
141
+ await fireEvent.click(newFolderBtn);
142
+ const input = container.querySelector('.sh3-doc-browser__rename-input');
143
+ await fireEvent.keyDown(input, { key: 'Escape' });
144
+ expect(handle.mkdir).not.toHaveBeenCalled();
145
+ expect(container.querySelector('.sh3-doc-browser__rename-input')).toBeNull();
146
+ });
147
+ it('duplicate name shows error and does not call mkdir', async () => {
148
+ const handle = makeHandle();
149
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
150
+ await fireEvent.click(screen.getByText('sh1'));
151
+ const newFolderBtn = container.querySelector('[title="New folder"]');
152
+ await fireEvent.click(newFolderBtn);
153
+ const input = container.querySelector('.sh3-doc-browser__rename-input');
154
+ // 'sub' already exists as a folder from sampleDocs
155
+ await fireEvent.input(input, { target: { value: 'sub' } });
156
+ await fireEvent.keyDown(input, { key: 'Enter' });
157
+ expect(handle.mkdir).not.toHaveBeenCalled();
158
+ const errorEl = container.querySelector('.sh3-doc-browser__toolbar-error');
159
+ expect(errorEl === null || errorEl === void 0 ? void 0 : errorEl.textContent).toMatch(/already exists/);
160
+ });
161
+ });
162
+ describe('_DocumentBrowser rename', () => {
163
+ beforeEach(() => { vi.clearAllMocks(); });
164
+ it('clicking Rename while a file is selected shows inline input', async () => {
165
+ const handle = makeHandle();
166
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
167
+ await fireEvent.click(screen.getByText('sh1'));
168
+ await fireEvent.click(screen.getByText('a.md'));
169
+ const renameBtn = container.querySelector('[title="Rename"]');
170
+ await fireEvent.click(renameBtn);
171
+ const input = container.querySelector('.sh3-doc-browser__rename-input');
172
+ expect(input).not.toBeNull();
173
+ expect(input.value).toBe('a.md');
174
+ });
175
+ it('confirming rename calls handle.rename', async () => {
176
+ const handle = makeHandle();
177
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
178
+ await fireEvent.click(screen.getByText('sh1'));
179
+ await fireEvent.click(screen.getByText('a.md'));
180
+ const renameBtn = container.querySelector('[title="Rename"]');
181
+ await fireEvent.click(renameBtn);
182
+ const input = container.querySelector('.sh3-doc-browser__rename-input');
183
+ await fireEvent.input(input, { target: { value: 'renamed.md' } });
184
+ await fireEvent.keyDown(input, { key: 'Enter' });
185
+ expect(handle.rename).toHaveBeenCalledWith('sh1', 'a.md', 'renamed.md');
186
+ });
187
+ });
188
+ describe('_DocumentBrowser delete', () => {
189
+ beforeEach(() => { vi.clearAllMocks(); });
190
+ it('clicking Delete while a file is selected shows confirm overlay', async () => {
191
+ const handle = makeHandle();
192
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
193
+ await fireEvent.click(screen.getByText('sh1'));
194
+ await fireEvent.click(screen.getByText('a.md'));
195
+ const deleteBtn = container.querySelector('[title="Delete"]');
196
+ await fireEvent.click(deleteBtn);
197
+ expect(container.querySelector('.sh3-confirm-delete')).not.toBeNull();
198
+ });
199
+ it('confirming delete calls handle.delete', async () => {
200
+ const handle = makeHandle();
201
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
202
+ await fireEvent.click(screen.getByText('sh1'));
203
+ await fireEvent.click(screen.getByText('a.md'));
204
+ const deleteBtn = container.querySelector('[title="Delete"]');
205
+ await fireEvent.click(deleteBtn);
206
+ const confirmBtn = container.querySelector('.sh3-confirm-delete__btn--danger');
207
+ await fireEvent.click(confirmBtn);
208
+ expect(handle.delete).toHaveBeenCalledWith('sh1', 'a.md');
209
+ });
210
+ it('cancelling delete hides the overlay without calling delete', async () => {
211
+ const handle = makeHandle();
212
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
213
+ await fireEvent.click(screen.getByText('sh1'));
214
+ await fireEvent.click(screen.getByText('a.md'));
215
+ const deleteBtn = container.querySelector('[title="Delete"]');
216
+ await fireEvent.click(deleteBtn);
217
+ const cancelBtn = container.querySelector('.sh3-confirm-delete__btn:not(.sh3-confirm-delete__btn--danger)');
218
+ await fireEvent.click(cancelBtn);
219
+ expect(handle.delete).not.toHaveBeenCalled();
220
+ expect(container.querySelector('.sh3-confirm-delete')).toBeNull();
221
+ });
222
+ });
223
+ describe('_DocumentBrowser cut and paste', () => {
224
+ beforeEach(() => { vi.clearAllMocks(); });
225
+ it('Paste button is disabled when clipboard is empty', async () => {
226
+ const handle = makeHandle();
227
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
228
+ await fireEvent.click(screen.getByText('sh1'));
229
+ const pasteBtn = container.querySelector('[title="Paste"]');
230
+ expect(pasteBtn.disabled).toBe(true);
231
+ });
232
+ it('Cut then Paste calls handle.rename with new path', async () => {
233
+ const handle = makeHandle();
234
+ const { container } = render(DocumentBrowser, { props: baseProps({ handle }) });
235
+ await fireEvent.click(screen.getByText('sh1'));
236
+ await fireEvent.click(screen.getByText('a.md'));
237
+ const cutBtn = container.querySelector('[title="Cut"]');
238
+ await fireEvent.click(cutBtn);
239
+ // Navigate into sub folder (dblclick)
240
+ await fireEvent.dblClick(screen.getByText('sub'));
241
+ const pasteBtn = container.querySelector('[title="Paste"]');
242
+ expect(pasteBtn.disabled).toBe(false);
243
+ await fireEvent.click(pasteBtn);
244
+ expect(handle.rename).toHaveBeenCalledWith('sh1', 'a.md', 'sub/a.md');
245
+ });
246
+ });
247
+ describe('_DocumentBrowser save mode', () => {
248
+ it('shows filename input when inside a shard in save mode', async () => {
249
+ const { container } = render(DocumentBrowser, {
250
+ props: Object.assign(Object.assign({}, baseProps()), { mode: 'save' }),
251
+ });
252
+ await fireEvent.click(screen.getByText('sh1'));
253
+ expect(container.querySelector('.sh3-doc-browser__save-input')).not.toBeNull();
254
+ });
255
+ it('clicking a file in save mode populates the filename field', async () => {
256
+ const { container } = render(DocumentBrowser, {
257
+ props: Object.assign(Object.assign({}, baseProps()), { mode: 'save' }),
258
+ });
259
+ await fireEvent.click(screen.getByText('sh1'));
260
+ await fireEvent.click(screen.getByText('a.md'));
261
+ const input = container.querySelector('.sh3-doc-browser__save-input');
262
+ expect(input.value).toBe('a.md');
263
+ });
264
+ it('Save commits the typed path', async () => {
265
+ const onCommit = vi.fn();
266
+ const close = vi.fn();
267
+ const { container } = render(DocumentBrowser, {
268
+ props: Object.assign(Object.assign({}, baseProps({ onCommit, close })), { mode: 'save' }),
269
+ });
270
+ await fireEvent.click(screen.getByText('sh1'));
271
+ const input = container.querySelector('.sh3-doc-browser__save-input');
272
+ await fireEvent.input(input, { target: { value: 'report.txt' } });
273
+ await fireEvent.click(screen.getByText('Save'));
274
+ expect(onCommit).toHaveBeenCalledWith('sh1/report.txt');
275
+ expect(close).toHaveBeenCalled();
276
+ });
277
+ });
@@ -0,0 +1,57 @@
1
+ <script lang="ts">
2
+ let {
3
+ item,
4
+ childCount,
5
+ onConfirm,
6
+ onCancel,
7
+ }: {
8
+ item: { kind: 'file' | 'folder'; name: string };
9
+ childCount: number;
10
+ onConfirm: () => void;
11
+ onCancel: () => void;
12
+ } = $props();
13
+ </script>
14
+
15
+ <div class="sh3-confirm-delete">
16
+ <div class="sh3-confirm-delete__title">
17
+ {#if item.kind === 'file'}
18
+ Delete "{item.name}"?
19
+ {:else if childCount === 0}
20
+ Delete folder "{item.name}"?
21
+ {:else}
22
+ Delete folder "{item.name}" and {childCount} item{childCount === 1 ? '' : 's'}?
23
+ {/if}
24
+ </div>
25
+ <div class="sh3-confirm-delete__buttons">
26
+ <button type="button" class="sh3-confirm-delete__btn" onclick={onCancel}>Cancel</button>
27
+ <button type="button" class="sh3-confirm-delete__btn sh3-confirm-delete__btn--danger" onclick={onConfirm}>Delete</button>
28
+ </div>
29
+ </div>
30
+
31
+ <style>
32
+ .sh3-confirm-delete {
33
+ padding: 12px 16px;
34
+ min-width: 240px;
35
+ color: var(--sh3-fg);
36
+ font-size: 0.8125rem;
37
+ }
38
+ .sh3-confirm-delete__title { font-size: 0.875rem; margin-bottom: 12px; }
39
+ .sh3-confirm-delete__buttons { display: flex; justify-content: flex-end; gap: 8px; }
40
+ .sh3-confirm-delete__btn {
41
+ display: inline-flex; align-items: center;
42
+ height: 26px; padding: 0 12px;
43
+ border: 1px solid var(--sh3-border);
44
+ border-radius: var(--sh3-radius-sm);
45
+ background: var(--sh3-bg-elevated);
46
+ color: var(--sh3-fg);
47
+ font: inherit; font-size: 0.75rem;
48
+ cursor: pointer;
49
+ }
50
+ .sh3-confirm-delete__btn:hover { background: var(--sh3-bg); }
51
+ .sh3-confirm-delete__btn--danger {
52
+ background: var(--sh3-error);
53
+ color: var(--sh3-fg-on-error);
54
+ border-color: var(--sh3-error);
55
+ }
56
+ .sh3-confirm-delete__btn--danger:hover { filter: brightness(1.1); }
57
+ </style>
@@ -0,0 +1,12 @@
1
+ type $$ComponentProps = {
2
+ item: {
3
+ kind: 'file' | 'folder';
4
+ name: string;
5
+ };
6
+ childCount: number;
7
+ onConfirm: () => void;
8
+ onCancel: () => void;
9
+ };
10
+ declare const FolderConfirmDelete: import("svelte").Component<$$ComponentProps, {}, "">;
11
+ type FolderConfirmDelete = ReturnType<typeof FolderConfirmDelete>;
12
+ export default FolderConfirmDelete;
@@ -112,7 +112,38 @@
112
112
  color: var(--sh3-fg);
113
113
  cursor: pointer;
114
114
  }
115
- .delete-project-dialog__opt input { margin-top: 3px; }
115
+ .delete-project-dialog__opt input[type='checkbox'] {
116
+ appearance: none;
117
+ -webkit-appearance: none;
118
+ width: 16px;
119
+ height: 16px;
120
+ margin-top: 3px;
121
+ border: 1px solid var(--sh3-border);
122
+ border-radius: 3px;
123
+ background: var(--sh3-bg-elevated);
124
+ cursor: pointer;
125
+ flex-shrink: 0;
126
+ position: relative;
127
+ }
128
+ .delete-project-dialog__opt input[type='checkbox']:checked {
129
+ background: var(--sh3-accent);
130
+ border-color: var(--sh3-accent);
131
+ }
132
+ .delete-project-dialog__opt input[type='checkbox']:checked::after {
133
+ content: '';
134
+ position: absolute;
135
+ left: 4px;
136
+ top: 1px;
137
+ width: 6px;
138
+ height: 10px;
139
+ border: solid #fff;
140
+ border-width: 0 2px 2px 0;
141
+ transform: rotate(45deg);
142
+ }
143
+ .delete-project-dialog__opt input[type='checkbox']:disabled {
144
+ opacity: 0.5;
145
+ cursor: not-allowed;
146
+ }
116
147
  .delete-project-dialog__path {
117
148
  display: block;
118
149
  font-family: var(--sh3-font-mono, monospace);
@@ -14,8 +14,16 @@
14
14
  import { getUser } from '../auth/auth.svelte';
15
15
  import AppPicker from '../primitives/widgets/AppPicker.svelte';
16
16
  import UserPicker from '../primitives/widgets/UserPicker.svelte';
17
+ import TabbedPanel from '../primitives/TabbedPanel.svelte';
17
18
  import { modalManager } from '../overlays/modal';
18
19
  import DeleteProjectDialog from './DeleteProjectDialog.svelte';
20
+ import { apiFetch } from '../transport/apiFetch';
21
+
22
+ interface MountEntry {
23
+ id: string;
24
+ label?: string;
25
+ status: 'resolved' | 'unresolved' | 'error';
26
+ }
19
27
 
20
28
  interface Props {
21
29
  project?: ProjectRecord | null;
@@ -44,6 +52,36 @@
44
52
  );
45
53
  let saving = $state(false);
46
54
  let error = $state<string | null>(null);
55
+ let activeTab = $state(0);
56
+ let allMounts = $state<MountEntry[]>([]);
57
+ let mountAttached = $state<Set<string>>(new Set());
58
+ let mountsLoading = $state(true);
59
+ let mountsLoadError = $state<string | null>(null);
60
+ let baselineApps = $state<string[]>(untrack(() => (project ? [...project.appAllowlist] : [])));
61
+ let baselineMembers = $state<string[]>(
62
+ untrack(() => {
63
+ if (project) return [...project.members];
64
+ const userId = getUser()?.id;
65
+ return userId ? [userId] : [];
66
+ }),
67
+ );
68
+ let baselineMounts = $state<Set<string>>(new Set());
69
+
70
+ function arrayEq(a: string[], b: string[]): boolean {
71
+ if (a.length !== b.length) return false;
72
+ const sa = [...a].sort();
73
+ const sb = [...b].sort();
74
+ return sa.every((v, i) => v === sb[i]);
75
+ }
76
+ function setEq(a: Set<string>, b: Set<string>): boolean {
77
+ if (a.size !== b.size) return false;
78
+ for (const v of a) if (!b.has(v)) return false;
79
+ return true;
80
+ }
81
+
82
+ const appsDirty = $derived(!arrayEq(appAllowlist, baselineApps));
83
+ const membersDirty = $derived(!arrayEq(members, baselineMembers));
84
+ const mountsDirty = $derived(!setEq(mountAttached, baselineMounts));
47
85
 
48
86
  async function save() {
49
87
  if (!name.trim()) {
@@ -59,11 +97,49 @@
59
97
  appAllowlist,
60
98
  };
61
99
  try {
62
- if (isEdit && project) {
63
- await projectsApi.update(project.id, payload);
64
- } else {
65
- await projectsApi.create(payload);
100
+ const saved = isEdit && project
101
+ ? await projectsApi.update(project.id, payload)
102
+ : await projectsApi.create(payload);
103
+
104
+ const projectId = saved.id;
105
+ const toAttach: string[] = [];
106
+ const toDetach: string[] = [];
107
+ for (const id of mountAttached) if (!baselineMounts.has(id)) toAttach.push(id);
108
+ for (const id of baselineMounts) if (!mountAttached.has(id)) toDetach.push(id);
109
+
110
+ const errors: string[] = [];
111
+ for (const mountId of toAttach) {
112
+ try {
113
+ const res = await apiFetch('/api/admin/mount-attachments', {
114
+ method: 'POST',
115
+ headers: { 'content-type': 'application/json' },
116
+ body: JSON.stringify({ mountId, tenantId: projectId }),
117
+ });
118
+ if (!res.ok) errors.push(`attach ${mountId}: ${res.status}`);
119
+ } catch (e) {
120
+ errors.push(`attach ${mountId}: ${(e as Error).message}`);
121
+ }
122
+ }
123
+ for (const mountId of toDetach) {
124
+ try {
125
+ const res = await apiFetch('/api/admin/mount-attachments', {
126
+ method: 'DELETE',
127
+ headers: { 'content-type': 'application/json' },
128
+ body: JSON.stringify({ mountId, tenantId: projectId }),
129
+ });
130
+ if (!res.ok && res.status !== 204) errors.push(`detach ${mountId}: ${res.status}`);
131
+ } catch (e) {
132
+ errors.push(`detach ${mountId}: ${(e as Error).message}`);
133
+ }
134
+ }
135
+ if (errors.length > 0) {
136
+ error = `Mount changes failed: ${errors.join('; ')}`;
137
+ baselineMounts = new Set(mountAttached);
138
+ await refreshProjects();
139
+ return;
66
140
  }
141
+
142
+ baselineMounts = new Set(mountAttached);
67
143
  await refreshProjects();
68
144
  onClose();
69
145
  } catch (e) {
@@ -94,10 +170,51 @@
94
170
  },
95
171
  });
96
172
  }
173
+
174
+ function toggleMount(mountId: string, checked: boolean): void {
175
+ const next = new Set(mountAttached);
176
+ if (checked) next.add(mountId);
177
+ else next.delete(mountId);
178
+ mountAttached = next;
179
+ }
180
+
181
+ async function loadMounts(): Promise<void> {
182
+ mountsLoading = true;
183
+ mountsLoadError = null;
184
+ try {
185
+ const reqs: Promise<Response>[] = [
186
+ apiFetch('/api/admin/mounts'),
187
+ ];
188
+ if (project) {
189
+ reqs.push(apiFetch(
190
+ `/api/admin/tenants/${encodeURIComponent(project.id)}/attachments`,
191
+ ));
192
+ }
193
+ const results = await Promise.all(reqs);
194
+ const mountsRes = results[0];
195
+ const attRes = results[1];
196
+
197
+ if (!mountsRes.ok) throw new Error(`GET /api/admin/mounts failed: ${mountsRes.status}`);
198
+ allMounts = await mountsRes.json() as MountEntry[];
199
+
200
+ if (attRes) {
201
+ if (!attRes.ok) throw new Error(`GET tenant attachments failed: ${attRes.status}`);
202
+ const atts = await attRes.json() as { mountId: string }[];
203
+ mountAttached = new Set(atts.map((a) => a.mountId));
204
+ baselineMounts = new Set(mountAttached);
205
+ }
206
+ } catch (e) {
207
+ mountsLoadError = (e as Error).message;
208
+ } finally {
209
+ mountsLoading = false;
210
+ }
211
+ }
212
+
213
+ loadMounts();
97
214
  </script>
98
215
 
99
216
  <div class="project-manage">
100
- <div class="body">
217
+ <header class="header">
101
218
  <h2>{isEdit ? `Edit ${project!.name}` : 'Create Project'}</h2>
102
219
 
103
220
  {#if isEdit}
@@ -113,27 +230,22 @@
113
230
  <span>Description</span>
114
231
  <textarea bind:value={description} rows={2} disabled={saving}></textarea>
115
232
  </label>
233
+ </header>
116
234
 
117
- <label class="field">
118
- <span>Members</span>
119
- <UserPicker bind:value={members} disabled={saving} />
120
- {#if me}
121
- <span class="hint">
122
- Your id: <code>{me.id}</code> ({me.username ?? me.displayName})
123
- </span>
124
- {/if}
125
- </label>
126
-
127
- <label class="field">
128
- <span>App allowlist</span>
129
- <AppPicker bind:value={appAllowlist} disabled={saving} />
130
- </label>
131
-
132
- {#if error}
133
- <p class="error">{error}</p>
134
- {/if}
235
+ <div class="tabs">
236
+ <TabbedPanel
237
+ labels={['Apps', 'Users', 'Mounts']}
238
+ {activeTab}
239
+ onActiveChange={(i) => (activeTab = i)}
240
+ dirty={[appsDirty, membersDirty, mountsDirty]}
241
+ body={tabBody}
242
+ />
135
243
  </div>
136
244
 
245
+ {#if error}
246
+ <p class="error">{error}</p>
247
+ {/if}
248
+
137
249
  <div class="actions">
138
250
  <button type="button" class="primary" onclick={save} disabled={saving}>
139
251
  {isEdit ? 'Save' : 'Create'}
@@ -145,6 +257,54 @@
145
257
  </div>
146
258
  </div>
147
259
 
260
+ {#snippet tabBody(i: number)}
261
+ <div class="tab-pane">
262
+ {#if i === 0}
263
+ <label class="field">
264
+ <span>App allowlist</span>
265
+ <AppPicker bind:value={appAllowlist} disabled={saving} />
266
+ </label>
267
+ {:else if i === 1}
268
+ <label class="field">
269
+ <span>Members</span>
270
+ <UserPicker bind:value={members} disabled={saving} />
271
+ {#if me}
272
+ <span class="hint">
273
+ Your id: <code>{me.id}</code> ({me.username ?? me.displayName})
274
+ </span>
275
+ {/if}
276
+ </label>
277
+ {:else}
278
+ {#if mountsLoading}
279
+ <p class="muted">Loading mounts...</p>
280
+ {:else if mountsLoadError}
281
+ <p class="error">{mountsLoadError}</p>
282
+ {:else if allMounts.length === 0}
283
+ <p class="muted">No mounts configured. Create mounts first.</p>
284
+ {:else}
285
+ <ul class="mount-list">
286
+ {#each allMounts as mount (mount.id)}
287
+ <li class="mount-row" data-mount-row={mount.id}>
288
+ <label>
289
+ <input
290
+ class="sh3-base-check"
291
+ type="checkbox"
292
+ checked={mountAttached.has(mount.id)}
293
+ disabled={saving}
294
+ onchange={(e) => toggleMount(mount.id, (e.currentTarget as HTMLInputElement).checked)}
295
+ />
296
+ <span class="mount-status" data-status={mount.status}></span>
297
+ <span class="mount-label">{mount.label ?? mount.id}</span>
298
+ <span class="mount-id">{mount.id}</span>
299
+ </label>
300
+ </li>
301
+ {/each}
302
+ </ul>
303
+ {/if}
304
+ {/if}
305
+ </div>
306
+ {/snippet}
307
+
148
308
  <style>
149
309
  .project-manage {
150
310
  position: absolute;
@@ -155,11 +315,20 @@
155
315
  color: var(--sh3-fg);
156
316
  background: var(--sh3-bg);
157
317
  }
158
- .body {
159
- flex: 1;
160
- overflow-y: auto;
161
- padding: 16px 16px 8px;
162
- }
318
+ .header { padding: 16px 16px 8px; flex: 0 0 auto; border-bottom: 1px solid var(--sh3-border); }
319
+ .tabs { flex: 1 1 auto; min-height: 0; position: relative; }
320
+ .tab-pane { padding: 16px; height: 100%; overflow-y: auto; box-sizing: border-box; }
321
+ .muted { color: var(--sh3-fg-muted); font-style: italic; font-size: 13px; }
322
+ .mount-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
323
+ .mount-row { padding: 6px 0; border-bottom: 1px solid var(--sh3-border); font-size: 13px; }
324
+ .mount-row label { display: flex; align-items: center; gap: 8px; cursor: pointer; }
325
+ .mount-row input[type="checkbox"] { cursor: pointer; }
326
+ .mount-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: #999; }
327
+ .mount-status[data-status="resolved"] { background: #4caf50; }
328
+ .mount-status[data-status="unresolved"] { background: #ff9800; }
329
+ .mount-status[data-status="error"] { background: #f44336; }
330
+ .mount-label { font-weight: 500; }
331
+ .mount-id { font-size: 11px; color: var(--sh3-fg-muted); font-family: var(--sh3-font-mono, monospace); margin-left: auto; }
163
332
  h2 { margin: 0 0 8px; font-size: 16px; }
164
333
  .project-id { font-size: 12px; color: var(--sh3-fg-muted); margin: 0 0 16px; }
165
334
  .project-id code { font-family: var(--sh3-font-mono, monospace); }
@@ -0,0 +1 @@
1
+ export {};