sh3-core 0.20.1 → 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 (70) hide show
  1. package/dist/documents/backends.d.ts +12 -0
  2. package/dist/documents/backends.js +230 -3
  3. package/dist/documents/backends.test.js +147 -1
  4. package/dist/documents/config.d.ts +2 -0
  5. package/dist/documents/config.js +4 -0
  6. package/dist/documents/handle.js +40 -0
  7. package/dist/documents/handle.test.js +88 -1
  8. package/dist/documents/http-backend.d.ts +6 -0
  9. package/dist/documents/http-backend.js +61 -0
  10. package/dist/documents/http-backend.test.js +51 -1
  11. package/dist/documents/picker-api.test.js +2 -2
  12. package/dist/documents/types.d.ts +76 -14
  13. package/dist/documents/types.js +4 -0
  14. package/dist/primitives/widgets/DocumentFilePicker.d.ts +6 -2
  15. package/dist/primitives/widgets/DocumentFilePicker.js +12 -5
  16. package/dist/primitives/widgets/DocumentFilePicker.svelte +23 -1
  17. package/dist/primitives/widgets/DocumentFilePicker.svelte.d.ts +14 -0
  18. package/dist/primitives/widgets/DocumentFilePicker.test.d.ts +1 -0
  19. package/dist/primitives/widgets/DocumentFilePicker.test.js +33 -0
  20. package/dist/primitives/widgets/DocumentOpener.svelte +20 -0
  21. package/dist/primitives/widgets/DocumentOpener.svelte.d.ts +14 -0
  22. package/dist/primitives/widgets/DocumentSaver.svelte +17 -0
  23. package/dist/primitives/widgets/DocumentSaver.svelte.d.ts +13 -0
  24. package/dist/primitives/widgets/_DocumentBrowser.svelte +414 -27
  25. package/dist/primitives/widgets/_DocumentBrowser.svelte.d.ts +12 -0
  26. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.d.ts +1 -0
  27. package/dist/primitives/widgets/_DocumentBrowser.svelte.test.js +277 -0
  28. package/dist/primitives/widgets/_FolderConfirmDelete.svelte +57 -0
  29. package/dist/primitives/widgets/_FolderConfirmDelete.svelte.d.ts +12 -0
  30. package/dist/sh3Api/headless.js +10 -0
  31. package/dist/shards/activate.svelte.js +2 -2
  32. package/dist/shell-shard/Terminal.svelte +4 -1
  33. package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
  34. package/dist/shell-shard/dispatch.d.ts +2 -0
  35. package/dist/shell-shard/dispatch.js +2 -0
  36. package/dist/shell-shard/manifest.js +7 -1
  37. package/dist/shell-shard/shellShard.svelte.js +1 -1
  38. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  39. package/dist/shell-shard/verbs/cat.js +35 -0
  40. package/dist/shell-shard/verbs/cat.test.d.ts +1 -0
  41. package/dist/shell-shard/verbs/cat.test.js +49 -0
  42. package/dist/shell-shard/verbs/index.js +12 -0
  43. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  44. package/dist/shell-shard/verbs/ls.js +48 -0
  45. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  46. package/dist/shell-shard/verbs/ls.test.js +64 -0
  47. package/dist/shell-shard/verbs/mkdir.d.ts +2 -0
  48. package/dist/shell-shard/verbs/mkdir.js +30 -0
  49. package/dist/shell-shard/verbs/mkdir.test.d.ts +1 -0
  50. package/dist/shell-shard/verbs/mkdir.test.js +48 -0
  51. package/dist/shell-shard/verbs/mv.d.ts +2 -0
  52. package/dist/shell-shard/verbs/mv.js +33 -0
  53. package/dist/shell-shard/verbs/mv.test.d.ts +1 -0
  54. package/dist/shell-shard/verbs/mv.test.js +55 -0
  55. package/dist/shell-shard/verbs/rm.d.ts +2 -0
  56. package/dist/shell-shard/verbs/rm.js +28 -0
  57. package/dist/shell-shard/verbs/rm.test.d.ts +1 -0
  58. package/dist/shell-shard/verbs/rm.test.js +47 -0
  59. package/dist/shell-shard/verbs/scope-parse.d.ts +7 -0
  60. package/dist/shell-shard/verbs/scope-parse.js +33 -0
  61. package/dist/shell-shard/verbs/scope-parse.test.d.ts +1 -0
  62. package/dist/shell-shard/verbs/scope-parse.test.js +76 -0
  63. package/dist/shell-shard/verbs/xfer.d.ts +2 -0
  64. package/dist/shell-shard/verbs/xfer.js +101 -0
  65. package/dist/shell-shard/verbs/xfer.test.d.ts +1 -0
  66. package/dist/shell-shard/verbs/xfer.test.js +96 -0
  67. package/dist/verbs/types.d.ts +18 -0
  68. package/dist/version.d.ts +1 -1
  69. package/dist/version.js +1 -1
  70. package/package.json +1 -1
