sh3-core 0.15.0 → 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 (141) 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/store/verbs.js +4 -0
  14. package/dist/app-appearance/appearanceShard.svelte.js +19 -6
  15. package/dist/app-appearance/appearanceState.svelte.js +3 -3
  16. package/dist/host.js +2 -1
  17. package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
  18. package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
  19. package/dist/layouts-shard/LayoutsSection.svelte +142 -0
  20. package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
  21. package/dist/layouts-shard/filter.d.ts +3 -0
  22. package/dist/layouts-shard/filter.js +66 -0
  23. package/dist/layouts-shard/filter.test.d.ts +1 -0
  24. package/dist/layouts-shard/filter.test.js +123 -0
  25. package/dist/layouts-shard/index.d.ts +1 -0
  26. package/dist/layouts-shard/index.js +1 -0
  27. package/dist/layouts-shard/layoutsApi.d.ts +12 -0
  28. package/dist/layouts-shard/layoutsApi.js +41 -0
  29. package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
  30. package/dist/layouts-shard/layoutsApi.test.js +74 -0
  31. package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
  32. package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
  33. package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
  34. package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
  35. package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
  36. package/dist/layouts-shard/layoutsState.svelte.js +50 -0
  37. package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
  38. package/dist/layouts-shard/layoutsState.test.js +43 -0
  39. package/dist/layouts-shard/types.d.ts +21 -0
  40. package/dist/layouts-shard/types.js +6 -0
  41. package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
  42. package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
  43. package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
  44. package/dist/overlays/EntityAppearanceModal.test.js +57 -0
  45. package/dist/overlays/FloatFrame.svelte +149 -8
  46. package/dist/overlays/FloatFrame.svelte.d.ts +1 -1
  47. package/dist/overlays/FloatLayer.svelte +2 -2
  48. package/dist/overlays/float.d.ts +38 -1
  49. package/dist/overlays/float.js +82 -0
  50. package/dist/overlays/float.test.js +394 -0
  51. package/dist/overlays/floatMaximized.svelte.d.ts +4 -0
  52. package/dist/overlays/floatMaximized.svelte.js +30 -0
  53. package/dist/runtime/runVerb-shell.test.d.ts +1 -0
  54. package/dist/runtime/runVerb-shell.test.js +231 -0
  55. package/dist/sh3core-shard/ShellHome.svelte +3 -0
  56. package/dist/sh3core-shard/sh3coreShard.svelte.d.ts +7 -0
  57. package/dist/sh3core-shard/sh3coreShard.svelte.js +23 -0
  58. package/dist/shards/activate-runtime.test.js +24 -2
  59. package/dist/shards/activate.svelte.js +18 -4
  60. package/dist/shards/types.d.ts +44 -4
  61. package/dist/shell-shard/CommandLine.svelte +143 -0
  62. package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
  63. package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
  64. package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
  65. package/dist/shell-shard/InputLine.svelte +17 -40
  66. package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
  67. package/dist/shell-shard/ScrollbackView.svelte +10 -3
  68. package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
  69. package/dist/shell-shard/Terminal.svelte +94 -22
  70. package/dist/shell-shard/buffer-store.d.ts +15 -0
  71. package/dist/shell-shard/buffer-store.js +124 -0
  72. package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
  73. package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
  74. package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
  75. package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
  76. package/dist/shell-shard/contract.d.ts +7 -0
  77. package/dist/shell-shard/dispatch-custom.test.js +3 -1
  78. package/dist/shell-shard/dispatch-gating.test.js +6 -2
  79. package/dist/shell-shard/dispatch-invoke.test.js +10 -8
  80. package/dist/shell-shard/dispatch.d.ts +7 -2
  81. package/dist/shell-shard/dispatch.js +23 -27
  82. package/dist/shell-shard/display-cwd.d.ts +1 -0
  83. package/dist/shell-shard/display-cwd.js +27 -0
  84. package/dist/shell-shard/display-cwd.test.d.ts +1 -0
  85. package/dist/shell-shard/display-cwd.test.js +29 -0
  86. package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
  87. package/dist/shell-shard/manifest.js +2 -1
  88. package/dist/shell-shard/manifest.test.d.ts +1 -0
  89. package/dist/shell-shard/manifest.test.js +8 -0
  90. package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
  91. package/dist/shell-shard/mode-buffer.svelte.js +19 -0
  92. package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
  93. package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
  94. package/dist/shell-shard/modes/builtin.js +2 -0
  95. package/dist/shell-shard/modes/types.d.ts +8 -0
  96. package/dist/shell-shard/protocol.d.ts +12 -6
  97. package/dist/shell-shard/replay.d.ts +3 -0
  98. package/dist/shell-shard/replay.js +44 -0
  99. package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
  100. package/dist/shell-shard/replay.svelte.test.js +47 -0
  101. package/dist/shell-shard/rich-registry.d.ts +5 -0
  102. package/dist/shell-shard/rich-registry.js +25 -0
  103. package/dist/shell-shard/rich-registry.test.d.ts +1 -0
  104. package/dist/shell-shard/rich-registry.test.js +31 -0
  105. package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
  106. package/dist/shell-shard/scrollback.svelte.js +23 -0
  107. package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
  108. package/dist/shell-shard/scrollback.svelte.test.js +51 -0
  109. package/dist/shell-shard/session-client.svelte.d.ts +18 -2
  110. package/dist/shell-shard/session-client.svelte.js +21 -4
  111. package/dist/shell-shard/shellApi.d.ts +2 -1
  112. package/dist/shell-shard/shellApi.js +32 -3
  113. package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
  114. package/dist/shell-shard/shellApi.svelte.test.js +59 -0
  115. package/dist/shell-shard/shellShard.svelte.js +11 -1
  116. package/dist/shell-shard/terminal-dispatch.test.js +3 -1
  117. package/dist/shell-shard/verbs/apps.js +9 -0
  118. package/dist/shell-shard/verbs/env.js +4 -0
  119. package/dist/shell-shard/verbs/help.js +9 -1
  120. package/dist/shell-shard/verbs/help.svelte.test.d.ts +1 -0
  121. package/dist/shell-shard/verbs/help.svelte.test.js +53 -0
  122. package/dist/shell-shard/verbs/history.js +8 -1
  123. package/dist/shell-shard/verbs/index.js +0 -8
  124. package/dist/shell-shard/verbs/shards.js +5 -0
  125. package/dist/shell-shard/verbs/views.js +9 -0
  126. package/dist/shell-shard/verbs/zones.js +9 -0
  127. package/dist/verbs/types.d.ts +9 -0
  128. package/dist/version.d.ts +1 -1
  129. package/dist/version.js +1 -1
  130. package/package.json +1 -1
  131. package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
  132. package/dist/shell-shard/verbs/cat.d.ts +0 -2
  133. package/dist/shell-shard/verbs/cat.js +0 -34
  134. package/dist/shell-shard/verbs/cd.test.js +0 -56
  135. package/dist/shell-shard/verbs/ls.d.ts +0 -2
  136. package/dist/shell-shard/verbs/ls.js +0 -29
  137. package/dist/shell-shard/verbs/ls.test.js +0 -49
  138. package/dist/shell-shard/verbs/session.d.ts +0 -4
  139. package/dist/shell-shard/verbs/session.js +0 -97
  140. /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
  141. /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { floatManager, __resetFloatManagerForTest } from '../overlays/float';
