sh3-core 0.7.5 → 0.8.0

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 (129) hide show
  1. package/dist/api.d.ts +11 -2
  2. package/dist/api.js +13 -1
  3. package/dist/app/store/StoreView.svelte +36 -7
  4. package/dist/app/store/storeShard.svelte.js +9 -3
  5. package/dist/app/store/verbs.js +8 -2
  6. package/dist/apps/lifecycle.d.ts +11 -0
  7. package/dist/apps/lifecycle.js +21 -1
  8. package/dist/apps/lifecycle.test.js +50 -1
  9. package/dist/apps/types.d.ts +7 -2
  10. package/dist/createShell.d.ts +2 -0
  11. package/dist/createShell.js +9 -7
  12. package/dist/documents/handle.js +5 -0
  13. package/dist/documents/index.d.ts +1 -0
  14. package/dist/documents/index.js +1 -0
  15. package/dist/documents/journal-hook.d.ts +6 -0
  16. package/dist/documents/journal-hook.js +16 -0
  17. package/dist/documents/sync/activate-integration.test.d.ts +1 -0
  18. package/dist/documents/sync/activate-integration.test.js +37 -0
  19. package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
  20. package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
  21. package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
  22. package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
  23. package/dist/documents/sync/conflicts.d.ts +30 -0
  24. package/dist/documents/sync/conflicts.js +77 -0
  25. package/dist/documents/sync/conflicts.test.d.ts +1 -0
  26. package/dist/documents/sync/conflicts.test.js +71 -0
  27. package/dist/documents/sync/engine.d.ts +19 -0
  28. package/dist/documents/sync/engine.js +188 -0
  29. package/dist/documents/sync/engine.test.d.ts +1 -0
  30. package/dist/documents/sync/engine.test.js +169 -0
  31. package/dist/documents/sync/handle.d.ts +11 -0
  32. package/dist/documents/sync/handle.js +79 -0
  33. package/dist/documents/sync/handle.test.d.ts +1 -0
  34. package/dist/documents/sync/handle.test.js +56 -0
  35. package/dist/documents/sync/hash.d.ts +1 -0
  36. package/dist/documents/sync/hash.js +13 -0
  37. package/dist/documents/sync/hash.test.d.ts +1 -0
  38. package/dist/documents/sync/hash.test.js +20 -0
  39. package/dist/documents/sync/index.d.ts +6 -0
  40. package/dist/documents/sync/index.js +12 -0
  41. package/dist/documents/sync/journal.d.ts +30 -0
  42. package/dist/documents/sync/journal.js +179 -0
  43. package/dist/documents/sync/journal.test.d.ts +1 -0
  44. package/dist/documents/sync/journal.test.js +87 -0
  45. package/dist/documents/sync/registry.d.ts +10 -0
  46. package/dist/documents/sync/registry.js +66 -0
  47. package/dist/documents/sync/registry.test.d.ts +1 -0
  48. package/dist/documents/sync/registry.test.js +42 -0
  49. package/dist/documents/sync/serialization.d.ts +5 -0
  50. package/dist/documents/sync/serialization.js +24 -0
  51. package/dist/documents/sync/serialization.test.d.ts +1 -0
  52. package/dist/documents/sync/serialization.test.js +26 -0
  53. package/dist/documents/sync/singleton.d.ts +11 -0
  54. package/dist/documents/sync/singleton.js +26 -0
  55. package/dist/documents/sync/tombstones.d.ts +19 -0
  56. package/dist/documents/sync/tombstones.js +58 -0
  57. package/dist/documents/sync/tombstones.test.d.ts +1 -0
  58. package/dist/documents/sync/tombstones.test.js +37 -0
  59. package/dist/documents/sync/types.d.ts +116 -0
  60. package/dist/documents/sync/types.js +27 -0
  61. package/dist/documents/sync/write-hook.test.d.ts +1 -0
  62. package/dist/documents/sync/write-hook.test.js +36 -0
  63. package/dist/env/client.d.ts +10 -5
  64. package/dist/env/client.js +12 -4
  65. package/dist/registry/installer.d.ts +10 -7
  66. package/dist/registry/installer.js +39 -35
  67. package/dist/registry/register.d.ts +17 -0
  68. package/dist/registry/register.js +22 -0
  69. package/dist/registry/register.test.d.ts +1 -0
  70. package/dist/registry/register.test.js +28 -0
  71. package/dist/shards/activate.svelte.js +23 -2
  72. package/dist/shards/types.d.ts +10 -1
  73. package/dist/shell-shard/Terminal.svelte +140 -33
  74. package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
  75. package/dist/shell-shard/auto-relocate.d.ts +12 -0
  76. package/dist/shell-shard/auto-relocate.js +20 -0
  77. package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
  78. package/dist/shell-shard/auto-relocate.test.js +35 -0
  79. package/dist/shell-shard/dispatch.d.ts +15 -0
  80. package/dist/shell-shard/dispatch.js +56 -0
  81. package/dist/shell-shard/modes/builtin.d.ts +5 -0
  82. package/dist/shell-shard/modes/builtin.js +18 -0
  83. package/dist/shell-shard/modes/prefs.d.ts +5 -0
  84. package/dist/shell-shard/modes/prefs.js +31 -0
  85. package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
  86. package/dist/shell-shard/modes/prefs.test.js +46 -0
  87. package/dist/shell-shard/modes/registry.d.ts +7 -0
  88. package/dist/shell-shard/modes/registry.js +27 -0
  89. package/dist/shell-shard/modes/registry.test.d.ts +1 -0
  90. package/dist/shell-shard/modes/registry.test.js +35 -0
  91. package/dist/shell-shard/modes/types.d.ts +8 -0
  92. package/dist/shell-shard/modes/types.js +1 -0
  93. package/dist/shell-shard/protocol.d.ts +6 -0
  94. package/dist/shell-shard/shellShard.svelte.js +5 -1
  95. package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
  96. package/dist/shell-shard/tenant-fs-client.js +44 -0
  97. package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
  98. package/dist/shell-shard/tenant-fs-client.test.js +49 -0
  99. package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
  100. package/dist/shell-shard/terminal-dispatch.test.js +53 -0
  101. package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
  102. package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
  103. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
  104. package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
  105. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
  106. package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
  107. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
  108. package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
  109. package/dist/shell-shard/toolbar/slots.d.ts +17 -0
  110. package/dist/shell-shard/toolbar/slots.js +26 -0
  111. package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
  112. package/dist/shell-shard/toolbar/slots.test.js +28 -0
  113. package/dist/shell-shard/verbs/cat.d.ts +2 -0
  114. package/dist/shell-shard/verbs/cat.js +34 -0
  115. package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
  116. package/dist/shell-shard/verbs/cd.test.js +56 -0
  117. package/dist/shell-shard/verbs/env.d.ts +2 -0
  118. package/dist/shell-shard/verbs/env.js +14 -0
  119. package/dist/shell-shard/verbs/index.js +6 -1
  120. package/dist/shell-shard/verbs/ls.d.ts +2 -0
  121. package/dist/shell-shard/verbs/ls.js +29 -0
  122. package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
  123. package/dist/shell-shard/verbs/ls.test.js +49 -0
  124. package/dist/shell-shard/verbs/session.d.ts +0 -1
  125. package/dist/shell-shard/verbs/session.js +58 -26
  126. package/dist/verbs/types.d.ts +2 -0
  127. package/dist/version.d.ts +1 -1
  128. package/dist/version.js +1 -1
  129. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { resetFramework } from '../__test__/reset';
