sh3-core 0.7.1 → 0.7.3

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 CHANGED
@@ -20,7 +20,7 @@
20
20
  import type { OverlayLayer } from './overlays/types';
21
21
  import { registerLayerRoot, unregisterLayerRoot } from './overlays/roots';
22
22
  import { bindFloatStore, unbindFloatStore, floatManager } from './overlays/float';
23
- import { returnToHome, isAdmin } from './api';
23
+ import { returnToHome, isAdmin, focusView } from './api';
24
24
  import { getActiveRoot, layoutStore } from './layout/store.svelte';
25
25
  import { isAuthenticated, isLocalOwner, getUser, logout } from './auth/index';
26
26
  import iconsUrl from './assets/icons.svg';
@@ -82,7 +82,8 @@
82
82
  ) return;
83
83
  }
84
84
  e.preventDefault();
85
- floatManager.open('shell:terminal', { title: 'Shell' });
85
+ if (!focusView('shell:terminal'))
86
+ floatManager.open('shell:terminal', { title: 'Shell' });
86
87
  }
87
88
  window.addEventListener('keydown', onKeyDown);
88
89
  return () => window.removeEventListener('keydown', onKeyDown);
@@ -8,12 +8,22 @@ export declare const contract: {
8
8
  * listed in shardImports or hostImports is illegal. */
9
9
  readonly packagePrefix: "sh3-core";
10
10
  readonly shard: {
11
+ /** Fields external authors must declare. */
12
+ readonly sourceRequiredFields: readonly ["id", "label", "views"];
13
+ /** Fields the framework stamps at load time — must NOT appear in source. */
14
+ readonly runtimeRequiredFields: readonly ["version"];
15
+ /** Union of both — the full runtime shape. Kept for backward compat. */
11
16
  readonly requiredFields: readonly ["id", "label", "version", "views"];
12
17
  readonly views: {
13
18
  readonly requiredFields: readonly ["id", "label"];
14
19
  };
15
20
  };
16
21
  readonly app: {
22
+ /** Fields external authors must declare. */
23
+ readonly sourceRequiredFields: readonly ["id", "label", "requiredShards", "layoutVersion"];
24
+ /** Fields the framework stamps at load time — must NOT appear in source. */
25
+ readonly runtimeRequiredFields: readonly ["version"];
26
+ /** Union of both — the full runtime shape. Kept for backward compat. */
17
27
  readonly requiredFields: readonly ["id", "label", "version", "requiredShards", "layoutVersion"];
18
28
  };
19
29
  };
package/dist/contract.js CHANGED
@@ -17,12 +17,22 @@ export const contract = {
17
17
  * listed in shardImports or hostImports is illegal. */
18
18
  packagePrefix: 'sh3-core',
19
19
  shard: {
20
+ /** Fields external authors must declare. */
21
+ sourceRequiredFields: ['id', 'label', 'views'],
22
+ /** Fields the framework stamps at load time — must NOT appear in source. */
23
+ runtimeRequiredFields: ['version'],
24
+ /** Union of both — the full runtime shape. Kept for backward compat. */
20
25
  requiredFields: ['id', 'label', 'version', 'views'],
21
26
  views: {
22
27
  requiredFields: ['id', 'label'],
23
28
  },
24
29
  },
25
30
  app: {
31
+ /** Fields external authors must declare. */
32
+ sourceRequiredFields: ['id', 'label', 'requiredShards', 'layoutVersion'],
33
+ /** Fields the framework stamps at load time — must NOT appear in source. */
34
+ runtimeRequiredFields: ['version'],
35
+ /** Union of both — the full runtime shape. Kept for backward compat. */
26
36
  requiredFields: ['id', 'label', 'version', 'requiredShards', 'layoutVersion'],
27
37
  },
28
38
  };
@@ -15,6 +15,7 @@
15
15
  import { activeLayout, getActiveRoot } from './store.svelte';
16
16
  import { nodeAtPath, findTabBySlotId, removeTabBySlotId, cleanupTree, splitNodeAtPath, } from './ops';
