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
@@ -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;
@@ -31,6 +31,8 @@ import { listFields as listFieldsImpl, getField as getFieldImpl, setField as set
31
31
  import { attachDecoration as attachDecorationImpl } from '../fields/decoration';
32
32
  import { onChange as onContributionsChange } from '../contributions';
33
33
  import { FIELD_POINT_ID, WALKER_SHARD_ID } from '../fields/types';
34
+ import { getActiveScopeId, getPersonalScopeId } from '../documents/config';
35
+ import { projectsState } from '../projects-shard/projectsShard.svelte';
34
36
  const KNOWN_ZONES = ['ephemeral', 'session', 'workspace', 'user'];
35
37
  function collectTabEntries(node) {
36
38
  if (node.type === 'tabs') {
@@ -317,6 +319,14 @@ export function makeSh3Api(opts) {
317
319
  return listViewsImpl();
318
320
  },
319
321
  fields,
322
+ getActiveScope() {
323
+ const id = getActiveScopeId();
324
+ const personalId = getPersonalScopeId();
325
+ return { id, isProject: id !== personalId, personalId };
326
+ },
327
+ listProjects() {
328
+ return projectsState.projects.map((p) => ({ id: p.id, name: p.name }));
329
+ },
320
330
  };
321
331
  }
322
332
  /** @deprecated Renamed to makeSh3Api(opts?). Kept for one minor cycle. */
@@ -19,7 +19,7 @@
19
19
  import { sh3 } from '../sh3Runtime.svelte';
20
20
  import { registerView, unregisterView, registerVerb as fwRegisterVerb, unregisterVerb as fwUnregisterVerb } from './registry';
21
21
  import { makeSh3Api } from '../sh3Api/headless';
22
- import { createDocumentHandle, getTenantId, getDocumentBackend } from '../documents';
22
+ import { createDocumentHandle, getTenantId, getDocumentBackend, getActiveScopeId } from '../documents';
23
23
  import { fetchEnvState, putEnvState } from '../env/client';
24
24
  import { getEnvServerUrl } from '../env/index';
25
25
  import { apiFetch } from '../transport/apiFetch';
@@ -179,7 +179,7 @@ export async function activateShard(id, opts) {
179
179
  }
180
180
  },
181
181
  documents: (options) => {
182
- const handle = createDocumentHandle(getTenantId(), id, getDocumentBackend(), options);
182
+ const handle = createDocumentHandle(getActiveScopeId(), id, getDocumentBackend(), options);
183
183
  entry.cleanupFns.push(() => handle.dispose());
184
184
  return handle;
185
185
  },
@@ -28,6 +28,7 @@
28
28
  import BusySlot from './toolbar/slots/BusySlot.svelte';
29
29
  import { registerTerminalView, mintTerminalId, type TerminalHandle } from './terminal-registry';
30
30
  import { makeDispatchToTerminal } from './dispatch-to-terminal';
31
+ import type { BrowseCapability } from '../documents/browse';
31
32
 
32
33
  interface Props {
33
34
  shell: Sh3Api;
@@ -35,8 +36,9 @@
35
36
  userId: string;
36
37
  role: ShellRole;
37
38
  contributions: ContributionsApi;
39
+ docs?: BrowseCapability;
38
40
  }
39
- let { shell, wsUrl, userId, role, contributions }: Props = $props();
41
+ let { shell, wsUrl, userId, role, contributions, docs }: Props = $props();
40
42
 
41
43
  // Per-mode buffer map. Each ModeBuffer bundles a Scrollback + history +
42
44
  // locked flag and is materialized lazily on first switch into that mode.
@@ -250,6 +252,7 @@
250
252
  session,
251
253
  sh3: shellWithModes,
252
254
  fs,
255
+ docs,
253
256
  cwd: () => session.cwd,
254
257
  busy: acquireBusy,
255
258
  customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
@@ -1,12 +1,14 @@
1
1
  import { type Sh3Api } from './registry';
2
2
  import type { ShellRole } from './modes/types';
3
3
  import type { ContributionsApi } from '../contributions/types';
4
+ import type { BrowseCapability } from '../documents/browse';
4
5
  interface Props {
5
6
  shell: Sh3Api;
6
7
  wsUrl: string;
7
8
  userId: string;
8
9
  role: ShellRole;
9
10
  contributions: ContributionsApi;
11
+ docs?: BrowseCapability;
10
12
  }