3
+ import { makeApp, makeAppManifest, makeShard, makeShardManifest } from '../__test__/fixtures';
4
+ import { registerLoadedBundle } from './register';
5
+ import { registeredApps } from '../apps/registry.svelte';
6
+ import { registeredShards } from '../shards/activate.svelte';
7
+ describe('registerLoadedBundle', () => {
8
+ beforeEach(resetFramework);
9
+ it('stamps meta.version onto every shard manifest before registering', () => {
10
+ var _a;
11
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'shard-1', version: '' }) });
12
+ registerLoadedBundle({ shards: [shard], apps: [] }, { version: '2.3.4', sourceRegistry: 'https://r', contractVersion: '1' });
13
+ expect((_a = registeredShards.get('shard-1')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('2.3.4');
14
+ });
15
+ it('stamps meta.version onto every app manifest before registering', () => {
16
+ var _a;
17
+ const app = makeApp({ manifest: makeAppManifest({ id: 'app-1', version: '' }) });
18
+ registerLoadedBundle({ shards: [], apps: [app] }, { version: '2.3.4', sourceRegistry: 'https://r', contractVersion: '1' });
19
+ expect((_a = registeredApps.get('app-1')) === null || _a === void 0 ? void 0 : _a.manifest.version).toBe('2.3.4');
20
+ });
21
+ it('registers combo bundles (both shards and apps)', () => {
22
+ const shard = makeShard({ manifest: makeShardManifest({ id: 'combo-s' }) });
23
+ const app = makeApp({ manifest: makeAppManifest({ id: 'combo-a', requiredShards: ['combo-s'] }) });
24
+ registerLoadedBundle({ shards: [shard], apps: [app] }, { version: '1.0.0', sourceRegistry: '', contractVersion: '1' });
25
+ expect(registeredShards.has('combo-s')).toBe(true);
26
+ expect(registeredApps.has('combo-a')).toBe(true);
27
+ });
28
+ });
@@ -23,6 +23,9 @@ import { fetchEnvState, putEnvState } from '../env/client';
23
23
  import { isAdmin as checkIsAdmin } from '../auth/index';
