sh3-core 0.15.1 → 0.15.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.
Files changed (128) hide show
  1. package/dist/actions/ctx-actions.svelte.test.js +111 -0
  2. package/dist/actions/dispatcher.svelte.js +23 -2
  3. package/dist/actions/dispatcher.test.js +33 -0
  4. package/dist/actions/listActionsFromEntries.test.js +78 -0
  5. package/dist/actions/listActive.d.ts +2 -1
  6. package/dist/actions/listActive.js +43 -17
  7. package/dist/actions/listeners.d.ts +16 -0
  8. package/dist/actions/listeners.js +68 -14
  9. package/dist/actions/programmatic-dispatch.svelte.test.d.ts +1 -0
  10. package/dist/actions/programmatic-dispatch.svelte.test.js +98 -0
  11. package/dist/actions/types.d.ts +37 -0
  12. package/dist/api.d.ts +1 -1
  13. package/dist/app-appearance/appearanceShard.svelte.js +19 -6
  14. package/dist/app-appearance/appearanceState.svelte.js +3 -3
  15. package/dist/host.js +2 -1
  16. package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
  17. package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
  18. package/dist/layouts-shard/LayoutsSection.svelte +142 -0
  19. package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
  20. package/dist/layouts-shard/filter.d.ts +3 -0
  21. package/dist/layouts-shard/filter.js +66 -0
  22. package/dist/layouts-shard/filter.test.d.ts +1 -0
  23. package/dist/layouts-shard/filter.test.js +123 -0
  24. package/dist/layouts-shard/index.d.ts +1 -0
  25. package/dist/layouts-shard/index.js +1 -0
  26. package/dist/layouts-shard/layoutsApi.d.ts +12 -0
  27. package/dist/layouts-shard/layoutsApi.js +41 -0
  28. package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
  29. package/dist/layouts-shard/layoutsApi.test.js +74 -0
  30. package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
  31. package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
  32. package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
  33. package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
  34. package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
  35. package/dist/layouts-shard/layoutsState.svelte.js +50 -0
  36. package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
  37. package/dist/layouts-shard/layoutsState.test.js +43 -0
  38. package/dist/layouts-shard/types.d.ts +21 -0
  39. package/dist/layouts-shard/types.js +6 -0
  40. package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
  41. package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
  42. package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
  43. package/dist/overlays/EntityAppearanceModal.test.js +57 -0
  44. package/dist/overlays/FloatFrame.svelte +17 -0
  45. package/dist/overlays/float.d.ts +17 -1
  46. package/dist/overlays/float.js +16 -0
  47. package/dist/overlays/float.test.js +35 -0
  48. package/dist/sh3core-shard/ShellHome.svelte +3 -0
  49. package/dist/shards/activate.svelte.js +11 -2
  50. package/dist/shards/types.d.ts +33 -1
  51. package/dist/shell-shard/CommandLine.svelte +143 -0
  52. package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
  53. package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
  54. package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
  55. package/dist/shell-shard/InputLine.svelte +17 -40
  56. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  57. package/dist/shell-shard/ScrollbackView.svelte +10 -3
  58. package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
  59. package/dist/shell-shard/Terminal.svelte +93 -22
  60. package/dist/shell-shard/buffer-store.d.ts +15 -0
  61. package/dist/shell-shard/buffer-store.js +124 -0
  62. package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
  63. package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
  64. package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
  65. package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
  66. package/dist/shell-shard/contract.d.ts +7 -0
  67. package/dist/shell-shard/dispatch-custom.test.js +3 -1
  68. package/dist/shell-shard/dispatch-gating.test.js +6 -2
  69. package/dist/shell-shard/dispatch-invoke.test.js +10 -8
  70. package/dist/shell-shard/dispatch.d.ts +7 -2
  71. package/dist/shell-shard/dispatch.js +23 -27
  72. package/dist/shell-shard/display-cwd.d.ts +1 -0
  73. package/dist/shell-shard/display-cwd.js +27 -0
  74. package/dist/shell-shard/display-cwd.test.d.ts +1 -0
  75. package/dist/shell-shard/display-cwd.test.js +29 -0
  76. package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
  77. package/dist/shell-shard/manifest.js +2 -1
  78. package/dist/shell-shard/manifest.test.d.ts +1 -0
  79. package/dist/shell-shard/manifest.test.js +8 -0
  80. package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
  81. package/dist/shell-shard/mode-buffer.svelte.js +19 -0
  82. package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
  83. package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
  84. package/dist/shell-shard/modes/builtin.js +2 -0
  85. package/dist/shell-shard/modes/types.d.ts +8 -0
  86. package/dist/shell-shard/protocol.d.ts +12 -6
  87. package/dist/shell-shard/replay.d.ts +3 -0
  88. package/dist/shell-shard/replay.js +44 -0
  89. package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
  90. package/dist/shell-shard/replay.svelte.test.js +47 -0
  91. package/dist/shell-shard/rich-registry.d.ts +5 -0
  92. package/dist/shell-shard/rich-registry.js +25 -0
  93. package/dist/shell-shard/rich-registry.test.d.ts +1 -0
  94. package/dist/shell-shard/rich-registry.test.js +31 -0
  95. package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
  96. package/dist/shell-shard/scrollback.svelte.js +23 -0
  97. package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
  98. package/dist/shell-shard/scrollback.svelte.test.js +51 -0
  99. package/dist/shell-shard/session-client.svelte.d.ts +18 -2
  100. package/dist/shell-shard/session-client.svelte.js +21 -4
  101. package/dist/shell-shard/shellApi.d.ts +2 -1
  102. package/dist/shell-shard/shellApi.js +31 -3
  103. package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
  104. package/dist/shell-shard/shellApi.svelte.test.js +59 -0
  105. package/dist/shell-shard/shellShard.svelte.js +11 -1
  106. package/dist/shell-shard/terminal-dispatch.test.js +3 -1
  107. package/dist/shell-shard/verbs/apps.js +7 -0
  108. package/dist/shell-shard/verbs/env.js +4 -0
  109. package/dist/shell-shard/verbs/help.js +4 -0
  110. package/dist/shell-shard/verbs/history.js +8 -1
  111. package/dist/shell-shard/verbs/index.js +0 -8
  112. package/dist/shell-shard/verbs/shards.js +4 -0
  113. package/dist/shell-shard/verbs/views.js +4 -0
  114. package/dist/shell-shard/verbs/zones.js +7 -0
  115. package/dist/version.d.ts +1 -1
  116. package/dist/version.js +1 -1
  117. package/package.json +1 -1
  118. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
  119. package/dist/shell-shard/verbs/cat.d.ts +0 -2
  120. package/dist/shell-shard/verbs/cat.js +0 -35
  121. package/dist/shell-shard/verbs/cd.test.js +0 -56
  122. package/dist/shell-shard/verbs/ls.d.ts +0 -2
  123. package/dist/shell-shard/verbs/ls.js +0 -30
  124. package/dist/shell-shard/verbs/ls.test.js +0 -49
  125. package/dist/shell-shard/verbs/session.d.ts +0 -4
  126. package/dist/shell-shard/verbs/session.js +0 -99
  127. /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
  128. /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