@@ -32,4 +32,10 @@ export declare class HttpDocumentBackend implements DocumentBackend {
32
32
  } | string): Promise<void>;
33
33
  readBranch(tenantId: string, shardId: string, path: string, origin: string): Promise<string | null>;
34
34
  rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
35
+ mkdir(tenantId: string, shardId: string, path: string): Promise<void>;
36
+ rmdir(tenantId: string, shardId: string, path: string, opts: {
37
+ recursive: boolean;
38
+ }): Promise<void>;
39
+ renameFolder(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
40
+ listFolders(tenantId: string, shardId: string, prefix: string): Promise<string[]>;
35
41
  }
@@ -147,6 +147,67 @@ export class HttpDocumentBackend {
147
147
  throw new Error(`Document rename failed: ${res.status}`);
148
148
  }
149
149
  }
150
+ async mkdir(tenantId, shardId, path) {
151
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}/mkdir`;
152
+ const res = await apiFetch(url, {
153
+ method: 'POST',
154
+ headers: __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this),
155
+ credentials: 'include',
156
+ });
157
+ if (!res.ok) {
158
+ let detail = `HTTP ${res.status}`;
159
+ try {
160
+ const b = await res.json();
161
+ if (b.error)
162
+ detail = b.error;
163
+ }
164
+ catch ( /* not JSON */_a) { /* not JSON */ }
165
+ throw new Error(`mkdir failed: ${detail}`);
166
+ }
167
+ }
168
+ async rmdir(tenantId, shardId, path, opts) {
169
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${path}/rmdir`;
170
+ const res = await apiFetch(url, {
171
+ method: 'POST',
172
+ headers: Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' }),
173
+ body: JSON.stringify({ recursive: opts.recursive }),
174
+ credentials: 'include',
175
+ });
176
+ if (!res.ok) {
177
+ let detail = `HTTP ${res.status}`;
178
+ try {
179
+ const b = await res.json();
180
+ if (b.error)
181
+ detail = b.error;
182
+ }
183
+ catch ( /* not JSON */_a) { /* not JSON */ }
184
+ throw new Error(`rmdir failed: ${detail}`);
185
+ }
186
+ }
187
+ async renameFolder(tenantId, shardId, oldPath, newPath) {
188
+ const url = `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${oldPath}/rename-folder`;
189
+ const res = await apiFetch(url, {
190
+ method: 'POST',
191
+ headers: Object.assign(Object.assign({}, __classPrivateFieldGet(this, _HttpDocumentBackend_instances, "m", _HttpDocumentBackend_authHeaders).call(this)), { 'Content-Type': 'application/json' }),
192
+ body: JSON.stringify({ to: newPath }),
193
+ credentials: 'include',
194
+ });
195
+ if (res.status === 404)
196
+ throw new Error(`Folder not found at ${oldPath}`);
197
+ if (res.status === 409)
198
+ throw new Error(`Folder already exists at ${newPath}`);
199
+ if (!res.ok)
200
+ throw new Error(`renameFolder failed: ${res.status}`);
201
+ }
202
+ async listFolders(tenantId, shardId, prefix) {
203
+ const base = prefix
204
+ ? `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}/${prefix}?folders=1`
205
+ : `${__classPrivateFieldGet(this, _HttpDocumentBackend_baseUrl, "f")}/api/docs/${tenantId}/${shardId}?folders=1`;
206
+ const res = await apiFetch(base, { credentials: 'include' });
207
+ if (!res.ok)
208
+ throw new Error(`listFolders failed: ${res.status}`);
209
+ return res.json();
210
+ }
150
211
  }