24
24
  import { createZoneManager } from '../state/manage';
25
25
  import { PERMISSION_STATE_MANAGE } from '../state/types';
26
+ import { PERMISSION_DOCUMENTS_SYNC } from '../documents/sync/types';
27
+ import { getSyncBundle } from '../documents/sync/singleton';
28
+ import { createSyncHandle } from '../documents/sync/handle';
26
29
  /**
27
30
  * Reactive registry of every shard known to the host. Keys are shard ids.
28
31
  * Populated once at boot by the glob-discovery loop in main.ts (through
@@ -65,7 +68,7 @@ export function registerShard(shard) {
65
68
  * @throws If the shard is not registered, or if a manifest view has no factory after activation.
66
69
  */
67
70
  export async function activateShard(id) {
68
- var _a, _b;
71
+ var _a, _b, _c;
69
72
  const shard = registeredShards.get(id);
70
73
  if (!shard) {
71
74
  throw new Error(`Cannot activate shard "${id}": not registered`);
@@ -128,6 +131,24 @@ export async function activateShard(id) {
128
131
  zones: ((_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
129
132
  ? createZoneManager()
130
133
  : undefined,
134
+ sync: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_SYNC))
135
+ ? () => {
136
+ const backend = getDocumentBackend();
137
+ const tenantId = getTenantId();
138
+ const bundlePromise = getSyncBundle(backend, tenantId);
139
+ const handlePromise = bundlePromise.then(({ engine, registry }) => createSyncHandle({ tenantId, connectorId: id, engine, registry }));
140
+ return {
141
+ connectorId: id,
142
+ grantedScopes: async () => (await handlePromise).grantedScopes(),
143
+ getManifest: async (scope) => (await handlePromise).getManifest(scope),
144
+ changesSince: async (scope, cursor) => (await handlePromise).changesSince(scope, cursor),
145
+ ack: async (scope, cursor) => (await handlePromise).ack(scope, cursor),
146
+ apply: async (scope, entry, opts) => (await handlePromise).apply(scope, entry, opts),
147
+ applyBatch: async (scope, manifest, opts) => (await handlePromise).applyBatch(scope, manifest, opts),
148
+ forget: async (scope, path) => (await handlePromise).forget(scope, path),
149
+ };
150
+ }
151
+ : undefined,
131
152
  };
132
153
  entry.ctx = ctx;
133
154
  active.set(id, entry);
@@ -149,7 +170,7 @@ export async function activateShard(id) {
149
170
  console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
150
171
  }
151
172
  }
152
- void ((_b = shard.autostart) === null || _b === void 0 ? void 0 : _b.call(shard, ctx));
173
+ void ((_c = shard.autostart) === null || _c === void 0 ? void 0 : _c.call(shard, ctx));
153
174
  }
154
175
  /**
155
176
  * Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
@@ -1,6 +1,7 @@
1
1
  import type { StateZones } from '../state/zones.svelte';
2
2
  import type { ZoneSchema, ZoneManager } from '../state/types';
3
3
  import type { DocumentHandle, DocumentHandleOptions } from '../documents/types';
4
+ import type { SyncHandle } from '../documents/sync/types';
4
5
  import type { EnvState } from '../env/types';
5
6
  import type { Verb } from '../verbs/types';
6
7
  /**
@@ -116,7 +117,9 @@ export interface ShardManifest {
116
117
  /**
117
118
  * Optional permissions this shard requests beyond the default sandbox.
118
119
  * Declared in the manifest and surfaced to the user at install time.
119
- * Currently recognized: `'state:manage'` — cross-shard zone access.
120
+ * Currently recognized:
121
+ * - 'state:manage' — cross-shard zone access.
122
+ * - 'documents:sync' — cross-shard document sync API.
120
123
  */