3
+ import { captureFromFloat, restoreToFloat } from './layoutsApi';
4
+ const STANDALONE = new Set(['shell:terminal', 'graphlive:hierarchy']);
5
+ const isStandalone = (id) => STANDALONE.has(id);
6
+ describe('captureFromFloat', () => {
7
+ beforeEach(() => __resetFloatManagerForTest());
8
+ it('returns null for an unknown floatId', () => {
9
+ const out = captureFromFloat('nope', isStandalone);
10
+ expect(out).toBeNull();
11
+ });
12
+ it('returns null when filter empties the float', () => {
13
+ const id = floatManager.openWithContent({
14
+ content: { type: 'slot', slotId: 's', viewId: 'app-only:view' },
15
+ size: { w: 600, h: 400 },
16
+ });
17
+ expect(captureFromFloat(id, isStandalone)).toBeNull();
18
+ });
19
+ it('returns a clone of the filtered tree (mutating result must not affect source)', () => {
20
+ const content = {
21
+ type: 'tabs',
22
+ activeTab: 0,
23
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
24
+ };
25
+ const id = floatManager.openWithContent({ content, size: { w: 700, h: 500 } });
26
+ const out = captureFromFloat(id, isStandalone);
27
+ expect(out).not.toBeNull();
28
+ expect(out.size).toEqual({ w: 700, h: 500 });
29
+ expect(out.content).toEqual(content);
30
+ // Mutate the returned content; verify the float entry is unaffected.
31
+ if (out.content.type === 'tabs')
32
+ out.content.tabs[0].label = 'MUTATED';
33
+ const live = floatManager.list().find((f) => f.id === id);
34
+ if (live.content.type === 'tabs') {
35
+ expect(live.content.tabs[0].label).toBe('Shell');
36
+ }
37
+ });
38
+ });
39
+ describe('restoreToFloat', () => {
40
+ beforeEach(() => __resetFloatManagerForTest());
41
+ function mk(content) {
42
+ return {
43
+ id: 'sl-1',
44
+ name: 'My Layout',
45
+ createdAt: 0,
46
+ size: { w: 720, h: 480 },
47
+ content,
48
+ };
49
+ }
50
+ it('returns empty string + does not open a float when filter empties', () => {
51
+ const toast = vi.fn();
52
+ const layout = mk({ type: 'slot', slotId: 's', viewId: 'app-only:view' });
53
+ const id = restoreToFloat(layout, isStandalone, toast);
54
+ expect(id).toBe('');
55
+ expect(floatManager.list()).toHaveLength(0);
56
+ expect(toast).toHaveBeenCalledWith(expect.stringContaining('My Layout'), 'warn');
57
+ });
58
+ it('opens a float with the filtered content and returns its id', () => {
59
+ const toast = vi.fn();
60
+ const layout = mk({
61
+ type: 'tabs',
62
+ activeTab: 0,
63
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
64
+ });
65
+ const id = restoreToFloat(layout, isStandalone, toast);
66
+ expect(id).not.toBe('');
67
+ const list = floatManager.list();
68
+ expect(list).toHaveLength(1);
69
+ expect(list[0].id).toBe(id);
70
+ expect(list[0].title).toBe('My Layout');
71
+ expect(list[0].size).toEqual({ w: 720, h: 480 });
72
+ expect(toast).not.toHaveBeenCalled();
73
+ });
74
+ });
@@ -0,0 +1,11 @@
1
+ import type { Shard } from '../shards/types';
2
+ import type { SavedLayout } from './types';
3
+ export declare function __testSaveLayout(floatId: string, name: string): SavedLayout | null;
4
+ export declare function __testCustomize(layoutId: string, next: {
5
+ label: string;
6
+ icon?: string;
7
+ color?: string;
8
+ }): void;
9
+ export declare function __testCustomizeReset(layoutId: string): void;
10
+ export declare function __testDelete(layoutId: string): void;
11
+ export declare const layoutsShard: Shard;
@@ -0,0 +1,231 @@
1
+ /*
2
+ * `__layouts__` shard — saved-layout capture-and-restore.
3
+ *
4
+ * Owns the user-zone for SavedLayout records, contributes the
5
+ * `sh3.layout.save` action under element:float-header (handler resolves
6
+ * the target floatId from the typed selection set by FloatFrame's
7
+ * oncontextmenu), and (in later tasks) the open-saved-layout palette
8
+ * submenu and the home-page card grid.
9
+ */
10
+ import { VERSION } from '../version';
11
+ import { listStandaloneViews } from '../shards/activate.svelte';
12
+ import { getSelection } from '../actions/selection.svelte';
13
+ import { modalManager } from '../overlays/modal';
14
+ import { toastManager } from '../overlays/toast';
15
+ import LayoutSaveModal from './LayoutSaveModal.svelte';
16
+ import EntityAppearanceModal from '../overlays/EntityAppearanceModal.svelte';
17
+ import { __bindZone, __unbindZone, addLayout, deleteLayout, getLayouts, updateLayout, } from './layoutsState.svelte';
18
+ import ConfirmDialog from '../overlays/ConfirmDialog.svelte';
19
+ import { captureFromFloat, restoreToFloat } from './layoutsApi';
20
+ function readFloatHeaderRef() {
21
+ const sel = getSelection();
22
+ if (!sel || sel.type !== 'float-header')
23
+ return null;
24
+ return sel.ref;
25
+ }
26
+ function isStandalone(viewId) {
27
+ return listStandaloneViews().some((v) => v.viewId === viewId);
28
+ }
29
+ function resolveViewLabel(viewId) {
30
+ var _a, _b;
31
+ return (_b = (_a = listStandaloneViews().find((v) => v.viewId === viewId)) === null || _a === void 0 ? void 0 : _a.label) !== null && _b !== void 0 ? _b : viewId;
32
+ }
33
+ function generateLayoutId() {
34
+ return `sl-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
35
+ }
36
+ /**
37
+ * Effectful save path. Exposed as `__testSaveLayout` for unit tests so they
38
+ * don't need to mount the modal. Production path runs through the modal,
39
+ * which calls this same function via its onConfirm callback.
40
+ */
41
+ function performSave(floatId, name) {
42
+ const captured = captureFromFloat(floatId, isStandalone);
43
+ if (captured === null)
44
+ return null;
45
+ const layout = {
46
+ id: generateLayoutId(),
47
+ name,
48
+ createdAt: Date.now(),
49
+ size: captured.size,
50
+ content: captured.content,
51
+ };
52
+ addLayout(layout);
53
+ return layout;
54
+ }
55
+ export function __testSaveLayout(floatId, name) {
56
+ return performSave(floatId, name);
57
+ }
58
+ function readSavedLayoutRef() {
59
+ const sel = getSelection();
60
+ if (!sel || sel.type !== 'saved-layout')
61
+ return null;
62
+ return sel.ref;
63
+ }
64
+ function performCustomize(layoutId, next) {
65
+ const appearance = next.icon === undefined && next.color === undefined
66
+ ? undefined
67
+ : { icon: next.icon, color: next.color };
68
+ updateLayout(layoutId, {
69
+ name: next.label,
70
+ appearance,
71
+ });
72
+ }
73
+ function performCustomizeReset(layoutId) {
74
+ updateLayout(layoutId, { appearance: undefined });
75
+ }
76
+ export function __testCustomize(layoutId, next) {
77
+ performCustomize(layoutId, next);
78
+ }
79
+ export function __testCustomizeReset(layoutId) {
80
+ performCustomizeReset(layoutId);
81
+ }
82
+ function runCustomize(_ctx) {
83
+ const ref = readSavedLayoutRef();
84
+ if (!ref)
85
+ return;
86
+ const layout = getLayouts().find((l) => l.id === ref.layoutId);
87
+ if (!layout)
88
+ return;
89
+ const props = {
90
+ entityLabel: layout.name,
91
+ initialAppearance: layout.appearance,
92
+ defaultIcon: 'eye',
93
+ requireLabel: true,
94
+ onSave: (next) => performCustomize(ref.layoutId, next),
95
+ onReset: () => performCustomizeReset(ref.layoutId),
96
+ };
97
+ modalManager.open(EntityAppearanceModal, props);
98
+ }
99
+ function performDelete(layoutId) {
100
+ deleteLayout(layoutId);
101
+ }
102
+ export function __testDelete(layoutId) {
103
+ performDelete(layoutId);
104
+ }
105
+ function runDelete(_ctx) {
106
+ const ref = readSavedLayoutRef();
107
+ if (!ref)
108
+ return;
109
+ const layout = getLayouts().find((l) => l.id === ref.layoutId);
110
+ if (!layout)
111
+ return;
112
+ modalManager.open(ConfirmDialog, {
113
+ title: `Delete saved layout "${layout.name}"?`,
114
+ body: 'This cannot be undone.',
115
+ confirmLabel: 'Delete',
116
+ confirmTone: 'danger',
117
+ onConfirm: () => performDelete(ref.layoutId),
118
+ });
119
+ }
120
+ function runSave(_ctx) {
121
+ const ref = readFloatHeaderRef();
122
+ if (!ref)
123
+ return;
124
+ const props = {
125
+ floatId: ref.floatId,
126
+ isStandalone,
127
+ resolveLabel: resolveViewLabel,
128
+ defaultName: `Layout ${getLayouts().length + 1}`,
129
+ onConfirm: (name) => { performSave(ref.floatId, name); },
130
+ };
131
+ modalManager.open(LayoutSaveModal, props);
132
+ }
133
+ export const layoutsShard = {
134
+ manifest: {
135
+ id: '__layouts__',
136
+ label: 'Saved Layouts',
137
+ version: VERSION,
138
+ views: [],
139
+ },
140
+ activate(ctx) {
141
+ const zone = ctx.state({
142
+ user: { layouts: [] },
143
+ });
144
+ __bindZone(zone);
145
+ const toast = (m, level) => {
146
+ toastManager.notify(m, { level });
147
+ };
148
+ const save = {
149
+ id: 'sh3.layout.save',
150
+ label: 'Save layout as…',
151
+ scope: { element: 'float-header' },
152
+ contextItem: true,
153
+ paletteItem: false,
154
+ run: runSave,
155
+ };
156
+ ctx.actions.register(save);
157
+ // Submenu parent. The dispatcher's default behavior on a parent
158
+ // without a run() opens a sub-palette filtered to its children.
159
+ ctx.actions.register({
160
+ id: 'sh3.layout.open',
161
+ label: 'Open saved layout',
162
+ scope: ['home', 'app'],
163
+ submenu: true,
164
+ paletteItem: true,
165
+ });
166
+ ctx.actions.register({
167
+ id: 'sh3.layout.customize',
168
+ label: 'Customize…',
169
+ scope: { element: 'saved-layout' },
170
+ contextItem: true,
171
+ group: 'appearance',
172
+ run: runCustomize,
173
+ });
174
+ ctx.actions.register({
175
+ id: 'sh3.layout.delete',
176
+ label: 'Delete',
177
+ scope: { element: 'saved-layout' },
178
+ contextItem: true,
179
+ run: runDelete,
180
+ });
181
+ // Dynamic children — one per saved layout, kept in sync as layouts
182
+ // are added / renamed / removed. Identical pattern to sh3.app.launch
183
+ // in sh3coreShard.svelte.ts, plus rename detection via a sidecar
184
+ // label map so the registered child label tracks the saved name.
185
+ const openUnregisters = new Map();
186
+ const registeredLabels = new Map();
187
+ $effect.root(() => {
188
+ $effect(() => {
189
+ const layouts = getLayouts();
190
+ const currentIds = new Set();
191
+ for (const layout of layouts) {
192
+ currentIds.add(layout.id);
193
+ const existing = openUnregisters.get(layout.id);
194
+ if (existing && registeredLabels.get(layout.id) === layout.name)
195
+ continue;
196
+ if (existing) {
197
+ existing();
198
+ openUnregisters.delete(layout.id);
199
+ registeredLabels.delete(layout.id);
200
+ }
201
+ const off = ctx.actions.register({
202
+ id: `sh3.layout.open:${layout.id}`,
203
+ label: layout.name,
204
+ scope: ['home', 'app'],
205
+ submenuOf: 'sh3.layout.open',
206
+ run: () => {
207
+ const live = getLayouts().find((l) => l.id === layout.id);
208
+ if (live)
209
+ restoreToFloat(live, isStandalone, toast);
210
+ },
211
+ });
212
+ openUnregisters.set(layout.id, off);
213
+ registeredLabels.set(layout.id, layout.name);
214
+ }
215
+ for (const id of [...openUnregisters.keys()]) {
216
+ if (!currentIds.has(id)) {
217
+ openUnregisters.get(id)();
218
+ openUnregisters.delete(id);
219
+ registeredLabels.delete(id);
220
+ }
221
+ }
222
+ });
223
+ });
224
+ },
225
+ autostart() {
226
+ /* self-start so the action is available before any app launches. */
227
+ },
228
+ deactivate() {
229
+ __unbindZone();
230
+ },
231
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { registerShard, activateShard } from '../shards/activate.svelte';
3
+ import { addAutostartShard } from '../actions/state.svelte';
4
+ import { floatManager } from '../overlays/float';
5
+ import { layoutsShard } from './layoutsShard.svelte';
6
+ import { __resetForTests, getLayouts } from './layoutsState.svelte';
7
+ import { __resetActionsRegistryForTest } from '../actions/registry';
8
+ import { resetFramework } from '../__test__/reset';
9
+ // Minimal stub shard exposing one standalone view, so listStandaloneViews()
10
+ // returns something useful inside this test file.
11
+ const stubShard = {
12
+ manifest: {
13
+ id: 'stub',
14
+ label: 'Stub',
15
+ version: '0.0.0',
16
+ views: [{ id: 'shell:terminal', label: 'Shell', standalone: true }],
17
+ },
18
+ activate(ctx) {
19
+ ctx.registerView('shell:terminal', {
20
+ mount: () => ({ unmount() { } }),
21
+ });
22
+ },
23
+ };
24
+ describe('layoutsShard — sh3.layout.save', () => {
25
+ beforeEach(async () => {
26
+ resetFramework();
27
+ __resetActionsRegistryForTest();
28
+ __resetForTests();
29
+ registerShard(stubShard);
30
+ addAutostartShard(stubShard.manifest.id);
31
+ await activateShard(stubShard.manifest.id);
32
+ registerShard(layoutsShard);
33
+ addAutostartShard(layoutsShard.manifest.id);
34
+ await activateShard(layoutsShard.manifest.id);
35
+ });
36
+ it('registers sh3.layout.save against element:float-header', async () => {
37
+ const { listActions } = await import('../actions/registry');
38
+ const entries = listActions();
39
+ const save = entries.find((e) => e.action.id === 'sh3.layout.save');
40
+ expect(save).toBeDefined();
41
+ expect(JSON.stringify(save.action.scope)).toContain('float-header');
42
+ });
43
+ it('persists a captured layout to the user zone via the save flow', async () => {
44
+ const floatId = floatManager.openWithContent({
45
+ content: {
46
+ type: 'tabs',
47
+ activeTab: 0,
48
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
49
+ },
50
+ size: { w: 800, h: 600 },
51
+ });
52
+ const { __testSaveLayout } = await import('./layoutsShard.svelte');
53
+ __testSaveLayout(floatId, 'My Saved Layout');
54
+ const layouts = getLayouts();
55
+ expect(layouts).toHaveLength(1);
56
+ expect(layouts[0].name).toBe('My Saved Layout');
57
+ expect(layouts[0].size).toEqual({ w: 800, h: 600 });
58
+ expect(layouts[0].content).toMatchObject({
59
+ type: 'tabs',
60
+ tabs: [{ viewId: 'shell:terminal' }],
61
+ });
62
+ });
63
+ });
64
+ describe('layoutsShard — sh3.layout.open palette submenu', () => {
65
+ beforeEach(async () => {
66
+ resetFramework();
67
+ __resetActionsRegistryForTest();
68
+ __resetForTests();
69
+ registerShard(stubShard);
70
+ addAutostartShard(stubShard.manifest.id);
71
+ await activateShard(stubShard.manifest.id);
72
+ registerShard(layoutsShard);
73
+ addAutostartShard(layoutsShard.manifest.id);
74
+ await activateShard(layoutsShard.manifest.id);
75
+ });
76
+ it('registers sh3.layout.open as a submenu parent', async () => {
77
+ const { listActions } = await import('../actions/registry');
78
+ const entry = listActions().find((e) => e.action.id === 'sh3.layout.open');
79
+ expect(entry).toBeDefined();
80
+ expect(entry.action.submenu).toBe(true);
81
+ expect(entry.action.paletteItem).toBe(true);
82
+ });
83
+ it('registers a dynamic child per saved layout under sh3.layout.open', async () => {
84
+ const { __testSaveLayout } = await import('./layoutsShard.svelte');
85
+ const floatId = floatManager.openWithContent({
86
+ content: {
87
+ type: 'tabs',
88
+ activeTab: 0,
89
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
90
+ },
91
+ size: { w: 600, h: 400 },
92
+ });
93
+ __testSaveLayout(floatId, 'Workspace A');
94
+ const { listActions } = await import('../actions/registry');
95
+ const child = listActions().find((e) => e.action.submenuOf === 'sh3.layout.open' && e.action.label === 'Workspace A');
96
+ expect(child).toBeDefined();
97
+ });
98
+ it('restores the saved layout into a fresh float when its child is run', async () => {
99
+ const { __testSaveLayout } = await import('./layoutsShard.svelte');
100
+ const sourceId = floatManager.openWithContent({
101
+ content: {
102
+ type: 'tabs',
103
+ activeTab: 0,
104
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
105
+ },
106
+ size: { w: 720, h: 480 },
107
+ });
108
+ const layout = __testSaveLayout(sourceId, 'Workspace A');
109
+ floatManager.close(sourceId);
110
+ expect(floatManager.list()).toHaveLength(0);
111
+ const { listActions } = await import('../actions/registry');
112
+ const child = listActions().find((e) => e.action.id === `sh3.layout.open:${layout.id}`);
113
+ await child.action.run({
114
+ action: { id: child.action.id, label: 'Workspace A' },
115
+ appId: null,
116
+ invokedVia: 'palette',
117
+ dispatch: () => { },
118
+ });
119
+ const list = floatManager.list();
120
+ expect(list).toHaveLength(1);
121
+ expect(list[0].title).toBe('Workspace A');
122
+ expect(list[0].size).toEqual({ w: 720, h: 480 });
123
+ });
124
+ });
125
+ describe('layoutsShard — sh3.layout.customize', () => {
126
+ beforeEach(async () => {
127
+ resetFramework();
128
+ __resetActionsRegistryForTest();
129
+ __resetForTests();
130
+ registerShard(stubShard);
131
+ addAutostartShard(stubShard.manifest.id);
132
+ await activateShard(stubShard.manifest.id);
133
+ registerShard(layoutsShard);
134
+ addAutostartShard(layoutsShard.manifest.id);
135
+ await activateShard(layoutsShard.manifest.id);
136
+ });
137
+ it('rename via __testCustomize updates the layout name and palette child label', async () => {
138
+ const { __testSaveLayout, __testCustomize } = await import('./layoutsShard.svelte');
139
+ const sourceId = floatManager.openWithContent({
140
+ content: {
141
+ type: 'tabs',
142
+ activeTab: 0,
143
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
144
+ },
145
+ size: { w: 600, h: 400 },
146
+ });
147
+ const layout = __testSaveLayout(sourceId, 'Original');
148
+ __testCustomize(layout.id, { label: 'Renamed', icon: 'cog', color: '#abc' });
149
+ const after = getLayouts().find((l) => l.id === layout.id);
150
+ expect(after.name).toBe('Renamed');
151
+ expect(after.appearance).toEqual({ icon: 'cog', color: '#abc' });
152
+ const { listActions } = await import('../actions/registry');
153
+ const child = listActions().find((e) => e.action.id === `sh3.layout.open:${layout.id}`);
154
+ expect(child).toBeDefined();
155
+ expect(child.action.label).toBe('Renamed');
156
+ });
157
+ it('reset via __testCustomizeReset clears appearance but keeps name', async () => {
158
+ const { __testSaveLayout, __testCustomize, __testCustomizeReset } = await import('./layoutsShard.svelte');
159
+ const sourceId = floatManager.openWithContent({
160
+ content: {
161
+ type: 'tabs',
162
+ activeTab: 0,
163
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
164
+ },
165
+ size: { w: 600, h: 400 },
166
+ });
167
+ const layout = __testSaveLayout(sourceId, 'Original');
168
+ __testCustomize(layout.id, { label: 'Renamed', icon: 'cog', color: '#abc' });
169
+ __testCustomizeReset(layout.id);
170
+ const after = getLayouts().find((l) => l.id === layout.id);
171
+ expect(after.name).toBe('Renamed');
172
+ expect(after.appearance).toBeUndefined();
173
+ });
174
+ it('registers sh3.layout.customize against element:saved-layout', async () => {
175
+ const { listActions } = await import('../actions/registry');
176
+ const entry = listActions().find((e) => e.action.id === 'sh3.layout.customize');
177
+ expect(entry).toBeDefined();
178
+ expect(JSON.stringify(entry.action.scope)).toContain('saved-layout');
179
+ });
180
+ });
181
+ describe('layoutsShard — sh3.layout.delete', () => {
182
+ beforeEach(async () => {
183
+ resetFramework();
184
+ __resetActionsRegistryForTest();
185
+ __resetForTests();
186
+ registerShard(stubShard);
187
+ addAutostartShard(stubShard.manifest.id);
188
+ await activateShard(stubShard.manifest.id);
189
+ registerShard(layoutsShard);
190
+ addAutostartShard(layoutsShard.manifest.id);
191
+ await activateShard(layoutsShard.manifest.id);
192
+ });
193
+ it('removes the layout record and unregisters its palette child', async () => {
194
+ const { __testSaveLayout, __testDelete } = await import('./layoutsShard.svelte');
195
+ const sourceId = floatManager.openWithContent({
196
+ content: {
197
+ type: 'tabs',
198
+ activeTab: 0,
199
+ tabs: [{ slotId: 's', viewId: 'shell:terminal', label: 'Shell' }],
200
+ },
201
+ size: { w: 600, h: 400 },
202
+ });
203
+ const layout = __testSaveLayout(sourceId, 'Doomed');
204
+ __testDelete(layout.id);
205
+ expect(getLayouts().find((l) => l.id === layout.id)).toBeUndefined();
206
+ const { listActions } = await import('../actions/registry');
207
+ expect(listActions().find((e) => e.action.id === `sh3.layout.open:${layout.id}`)).toBeUndefined();
208
+ });
209
+ it('registers sh3.layout.delete against element:saved-layout', async () => {
210
+ const { listActions } = await import('../actions/registry');
211
+ const entry = listActions().find((e) => e.action.id === 'sh3.layout.delete');
212
+ expect(entry).toBeDefined();
213
+ expect(JSON.stringify(entry.action.scope)).toContain('saved-layout');
214
+ });
215
+ });
@@ -0,0 +1,9 @@
1
+ import type { StateZones } from '../state/zones.svelte';
2
+ import type { LayoutsZoneSchema, SavedLayout } from './types';
3
+ export declare function __bindZone(s: StateZones<LayoutsZoneSchema>): void;
4
+ export declare function __unbindZone(): void;
5
+ export declare function getLayouts(): SavedLayout[];
6
+ export declare function addLayout(layout: SavedLayout): void;
7
+ export declare function updateLayout(id: string, patch: Partial<SavedLayout>): void;
8
+ export declare function deleteLayout(id: string): void;
9
+ export declare function __resetForTests(): void;
@@ -0,0 +1,50 @@
1
+ /*
2
+ * Reactive store for the layouts shard's user-zone proxy. Lives apart from
3
+ * the shard so unit tests can exercise CRUD without booting a real
4
+ * ShardContext, mirroring the pattern in app-appearance/appearanceState.
5
+ *
6
+ * Production: __bindZone() in layoutsShard.activate() swaps the memory
7
+ * shim for the live createStateZones-backed user proxy. Reactivity flows
8
+ * through the underlying $state — readers (LayoutsSection, palette
9
+ * children) re-derive when the array changes.
10
+ */
11
+ let zoneState = null;
12
+ export function __bindZone(s) {
13
+ zoneState = s;
14
+ }
15
+ export function __unbindZone() {
16
+ zoneState = null;
17
+ }
18
+ export function getLayouts() {
19
+ var _a;
20
+ return (_a = zoneState === null || zoneState === void 0 ? void 0 : zoneState.user.layouts) !== null && _a !== void 0 ? _a : [];
21
+ }
22
+ export function addLayout(layout) {
23
+ if (!zoneState)
24
+ return;
25
+ zoneState.user.layouts = [...zoneState.user.layouts, layout];
26
+ }
27
+ export function updateLayout(id, patch) {
28
+ if (!zoneState)
29
+ return;
30
+ const idx = zoneState.user.layouts.findIndex((l) => l.id === id);
31
+ if (idx < 0)
32
+ return;
33
+ const next = [...zoneState.user.layouts];
34
+ next[idx] = Object.assign(Object.assign({}, next[idx]), patch);
35
+ zoneState.user.layouts = next;
36
+ }
37
+ export function deleteLayout(id) {
38
+ if (!zoneState)
39
+ return;
40
+ zoneState.user.layouts = zoneState.user.layouts.filter((l) => l.id !== id);
41
+ }
42
+ export function __resetForTests() {
43
+ zoneState = {
44
+ ephemeral: {},
45
+ session: {},
46
+ workspace: {},
47
+ user: { layouts: [] },
48
+ };
49
+ }
50
+ __resetForTests();
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,43 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import { getLayouts, addLayout, updateLayout, deleteLayout, __resetForTests, } from './layoutsState.svelte';
3
+ function mk(id, name = id) {
4
+ return {
5
+ id,
6
+ name,
7
+ createdAt: 0,
8
+ size: { w: 600, h: 400 },
9
+ content: { type: 'slot', slotId: id, viewId: 'shell:terminal' },
10
+ };
11
+ }
12
+ describe('layoutsState', () => {
13
+ beforeEach(() => __resetForTests());
14
+ it('returns an empty list initially', () => {
15
+ expect(getLayouts()).toEqual([]);
16
+ });
17
+ it('addLayout appends', () => {
18
+ addLayout(mk('a'));
19
+ addLayout(mk('b'));
20
+ expect(getLayouts().map((l) => l.id)).toEqual(['a', 'b']);
21
+ });
22
+ it('updateLayout merges fields by id', () => {
23
+ var _a;
24
+ addLayout(mk('a', 'original'));
25
+ updateLayout('a', { name: 'renamed', appearance: { color: '#abc' } });
26
+ const after = getLayouts()[0];
27
+ expect(after.name).toBe('renamed');
28
+ expect((_a = after.appearance) === null || _a === void 0 ? void 0 : _a.color).toBe('#abc');
29
+ // Untouched fields preserved
30
+ expect(after.size).toEqual({ w: 600, h: 400 });
31
+ });
32
+ it('deleteLayout removes by id', () => {
33
+ addLayout(mk('a'));
34
+ addLayout(mk('b'));
35
+ deleteLayout('a');
36
+ expect(getLayouts().map((l) => l.id)).toEqual(['b']);
37
+ });
38
+ it('updateLayout is a no-op for an unknown id', () => {
39
+ addLayout(mk('a'));
40
+ updateLayout('nope', { name: 'x' });
41
+ expect(getLayouts()[0].name).toBe('a');
42
+ });
43
+ });
@@ -0,0 +1,21 @@
1
+ import type { LayoutNode } from '../layout/types';
2
+ export interface SavedLayoutAppearance {
3
+ color?: string;
4
+ icon?: string;
5
+ }
6
+ export interface SavedLayout {
7
+ id: string;
8
+ name: string;
9
+ createdAt: number;
10
+ appearance?: SavedLayoutAppearance;
11
+ size: {
12
+ w: number;
13
+ h: number;
14
+ };
15
+ content: LayoutNode;
16
+ }
17
+ export interface LayoutsZoneSchema {
18
+ user: {
19
+ layouts: SavedLayout[];
20
+ };
21
+ }
@@ -0,0 +1,6 @@
1
+ /*
2
+ * SavedLayout — capture-and-restore record for a free-form view
3
+ * arrangement built inside a float. See
4
+ * docs/superpowers/specs/2026-05-08-saved-layouts-design.md.
5
+ */
6
+ export {};