17
17
  import { getSlotHandle } from './slotHostPool.svelte';
18
+ import { floatManager } from '../overlays/float';
18
19
  /**
19
20
  * Read-only snapshot of the currently-rendered layout tree. The return
20
21
  * value is the live object — callers MUST NOT mutate it directly;
@@ -46,16 +47,20 @@ export function spliceIntoActiveLayout(entry) {
46
47
  * layout. Returns `true` if a matching tab was found and activated.
47
48
  */
48
49
  export function focusTab(slotId) {
49
- const root = activeLayout().docked;
50
- return focusTabWhere(root, (entry) => entry.slotId === slotId);
50
+ const tree = activeLayout();
51
+ if (focusTabWhere(tree.docked, (entry) => entry.slotId === slotId))
52
+ return true;
53
+ return focusTabInFloats(tree, (entry) => entry.slotId === slotId);
51
54
  }
52
55
  /**
53
56
  * Activate the first tab whose `viewId` matches in the currently-rendered
54
57
  * layout. Returns `true` if a matching tab was found and activated.
55
58
  */
56
59
  export function focusView(viewId) {
57
- const root = activeLayout().docked;
58
- return focusTabWhere(root, (entry) => entry.viewId === viewId);
60
+ const tree = activeLayout();
61
+ if (focusTabWhere(tree.docked, (entry) => entry.viewId === viewId))
62
+ return true;
63
+ return focusTabInFloats(tree, (entry) => entry.viewId === viewId);
59
64
  }
60
65
  /** Walk the tree looking for a tab entry that satisfies `pred`, activate it. */
61
66
  function focusTabWhere(node, pred) {
@@ -75,6 +80,16 @@ function focusTabWhere(node, pred) {
75
80
  }
76
81
  return false;
77
82
  }
83
+ /** Search floats for a matching tab; activate it and raise the float. */
84
+ function focusTabInFloats(tree, pred) {
85
+ for (const floatEntry of tree.floats) {
86
+ if (focusTabWhere(floatEntry.content, pred)) {
87
+ floatManager.focus(floatEntry.id);
88
+ return true;
89
+ }
90
+ }
91
+ return false;
92
+ }
78
93
  /**
79
94
  * Collapse a child of a split node at the given path. Returns true if
80
95
  * the split was found and the child was collapsed.
@@ -115,10 +130,13 @@ function setCollapsed(splitPath, childIndex, value) {
115
130
  * the sole authority on tree mutations.
116
131
  */