121
124
  permissions?: string[];
122
125
  }
@@ -191,6 +194,12 @@ export interface ShardContext {
191
194
  * `if (ctx.zones)` before use.
192
195
  */
193
196
  zones?: ZoneManager;
197
+ /**
198
+ * Cross-shard document sync API. Only present when the shard's
199
+ * manifest declares the `'documents:sync'` permission. Check with
200
+ * `if (ctx.sync)` before use.
201
+ */
202
+ sync?: () => SyncHandle;
194
203
  }
195
204
  /**
196
205
  * A shard module. Shards are the fundamental unit of contribution in SH3.
@@ -6,55 +6,149 @@
6
6
  import { SessionClient } from './session-client.svelte';
7
7
  import { VerbRegistry, type ShellApi } from './registry';
8
8
  import type { ServerMessage } from './protocol';
9
+ import { TenantFsClient } from './tenant-fs-client';
10
+ import { ShellModeRegistry } from './modes/registry';
11
+ import { registerBuiltinModes } from './modes/builtin';
12
+ import { resolveInitialMode, writeLastMode } from './modes/prefs';
13
+ import type { ShellMode, ShellRole } from './modes/types';
14
+ import { makeDispatch } from './dispatch';
15
+ import { computeRelocate } from './auto-relocate';
16
+ import { activeLayout } from '../layout/store.svelte';
17
+ import type { LayoutNode } from '../layout/types';
18
+ import Toolbar from './toolbar/Toolbar.svelte';
19
+ import { ToolbarSlotRegistry } from './toolbar/slots';
20
+ import ModeSlot from './toolbar/slots/ModeSlot.svelte';
21
+ import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
22
+ import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
9
23
 
10
24
  interface Props {
11
25
  shell: ShellApi;
12
26
  wsUrl: string;
27
+ userId: string;
28
+ role: ShellRole;
13
29
  }
14
- let { shell, wsUrl }: Props = $props();
30
+ let { shell, wsUrl, userId, role }: Props = $props();
15
31
 
16
32
  const scrollback = new Scrollback();
33
+ const resolver = new VerbRegistry();
34
+ const fs = new TenantFsClient();
35
+
36
+ // Mode registry
37
+ const modeRegistry = new ShellModeRegistry();
38
+ registerBuiltinModes(modeRegistry);
39
+
40
+ // Reactive current mode
41
+ let mode = $state<ShellMode>(
42
+ untrack(() => resolveInitialMode(modeRegistry, userId, role)),
43
+ );
44
+
45
+ function setMode(id: string): void {
46
+ const next = modeRegistry.get(id);
47
+ if (!next) return;
48
+ if (next.requiresRole && next.requiresRole !== role) return;
49
+ mode = next;
50
+ writeLastMode(userId, id);
51
+ if (next.transport !== 'ws') {
52
+ scrollback.push({ kind: 'status', text: 'mode switch: reload to take effect for server-shell changes', level: 'info', ts: Date.now() });
53
+ }
54
+ }
55
+
17
56
  // wsUrl is a prop read at construction only. untrack prevents Svelte 5's
18
57
  // "referenced outside a closure" warning; the URL never changes at runtime.
19
58
  const session = untrack(() => new SessionClient(wsUrl));
20
- const resolver = new VerbRegistry();
59
+
60
+ const dispatch = untrack(() => makeDispatch({
61
+ mode: () => mode,
62
+ resolver,
63
+ scrollback,
64
+ session,
65
+ shell,
66
+ fs,
67
+ cwd: () => session.cwd,
68
+ }));
21
69
 
22
70
  let locked = $state(false);
23
71
 
24
- async function dispatch(line: string): Promise<void> {
25
- session.history.push(line);
26
- const resolution = resolver.resolve(line);
27
- if (resolution.kind === 'local') {
28
- // Log locally-dispatched verbs for shared history
29
- session.send({ t: 'history-log', line });
30
- scrollback.push({
31
- kind: 'prompt',
32
- cwd: session.cwd,
33
- line,
34
- ts: Date.now(),
35
- });
36
- try {
37
- await resolution.verb.run({
38
- shell,
39
- scrollback,
40
- session,
41
- cwd: session.cwd,
42
- dispatch,
43
- }, resolution.args);
44
- } catch (err) {
45
- scrollback.push({
46
- kind: 'status',
47
- text: `shell: verb ${resolution.verb.name} threw — ${(err as Error).message}`,
48
- level: 'error',
49
- ts: Date.now(),
50
- });
72
+ // ---------------------------------------------------------------------------
73
+ // Auto-relocate: track the focused shard and update session.cwd when focus
74
+ // changes to a shard whose documents directory exists. focusLocked and
75
+ // targetShard are read by Task 13's toolbar component via props.
76
+ // ---------------------------------------------------------------------------
77
+
78
+ let focusLocked = $state(false);
79
+ let targetShard = $state<string | null>(null);
80
+
81
+ function toggleFocusLock(): void {
82
+ focusLocked = !focusLocked;
83
+ }
84
+
85
+ // Toolbar slot registry
86
+ const toolbarRegistry = new ToolbarSlotRegistry();
87
+ toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
88
+ toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'user', component: FocusLockSlot });
89
+ toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: TargetShardSlot });
90
+
91
+ let toolbarExpanded = $state((() => {
92
+ try { return localStorage.getItem('sh3.shell.toolbarExpanded') !== '0'; } catch { return true; }
93
+ })());
94
+
95
+ function toggleToolbar() {
96
+ toolbarExpanded = !toolbarExpanded;
97
+ try { localStorage.setItem('sh3.shell.toolbarExpanded', toolbarExpanded ? '1' : '0'); } catch {}
98
+ }
99
+
100
+ /** Walk the layout tree and return the viewId of the active tab in the first
101
+ * TabsNode found (breadth-first). Returns null if the layout contains no
102
+ * tabs node with a populated active tab. */
103
+ function getActiveViewId(node: LayoutNode): string | null {
104
+ if (node.type === 'tabs') {
105
+ const entry = node.tabs[node.activeTab];
106
+ return entry?.viewId ?? null;
107
+ }
108
+ if (node.type === 'split') {
109
+ for (const child of node.children) {
110
+ const found = getActiveViewId(child);
111
+ if (found !== null) return found;
51
112
  }
52
- } else {
53
- // Forward to server
54
- session.send({ t: 'submit', line: resolution.line });
55
113
  }
114
+ // slot node
115
+ return node.viewId ?? null;
56
116
  }