11
13
  declare const Terminal: import("svelte").Component<Props, {}, "">;
12
14
  type Terminal = ReturnType<typeof Terminal>;
@@ -4,6 +4,7 @@ import type { TenantFsClient } from './tenant-fs-client';
4
4
  import type { ModeBuffer } from './mode-buffer.svelte';
5
5
  import type { ShellMode, ShellRole } from './modes/types';
6
6
  import type { ShellModeDescriptor } from './contract';
7
+ import type { BrowseCapability } from '../documents/browse';
7
8
  export interface DispatchDeps {
8
9
  mode: () => ShellMode;
9
10
  /** Current shell role — used by invoke() role-gating. */
@@ -18,6 +19,7 @@ export interface DispatchDeps {
18
19
  session: SessionClient;
19
20
  sh3: Sh3Api;
20
21
  fs: TenantFsClient;
22
+ docs?: BrowseCapability;
21
23
  cwd: () => string;
22
24
  /**
23
25
  * Acquire a busy indicator. Returns a clear handle. Calling clear()
@@ -39,6 +39,7 @@ export function makeDispatch(deps) {
39
39
  cwd: deps.cwd(),
40
40
  dispatch,
41
41
  fs: deps.fs,
42
+ docs: deps.docs,
42
43
  }, resolution.args);
43
44
  return;
44
45
  }
@@ -107,6 +108,7 @@ export function makeDispatch(deps) {
107
108
  cwd: deps.cwd(),
108
109
  dispatch,
109
110
  fs: deps.fs,
111
+ docs: deps.docs,
110
112
  }, resolution.args);
111
113
  }
112
114
  catch (err) {
@@ -1,5 +1,6 @@
1
1
  import { VERSION } from '../version';
2
2
  import { PERMISSION_STATE_MANAGE } from '../state/types';
3
+ import { PERMISSION_DOCUMENTS_BROWSE, PERMISSION_DOCUMENTS_READ, PERMISSION_DOCUMENTS_WRITE, } from '../documents/types';
3
4
  export const manifest = {
4
5
  id: 'shell',
5
6
  label: 'Sh3',
@@ -9,5 +10,10 @@ export const manifest = {
9
10
  // and is statically mounted at sh3-server boot. The existing contract in
10
11
  // sh3-core/src/shards/types.ts documents that framework-shipped shards do
11
12
  // not use this field.
12
- permissions: [PERMISSION_STATE_MANAGE],
13
+ permissions: [
14
+ PERMISSION_STATE_MANAGE,
15
+ PERMISSION_DOCUMENTS_BROWSE,
16
+ PERMISSION_DOCUMENTS_READ,
17
+ PERMISSION_DOCUMENTS_WRITE,
18
+ ],
13
19
  };
@@ -64,7 +64,7 @@ export const shellShard = {
64
64
  const role = isAdmin() ? 'admin' : 'user';
65
65
  const instance = mount(Terminal, {
66
66
  target: container,
67
- props: { shell, wsUrl, userId, role, contributions: ctx.contributions },
67
+ props: { shell, wsUrl, userId, role, contributions: ctx.contributions, docs: ctx.browse },
68
68
  });
69
69
  return {
70
70
  unmount() {
@@ -0,0 +1,2 @@
1
+ import type { Verb } from '../../verbs/types';
2
+ export declare const catVerb: Verb;
@@ -0,0 +1,35 @@
1
+ import { parseScopePath } from './scope-parse';
2
+ export const catVerb = {
3
+ name: 'cat',
4
+ summary: 'Print document content. Usage: cat <shardId>/<path>',
5
+ programmatic: true,
6
+ async run(ctx, args) {
7
+ const ts = Date.now();
8
+ if (!ctx.docs) {
9
+ ctx.scrollback.push({ kind: 'status', text: 'cat: document capability not available', level: 'error', ts });
10
+ return;
11
+ }
12
+ if (!args[0]) {
13
+ ctx.scrollback.push({ kind: 'status', text: 'usage: cat <shardId>/<path>', level: 'error', ts });
14
+ return;
15
+ }
16
+ const parsed = parseScopePath(args[0]);
17
+ if (!parsed || !parsed.path) {
18
+ ctx.scrollback.push({ kind: 'status', text: `cat: invalid path '${args[0]}'`, level: 'error', ts });
19
+ return;
20
+ }
21
+ if (!ctx.docs.readFrom) {
22
+ ctx.scrollback.push({ kind: 'status', text: 'cat: read permission not granted', level: 'error', ts });
23
+ return;
24
+ }
25
+ const content = await ctx.docs.readFrom(parsed.shardId, parsed.path);
26
+ if (content === null) {
27
+ ctx.scrollback.push({ kind: 'status', text: `cat: not found: ${args[0]}`, level: 'error', ts });
28
+ return;
29
+ }
30
+ const text = content instanceof ArrayBuffer
31
+ ? `<binary ${content.byteLength}b>`
32
+ : content;
33
+ ctx.scrollback.push({ kind: 'text', stream: 'stdout', chunks: [text], ts });
34
+ },
35
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { catVerb } from './cat';
3
+ function makeDocs(overrides = {}) {
4
+ return Object.assign({ listDocuments: vi.fn(async () => []), listShards: vi.fn(async () => []), watchDocuments: vi.fn(() => () => { }), readFrom: vi.fn(async () => null) }, overrides);
5
+ }
6
+ function makeCtx(docs) {
7
+ const pushed = [];
8
+ const ctx = {
9
+ sh3: {},
10
+ scrollback: { push: (e) => pushed.push(e) },
11
+ session: {},
12
+ cwd: '/',
13
+ fs: {},
14
+ docs,
15
+ dispatch: async () => { },
16
+ };
17
+ return { ctx, pushed };
18
+ }
19
+ describe('cat verb', () => {
20
+ it('emits error when docs capability missing', async () => {
21
+ const { ctx, pushed } = makeCtx(undefined);
22
+ await catVerb.run(ctx, ['notes/draft.md']);
23
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
24
+ expect(err).toBeDefined();
25
+ });
26
+ it('emits usage error when no args', async () => {
27
+ const { ctx, pushed } = makeCtx(makeDocs());
28
+ await catVerb.run(ctx, []);
29
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
30
+ expect(err).toBeDefined();
31
+ expect(err.text).toMatch(/usage/i);
32
+ });
33
+ it('outputs document content', async () => {
34
+ const docs = makeDocs({ readFrom: vi.fn(async () => 'hello world') });
35
+ const { ctx, pushed } = makeCtx(docs);
36
+ await catVerb.run(ctx, ['notes/draft.md']);
37
+ expect(docs.readFrom).toHaveBeenCalledWith('notes', 'draft.md');
38
+ const text = pushed.find((e) => e.kind === 'text');
39
+ expect(text === null || text === void 0 ? void 0 : text.chunks.join('')).toContain('hello world');
40
+ });
41
+ it('emits error when document not found', async () => {
42
+ const docs = makeDocs({ readFrom: vi.fn(async () => null) });
43
+ const { ctx, pushed } = makeCtx(docs);
44
+ await catVerb.run(ctx, ['notes/missing.md']);
45
+ const err = pushed.find((e) => e.kind === 'status' && e.level === 'error');
46
+ expect(err).toBeDefined();
47
+ expect(err.text).toMatch(/not found/i);
48
+ });
49
+ });
@@ -12,6 +12,12 @@ import { viewsVerb, openVerb, closeVerb, popoutVerb, dockVerb } from './views';
12
12
  import { zonesVerb, zoneVerb } from './zones';
13
13
  import { envVerb } from './env';
14
14
  import { resetVerb } from './reset';
15
+ import { lsVerb } from './ls';
16
+ import { catVerb } from './cat';
17
+ import { rmVerb } from './rm';
18
+ import { mkdirVerb } from './mkdir';
19
+ import { mvVerb } from './mv';
20
+ import { xferVerb } from './xfer';
15
21
  export function registerV1Verbs(ctx) {
16
22
  ctx.registerVerb(makeHelpVerb());
17
23
  ctx.registerVerb(clearVerb);
@@ -29,4 +35,10 @@ export function registerV1Verbs(ctx) {
29
35
  ctx.registerVerb(zoneVerb);
30
36
  ctx.registerVerb(envVerb);
31
37
  ctx.registerVerb(resetVerb);
38
+ ctx.registerVerb(lsVerb);
39
+ ctx.registerVerb(catVerb);
40
+ ctx.registerVerb(rmVerb);
41
+ ctx.registerVerb(mkdirVerb);
42
+ ctx.registerVerb(mvVerb);
43
+ ctx.registerVerb(xferVerb);
32
44
  }
@@ -0,0 +1,2 @@
1
+ import type { Verb } from '../../verbs/types';
2
+ export declare const lsVerb: Verb;