sh3-core 0.8.0 → 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Shell.svelte +19 -0
- package/dist/api.d.ts +5 -1
- package/dist/api.js +6 -1
- package/dist/app/admin/ApiKeysView.svelte +16 -27
- package/dist/app/admin/SystemView.svelte +149 -11
- package/dist/documents/backends.d.ts +8 -0
- package/dist/documents/backends.js +87 -0
- package/dist/documents/backends.test.d.ts +1 -0
- package/dist/documents/backends.test.js +33 -0
- package/dist/documents/browse.d.ts +12 -0
- package/dist/documents/browse.js +19 -0
- package/dist/documents/browse.test.d.ts +1 -0
- package/dist/documents/browse.test.js +41 -0
- package/dist/documents/http-backend.d.ts +4 -0
- package/dist/documents/http-backend.js +14 -0
- package/dist/documents/sync/index.d.ts +1 -2
- package/dist/documents/sync/index.js +0 -2
- package/dist/documents/sync/observer.d.ts +3 -0
- package/dist/documents/sync/observer.js +45 -0
- package/dist/documents/sync/registry.d.ts +3 -0
- package/dist/documents/sync/registry.js +8 -1
- package/dist/documents/sync/registry.test.js +11 -0
- package/dist/documents/types.d.ts +18 -0
- package/dist/documents/types.js +6 -1
- package/dist/keys/ConsentDialog.svelte +176 -0
- package/dist/keys/ConsentDialog.svelte.d.ts +3 -0
- package/dist/keys/client.d.ts +13 -0
- package/dist/keys/client.js +65 -0
- package/dist/keys/client.test.d.ts +1 -0
- package/dist/keys/client.test.js +44 -0
- package/dist/keys/consent.svelte.d.ts +16 -0
- package/dist/keys/consent.svelte.js +29 -0
- package/dist/keys/consent.test.d.ts +1 -0
- package/dist/keys/consent.test.js +53 -0
- package/dist/keys/revocation-bus.svelte.d.ts +35 -0
- package/dist/keys/revocation-bus.svelte.js +92 -0
- package/dist/keys/revocation-bus.test.d.ts +1 -0
- package/dist/keys/revocation-bus.test.js +95 -0
- package/dist/keys/types.d.ts +32 -0
- package/dist/keys/types.js +13 -0
- package/dist/layout/inspection.d.ts +17 -0
- package/dist/layout/inspection.js +53 -0
- package/dist/server-shard/types.d.ts +21 -2
- package/dist/server-sync.d.ts +6 -0
- package/dist/server-sync.js +634 -0
- package/dist/server-sync.js.map +7 -0
- package/dist/sh3core-shard/ShellHome.svelte +140 -63
- package/dist/sh3core-shard/sh3coreShard.svelte.js +12 -1
- package/dist/shards/activate-browse.test.d.ts +1 -0
- package/dist/shards/activate-browse.test.js +36 -0
- package/dist/shards/activate-on-key-revoked.test.d.ts +1 -0
- package/dist/shards/activate-on-key-revoked.test.js +60 -0
- package/dist/shards/activate-sync-registry.test.d.ts +1 -0
- package/dist/shards/activate-sync-registry.test.js +42 -0
- package/dist/shards/activate-tenantid.test.d.ts +1 -0
- package/dist/shards/activate-tenantid.test.js +21 -0
- package/dist/shards/activate.svelte.d.ts +12 -0
- package/dist/shards/activate.svelte.js +55 -3
- package/dist/shards/types.d.ts +42 -0
- package/dist/shards/types.js +1 -1
- package/dist/shell/views/KeysAndPeers.svelte +110 -0
- package/dist/shell/views/KeysAndPeers.svelte.d.ts +3 -0
- package/dist/shell-shard/Terminal.svelte +0 -11
- package/dist/shell-shard/manifest.js +1 -1
- package/dist/shell-shard/shellShard.svelte.js +52 -4
- package/dist/shell-shard/toolbar/Toolbar.svelte +11 -32
- package/dist/shell-shard/toolbar/Toolbar.svelte.d.ts +0 -2
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +29 -62
- package/dist/shell-shard/verbs/index.js +3 -1
- package/dist/shell-shard/verbs/views.d.ts +2 -0
- package/dist/shell-shard/verbs/views.js +103 -2
- package/dist/testing.d.ts +3 -0
- package/dist/testing.js +77 -0
- package/dist/testing.js.map +7 -0
- package/dist/verbs/types.d.ts +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +10 -2
|
@@ -24,8 +24,14 @@ import { isAdmin as checkIsAdmin } from '../auth/index';
|
|
|
24
24
|
import { createZoneManager } from '../state/manage';
|
|
25
25
|
import { PERMISSION_STATE_MANAGE } from '../state/types';
|
|
26
26
|
import { PERMISSION_DOCUMENTS_SYNC } from '../documents/sync/types';
|
|
27
|
+
import { PERMISSION_DOCUMENTS_BROWSE } from '../documents/types';
|
|
28
|
+
import { createBrowseCapability } from '../documents/browse';
|
|
27
29
|
import { getSyncBundle } from '../documents/sync/singleton';
|
|
28
30
|
import { createSyncHandle } from '../documents/sync/handle';
|
|
31
|
+
import { createSyncRegistryAccessor } from '../documents/sync/observer';
|
|
32
|
+
import { createShardKeysApi } from '../keys/client';
|
|
33
|
+
import { PERMISSION_KEYS_MINT } from '../keys/types';
|
|
34
|
+
import { subscribe } from '../keys/revocation-bus.svelte';
|
|
29
35
|
/**
|
|
30
36
|
* Reactive registry of every shard known to the host. Keys are shard ids.
|
|
31
37
|
* Populated once at boot by the glob-discovery loop in main.ts (through
|
|
@@ -68,7 +74,7 @@ export function registerShard(shard) {
|
|
|
68
74
|
* @throws If the shard is not registered, or if a manifest view has no factory after activation.
|
|
69
75
|
*/
|
|
70
76
|
export async function activateShard(id) {
|
|
71
|
-
var _a, _b, _c;
|
|
77
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
72
78
|
const shard = registeredShards.get(id);
|
|
73
79
|
if (!shard) {
|
|
74
80
|
throw new Error(`Cannot activate shard "${id}": not registered`);
|
|
@@ -128,10 +134,19 @@ export async function activateShard(id) {
|
|
|
128
134
|
get isAdmin() {
|
|
129
135
|
return checkIsAdmin();
|
|
130
136
|
},
|
|
137
|
+
get tenantId() {
|
|
138
|
+
return getTenantId();
|
|
139
|
+
},
|
|
131
140
|
zones: ((_a = shard.manifest.permissions) === null || _a === void 0 ? void 0 : _a.includes(PERMISSION_STATE_MANAGE))
|
|
132
141
|
? createZoneManager()
|
|
133
142
|
: undefined,
|
|
134
|
-
|
|
143
|
+
browse: ((_b = shard.manifest.permissions) === null || _b === void 0 ? void 0 : _b.includes(PERMISSION_DOCUMENTS_BROWSE))
|
|
144
|
+
? createBrowseCapability(getTenantId(), getDocumentBackend())
|
|
145
|
+
: undefined,
|
|
146
|
+
syncRegistry: ((_c = shard.manifest.permissions) === null || _c === void 0 ? void 0 : _c.includes(PERMISSION_DOCUMENTS_BROWSE))
|
|
147
|
+
? createSyncRegistryAccessor(getDocumentBackend(), getTenantId())
|
|
148
|
+
: undefined,
|
|
149
|
+
sync: ((_d = shard.manifest.permissions) === null || _d === void 0 ? void 0 : _d.includes(PERMISSION_DOCUMENTS_SYNC))
|
|
135
150
|
? () => {
|
|
136
151
|
const backend = getDocumentBackend();
|
|
137
152
|
const tenantId = getTenantId();
|
|
@@ -149,8 +164,27 @@ export async function activateShard(id) {
|
|
|
149
164
|
};
|
|
150
165
|
}
|
|
151
166
|
: undefined,
|
|
167
|
+
keys: ((_e = shard.manifest.permissions) === null || _e === void 0 ? void 0 : _e.includes(PERMISSION_KEYS_MINT))
|
|
168
|
+
? createShardKeysApi({
|
|
169
|
+
shardId: id,
|
|
170
|
+
shardPermissions: (_f = shard.manifest.permissions) !== null && _f !== void 0 ? _f : [],
|
|
171
|
+
})
|
|
172
|
+
: undefined,
|
|
152
173
|
};
|
|
153
174
|
entry.ctx = ctx;
|
|
175
|
+
// Wire onKeyRevoked hook: subscribe to the revocation bus for this shard.
|
|
176
|
+
// Only shards that declare the hook incur the subscription overhead.
|
|
177
|
+
if (shard.onKeyRevoked) {
|
|
178
|
+
const off = subscribe(id, async (keyId) => {
|
|
179
|
+
try {
|
|
180
|
+
await shard.onKeyRevoked(keyId);
|
|
181
|
+
}
|
|
182
|
+
catch (err) {
|
|
183
|
+
console.error(`[sh3] onKeyRevoked failed in "${id}":`, err);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
entry.cleanupFns.push(async () => off());
|
|
187
|
+
}
|
|
154
188
|
active.set(id, entry);
|
|
155
189
|
activeShards.set(id, shard);
|
|
156
190
|
await shard.activate(ctx);
|
|
@@ -170,7 +204,7 @@ export async function activateShard(id) {
|
|
|
170
204
|
console.warn(`[sh3] Failed to hydrate env state for shard "${id}":`, err instanceof Error ? err.message : err);
|
|
171
205
|
}
|
|
172
206
|
}
|
|
173
|
-
void ((
|
|
207
|
+
void ((_g = shard.autostart) === null || _g === void 0 ? void 0 : _g.call(shard, ctx));
|
|
174
208
|
}
|
|
175
209
|
/**
|
|
176
210
|
* Deactivate an active shard. Calls `shard.deactivate`, flushes and disposes
|
|
@@ -212,6 +246,24 @@ export function getShardContext(id) {
|
|
|
212
246
|
var _a;
|
|
213
247
|
return (_a = active.get(id)) === null || _a === void 0 ? void 0 : _a.ctx;
|
|
214
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Enumerate every view declared as `standalone` across the currently
|
|
251
|
+
* active shards. Intended for the `views --standalone` verb and any
|
|
252
|
+
* launcher UI that wants to surface "summonable" primitives. Only
|
|
253
|
+
* pulls from `activeShards` — registered-but-inactive shards aren't
|
|
254
|
+
* ready to mount.
|
|
255
|
+
*/
|
|
256
|
+
export function listStandaloneViews() {
|
|
257
|
+
const out = [];
|
|
258
|
+
for (const shard of activeShards.values()) {
|
|
259
|
+
for (const view of shard.manifest.views) {
|
|
260
|
+
if (view.standalone) {
|
|
261
|
+
out.push({ shardId: shard.manifest.id, viewId: view.id, label: view.label });
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
215
267
|
/**
|
|
216
268
|
* Test-only reset. Tears down any active shard entries (without running
|
|
217
269
|
* deactivate hooks — tests should run deactivate explicitly if they care)
|
package/dist/shards/types.d.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
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 { BrowseCapability } from '../documents/browse';
|
|
4
5
|
import type { SyncHandle } from '../documents/sync/types';
|
|
6
|
+
import type { SyncRegistry } from '../documents/sync/registry';
|
|
5
7
|
import type { EnvState } from '../env/types';
|
|
6
8
|
import type { Verb } from '../verbs/types';
|
|
9
|
+
import type { ShardContextKeys } from '../keys/types';
|
|
10
|
+
export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
|
|
7
11
|
/**
|
|
8
12
|
* The object returned by `ViewFactory.mount`. The framework calls
|
|
9
13
|
* `unmount()` when the slot goes away, and `onResize(w, h)` whenever the
|
|
@@ -83,6 +87,14 @@ export interface ViewDeclaration {
|
|
|
83
87
|
label: string;
|
|
84
88
|
/** Optional icon hint (reserved; not yet rendered in phase 8). */
|
|
85
89
|
icon?: string;
|
|
90
|
+
/**
|
|
91
|
+
* When true, this view is a standalone primitive — summonable from
|
|
92
|
+
* anywhere without requiring an owning app. Standalone views are
|
|
93
|
+
* enumerable via the framework's view directory and can be popped
|
|
94
|
+
* out into a float or docked into the active layout by verbs like
|
|
95
|
+
* `popout` and `dock`. Defaults to false.
|
|
96
|
+
*/
|
|
97
|
+
standalone?: boolean;
|
|
86
98
|
}
|
|
87
99
|
/**
|
|
88
100
|
* Static description of a shard as observed by the framework and consumers
|
|
@@ -120,6 +132,8 @@ export interface ShardManifest {
|
|
|
120
132
|
* Currently recognized:
|
|
121
133
|
* - 'state:manage' — cross-shard zone access.
|
|
122
134
|
* - 'documents:sync' — cross-shard document sync API.
|
|
135
|
+
* - 'documents:browse' — tenant-wide document observation and sync
|
|
136
|
+
* registry visibility (observer-class shards, e.g. file-explorer).
|
|
123
137
|
*/
|
|
124
138
|
permissions?: string[];
|
|
125
139
|
}
|
|
@@ -188,6 +202,12 @@ export interface ShardContext {
|
|
|
188
202
|
envUpdate<T extends Record<string, unknown>>(patch: Partial<T>): Promise<void>;
|
|
189
203
|
/** Whether the current session has admin privileges. */
|
|
190
204
|
isAdmin: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* The active tenant id. Always present. Exposed for logging / diagnostics.
|
|
207
|
+
* Scopes never carry tenantId; the engine rebinds per-session. Do not
|
|
208
|
+
* serialize into persistent storage.
|
|
209
|
+
*/
|
|
210
|
+
tenantId: string;
|
|
191
211
|
/**
|
|
192
212
|
* Cross-shard zone management API. Only present when the shard's
|
|
193
213
|
* manifest declares the `'state:manage'` permission. Check with
|
|
@@ -200,6 +220,26 @@ export interface ShardContext {
|
|
|
200
220
|
* `if (ctx.sync)` before use.
|
|
201
221
|
*/
|
|
202
222
|
sync?: () => SyncHandle;
|
|
223
|
+
/**
|
|
224
|
+
* Tenant-wide document browse API. Read-only enumeration and change
|
|
225
|
+
* subscription across every shard's documents for the active tenant.
|
|
226
|
+
* Only present when the shard's manifest declares the
|
|
227
|
+
* `'documents:browse'` permission. Writes still flow through the
|
|
228
|
+
* owning shard's own `ctx.documents()` handle.
|
|
229
|
+
*/
|
|
230
|
+
browse?: BrowseCapability;
|
|
231
|
+
/**
|
|
232
|
+
* Sync registry observer. Read-only list/revoke/conflict enumeration
|
|
233
|
+
* for explorer-class shards. Only present when the shard declares
|
|
234
|
+
* `'documents:browse'`. Granting still happens exclusively via
|
|
235
|
+
* `<SyncGrantPicker />`.
|
|
236
|
+
*/
|
|
237
|
+
syncRegistry?: () => SyncRegistry;
|
|
238
|
+
/**
|
|
239
|
+
* Mint/list/revoke keys minted by this shard. Only available when the
|
|
240
|
+
* manifest declares the `keys:mint` permission.
|
|
241
|
+
*/
|
|
242
|
+
keys?: ShardContextKeys;
|
|
203
243
|
}
|
|
204
244
|
/**
|
|
205
245
|
* A shard module. Shards are the fundamental unit of contribution in SH3.
|
|
@@ -241,6 +281,8 @@ export interface Shard {
|
|
|
241
281
|
* `ShardContext` that `activate` received.
|
|
242
282
|
*/
|
|
243
283
|
resume?(ctx: ShardContext): void | Promise<void>;
|
|
284
|
+
/** Fires when a key minted by this shard is revoked from any source. */
|
|
285
|
+
onKeyRevoked?(id: string): void | Promise<void>;
|
|
244
286
|
}
|
|
245
287
|
/**
|
|
246
288
|
* Source-level shape of a shard as written by external package authors.
|
package/dist/shards/types.js
CHANGED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* Keys & Peers view — lists every API key minted for the current user's
|
|
4
|
+
* tenant (admin keys excluded) and allows individual revocation.
|
|
5
|
+
*
|
|
6
|
+
* Reachable from settings. No create-key button; keys are only minted
|
|
7
|
+
* in-shard via the consent flow.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ApiKeyPublic } from '../../keys/types';
|
|
11
|
+
import { subscribe as subscribeBus } from '../../keys/revocation-bus.svelte';
|
|
12
|
+
|
|
13
|
+
let rows = $state<ApiKeyPublic[]>([]);
|
|
14
|
+
let loadError = $state<string | null>(null);
|
|
15
|
+
let loading = $state(true);
|
|
16
|
+
let confirmingId = $state<string | null>(null);
|
|
17
|
+
|
|
18
|
+
async function refresh(): Promise<void> {
|
|
19
|
+
loadError = null;
|
|
20
|
+
loading = true;
|
|
21
|
+
try {
|
|
22
|
+
const res = await fetch('/api/keys', { credentials: 'include' });
|
|
23
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
24
|
+
rows = await res.json();
|
|
25
|
+
} catch (err) {
|
|
26
|
+
loadError = err instanceof Error ? err.message : String(err);
|
|
27
|
+
} finally {
|
|
28
|
+
loading = false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function revoke(id: string): Promise<void> {
|
|
33
|
+
confirmingId = null;
|
|
34
|
+
const res = await fetch(`/api/keys/${encodeURIComponent(id)}`, {
|
|
35
|
+
method: 'DELETE',
|
|
36
|
+
credentials: 'include',
|
|
37
|
+
});
|
|
38
|
+
if (res.ok || res.status === 404) {
|
|
39
|
+
rows = rows.filter((r) => r.id !== id);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function formatDate(iso: string): string {
|
|
44
|
+
return new Date(iso).toLocaleDateString(undefined, {
|
|
45
|
+
year: 'numeric', month: 'short', day: 'numeric',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
$effect(() => { refresh(); });
|
|
50
|
+
|
|
51
|
+
// Refresh whenever any key is revoked (by this or another tab/shard).
|
|
52
|
+
$effect(() => subscribeBus('*', () => { void refresh(); }));
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<div class="keys-peers">
|
|
56
|
+
<div class="keys-peers-header">
|
|
57
|
+
<h2>Keys & Peers</h2>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
{#if loading}
|
|
61
|
+
<p class="keys-peers-muted">Loading...</p>
|
|
62
|
+
{:else if loadError}
|
|
63
|
+
<p class="keys-peers-error">Failed to load: {loadError}</p>
|
|
64
|
+
{:else if rows.length === 0}
|
|
65
|
+
<p class="keys-peers-muted">No keys yet. Shards will ask your permission before creating one.</p>
|
|
66
|
+
{:else}
|
|
67
|
+
<ul class="keys-peers-list">
|
|
68
|
+
{#each rows as row (row.id)}
|
|
69
|
+
<li class="keys-peers-item">
|
|
70
|
+
<div class="keys-peers-info">
|
|
71
|
+
<span class="keys-peers-label">{row.label}</span>
|
|
72
|
+
<span class="keys-peers-meta">
|
|
73
|
+
Minted by: {row.mintedByShardId ?? 'system'}{row.connectorId ? ` \u00B7 Connector: ${row.connectorId}` : ''}
|
|
74
|
+
</span>
|
|
75
|
+
<span class="keys-peers-meta">
|
|
76
|
+
Scopes: {row.scopes.join(', ')}
|
|
77
|
+
</span>
|
|
78
|
+
<span class="keys-peers-meta">
|
|
79
|
+
Created: {formatDate(row.createdAt)} · Expires: {row.expiresAt ? formatDate(row.expiresAt) : 'never'}
|
|
80
|
+
</span>
|
|
81
|
+
</div>
|
|
82
|
+
<div class="keys-peers-actions">
|
|
83
|
+
{#if confirmingId === row.id}
|
|
84
|
+
<button type="button" class="keys-peers-btn-danger" onclick={() => revoke(row.id)}>Confirm</button>
|
|
85
|
+
<button type="button" class="keys-peers-btn-secondary" onclick={() => { confirmingId = null; }}>Cancel</button>
|
|
86
|
+
{:else}
|
|
87
|
+
<button type="button" class="keys-peers-btn-danger" onclick={() => { confirmingId = row.id; }}>Revoke</button>
|
|
88
|
+
{/if}
|
|
89
|
+
</div>
|
|
90
|
+
</li>
|
|
91
|
+
{/each}
|
|
92
|
+
</ul>
|
|
93
|
+
{/if}
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<style>
|
|
97
|
+
.keys-peers { padding: 24px; font-family: system-ui, sans-serif; color: var(--shell-fg); }
|
|
98
|
+
.keys-peers-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; }
|
|
99
|
+
.keys-peers-header h2 { margin: 0; font-size: 18px; }
|
|
100
|
+
.keys-peers-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
|
|
101
|
+
.keys-peers-item { display: flex; justify-content: space-between; align-items: flex-start; padding: 12px 16px; background: var(--shell-bg-elevated, #252540); border: 1px solid var(--shell-border, #3a3a5c); border-radius: var(--shell-radius, 6px); gap: 12px; }
|
|
102
|
+
.keys-peers-info { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
|
103
|
+
.keys-peers-label { font-weight: 600; }
|
|
104
|
+
.keys-peers-meta { font-size: 11px; color: var(--shell-fg-subtle); }
|
|
105
|
+
.keys-peers-actions { display: flex; gap: 6px; flex-shrink: 0; align-items: flex-start; padding-top: 2px; }
|
|
106
|
+
.keys-peers-btn-danger { background: transparent; color: var(--shell-error, #d32f2f); border: 1px solid var(--shell-error, #d32f2f); font-size: 12px; padding: 4px 10px; border-radius: var(--shell-radius, 6px); cursor: pointer; }
|
|
107
|
+
.keys-peers-btn-secondary { background: transparent; color: var(--shell-fg-subtle); border: 1px solid var(--shell-border, #3a3a5c); font-size: 12px; padding: 4px 10px; border-radius: var(--shell-radius, 6px); cursor: pointer; }
|
|
108
|
+
.keys-peers-muted { color: var(--shell-fg-muted); font-style: italic; }
|
|
109
|
+
.keys-peers-error { color: var(--shell-error, #d32f2f); font-size: 13px; }
|
|
110
|
+
</style>
|
|
@@ -88,15 +88,6 @@
|
|
|
88
88
|
toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'user', component: FocusLockSlot });
|
|
89
89
|
toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'user', component: TargetShardSlot });
|
|
90
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
91
|
/** Walk the layout tree and return the viewId of the active tab in the first
|
|
101
92
|
* TabsNode found (breadth-first). Returns null if the layout contains no
|
|
102
93
|
* tabs node with a populated active tab. */
|
|
@@ -205,8 +196,6 @@
|
|
|
205
196
|
<Toolbar
|
|
206
197
|
registry={toolbarRegistry}
|
|
207
198
|
ctx={{ mode, role }}
|
|
208
|
-
expanded={toolbarExpanded}
|
|
209
|
-
onToggle={toggleToolbar}
|
|
210
199
|
slotProps={{
|
|
211
200
|
mode: { mode, role, registry: modeRegistry, onSelect: setMode },
|
|
212
201
|
'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
|
|
@@ -3,7 +3,7 @@ export const manifest = {
|
|
|
3
3
|
id: 'shell',
|
|
4
4
|
label: 'Shell',
|
|
5
5
|
version: VERSION,
|
|
6
|
-
views: [{ id: 'shell:terminal', label: 'Shell' }],
|
|
6
|
+
views: [{ id: 'shell:terminal', label: 'Shell', standalone: true }],
|
|
7
7
|
// serverBundle intentionally omitted — this shard is a framework built-in
|
|
8
8
|
// and is statically mounted at sh3-server boot. The existing contract in
|
|
9
9
|
// sh3-core/src/shards/types.ts documents that framework-shipped shards do
|
|
@@ -19,7 +19,9 @@ import { registerV1Verbs } from './verbs';
|
|
|
19
19
|
import { listRegisteredApps, getActiveApp } from '../apps/registry.svelte';
|
|
20
20
|
import { launchApp } from '../apps/lifecycle';
|
|
21
21
|
import { registeredShards } from '../shards/activate.svelte';
|
|
22
|
-
import { inspectActiveLayout, focusView, closeTab } from '../layout/inspection';
|
|
22
|
+
import { inspectActiveLayout, focusView, closeTab, popoutView, dockFloat, dockIntoActiveLayout } from '../layout/inspection';
|
|
23
|
+
import { floatManager } from '../overlays/float';
|
|
24
|
+
import { listStandaloneViews } from '../shards/activate.svelte';
|
|
23
25
|
import { getUser, isAdmin } from '../auth/index';
|
|
24
26
|
/** Walk a layout tree and collect all tab entries (slotId + viewId + label). */
|
|
25
27
|
function collectTabEntries(node) {
|
|
@@ -76,16 +78,62 @@ function makeShellApi(_ctx) {
|
|
|
76
78
|
return [];
|
|
77
79
|
}
|
|
78
80
|
},
|
|
79
|
-
// → layout/inspection: focusView(viewId)
|
|
81
|
+
// → layout/inspection: focusView(viewId). Falls back to dockIntoActiveLayout
|
|
82
|
+
// for standalone views that aren't mounted yet — this is the single
|
|
83
|
+
// "summon" entry point wired behind the `open` verb.
|
|
80
84
|
openViewInCurrentLayout(viewId) {
|
|
81
85
|
try {
|
|
82
|
-
|
|
83
|
-
|
|
86
|
+
if (focusView(viewId))
|
|
87
|
+
return { ok: true };
|
|
88
|
+
const standalone = listStandaloneViews().find((v) => v.viewId === viewId);
|
|
89
|
+
if (standalone) {
|
|
90
|
+
const slotId = `standalone:${viewId}:${Date.now()}`;
|
|
91
|
+
const ok = dockIntoActiveLayout({ slotId, viewId, label: standalone.label });
|
|
92
|
+
return ok ? { ok: true } : { ok: false, error: `could not dock "${viewId}" — no available slot` };
|
|
93
|
+
}
|
|
94
|
+
return { ok: false, error: `view "${viewId}" not found in current layout` };
|
|
84
95
|
}
|
|
85
96
|
catch (err) {
|
|
86
97
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
87
98
|
}
|
|
88
99
|
},
|
|
100
|
+
// → shards/activate.svelte: listStandaloneViews() walks activeShards
|
|
101
|
+
listStandaloneViews() {
|
|
102
|
+
return listStandaloneViews();
|
|
103
|
+
},
|
|
104
|
+
// → layout/inspection: popoutView(slotId) returns floatId | null
|
|
105
|
+
popoutSlot(slotId) {
|
|
106
|
+
try {
|
|
107
|
+
const floatId = popoutView(slotId);
|
|
108
|
+
return floatId ? { ok: true, floatId } : { ok: false, error: `slot "${slotId}" not found in docked tree` };
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
// → layout/inspection: dockFloat(floatId) returns boolean
|
|
115
|
+
dockFloat(floatId) {
|
|
116
|
+
try {
|
|
117
|
+
const ok = dockFloat(floatId);
|
|
118
|
+
return ok ? { ok: true } : { ok: false, error: `float "${floatId}" not found or has no dockable content` };
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
// → overlays/float: floatManager.list() returns FloatEntry[]
|
|
125
|
+
listFloats() {
|
|
126
|
+
return floatManager.list().map((f) => {
|
|
127
|
+
var _a, _b, _c, _d, _e;
|
|
128
|
+
const tabs = f.content.type === 'tabs' ? f.content : null;
|
|
129
|
+
const active = tabs ? (_b = tabs.tabs[(_a = tabs.activeTab) !== null && _a !== void 0 ? _a : 0]) !== null && _b !== void 0 ? _b : tabs.tabs[0] : null;
|
|
130
|
+
return {
|
|
131
|
+
floatId: f.id,
|
|
132
|
+
viewId: (_c = active === null || active === void 0 ? void 0 : active.viewId) !== null && _c !== void 0 ? _c : null,
|
|
133
|
+
label: (_e = (_d = f.title) !== null && _d !== void 0 ? _d : active === null || active === void 0 ? void 0 : active.label) !== null && _e !== void 0 ? _e : f.id,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
},
|
|
89
137
|
// → layout/inspection: closeTab(slotId) is async (guarded close).
|
|
90
138
|
// Fire-and-forget; the tab disappears asynchronously. ShellApi stays sync.
|
|
91
139
|
closeSlot(slotId) {
|
|
@@ -4,28 +4,21 @@
|
|
|
4
4
|
interface Props {
|
|
5
5
|
registry: ToolbarSlotRegistry;
|
|
6
6
|
ctx: ShellSlotCtx;
|
|
7
|
-
expanded: boolean;
|
|
8
|
-
onToggle: () => void;
|
|
9
7
|
slotProps?: Record<string, Record<string, unknown>>;
|
|
10
8
|
}
|
|
11
9
|
|
|
12
|
-
let { registry, ctx,
|
|
10
|
+
let { registry, ctx, slotProps = {} }: Props = $props();
|
|
13
11
|
|
|
14
12
|
let slots = $derived(registry.list(ctx));
|
|
15
13
|
</script>
|
|
16
14
|
|
|
17
|
-
<div class="toolbar"
|
|
18
|
-
<
|
|
19
|
-
{
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
{@const Slot = s.component}
|
|
25
|
-
<Slot {...(slotProps[s.id] ?? {})} />
|
|
26
|
-
{/each}
|
|
27
|
-
</div>
|
|
28
|
-
{/if}
|
|
15
|
+
<div class="toolbar">
|
|
16
|
+
<div class="toolbar-slots">
|
|
17
|
+
{#each slots as s (s.id)}
|
|
18
|
+
{@const Slot = s.component}
|
|
19
|
+
<Slot {...(slotProps[s.id] ?? {})} />
|
|
20
|
+
{/each}
|
|
21
|
+
</div>
|
|
29
22
|
</div>
|
|
30
23
|
|
|
31
24
|
<style>
|
|
@@ -33,30 +26,16 @@
|
|
|
33
26
|
display: flex;
|
|
34
27
|
align-items: center;
|
|
35
28
|
gap: 6px;
|
|
36
|
-
padding:
|
|
37
|
-
background: var(--shell-
|
|
29
|
+
padding: 4px 6px;
|
|
30
|
+
background: var(--shell-bg-elevated, var(--shell-bg, #1a1a1a));
|
|
38
31
|
border-bottom: 1px solid var(--shell-border, #333);
|
|
39
32
|
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
33
|
}
|
|
56
34
|
|
|
57
35
|
.toolbar-slots {
|
|
58
36
|
display: flex;
|
|
59
37
|
align-items: center;
|
|
60
38
|
gap: 8px;
|
|
39
|
+
flex-wrap: wrap;
|
|
61
40
|
}
|
|
62
41
|
</style>
|
|
@@ -2,8 +2,6 @@ import type { ToolbarSlotRegistry, ShellSlotCtx } from './slots';
|
|
|
2
2
|
interface Props {
|
|
3
3
|
registry: ToolbarSlotRegistry;
|
|
4
4
|
ctx: ShellSlotCtx;
|
|
5
|
-
expanded: boolean;
|
|
6
|
-
onToggle: () => void;
|
|
7
5
|
slotProps?: Record<string, Record<string, unknown>>;
|
|
8
6
|
}
|
|
9
7
|
declare const Toolbar: import("svelte").Component<Props, {}, "">;
|
|
@@ -11,92 +11,59 @@
|
|
|
11
11
|
|
|
12
12
|
let { mode, role, registry, onSelect }: Props = $props();
|
|
13
13
|
|
|
14
|
-
let
|
|
15
|
-
|
|
16
|
-
function select(id: string) {
|
|
17
|
-
open = false;
|
|
18
|
-
onSelect(id);
|
|
19
|
-
}
|
|
14
|
+
let modes = $derived(registry.list(role));
|
|
20
15
|
</script>
|
|
21
16
|
|
|
22
17
|
{#if role === 'admin'}
|
|
23
|
-
<div class="mode-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
{
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
>
|
|
36
|
-
{m.label}
|
|
37
|
-
</button>
|
|
38
|
-
</li>
|
|
39
|
-
{/each}
|
|
40
|
-
</ul>
|
|
41
|
-
{/if}
|
|
18
|
+
<div class="mode-bar" role="toolbar" aria-label="Shell mode">
|
|
19
|
+
{#each modes as m (m.id)}
|
|
20
|
+
<button
|
|
21
|
+
type="button"
|
|
22
|
+
class="mode-btn"
|
|
23
|
+
class:active={m.id === mode.id}
|
|
24
|
+
aria-pressed={m.id === mode.id}
|
|
25
|
+
onclick={() => onSelect(m.id)}
|
|
26
|
+
>
|
|
27
|
+
{m.label}
|
|
28
|
+
</button>
|
|
29
|
+
{/each}
|
|
42
30
|
</div>
|
|
43
31
|
{:else}
|
|
44
32
|
<span class="mode-label">{mode.label}</span>
|
|
45
33
|
{/if}
|
|
46
34
|
|
|
47
35
|
<style>
|
|
48
|
-
.mode-
|
|
49
|
-
|
|
50
|
-
|
|
36
|
+
.mode-bar {
|
|
37
|
+
display: inline-flex;
|
|
38
|
+
gap: 2px;
|
|
39
|
+
padding: 1px;
|
|
40
|
+
border: 1px solid var(--shell-border, #444);
|
|
41
|
+
border-radius: 3px;
|
|
51
42
|
}
|
|
52
43
|
|
|
53
44
|
.mode-btn {
|
|
54
45
|
background: none;
|
|
55
|
-
border:
|
|
56
|
-
color: var(--shell-fg, #
|
|
57
|
-
padding: 2px
|
|
58
|
-
border-radius:
|
|
46
|
+
border: none;
|
|
47
|
+
color: var(--shell-fg-dim, var(--shell-fg-muted, #888));
|
|
48
|
+
padding: 2px 8px;
|
|
49
|
+
border-radius: 2px;
|
|
59
50
|
cursor: pointer;
|
|
60
51
|
font-size: 0.85em;
|
|
52
|
+
line-height: 1.4;
|
|
61
53
|
}
|
|
62
54
|
|
|
63
55
|
.mode-btn:hover {
|
|
64
|
-
background: var(--shell-hover, #
|
|
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;
|
|
56
|
+
background: var(--shell-hover, color-mix(in srgb, var(--shell-fg, #ddd) 10%, transparent));
|
|
86
57
|
color: var(--shell-fg, #ddd);
|
|
87
|
-
padding: 4px 10px;
|
|
88
|
-
text-align: left;
|
|
89
|
-
cursor: pointer;
|
|
90
|
-
font-size: 0.85em;
|
|
91
58
|
}
|
|
92
59
|
|
|
93
|
-
.mode-
|
|
94
|
-
|
|
95
|
-
|
|
60
|
+
.mode-btn.active {
|
|
61
|
+
background: var(--shell-accent, #7c7cf0);
|
|
62
|
+
color: var(--shell-bg, #1a1a2e);
|
|
96
63
|
}
|
|
97
64
|
|
|
98
65
|
.mode-label {
|
|
99
66
|
font-size: 0.85em;
|
|
100
|
-
color: var(--shell-fg-dim, #888);
|
|
67
|
+
color: var(--shell-fg-dim, var(--shell-fg-muted, #888));
|
|
101
68
|
}
|
|
102
69
|
</style>
|
|
@@ -7,7 +7,7 @@ import { clearVerb } from './clear';
|
|
|
7
7
|
import { historyVerb } from './history';
|
|
8
8
|
import { appsVerb, appVerb } from './apps';
|
|
9
9
|
import { shardsVerb } from './shards';
|
|
10
|
-
import { viewsVerb, openVerb, closeVerb } from './views';
|
|
10
|
+
import { viewsVerb, openVerb, closeVerb, popoutVerb, dockVerb } from './views';
|
|
11
11
|
import { zonesVerb, zoneVerb } from './zones';
|
|
12
12
|
import { pwdVerb, cdVerb, whoamiVerb } from './session';
|
|
13
13
|
import { envVerb } from './env';
|
|
@@ -23,6 +23,8 @@ export function registerV1Verbs(ctx) {
|
|
|
23
23
|
ctx.registerVerb(viewsVerb);
|
|
24
24
|
ctx.registerVerb(openVerb);
|
|
25
25
|
ctx.registerVerb(closeVerb);
|
|
26
|
+
ctx.registerVerb(popoutVerb);
|
|
27
|
+
ctx.registerVerb(dockVerb);
|
|
26
28
|
ctx.registerVerb(zonesVerb);
|
|
27
29
|
ctx.registerVerb(zoneVerb);
|
|
28
30
|
ctx.registerVerb(pwdVerb);
|