sh3-core 0.11.4 → 0.11.6
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/BrandSlot.svelte +80 -0
- package/dist/BrandSlot.svelte.d.ts +3 -0
- package/dist/BrandSlot.test.d.ts +1 -0
- package/dist/BrandSlot.test.js +71 -0
- package/dist/Shell.svelte +8 -10
- package/dist/actions/ActionPanel.svelte +105 -0
- package/dist/actions/ActionPanel.svelte.d.ts +13 -0
- package/dist/actions/ActionPanel.test.d.ts +1 -0
- package/dist/actions/ActionPanel.test.js +80 -0
- package/dist/actions/ContextMenu.svelte +17 -85
- package/dist/actions/MenuBar.svelte +57 -0
- package/dist/actions/MenuBar.svelte.d.ts +3 -0
- package/dist/actions/MenuBar.test.d.ts +1 -0
- package/dist/actions/MenuBar.test.js +109 -0
- package/dist/actions/MenuButton.svelte +104 -0
- package/dist/actions/MenuButton.svelte.d.ts +9 -0
- package/dist/actions/MenuButton.test.d.ts +1 -0
- package/dist/actions/MenuButton.test.js +88 -0
- package/dist/actions/defaultMenuContainers.d.ts +2 -0
- package/dist/actions/defaultMenuContainers.js +7 -0
- package/dist/actions/defaultMenuContainers.test.d.ts +1 -0
- package/dist/actions/defaultMenuContainers.test.js +23 -0
- package/dist/actions/menuBarModel.d.ts +28 -0
- package/dist/actions/menuBarModel.js +67 -0
- package/dist/actions/menuBarModel.test.d.ts +1 -0
- package/dist/actions/menuBarModel.test.js +84 -0
- package/dist/actions/types.d.ts +8 -0
- package/dist/apps/lifecycle.js +8 -1
- package/dist/apps/lifecycle.test.js +211 -1
- package/dist/apps/registry.svelte.d.ts +17 -1
- package/dist/apps/registry.svelte.js +20 -1
- package/dist/apps/types.d.ts +28 -0
- package/dist/layout/store.svelte.d.ts +27 -0
- package/dist/layout/store.svelte.js +63 -0
- package/dist/overlays/ConfirmDialog.svelte +138 -0
- package/dist/overlays/ConfirmDialog.svelte.d.ts +13 -0
- package/dist/overlays/ConfirmDialog.test.d.ts +1 -0
- package/dist/overlays/ConfirmDialog.test.js +123 -0
- package/dist/overlays/FloatFrame.svelte +2 -2
- package/dist/overlays/ToastItem.svelte +3 -3
- package/dist/primitives/base.css +5 -5
- package/dist/sh3core-shard/sh3coreShard.svelte.js +20 -0
- package/dist/shell-shard/shellShard.svelte.js +0 -4
- package/dist/tokens.css +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -5,7 +5,7 @@ import { launchApp, returnToHome, unregisterApp } from './lifecycle';
|
|
|
5
5
|
import { registerApp } from './registry.svelte';
|
|
6
6
|
import { registerShard } from '../shards/activate.svelte';
|
|
7
7
|
import { presetManager } from '../overlays/presets';
|
|
8
|
-
import { layoutStore } from '../layout/store.svelte';
|
|
8
|
+
import { layoutStore, resetActivePresetToDefault } from '../layout/store.svelte';
|
|
9
9
|
import LayoutRenderer from '../layout/LayoutRenderer.svelte';
|
|
10
10
|
import { renderWithShell } from '../__test__/render';
|
|
11
11
|
import { registerView } from '../shards/registry';
|
|
@@ -307,3 +307,213 @@ describe('installPackage evict-before-register (simulated via registerLoadedBund
|
|
|
307
307
|
expect((_b = registeredShards.get('S')) === null || _b === void 0 ? void 0 : _b.manifest.version).toBe('1.0.1');
|
|
308
308
|
});
|
|
309
309
|
});
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// Scenario C.1 — resetActivePresetToDefault rebuilds the active preset
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
describe('resetActivePresetToDefault — scenario C.1 happy path', () => {
|
|
314
|
+
beforeEach(resetFramework);
|
|
315
|
+
it('replaces the active preset tree with a fresh copy of initialLayout', async () => {
|
|
316
|
+
var _a;
|
|
317
|
+
registerApp(makeApp({
|
|
318
|
+
manifest: makeAppManifest({ id: 'reset-1' }),
|
|
319
|
+
initialLayout: [
|
|
320
|
+
{
|
|
321
|
+
name: 'main',
|
|
322
|
+
tree: makeTree(makeTabsNode([
|
|
323
|
+
makeTabEntry({ slotId: 'a', label: 'A' }),
|
|
324
|
+
makeTabEntry({ slotId: 'b', label: 'B' }),
|
|
325
|
+
])),
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
}));
|
|
329
|
+
await launchApp('reset-1');
|
|
330
|
+
// Mutate the live tree: change activeTab and add a float.
|
|
331
|
+
const root = layoutStore.root;
|
|
332
|
+
expect(root === null || root === void 0 ? void 0 : root.type).toBe('tabs');
|
|
333
|
+
if ((root === null || root === void 0 ? void 0 : root.type) === 'tabs')
|
|
334
|
+
root.activeTab = 1;
|
|
335
|
+
layoutStore.tree.floats.push({
|
|
336
|
+
id: 'float-test',
|
|
337
|
+
content: makeSlotNode('floated'),
|
|
338
|
+
position: { x: 10, y: 10 },
|
|
339
|
+
size: { w: 100, h: 100 },
|
|
340
|
+
});
|
|
341
|
+
expect(layoutStore.tree.floats.length).toBe(1);
|
|
342
|
+
resetActivePresetToDefault();
|
|
343
|
+
const resetRoot = layoutStore.root;
|
|
344
|
+
expect(resetRoot === null || resetRoot === void 0 ? void 0 : resetRoot.type).toBe('tabs');
|
|
345
|
+
if ((resetRoot === null || resetRoot === void 0 ? void 0 : resetRoot.type) === 'tabs') {
|
|
346
|
+
expect((_a = resetRoot.activeTab) !== null && _a !== void 0 ? _a : 0).toBe(0);
|
|
347
|
+
expect(resetRoot.tabs.map((t) => t.slotId)).toEqual(['a', 'b']);
|
|
348
|
+
}
|
|
349
|
+
expect(layoutStore.tree.floats.length).toBe(0);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Scenario C.2 — reset falls back when active preset name no longer exists
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
describe('resetActivePresetToDefault — scenario C.2 missing-preset fallback', () => {
|
|
356
|
+
beforeEach(resetFramework);
|
|
357
|
+
it('falls back to canonical[0] and updates activePreset when the stored name is gone', async () => {
|
|
358
|
+
// Launch with two presets, switch to the second, then simulate the app
|
|
359
|
+
// dropping that preset by re-registering it with a different layout.
|
|
360
|
+
registerApp(makeApp({
|
|
361
|
+
manifest: makeAppManifest({ id: 'reset-2' }),
|
|
362
|
+
initialLayout: [
|
|
363
|
+
{ name: 'first', tree: makeTree(makeSlotNode('x')) },
|
|
364
|
+
{ name: 'second', tree: makeTree(makeSlotNode('y')) },
|
|
365
|
+
],
|
|
366
|
+
}));
|
|
367
|
+
await launchApp('reset-2');
|
|
368
|
+
presetManager.switch('second');
|
|
369
|
+
expect(presetManager.active()).toBe('second');
|
|
370
|
+
// Replace the registry entry with a version that no longer declares
|
|
371
|
+
// 'second'. We mutate registeredApps directly because the public
|
|
372
|
+
// `unregisterApp` would also detach. This mimics the in-process
|
|
373
|
+
// version-update scenario for the reset code path.
|
|
374
|
+
const { registeredApps } = await import('./registry.svelte');
|
|
375
|
+
const existing = registeredApps.get('reset-2');
|
|
376
|
+
registeredApps.set('reset-2', Object.assign(Object.assign({}, existing), { initialLayout: [
|
|
377
|
+
{ name: 'first', tree: makeTree(makeSlotNode('x')) },
|
|
378
|
+
// 'second' removed
|
|
379
|
+
] }));
|
|
380
|
+
resetActivePresetToDefault();
|
|
381
|
+
expect(presetManager.active()).toBe('first');
|
|
382
|
+
// The previously-active 'second' preset is still in the blob (we
|
|
383
|
+
// don't garbage-collect dropped presets); only its name was vacated.
|
|
384
|
+
expect(layoutStore.root).toMatchObject({ type: 'slot', slotId: 'x' });
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
// ---------------------------------------------------------------------------
|
|
388
|
+
// Scenario C.3 — reset throws when no app is attached
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
describe('resetActivePresetToDefault — scenario C.3 no app attached', () => {
|
|
391
|
+
beforeEach(resetFramework);
|
|
392
|
+
it('throws a useful error when called with no attached app', () => {
|
|
393
|
+
expect(() => resetActivePresetToDefault()).toThrow(/no app attached/);
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
// ---------------------------------------------------------------------------
|
|
397
|
+
// Scenario C.4 — reset re-scopes slot-host refcount holds
|
|
398
|
+
// ---------------------------------------------------------------------------
|
|
399
|
+
describe('resetActivePresetToDefault — scenario C.4 slot-host hold accounting', () => {
|
|
400
|
+
beforeEach(resetFramework);
|
|
401
|
+
it('updates appEntry.heldSlotIds to match the rebuilt tree', async () => {
|
|
402
|
+
var _a, _b;
|
|
403
|
+
const { __inspectAppEntryHeldSlotIdsForTest } = await import('../layout/store.svelte');
|
|
404
|
+
registerApp(makeApp({
|
|
405
|
+
manifest: makeAppManifest({ id: 'reset-3' }),
|
|
406
|
+
initialLayout: [
|
|
407
|
+
{
|
|
408
|
+
name: 'main',
|
|
409
|
+
tree: makeTree(makeTabsNode([
|
|
410
|
+
makeTabEntry({ slotId: 't-a', label: 'A' }),
|
|
411
|
+
makeTabEntry({ slotId: 't-b', label: 'B' }),
|
|
412
|
+
])),
|
|
413
|
+
},
|
|
414
|
+
],
|
|
415
|
+
}));
|
|
416
|
+
await launchApp('reset-3');
|
|
417
|
+
expect((_a = __inspectAppEntryHeldSlotIdsForTest()) === null || _a === void 0 ? void 0 : _a.slice().sort()).toEqual(['t-a', 't-b']);
|
|
418
|
+
// Mutate the live tree by replacing the docked node with a different
|
|
419
|
+
// single slot. After reset, the held set must return to the default
|
|
420
|
+
// pair, which proves the old single hold was released and the
|
|
421
|
+
// canonical pair was re-acquired.
|
|
422
|
+
layoutStore.tree.docked = makeSlotNode('mutated');
|
|
423
|
+
resetActivePresetToDefault();
|
|
424
|
+
expect((_b = __inspectAppEntryHeldSlotIdsForTest()) === null || _b === void 0 ? void 0 : _b.slice().sort()).toEqual(['t-a', 't-b']);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
// Scenario C.5 — sh3.app.reset-layout is registered with the right shape
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
describe('sh3coreShard — sh3.app.reset-layout registration', () => {
|
|
431
|
+
beforeEach(resetFramework);
|
|
432
|
+
it('exposes a "Reset Current Layout" action scoped to app, palette-only', async () => {
|
|
433
|
+
// Register and activate the sh3core pseudo-shard the same way bootstrap()
|
|
434
|
+
// would — tests don't run bootstrap, so the shard's actions wouldn't
|
|
435
|
+
// otherwise be present.
|
|
436
|
+
const { sh3coreShard } = await import('../sh3core-shard/sh3coreShard.svelte');
|
|
437
|
+
const { activateShard } = await import('../shards/activate.svelte');
|
|
438
|
+
const { addAutostartShard } = await import('../actions/state.svelte');
|
|
439
|
+
registerShard(sh3coreShard);
|
|
440
|
+
// Mirror what bootstrap() does: mark the framework shard as autostart so
|
|
441
|
+
// its 'app'-scope actions are treated as ambient inside any app.
|
|
442
|
+
addAutostartShard(sh3coreShard.manifest.id);
|
|
443
|
+
await activateShard(sh3coreShard.manifest.id);
|
|
444
|
+
registerApp(makeApp({
|
|
445
|
+
manifest: makeAppManifest({ id: 'reset-action-1' }),
|
|
446
|
+
initialLayout: [{ name: 'main', tree: makeTree(makeSlotNode('x')) }],
|
|
447
|
+
}));
|
|
448
|
+
await launchApp('reset-action-1');
|
|
449
|
+
const { shell } = await import('../shellRuntime.svelte');
|
|
450
|
+
const actions = shell.actions.listActive();
|
|
451
|
+
const reset = actions.find((a) => a.id === 'sh3.app.reset-layout');
|
|
452
|
+
expect(reset).toBeDefined();
|
|
453
|
+
expect(reset === null || reset === void 0 ? void 0 : reset.label).toBe('Reset Current Layout');
|
|
454
|
+
expect(reset === null || reset === void 0 ? void 0 : reset.paletteItem).toBe(true);
|
|
455
|
+
expect(reset === null || reset === void 0 ? void 0 : reset.contextItem).toBe(false);
|
|
456
|
+
expect(reset === null || reset === void 0 ? void 0 : reset.effectiveShortcut).toBeNull();
|
|
457
|
+
});
|
|
458
|
+
it('hides "app"-scope actions after returnToHome', async () => {
|
|
459
|
+
const { sh3coreShard } = await import('../sh3core-shard/sh3coreShard.svelte');
|
|
460
|
+
const { activateShard } = await import('../shards/activate.svelte');
|
|
461
|
+
const { addAutostartShard } = await import('../actions/state.svelte');
|
|
462
|
+
registerShard(sh3coreShard);
|
|
463
|
+
addAutostartShard(sh3coreShard.manifest.id);
|
|
464
|
+
await activateShard(sh3coreShard.manifest.id);
|
|
465
|
+
registerApp(makeApp({
|
|
466
|
+
manifest: makeAppManifest({ id: 'reset-action-2' }),
|
|
467
|
+
initialLayout: [{ name: 'main', tree: makeTree(makeSlotNode('x')) }],
|
|
468
|
+
}));
|
|
469
|
+
await launchApp('reset-action-2');
|
|
470
|
+
const { shell } = await import('../shellRuntime.svelte');
|
|
471
|
+
expect(shell.actions.listActive().some((a) => a.id === 'sh3.app.reset-layout')).toBe(true);
|
|
472
|
+
await returnToHome();
|
|
473
|
+
expect(shell.actions.listActive().some((a) => a.id === 'sh3.app.reset-layout')).toBe(false);
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
// ---------------------------------------------------------------------------
|
|
477
|
+
// breadcrumbAppId — lingers across returnToHome (distinct from lastAppState)
|
|
478
|
+
// ---------------------------------------------------------------------------
|
|
479
|
+
describe('breadcrumbAppId', () => {
|
|
480
|
+
beforeEach(resetFramework);
|
|
481
|
+
it('starts null when no app has launched this session', async () => {
|
|
482
|
+
const { getBreadcrumbAppId } = await import('./registry.svelte');
|
|
483
|
+
expect(getBreadcrumbAppId()).toBeNull();
|
|
484
|
+
});
|
|
485
|
+
it('is set to the launched app id', async () => {
|
|
486
|
+
const { getBreadcrumbAppId } = await import('./registry.svelte');
|
|
487
|
+
registerShard(makeShard({ manifest: makeShardManifest({ id: 'shard-A' }) }));
|
|
488
|
+
registerApp(makeApp({
|
|
489
|
+
manifest: makeAppManifest({ id: 'app-bc1', requiredShards: ['shard-A'] }),
|
|
490
|
+
}));
|
|
491
|
+
await launchApp('app-bc1');
|
|
492
|
+
expect(getBreadcrumbAppId()).toBe('app-bc1');
|
|
493
|
+
});
|
|
494
|
+
it('lingers across returnToHome (does NOT clear like lastAppState)', async () => {
|
|
495
|
+
const { getBreadcrumbAppId } = await import('./registry.svelte');
|
|
496
|
+
const { readLastApp } = await import('./lifecycle');
|
|
497
|
+
registerShard(makeShard({ manifest: makeShardManifest({ id: 'shard-A' }) }));
|
|
498
|
+
registerApp(makeApp({
|
|
499
|
+
manifest: makeAppManifest({ id: 'app-bc2', requiredShards: ['shard-A'] }),
|
|
500
|
+
}));
|
|
501
|
+
await launchApp('app-bc2');
|
|
502
|
+
await returnToHome();
|
|
503
|
+
expect(getBreadcrumbAppId()).toBe('app-bc2');
|
|
504
|
+
expect(readLastApp()).toBeNull();
|
|
505
|
+
});
|
|
506
|
+
it('overwrites on a subsequent launch', async () => {
|
|
507
|
+
const { getBreadcrumbAppId } = await import('./registry.svelte');
|
|
508
|
+
registerShard(makeShard({ manifest: makeShardManifest({ id: 'shard-A' }) }));
|
|
509
|
+
registerApp(makeApp({
|
|
510
|
+
manifest: makeAppManifest({ id: 'app-bc3a', requiredShards: ['shard-A'] }),
|
|
511
|
+
}));
|
|
512
|
+
registerApp(makeApp({
|
|
513
|
+
manifest: makeAppManifest({ id: 'app-bc3b', requiredShards: ['shard-A'] }),
|
|
514
|
+
}));
|
|
515
|
+
await launchApp('app-bc3a');
|
|
516
|
+
await launchApp('app-bc3b');
|
|
517
|
+
expect(getBreadcrumbAppId()).toBe('app-bc3b');
|
|
518
|
+
});
|
|
519
|
+
});
|
|
@@ -13,6 +13,22 @@ export declare const registeredApps: Map<string, App>;
|
|
|
13
13
|
export declare const activeApp: {
|
|
14
14
|
id: string | null;
|
|
15
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* Most recently launched app id this session. Distinct from
|
|
18
|
+
* `lastAppState` in `lifecycle.ts` (which is persisted to the user zone
|
|
19
|
+
* for boot, and cleared by `returnToHome`). The breadcrumb pointer
|
|
20
|
+
* survives `returnToHome` so the top-bar BrandSlot can render
|
|
21
|
+
* `SH3 > [App Name]` and let the user one-click back into the app.
|
|
22
|
+
*
|
|
23
|
+
* In-memory only — page reload resets it to null. No persistence.
|
|
24
|
+
*/
|
|
25
|
+
export declare const breadcrumbApp: {
|
|
26
|
+
id: string | null;
|
|
27
|
+
};
|
|
28
|
+
/** Read the breadcrumb app id. `null` when no app has launched this session. */
|
|
29
|
+
export declare function getBreadcrumbAppId(): string | null;
|
|
30
|
+
/** @internal — test helper; resets the breadcrumb to null. */
|
|
31
|
+
export declare function __resetBreadcrumbForTest(): void;
|
|
16
32
|
/**
|
|
17
33
|
* Register (or re-register) an app with the framework.
|
|
18
34
|
*
|
|
@@ -38,5 +54,5 @@ export declare function getActiveApp(): AppManifest | null;
|
|
|
38
54
|
* activate hook. Not re-exported through `api.ts`.
|
|
39
55
|
*/
|
|
40
56
|
export declare function getRegisteredApp(id: string): App | undefined;
|
|
41
|
-
/** Test-only reset: clear registered apps
|
|
57
|
+
/** Test-only reset: clear registered apps, active-app pointer, and breadcrumb. */
|
|
42
58
|
export declare function __resetAppRegistryForTest(): void;
|
|
@@ -20,6 +20,24 @@ export const registeredApps = $state(new Map());
|
|
|
20
20
|
* most one active app at a time.
|
|
21
21
|
*/
|
|
22
22
|
export const activeApp = $state({ id: null });
|
|
23
|
+
/**
|
|
24
|
+
* Most recently launched app id this session. Distinct from
|
|
25
|
+
* `lastAppState` in `lifecycle.ts` (which is persisted to the user zone
|
|
26
|
+
* for boot, and cleared by `returnToHome`). The breadcrumb pointer
|
|
27
|
+
* survives `returnToHome` so the top-bar BrandSlot can render
|
|
28
|
+
* `SH3 > [App Name]` and let the user one-click back into the app.
|
|
29
|
+
*
|
|
30
|
+
* In-memory only — page reload resets it to null. No persistence.
|
|
31
|
+
*/
|
|
32
|
+
export const breadcrumbApp = $state({ id: null });
|
|
33
|
+
/** Read the breadcrumb app id. `null` when no app has launched this session. */
|
|
34
|
+
export function getBreadcrumbAppId() {
|
|
35
|
+
return breadcrumbApp.id;
|
|
36
|
+
}
|
|
37
|
+
/** @internal — test helper; resets the breadcrumb to null. */
|
|
38
|
+
export function __resetBreadcrumbForTest() {
|
|
39
|
+
breadcrumbApp.id = null;
|
|
40
|
+
}
|
|
23
41
|
/**
|
|
24
42
|
* Register (or re-register) an app with the framework.
|
|
25
43
|
*
|
|
@@ -57,8 +75,9 @@ export function getActiveApp() {
|
|
|
57
75
|
export function getRegisteredApp(id) {
|
|
58
76
|
return registeredApps.get(id);
|
|
59
77
|
}
|
|
60
|
-
/** Test-only reset: clear registered apps
|
|
78
|
+
/** Test-only reset: clear registered apps, active-app pointer, and breadcrumb. */
|
|
61
79
|
export function __resetAppRegistryForTest() {
|
|
62
80
|
registeredApps.clear();
|
|
63
81
|
activeApp.id = null;
|
|
82
|
+
breadcrumbApp.id = null;
|
|
64
83
|
}
|
package/dist/apps/types.d.ts
CHANGED
|
@@ -1,6 +1,28 @@
|
|
|
1
1
|
import type { LayoutNode, LayoutTree, LayoutPreset } from '../layout/types';
|
|
2
2
|
import type { ZoneSchema, ZoneManager } from '../state/types';
|
|
3
3
|
import type { StateZones } from '../state/zones.svelte';
|
|
4
|
+
/**
|
|
5
|
+
* One menu bar container ("File", "Edit", etc.). Apps declare these in
|
|
6
|
+
* `AppManifest.menus`; when omitted, sh3-core uses DEFAULT_MENU_CONTAINERS.
|
|
7
|
+
* Items reach a container by setting `Action.menuItem` to its `id`.
|
|
8
|
+
*/
|
|
9
|
+
export interface MenuContainer {
|
|
10
|
+
/** Container id referenced by Action.menuItem. */
|
|
11
|
+
id: string;
|
|
12
|
+
/** Display label shown in the menu bar. */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Optional lucide icon name. */
|
|
15
|
+
icon?: string;
|
|
16
|
+
/** Icon placement relative to the label. Default: 'before'. */
|
|
17
|
+
iconPosition?: 'before' | 'after';
|
|
18
|
+
/**
|
|
19
|
+
* Optional explicit ordering. When omitted, declaration order in the
|
|
20
|
+
* `AppManifest.menus` array is used. Containers with `order` defined
|
|
21
|
+
* sort first (ascending); ties and undefined fall back to declaration
|
|
22
|
+
* order.
|
|
23
|
+
*/
|
|
24
|
+
order?: number;
|
|
25
|
+
}
|
|
4
26
|
/**
|
|
5
27
|
* Static description of an app as observed by the framework at runtime.
|
|
6
28
|
* `version` is always present here: externally installed apps have it
|
|
@@ -46,6 +68,12 @@ export interface AppManifest {
|
|
|
46
68
|
* in a later plan alongside the server-side sync runtime.
|
|
47
69
|
*/
|
|
48
70
|
permissions?: string[];
|
|
71
|
+
/**
|
|
72
|
+
* Optional menu bar container list. When present, fully replaces the
|
|
73
|
+
* canonical fallback (file, edit, view, window, help) for this app.
|
|
74
|
+
* When absent, the canonical fallback is used. See MenuContainer.
|
|
75
|
+
*/
|
|
76
|
+
menus?: MenuContainer[];
|
|
49
77
|
}
|
|
50
78
|
/**
|
|
51
79
|
* Context object passed to `App.activate`. Provides app-scoped state zones
|
|
@@ -23,6 +23,26 @@ export declare function attachApp(app: App): void;
|
|
|
23
23
|
* only and let renderers own all refcounts.
|
|
24
24
|
*/
|
|
25
25
|
export declare function acquireAppSlotHolds(): void;
|
|
26
|
+
/**
|
|
27
|
+
* Rebuild the currently-attached app's active preset from a fresh copy
|
|
28
|
+
* of `app.initialLayout`. Discards in-place customizations (split sizes,
|
|
29
|
+
* tab order, drops, floats) and re-scopes slot-host refcount holds so
|
|
30
|
+
* the pool tears down hosts the new tree no longer references.
|
|
31
|
+
*
|
|
32
|
+
* The active preset name is preserved unless the app's initialLayout no
|
|
33
|
+
* longer declares it (the app shipped a new version that dropped the
|
|
34
|
+
* preset name); in that case the active preset is updated to the first
|
|
35
|
+
* canonical preset.
|
|
36
|
+
*
|
|
37
|
+
* Used by the `sh3.app.reset-layout` action — a recovery affordance
|
|
38
|
+
* available from the command palette only. Other presets are left alone;
|
|
39
|
+
* the user's customizations on them survive.
|
|
40
|
+
*
|
|
41
|
+
* Note: this re-scopes holds for the reset path only. The
|
|
42
|
+
* `presetManager.switch` slot-hold leak (TODO above on
|
|
43
|
+
* `acquireAppSlotHolds`) is a separate concern.
|
|
44
|
+
*/
|
|
45
|
+
export declare function resetActivePresetToDefault(): void;
|
|
26
46
|
/**
|
|
27
47
|
* Detach the currently-attached app. Releases its refcount holds; the
|
|
28
48
|
* pool's microtask cleanup drops the pooled hosts if they also have no
|
|
@@ -63,3 +83,10 @@ export declare const layoutStore: {
|
|
|
63
83
|
* tests import this submodule path directly.
|
|
64
84
|
*/
|
|
65
85
|
export declare function __resetLayoutStoreForTest(): void;
|
|
86
|
+
/**
|
|
87
|
+
* Test-only inspection: returns a shallow copy of the currently-attached
|
|
88
|
+
* app's held slot ids, in acquisition order. Returns `null` when no app
|
|
89
|
+
* is attached. Not exported from `src/index.ts` — tests import this
|
|
90
|
+
* submodule path directly.
|
|
91
|
+
*/
|
|
92
|
+
export declare function __inspectAppEntryHeldSlotIdsForTest(): string[] | null;
|
|
@@ -34,6 +34,7 @@ import { acquireSlotHost, releaseSlotHost } from './slotHostPool.svelte';
|
|
|
34
34
|
import { normalizeInitialLayout } from './presets';
|
|
35
35
|
import { collectTreeSlotRefs } from './tree-walk';
|
|
36
36
|
import { bindPresetBlob, unbindPresetBlob } from '../overlays/presets';
|
|
37
|
+
import { getRegisteredApp } from '../apps/registry.svelte';
|
|
37
38
|
// ---------- orphan cleanup of pre-phase-8 shell layout key ----------------
|
|
38
39
|
// Legacy pre-phase-8 orphan cleanup. The literal '__shell__' here is
|
|
39
40
|
// intentional — it clears data written under the old reserved id before
|
|
@@ -168,6 +169,59 @@ export function acquireAppSlotHolds() {
|
|
|
168
169
|
appEntry.heldSlotIds.push(slotId);
|
|
169
170
|
}
|
|
170
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Rebuild the currently-attached app's active preset from a fresh copy
|
|
174
|
+
* of `app.initialLayout`. Discards in-place customizations (split sizes,
|
|
175
|
+
* tab order, drops, floats) and re-scopes slot-host refcount holds so
|
|
176
|
+
* the pool tears down hosts the new tree no longer references.
|
|
177
|
+
*
|
|
178
|
+
* The active preset name is preserved unless the app's initialLayout no
|
|
179
|
+
* longer declares it (the app shipped a new version that dropped the
|
|
180
|
+
* preset name); in that case the active preset is updated to the first
|
|
181
|
+
* canonical preset.
|
|
182
|
+
*
|
|
183
|
+
* Used by the `sh3.app.reset-layout` action — a recovery affordance
|
|
184
|
+
* available from the command palette only. Other presets are left alone;
|
|
185
|
+
* the user's customizations on them survive.
|
|
186
|
+
*
|
|
187
|
+
* Note: this re-scopes holds for the reset path only. The
|
|
188
|
+
* `presetManager.switch` slot-hold leak (TODO above on
|
|
189
|
+
* `acquireAppSlotHolds`) is a separate concern.
|
|
190
|
+
*/
|
|
191
|
+
export function resetActivePresetToDefault() {
|
|
192
|
+
if (!appEntry) {
|
|
193
|
+
throw new Error('resetActivePresetToDefault: no app attached');
|
|
194
|
+
}
|
|
195
|
+
const app = getRegisteredApp(appEntry.appId);
|
|
196
|
+
if (!app) {
|
|
197
|
+
throw new Error(`resetActivePresetToDefault: attached app "${appEntry.appId}" not in registry`);
|
|
198
|
+
}
|
|
199
|
+
const canonical = normalizeInitialLayout(app.initialLayout);
|
|
200
|
+
const blob = appEntry.proxy;
|
|
201
|
+
const targetName = blob.activePreset;
|
|
202
|
+
let target = canonical.find((p) => p.name === targetName);
|
|
203
|
+
if (!target) {
|
|
204
|
+
target = canonical[0];
|
|
205
|
+
blob.activePreset = target.name;
|
|
206
|
+
}
|
|
207
|
+
// Release old slot holds before swapping the tree so the pool's
|
|
208
|
+
// microtask sees no live refs and tears down hosts that the new
|
|
209
|
+
// tree doesn't re-acquire.
|
|
210
|
+
for (const slotId of appEntry.heldSlotIds) {
|
|
211
|
+
releaseSlotHost(slotId);
|
|
212
|
+
}
|
|
213
|
+
appEntry.heldSlotIds = [];
|
|
214
|
+
// Deep-clone so the canonical object isn't aliased with future
|
|
215
|
+
// attaches or with the app's source `LayoutPreset` objects.
|
|
216
|
+
const freshTree = structuredClone(target.variants.default);
|
|
217
|
+
blob.presets[blob.activePreset].default = freshTree;
|
|
218
|
+
// Re-acquire holds against the new tree (mirrors acquireAppSlotHolds).
|
|
219
|
+
const refs = collectTreeSlotRefs(freshTree);
|
|
220
|
+
for (const { slotId, viewId, label, meta } of refs) {
|
|
221
|
+
acquireSlotHost(slotId, viewId, label, meta);
|
|
222
|
+
appEntry.heldSlotIds.push(slotId);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
171
225
|
/**
|
|
172
226
|
* Detach the currently-attached app. Releases its refcount holds; the
|
|
173
227
|
* pool's microtask cleanup drops the pooled hosts if they also have no
|
|
@@ -275,3 +329,12 @@ export function __resetLayoutStoreForTest() {
|
|
|
275
329
|
HOME_TREE.floats.length = 0;
|
|
276
330
|
HOME_TREE.docked = HOME_LAYOUT;
|
|
277
331
|
}
|
|
332
|
+
/**
|
|
333
|
+
* Test-only inspection: returns a shallow copy of the currently-attached
|
|
334
|
+
* app's held slot ids, in acquisition order. Returns `null` when no app
|
|
335
|
+
* is attached. Not exported from `src/index.ts` — tests import this
|
|
336
|
+
* submodule path directly.
|
|
337
|
+
*/
|
|
338
|
+
export function __inspectAppEntryHeldSlotIdsForTest() {
|
|
339
|
+
return appEntry ? [...appEntry.heldSlotIds] : null;
|
|
340
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* ConfirmDialog — small reusable confirmation primitive for destructive
|
|
4
|
+
* or otherwise non-trivial actions. Mounted via modalManager.open():
|
|
5
|
+
*
|
|
6
|
+
* modalManager.open(ConfirmDialog, {
|
|
7
|
+
* title: 'Reset layout?',
|
|
8
|
+
* body: 'This discards your customizations.',
|
|
9
|
+
* confirmLabel: 'Reset',
|
|
10
|
+
* confirmTone: 'danger',
|
|
11
|
+
* onConfirm: () => doReset(),
|
|
12
|
+
* });
|
|
13
|
+
*
|
|
14
|
+
* Backdrop click does NOT dismiss (no dismissOnBackdrop on the modal).
|
|
15
|
+
* Escape dismisses via the modal manager's shared listener.
|
|
16
|
+
* Default focus is the Cancel button so destructive actions don't fire on
|
|
17
|
+
* stray Enter presses.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
let {
|
|
21
|
+
title,
|
|
22
|
+
body,
|
|
23
|
+
confirmLabel = 'Confirm',
|
|
24
|
+
cancelLabel = 'Cancel',
|
|
25
|
+
confirmTone = 'default',
|
|
26
|
+
onConfirm,
|
|
27
|
+
onCancel,
|
|
28
|
+
close,
|
|
29
|
+
}: {
|
|
30
|
+
title: string;
|
|
31
|
+
body: string;
|
|
32
|
+
confirmLabel?: string;
|
|
33
|
+
cancelLabel?: string;
|
|
34
|
+
confirmTone?: 'default' | 'danger';
|
|
35
|
+
onConfirm: () => void | Promise<void>;
|
|
36
|
+
onCancel?: () => void;
|
|
37
|
+
close: () => void;
|
|
38
|
+
} = $props();
|
|
39
|
+
|
|
40
|
+
let cancelBtn: HTMLButtonElement | undefined = $state();
|
|
41
|
+
let busy = $state(false);
|
|
42
|
+
|
|
43
|
+
$effect(() => {
|
|
44
|
+
cancelBtn?.focus();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
async function handleConfirm(): Promise<void> {
|
|
48
|
+
if (busy) return;
|
|
49
|
+
busy = true;
|
|
50
|
+
try {
|
|
51
|
+
await onConfirm();
|
|
52
|
+
} finally {
|
|
53
|
+
close();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function handleCancel(): void {
|
|
58
|
+
if (busy) return;
|
|
59
|
+
onCancel?.();
|
|
60
|
+
close();
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<div class="confirm-dialog">
|
|
65
|
+
<div class="confirm-dialog-title">{title}</div>
|
|
66
|
+
<div class="confirm-dialog-body">{body}</div>
|
|
67
|
+
<div class="confirm-dialog-actions">
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
class="confirm-dialog-btn confirm-dialog-btn-cancel"
|
|
71
|
+
data-confirm-dialog-cancel
|
|
72
|
+
bind:this={cancelBtn}
|
|
73
|
+
onclick={handleCancel}
|
|
74
|
+
disabled={busy}
|
|
75
|
+
>
|
|
76
|
+
{cancelLabel}
|
|
77
|
+
</button>
|
|
78
|
+
<button
|
|
79
|
+
type="button"
|
|
80
|
+
class="confirm-dialog-btn"
|
|
81
|
+
class:confirm-dialog-btn-danger={confirmTone === 'danger'}
|
|
82
|
+
class:confirm-dialog-btn-default={confirmTone === 'default'}
|
|
83
|
+
data-confirm-dialog-confirm
|
|
84
|
+
onclick={handleConfirm}
|
|
85
|
+
disabled={busy}
|
|
86
|
+
>
|
|
87
|
+
{confirmLabel}
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.confirm-dialog {
|
|
94
|
+
display: flex;
|
|
95
|
+
flex-direction: column;
|
|
96
|
+
gap: 16px;
|
|
97
|
+
padding: 20px 24px;
|
|
98
|
+
min-width: 360px;
|
|
99
|
+
max-width: 480px;
|
|
100
|
+
}
|
|
101
|
+
.confirm-dialog-title {
|
|
102
|
+
font-size: 16px;
|
|
103
|
+
font-weight: 600;
|
|
104
|
+
color: var(--shell-fg);
|
|
105
|
+
}
|
|
106
|
+
.confirm-dialog-body {
|
|
107
|
+
font-size: 13px;
|
|
108
|
+
color: var(--shell-fg-muted, var(--shell-fg));
|
|
109
|
+
line-height: 1.5;
|
|
110
|
+
}
|
|
111
|
+
.confirm-dialog-actions {
|
|
112
|
+
display: flex;
|
|
113
|
+
justify-content: flex-end;
|
|
114
|
+
gap: 8px;
|
|
115
|
+
margin-top: 4px;
|
|
116
|
+
}
|
|
117
|
+
.confirm-dialog-btn {
|
|
118
|
+
font-size: 13px;
|
|
119
|
+
padding: 6px 14px;
|
|
120
|
+
border-radius: var(--shell-radius-sm, 4px);
|
|
121
|
+
border: 1px solid var(--shell-border-strong);
|
|
122
|
+
background: transparent;
|
|
123
|
+
color: var(--shell-fg);
|
|
124
|
+
cursor: pointer;
|
|
125
|
+
}
|
|
126
|
+
.confirm-dialog-btn:disabled {
|
|
127
|
+
opacity: 0.6;
|
|
128
|
+
cursor: not-allowed;
|
|
129
|
+
}
|
|
130
|
+
.confirm-dialog-btn-default {
|
|
131
|
+
background: var(--shell-bg-elevated);
|
|
132
|
+
}
|
|
133
|
+
.confirm-dialog-btn-danger {
|
|
134
|
+
background: transparent;
|
|
135
|
+
color: var(--shell-error, #d32f2f);
|
|
136
|
+
border-color: var(--shell-error, #d32f2f);
|
|
137
|
+
}
|
|
138
|
+
</style>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
title: string;
|
|
3
|
+
body: string;
|
|
4
|
+
confirmLabel?: string;
|
|
5
|
+
cancelLabel?: string;
|
|
6
|
+
confirmTone?: 'default' | 'danger';
|
|
7
|
+
onConfirm: () => void | Promise<void>;
|
|
8
|
+
onCancel?: () => void;
|
|
9
|
+
close: () => void;
|
|
10
|
+
};
|
|
11
|
+
declare const ConfirmDialog: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
12
|
+
type ConfirmDialog = ReturnType<typeof ConfirmDialog>;
|
|
13
|
+
export default ConfirmDialog;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|