151
212
  _HttpDocumentBackend_baseUrl = new WeakMap(), _HttpDocumentBackend_apiKey = new WeakMap(), _HttpDocumentBackend_instances = new WeakSet(), _HttpDocumentBackend_authHeaders = function _HttpDocumentBackend_authHeaders() {
152
213
  if (!__classPrivateFieldGet(this, _HttpDocumentBackend_apiKey, "f"))
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, afterEach } from 'vitest';
1
+ import { describe, it, expect, afterEach, vi } from 'vitest';
2
2
  import { HttpDocumentBackend } from './http-backend';
3
3
  const originalFetch = globalThis.fetch;
4
4
  describe('HttpDocumentBackend.readBranch', () => {
@@ -79,3 +79,53 @@ describe('HttpDocumentBackend.rename', () => {
79
79
  .rejects.toThrow(/rename failed/i);
80
80
  });
81
81
  });
82
+ describe('HttpDocumentBackend folder ops', () => {
83
+ afterEach(() => {
84
+ globalThis.fetch = originalFetch;
85
+ });
86
+ it('mkdir POSTs to /:path/mkdir', async () => {
87
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
88
+ globalThis.fetch = fetchMock;
89
+ const backend = new HttpDocumentBackend('http://server');
90
+ await backend.mkdir('t', 's', 'a/b');
91
+ expect(fetchMock).toHaveBeenCalledWith('http://server/api/docs/t/s/a/b/mkdir', expect.objectContaining({ method: 'POST' }));
92
+ });
93
+ it('rmdir POSTs to /:path/rmdir with recursive flag', async () => {
94
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
95
+ globalThis.fetch = fetchMock;
96
+ const backend = new HttpDocumentBackend('http://server');
97
+ await backend.rmdir('t', 's', 'a', { recursive: true });
98
+ const call = fetchMock.mock.calls[0];
99
+ expect(call[0]).toBe('http://server/api/docs/t/s/a/rmdir');
100
+ const body = JSON.parse(call[1].body);
101
+ expect(body).toEqual({ recursive: true });
102
+ });
103
+ it('rmdir throws on 409', async () => {
104
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ error: 'not empty' }), { status: 409 }));
105
+ const backend = new HttpDocumentBackend('http://server');
106
+ await expect(backend.rmdir('t', 's', 'a', { recursive: false })).rejects.toThrow();
107
+ });
108
+ it('renameFolder POSTs to /:path/rename-folder with { to }', async () => {
109
+ const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }), { status: 200 }));
110
+ globalThis.fetch = fetchMock;
111
+ const backend = new HttpDocumentBackend('http://server');
112
+ await backend.renameFolder('t', 's', 'old', 'new');
113
+ const call = fetchMock.mock.calls[0];
114
+ expect(call[0]).toBe('http://server/api/docs/t/s/old/rename-folder');
115
+ expect(JSON.parse(call[1].body)).toEqual({ to: 'new' });
116
+ });
117
+ it('listFolders at root uses ?folders=1 on the shard list URL', async () => {
118
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(['a', 'b']), { status: 200 }));
119
+ const backend = new HttpDocumentBackend('http://server');
120
+ const result = await backend.listFolders('t', 's', '');
121
+ expect(globalThis.fetch).toHaveBeenCalledWith('http://server/api/docs/t/s?folders=1', expect.any(Object));
122
+ expect(result).toEqual(['a', 'b']);
123
+ });
124
+ it('listFolders with prefix uses ?folders=1 on the path URL', async () => {
125
+ globalThis.fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify(['c']), { status: 200 }));
126
+ const backend = new HttpDocumentBackend('http://server');
127
+ const result = await backend.listFolders('t', 's', 'a');
128
+ expect(globalThis.fetch).toHaveBeenCalledWith('http://server/api/docs/t/s/a?folders=1', expect.any(Object));
129
+ expect(result).toEqual(['c']);
130
+ });
131
+ });
@@ -43,7 +43,7 @@ beforeEach(() => {
43
43
  vi.clearAllMocks();
44
44
  });