57
117
 
118
+ /** Derive the focused shard id from the currently-active layout. The shard
119
+ * id is the prefix before the first ':' in a viewId (e.g. 'shell:terminal'
120
+ * → 'shell'). Returns null when no view is active. */
121
+ function getFocusedShardId(): string | null {
122
+ try {
123
+ const tree = activeLayout();
124
+ const viewId = getActiveViewId(tree.docked);
125
+ if (!viewId) return null;
126
+ const colon = viewId.indexOf(':');
127
+ return colon >= 0 ? viewId.slice(0, colon) : viewId;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ $effect(() => {
134
+ const focused = getFocusedShardId();
135
+ const autoRelocate = mode.autoRelocate;
136
+ const isFocusLocked = focusLocked;
137
+ (async () => {
138
+ const r = await computeRelocate({
139
+ modeAutoRelocate: autoRelocate,
140
+ focusLocked: isFocusLocked,
141
+ focusedShardId: focused,
142
+ currentShardId: 'shell',
143
+ }, fs);
144
+ if (r.kind === 'relocate' && r.path !== undefined) {
145
+ session.cwd = r.path;
146
+ }
147
+ if (focused && focused !== 'shell') targetShard = focused;
148
+ else if (!focused) targetShard = null;
149
+ })();
150
+ });
151
+
58
152
  function handleServerMessage(msg: ServerMessage) {
59
153
  if (msg.t !== 'event') return;
60
154
  const e = msg.event;
@@ -96,7 +190,9 @@
96
190
 
97
191
  onMount(() => {
98
192
  unsub = session.onMessage(handleServerMessage);
99
- session.connect();
193
+ if (mode.transport === 'ws') {
194
+ session.connect();
195
+ }
100
196
  });
101
197
 
102
198
  onDestroy(() => {
@@ -106,6 +202,17 @@
106
202
  </script>
107
203
 
108
204
  <div class="shell-terminal">
205
+ <Toolbar
206
+ registry={toolbarRegistry}
207
+ ctx={{ mode, role }}
208
+ expanded={toolbarExpanded}
209
+ onToggle={toggleToolbar}
210
+ slotProps={{
211
+ mode: { mode, role, registry: modeRegistry, onSelect: setMode },
212
+ 'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
213
+ 'target-shard': { target: targetShard },
214
+ }}
215
+ />
109
216
  <ScrollbackView {scrollback} />
110
217
  <InputLine
111
218
  cwd={session.cwd}
@@ -1,7 +1,10 @@
1
1
  import { type ShellApi } from './registry';
2
+ import type { ShellRole } from './modes/types';
2
3
  interface Props {
3
4
  shell: ShellApi;
4
5
  wsUrl: string;
6
+ userId: string;
7
+ role: ShellRole;
5
8
  }
6
9
  declare const Terminal: import("svelte").Component<Props, {}, "">;
7
10
  type Terminal = ReturnType<typeof Terminal>;
@@ -0,0 +1,12 @@
1
+ import type { TenantFsClient } from './tenant-fs-client';
2
+ export interface RelocateInput {
3
+ modeAutoRelocate: boolean;
4
+ focusLocked: boolean;
5
+ focusedShardId: string | null;
6
+ currentShardId: string;
7
+ }
8
+ export interface RelocateEffect {
9
+ kind: 'noop' | 'relocate';
10
+ path?: string;
11
+ }
12
+ export declare function computeRelocate(input: RelocateInput, fs: TenantFsClient): Promise<RelocateEffect>;
@@ -0,0 +1,20 @@
1
+ export async function computeRelocate(input, fs) {
2
+ if (!input.modeAutoRelocate)
3
+ return { kind: 'noop' };
4
+ if (input.focusLocked)
5
+ return { kind: 'noop' };
6
+ if (input.focusedShardId === null)
7
+ return { kind: 'noop' };
8
+ if (input.focusedShardId === input.currentShardId)
9
+ return { kind: 'noop' };
10
+ const path = input.focusedShardId;
11
+ try {
12
+ const s = await fs.stat(path);
13
+ if (s.kind !== 'dir')
14
+ return { kind: 'noop' };
15
+ }
16
+ catch (_a) {
17
+ return { kind: 'noop' };
18
+ }
19
+ return { kind: 'relocate', path };
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { computeRelocate } from './auto-relocate';
3
+ const statOk = { list: vi.fn(), read: vi.fn(), stat: async () => ({ name: 'notes', kind: 'dir', size: 0, mtime: 0 }) };
4
+ const statMissing = { list: vi.fn(), read: vi.fn(), stat: async () => { throw new Error('not found'); } };
5
+ const statFile = { list: vi.fn(), read: vi.fn(), stat: async () => ({ name: 'notes', kind: 'file', size: 0, mtime: 0 }) };
6
+ describe('computeRelocate', () => {
7
+ it('noop when mode disables auto-relocate', async () => {
8
+ const r = await computeRelocate({ modeAutoRelocate: false, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
9
+ expect(r.kind).toBe('noop');
10
+ });
11
+ it('noop when focus-lock is on', async () => {
12
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: true, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
13
+ expect(r.kind).toBe('noop');
14
+ });
15
+ it('noop when nothing relevant is focused', async () => {
16
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: null, currentShardId: 'shell' }, statOk);
17
+ expect(r.kind).toBe('noop');
18
+ });
19
+ it('noop when focusing the shell itself', async () => {
20
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'shell', currentShardId: 'shell' }, statOk);
21
+ expect(r.kind).toBe('noop');
22
+ });
23
+ it('noop when target documents folder does not exist', async () => {
24
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statMissing);
25
+ expect(r.kind).toBe('noop');
26
+ });
27
+ it('noop when target is a file, not a directory', async () => {
28
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statFile);
29
+ expect(r.kind).toBe('noop');
30
+ });
31
+ it('relocates to the shard id path on happy path', async () => {
32
+ const r = await computeRelocate({ modeAutoRelocate: true, focusLocked: false, focusedShardId: 'notes', currentShardId: 'shell' }, statOk);
33
+ expect(r).toEqual({ kind: 'relocate', path: 'notes' });
34
+ });
35
+ });
@@ -0,0 +1,15 @@
1
+ import type { VerbRegistry, ShellApi } from './registry';
2
+ import type { Scrollback } from './scrollback.svelte';
3
+ import type { SessionClient } from './session-client.svelte';
4
+ import type { TenantFsClient } from './tenant-fs-client';
5
+ import type { ShellMode } from './modes/types';
6
+ export interface DispatchDeps {
7
+ mode: () => ShellMode;
8
+ resolver: VerbRegistry;
9
+ scrollback: Scrollback;
10
+ session: SessionClient;
11
+ shell: ShellApi;
12
+ fs: TenantFsClient;
13
+ cwd: () => string;
14
+ }
15
+ export declare function makeDispatch(deps: DispatchDeps): (line: string) => Promise<void>;
@@ -0,0 +1,56 @@
1
+ /*
2
+ * makeDispatch — mode-aware verb dispatch factory for Terminal.svelte.
3
+ *
4
+ * Pure function (no Svelte reactivity) so it can be unit-tested independently.
5
+ * The mode is passed as a getter so the dispatch closure always sees the
6
+ * current mode without being reconstructed on every mode change.
7
+ */
8
+ export function makeDispatch(deps) {
9
+ return async function dispatch(line) {
10
+ var _a;
11
+ const mode = deps.mode();
12
+ deps.session.history.push(line);
13
+ // User-mode $ escape: block server-shell access
14
+ if (mode.transport === 'none' && line.trimStart().startsWith('$ ')) {
15
+ deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
16
+ deps.scrollback.push({ kind: 'status', text: 'shell: server shell not available in user mode', level: 'error', ts: Date.now() });
17
+ return;
18
+ }
19
+ const resolution = deps.resolver.resolve(line);
20
+ if (resolution.kind === 'local') {
21
+ // Log locally-dispatched verbs for shared history (ws only)
22
+ if (mode.transport === 'ws') {
23
+ deps.session.send({ t: 'history-log', line });
24
+ }
25
+ deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line, ts: Date.now() });
26
+ try {
27
+ await resolution.verb.run({
28
+ shell: deps.shell,
29
+ scrollback: deps.scrollback,
30
+ session: deps.session,
31
+ cwd: deps.cwd(),
32
+ dispatch,
33
+ fs: deps.fs,
34
+ }, resolution.args);
35
+ }
36
+ catch (err) {
37
+ deps.scrollback.push({
38
+ kind: 'status',
39
+ text: `shell: verb ${resolution.verb.name} threw — ${err.message}`,
40
+ level: 'error',
41
+ ts: Date.now(),
42
+ });
43
+ }
44
+ return;
45
+ }
46
+ // forward path
47
+ if (mode.transport === 'ws') {
48
+ deps.session.send({ t: 'submit', line: resolution.line });
49
+ }
50
+ else {
51
+ const firstToken = (_a = resolution.line.split(/\s+/)[0]) !== null && _a !== void 0 ? _a : '';
52
+ deps.scrollback.push({ kind: 'prompt', cwd: deps.cwd(), line: resolution.line, ts: Date.now() });
53
+ deps.scrollback.push({ kind: 'status', text: `unknown verb: ${firstToken}`, level: 'error', ts: Date.now() });
54
+ }
55
+ };
56
+ }
@@ -0,0 +1,5 @@
1
+ import { ShellModeRegistry } from './registry';
2
+ import type { ShellMode } from './types';
3
+ export declare const DEV_MODE: ShellMode;
4
+ export declare const USER_MODE: ShellMode;
5
+ export declare function registerBuiltinModes(reg: ShellModeRegistry): void;
@@ -0,0 +1,18 @@
1
+ import { ShellModeRegistry } from './registry';
2
+ export const DEV_MODE = {
3
+ id: 'dev',
4
+ label: 'Dev',
5
+ requiresRole: 'admin',
6
+ transport: 'ws',
7
+ autoRelocate: false,
8
+ };
9
+ export const USER_MODE = {
10
+ id: 'user',
11
+ label: 'User',
12
+ transport: 'none',
13
+ autoRelocate: true,
14
+ };
15
+ export function registerBuiltinModes(reg) {
16
+ reg.register(DEV_MODE);
17
+ reg.register(USER_MODE);
18
+ }
@@ -0,0 +1,5 @@
1
+ import type { ShellMode, ShellRole } from './types';
2
+ import type { ShellModeRegistry } from './registry';
3
+ export declare function readLastMode(userId: string): string | null;
4
+ export declare function writeLastMode(userId: string, modeId: string): void;
5
+ export declare function resolveInitialMode(reg: ShellModeRegistry, userId: string, role: ShellRole): ShellMode;
@@ -0,0 +1,31 @@
1
+ function key(userId) {
2
+ return `sh3.shell.lastMode.${userId}`;
3
+ }
4
+ export function readLastMode(userId) {
5
+ var _a, _b;
6
+ try {
7
+ return (_b = (_a = globalThis.localStorage) === null || _a === void 0 ? void 0 : _a.getItem(key(userId))) !== null && _b !== void 0 ? _b : null;
8
+ }
9
+ catch (_c) {
10
+ return null;
11
+ }
12
+ }
13
+ export function writeLastMode(userId, modeId) {
14
+ var _a;
15
+ try {
16
+ (_a = globalThis.localStorage) === null || _a === void 0 ? void 0 : _a.setItem(key(userId), modeId);
17
+ }
18
+ catch (_b) {
19
+ // Non-browser host or storage disabled — persistence is best-effort.
20
+ }
21
+ }
22
+ export function resolveInitialMode(reg, userId, role) {
23
+ const persisted = readLastMode(userId);
24
+ if (persisted) {
25
+ const m = reg.get(persisted);
26
+ if (m && (!m.requiresRole || m.requiresRole === role))
27
+ return m;
28
+ }
29
+ const fallback = role === 'admin' ? 'dev' : 'user';
30
+ return reg.get(fallback);
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { readLastMode, writeLastMode, resolveInitialMode } from './prefs';
3
+ import { ShellModeRegistry } from './registry';
4
+ import { registerBuiltinModes } from './builtin';
5
+ class MemStorage {
6
+ constructor() {
7
+ this.m = new Map();
8
+ }
9
+ getItem(k) { var _a; return (_a = this.m.get(k)) !== null && _a !== void 0 ? _a : null; }
10
+ setItem(k, v) { this.m.set(k, v); }
11
+ removeItem(k) { this.m.delete(k); }
12
+ }
13
+ beforeEach(() => {
14
+ globalThis.localStorage = new MemStorage();
15
+ });
16
+ describe('readLastMode / writeLastMode', () => {
17
+ it('round-trips a mode id for a user', () => {
18
+ writeLastMode('alice', 'user');
19
+ expect(readLastMode('alice')).toBe('user');
20
+ });
21
+ it('returns null when nothing persisted', () => {
22
+ expect(readLastMode('bob')).toBeNull();
23
+ });
24
+ });
25
+ describe('resolveInitialMode', () => {
26
+ const reg = new ShellModeRegistry();
27
+ registerBuiltinModes(reg);
28
+ it('admin with no pref → dev', () => {
29
+ expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('dev');
30
+ });
31
+ it('user with no pref → user', () => {
32
+ expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('user');
33
+ });
34
+ it('admin with persisted user → user', () => {
35
+ writeLastMode('alice', 'user');
36
+ expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('user');
37
+ });
38
+ it('user with persisted dev (not allowed) → falls back to user', () => {
39
+ writeLastMode('alice', 'dev');
40
+ expect(resolveInitialMode(reg, 'alice', 'user').id).toBe('user');
41
+ });
42
+ it('persisted unknown id → role default', () => {
43
+ writeLastMode('alice', 'nonsense');
44
+ expect(resolveInitialMode(reg, 'alice', 'admin').id).toBe('dev');
45
+ });
46
+ });
@@ -0,0 +1,7 @@
1
+ import type { ShellMode, ShellRole } from './types';
2
+ export declare class ShellModeRegistry {
3
+ #private;
4
+ register(mode: ShellMode): void;
5
+ get(id: string): ShellMode | undefined;
6
+ list(role: ShellRole): ShellMode[];
7
+ }
@@ -0,0 +1,27 @@
1
+ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
2
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
3
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
+ return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
+ };
6
+ var _ShellModeRegistry_modes;
7
+ export class ShellModeRegistry {
8
+ constructor() {
9
+ _ShellModeRegistry_modes.set(this, new Map());
10
+ }
11
+ register(mode) {
12
+ __classPrivateFieldGet(this, _ShellModeRegistry_modes, "f").set(mode.id, mode);
13
+ }
14
+ get(id) {
15
+ return __classPrivateFieldGet(this, _ShellModeRegistry_modes, "f").get(id);
16
+ }
17
+ list(role) {
18
+ const out = [];
19
+ for (const m of __classPrivateFieldGet(this, _ShellModeRegistry_modes, "f").values()) {
20
+ if (m.requiresRole && m.requiresRole !== role)
21
+ continue;
22
+ out.push(m);
23
+ }
24
+ return out;
25
+ }
26
+ }
27
+ _ShellModeRegistry_modes = new WeakMap();
@@ -0,0 +1 @@
1
+ export {};