sh3-core 0.21.2 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/__test__/reset.js +2 -0
  2. package/dist/apps/lifecycle.js +49 -2
  3. package/dist/apps/lifecycle.test.js +234 -0
  4. package/dist/artifact.d.ts +2 -0
  5. package/dist/build.js +1 -1
  6. package/dist/documents/handle.d.ts +1 -1
  7. package/dist/documents/handle.js +37 -24
  8. package/dist/documents/handle.test.js +63 -0
  9. package/dist/documents/types.d.ts +6 -0
  10. package/dist/layout/LayoutRenderer.svelte +1 -1
  11. package/dist/layout/SlotContainer.svelte +1 -0
  12. package/dist/layout/inspection.js +19 -14
  13. package/dist/layout/inspection.svelte.test.js +136 -1
  14. package/dist/layout/slotHostPool.svelte.d.ts +2 -1
  15. package/dist/layout/slotHostPool.svelte.js +6 -3
  16. package/dist/layout/slotHostPool.test.js +17 -0
  17. package/dist/layout/store.projectScope.test.d.ts +1 -0
  18. package/dist/layout/store.projectScope.test.js +76 -0
  19. package/dist/layout/store.svelte.d.ts +6 -0
  20. package/dist/layout/store.svelte.js +43 -13
  21. package/dist/layout/tree-walk.d.ts +8 -1
  22. package/dist/layout/tree-walk.js +11 -1
  23. package/dist/layout/tree-walk.test.js +53 -1
  24. package/dist/layout/types.d.ts +27 -0
  25. package/dist/layout/types.test.js +28 -0
  26. package/dist/overlays/FloatFrame.svelte +4 -1
  27. package/dist/overlays/float.d.ts +7 -1
  28. package/dist/overlays/float.js +4 -0
  29. package/dist/projects-shard/ProjectsSection.svelte +1 -5
  30. package/dist/shards/activate-runtime.test.js +45 -0
  31. package/dist/shards/activate.svelte.js +5 -1
  32. package/dist/shards/app-binding.svelte.d.ts +8 -0
  33. package/dist/shards/app-binding.svelte.js +30 -0
  34. package/dist/shards/app-binding.test.d.ts +1 -0
  35. package/dist/shards/app-binding.test.js +25 -0
  36. package/dist/shards/types.d.ts +77 -10
  37. package/dist/shell-shard/shellShard.svelte.js +10 -1
  38. package/dist/version.d.ts +1 -1
  39. package/dist/version.js +1 -1
  40. package/package.json +1 -1
@@ -24,3 +24,31 @@ describe('LAYOUT_SCHEMA_VERSION', () => {
24
24
  expect(LAYOUT_SCHEMA_VERSION).toBe(7);
25
25
  });
26
26
  });
27
+ describe('SlotNode props', () => {
28
+ it('accepts optional props', () => {
29
+ var _a;
30
+ const node = {
31
+ type: 'slot',
32
+ slotId: 'slot-1',
33
+ viewId: 'my:view',
34
+ props: { filePath: '/foo.guml', count: 3, flag: true, nul: null },
35
+ };
36
+ expect((_a = node.props) === null || _a === void 0 ? void 0 : _a.filePath).toBe('/foo.guml');
37
+ });
38
+ it('omitting props is valid', () => {
39
+ const node = { type: 'slot', slotId: 's', viewId: null };
40
+ expect(node.props).toBeUndefined();
41
+ });
42
+ });
43
+ describe('TabEntry props', () => {
44
+ it('accepts optional props', () => {
45
+ var _a;
46
+ const entry = {
47
+ slotId: 'slot-1',
48
+ viewId: 'my:view',
49
+ label: 'My View',
50
+ props: { x: 1 },
51
+ };
52
+ expect((_a = entry.props) === null || _a === void 0 ? void 0 : _a.x).toBe(1);
53
+ });
54
+ });
@@ -35,6 +35,7 @@
35
35
  import { makeSelectionApi } from '../actions/selection.svelte';
