sh3-core 0.19.5 → 0.20.1
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 -0
- 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/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 +4 -0
- package/dist/documents/config.js +15 -2
- package/dist/documents/handle.js +25 -17
- package/dist/documents/http-backend.js +10 -2
- package/dist/documents/index.d.ts +2 -2
- package/dist/documents/index.js +1 -1
- package/dist/documents/picker-api.d.ts +33 -0
- package/dist/documents/picker-api.js +1 -0
- package/dist/documents/picker-api.test.d.ts +1 -0
- package/dist/documents/picker-api.test.js +162 -0
- package/dist/documents/picker-primitive.d.ts +11 -0
- package/dist/documents/picker-primitive.js +56 -0
- package/dist/documents/types.d.ts +17 -5
- package/dist/documents/types.js +2 -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.svelte +4 -4
- package/dist/primitives/widgets/PickerList.svelte +1 -0
- package/dist/primitives/widgets/_DocumentBrowser.svelte +7 -8
- package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +1 -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/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 +31 -14
- package/dist/shards/types.d.ts +15 -0
- package/dist/shell-shard/tenant-fs-client.js +2 -1
- package/dist/transport/apiFetch.js +12 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/documents/config.js
CHANGED
|
@@ -5,18 +5,31 @@
|
|
|
5
5
|
* calls __setActiveScope and __setDocumentBackend before bootstrap() to
|
|
6
6
|
* configure multi-scope routing and swap backends (e.g. Tauri FS).
|
|
7
7
|
*
|
|
8
|
+
* Project scope: when a project is active (sessionState.activeProjectId),
|
|
9
|
+
* the scope resolver returns the project id so all document operations
|
|
10
|
+
* operate on the project's virtual tenant instead of the user's personal
|
|
11
|
+
* scope. The resolver is wired by createShell after bootstrap.
|
|
12
|
+
*
|
|
8
13
|
* Defaults: scopeId='local' (single-user self-hosted), backend=IndexedDB.
|
|
9
14
|
*/
|
|
10
15
|
import { IndexedDBDocumentBackend } from './backends';
|
|
11
16
|
const DEFAULT_SCOPE = 'local';
|
|
12
17
|
let scopeId = DEFAULT_SCOPE;
|
|
13
18
|
let backend = new IndexedDBDocumentBackend();
|
|
19
|
+
let scopeResolver = null;
|
|
20
|
+
/** Host-only. Register a callback that resolves the active project scope.
|
|
21
|
+
* When non-null and returns a string, that string overrides the static
|
|
22
|
+
* scopeId for all document operations. Wired by createShell after bootstrap. */
|
|
23
|
+
export function __setScopeResolver(resolver) {
|
|
24
|
+
scopeResolver = resolver;
|
|
25
|
+
}
|
|
14
26
|
export function getActiveScopeId() {
|
|
15
|
-
|
|
27
|
+
var _a;
|
|
28
|
+
return (_a = scopeResolver === null || scopeResolver === void 0 ? void 0 : scopeResolver()) !== null && _a !== void 0 ? _a : scopeId;
|
|
16
29
|
}
|
|
17
30
|
/** @deprecated use getActiveScopeId — kept until callers migrate. */
|
|
18
31
|
export function getTenantId() {
|
|
19
|
-
return
|
|
32
|
+
return getActiveScopeId();
|
|
20
33
|
}
|
|
21
34
|
export function getDocumentBackend() {
|
|
22
35
|
return backend;
|
package/dist/documents/handle.js
CHANGED
|
@@ -31,36 +31,43 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
31
31
|
return true;
|
|
32
32
|
return options.extensions.some((ext) => path.endsWith(ext));
|
|
33
33
|
}
|
|
34
|
-
function
|
|
35
|
-
|
|
34
|
+
function resolveTenant(opts) {
|
|
35
|
+
var _a;
|
|
36
|
+
return (_a = opts === null || opts === void 0 ? void 0 : opts.scope) !== null && _a !== void 0 ? _a : tenantId;
|
|
37
|
+
}
|
|
38
|
+
function emitChange(type, path, tid) {
|
|
39
|
+
documentChanges.emit({ type, path, tenantId: tid, shardId });
|
|
36
40
|
}
|
|
37
41
|
const handle = {
|
|
38
|
-
async list() {
|
|
39
|
-
const
|
|
42
|
+
async list(opts) {
|
|
43
|
+
const tid = resolveTenant(opts);
|
|
44
|
+
const all = await backend.list(tid, shardId);
|
|
40
45
|
if (!options.extensions || options.extensions.length === 0)
|
|
41
46
|
return all;
|
|
42
47
|
return all.filter((meta) => matchesExtensions(meta.path));
|
|
43
48
|
},
|
|
44
|
-
async read(path) {
|
|
45
|
-
const content = await backend.read(
|
|
49
|
+
async read(path, opts) {
|
|
50
|
+
const content = await backend.read(resolveTenant(opts), shardId, path);
|
|
46
51
|
if (content === null)
|
|
47
52
|
return null;
|
|
48
53
|
// Phase 1: text format only. Binary returns as-is from the backend
|
|
49
54
|
// but the handle types it as string for text-format handles.
|
|
50
55
|
return typeof content === 'string' ? content : new TextDecoder().decode(content);
|
|
51
56
|
},
|
|
52
|
-
async write(path, content) {
|
|
53
|
-
const
|
|
54
|
-
await backend.
|
|
55
|
-
|
|
57
|
+
async write(path, content, opts) {
|
|
58
|
+
const tid = resolveTenant(opts);
|
|
59
|
+
const existed = await backend.exists(tid, shardId, path);
|
|
60
|
+
await backend.write(tid, shardId, path, content);
|
|
61
|
+
emitChange(existed ? 'update' : 'create', path, tid);
|
|
56
62
|
},
|
|
57
|
-
async delete(path) {
|
|
58
|
-
const
|
|
59
|
-
await backend.
|
|
63
|
+
async delete(path, opts) {
|
|
64
|
+
const tid = resolveTenant(opts);
|
|
65
|
+
const existed = await backend.exists(tid, shardId, path);
|
|
66
|
+
await backend.delete(tid, shardId, path);
|
|
60
67
|
if (existed)
|
|
61
|
-
emitChange('delete', path);
|
|
68
|
+
emitChange('delete', path, tid);
|
|
62
69
|
},
|
|
63
|
-
async rename(oldPath, newPath) {
|
|
70
|
+
async rename(oldPath, newPath, opts) {
|
|
64
71
|
if (!matchesExtensions(newPath)) {
|
|
65
72
|
throw new Error(`Cannot rename to ${newPath}: violates handle extensions filter`);
|
|
66
73
|
}
|
|
@@ -69,12 +76,13 @@ export function createDocumentHandle(tenantId, shardId, backend, options) {
|
|
|
69
76
|
throw new Error(`Cannot rename: active autosave on ${oldPath}; flush and dispose first`);
|
|
70
77
|
}
|
|
71
78
|
}
|
|
72
|
-
|
|
79
|
+
const tid = resolveTenant(opts);
|
|
80
|
+
await backend.rename(tid, shardId, oldPath, newPath);
|
|
73
81
|
documentChanges.emit({
|
|
74
82
|
type: 'rename',
|
|
75
83
|
path: newPath,
|
|
76
84
|
oldPath,
|
|
77
|
-
tenantId,
|
|
85
|
+
tenantId: tid,
|
|
78
86
|
shardId,
|
|
79
87
|
});
|
|
80
88
|
},
|
|
@@ -52,8 +52,16 @@ export class HttpDocumentBackend {
|
|
|
52
52
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
53
53
|
const headers = Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': typeof content === 'string' ? 'text/plain' : 'application/octet-stream' });
|
|
54
54
|
const res = await apiFetch(url, { method: 'PUT', headers, body: content, credentials: 'include' });
|
|
55
|
-
if (!res.ok)
|
|
56
|
-
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
let detail = `HTTP ${res.status}`;
|
|
57
|
+
try {
|
|
58
|
+
const b = await res.json();
|
|
59
|
+
if (b.error)
|
|
60
|
+
detail = b.error;
|
|
61
|
+
}
|
|
62
|
+
catch ( /* body not JSON */_a) { /* body not JSON */ }
|
|
63
|
+
throw new Error(`Document write failed: ${detail}`);
|
|
64
|
+
}
|
|
57
65
|
}
|
|
58
66
|
async delete(tenantId, shardId, path) {
|
|
59
67
|
const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}`;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
export type { DocumentFormat, DocumentHandleOptions, DocumentMeta, DocumentChange, DocumentBackend, DocumentHandle, AutosaveController, } from './types';
|
|
1
|
+
export type { DocumentFormat, DocumentHandleOptions, DocumentMeta, DocumentChange, DocumentBackend, DocumentHandle, AutosaveController, ScopeOption, } from './types';
|
|
2
2
|
export { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
|
|
3
3
|
export { HttpDocumentBackend } from './http-backend';
|
|
4
4
|
export { createDocumentHandle } from './handle';
|
|
5
5
|
export { documentChanges } from './notifications';
|
|
6
|
-
export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, } from './config';
|
|
6
|
+
export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, __setScopeResolver, } from './config';
|
|
7
7
|
export type { SyncPolicy, SyncPolicyRule, DocStatus, ConflictFile, ConflictBranch, } from './sync-types';
|
|
8
8
|
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
|
package/dist/documents/index.js
CHANGED
|
@@ -5,5 +5,5 @@ export { MemoryDocumentBackend, IndexedDBDocumentBackend } from './backends';
|
|
|
5
5
|
export { HttpDocumentBackend } from './http-backend';
|
|
6
6
|
export { createDocumentHandle } from './handle';
|
|
7
7
|
export { documentChanges } from './notifications';
|
|
8
|
-
export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, } from './config';
|
|
8
|
+
export { getActiveScopeId, getTenantId, getDocumentBackend, __setActiveScope, __setTenantId, __setDocumentBackend, __setScopeResolver, } from './config';
|
|
9
9
|
export { PERMISSION_SYNC_PEER, PERMISSION_SYNC_POLICY, } from './sync-types';
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DocEntry, OpenerValue, SaverValue } from '../primitives/widgets/DocumentFilePicker';
|
|
2
|
+
/** Function that returns the document tree for the picker to browse. */
|
|
3
|
+
export type DocListFn = () => Promise<DocEntry[]>;
|
|
4
|
+
/** Options for `ctx.documentPicker.open()`. */
|
|
5
|
+
export interface DocumentOpenOptions {
|
|
6
|
+
/** When set, the browser opens as a popup anchored to this element.
|
|
7
|
+
* When omitted (default), the browser opens as a centered modal. */
|
|
8
|
+
anchor?: HTMLElement;
|
|
9
|
+
}
|
|
10
|
+
/** Options for `ctx.documentPicker.save()`. */
|
|
11
|
+
export interface DocumentSaveOptions {
|
|
12
|
+
/** Pre-fill the filename input in the save dialog. */
|
|
13
|
+
suggestedName?: string;
|
|
14
|
+
/** When set, the browser opens as a popup anchored to this element.
|
|
15
|
+
* When omitted (default), the browser opens as a centered modal. */
|
|
16
|
+
anchor?: HTMLElement;
|
|
17
|
+
}
|
|
18
|
+
/** Programmatic document picker API — available on `ctx.documentPicker`. */
|
|
19
|
+
export interface DocumentPickerApi {
|
|
20
|
+
/**
|
|
21
|
+
* Open the document browser in "open" mode. The user browses and selects
|
|
22
|
+
* an existing document. Returns the selected `{shardId, path}` or null
|
|
23
|
+
* if cancelled or dismissed externally.
|
|
24
|
+
*/
|
|
25
|
+
open(opts?: DocumentOpenOptions): Promise<OpenerValue | null>;
|
|
26
|
+
/**
|
|
27
|
+
* Open the document browser in "save" mode. The user navigates to a
|
|
28
|
+
* folder and provides a filename. Returns the full path string or null
|
|
29
|
+
* if cancelled or dismissed externally.
|
|
30
|
+
*/
|
|
31
|
+
save(opts?: DocumentSaveOptions): Promise<SaverValue | null>;
|
|
32
|
+
}
|
|
33
|
+
export type { OpenerValue, SaverValue };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '../sh3Runtime.svelte';
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import '../sh3Runtime.svelte'; // intercepted by vi.mock below
|
|
3
|
+
import { createDocumentPicker } from './picker-primitive';
|
|
4
|
+
const { mockPopupShow, mockModalOpen } = vi.hoisted(() => ({
|
|
5
|
+
mockPopupShow: vi.fn(),
|
|
6
|
+
mockModalOpen: vi.fn(),
|
|
7
|
+
}));
|
|
8
|
+
vi.mock('../sh3Runtime.svelte', () => ({
|
|
9
|
+
sh3: {
|
|
10
|
+
popup: { show: mockPopupShow },
|
|
11
|
+
modal: { open: mockModalOpen },
|
|
12
|
+
},
|
|
13
|
+
}));
|
|
14
|
+
function mockPopup() {
|
|
15
|
+
let captured = null;
|
|
16
|
+
const handle = { close: vi.fn() };
|
|
17
|
+
mockPopupShow.mockImplementation((_Content, _opts, props) => {
|
|
18
|
+
captured = props;
|
|
19
|
+
return handle;
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
commit: (v) => { captured === null || captured === void 0 ? void 0 : captured.onCommit(v); },
|
|
23
|
+
cancel: () => { captured === null || captured === void 0 ? void 0 : captured.onCancel(); },
|
|
24
|
+
dismiss: () => { handle.close(); },
|
|
25
|
+
handle,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function mockModal() {
|
|
29
|
+
let captured = null;
|
|
30
|
+
const handle = { close: vi.fn() };
|
|
31
|
+
mockModalOpen.mockImplementation((_Content, props) => {
|
|
32
|
+
captured = props;
|
|
33
|
+
return handle;
|
|
34
|
+
});
|
|
35
|
+
return {
|
|
36
|
+
commit: (v) => { captured === null || captured === void 0 ? void 0 : captured.onCommit(v); },
|
|
37
|
+
cancel: () => { captured === null || captured === void 0 ? void 0 : captured.onCancel(); },
|
|
38
|
+
dismiss: () => { handle.close(); },
|
|
39
|
+
handle,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
beforeEach(() => {
|
|
43
|
+
vi.clearAllMocks();
|
|
44
|
+
});
|
|
45
|
+
describe('createDocumentPicker', () => {
|
|
46
|
+
const sampleDoc = { shardId: 'my-shard', path: 'readme.md' };
|
|
47
|
+
describe('open() — modal (no anchor)', () => {
|
|
48
|
+
it('resolves with OpenerValue when user commits', async () => {
|
|
49
|
+
const listFn = async () => [{ shardId: 'my-shard', path: 'readme.md', size: 100, lastModified: 0 }];
|
|
50
|
+
const picker = createDocumentPicker(listFn);
|
|
51
|
+
const modal = mockModal();
|
|
52
|
+
const promise = picker.open();
|
|
53
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
54
|
+
modal.commit(sampleDoc);
|
|
55
|
+
const result = await promise;
|
|
56
|
+
expect(result).toEqual({ shardId: 'my-shard', path: 'readme.md' });
|
|
57
|
+
expect(mockPopupShow).not.toHaveBeenCalled();
|
|
58
|
+
});
|
|
59
|
+
it('resolves with null when user cancels', async () => {
|
|
60
|
+
const listFn = async () => [];
|
|
61
|
+
const picker = createDocumentPicker(listFn);
|
|
62
|
+
const modal = mockModal();
|
|
63
|
+
const promise = picker.open();
|
|
64
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
65
|
+
modal.cancel();
|
|
66
|
+
const result = await promise;
|
|
67
|
+
expect(result).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
it('resolves with null when modal is dismissed externally', async () => {
|
|
70
|
+
const listFn = async () => [];
|
|
71
|
+
const picker = createDocumentPicker(listFn);
|
|
72
|
+
const modal = mockModal();
|
|
73
|
+
const promise = picker.open();
|
|
74
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
75
|
+
modal.dismiss();
|
|
76
|
+
const result = await promise;
|
|
77
|
+
expect(result).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
it('rejects when listFn fails', async () => {
|
|
80
|
+
const listFn = async () => { throw new Error('network error'); };
|
|
81
|
+
const picker = createDocumentPicker(listFn);
|
|
82
|
+
const promise = picker.open();
|
|
83
|
+
await expect(promise).rejects.toThrow('network error');
|
|
84
|
+
expect(mockModalOpen).not.toHaveBeenCalled();
|
|
85
|
+
expect(mockPopupShow).not.toHaveBeenCalled();
|
|
86
|
+
});
|
|
87
|
+
it('passes mode and dismissOnBackdrop to modal', async () => {
|
|
88
|
+
const listFn = async () => [];
|
|
89
|
+
const picker = createDocumentPicker(listFn);
|
|
90
|
+
mockModal();
|
|
91
|
+
picker.open();
|
|
92
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
93
|
+
const call = mockModalOpen.mock.calls[0];
|
|
94
|
+
expect(call[1].mode).toBe('open');
|
|
95
|
+
expect(call[2]).toEqual({ dismissOnBackdrop: true, boxStyle: 'max-width: min(800px, 95vw);' });
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
describe('open() — popup (with anchor)', () => {
|
|
99
|
+
it('uses popup when anchor element is provided', async () => {
|
|
100
|
+
const listFn = async () => [];
|
|
101
|
+
const picker = createDocumentPicker(listFn);
|
|
102
|
+
mockPopup();
|
|
103
|
+
const el = document.createElement('div');
|
|
104
|
+
picker.open({ anchor: el });
|
|
105
|
+
await vi.waitFor(() => expect(mockPopupShow).toHaveBeenCalledOnce());
|
|
106
|
+
expect(mockModalOpen).not.toHaveBeenCalled();
|
|
107
|
+
const call = mockPopupShow.mock.calls[0];
|
|
108
|
+
expect(call[1].anchor).toEqual({ x: 0, y: 0 });
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
describe('save() — modal (no anchor)', () => {
|
|
112
|
+
it('resolves with SaverValue string when user commits a filename', async () => {
|
|
113
|
+
const listFn = async () => [];
|
|
114
|
+
const picker = createDocumentPicker(listFn);
|
|
115
|
+
const modal = mockModal();
|
|
116
|
+
const promise = picker.save();
|
|
117
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
118
|
+
modal.commit('my-shard/report.txt');
|
|
119
|
+
const result = await promise;
|
|
120
|
+
expect(result).toBe('my-shard/report.txt');
|
|
121
|
+
});
|
|
122
|
+
it('resolves with null when user cancels', async () => {
|
|
123
|
+
const listFn = async () => [];
|
|
124
|
+
const picker = createDocumentPicker(listFn);
|
|
125
|
+
const modal = mockModal();
|
|
126
|
+
const promise = picker.save();
|
|
127
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
128
|
+
modal.cancel();
|
|
129
|
+
const result = await promise;
|
|
130
|
+
expect(result).toBeNull();
|
|
131
|
+
});
|
|
132
|
+
it('passes suggestedName as prop', async () => {
|
|
133
|
+
const listFn = async () => [];
|
|
134
|
+
const picker = createDocumentPicker(listFn);
|
|
135
|
+
mockModal();
|
|
136
|
+
picker.save({ suggestedName: 'draft.txt' });
|
|
137
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
138
|
+
const call = mockModalOpen.mock.calls[0];
|
|
139
|
+
expect(call[1].suggestedName).toBe('draft.txt');
|
|
140
|
+
});
|
|
141
|
+
it('passes mode to modal', async () => {
|
|
142
|
+
const listFn = async () => [];
|
|
143
|
+
const picker = createDocumentPicker(listFn);
|
|
144
|
+
mockModal();
|
|
145
|
+
picker.save();
|
|
146
|
+
await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
|
|
147
|
+
const call = mockModalOpen.mock.calls[0];
|
|
148
|
+
expect(call[1].mode).toBe('save');
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
describe('save() — popup (with anchor)', () => {
|
|
152
|
+
it('uses popup when anchor element is provided', async () => {
|
|
153
|
+
const listFn = async () => [];
|
|
154
|
+
const picker = createDocumentPicker(listFn);
|
|
155
|
+
mockPopup();
|
|
156
|
+
const el = document.createElement('div');
|
|
157
|
+
picker.save({ anchor: el });
|
|
158
|
+
await vi.waitFor(() => expect(mockPopupShow).toHaveBeenCalledOnce());
|
|
159
|
+
expect(mockModalOpen).not.toHaveBeenCalled();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DocumentPickerApi, DocListFn } from './picker-api';
|
|
2
|
+
/**
|
|
3
|
+
* Create a document picker API bound to a document listing function.
|
|
4
|
+
* The listFn is derived from the shard's document zone + browse permission
|
|
5
|
+
* and baked in at construction time so callers don't pass their own scope.
|
|
6
|
+
*
|
|
7
|
+
* When an `anchor` element is provided the browser opens as a popup
|
|
8
|
+
* (anchored near the element). Without an anchor it opens as a centered
|
|
9
|
+
* modal (the expected default for file-browser dialogs).
|
|
10
|
+
*/
|
|
11
|
+
export declare function createDocumentPicker(listFn: DocListFn): DocumentPickerApi;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { sh3 } from '../sh3Runtime.svelte';
|
|
2
|
+
import DocumentBrowser from '../primitives/widgets/_DocumentBrowser.svelte';
|
|
3
|
+
const BOX_STYLE = 'max-width: min(800px, 95vw);';
|
|
4
|
+
const MODAL_OPTS = { dismissOnBackdrop: true, boxStyle: BOX_STYLE };
|
|
5
|
+
/**
|
|
6
|
+
* Create a document picker API bound to a document listing function.
|
|
7
|
+
* The listFn is derived from the shard's document zone + browse permission
|
|
8
|
+
* and baked in at construction time so callers don't pass their own scope.
|
|
9
|
+
*
|
|
10
|
+
* When an `anchor` element is provided the browser opens as a popup
|
|
11
|
+
* (anchored near the element). Without an anchor it opens as a centered
|
|
12
|
+
* modal (the expected default for file-browser dialogs).
|
|
13
|
+
*/
|
|
14
|
+
export function createDocumentPicker(listFn) {
|
|
15
|
+
/** Resolve handle for either popup (anchored) or modal (centered) path. */
|
|
16
|
+
function openBrowser(browserProps, anchor) {
|
|
17
|
+
if (anchor) {
|
|
18
|
+
const rect = anchor.getBoundingClientRect();
|
|
19
|
+
return sh3.popup.show(DocumentBrowser, { anchor: { x: rect.left + rect.width / 2, y: rect.top } }, browserProps);
|
|
20
|
+
}
|
|
21
|
+
return sh3.modal.open(DocumentBrowser, browserProps, MODAL_OPTS);
|
|
22
|
+
}
|
|
23
|
+
function wrapHandle(handle, resolve) {
|
|
24
|
+
const origClose = handle.close;
|
|
25
|
+
handle.close = () => {
|
|
26
|
+
origClose();
|
|
27
|
+
resolve(null);
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
async function open(opts) {
|
|
31
|
+
const docs = await listFn();
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const handle = openBrowser({
|
|
34
|
+
mode: 'open',
|
|
35
|
+
docs,
|
|
36
|
+
onCommit: (value) => { resolve(value); },
|
|
37
|
+
onCancel: () => { resolve(null); },
|
|
38
|
+
}, opts === null || opts === void 0 ? void 0 : opts.anchor);
|
|
39
|
+
wrapHandle(handle, resolve);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
async function save(opts) {
|
|
43
|
+
const docs = await listFn();
|
|
44
|
+
return new Promise((resolve) => {
|
|
45
|
+
const handle = openBrowser({
|
|
46
|
+
mode: 'save',
|
|
47
|
+
docs,
|
|
48
|
+
suggestedName: opts === null || opts === void 0 ? void 0 : opts.suggestedName,
|
|
49
|
+
onCommit: (value) => { resolve(value); },
|
|
50
|
+
onCancel: () => { resolve(null); },
|
|
51
|
+
}, opts === null || opts === void 0 ? void 0 : opts.anchor);
|
|
52
|
+
wrapHandle(handle, resolve);
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return { open, save };
|
|
56
|
+
}
|
|
@@ -34,6 +34,8 @@ export declare const PERMISSION_DOCUMENTS_READ = "documents:read";
|
|
|
34
34
|
* `browse`.
|
|
35
35
|
*/
|
|
36
36
|
export declare const PERMISSION_DOCUMENTS_WRITE = "documents:write";
|
|
37
|
+
/** Permission to manage document mount points (admin-only). */
|
|
38
|
+
export declare const PERMISSION_DOCUMENTS_MOUNT = "documents:mount";
|
|
37
39
|
/**
|
|
38
40
|
* Format hint for document content. Determines whether reads return a string
|
|
39
41
|
* (`text`) or an `ArrayBuffer` (`binary`).
|
|
@@ -158,23 +160,33 @@ export interface DocumentBackend {
|
|
|
158
160
|
/**
|
|
159
161
|
* Shard-facing document handle returned by `ctx.documents()`. Binds
|
|
160
162
|
* the tenant, shard, and backend so shard code only deals in paths.
|
|
163
|
+
*
|
|
164
|
+
* All read/write/list/delete/rename methods accept an optional scope
|
|
165
|
+
* override for cross-scope document transfers (e.g. moving documents
|
|
166
|
+
* between a personal tenant and a project). When omitted, the handle's
|
|
167
|
+
* bound tenant is used.
|
|
161
168
|
*/
|
|
169
|
+
/** Optional scope override for cross-tenant operations. */
|
|
170
|
+
export interface ScopeOption {
|
|
171
|
+
/** Override the handle's bound tenant for a single operation. */
|
|
172
|
+
scope?: string;
|
|
173
|
+
}
|
|
162
174
|
export interface DocumentHandle {
|
|
163
175
|
/** List documents matching the handle's extensions filter. */
|
|
164
|
-
list(): Promise<DocumentMeta[]>;
|
|
176
|
+
list(opts?: ScopeOption): Promise<DocumentMeta[]>;
|
|
165
177
|
/** Read a document by path. Returns null if not found. */
|
|
166
|
-
read(path: string): Promise<string | null>;
|
|
178
|
+
read(path: string, opts?: ScopeOption): Promise<string | null>;
|
|
167
179
|
/** Write (create or overwrite) a document. Explicit save. */
|
|
168
|
-
write(path: string, content: string): Promise<void>;
|
|
180
|
+
write(path: string, content: string, opts?: ScopeOption): Promise<void>;
|
|
169
181
|
/** Delete a document. */
|
|
170
|
-
delete(path: string): Promise<void>;
|
|
182
|
+
delete(path: string, opts?: ScopeOption): Promise<void>;
|
|
171
183
|
/**
|
|
172
184
|
* Rename a document. Throws if there is an active autosave controller
|
|
173
185
|
* for oldPath (caller must flush and dispose first). Throws if newPath
|
|
174
186
|
* already exists or if oldPath does not. Subject to the handle's
|
|
175
187
|
* extensions filter — newPath must satisfy the filter.
|
|
176
188
|
*/
|
|
177
|
-
rename(oldPath: string, newPath: string): Promise<void>;
|
|
189
|
+
rename(oldPath: string, newPath: string, opts?: ScopeOption): Promise<void>;
|
|
178
190
|
/** Check existence without reading content. */
|
|
179
191
|
exists(path: string): Promise<boolean>;
|
|
180
192
|
/** Fetch sync-state metadata for a path. Null if the doc does not exist. */
|
package/dist/documents/types.js
CHANGED
|
@@ -45,6 +45,8 @@ export const PERMISSION_DOCUMENTS_READ = 'documents:read';
|
|
|
45
45
|
* `browse`.
|
|
46
46
|
*/
|
|
47
47
|
export const PERMISSION_DOCUMENTS_WRITE = 'documents:write';
|
|
48
|
+
/** Permission to manage document mount points (admin-only). */
|
|
49
|
+
export const PERMISSION_DOCUMENTS_MOUNT = 'documents:mount';
|
|
48
50
|
/** Type guard: narrows a DocumentChange to the rename variant. */
|
|
49
51
|
export function isRename(change) {
|
|
50
52
|
return change.type === 'rename';
|
|
@@ -25,7 +25,7 @@ describe('normalizeInitialLayout', () => {
|
|
|
25
25
|
});
|
|
26
26
|
it('canonicalizes a preset list, using tree as default and preserving variants', () => {
|
|
27
27
|
const authorTree = { docked: leafNode, floats: [] };
|
|
28
|
-
const
|
|
28
|
+
const compactTree = {
|
|
29
29
|
docked: { type: 'slot', slotId: 's2', viewId: 'v2' },
|
|
30
30
|
floats: [],
|
|
31
31
|
};
|
|
@@ -33,7 +33,7 @@ describe('normalizeInitialLayout', () => {
|
|
|
33
33
|
{
|
|
34
34
|
name: 'author',
|
|
35
35
|
tree: authorTree,
|
|
36
|
-
variants: {
|
|
36
|
+
variants: { compact: compactTree },
|
|
37
37
|
},
|
|
38
38
|
];
|
|
39
39
|
const result = normalizeInitialLayout(presets);
|
|
@@ -42,7 +42,7 @@ describe('normalizeInitialLayout', () => {
|
|
|
42
42
|
name: 'author',
|
|
43
43
|
variants: {
|
|
44
44
|
default: authorTree,
|
|
45
|
-
|
|
45
|
+
compact: compactTree,
|
|
46
46
|
},
|
|
47
47
|
},
|
|
48
48
|
]);
|
|
@@ -54,7 +54,7 @@ describe('normalizeInitialLayout', () => {
|
|
|
54
54
|
expect(result).toEqual([{ name: 'x', variants: { default: tree } }]);
|
|
55
55
|
});
|
|
56
56
|
it('throws if a preset has neither tree nor variants.default', () => {
|
|
57
|
-
const bad = [{ name: 'broken', variants: {
|
|
57
|
+
const bad = [{ name: 'broken', variants: { compact: { docked: leafNode, floats: [] } } }];
|
|
58
58
|
expect(() => normalizeInitialLayout(bad)).toThrow(/must provide either 'tree' or 'variants.default'/);
|
|
59
59
|
});
|
|
60
60
|
it('when a preset has both tree and variants.default, variants.default wins', () => {
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -176,7 +176,7 @@ export interface LayoutTree {
|
|
|
176
176
|
* manifest; users switch between them at runtime. The ergonomic `tree`
|
|
177
177
|
* field is shorthand for `variants.default`; the normalizer canonicalizes
|
|
178
178
|
* every preset into a variants-only shape on load. v1 always uses the
|
|
179
|
-
* `default` variant; other variant keys (e.g. `
|
|
179
|
+
* `default` variant; other variant keys (e.g. `compact`) are reserved
|
|
180
180
|
* for the rescoped DF10 selection policy and are persisted but inert.
|
|
181
181
|
*/
|
|
182
182
|
export interface LayoutPreset {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { toastManager } from '../overlays/toast';
|
|
12
12
|
import { sh3 } from '../sh3Runtime.svelte';
|
|
13
13
|
import { makeSelectionApi } from '../actions/selection.svelte';
|
|
14
|
+
import HomeSection from '../sh3core-shard/HomeSection.svelte';
|
|
14
15
|
import iconsUrl from '../assets/icons.svg';
|
|
15
16
|
|
|
16
17
|
const layouts = $derived(getLayouts());
|
|
@@ -41,8 +42,7 @@
|
|
|
41
42
|
</script>
|
|
42
43
|
|
|
43
44
|
{#if layouts.length > 0}
|
|
44
|
-
<
|
|
45
|
-
<h2 class="saved-layouts-heading">Saved Layouts</h2>
|
|
45
|
+
<HomeSection title="Saved Layouts" persistKey="layouts">
|
|
46
46
|
<div class="saved-layouts-grid">
|
|
47
47
|
{#each layouts as layout (layout.id)}
|
|
48
48
|
<button
|
|
@@ -64,23 +64,10 @@
|
|
|
64
64
|
</button>
|
|
65
65
|
{/each}
|
|
66
66
|
</div>
|
|
67
|
-
</
|
|
67
|
+
</HomeSection>
|
|
68
68
|
{/if}
|
|
69
69
|
|
|
70
70
|
<style>
|
|
71
|
-
.saved-layouts-section {
|
|
72
|
-
width: 100%;
|
|
73
|
-
max-width: 720px;
|
|
74
|
-
margin-bottom: 28px;
|
|
75
|
-
}
|
|
76
|
-
.saved-layouts-heading {
|
|
77
|
-
font-size: 13px;
|
|
78
|
-
font-weight: 600;
|
|
79
|
-
text-transform: uppercase;
|
|
80
|
-
letter-spacing: 0.06em;
|
|
81
|
-
color: var(--sh3-fg-subtle);
|
|
82
|
-
margin: 0 0 12px;
|
|
83
|
-
}
|
|
84
71
|
.saved-layouts-grid {
|
|
85
72
|
display: grid;
|
|
86
73
|
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
|
|
@@ -58,9 +58,8 @@
|
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
const
|
|
61
|
+
const modalHandle = sh3.modal.open(
|
|
62
62
|
DocumentBrowser,
|
|
63
|
-
{ anchor: trigger },
|
|
64
63
|
{
|
|
65
64
|
mode,
|
|
66
65
|
docs,
|
|
@@ -69,10 +68,11 @@
|
|
|
69
68
|
},
|
|
70
69
|
onCancel: () => {},
|
|
71
70
|
},
|
|
71
|
+
{ dismissOnBackdrop: true, boxStyle: 'max-width: min(800px, 95vw);' },
|
|
72
72
|
);
|
|
73
73
|
|
|
74
|
-
const origClose =
|
|
75
|
-
|
|
74
|
+
const origClose = modalHandle.close;
|
|
75
|
+
modalHandle.close = () => {
|
|
76
76
|
origClose();
|
|
77
77
|
onOpenClosed();
|
|
78
78
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
2
3
|
import {
|
|
3
4
|
buildTree,
|
|
4
5
|
formatSize,
|
|
@@ -17,18 +18,20 @@
|
|
|
17
18
|
onCommit,
|
|
18
19
|
onCancel,
|
|
19
20
|
close,
|
|
21
|
+
suggestedName = '',
|
|
20
22
|
}: {
|
|
21
23
|
mode: 'open' | 'save';
|
|
22
24
|
docs: DocEntry[];
|
|
23
25
|
onCommit: (value: OpenerValue | SaverValue) => void;
|
|
24
26
|
onCancel: () => void;
|
|
25
27
|
close: () => void;
|
|
28
|
+
suggestedName?: string;
|
|
26
29
|
} = $props();
|
|
27
30
|
|
|
28
31
|
let shardId = $state<string | null>(null);
|
|
29
32
|
let prefix = $state('');
|
|
30
33
|
let selectedFile = $state<DocEntry | null>(null);
|
|
31
|
-
let filename = $state(
|
|
34
|
+
let filename = $state(untrack(() => suggestedName));
|
|
32
35
|
let activeIdx = $state(0);
|
|
33
36
|
let listEl = $state<HTMLElement | undefined>(undefined);
|
|
34
37
|
|
|
@@ -234,13 +237,9 @@
|
|
|
234
237
|
|
|
235
238
|
<style>
|
|
236
239
|
.sh3-doc-browser {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
box-shadow: var(--sh3-shadow-lg);
|
|
241
|
-
width: 420px;
|
|
242
|
-
max-height: 480px;
|
|
243
|
-
display: flex; flex-direction: column;
|
|
240
|
+
display: flex;
|
|
241
|
+
flex-direction: column;
|
|
242
|
+
max-height: 85vh;
|
|
244
243
|
overflow: hidden;
|
|
245
244
|
color: var(--sh3-fg);
|
|
246
245
|
font-size: 0.8125rem;
|
|
@@ -5,6 +5,7 @@ type $$ComponentProps = {
|
|
|
5
5
|
onCommit: (value: OpenerValue | SaverValue) => void;
|
|
6
6
|
onCancel: () => void;
|
|
7
7
|
close: () => void;
|
|
8
|
+
suggestedName?: string;
|
|
8
9
|
};
|
|
9
10
|
declare const DocumentBrowser: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
10
11
|
type DocumentBrowser = ReturnType<typeof DocumentBrowser>;
|