117
132
  export async function closeTab(slotId) {
118
- const root = activeLayout().docked;
133
+ const tree = activeLayout();
134
+ const root = tree.docked;
119
135
  const located = findTabBySlotId(root, slotId);
120
- if (!located)
121
- return false;
136
+ // Not found in docked tree — check floats.
137
+ if (!located) {
138
+ return closeFloatTab(tree, slotId);
139
+ }
122
140
  const handle = getSlotHandle(slotId);
123
141
  const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
124
142
  // Non-closable: no action.
@@ -143,6 +161,39 @@ export async function closeTab(slotId) {
143
161
  cleanupTree(root);
144
162
  return true;
145
163
  }
164
+ /**
165
+ * Close a tab that lives inside a float entry. Float tabs are
166
+ * auto-closable (views gain closability when mounted in a float) but
167
+ * guarded canClose() on the view handle is still respected.
168
+ * Closing the last tab in a float removes the entire float.
169
+ */
170
+ async function closeFloatTab(tree, slotId) {
171
+ for (const entry of tree.floats) {
172
+ const located = findTabBySlotId(entry.content, slotId);
173
+ if (!located)
174
+ continue;
175
+ // Respect guarded canClose() if the view declared one.
176
+ const handle = getSlotHandle(slotId);
177
+ const closable = handle === null || handle === void 0 ? void 0 : handle.closable;
178
+ if (typeof closable === 'object') {
179
+ const allowed = await closable.canClose();
180
+ if (!allowed)
181
+ return false;
182
+ // Re-verify after async gap.
183
+ if (!findTabBySlotId(entry.content, slotId))
184
+ return false;
185
+ }
186
+ // Remove the tab from the float's content tree.
187
+ removeTabBySlotId(entry.content, slotId);
188
+ // If the float's content is now empty, remove the entire float.
189
+ const tabs = entry.content.type === 'tabs' ? entry.content : null;
190
+ if (!tabs || tabs.tabs.length === 0) {
191
+ floatManager.close(entry.id);
192
+ }
193
+ return true;
194
+ }
195
+ return false;
196
+ }
146
197
  function findFirstTabsNode(node) {
147
198
  if (node.type === 'tabs')
148
199
  return node;
@@ -195,6 +195,24 @@ export function makeSplitWithNewTab(existing, entry, side) {
195
195
  children,
196
196
  };
197
197
  }
198
+ /**
199
+ * Shallow-clone a layout node. Used by splitNodeAtPath's root case to
200
+ * break the circular reference that would occur if the root object
201
+ * (mutated in-place) were embedded as its own child.
202
+ */
203
+ function snapshotNode(node) {
204
+ if (node.type === 'tabs')
205
+ return Object.assign(Object.assign({}, node), { tabs: [...node.tabs] });
206
+ if (node.type === 'split') {
207
+ const clone = Object.assign(Object.assign({}, node), { children: [...node.children], sizes: [...node.sizes] });
208
+ if (node.pinned)
209
+ clone.pinned = [...node.pinned];
210
+ if (node.collapsed)
211
+ clone.collapsed = [...node.collapsed];
212
+ return clone;
213
+ }
214
+ return Object.assign({}, node); // slot — plain shallow copy
215
+ }
198
216
  /**
199
217
  * Apply a slot-split as a tree mutation: find the target node at the
200
218
  * given path and replace it with a new split. Handles the root case
@@ -205,14 +223,13 @@ export function splitNodeAtPath(root, path, entry, side) {
205
223
  const target = nodeAtPath(root, path);
206
224
  if (!target)
207
225
  return;
208
- const replacement = makeSplitWithNewTab(target, entry, side);
209
226
  if (path.length === 0) {
210
- // Replace root contents in place. The root is a discriminated
211
- // union; to rewrite it we cast once through `unknown` and then to
212
- // a loose record shape, overwrite fields, and clear stale keys
213
- // from the previous node kind so the union stays well-formed.
227
+ // Root case: target IS root. Snapshot it so makeSplitWithNewTab
228
+ // embeds the clone, not root itself avoids a circular reference
229
+ // when we Object.assign the replacement back onto root.
230
+ const snapshot = snapshotNode(target);
231
+ const replacement = makeSplitWithNewTab(snapshot, entry, side);
214
232
  const rootAsRecord = root;
215
- // Clear stale keys first so Object.assign doesn't leave a hybrid.
216
233
  delete rootAsRecord.tabs;
217
234
  delete rootAsRecord.activeTab;
218
235
  delete rootAsRecord.slotId;
@@ -220,10 +237,12 @@ export function splitNodeAtPath(root, path, entry, side) {
220
237
  delete rootAsRecord.direction;
221
238
  delete rootAsRecord.sizes;
222
239
  delete rootAsRecord.pinned;
240
+ delete rootAsRecord.collapsed;
223
241
  delete rootAsRecord.children;
224
242
  Object.assign(rootAsRecord, replacement);
225
243
  return;
226
244
  }
245
+ const replacement = makeSplitWithNewTab(target, entry, side);
227
246
  const parentPath = path.slice(0, -1);
228
247
  const indexInParent = path[path.length - 1];
229
248
  const parent = nodeAtPath(root, parentPath);
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { findTabInTree } from './ops';
2
+ import { findTabInTree, splitNodeAtPath, cleanupTree, findTabBySlotId } from './ops';
3
3
  describe('findTabInTree', () => {
4
4
  const tree = {
5
5
  docked: {
@@ -34,3 +34,53 @@ describe('findTabInTree', () => {
34
34
  expect(findTabInTree(tree, 'nonexistent')).toBeNull();
35
35
  });
36
36
  });
37
+ describe('splitNodeAtPath — root case (path = [])', () => {
38
+ it('does not create a circular reference when splitting the root', () => {
39
+ // splitNodeAtPath mutates root in-place (tabs → split). Cast to
40
+ // LayoutNode so TS doesn't narrow from the initializer.
41
+ const root = {
42
+ type: 'tabs',
43
+ tabs: [{ slotId: 's1', viewId: 'v1', label: 'One' }],
44
+ activeTab: 0,
45
+ };
46
+ const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
47
+ splitNodeAtPath(root, [], entry, 'right');
48
+ // After split, root should be a split node with two children.
49
+ // Neither child should be root itself (no circular ref).
50
+ expect(root.type).toBe('split');
51
+ if (root.type === 'split') {
52
+ for (const child of root.children) {
53
+ expect(child).not.toBe(root);
54
+ }
55
+ }
56
+ });
57
+ it('cleanupTree does not infinite-loop after splitting the root', () => {
58
+ const root = {
59
+ type: 'tabs',
60
+ tabs: [{ slotId: 's1', viewId: 'v1', label: 'One' }],
61
+ activeTab: 0,
62
+ };
63
+ const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
64
+ splitNodeAtPath(root, [], entry, 'right');
65
+ // This must terminate. Before the fix it infinite-loops.
66
+ cleanupTree(root);
67
+ // Both tabs should still be findable.
68
+ expect(findTabBySlotId(root, 's1')).not.toBeNull();
69
+ expect(findTabBySlotId(root, 's2')).not.toBeNull();
70
+ });
71
+ it('works when root is a slot leaf', () => {
72
+ const root = {
73
+ type: 'slot',
74
+ slotId: 'leaf',
75
+ viewId: 'v',
76
+ };
77
+ const entry = { slotId: 's2', viewId: 'v2', label: 'Two' };
78
+ splitNodeAtPath(root, [], entry, 'bottom');
79
+ expect(root.type).toBe('split');
80
+ if (root.type === 'split') {
81
+ for (const child of root.children) {
82
+ expect(child).not.toBe(root);
83
+ }
84
+ }
85
+ });
86
+ });
@@ -113,7 +113,7 @@ function createHost(slotId, viewId, label) {
113
113
  },
114
114
  };
115
115
  entry.handle = factory === null || factory === void 0 ? void 0 : factory.mount(host, ctx);
116
- if ((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) {
116
+ if (((_a = entry.handle) === null || _a === void 0 ? void 0 : _a.closable) || slotId.startsWith('float:')) {
117
117
  closableState[slotId] = true;
118
118
  }
119
119
  // The pool owns the ResizeObserver so its lifetime matches the
@@ -50,10 +50,10 @@ const HOME_LAYOUT = {
50
50
  slotId: 'sh3core.home',
51
51
  viewId: 'sh3core:home',
52
52
  };
53
- const HOME_TREE = {
53
+ const HOME_TREE = $state({
54
54
  docked: HOME_LAYOUT,
55
55
  floats: [],
56
- };
56
+ });
57
57
  let appEntry = $state(null);
58
58
  let activeRoot = $state('home');
59
59
  // ---------- read-side adapter helpers -------------------------------------
@@ -30,6 +30,7 @@
30
30
 
31
31
  function onHeaderPointerDown(e: PointerEvent): void {
32
32
  if (e.button !== 0) return;
33
+ if ((e.target as HTMLElement).closest('.sh3-float-close')) return;
33
34
  const target = e.currentTarget as HTMLElement;
34
35
  target.setPointerCapture(e.pointerId);
35
36
  dragging = true;
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.7.1";
2
+ export declare const VERSION = "0.7.3";
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.7.1';
2
+ export const VERSION = '0.7.3';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sh3-core",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "dist"