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,35 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { ShellModeRegistry } from './registry';
|
|
3
|
+
const dev = { id: 'dev', label: 'Dev', requiresRole: 'admin', transport: 'ws', autoRelocate: false };
|
|
4
|
+
const user = { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
|
|
5
|
+
const ssh = { id: 'ssh', label: 'SSH', requiresRole: 'admin', transport: 'custom', autoRelocate: false };
|
|
6
|
+
describe('ShellModeRegistry', () => {
|
|
7
|
+
let reg;
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
reg = new ShellModeRegistry();
|
|
10
|
+
});
|
|
11
|
+
it('registers and retrieves modes', () => {
|
|
12
|
+
reg.register(dev);
|
|
13
|
+
expect(reg.get('dev')).toEqual(dev);
|
|
14
|
+
});
|
|
15
|
+
it('list(user) excludes admin-only modes', () => {
|
|
16
|
+
reg.register(dev);
|
|
17
|
+
reg.register(user);
|
|
18
|
+
reg.register(ssh);
|
|
19
|
+
const ids = reg.list('user').map((m) => m.id);
|
|
20
|
+
expect(ids).toEqual(['user']);
|
|
21
|
+
});
|
|
22
|
+
it('list(admin) includes all modes', () => {
|
|
23
|
+
reg.register(dev);
|
|
24
|
+
reg.register(user);
|
|
25
|
+
reg.register(ssh);
|
|
26
|
+
const ids = reg.list('admin').map((m) => m.id).sort();
|
|
27
|
+
expect(ids).toEqual(['dev', 'ssh', 'user']);
|
|
28
|
+
});
|
|
29
|
+
it('re-registering same id replaces the mode', () => {
|
|
30
|
+
var _a;
|
|
31
|
+
reg.register(dev);
|
|
32
|
+
reg.register(Object.assign(Object.assign({}, dev), { label: 'Dev+' }));
|
|
33
|
+
expect((_a = reg.get('dev')) === null || _a === void 0 ? void 0 : _a.label).toBe('Dev+');
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -23,6 +23,12 @@ export type ClientMessage =
|
|
|
23
23
|
/** Ask the server for its current cwd. */
|
|
24
24
|
| {
|
|
25
25
|
t: 'cwd-query';
|
|
26
|
+
}
|
|
27
|
+
/** Push a cwd change from the frontend (docs tree, file explorer, etc.).
|
|
28
|
+
* Server resolves `path` relative to current cwd, validates, broadcasts. */
|
|
29
|
+
| {
|
|
30
|
+
t: 'setCwd';
|
|
31
|
+
path: string;
|
|
26
32
|
};
|
|
27
33
|
export type ServerMessage =
|
|
28
34
|
/** Sent once on successful attach, immediately after hello. */
|
|
@@ -119,12 +119,16 @@ export const shellShard = {
|
|
|
119
119
|
const shell = makeShellApi(ctx);
|
|
120
120
|
const factory = {
|
|
121
121
|
mount(container, _context) {
|
|
122
|
+
var _a;
|
|
122
123
|
const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
|
|
123
124
|
const host = typeof location !== 'undefined' ? location.host : 'localhost';
|
|
124
125
|
const wsUrl = `${proto}://${host}/api/shell/session`;
|
|
126
|
+
const user = getUser();
|
|
127
|
+
const userId = (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest';
|
|
128
|
+
const role = isAdmin() ? 'admin' : 'user';
|
|
125
129
|
const instance = mount(Terminal, {
|
|
126
130
|
target: container,
|
|
127
|
-
props: { shell, wsUrl },
|
|
131
|
+
props: { shell, wsUrl, userId, role },
|
|
128
132
|
});
|
|
129
133
|
return {
|
|
130
134
|
unmount() {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for /api/fs/*. Read-only today.
|
|
3
|
+
* Shared across all shell modes so verb implementations stay mode-agnostic.
|
|
4
|
+
*/
|
|
5
|
+
export interface FsEntry {
|
|
6
|
+
name: string;
|
|
7
|
+
kind: 'file' | 'dir';
|
|
8
|
+
size: number;
|
|
9
|
+
mtime: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FsStat {
|
|
12
|
+
name: string;
|
|
13
|
+
kind: 'file' | 'dir';
|
|
14
|
+
size: number;
|
|
15
|
+
mtime: number;
|
|
16
|
+
}
|
|
17
|
+
export declare class TenantFsClient {
|
|
18
|
+
#private;
|
|
19
|
+
private readonly base;
|
|
20
|
+
constructor(base?: string);
|
|
21
|
+
list(path: string): Promise<FsEntry[]>;
|
|
22
|
+
stat(path: string): Promise<FsStat>;
|
|
23
|
+
read(path: string): Promise<string>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client for /api/fs/*. Read-only today.
|
|
3
|
+
* Shared across all shell modes so verb implementations stay mode-agnostic.
|
|
4
|
+
*/
|
|
5
|
+
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
|
|
6
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
|
|
7
|
+
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");
|
|
8
|
+
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
|
|
9
|
+
};
|
|
10
|
+
var _TenantFsClient_instances, _TenantFsClient_get;
|
|
11
|
+
export class TenantFsClient {
|
|
12
|
+
constructor(base = '') {
|
|
13
|
+
_TenantFsClient_instances.add(this);
|
|
14
|
+
this.base = base;
|
|
15
|
+
}
|
|
16
|
+
async list(path) {
|
|
17
|
+
const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/list', path);
|
|
18
|
+
const body = await res.json();
|
|
19
|
+
return body.entries;
|
|
20
|
+
}
|
|
21
|
+
async stat(path) {
|
|
22
|
+
const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/stat', path);
|
|
23
|
+
return await res.json();
|
|
24
|
+
}
|
|
25
|
+
async read(path) {
|
|
26
|
+
const res = await __classPrivateFieldGet(this, _TenantFsClient_instances, "m", _TenantFsClient_get).call(this, '/api/fs/read', path);
|
|
27
|
+
return await res.text();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
_TenantFsClient_instances = new WeakSet(), _TenantFsClient_get = async function _TenantFsClient_get(route, path) {
|
|
31
|
+
const url = `${this.base}${route}?path=${encodeURIComponent(path)}`;
|
|
32
|
+
const res = await fetch(url, { credentials: 'include' });
|
|
33
|
+
if (!res.ok) {
|
|
34
|
+
let msg = `HTTP ${res.status}`;
|
|
35
|
+
try {
|
|
36
|
+
const j = await res.json();
|
|
37
|
+
if (j.error)
|
|
38
|
+
msg = j.error;
|
|
39
|
+
}
|
|
40
|
+
catch ( /* non-json body */_a) { /* non-json body */ }
|
|
41
|
+
throw new Error(msg);
|
|
42
|
+
}
|
|
43
|
+
return res;
|
|
44
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { TenantFsClient } from './tenant-fs-client';
|
|
3
|
+
beforeEach(() => {
|
|
4
|
+
vi.restoreAllMocks();
|
|
5
|
+
});
|
|
6
|
+
function mockFetchJson(status, body) {
|
|
7
|
+
globalThis.fetch = vi.fn(async () => new Response(JSON.stringify(body), {
|
|
8
|
+
status,
|
|
9
|
+
headers: { 'content-type': 'application/json' },
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
12
|
+
describe('TenantFsClient.list', () => {
|
|
13
|
+
it('GETs /api/fs/list with the encoded path', async () => {
|
|
14
|
+
const calls = [];
|
|
15
|
+
globalThis.fetch = vi.fn(async (url) => {
|
|
16
|
+
calls.push(String(url));
|
|
17
|
+
return new Response(JSON.stringify({ entries: [{ name: 'a', kind: 'file', size: 1, mtime: 0 }] }), { status: 200 });
|
|
18
|
+
});
|
|
19
|
+
const c = new TenantFsClient();
|
|
20
|
+
const entries = await c.list('sub dir');
|
|
21
|
+
expect(calls[0]).toContain('/api/fs/list?path=sub%20dir');
|
|
22
|
+
expect(entries).toHaveLength(1);
|
|
23
|
+
});
|
|
24
|
+
it('throws on non-2xx', async () => {
|
|
25
|
+
mockFetchJson(403, { error: 'forbidden' });
|
|
26
|
+
const c = new TenantFsClient();
|
|
27
|
+
await expect(c.list('..')).rejects.toThrow(/forbidden/);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
describe('TenantFsClient.stat', () => {
|
|
31
|
+
it('returns kind and size', async () => {
|
|
32
|
+
mockFetchJson(200, { name: 'a.txt', kind: 'file', size: 5, mtime: 0 });
|
|
33
|
+
const c = new TenantFsClient();
|
|
34
|
+
const s = await c.stat('a.txt');
|
|
35
|
+
expect(s).toMatchObject({ kind: 'file', size: 5 });
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe('TenantFsClient.read', () => {
|
|
39
|
+
it('returns body text on 200', async () => {
|
|
40
|
+
globalThis.fetch = vi.fn(async () => new Response('hello', { status: 200 }));
|
|
41
|
+
const c = new TenantFsClient();
|
|
42
|
+
expect(await c.read('a.txt')).toBe('hello');
|
|
43
|
+
});
|
|
44
|
+
it('throws structured error on 413', async () => {
|
|
45
|
+
mockFetchJson(413, { error: 'file too large' });
|
|
46
|
+
const c = new TenantFsClient();
|
|
47
|
+
await expect(c.read('big.bin')).rejects.toThrow(/too large/);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { makeDispatch } from './dispatch';
|
|
3
|
+
function scaffold(modeId) {
|
|
4
|
+
const sent = [];
|
|
5
|
+
const pushed = [];
|
|
6
|
+
const mode = modeId === 'dev'
|
|
7
|
+
? { id: 'dev', label: 'Dev', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }
|
|
8
|
+
: { id: 'user', label: 'User', transport: 'none', autoRelocate: true };
|
|
9
|
+
const scrollback = { push: (e) => pushed.push(e) };
|
|
10
|
+
const session = {
|
|
11
|
+
history: { push: vi.fn() },
|
|
12
|
+
send: (m) => sent.push(m),
|
|
13
|
+
cwd: '/',
|
|
14
|
+
};
|
|
15
|
+
const fs = {};
|
|
16
|
+
const shell = {};
|
|
17
|
+
const resolver = {
|
|
18
|
+
resolve: (line) => line.startsWith('pwd')
|
|
19
|
+
? { kind: 'local', verb: { name: 'pwd', run: async () => { } }, args: [], line }
|
|
20
|
+
: { kind: 'forward', line },
|
|
21
|
+
};
|
|
22
|
+
const dispatch = makeDispatch({
|
|
23
|
+
mode: () => mode,
|
|
24
|
+
resolver,
|
|
25
|
+
scrollback,
|
|
26
|
+
session,
|
|
27
|
+
shell,
|
|
28
|
+
fs,
|
|
29
|
+
cwd: () => '/',
|
|
30
|
+
});
|
|
31
|
+
return { dispatch, sent, pushed };
|
|
32
|
+
}
|
|
33
|
+
describe('dispatch — user mode', () => {
|
|
34
|
+
it('unknown verbs print error, do not send', async () => {
|
|
35
|
+
const { dispatch, sent, pushed } = scaffold('user');
|
|
36
|
+
await dispatch('foo');
|
|
37
|
+
expect(sent).toEqual([]);
|
|
38
|
+
expect(pushed.some((e) => e.kind === 'status' && /unknown verb/.test(e.text))).toBe(true);
|
|
39
|
+
});
|
|
40
|
+
it('$ escape prints error', async () => {
|
|
41
|
+
const { dispatch, sent, pushed } = scaffold('user');
|
|
42
|
+
await dispatch('$ ls');
|
|
43
|
+
expect(sent).toEqual([]);
|
|
44
|
+
expect(pushed.some((e) => e.kind === 'status' && /server shell not available/.test(e.text))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
describe('dispatch — dev mode', () => {
|
|
48
|
+
it('forwards unknown verbs to server', async () => {
|
|
49
|
+
const { dispatch, sent } = scaffold('dev');
|
|
50
|
+
await dispatch('foo');
|
|
51
|
+
expect(sent.some((m) => m.t === 'submit' && m.line === 'foo')).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
registry: ToolbarSlotRegistry;
|
|
6
|
+
ctx: ShellSlotCtx;
|
|
7
|
+
expanded: boolean;
|
|
8
|
+
onToggle: () => void;
|
|
9
|
+
slotProps?: Record<string, Record<string, unknown>>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { registry, ctx, expanded, onToggle, slotProps = {} }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let slots = $derived(registry.list(ctx));
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="toolbar" class:collapsed={!expanded}>
|
|
18
|
+
<button class="toolbar-toggle" onclick={onToggle} title={expanded ? 'Collapse toolbar' : 'Expand toolbar'}>
|
|
19
|
+
{expanded ? '▲' : '▼'}
|
|
20
|
+
</button>
|
|
21
|
+
{#if expanded}
|
|
22
|
+
<div class="toolbar-slots">
|
|
23
|
+
{#each slots as s (s.id)}
|
|
24
|
+
{@const Slot = s.component}
|
|
25
|
+
<Slot {...(slotProps[s.id] ?? {})} />
|
|
26
|
+
{/each}
|
|
27
|
+
</div>
|
|
28
|
+
{/if}
|
|
29
|
+
</div>
|
|
30
|
+
|
|
31
|
+
<style>
|
|
32
|
+
.toolbar {
|
|
33
|
+
display: flex;
|
|
34
|
+
align-items: center;
|
|
35
|
+
gap: 6px;
|
|
36
|
+
padding: 2px 6px;
|
|
37
|
+
background: var(--shell-toolbar-bg, #1a1a1a);
|
|
38
|
+
border-bottom: 1px solid var(--shell-border, #333);
|
|
39
|
+
flex-shrink: 0;
|
|
40
|
+
min-height: 24px;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.toolbar-toggle {
|
|
44
|
+
background: none;
|
|
45
|
+
border: none;
|
|
46
|
+
color: var(--shell-fg-dim, #888);
|
|
47
|
+
cursor: pointer;
|
|
48
|
+
font-size: 0.7em;
|
|
49
|
+
padding: 0 2px;
|
|
50
|
+
line-height: 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.toolbar-toggle:hover {
|
|
54
|
+
color: var(--shell-fg, #ddd);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
.toolbar-slots {
|
|
58
|
+
display: flex;
|
|
59
|
+
align-items: center;
|
|
60
|
+
gap: 8px;
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
|
|
2
|
+
interface Props {
|
|
3
|
+
registry: ToolbarSlotRegistry;
|
|
4
|
+
ctx: ShellSlotCtx;
|
|
5
|
+
expanded: boolean;
|
|
6
|
+
onToggle: () => void;
|
|
7
|
+
slotProps?: Record<string, Record<string, unknown>>;
|
|
8
|
+
}
|
|
9
|
+
declare const Toolbar: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type Toolbar = ReturnType<typeof Toolbar>;
|
|
11
|
+
export default Toolbar;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
locked: boolean;
|
|
4
|
+
onToggle: () => void;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
let { locked, onToggle }: Props = $props();
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<button class="focus-lock-btn" onclick={onToggle} title={locked ? 'Unlock focus' : 'Lock focus'}>
|
|
11
|
+
{locked ? '🔒' : '🔓'}
|
|
12
|
+
</button>
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
.focus-lock-btn {
|
|
16
|
+
background: none;
|
|
17
|
+
border: 1px solid var(--shell-border, #444);
|
|
18
|
+
border-radius: 3px;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
padding: 2px 5px;
|
|
21
|
+
font-size: 0.9em;
|
|
22
|
+
line-height: 1;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.focus-lock-btn:hover {
|
|
26
|
+
background: var(--shell-hover, #222);
|
|
27
|
+
}
|
|
28
|
+
</style>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { ShellMode, ShellRole } from '../../modes/types';
|
|
3
|
+
import type { ShellModeRegistry } from '../../modes/registry';
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
mode: ShellMode;
|
|
7
|
+
role: ShellRole;
|
|
8
|
+
registry: ShellModeRegistry;
|
|
9
|
+
onSelect: (id: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
let { mode, role, registry, onSelect }: Props = $props();
|
|
13
|
+
|
|
14
|
+
let open = $state(false);
|
|
15
|
+
|
|
16
|
+
function select(id: string) {
|
|
17
|
+
open = false;
|
|
18
|
+
onSelect(id);
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
{#if role === 'admin'}
|
|
23
|
+
<div class="mode-slot">
|
|
24
|
+
<button class="mode-btn" onclick={() => (open = !open)}>
|
|
25
|
+
{mode.label} ▾
|
|
26
|
+
</button>
|
|
27
|
+
{#if open}
|
|
28
|
+
<ul class="mode-menu" role="menu">
|
|
29
|
+
{#each registry.list(role) as m (m.id)}
|
|
30
|
+
<li role="menuitem">
|
|
31
|
+
<button
|
|
32
|
+
class="mode-option"
|
|
33
|
+
class:active={m.id === mode.id}
|
|
34
|
+
onclick={() => select(m.id)}
|
|
35
|
+
>
|
|
36
|
+
{m.label}
|
|
37
|
+
</button>
|
|
38
|
+
</li>
|
|
39
|
+
{/each}
|
|
40
|
+
</ul>
|
|
41
|
+
{/if}
|
|
42
|
+
</div>
|
|
43
|
+
{:else}
|
|
44
|
+
<span class="mode-label">{mode.label}</span>
|
|
45
|
+
{/if}
|
|
46
|
+
|
|
47
|
+
<style>
|
|
48
|
+
.mode-slot {
|
|
49
|
+
position: relative;
|
|
50
|
+
display: inline-block;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.mode-btn {
|
|
54
|
+
background: none;
|
|
55
|
+
border: 1px solid var(--shell-border, #444);
|
|
56
|
+
color: var(--shell-fg, #ddd);
|
|
57
|
+
padding: 2px 6px;
|
|
58
|
+
border-radius: 3px;
|
|
59
|
+
cursor: pointer;
|
|
60
|
+
font-size: 0.85em;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
.mode-btn:hover {
|
|
64
|
+
background: var(--shell-hover, #222);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.mode-menu {
|
|
68
|
+
position: absolute;
|
|
69
|
+
top: 100%;
|
|
70
|
+
left: 0;
|
|
71
|
+
margin: 2px 0 0;
|
|
72
|
+
padding: 0;
|
|
73
|
+
list-style: none;
|
|
74
|
+
background: var(--shell-bg, #111);
|
|
75
|
+
border: 1px solid var(--shell-border, #444);
|
|
76
|
+
border-radius: 3px;
|
|
77
|
+
z-index: 100;
|
|
78
|
+
min-width: 100%;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.mode-option {
|
|
82
|
+
display: block;
|
|
83
|
+
width: 100%;
|
|
84
|
+
background: none;
|
|
85
|
+
border: none;
|
|
86
|
+
color: var(--shell-fg, #ddd);
|
|
87
|
+
padding: 4px 10px;
|
|
88
|
+
text-align: left;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
font-size: 0.85em;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.mode-option:hover,
|
|
94
|
+
.mode-option.active {
|
|
95
|
+
background: var(--shell-hover, #222);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.mode-label {
|
|
99
|
+
font-size: 0.85em;
|
|
100
|
+
color: var(--shell-fg-dim, #888);
|
|
101
|
+
}
|
|
102
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ShellMode, ShellRole } from '../../modes/types';
|
|
2
|
+
import type { ShellModeRegistry } from '../../modes/registry';
|
|
3
|
+
interface Props {
|
|
4
|
+
mode: ShellMode;
|
|
5
|
+
role: ShellRole;
|
|
6
|
+
registry: ShellModeRegistry;
|
|
7
|
+
onSelect: (id: string) => void;
|
|
8
|
+
}
|
|
9
|
+
declare const ModeSlot: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type ModeSlot = ReturnType<typeof ModeSlot>;
|
|
11
|
+
export default ModeSlot;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
interface Props {
|
|
3
|
+
target: string | null;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
let { target }: Props = $props();
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<span class="target-shard" title="Target shard">→ {target ?? '—'}</span>
|
|
10
|
+
|
|
11
|
+
<style>
|
|
12
|
+
.target-shard {
|
|
13
|
+
font-size: 0.85em;
|
|
14
|
+
color: var(--shell-fg-dim, #888);
|
|
15
|
+
font-family: monospace;
|
|
16
|
+
}
|
|
17
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Component } from 'svelte';
|
|
2
|
+
import type { ShellMode, ShellRole } from '../modes/types';
|
|
3
|
+
export interface ShellSlotCtx {
|
|
4
|
+
mode: ShellMode;
|
|
5
|
+
role: ShellRole;
|
|
6
|
+
}
|
|
7
|
+
export interface ToolbarSlot {
|
|
8
|
+
id: string;
|
|
9
|
+
order: number;
|
|
10
|
+
visible(ctx: ShellSlotCtx): boolean;
|
|
11
|
+
component: Component<any>;
|
|
12
|
+
}
|
|
13
|
+
export declare class ToolbarSlotRegistry {
|
|
14
|
+
#private;
|
|
15
|
+
register(slot: ToolbarSlot): void;
|
|
16
|
+
list(ctx: ShellSlotCtx): ToolbarSlot[];
|
|
17
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
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 __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
|
|
7
|
+
if (kind === "m") throw new TypeError("Private method is not writable");
|
|
8
|
+
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
|
|
9
|
+
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
|
|
10
|
+
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
|
|
11
|
+
};
|
|
12
|
+
var _ToolbarSlotRegistry_slots;
|
|
13
|
+
export class ToolbarSlotRegistry {
|
|
14
|
+
constructor() {
|
|
15
|
+
_ToolbarSlotRegistry_slots.set(this, []);
|
|
16
|
+
}
|
|
17
|
+
register(slot) {
|
|
18
|
+
__classPrivateFieldSet(this, _ToolbarSlotRegistry_slots, __classPrivateFieldGet(this, _ToolbarSlotRegistry_slots, "f").filter((s) => s.id !== slot.id).concat(slot), "f");
|
|
19
|
+
}
|
|
20
|
+
list(ctx) {
|
|
21
|
+
return __classPrivateFieldGet(this, _ToolbarSlotRegistry_slots, "f")
|
|
22
|
+
.filter((s) => s.visible(ctx))
|
|
23
|
+
.sort((a, b) => a.order - b.order);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
_ToolbarSlotRegistry_slots = new WeakMap();
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ToolbarSlotRegistry } from './slots';
|
|
3
|
+
const A = { id: 'a', order: 20, visible: () => true, component: {} };
|
|
4
|
+
const B = { id: 'b', order: 10, visible: () => true, component: {} };
|
|
5
|
+
const C = { id: 'c', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: {} };
|
|
6
|
+
const userCtx = { mode: { id: 'user', label: 'User', transport: 'none', autoRelocate: true }, role: 'user' };
|
|
7
|
+
const devCtx = { mode: { id: 'dev', label: 'Dev', transport: 'ws', autoRelocate: false, requiresRole: 'admin' }, role: 'admin' };
|
|
8
|
+
describe('ToolbarSlotRegistry', () => {
|
|
9
|
+
it('lists visible slots in order', () => {
|
|
10
|
+
const r = new ToolbarSlotRegistry();
|
|
11
|
+
r.register(A);
|
|
12
|
+
r.register(B);
|
|
13
|
+
r.register(C);
|
|
14
|
+
expect(r.list(userCtx).map((s) => s.id)).toEqual(['b', 'a', 'c']);
|
|
15
|
+
});
|
|
16
|
+
it('filters by visible', () => {
|
|
17
|
+
const r = new ToolbarSlotRegistry();
|
|
18
|
+
r.register(A);
|
|
19
|
+
r.register(C);
|
|
20
|
+
expect(r.list(devCtx).map((s) => s.id)).toEqual(['a']);
|
|
21
|
+
});
|
|
22
|
+
it('re-registering by id replaces', () => {
|
|
23
|
+
const r = new ToolbarSlotRegistry();
|
|
24
|
+
r.register(A);
|
|
25
|
+
r.register(Object.assign(Object.assign({}, A), { order: 99 }));
|
|
26
|
+
expect(r.list(userCtx)[0].order).toBe(99);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const catVerb = {
|
|
2
|
+
name: 'cat',
|
|
3
|
+
summary: 'Read a file into the scrollback.',
|
|
4
|
+
async run(ctx, args) {
|
|
5
|
+
if (args.length === 0) {
|
|
6
|
+
ctx.scrollback.push({
|
|
7
|
+
kind: 'status',
|
|
8
|
+
text: 'cat: missing file argument',
|
|
9
|
+
level: 'error',
|
|
10
|
+
ts: Date.now(),
|
|
11
|
+
});
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
const path = args[0];
|
|
15
|
+
const target = ctx.session.cwd ? `${ctx.session.cwd}/${path}` : path;
|
|
16
|
+
try {
|
|
17
|
+
const text = await ctx.fs.read(target);
|
|
18
|
+
ctx.scrollback.push({
|
|
19
|
+
kind: 'text',
|
|
20
|
+
stream: 'stdout',
|
|
21
|
+
chunks: [text],
|
|
22
|
+
ts: Date.now(),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
ctx.scrollback.push({
|
|
27
|
+
kind: 'status',
|
|
28
|
+
text: `cat: ${err.message}`,
|
|
29
|
+
level: 'error',
|
|
30
|
+
ts: Date.now(),
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|