36
36
  import { spawnSatellite } from '../sh3Api/window';
37
37
  import { walkShardsForContent } from '../satellite/walkShards';
38
+ import { getActiveScopeId, getPersonalScopeId } from '../documents/config';
38
39
  import { logGesture } from '../gestures';
39
40
 
40
41
  const isTauri =
@@ -256,13 +257,15 @@
256
257
  async function onPopOut(e: MouseEvent): Promise<void> {
257
258
  e.stopPropagation();
258
259
  try {
260
+ const scopeId = getActiveScopeId();
261
+ const personalId = getPersonalScopeId();
259
262
  await spawnSatellite({
260
263
  kind: 'float',
261
264
  content: entry.content,
262
265
  title: entry.title,
263
266
  size: { w: entry.size.w, h: entry.size.h },
264
267
  activateShards: walkShardsForContent(entry.content),
265
- projectId: sh3.getActiveScope().isProject ? sh3.getActiveScope().id : undefined,
268
+ projectId: scopeId !== personalId ? scopeId : undefined,
266
269
  });
267
270
  floatManager.close(entry.id);
268
271
  } catch (err) {
@@ -1,4 +1,4 @@
1
- import type { LayoutNode, FloatEntry } from '../layout/types';
1
+ import type { LayoutNode, FloatEntry, JsonValue } from '../layout/types';
2
2
  import type { Size } from '../layout/floats';
3
3
  export interface FloatOptions {
4
4
  title?: string;
@@ -9,6 +9,12 @@ export interface FloatOptions {
9
9
  size?: Size;
10
10
  /** Instance data threaded to the view factory via `MountContext.meta`. */
11
11
  meta?: Record<string, unknown>;
12
+ /**
13
+ * Serializable view props threaded to the factory via `MountContext.props`
14
+ * and persisted with the float entry. Survives `popoutView` → `dockFloat`
15
+ * round-trip. JSON-safe values only.
16
+ */
17
+ props?: Record<string, JsonValue>;
12
18
  /**
13
19
  * When true, the float dismisses on any pointerdown outside its frame,
14
20
  * is not dockable, and renders without a header when `title` is unset.
@@ -150,6 +150,8 @@ function openFloat(viewId, options = {}) {
150
150
  };
151
151
  if (options.meta)
152
152
  slot.meta = options.meta;
153
+ if (options.props)
154
+ slot.props = options.props;
153
155
  content = slot;
154
156
  }
155
157
  else {
@@ -157,6 +159,8 @@ function openFloat(viewId, options = {}) {
157
159
  const tab = { slotId, viewId, label };
158
160
  if (options.meta)
159
161
  tab.meta = options.meta;
162
+ if (options.props)
163
+ tab.props = options.props;
160
164
  content = {
161
165
  type: 'tabs',
162
166
  tabs: [tab],
@@ -90,11 +90,7 @@
90
90
  border-color: var(--sh3-accent);
91
91
  transform: translateY(-1px);
92
92
  }
93
- .project-card.active {
94
- border-color: var(--sh3-accent);
95
- box-shadow: 0 0 0 2px color-mix(in srgb, var(--sh3-accent) 40%, transparent);
96
- }
97
- .project-name { font-weight: 600; font-size: 13px; }
93
+ .project-name { font-weight: 600; font-size: 13px; }
98
94
  .project-meta { font-size: 11px; color: var(--sh3-fg-muted); }
99
95
  .leave-project {
100
96
  display: flex;
@@ -297,3 +297,48 @@ describe('ctx.listVerbs / ctx.runVerb (integration)', () => {
297
297
  expect(names.find((n) => n.startsWith('ignored:'))).toBeUndefined();
298
298
  });
299
299
  });
300
+ // ─── registerContributions ────────────────────────────────────────────────────
301
+ describe('registerContributions hook', () => {
302
+ beforeEach(() => {
303
+ __resetShardRegistryForTest();
304
+ __resetViewRegistryForTest();
305
+ __setDocumentBackend(new MemoryDocumentBackend());
306
+ __setActiveScope('tenant-test');
307
+ });
308
+ it('is called after activate() with the same ShardContext', async () => {
309
+ const order = [];
310
+ let activateCtx = null;
311
+ let registerCtx = null;
312
+ registerShard({
313
+ manifest: { id: 'rc-shard', label: 'RC', version: '0.0.0', views: [] },
314
+ activate(ctx) {
315
+ activateCtx = ctx;
316
+ order.push('activate');
317
+ },
318
+ registerContributions(ctx) {
319
+ registerCtx = ctx;
320
+ order.push('registerContributions');
321
+ },
322
+ });
323
+ await activateShard('rc-shard');
324
+ expect(order).toEqual(['activate', 'registerContributions']);
325
+ expect(registerCtx).toBe(activateCtx);
326
+ });
327
+ it('is not called when the hook is absent', async () => {
328
+ registerShard({
329
+ manifest: { id: 'no-rc', label: 'NoRC', version: '0.0.0', views: [] },
330
+ activate() { },
331
+ });
332
+ await expect(activateShard('no-rc')).resolves.toBeUndefined();
333
+ });
334
+ it('is not called when activate() throws', async () => {
335
+ const registerContributions = vi.fn();
336
+ registerShard({
337
+ manifest: { id: 'fail-rc', label: 'FailRC', version: '0.0.0', views: [] },
338
+ activate() { throw new Error('boom'); },
339
+ registerContributions,
340
+ });
341
+ await expect(activateShard('fail-rc')).rejects.toThrow('boom');
342
+ expect(registerContributions).not.toHaveBeenCalled();
343
+ });
344
+ });
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import { sh3 } from '../sh3Runtime.svelte';
20
20
  import { registerView, unregisterView, registerVerb as fwRegisterVerb, unregisterVerb as fwUnregisterVerb } from './registry';
21
+ import { getShardBinding } from './app-binding.svelte';
21
22
  import { makeSh3Api } from '../sh3Api/headless';
22
23
  import { createDocumentHandle, getDocumentBackend, getActiveScopeId } from '../documents';
23
24
  import { fetchEnvState, putEnvState } from '../env/client';
@@ -179,7 +180,7 @@ export async function activateShard(id, opts) {
179
180
  }
180
181
  },
181
182
  documents: (options) => {
182
- const handle = createDocumentHandle(getActiveScopeId(), id, getDocumentBackend(), options);
183
+ const handle = createDocumentHandle(getActiveScopeId, () => { var _a; return (_a = getShardBinding(id)) !== null && _a !== void 0 ? _a : id; }, getDocumentBackend(), options);
183
184
  entry.cleanupFns.push(() => handle.dispose());
184
185
  return handle;
185
186
  },
@@ -329,6 +330,9 @@ export async function activateShard(id, opts) {
329
330
  }
330
331
  // Activation succeeded — clear any prior error record for this shard.
331
332
  erroredShards.delete(id);
333
+ if (shard.registerContributions) {
334
+ shard.registerContributions(ctx);
335
+ }
332
336
  void ((_l = shard.autostart) === null || _l === void 0 ? void 0 : _l.call(shard, ctx));
333
337
  }
334
338
  /**
@@ -0,0 +1,8 @@
1
+ /** Record that `shardId` is currently part of `appId`. Overwrites any prior binding. */
2
+ export declare function bindShardToApp(shardId: string, appId: string): void;
3
+ /** Remove the binding for `shardId`. No-op if not bound. */
4
+ export declare function clearShardBinding(shardId: string): void;
5
+ /** Return the app id this shard is currently bound to, or null. */
6
+ export declare function getShardBinding(shardId: string): string | null;
7
+ /** Test-only reset. */
8
+ export declare function __resetShardBindingsForTest(): void;
@@ -0,0 +1,30 @@
1
+ /*
2
+ * Shard ↔ app binding registry.
3
+ *
4
+ * Records which app (if any) currently owns each active shard. Read by
5
+ * `ctx.documents()` so its handle's namespace can resolve to `{appId}` instead
6
+ * of `{shardId}` when the shard is required by a running app — the framework
7
+ * default for app-shared document namespaces.
8
+ *
9
+ * Set by `launchApp` for non-autostart required shards. Cleared by
10
+ * `unloadApp` (also for non-autostart required shards; autostart shards
11
+ * never get a binding in the first place since they serve multiple apps).
12
+ */
13
+ const bindings = $state(new Map());
14
+ /** Record that `shardId` is currently part of `appId`. Overwrites any prior binding. */
15
+ export function bindShardToApp(shardId, appId) {
16
+ bindings.set(shardId, appId);
17
+ }
18
+ /** Remove the binding for `shardId`. No-op if not bound. */
19
+ export function clearShardBinding(shardId) {
20
+ bindings.delete(shardId);
21
+ }
22
+ /** Return the app id this shard is currently bound to, or null. */
23
+ export function getShardBinding(shardId) {
24
+ var _a;
25
+ return (_a = bindings.get(shardId)) !== null && _a !== void 0 ? _a : null;
26
+ }
27
+ /** Test-only reset. */
28
+ export function __resetShardBindingsForTest() {
29
+ bindings.clear();
30
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { bindShardToApp, clearShardBinding, getShardBinding, __resetShardBindingsForTest, } from './app-binding.svelte';
3
+ describe('shardAppBindings', () => {
4
+ beforeEach(__resetShardBindingsForTest);
5
+ it('returns null for an unbound shard', () => {
6
+ expect(getShardBinding('shard-A')).toBeNull();
7
+ });
8
+ it('records and reads a binding', () => {
9
+ bindShardToApp('shard-A', 'app-X');
10
+ expect(getShardBinding('shard-A')).toBe('app-X');
11
+ });
12
+ it('overwrites a prior binding (e.g. shard switching apps)', () => {
13
+ bindShardToApp('shard-A', 'app-X');
14
+ bindShardToApp('shard-A', 'app-Y');
15
+ expect(getShardBinding('shard-A')).toBe('app-Y');
16
+ });
17
+ it('clears a binding back to null', () => {
18
+ bindShardToApp('shard-A', 'app-X');
19
+ clearShardBinding('shard-A');
20
+ expect(getShardBinding('shard-A')).toBeNull();
21
+ });
22
+ it('clearShardBinding on an unbound shard is a no-op', () => {
23
+ expect(() => clearShardBinding('shard-A')).not.toThrow();
24
+ });
25
+ });
@@ -9,7 +9,7 @@ import type { Sh3Api } from '../verbs/types';
9
9
  import type { ShardContextKeys } from '../keys/types';
10
10
  import type { ContributionsApi } from '../contributions/types';
11
11
  import type { ActionsApi } from '../actions/types';
12
- import type { TreeRootRef, SlotRole } from '../layout/types';
12
+ import type { TreeRootRef, SlotRole, JsonValue, RestoredSlot } from '../layout/types';
13
13
  export { PERMISSION_KEYS_MINT, type ShardContextKeys, type ApiKeyPublic, type MintOpts, ScopeEscalationError, ConsentDeniedError } from '../keys/types';
14
14
  /**
15
15
  * The object returned by `ViewFactory.mount`. The framework calls
@@ -65,6 +65,13 @@ export interface MountContext {
65
65
  * Not persisted with the layout — ephemeral per mount.
66
66
  */
67
67
  meta?: Record<string, unknown>;
68
+ /**
69
+ * Persistent view-level parameters passed by the caller when opening
70
+ * this view. Serialized with the layout tree — present on every mount
71
+ * including layout restore. Undefined when the slot was opened without
72
+ * props. Values are JSON-safe (`JsonValue`).
73
+ */
74
+ props?: Record<string, JsonValue>;
68
75
  /**
69
76
  * Push dirty-state to the tab strip. The framework renders a dirty
70
77
  * indicator (filled dot) on the tab when true, clears it when false.
@@ -84,6 +91,21 @@ export interface MountContext {
84
91
  */
85
92
  location(): TreeRootRef | null;
86
93
  }
94
+ /**
95
+ * Passed to `Shard.onAppActivate`. The `documents()` factory remains for
96
+ * back-compat (it returns a handle scoped to the app namespace, identical
97
+ * to what `ctx.documents()` now returns by default). Prefer `ctx.documents()`
98
+ * in new code — the framework auto-scopes the shard's existing handle to
99
+ * the active app's namespace via `shardAppBindings`.
100
+ *
101
+ * @deprecated Use `ctx.documents()` — auto-binds to the active app.
102
+ */
103
+ export interface AppActivateContext {
104
+ /** The id of the app that just became active. */
105
+ readonly appId: string;
106
+ /** @deprecated Use `ctx.documents()`. */
107
+ documents(options?: DocumentHandleOptions): DocumentHandle;
108
+ }
87
109
  /**
88
110
  * The shard-side adapter that knows how to bring a view to life inside a
89
111
  * given HTMLElement. The container is owned by the framework (the slot);
@@ -350,15 +372,10 @@ export interface Shard {
350
372
  */
351
373
  activate(ctx: ShardContext): void | Promise<void>;
352
374
  /**
353
- * Optional self-starting hook. A shard that defines `autostart` is
354
- * eagerly activated by the framework at boot (right after the register
355
- * pass finishes) instead of waiting for an app to require it. `activate`
356
- * runs first; `autostart` runs immediately after and may take imperative
357
- * action — docking its own views into the active layout, opening a
358
- * modal, subscribing to framework state, etc. The `__sh3core__` pseudo-
359
- * shard uses this with a no-op body so its activation path is uniform
360
- * with other self-starting shards. Diagnostic-style shards use it to
361
- * do real work.
375
+ * @deprecated Use `registerContributions(ctx)` for static registrations
376
+ * and `activate(ctx)` for imperative setup. `autostart` will be removed
377
+ * in a future ADR see ADR-026. Existing shards using `autostart` are
378
+ * not broken; this is a migration signal only.
362
379
  */
363
380
  autostart?(ctx: ShardContext): void | Promise<void>;
364
381
  /** Optional cleanup hook called when the shard is deactivated. Release timers, subscriptions, and external resources here. */
@@ -376,6 +393,56 @@ export interface Shard {
376
393
  resume?(ctx: ShardContext): void | Promise<void>;
377
394
  /** Fires when a key minted by this shard is revoked from any source. */
378
395
  onKeyRevoked?(id: string): void | Promise<void>;
396
+ /**
397
+ * Register views, commands, hotkey bindings, verbs, toolbar items, and
398
+ * menu entries — anything that doesn't depend on session state. Called
399
+ * once after `activate()`. Separating static registrations from ceremony
400
+ * allows the framework to re-call this on hot-reload without re-running
401
+ * the full activation lifecycle. All contributions registered here are
402
+ * auto-unregistered when the shard deactivates.
403
+ *
404
+ * Shards that currently register everything inside `activate()` need not
405
+ * move — `activate()` still works. Use `registerContributions()` for new
406
+ * shards or when you want hot-reload-safe contribution registration.
407
+ */
408
+ registerContributions?(ctx: ShardContext): void;
409
+ /**
410
+ * Called after all required shards have activated and the app's document
411
+ * namespace binding is established. Use this hook for app-context-aware
412
+ * setup (e.g. preloading docs, registering app-specific contributions).
413
+ * The shard's existing `ctx.documents()` handle is already auto-bound to
414
+ * the app namespace — there is no need to re-mint or swap handles here.
415
+ *
416
+ * The `appCtx.documents()` factory is retained for back-compat but is
417
+ * deprecated; new code should use `ctx.documents()`.
418
+ */
419
+ onAppActivate?(appId: string, appCtx: AppActivateContext): void | Promise<void>;
420
+ /**
421
+ * Called when the app this shard was part of is unloaded — a different app
422
+ * is launched (the current one is force-unloaded), or `unregisterApp` is
423
+ * called. NOT fired by `returnToHome` — that path keeps the app alive and
424
+ * uses the `suspend`/`resume` hooks instead. The shard remains active; its
425
+ * background services and standalone document handles are still valid.
426
+ * Use this to release app-scoped document handles and revert to standalone
427
+ * state.
428
+ */
429
+ onAppDeactivate?(appId: string): void | Promise<void>;
430
+ /**
431
+ * Called BEFORE layout slots begin mounting, after all required shards have
432
+ * activated. `slots` contains every slot in the restored layout tree that
433
+ * has a non-null viewId, including their persisted `props`. Register any
434
+ * slot-specific contributions here (e.g. `EDITOR_DOCUMENT_POINT` keyed by
435
+ * `slotId`) so they are in place when the view factory's `mount()` is called.
436
+ */
437
+ onLayoutWillRestore?(slots: RestoredSlot[]): void | Promise<void>;
438
+ /**
439
+ * Called AFTER the layout has been switched to the app's tree and slots
440
+ * have begun mounting. Use for post-mount wiring and reconciliation.
441
+ * Note: view factories may still be mounting asynchronously (microtask);
442
+ * this hook fires after the layout render, not after all `mount()` calls
443
+ * have returned.
444
+ */
445
+ onLayoutRestored?(slots: RestoredSlot[]): void;
379
446
  }
380
447
  /**
381
448
  * Source-level shape of a shard as written by external package authors.
@@ -25,6 +25,7 @@ import { focusView } from '../layout/inspection';
25
25
  import { floatManager } from '../overlays/float';
26
26
  import { getUser, isAdmin } from '../auth/index';
27
27
  import { __bindZone, __unbindZone } from './buffer-zone-state.svelte';
28
+ import { getAuthToken } from '../transport/authToken';
28
29
  export { makeSh3ApiHeadless, makeSh3ApiForTest } from '../sh3Api/headless';
29
30
  export const shellShard = {
30
31
  manifest,
@@ -58,7 +59,15 @@ export const shellShard = {
58
59
  var _a;
59
60
  const proto = typeof location !== 'undefined' && location.protocol === 'https:' ? 'wss' : 'ws';
60
61
  const host = typeof location !== 'undefined' ? location.host : 'localhost';
61
- const wsUrl = `${proto}://${host}/api/shell/session`;
62
+ let wsUrl = `${proto}://${host}/api/shell/session`;
63
+ // Tauri WebSocket can't set Authorization headers; use the query-param
64
+ // token fallback already supported by extractSessionToken() in auth.ts.
65
+ const isTauri = typeof window !== 'undefined' && ('__TAURI_INTERNALS__' in window || '__TAURI__' in window);
66
+ if (isTauri) {
67
+ const tok = getAuthToken();
68
+ if (tok)
69
+ wsUrl += `?token=${encodeURIComponent(tok)}`;
70
+ }
62
71
  const user = getUser();
63
72
  const userId = (_a = user === null || user === void 0 ? void 0 : user.id) !== null && _a !== void 0 ? _a : 'guest';
64
73
  const role = isAdmin() ? 'admin' : 'user';
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export declare const VERSION = "0.21.2";
2
+ export declare const VERSION = "0.22.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  /** Auto-generated from package.json — do not edit manually. */
2
- export const VERSION = '0.21.2';
2
+ export const VERSION = '0.22.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"