@@ -0,0 +1,111 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { MemoryDocumentBackend } from '../documents/backends';
3
+ import { __setDocumentBackend, __setTenantId } from '../documents/config';
4
+ import { registerShard, activateShard, __resetShardRegistryForTest, } from '../shards/activate.svelte';
5
+ import { __resetViewRegistryForTest } from '../shards/registry';
6
+ import { __resetActionsRegistryForTest } from './registry';
7
+ import { __resetContributionsForTest } from '../contributions/registry';
8
+ import { __resetDispatcherStateForTest } from './state.svelte';
9
+ describe('ShardContext.listActions / runAction (integration)', () => {
10
+ beforeEach(() => {
11
+ __resetShardRegistryForTest();
12
+ __resetViewRegistryForTest();
13
+ __resetContributionsForTest();
14
+ __resetActionsRegistryForTest();
15
+ __resetDispatcherStateForTest();
16
+ __setDocumentBackend(new MemoryDocumentBackend());
17
+ __setTenantId('tenant-test');
18
+ });
19
+ it('listActions enumerates actions registered by other shards', async () => {
20
+ registerShard({
21
+ manifest: { id: 'producer', label: 'P', version: '0.0.0', views: [] },
22
+ activate(ctx) {
23
+ ctx.actions.register({
24
+ id: 'producer.do',
25
+ label: 'Do',
26
+ scope: 'home',
27
+ run: () => { },
28
+ });
29
+ },
30
+ });
31
+ let consumerCtx = null;
32
+ registerShard({
33
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
34
+ activate(ctx) { consumerCtx = ctx; },
35
+ });
36
+ await activateShard('producer');
37
+ await activateShard('consumer');
38
+ const list = consumerCtx.listActions();
39
+ const ids = list.map((d) => d.id);
40
+ expect(ids).toContain('producer.do');
41
+ const desc = list.find((d) => d.id === 'producer.do');
42
+ expect(desc.active).toBe(true);
43
+ expect(desc.ownerShardId).toBe('producer');
44
+ });
45
+ it('listActions({ activeOnly: true }) filters out inactive actions', async () => {
46
+ registerShard({
47
+ manifest: { id: 'producer', label: 'P', version: '0.0.0', views: [] },
48
+ activate(ctx) {
49
+ ctx.actions.register({
50
+ id: 'home.go', label: 'H', scope: 'home', run: () => { },
51
+ });
52
+ ctx.actions.register({
53
+ // 'app' tier inactive without an active app
54
+ id: 'app.go', label: 'A', scope: 'app', run: () => { },
55
+ });
56
+ },
57
+ });
58
+ let consumerCtx = null;
59
+ registerShard({
60
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
61
+ activate(ctx) { consumerCtx = ctx; },
62
+ });
63
+ await activateShard('producer');
64
+ await activateShard('consumer');
65
+ const snapshot = consumerCtx.listActions({ activeOnly: true });
66
+ const ids = snapshot.map((d) => d.id);
67
+ expect(ids).toContain('home.go');
68
+ expect(ids).not.toContain('app.go');
69
+ });
70
+ it('runAction dispatches through the programmatic path', async () => {
71
+ let invokedVia = null;
72
+ registerShard({
73
+ manifest: { id: 'producer', label: 'P', version: '0.0.0', views: [] },
74
+ activate(ctx) {
75
+ ctx.actions.register({
76
+ id: 'producer.go',
77
+ label: 'Go',
78
+ scope: 'home',
79
+ run: (rctx) => { invokedVia = rctx.invokedVia; },
80
+ });
81
+ },
82
+ });
83
+ let consumerCtx = null;
84
+ registerShard({
85
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
86
+ activate(ctx) { consumerCtx = ctx; },
87
+ });
88
+ await activateShard('producer');
89
+ await activateShard('consumer');
90
+ await consumerCtx.runAction('producer.go');
91
+ expect(invokedVia).toBe('programmatic');
92
+ });
93
+ it('runAction rejects when the target action is inactive', async () => {
94
+ registerShard({
95
+ manifest: { id: 'producer', label: 'P', version: '0.0.0', views: [] },
96
+ activate(ctx) {
97
+ ctx.actions.register({
98
+ id: 'gated.go', label: 'G', scope: 'app', run: () => { },
99
+ });
100
+ },
101
+ });
102
+ let consumerCtx = null;
103
+ registerShard({
104
+ manifest: { id: 'consumer', label: 'C', version: '0.0.0', views: [] },
105
+ activate(ctx) { consumerCtx = ctx; },
106
+ });
107
+ await activateShard('producer');
108
+ await activateShard('consumer');
109
+ await expect(consumerCtx.runAction('gated.go')).rejects.toThrow(/not active/);
110
+ });
111
+ });
@@ -76,6 +76,22 @@ function isTextInput(target) {
76
76
  return true;
77
77
  return false;
78
78
  }