45
45
  describe('createDocumentPicker', () => {
46
- const sampleDoc = { shardId: 'my-shard', path: 'readme.md' };
46
+ const sampleDoc = { shardId: 'my-shard', path: 'readme.md', kind: 'file' };
47
47
  describe('open() — modal (no anchor)', () => {
48
48
  it('resolves with OpenerValue when user commits', async () => {
49
49
  const listFn = async () => [{ shardId: 'my-shard', path: 'readme.md', size: 100, lastModified: 0 }];
@@ -53,7 +53,7 @@ describe('createDocumentPicker', () => {
53
53
  await vi.waitFor(() => expect(mockModalOpen).toHaveBeenCalledOnce());
54
54
  modal.commit(sampleDoc);
55
55
  const result = await promise;
56
- expect(result).toEqual({ shardId: 'my-shard', path: 'readme.md' });
56
+ expect(result).toEqual({ shardId: 'my-shard', path: 'readme.md', kind: 'file' });
57
57
  expect(mockPopupShow).not.toHaveBeenCalled();
58
58
  });
59
59
  it('resolves with null when user cancels', async () => {
@@ -73,26 +73,37 @@ export interface DocumentMeta {
73
73
  deleted?: boolean;
74
74
  }
75
75
  /** Change notification payload delivered to watch callbacks. */
76
- export interface DocumentChange {
77
- type: 'create' | 'update' | 'delete' | 'rename';
78
- /**
79
- * For 'create' / 'update' / 'delete', the affected document path.
80
- * For 'rename', the new path the document now lives at.
81
- */
76
+ export type DocumentChange = {
77
+ type: 'create' | 'update' | 'delete';
82
78
  path: string;
83
- /**
84
- * Populated only when type === 'rename'. The path the document
85
- * used to live at before the rename.
86
- */
87
- oldPath?: string;
88
79
  tenantId: string;
89
80
  shardId: string;
90
- }
91
- /** Type guard: narrows a DocumentChange to the rename variant. */
92
- export declare function isRename(change: DocumentChange): change is DocumentChange & {
81
+ } | {
93
82
  type: 'rename';
83
+ path: string;
94
84
  oldPath: string;
85
+ tenantId: string;
86
+ shardId: string;
87
+ } | {
88
+ type: 'folder-create' | 'folder-delete';
89
+ path: string;
90
+ tenantId: string;
91
+ shardId: string;
92
+ } | {
93
+ type: 'folder-rename';
94
+ path: string;
95
+ oldPath: string;
96
+ tenantId: string;
97
+ shardId: string;
95
98
  };
99
+ /** Type guard: narrows a DocumentChange to the rename variant. */
100
+ export declare function isRename(change: DocumentChange): change is Extract<DocumentChange, {
101
+ type: 'rename';
102
+ }>;
103
+ /** Type guard: narrows a DocumentChange to the folder-rename variant. */
104
+ export declare function isFolderRename(change: DocumentChange): change is Extract<DocumentChange, {
105
+ type: 'folder-rename';
106
+ }>;
96
107
  import type { DocStatus } from './sync-types';
97
108
  /**
98
109
  * File-oriented backend for the document zone.
@@ -120,6 +131,31 @@ export interface DocumentBackend {
120
131
  * as folder semantics.
121
132
  */
122
133
  rename(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
134
+ /**
135
+ * Create an empty folder. No-op if the folder already exists.
136
+ * Throws if a document occupies the path.
137
+ */
138
+ mkdir(tenantId: string, shardId: string, path: string): Promise<void>;
139
+ /**
140
+ * Remove a folder. Throws if non-empty and `recursive` is false.
141
+ * When `recursive: true`, atomically removes the folder and all
142
+ * descendant documents and folders.
143
+ */
144
+ rmdir(tenantId: string, shardId: string, path: string, opts: {
145
+ recursive: boolean;
146
+ }): Promise<void>;
147
+ /**
148
+ * Rename a folder atomically. Rewrites all descendant document paths
149
+ * to use the new prefix. Throws if `oldPath` does not exist as a
150
+ * folder, or if `newPath` already exists.
151
+ */
152
+ renameFolder(tenantId: string, shardId: string, oldPath: string, newPath: string): Promise<void>;
153
+ /**
154
+ * List immediate folder children of `prefix`. Returns folder names
155
+ * (not full paths). Empty `prefix` lists folders directly under the
156
+ * shard root.
157
+ */
158
+ listFolders(tenantId: string, shardId: string, prefix: string): Promise<string[]>;
123
159
  /** List all documents stored for this tenant + shard combination. */
124
160
  list(tenantId: string, shardId: string): Promise<DocumentMeta[]>;
125
161
  /** Return true if the document at `path` exists. */
@@ -187,6 +223,32 @@ export interface DocumentHandle {
187
223
  * extensions filter — newPath must satisfy the filter.
188
224
  */
189
225
  rename(oldPath: string, newPath: string, opts?: ScopeOption): Promise<void>;
226
+ /**
227
+ * Create an empty folder at `path`. No-op if the folder already
228
+ * exists. Throws if a document occupies the path.
229
+ */
230
+ mkdir(path: string, opts?: ScopeOption): Promise<void>;
231
+ /**
232
+ * Remove a folder. Throws if non-empty and `opts.recursive` is not
233
+ * true. When `opts.recursive` is true, atomically removes the folder
234
+ * and all descendants. Throws if any active autosave controller's
235
+ * path falls inside the folder.
236
+ */
237
+ rmdir(path: string, opts?: {
238
+ recursive?: boolean;
239
+ } & ScopeOption): Promise<void>;
240
+ /**
241
+ * Rename a folder. Atomically rewrites all descendant document paths.
242
+ * Throws if any active autosave controller's path falls inside the
243
+ * folder (caller must flush and dispose first). Throws if `newPath`
244
+ * already exists or if `oldPath` does not.
245
+ */
246
+ renameFolder(oldPath: string, newPath: string, opts?: ScopeOption): Promise<void>;
247
+ /**
248
+ * List immediate folder children of `prefix`. Empty `prefix` (or
249
+ * omitted) lists folders directly under the shard root.
250
+ */
251
+ listFolders(prefix?: string, opts?: ScopeOption): Promise<string[]>;
190
252
  /** Check existence without reading content. */
191
253
  exists(path: string): Promise<boolean>;
192
254
  /** Fetch sync-state metadata for a path. Null if the doc does not exist. */
@@ -51,3 +51,7 @@ export const PERMISSION_DOCUMENTS_MOUNT = 'documents:mount';
51
51
  export function isRename(change) {
52
52
  return change.type === 'rename';
53
53
  }
54
+ /** Type guard: narrows a DocumentChange to the folder-rename variant. */
55
+ export function isFolderRename(change) {
56
+ return change.type === 'folder-rename';
57
+ }
@@ -2,7 +2,11 @@ import type { DocumentMeta } from '../../documents/types';
2
2
  export type DocEntry = DocumentMeta & {
3
3
  shardId: string;
4
4
  };
5
- export type OpenerValue = Pick<DocEntry, 'shardId' | 'path'> | null;
5
+ export type OpenerValue = {
6
+ shardId: string;
7
+ path: string;
8
+ kind: 'file' | 'folder';
9
+ } | null;
6
10
  export type SaverValue = string | null;
7
11
  export type FileItem = {
8
12
  kind: 'folder';
@@ -13,7 +17,7 @@ export type FileItem = {
13
17
  name: string;
14
18
  doc: DocEntry;
15
19
  };
16
- export declare function buildTree(docs: DocEntry[], shardId: string | null, prefix: string): FileItem[];
20
+ export declare function buildTree(docs: DocEntry[], folders: string[], shardId: string | null, prefix: string): FileItem[];
17
21
  export declare function formatSize(bytes: number): string;
18
22
  export declare function formatDate(epochMs: number): string;
19
23
  export declare function iconForFile(name: string): string;
@@ -1,10 +1,10 @@
1
- export function buildTree(docs, shardId, prefix) {
1
+ export function buildTree(docs, folders, shardId, prefix) {
2
2
  if (shardId === null) {
3
3
  const shards = [...new Set(docs.map((d) => d.shardId))].sort();
4
4
  return shards.map((s) => ({ kind: 'folder', name: s, fullPath: s }));
5
5
  }
6
6
  const shardDocs = docs.filter((d) => d.shardId === shardId);
7
- const folders = new Map();
7
+ const folderMap = new Map();
8
8
  const files = [];
9
9
  const normPrefix = prefix ? prefix + '/' : '';
10
10
  const plen = normPrefix.length;
@@ -16,14 +16,21 @@ export function buildTree(docs, shardId, prefix) {
16
16
  if (slash >= 0) {
17
17
  const name = relative.slice(0, slash);
18
18
  const full = prefix ? `${prefix}/${name}` : name;
19
- if (!folders.has(name))
20
- folders.set(name, full);
19
+ if (!folderMap.has(name))
20
+ folderMap.set(name, full);
21
21
  }
22
22
  else {
23
23
  files.push({ kind: 'file', name: relative, doc });
24
24
  }
25
25
  }
26
- const folderItems = [...folders.entries()]
26
+ // Merge explicit empty folders (immediate children of prefix)
27
+ for (const name of folders) {
28
+ if (folderMap.has(name))
29
+ continue;
30
+ const full = prefix ? `${prefix}/${name}` : name;
31
+ folderMap.set(name, full);
32
+ }
33
+ const folderItems = [...folderMap.entries()]
27
34
  .map(([name, fullPath]) => ({ kind: 'folder', name, fullPath }))
28
35
  .sort((a, b) => a.name.localeCompare(b.name));
29
36
  const fileItems = files.sort((a, b) => a.name.localeCompare(b.name));
@@ -6,24 +6,40 @@
6
6
  import type { DocEntry, OpenerValue, SaverValue } from './DocumentFilePicker';
7
7
 
8
8
  type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
9
+ type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
10
+ type HandleFn = {
11
+ mkdir: (shardId: string, path: string) => Promise<void>;
12
+ rmdir: (shardId: string, path: string, opts: { recursive: boolean }) => Promise<void>;
13
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
15
+ delete: (shardId: string, path: string) => Promise<void>;
16
+ };
9
17
 
10
18
  let {
11
19
  mode,
12
20
  value = $bindable<OpenerValue | SaverValue>(null),
13
21
  listDocuments,
22
+ listFolders,
23
+ handle,
24
+ readOnlyShard,
14
25
  disabled = false,
15
26
  invalid = false,
16
27
  size = 'md',
17
28
  buttonLabel = 'Choose…',
29
+ selectable = 'file',
18
30
  onchange,
19
31
  }: {
20
32
  mode: 'open' | 'save';
21
33
  value?: OpenerValue | SaverValue;
22
34
  listDocuments: DocListFn;
35
+ listFolders?: FolderListFn;
36
+ handle?: HandleFn;
37
+ readOnlyShard?: (shardId: string) => boolean;
23
38
  disabled?: boolean;
24
39
  invalid?: boolean;
25
40
  size?: 'sm' | 'md';
26
41
  buttonLabel?: string;
42
+ selectable?: 'file' | 'folder' | 'both';
27
43
  } & CommitOnlyEvents<OpenerValue | SaverValue> = $props();
28
44
 
29
45
  let trigger = $state<HTMLButtonElement | undefined>(undefined);
@@ -33,7 +49,9 @@
33
49
  value
34
50
  ? typeof value === 'string'
35
51
  ? value
36
- : `${value.shardId}/${value.path}`
52
+ : value.kind === 'folder'
53
+ ? `${value.shardId}/${value.path}/`
54
+ : `${value.shardId}/${value.path}`
37
55
  : null,
38
56
  );
39
57
 
@@ -63,6 +81,10 @@
63
81
  {
64
82
  mode,
65
83
  docs,
84
+ selectable,
85
+ listFolders,
86
+ handle,
87
+ readOnlyShard,
66
88
  onCommit: (result: OpenerValue | SaverValue) => {
67
89
  handleCommit(result);
68
90
  },
@@ -4,14 +4,28 @@ import type { OpenerValue, SaverValue } from './DocumentFilePicker';
4
4
  type DocListFn = () => Promise<Array<DocumentMeta & {
5
5
  shardId: string;
6
6
  }>>;
7
+ type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
8
+ type HandleFn = {
9
+ mkdir: (shardId: string, path: string) => Promise<void>;
10
+ rmdir: (shardId: string, path: string, opts: {
11
+ recursive: boolean;
12
+ }) => Promise<void>;
13
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
15
+ delete: (shardId: string, path: string) => Promise<void>;
16
+ };
7
17
  type $$ComponentProps = {
8
18
  mode: 'open' | 'save';
9
19
  value?: OpenerValue | SaverValue;
10
20
  listDocuments: DocListFn;
21
+ listFolders?: FolderListFn;
22
+ handle?: HandleFn;
23
+ readOnlyShard?: (shardId: string) => boolean;
11
24
  disabled?: boolean;
12
25
  invalid?: boolean;
13
26
  size?: 'sm' | 'md';
14
27
  buttonLabel?: string;
28
+ selectable?: 'file' | 'folder' | 'both';
15
29
  } & CommitOnlyEvents<OpenerValue | SaverValue>;
16
30
  declare const DocumentFilePicker: import("svelte").Component<$$ComponentProps, {}, "value">;
17
31
  type DocumentFilePicker = ReturnType<typeof DocumentFilePicker>;
@@ -0,0 +1,33 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { buildTree } from './DocumentFilePicker';
3
+ describe('buildTree with explicit folders', () => {
4
+ const docs = [
5
+ { shardId: 'sh1', path: 'a.md', size: 0, lastModified: 0 },
6
+ { shardId: 'sh1', path: 'sub/b.md', size: 0, lastModified: 0 },
7
+ ];
8
+ it('shard root returns shard list (folders ignored at root)', () => {
9
+ const items = buildTree(docs, [], null, '');
10
+ expect(items.map((i) => i.kind === 'folder' && i.name)).toEqual(['sh1']);
11
+ });
12
+ it('within a shard, merges implicit folders with empty explicit folders', () => {
13
+ const items = buildTree(docs, ['emptyDir'], 'sh1', '');
14
+ const folderNames = items.filter((i) => i.kind === 'folder').map((i) => i.name).sort();
15
+ expect(folderNames).toEqual(['emptyDir', 'sub']);
16
+ });
17
+ it('deduplicates explicit and implicit folders with the same name', () => {
18
+ const items = buildTree(docs, ['sub'], 'sh1', '');
19
+ const folderNames = items.filter((i) => i.kind === 'folder').map((i) => i.name);
20
+ expect(folderNames).toEqual(['sub']);
21
+ });
22
+ it('file items carry kind:"file"', () => {
23
+ const items = buildTree(docs, [], 'sh1', '');
24
+ const fileItems = items.filter((i) => i.kind === 'file');
25
+ expect(fileItems[0]).toMatchObject({ kind: 'file', name: 'a.md' });
26
+ });
27
+ });
28
+ describe('OpenerValue type accepts folder kind', () => {
29
+ it('compiles with kind:"folder"', () => {
30
+ const v = { shardId: 'sh1', path: 'sub', kind: 'folder' };
31
+ expect(v === null || v === void 0 ? void 0 : v.kind).toBe('folder');
32
+ });
33
+ });
@@ -5,10 +5,22 @@
5
5
  import type { OpenerValue } from './DocumentFilePicker';
6
6
 
7
7
  type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
8
+ type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
9
+ type HandleFn = {
10
+ mkdir: (shardId: string, path: string) => Promise<void>;
11
+ rmdir: (shardId: string, path: string, opts: { recursive: boolean }) => Promise<void>;
12
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
13
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
+ delete: (shardId: string, path: string) => Promise<void>;
15
+ };
8
16
 
9
17
  let {
10
18
  value = $bindable<OpenerValue>(null),
11
19
  listDocuments,
20
+ listFolders,
21
+ handle,
22
+ readOnlyShard,
23
+ selectable = 'file',
12
24
  disabled = false,
13
25
  invalid = false,
14
26
  size = 'md',
@@ -17,6 +29,10 @@
17
29
  }: {
18
30
  value?: OpenerValue;
19
31
  listDocuments: DocListFn;
32
+ listFolders?: FolderListFn;
33
+ handle?: HandleFn;
34
+ readOnlyShard?: (shardId: string) => boolean;
35
+ selectable?: 'file' | 'folder' | 'both';
20
36
  disabled?: boolean;
21
37
  invalid?: boolean;
22
38
  size?: 'sm' | 'md';
@@ -28,6 +44,10 @@
28
44
  mode="open"
29
45
  bind:value
30
46
  {listDocuments}
47
+ {listFolders}
48
+ {handle}
49
+ {readOnlyShard}
50
+ {selectable}
31
51
  {disabled}
32
52
  {invalid}
33
53
  {size}
@@ -4,9 +4,23 @@ import type { OpenerValue } from './DocumentFilePicker';
4
4
  type DocListFn = () => Promise<Array<DocumentMeta & {
5
5
  shardId: string;
6
6
  }>>;
7
+ type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
8
+ type HandleFn = {
9
+ mkdir: (shardId: string, path: string) => Promise<void>;
10
+ rmdir: (shardId: string, path: string, opts: {
11
+ recursive: boolean;
12
+ }) => Promise<void>;
13
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
15
+ delete: (shardId: string, path: string) => Promise<void>;
16
+ };
7
17
  type $$ComponentProps = {
8
18
  value?: OpenerValue;
9
19
  listDocuments: DocListFn;
20
+ listFolders?: FolderListFn;
21
+ handle?: HandleFn;
22
+ readOnlyShard?: (shardId: string) => boolean;
23
+ selectable?: 'file' | 'folder' | 'both';
10
24
  disabled?: boolean;
11
25
  invalid?: boolean;
12
26
  size?: 'sm' | 'md';
@@ -5,10 +5,21 @@
5
5
  import type { SaverValue } from './DocumentFilePicker';
6
6
 
7
7
  type DocListFn = () => Promise<Array<DocumentMeta & { shardId: string }>>;
8
+ type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
9
+ type HandleFn = {
10
+ mkdir: (shardId: string, path: string) => Promise<void>;
11
+ rmdir: (shardId: string, path: string, opts: { recursive: boolean }) => Promise<void>;
12
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
13
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
+ delete: (shardId: string, path: string) => Promise<void>;
15
+ };
8
16
 
9
17
  let {
10
18
  value = $bindable<SaverValue>(null),
11
19
  listDocuments,
20
+ listFolders,
21
+ handle,
22
+ readOnlyShard,
12
23
  disabled = false,
13
24
  invalid = false,
14
25
  size = 'md',
@@ -17,6 +28,9 @@
17
28
  }: {
18
29
  value?: SaverValue;
19
30
  listDocuments: DocListFn;
31
+ listFolders?: FolderListFn;
32
+ handle?: HandleFn;
33
+ readOnlyShard?: (shardId: string) => boolean;
20
34
  disabled?: boolean;
21
35
  invalid?: boolean;
22
36
  size?: 'sm' | 'md';
@@ -28,6 +42,9 @@
28
42
  mode="save"
29
43
  bind:value
30
44
  {listDocuments}
45
+ {listFolders}
46
+ {handle}
47
+ {readOnlyShard}
31
48
  {disabled}
32
49
  {invalid}
33
50
  {size}
@@ -4,9 +4,22 @@ import type { SaverValue } from './DocumentFilePicker';
4
4
  type DocListFn = () => Promise<Array<DocumentMeta & {
5
5
  shardId: string;
6
6
  }>>;
7
+ type FolderListFn = (shardId: string, prefix: string) => Promise<string[]>;
8
+ type HandleFn = {
9
+ mkdir: (shardId: string, path: string) => Promise<void>;
10
+ rmdir: (shardId: string, path: string, opts: {
11
+ recursive: boolean;
12
+ }) => Promise<void>;
13
+ renameFolder: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
14
+ rename: (shardId: string, oldPath: string, newPath: string) => Promise<void>;
15
+ delete: (shardId: string, path: string) => Promise<void>;
16
+ };
7
17
  type $$ComponentProps = {
8
18
  value?: SaverValue;
9
19
  listDocuments: DocListFn;
20
+ listFolders?: FolderListFn;
21
+ handle?: HandleFn;
22
+ readOnlyShard?: (shardId: string) => boolean;
10
23
  disabled?: boolean;
11
24
  invalid?: boolean;
12
25
  size?: 'sm' | 'md';