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.
- package/dist/api.d.ts +11 -2
- package/dist/api.js +13 -1
- package/dist/app/store/StoreView.svelte +36 -7
- package/dist/app/store/storeShard.svelte.js +9 -3
- package/dist/app/store/verbs.js +8 -2
- package/dist/apps/lifecycle.d.ts +11 -0
- package/dist/apps/lifecycle.js +21 -1
- package/dist/apps/lifecycle.test.js +50 -1
- package/dist/apps/types.d.ts +7 -2
- package/dist/createShell.d.ts +2 -0
- package/dist/createShell.js +9 -7
- package/dist/documents/handle.js +5 -0
- package/dist/documents/index.d.ts +1 -0
- package/dist/documents/index.js +1 -0
- package/dist/documents/journal-hook.d.ts +6 -0
- package/dist/documents/journal-hook.js +16 -0
- package/dist/documents/sync/activate-integration.test.d.ts +1 -0
- package/dist/documents/sync/activate-integration.test.js +37 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte +99 -0
- package/dist/documents/sync/components/DocumentSyncExplorer.svelte.d.ts +15 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte +70 -0
- package/dist/documents/sync/components/SyncGrantPicker.svelte.d.ts +12 -0
- package/dist/documents/sync/conflicts.d.ts +30 -0
- package/dist/documents/sync/conflicts.js +77 -0
- package/dist/documents/sync/conflicts.test.d.ts +1 -0
- package/dist/documents/sync/conflicts.test.js +71 -0
- package/dist/documents/sync/engine.d.ts +19 -0
- package/dist/documents/sync/engine.js +188 -0
- package/dist/documents/sync/engine.test.d.ts +1 -0
- package/dist/documents/sync/engine.test.js +169 -0
- package/dist/documents/sync/handle.d.ts +11 -0
- package/dist/documents/sync/handle.js +79 -0
- package/dist/documents/sync/handle.test.d.ts +1 -0
- package/dist/documents/sync/handle.test.js +56 -0
- package/dist/documents/sync/hash.d.ts +1 -0
- package/dist/documents/sync/hash.js +13 -0
- package/dist/documents/sync/hash.test.d.ts +1 -0
- package/dist/documents/sync/hash.test.js +20 -0
- package/dist/documents/sync/index.d.ts +6 -0
- package/dist/documents/sync/index.js +12 -0
- package/dist/documents/sync/journal.d.ts +30 -0
- package/dist/documents/sync/journal.js +179 -0
- package/dist/documents/sync/journal.test.d.ts +1 -0
- package/dist/documents/sync/journal.test.js +87 -0
- package/dist/documents/sync/registry.d.ts +10 -0
- package/dist/documents/sync/registry.js +66 -0
- package/dist/documents/sync/registry.test.d.ts +1 -0
- package/dist/documents/sync/registry.test.js +42 -0
- package/dist/documents/sync/serialization.d.ts +5 -0
- package/dist/documents/sync/serialization.js +24 -0
- package/dist/documents/sync/serialization.test.d.ts +1 -0
- package/dist/documents/sync/serialization.test.js +26 -0
- package/dist/documents/sync/singleton.d.ts +11 -0
- package/dist/documents/sync/singleton.js +26 -0
- package/dist/documents/sync/tombstones.d.ts +19 -0
- package/dist/documents/sync/tombstones.js +58 -0
- package/dist/documents/sync/tombstones.test.d.ts +1 -0
- package/dist/documents/sync/tombstones.test.js +37 -0
- package/dist/documents/sync/types.d.ts +116 -0
- package/dist/documents/sync/types.js +27 -0
- package/dist/documents/sync/write-hook.test.d.ts +1 -0
- package/dist/documents/sync/write-hook.test.js +36 -0
- package/dist/env/client.d.ts +10 -5
- package/dist/env/client.js +12 -4
- package/dist/registry/installer.d.ts +10 -7
- package/dist/registry/installer.js +39 -35
- package/dist/registry/register.d.ts +17 -0
- package/dist/registry/register.js +22 -0
- package/dist/registry/register.test.d.ts +1 -0
- package/dist/registry/register.test.js +28 -0
- package/dist/shards/activate.svelte.js +23 -2
- package/dist/shards/types.d.ts +10 -1
- package/dist/shell-shard/Terminal.svelte +140 -33
- package/dist/shell-shard/Terminal.svelte.d.ts +3 -0
- package/dist/shell-shard/auto-relocate.d.ts +12 -0
- package/dist/shell-shard/auto-relocate.js +20 -0
- package/dist/shell-shard/auto-relocate.test.d.ts +1 -0
- package/dist/shell-shard/auto-relocate.test.js +35 -0
- package/dist/shell-shard/dispatch.d.ts +15 -0
- package/dist/shell-shard/dispatch.js +56 -0
- package/dist/shell-shard/modes/builtin.d.ts +5 -0
- package/dist/shell-shard/modes/builtin.js +18 -0
- package/dist/shell-shard/modes/prefs.d.ts +5 -0
- package/dist/shell-shard/modes/prefs.js +31 -0
- package/dist/shell-shard/modes/prefs.test.d.ts +1 -0
- package/dist/shell-shard/modes/prefs.test.js +46 -0
- package/dist/shell-shard/modes/registry.d.ts +7 -0
- package/dist/shell-shard/modes/registry.js +27 -0
- package/dist/shell-shard/modes/registry.test.d.ts +1 -0
- package/dist/shell-shard/modes/registry.test.js +35 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/modes/types.js +1 -0
- package/dist/shell-shard/protocol.d.ts +6 -0
- package/dist/shell-shard/shellShard.svelte.js +5 -1
- package/dist/shell-shard/tenant-fs-client.d.ts +24 -0
- package/dist/shell-shard/tenant-fs-client.js +44 -0
- package/dist/shell-shard/tenant-fs-client.test.d.ts +1 -0
- package/dist/shell-shard/tenant-fs-client.test.js +49 -0
- package/dist/shell-shard/terminal-dispatch.test.d.ts +1 -0
- package/dist/shell-shard/terminal-dispatch.test.js +53 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte +62 -0
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte +28 -0
- package/dist/shell-shard/toolbar/slots/FocusLockSlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +102 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +11 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte +17 -0
- package/dist/shell-shard/toolbar/slots/TargetShardSlot.svelte.d.ts +6 -0
- package/dist/shell-shard/toolbar/slots.d.ts +17 -0
- package/dist/shell-shard/toolbar/slots.js +26 -0
- package/dist/shell-shard/toolbar/slots.test.d.ts +1 -0
- package/dist/shell-shard/toolbar/slots.test.js +28 -0
- package/dist/shell-shard/verbs/cat.d.ts +2 -0
- package/dist/shell-shard/verbs/cat.js +34 -0
- package/dist/shell-shard/verbs/cd.test.d.ts +1 -0
- package/dist/shell-shard/verbs/cd.test.js +56 -0
- package/dist/shell-shard/verbs/env.d.ts +2 -0
- package/dist/shell-shard/verbs/env.js +14 -0
- package/dist/shell-shard/verbs/index.js +6 -1
- package/dist/shell-shard/verbs/ls.d.ts +2 -0
- package/dist/shell-shard/verbs/ls.js +29 -0
- package/dist/shell-shard/verbs/ls.test.d.ts +1 -0
- package/dist/shell-shard/verbs/ls.test.js +49 -0
- package/dist/shell-shard/verbs/session.d.ts +0 -1
- package/dist/shell-shard/verbs/session.js +58 -26
- package/dist/verbs/types.d.ts +2 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- 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 ((
|
|
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
|
package/dist/shards/types.d.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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,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,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 {};
|