79
+ /**
80
+ * An input opted into letting Ctrl/Alt/Meta-bearing shortcuts pass through
81
+ * to the global dispatcher. Plain typing (letters, Shift+letters, Tab,
82
+ * Escape, Enter, etc.) still goes to the input. Marked via
83
+ * `data-sh3-passthrough-modifiers` on the input or any ancestor; used by
84
+ * command-style inputs (e.g. the shell terminal) so the user can hit
85
+ * Ctrl+K mid-line and still get the palette.
86
+ */
87
+ function isModifierPassthroughTarget(target) {
88
+ if (!(target instanceof Element))
89
+ return false;
90
+ return target.closest('[data-sh3-passthrough-modifiers]') !== null;
91
+ }
92
+ function shortcutHasRealModifier(shortcut) {
93
+ return /(?:^|\+)(?:Ctrl|Alt|Meta)\+/.test(shortcut);
94
+ }
79
95
  export function dispatchKeydown(env) {
80
96
  var _a, _b, _c;
81
97
  const idx = buildTierIndex(env.entries, env.state);
@@ -87,9 +103,14 @@ export function dispatchKeydown(env) {
87
103
  const entry = actionById.get(id);
88
104
  if (!entry)
89
105
  continue;
90
- // Input-target blocking
106
+ // Input-target blocking. Inputs opted into modifier passthrough still
107
+ // let Ctrl/Alt/Meta-bearing shortcuts through so global bindings like
108
+ // Ctrl+K reach the dispatcher while the user is typing.
91
109
  if (isTextInput(env.target) && !entry.action.allowInInputs) {
92
- return null;
110
+ const passthrough = isModifierPassthroughTarget(env.target)
111
+ && shortcutHasRealModifier(env.shortcut);
112
+ if (!passthrough)
113
+ return null;
93
114
  }
94
115
  env.runAction(id, {
95
116
  action: { id: entry.action.id, label: resolveLabel(entry.action) },
@@ -152,4 +152,37 @@ describe('dispatchKeydown', () => {
152
152
  const result = dispatchKeydown(mkEnv({ entries, target: div }));
153
153
  expect(result).toBeNull();
154
154
  });
155
+ it('lets Ctrl-bearing shortcuts through when input has data-sh3-passthrough-modifiers', () => {
156
+ const wrap = document.createElement('div');
157
+ wrap.setAttribute('data-sh3-passthrough-modifiers', '');
158
+ const inp = document.createElement('input');
159
+ wrap.appendChild(inp);
160
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+K' })];
161
+ const result = dispatchKeydown(mkEnv({ entries, target: inp, shortcut: 'Ctrl+K' }));
162
+ expect(result).toBe('x');
163
+ });
164
+ it('still blocks plain (no-modifier) shortcuts on passthrough inputs', () => {
165
+ const wrap = document.createElement('div');
166
+ wrap.setAttribute('data-sh3-passthrough-modifiers', '');
167
+ const inp = document.createElement('input');
168
+ wrap.appendChild(inp);
169
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Escape' })];
170
+ const result = dispatchKeydown(mkEnv({ entries, target: inp, shortcut: 'Escape' }));
171
+ expect(result).toBeNull();
172
+ });
173
+ it('still blocks Shift-only shortcuts on passthrough inputs', () => {
174
+ const wrap = document.createElement('div');
175
+ wrap.setAttribute('data-sh3-passthrough-modifiers', '');
176
+ const inp = document.createElement('input');
177
+ wrap.appendChild(inp);
178
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Shift+K' })];
179
+ const result = dispatchKeydown(mkEnv({ entries, target: inp, shortcut: 'Shift+K' }));
180
+ expect(result).toBeNull();
181
+ });
182
+ it('Ctrl-bearing shortcut on a plain input (no passthrough) is still blocked', () => {
183
+ const inp = document.createElement('input');
184
+ const entries = [mkEntry({ id: 'x', scope: 'home', defaultShortcut: 'Ctrl+K' })];
185
+ const result = dispatchKeydown(mkEnv({ entries, target: inp, shortcut: 'Ctrl+K' }));
186
+ expect(result).toBeNull();
187
+ });
155
188
  });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { listActionsFromEntries } from './listActive';
3
+ const mkEntry = (a, owner = 'shard.x') => ({
4
+ ownerShardId: owner,
5
+ action: Object.assign({ id: 'a', label: 'A', scope: 'home', run: () => { } }, a),
6
+ });
7
+ const mkState = (o = {}) => (Object.assign({ activeAppId: null, activeAppRequiredShards: new Set(), autostartShards: new Set(), mountedViewIds: new Set(), focusedViewId: null, selection: null, bindings: {}, platform: 'other' }, o));
8
+ describe('listActionsFromEntries', () => {
9
+ it('returns [] for an empty registry', () => {
10
+ expect(listActionsFromEntries([], mkState())).toEqual([]);
11
+ });
12
+ it('marks an in-scope action as active', () => {
13
+ const entries = [mkEntry({ id: 'open', scope: 'home' })];
14
+ const out = listActionsFromEntries(entries, mkState());
15
+ expect(out).toHaveLength(1);
16
+ expect(out[0]).toMatchObject({ id: 'open', active: true, scope: 'home' });
17
+ });
18
+ it('marks an out-of-scope action as inactive', () => {
19
+ const entries = [mkEntry({ id: 'p', scope: 'app' })];
20
+ const out = listActionsFromEntries(entries, mkState()); // no active app
21
+ expect(out).toHaveLength(1);
22
+ expect(out[0].id).toBe('p');
23
+ expect(out[0].active).toBe(false);
24
+ });
25
+ it('marks a disabled action as inactive even when its scope is active', () => {
26
+ const entries = [mkEntry({ id: 'd', scope: 'home', disabled: true })];
27
+ const out = listActionsFromEntries(entries, mkState());
28
+ expect(out[0].active).toBe(false);
29
+ });
30
+ it('re-evaluates function-form disabled per call', () => {
31
+ let flag = false;
32
+ const entries = [mkEntry({
33
+ id: 'd', scope: 'home', disabled: () => flag,
34
+ })];
35
+ expect(listActionsFromEntries(entries, mkState())[0].active).toBe(true);
36
+ flag = true;
37
+ expect(listActionsFromEntries(entries, mkState())[0].active).toBe(false);
38
+ });
39
+ it('marks a submenu parent without run as inactive', () => {
40
+ const entries = [{
41
+ ownerShardId: 'shard.x',
42
+ action: {
43
+ id: 's', label: 'S', scope: 'home', submenu: true,
44
+ },
45
+ }];
46
+ const out = listActionsFromEntries(entries, mkState());
47
+ expect(out[0].active).toBe(false);
48
+ });
49
+ it('orders active descriptors by tier (innermost first), inactive trailing in insertion order', () => {
50
+ const entries = [
51
+ mkEntry({ id: 'inactive-a', scope: 'app' }, 'shard.a'), // app not active -> inactive
52
+ mkEntry({ id: 'home-1', scope: 'home' }, 'shard.a'), // active home tier
53
+ mkEntry({ id: 'inactive-b', scope: 'app' }, 'shard.b'), // inactive
54
+ mkEntry({ id: 'view-1', scope: 'view:editor' }, '__sh3core__'), // active view tier (innermost)
55
+ ];
56
+ const state = mkState({
57
+ activeAppId: null, // app tier inactive
58
+ autostartShards: new Set(['__sh3core__']),
59
+ mountedViewIds: new Set(['editor']),
60
+ });
61
+ const out = listActionsFromEntries(entries, state);
62
+ expect(out.map((d) => d.id)).toEqual([
63
+ 'view-1', // innermost active tier first
64
+ 'home-1', // then home tier
65
+ 'inactive-a', // inactive in insertion order
66
+ 'inactive-b',
67
+ ]);
68
+ });
69
+ it('dedupes by action id (first registration wins) for both active and inactive', () => {
70
+ const entries = [
71
+ mkEntry({ id: 'dup', scope: 'home' }, 'shard.a'),
72
+ mkEntry({ id: 'dup', scope: 'home' }, 'shard.b'),
73
+ ];
74
+ const out = listActionsFromEntries(entries, mkState());
75
+ expect(out).toHaveLength(1);
76
+ expect(out[0].ownerShardId).toBe('shard.a');
77
+ });
78
+ });
@@ -1,4 +1,5 @@
1
1
  import type { ActionEntry } from './registry';
2
2
  import { type DispatcherState } from './dispatcher.svelte';
3
- import { type ActiveActionDescriptor } from './types';
3
+ import { type ActionDescriptor, type ActiveActionDescriptor } from './types';
4
+ export declare function listActionsFromEntries(entries: ActionEntry[], state: DispatcherState): ActionDescriptor[];
4
5
  export declare function listActiveFromEntries(entries: ActionEntry[], state: DispatcherState): ActiveActionDescriptor[];
@@ -1,43 +1,69 @@
1
1
  /*
2
- * Pure read-side producer for `shell.actions.listActive()`.
2
+ * Pure read-side producers for action enumeration.
3
3
  *
4
- * Given a snapshot of the action registry and the dispatcher state,
5
- * return one `ActiveActionDescriptor` per currently-active action, in
6
- * tier-innermost-first order (element > focus > view > app > home),
7
- * with registration order preserved within a tier.
8
- *
9
- * The shell's live wrapper calls this with `listActions()` + `getLiveDispatcherState()`.
4
+ * `listActionsFromEntries` returns one descriptor per registered action,
5
+ * with an `active` flag indicating whether dispatching it right now would
6
+ * actually run. `listActiveFromEntries` is a filter on top of it: the active
7
+ * subset, in tier-innermost-first order, structurally compatible with the
8
+ * `ActiveActionDescriptor[]` shape consumed by `shell.actions.listActive()`.
10
9
  */
11
10
  import { TIER_ORDER, } from './dispatcher.svelte';
12
11
  import { effectiveShortcutWithSource } from './bindings';
13
12
  import { innermostActiveScope, scopeBadge, scopeToTier } from './scope-helpers';
14
- import { resolveLabel } from './types';
15
- export function listActiveFromEntries(entries, state) {
13
+ import { resolveLabel, } from './types';
14
+ function isDisabled(entry) {
15
+ const d = entry.action.disabled;
16
+ if (typeof d === 'function')
17
+ return Boolean(d());
18
+ return Boolean(d);
19
+ }
20
+ function firstScope(entry) {
21
+ const s = entry.action.scope;
22
+ return Array.isArray(s) ? s[0] : s;
23
+ }
24
+ export function listActionsFromEntries(entries, state) {
16
25
  const byTier = {
17
26
  element: [], focus: [], view: [], app: [], home: [],
18
27
  };
28
+ const inactive = [];
19
29
  const seen = new Set();
20
30
  for (const entry of entries) {
21
31
  if (seen.has(entry.action.id))
22
32
  continue;
23
- const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
24
- if (!winning)
25
- continue;
26
33
  seen.add(entry.action.id);
27
34
  const { shortcut, source } = effectiveShortcutWithSource(entry.action, state.bindings, state.platform);
28
- byTier[scopeToTier(winning)].push({
35
+ const winning = innermostActiveScope(entry.action.scope, state, entry.ownerShardId);
36
+ const hasRun = typeof entry.action.run === 'function';
37
+ const active = winning !== null &&
38
+ hasRun &&
39
+ !isDisabled(entry);
40
+ const scope = active ? winning : firstScope(entry);
41
+ const desc = {
29
42
  id: entry.action.id,
30
43
  label: resolveLabel(entry.action),
31
44
  effectiveShortcut: shortcut,
32
45
  bindingSource: source,
33
- scope: winning,
34
- scopeBadge: scopeBadge(winning),
46
+ scope,
47
+ scopeBadge: scopeBadge(scope),
35
48
  group: entry.action.group,
36
49
  icon: entry.action.icon,
37
50
  ownerShardId: entry.ownerShardId,
38
51
  paletteItem: entry.action.paletteItem !== false,
39
52
  contextItem: entry.action.contextItem !== false,
40
- });
53
+ active,
54
+ };
55
+ if (active) {
56
+ byTier[scopeToTier(winning)].push(desc);
57
+ }
58
+ else {
59
+ inactive.push(desc);
60
+ }
41
61
  }
42
- return TIER_ORDER.flatMap((tier) => byTier[tier]);
62
+ return [
63
+ ...TIER_ORDER.flatMap((tier) => byTier[tier]),
64
+ ...inactive,
65
+ ];
66
+ }
67
+ export function listActiveFromEntries(entries, state) {
68
+ return listActionsFromEntries(entries, state).filter((d) => d.active);
43
69
  }
@@ -11,6 +11,22 @@ export interface OpenPaletteOpts {
11
11
  submenuOf?: string;
12
12
  };
13
13
  }
14
+ /**
15
+ * Programmatic action dispatch entry point. Invokes a registered action by
16
+ * id with `invokedVia: 'programmatic'`, using current live state for
17
+ * appId / viewId / selection. Returns a Promise that resolves after the
18
+ * registered `run` settles (or its return value resolves), rejects on:
19
+ * - unknown id,
20
+ * - registered but inactive (out of scope, disabled, submenu without run),
21
+ * - any error thrown synchronously or asynchronously by `run`.
22
+ *
23
+ * Used by `ctx.runAction(id, opts?)` (returns the promise) and by
24
+ * `chainedDispatch` (the in-action `ctx.dispatch(id)` callback, which
25
+ * discards the promise to keep its existing fire-and-forget contract).
26
+ */
27
+ export declare function dispatchActionProgrammatic(actionId: string, _opts?: {
28
+ signal?: AbortSignal;
29
+ }): Promise<void>;
14
30
  export declare function attachGlobalListeners(): void;
15
31
  export declare function detachGlobalListeners(): void;
16
32
  export declare function openContextMenu(opts: OpenContextMenuOpts): void;
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { mount } from 'svelte';
7
7
  import { listActions } from './registry';
8
+ import { listActionsFromEntries } from './listActive';
8
9
  import { dispatchKeydown } from './dispatcher.svelte';
9
10
  import { getLiveDispatcherState, setFocusedViewId, } from './state.svelte';
10
11
  import { eventToShortcut } from './shortcuts';
@@ -71,25 +72,56 @@ function runAction(actionId, ctx) {
71
72
  }
72
73
  }
73
74
  /**
74
- * Invoke another action by id from within an action's `run`. Builds a fresh
75
- * dispatch context using current live state with `invokedVia: 'programmatic'`.
76
- * Called via `ctx.dispatch(id)` on ActionDispatchContext.
75
+ * Programmatic action dispatch entry point. Invokes a registered action by
76
+ * id with `invokedVia: 'programmatic'`, using current live state for
77
+ * appId / viewId / selection. Returns a Promise that resolves after the
78
+ * registered `run` settles (or its return value resolves), rejects on:
79
+ * - unknown id,
80
+ * - registered but inactive (out of scope, disabled, submenu without run),
81
+ * - any error thrown synchronously or asynchronously by `run`.
82
+ *
83
+ * Used by `ctx.runAction(id, opts?)` (returns the promise) and by
84
+ * `chainedDispatch` (the in-action `ctx.dispatch(id)` callback, which
85
+ * discards the promise to keep its existing fire-and-forget contract).
77
86
  */
78
- function chainedDispatch(actionId) {
87
+ export function dispatchActionProgrammatic(actionId, _opts) {
79
88
  var _a, _b;
80
- const entry = listActions().find((e) => e.action.id === actionId);
89
+ const entries = listActions();
90
+ const entry = entries.find((e) => e.action.id === actionId);
81
91
  if (!entry) {
82
- console.warn(`[sh3] ctx.dispatch("${actionId}") no action registered with that id.`);
83
- return;
92
+ return Promise.reject(new Error(`action "${actionId}" not registered`));
84
93
  }
85
94
  const state = getLiveDispatcherState();
86
- runAction(actionId, {
87
- action: { id: entry.action.id, label: resolveLabel(entry.action) },
88
- appId: state.activeAppId,
89
- viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
90
- selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
91
- invokedVia: 'programmatic',
92
- dispatch: chainedDispatch,
95
+ const desc = listActionsFromEntries(entries, state).find((d) => d.id === actionId);
96
+ if (!desc || !desc.active) {
97
+ return Promise.reject(new Error(`action "${actionId}" is not active in current scope`));
98
+ }
99
+ // run is guaranteed non-null by `desc.active === true`.
100
+ const run = entry.action.run;
101
+ try {
102
+ const result = run({
103
+ action: { id: entry.action.id, label: resolveLabel(entry.action) },
104
+ appId: state.activeAppId,
105
+ viewId: (_a = state.focusedViewId) !== null && _a !== void 0 ? _a : undefined,
106
+ selection: (_b = state.selection) !== null && _b !== void 0 ? _b : undefined,
107
+ invokedVia: 'programmatic',
108
+ dispatch: chainedDispatch,
109
+ });
110
+ return Promise.resolve(result).then(() => undefined);
111
+ }
112
+ catch (err) {
113
+ return Promise.reject(err);
114
+ }
115
+ }
116
+ /**
117
+ * In-action `ctx.dispatch(id)` callback. Fire-and-forget: discards the
118
+ * promise from {@link dispatchActionProgrammatic} so action authors can
119
+ * keep calling it as `dispatch(id)` without `await`. Errors are logged
120
+ * via console.error to preserve the original `chainedDispatch` behavior.
121
+ */
122
+ function chainedDispatch(actionId) {
123
+ dispatchActionProgrammatic(actionId).catch((err) => {
124
+ console.error(`[sh3] ctx.dispatch("${actionId}"):`, err);
93
125
  });
94
126
  }
95
127
  function onFocusIn(ev) {
@@ -272,6 +304,12 @@ export function openPalette(opts) {
272
304
  const entries = listActions();
273
305
  const state = getLiveDispatcherState();
274
306
  const candidates = buildPaletteCandidates(entries, state, { filter: opts === null || opts === void 0 ? void 0 : opts.filter });
307
+ // Snapshot the focus origin so a stateless invocation (or Escape)
308
+ // returns the user to whatever they were typing. If the action moved
309
+ // focus elsewhere, activeElement is no longer body and we leave it.
310
+ const previousFocus = typeof document !== 'undefined' && document.activeElement instanceof HTMLElement
311
+ ? document.activeElement
312
+ : null;
275
313
  const handle = shell.modal.open(CommandPalette, {
276
314
  candidates,
277
315
  recency,
@@ -308,4 +346,20 @@ export function openPalette(opts) {
308
346
  },
309
347
  onClose: () => handle.close(),
310
348
  }, { dismissOnBackdrop: true });
349
+ if (previousFocus) {
350
+ const innerClose = handle.close;
351
+ handle.close = () => {
352
+ innerClose();
353
+ // After unmount, if no other element claimed focus (stateless action
354
+ // ran, Escape, backdrop click), put the user back where they came
355
+ // from. A stateful action that focused a new element leaves
356
+ // activeElement non-body and we leave it alone.
357
+ queueMicrotask(() => {
358
+ if (document.activeElement === document.body
359
+ || document.activeElement === null) {
360
+ previousFocus.focus();
361
+ }
362
+ });
363
+ };
364
+ }
311
365
  }
@@ -0,0 +1,98 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { dispatchActionProgrammatic } from './listeners';
3
+ import { registerAction, __resetActionsRegistryForTest, } from './registry';
4
+ import { __resetContributionsForTest } from '../contributions/registry';
5
+ import { __resetDispatcherStateForTest, setActiveApp } from './state.svelte';
6
+ beforeEach(() => {
7
+ __resetContributionsForTest();
8
+ __resetActionsRegistryForTest();
9
+ __resetDispatcherStateForTest();
10
+ setActiveApp(null, new Set());
11
+ });
12
+ describe('dispatchActionProgrammatic', () => {
13
+ it('rejects on unknown action id', async () => {
14
+ await expect(dispatchActionProgrammatic('nope')).rejects.toThrow(/action "nope" not registered/);
15
+ });
16
+ it('rejects when the action is registered but inactive (out of scope)', async () => {
17
+ const run = vi.fn();
18
+ const action = {
19
+ id: 'gated',
20
+ label: 'Gated',
21
+ scope: 'app',
22
+ run,
23
+ };
24
+ registerAction(action, 'shard.x');
25
+ // No active app -> 'app' scope is inactive.
26
+ await expect(dispatchActionProgrammatic('gated')).rejects.toThrow(/action "gated" is not active/);
27
+ expect(run).not.toHaveBeenCalled();
28
+ });
29
+ it('rejects when the action is disabled', async () => {
30
+ const run = vi.fn();
31
+ registerAction({
32
+ id: 'd', label: 'D', scope: 'home', run, disabled: true,
33
+ }, 'shard.x');
34
+ await expect(dispatchActionProgrammatic('d')).rejects.toThrow(/action "d" is not active/);
35
+ expect(run).not.toHaveBeenCalled();
36
+ });
37
+ it('rejects on a submenu parent without run', async () => {
38
+ registerAction({
39
+ id: 's', label: 'S', scope: 'home', submenu: true,
40
+ }, 'shard.x');
41
+ await expect(dispatchActionProgrammatic('s')).rejects.toThrow(/action "s" is not active/);
42
+ });
43
+ it('invokes run with invokedVia="programmatic" on happy path', async () => {
44
+ let captured = null;
45
+ registerAction({
46
+ id: 'go', label: 'Go', scope: 'home', run: (ctx) => { captured = ctx; },
47
+ }, 'shard.x');
48
+ await dispatchActionProgrammatic('go');
49
+ expect(captured).not.toBeNull();
50
+ expect(captured.invokedVia).toBe('programmatic');
51
+ expect(captured.action).toEqual({ id: 'go', label: 'Go' });
52
+ });
53
+ it('awaits async run', async () => {
54
+ let resolveInner = null;
55
+ const inner = new Promise((res) => { resolveInner = res; });
56
+ let runReturned = false;
57
+ registerAction({
58
+ id: 'a',
59
+ label: 'A',
60
+ scope: 'home',
61
+ run: async () => {
62
+ await inner;
63
+ runReturned = true;
64
+ },
65
+ }, 'shard.x');
66
+ const dispatch = dispatchActionProgrammatic('a');
67
+ // Microtask break: the run promise has not settled yet.
68
+ await Promise.resolve();
69
+ expect(runReturned).toBe(false);
70
+ resolveInner();
71
+ await dispatch;
72
+ expect(runReturned).toBe(true);
73
+ });
74
+ it('rejects with the original error when run throws synchronously', async () => {
75
+ registerAction({
76
+ id: 't', label: 'T', scope: 'home', run: () => { throw new Error('boom'); },
77
+ }, 'shard.x');
78
+ await expect(dispatchActionProgrammatic('t')).rejects.toThrow('boom');
79
+ });
80
+ it('rejects with the original error when an async run rejects', async () => {
81
+ registerAction({
82
+ id: 't', label: 'T', scope: 'home', run: async () => { throw new Error('async boom'); },
83
+ }, 'shard.x');
84
+ await expect(dispatchActionProgrammatic('t')).rejects.toThrow('async boom');
85
+ });
86
+ it('exposes opts.signal on the dispatch context', async () => {
87
+ let captured = null;
88
+ registerAction({
89
+ id: 'go', label: 'Go', scope: 'home', run: (ctx) => { captured = ctx; },
90
+ }, 'shard.x');
91
+ const ctrl = new AbortController();
92
+ await dispatchActionProgrammatic('go', { signal: ctrl.signal });
93
+ // We don't assert on a typed `signal` field — opts is parity-only and not
94
+ // part of ActionDispatchContext today. The test just ensures the call
95
+ // path accepts the option without error.
96
+ expect(captured).not.toBeNull();
97
+ });
98
+ });
@@ -149,6 +149,43 @@ export interface ActiveActionDescriptor {
149
149
  paletteItem: boolean;
150
150
  contextItem: boolean;
151
151
  }
152
+ /**
153
+ * Read-only snapshot describing one action in the registry. Produced by
154
+ * `listActionsFromEntries()` and surfaced to shards via `ctx.listActions()`.
155
+ *
156
+ * Compared with {@link ActiveActionDescriptor}: this descriptor carries the
157
+ * full registered set, including actions that would not dispatch right now
158
+ * (out-of-scope, disabled, submenu parent without `run`). The `active` flag
159
+ * tells the caller whether `ctx.runAction(id)` would dispatch.
160
+ */
161
+ export interface ActionDescriptor {
162
+ /** Stable action id as registered. */
163
+ id: string;
164
+ /** Human-readable label as registered. */
165
+ label: string;
166
+ /**
167
+ * Shortcut string as it would dispatch right now (platform-resolved,
168
+ * user-rebind applied). `null` when `bindingSource` is `'disabled'` or
169
+ * `'none'`.
170
+ */
171
+ effectiveShortcut: string | null;
172
+ /** Where the effective shortcut came from. */
173
+ bindingSource: BindingSource;
174
+ /**
175
+ * The innermost active tier of the action's scope when `active === true`.
176
+ * When `active === false`, the first scope of the registered action.
177
+ */
178
+ scope: AtomicScope;
179
+ /** Display hint: `null` for home/app, else full string or element type. */
180
+ scopeBadge: string | null;
181
+ group?: string;
182
+ icon?: string;
183
+ ownerShardId: string;
184
+ paletteItem: boolean;
185
+ contextItem: boolean;
186
+ /** True when `runAction(id)` would dispatch right now. */
187
+ active: boolean;
188
+ }
152
189
  /**
153
190
  * Resolve an Action's label to a string. Function labels are called on each
154
191
  * read; string labels are returned unchanged.
package/dist/api.d.ts CHANGED
@@ -2,7 +2,7 @@ export { shell } from './shellRuntime.svelte';
2
2
  export type { Shell } from './shellRuntime.svelte';
3
3
  export type { Shard, ShardManifest, SourceShard, SourceShardManifest, ShardContext, ViewDeclaration, ViewFactory, ViewHandle, MountContext, } from './shards/types';
4
4
  export type { LayoutNode, SplitNode, TabsNode, SlotNode, TabEntry, SplitDirection, SizeMode, LayoutTree, FloatEntry, LayoutPreset, CanonicalPreset, TreeRootRef as SlotLocation, } from './layout/types';
5
- export type { ActiveActionDescriptor, BindingSource, AtomicScope, ActionScope, } from './actions/types';
5
+ export type { ActionDescriptor, ActiveActionDescriptor, BindingSource, AtomicScope, ActionScope, } from './actions/types';
6
6
  export type { FloatManager, FloatOptions } from './overlays/float';
7
7
  export type { ModalManager } from './overlays/modal';
8
8
  export type { PopupManager } from './overlays/popup';