sh3-core 0.13.4 → 0.14.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/api.d.ts +3 -0
- package/dist/api.js +3 -0
- package/dist/host.js +2 -0
- package/dist/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/tree-walk.js +6 -1
- package/dist/layout/types.d.ts +7 -0
- package/dist/migrations/mode-id-rename.d.ts +9 -0
- package/dist/migrations/mode-id-rename.js +39 -0
- package/dist/migrations/mode-id-rename.test.d.ts +1 -0
- package/dist/migrations/mode-id-rename.test.js +52 -0
- package/dist/overlays/FloatFrame.svelte +8 -2
- package/dist/overlays/float.js +6 -3
- package/dist/overlays/float.test.js +71 -0
- package/dist/primitives/widgets/IconToggleGroup.svelte +4 -1
- package/dist/primitives/widgets/Segmented.svelte +4 -1
- package/dist/sh3core-shard/AppInfoView.svelte +154 -0
- package/dist/sh3core-shard/AppInfoView.svelte.d.ts +11 -0
- package/dist/sh3core-shard/appActions.js +23 -5
- package/dist/shell-shard/ScrollbackView.svelte +40 -19
- package/dist/shell-shard/Terminal.svelte +140 -12
- package/dist/shell-shard/Terminal.svelte.d.ts +2 -0
- package/dist/shell-shard/contract.d.ts +99 -0
- package/dist/shell-shard/contract.js +11 -0
- package/dist/shell-shard/dispatch-custom.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-custom.test.js +152 -0
- package/dist/shell-shard/dispatch-gating.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-gating.test.js +63 -0
- package/dist/shell-shard/dispatch-invoke.test.d.ts +1 -0
- package/dist/shell-shard/dispatch-invoke.test.js +214 -0
- package/dist/shell-shard/dispatch.d.ts +23 -2
- package/dist/shell-shard/dispatch.js +130 -6
- package/dist/shell-shard/modes/builtin.d.ts +2 -2
- package/dist/shell-shard/modes/builtin.js +8 -8
- package/dist/shell-shard/modes/prefs.js +1 -1
- package/dist/shell-shard/modes/prefs.test.js +13 -13
- package/dist/shell-shard/modes/registry.test.js +13 -13
- package/dist/shell-shard/output.d.ts +10 -0
- package/dist/shell-shard/output.js +91 -0
- package/dist/shell-shard/output.test.d.ts +1 -0
- package/dist/shell-shard/output.test.js +73 -0
- package/dist/shell-shard/registerShellMode.d.ts +13 -0
- package/dist/shell-shard/registerShellMode.js +14 -0
- package/dist/shell-shard/registerShellMode.test.d.ts +1 -0
- package/dist/shell-shard/registerShellMode.test.js +19 -0
- package/dist/shell-shard/registry-resolve.test.d.ts +1 -0
- package/dist/shell-shard/registry-resolve.test.js +26 -0
- package/dist/shell-shard/registry.d.ts +12 -1
- package/dist/shell-shard/registry.js +12 -1
- package/dist/shell-shard/shellShard.svelte.js +8 -1
- package/dist/shell-shard/terminal-dispatch.test.js +19 -12
- package/dist/shell-shard/toolbar/slots/BusySlot.svelte +35 -0
- package/dist/shell-shard/toolbar/slots/BusySlot.svelte.d.ts +7 -0
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte +11 -51
- package/dist/shell-shard/toolbar/slots/ModeSlot.svelte.d.ts +2 -4
- package/dist/shell-shard/toolbar/slots.test.js +6 -6
- package/dist/shell-shard/verbs/clear.js +1 -0
- package/dist/shell-shard/verbs/index.js +2 -0
- package/dist/shell-shard/verbs/mode.d.ts +2 -0
- package/dist/shell-shard/verbs/mode.js +29 -0
- package/dist/shell-shard/verbs/mode.test.d.ts +1 -0
- package/dist/shell-shard/verbs/mode.test.js +43 -0
- package/dist/verbs/types.d.ts +19 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/api.d.ts
CHANGED
|
@@ -50,6 +50,9 @@ export type { Verb, VerbContext, ShellApi } from './verbs/types';
|
|
|
50
50
|
export type { Scrollback } from './shell-shard/scrollback.svelte';
|
|
51
51
|
export type { SessionClient } from './shell-shard/session-client.svelte';
|
|
52
52
|
export { listVerbs } from './shards/registry';
|
|
53
|
+
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
54
|
+
export type { ShellModeDescriptor, ShellModeOutput, ShellModeDispatchHandler, ShellModeDispatchInput, ShellModeRunsOn, RichEntryHandle, StreamHandle, } from './shell-shard/contract';
|
|
55
|
+
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
|
53
56
|
export { VERSION } from './version';
|
|
54
57
|
export declare const FRAMEWORK_SHARD_IDS: readonly string[];
|
|
55
58
|
export { setTokenOverrides, clearTokenOverrides, getTokenOverrides, } from './theme';
|
package/dist/api.js
CHANGED
|
@@ -56,6 +56,9 @@ export const capabilities = {
|
|
|
56
56
|
hotInstall: typeof Blob !== 'undefined' && typeof URL.createObjectURL === 'function',
|
|
57
57
|
};
|
|
58
58
|
export { listVerbs } from './shards/registry';
|
|
59
|
+
// Shell mode contributions (external shards extend the shell with new modes).
|
|
60
|
+
export { registerShellMode } from './shell-shard/registerShellMode';
|
|
61
|
+
export { SHELL_MODE_CONTRIBUTION_POINT } from './shell-shard/contract';
|
|
59
62
|
// Package version.
|
|
60
63
|
export { VERSION } from './version';
|
|
61
64
|
// Framework shard IDs — shards that are always present (built-in to sh3-core).
|
package/dist/host.js
CHANGED
|
@@ -31,6 +31,7 @@ import { storeApp } from './app/store/storeApp';
|
|
|
31
31
|
import { adminShard } from './app/admin/adminShard.svelte';
|
|
32
32
|
import { adminApp } from './app/admin/adminApp';
|
|
33
33
|
import { runShellRenameMigration, } from './migrations/shell-rename';
|
|
34
|
+
import { runModeIdRenameMigration } from './migrations/mode-id-rename';
|
|
34
35
|
import { setLifecycleHandlers } from './navigation/back-stack';
|
|
35
36
|
import { installWebEmitter } from './navigation/platform-web';
|
|
36
37
|
import { returnToHome } from './apps/lifecycle';
|
|
@@ -60,6 +61,7 @@ export async function bootstrap(config) {
|
|
|
60
61
|
// already in place when shards activate.
|
|
61
62
|
if (typeof globalThis.localStorage !== 'undefined') {
|
|
62
63
|
runShellRenameMigration(createWorkspaceZoneAdapter(), globalThis.localStorage);
|
|
64
|
+
runModeIdRenameMigration(globalThis.localStorage);
|
|
63
65
|
// Per ADR-002 amendment, app workspace state is keyed by (scopeId, appId).
|
|
64
66
|
// Rewrite legacy unkeyed entries to the personal scope namespace.
|
|
65
67
|
const { migrateLegacyWorkspaceKeys } = await import('./apps/workspace-rekey');
|
package/dist/layout/tree-walk.js
CHANGED
|
@@ -15,7 +15,12 @@ export function collectSlotRefs(tree) {
|
|
|
15
15
|
const out = [];
|
|
16
16
|
const walk = (node) => {
|
|
17
17
|
if (node.type === 'slot') {
|
|
18
|
-
out.push({
|
|
18
|
+
out.push({
|
|
19
|
+
slotId: node.slotId,
|
|
20
|
+
viewId: node.viewId,
|
|
21
|
+
label: node.viewId || node.slotId,
|
|
22
|
+
meta: node.meta,
|
|
23
|
+
});
|
|
19
24
|
return;
|
|
20
25
|
}
|
|
21
26
|
if (node.type === 'tabs') {
|
package/dist/layout/types.d.ts
CHANGED
|
@@ -86,6 +86,13 @@ export interface SlotNode {
|
|
|
86
86
|
slotId: string;
|
|
87
87
|
/** View id to mount into this slot, or null for an empty slot. */
|
|
88
88
|
viewId: string | null;
|
|
89
|
+
/**
|
|
90
|
+
* Caller-supplied instance data, threaded to `MountContext.meta`.
|
|
91
|
+
* Ephemeral — not serialized with the layout tree. Mirrors
|
|
92
|
+
* `TabEntry.meta` for the bare-slot case (e.g. dismissable floats whose
|
|
93
|
+
* content is a single slot rather than a TabsNode).
|
|
94
|
+
*/
|
|
95
|
+
meta?: Record<string, unknown>;
|
|
89
96
|
}
|
|
90
97
|
/**
|
|
91
98
|
* Union of all layout node kinds. The recursive tree is composed entirely of
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface MinimalStorage {
|
|
2
|
+
getItem(key: string): string | null;
|
|
3
|
+
setItem(key: string, value: string): void;
|
|
4
|
+
removeItem(key: string): void;
|
|
5
|
+
}
|
|
6
|
+
export declare function runModeIdRenameMigration(storage: MinimalStorage & {
|
|
7
|
+
_keys?: () => string[];
|
|
8
|
+
}): void;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* One-shot migration: rewrites persisted shell-mode preferences from the
|
|
3
|
+
* pre-rename ids (`dev`, `user`) to the new ids (`bash`, `sh3`). Idempotent —
|
|
4
|
+
* gated by a localStorage flag, safe to call on every boot.
|
|
5
|
+
*
|
|
6
|
+
* Persistence shape: localStorage keys of the form `sh3.shell.lastMode.<userId>`
|
|
7
|
+
* (see packages/sh3-core/src/shell-shard/modes/prefs.ts).
|
|
8
|
+
*/
|
|
9
|
+
const FLAG_KEY = 'sh3:migrations:mode-id-rename:done';
|
|
10
|
+
const KEY_PREFIX = 'sh3.shell.lastMode.';
|
|
11
|
+
const REWRITES = { dev: 'bash', user: 'sh3' };
|
|
12
|
+
function listKeys(storage) {
|
|
13
|
+
if (typeof storage._keys === 'function')
|
|
14
|
+
return storage._keys();
|
|
15
|
+
const ls = storage;
|
|
16
|
+
if (typeof ls.length === 'number' && typeof ls.key === 'function') {
|
|
17
|
+
const out = [];
|
|
18
|
+
for (let i = 0; i < ls.length; i++) {
|
|
19
|
+
const k = ls.key(i);
|
|
20
|
+
if (k !== null)
|
|
21
|
+
out.push(k);
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
export function runModeIdRenameMigration(storage) {
|
|
28
|
+
if (storage.getItem(FLAG_KEY))
|
|
29
|
+
return;
|
|
30
|
+
for (const key of listKeys(storage)) {
|
|
31
|
+
if (!key.startsWith(KEY_PREFIX))
|
|
32
|
+
continue;
|
|
33
|
+
const value = storage.getItem(key);
|
|
34
|
+
if (value && Object.prototype.hasOwnProperty.call(REWRITES, value)) {
|
|
35
|
+
storage.setItem(key, REWRITES[value]);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
storage.setItem(FLAG_KEY, '1');
|
|
39
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { runModeIdRenameMigration } from './mode-id-rename';
|
|
3
|
+
function makeStorage(initial = {}) {
|
|
4
|
+
const map = new Map(Object.entries(initial));
|
|
5
|
+
return {
|
|
6
|
+
getItem: (k) => (map.has(k) ? map.get(k) : null),
|
|
7
|
+
setItem: (k, v) => { map.set(k, v); },
|
|
8
|
+
removeItem: (k) => { map.delete(k); },
|
|
9
|
+
_dump: () => Object.fromEntries(map),
|
|
10
|
+
_keys: () => [...map.keys()],
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
describe('runModeIdRenameMigration', () => {
|
|
14
|
+
it('rewrites dev → bash for every user-keyed pref', () => {
|
|
15
|
+
const s = makeStorage({
|
|
16
|
+
'sh3.shell.lastMode.alice': 'dev',
|
|
17
|
+
'sh3.shell.lastMode.bob': 'dev',
|
|
18
|
+
});
|
|
19
|
+
runModeIdRenameMigration(s);
|
|
20
|
+
expect(s._dump()).toMatchObject({
|
|
21
|
+
'sh3.shell.lastMode.alice': 'bash',
|
|
22
|
+
'sh3.shell.lastMode.bob': 'bash',
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
it('rewrites user → sh3 for every user-keyed pref', () => {
|
|
26
|
+
const s = makeStorage({ 'sh3.shell.lastMode.alice': 'user' });
|
|
27
|
+
runModeIdRenameMigration(s);
|
|
28
|
+
expect(s.getItem('sh3.shell.lastMode.alice')).toBe('sh3');
|
|
29
|
+
});
|
|
30
|
+
it('leaves unknown values untouched', () => {
|
|
31
|
+
const s = makeStorage({ 'sh3.shell.lastMode.alice': 'gemini' });
|
|
32
|
+
runModeIdRenameMigration(s);
|
|
33
|
+
expect(s.getItem('sh3.shell.lastMode.alice')).toBe('gemini');
|
|
34
|
+
});
|
|
35
|
+
it('is idempotent (gated by a done flag)', () => {
|
|
36
|
+
const s = makeStorage({ 'sh3.shell.lastMode.alice': 'dev' });
|
|
37
|
+
runModeIdRenameMigration(s);
|
|
38
|
+
s.setItem('sh3.shell.lastMode.alice', 'dev');
|
|
39
|
+
runModeIdRenameMigration(s);
|
|
40
|
+
expect(s.getItem('sh3.shell.lastMode.alice')).toBe('dev');
|
|
41
|
+
});
|
|
42
|
+
it('ignores unrelated keys', () => {
|
|
43
|
+
const s = makeStorage({
|
|
44
|
+
'sh3.shell.lastMode.alice': 'dev',
|
|
45
|
+
'sh3.unrelated': 'dev',
|
|
46
|
+
'random': 'user',
|
|
47
|
+
});
|
|
48
|
+
runModeIdRenameMigration(s);
|
|
49
|
+
expect(s.getItem('sh3.unrelated')).toBe('dev');
|
|
50
|
+
expect(s.getItem('random')).toBe('user');
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -42,14 +42,20 @@
|
|
|
42
42
|
// stacking context — so a picker opened from inside a modal stacks above
|
|
43
43
|
// that modal without writing any z-index. The Svelte component lifecycle
|
|
44
44
|
// is unaffected; we're only relocating the rendered DOM node.
|
|
45
|
+
//
|
|
46
|
+
// The cleanup removes frameEl directly: Svelte's keyed-each iteration
|
|
47
|
+
// removal walks the anchor range left in FloatLayer to clear the DOM, and
|
|
48
|
+
// the portal moved frameEl out of that range — re-parenting it back to
|
|
49
|
+
// FloatLayer would orphan it (Svelte's removal pass clears the empty
|
|
50
|
+
// anchor range and misses the re-deposited frame). Effect deps are stable
|
|
51
|
+
// (frameEl, entry.id), so cleanup only fires on destroy.
|
|
45
52
|
$effect(() => {
|
|
46
53
|
if (!frameEl) return;
|
|
47
54
|
const host = getFloatParentHost(entry.id);
|
|
48
55
|
if (!host) return;
|
|
49
|
-
const original = frameEl.parentNode;
|
|
50
56
|
host.appendChild(frameEl);
|
|
51
57
|
return () => {
|
|
52
|
-
if (frameEl?.parentNode === host
|
|
58
|
+
if (frameEl?.parentNode === host) frameEl.remove();
|
|
53
59
|
};
|
|
54
60
|
});
|
|
55
61
|
|
package/dist/overlays/float.js
CHANGED
|
@@ -86,13 +86,16 @@ function openFloat(viewId, options = {}) {
|
|
|
86
86
|
let content;
|
|
87
87
|
if (options.dismissable) {
|
|
88
88
|
// Picker float: render the view directly as a leaf slot. No tab strip,
|
|
89
|
-
// no tabs wrapper, no drag-to-dock handle.
|
|
90
|
-
//
|
|
91
|
-
|
|
89
|
+
// no tabs wrapper, no drag-to-dock handle. SlotNode.meta carries
|
|
90
|
+
// options.meta through to MountContext, mirroring TabEntry.meta.
|
|
91
|
+
const slot = {
|
|
92
92
|
type: 'slot',
|
|
93
93
|
slotId,
|
|
94
94
|
viewId,
|
|
95
95
|
};
|
|
96
|
+
if (options.meta)
|
|
97
|
+
slot.meta = options.meta;
|
|
98
|
+
content = slot;
|
|
96
99
|
}
|
|
97
100
|
else {
|
|
98
101
|
const label = (_a = options.title) !== null && _a !== void 0 ? _a : viewId;
|
|
@@ -388,6 +388,32 @@ describe('floats — F.7 anchor portals to enclosing overlay host', () => {
|
|
|
388
388
|
const frame = container.querySelector('[role="dialog"][aria-label="NoAnchor"]');
|
|
389
389
|
expect(frame).toBeTruthy();
|
|
390
390
|
});
|
|
391
|
+
// Regression: Svelte's keyed-each iteration removal walks the anchor range
|
|
392
|
+
// left in FloatLayer to clear the DOM. The portal moved frameEl out of that
|
|
393
|
+
// range, so a re-parent-back-to-original cleanup orphans the empty frame
|
|
394
|
+
// in FloatLayer when the float is dismissed. Cleanup must remove frameEl
|
|
395
|
+
// directly.
|
|
396
|
+
it('removes the portaled frame on dismiss (no orphan in FloatLayer)', async () => {
|
|
397
|
+
const { container } = renderWithShell(FloatLayer, {});
|
|
398
|
+
const fakeModalHost = document.createElement('div');
|
|
399
|
+
fakeModalHost.className = 'fake-modal-host';
|
|
400
|
+
fakeModalHost.dataset.shellOverlayHost = 'modal';
|
|
401
|
+
const anchor = document.createElement('button');
|
|
402
|
+
fakeModalHost.appendChild(anchor);
|
|
403
|
+
document.body.appendChild(fakeModalHost);
|
|
404
|
+
const id = floatManager.open('test:view', {
|
|
405
|
+
dismissable: true,
|
|
406
|
+
anchor,
|
|
407
|
+
title: 'Picker',
|
|
408
|
+
});
|
|
409
|
+
await tick();
|
|
410
|
+
expect(fakeModalHost.querySelector('.sh3-float-frame')).not.toBeNull();
|
|
411
|
+
floatManager.close(id);
|
|
412
|
+
await tick();
|
|
413
|
+
const layerDiv = container.querySelector('.sh3-float-layer');
|
|
414
|
+
expect(layerDiv === null || layerDiv === void 0 ? void 0 : layerDiv.querySelector('.sh3-float-frame')).toBeNull();
|
|
415
|
+
expect(document.querySelectorAll('.sh3-float-frame').length).toBe(0);
|
|
416
|
+
});
|
|
391
417
|
});
|
|
392
418
|
// ---------------------------------------------------------------------------
|
|
393
419
|
// F.8 — overlay host marker on FloatFrame
|
|
@@ -406,3 +432,48 @@ describe('floats — F.8 overlay host marker', () => {
|
|
|
406
432
|
expect(frame.dataset.shellOverlayHost).toBe('float');
|
|
407
433
|
});
|
|
408
434
|
});
|
|
435
|
+
// ---------------------------------------------------------------------------
|
|
436
|
+
// F.9 — meta threading from floatManager.open into MountContext
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
import { registerView } from '../shards/registry';
|
|
439
|
+
describe('floats — F.9 meta threads to MountContext', () => {
|
|
440
|
+
beforeEach(() => {
|
|
441
|
+
resetFramework();
|
|
442
|
+
bindManagerToStore();
|
|
443
|
+
});
|
|
444
|
+
it('non-dismissable: ctx.meta receives options.meta on mount', async () => {
|
|
445
|
+
let captured;
|
|
446
|
+
registerView('test:meta-view', {
|
|
447
|
+
mount(_container, ctx) {
|
|
448
|
+
captured = ctx.meta;
|
|
449
|
+
return { unmount: () => { } };
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
renderWithShell(FloatLayer, {});
|
|
453
|
+
floatManager.open('test:meta-view', { title: 'M', meta: { kind: 'tab', n: 1 } });
|
|
454
|
+
await tick();
|
|
455
|
+
// acquireSlotHost defers factory.mount via queueMicrotask — flush it.
|
|
456
|
+
await Promise.resolve();
|
|
457
|
+
await tick();
|
|
458
|
+
expect(captured).toEqual({ kind: 'tab', n: 1 });
|
|
459
|
+
});
|
|
460
|
+
it('dismissable: ctx.meta receives options.meta on mount', async () => {
|
|
461
|
+
let captured;
|
|
462
|
+
registerView('test:meta-view', {
|
|
463
|
+
mount(_container, ctx) {
|
|
464
|
+
captured = ctx.meta;
|
|
465
|
+
return { unmount: () => { } };
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
renderWithShell(FloatLayer, {});
|
|
469
|
+
floatManager.open('test:meta-view', {
|
|
470
|
+
dismissable: true,
|
|
471
|
+
title: 'P',
|
|
472
|
+
meta: { kind: 'picker', color: '#abc' },
|
|
473
|
+
});
|
|
474
|
+
await tick();
|
|
475
|
+
await Promise.resolve();
|
|
476
|
+
await tick();
|
|
477
|
+
expect(captured).toEqual({ kind: 'picker', color: '#abc' });
|
|
478
|
+
});
|
|
479
|
+
});
|
|
@@ -78,7 +78,10 @@
|
|
|
78
78
|
color: var(--shell-fg);
|
|
79
79
|
filter: none;
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
/* Selector includes `.sh3-itg button` so specificity (0,2,1) beats the
|
|
82
|
+
base `.sh3-itg button` rule (0,1,1) — otherwise `background: transparent`
|
|
83
|
+
and `color: var(--shell-fg-muted)` would shadow the accent fill. */
|
|
84
|
+
.sh3-itg button.sh3-itg__btn--active {
|
|
82
85
|
background: var(--shell-accent);
|
|
83
86
|
color: var(--shell-fg-on-accent);
|
|
84
87
|
font-weight: 600;
|
|
@@ -75,7 +75,10 @@
|
|
|
75
75
|
color: var(--shell-fg);
|
|
76
76
|
filter: none;
|
|
77
77
|
}
|
|
78
|
-
|
|
78
|
+
/* Selector includes `.sh3-seg button` so specificity (0,2,1) beats the
|
|
79
|
+
base `.sh3-seg button` rule (0,1,1) — otherwise `background: transparent`
|
|
80
|
+
and `color: var(--shell-fg-muted)` would shadow the accent fill. */
|
|
81
|
+
.sh3-seg button.sh3-seg__btn--active {
|
|
79
82
|
background: var(--shell-accent);
|
|
80
83
|
color: var(--shell-fg-on-accent);
|
|
81
84
|
font-weight: 600;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/*
|
|
3
|
+
* Read-only details modal for an app, opened by the home-card `app.info`
|
|
4
|
+
* action. Pulls the manifest from listRegisteredApps() and — when the app
|
|
5
|
+
* was installed from a registry — augments it with the catalog entry
|
|
6
|
+
* (description, author) and the InstalledPackage record (source registry,
|
|
7
|
+
* install date, contract version). Built-in apps show only manifest fields.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AppManifest } from '../apps/types';
|
|
11
|
+
import type { InstalledPackage, PackageEntry } from '../registry/types';
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
manifest: AppManifest;
|
|
15
|
+
installed: InstalledPackage | null;
|
|
16
|
+
catalogEntry: PackageEntry | null;
|
|
17
|
+
close: () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let { manifest, installed, catalogEntry, close }: Props = $props();
|
|
21
|
+
|
|
22
|
+
let installedDate = $derived.by(() => {
|
|
23
|
+
if (!installed) return null;
|
|
24
|
+
const d = new Date(installed.installedAt);
|
|
25
|
+
return isNaN(d.getTime()) ? installed.installedAt : d.toLocaleString();
|
|
26
|
+
});
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div class="app-info-modal">
|
|
30
|
+
<header>
|
|
31
|
+
<h2>{manifest.label}</h2>
|
|
32
|
+
<code class="id">{manifest.id}</code>
|
|
33
|
+
</header>
|
|
34
|
+
|
|
35
|
+
<p class="version">
|
|
36
|
+
<span class="label">Version</span>
|
|
37
|
+
<code>v{manifest.version}</code>
|
|
38
|
+
{#if !installed}<span class="badge built-in">built-in</span>{/if}
|
|
39
|
+
</p>
|
|
40
|
+
|
|
41
|
+
{#if catalogEntry?.description}
|
|
42
|
+
<section class="description">
|
|
43
|
+
<span class="label">Description</span>
|
|
44
|
+
<p>{catalogEntry.description}</p>
|
|
45
|
+
</section>
|
|
46
|
+
{/if}
|
|
47
|
+
|
|
48
|
+
{#if catalogEntry?.author?.name}
|
|
49
|
+
<p class="row">
|
|
50
|
+
<span class="label">Author</span>
|
|
51
|
+
<span>{catalogEntry.author.name}</span>
|
|
52
|
+
</p>
|
|
53
|
+
{/if}
|
|
54
|
+
|
|
55
|
+
{#if manifest.requiredShards.length > 0}
|
|
56
|
+
<section class="row">
|
|
57
|
+
<span class="label">Required shards</span>
|
|
58
|
+
<ul class="chips">
|
|
59
|
+
{#each manifest.requiredShards as shard}
|
|
60
|
+
<li><code>{shard}</code></li>
|
|
61
|
+
{/each}
|
|
62
|
+
</ul>
|
|
63
|
+
</section>
|
|
64
|
+
{/if}
|
|
65
|
+
|
|
66
|
+
{#if manifest.permissions && manifest.permissions.length > 0}
|
|
67
|
+
<section class="row">
|
|
68
|
+
<span class="label">Permissions</span>
|
|
69
|
+
<ul class="chips">
|
|
70
|
+
{#each manifest.permissions as perm}
|
|
71
|
+
<li><code>{perm}</code></li>
|
|
72
|
+
{/each}
|
|
73
|
+
</ul>
|
|
74
|
+
</section>
|
|
75
|
+
{/if}
|
|
76
|
+
|
|
77
|
+
{#if installed}
|
|
78
|
+
<section class="installed">
|
|
79
|
+
<p class="row"><span class="label">Source registry</span><code>{installed.sourceRegistry}</code></p>
|
|
80
|
+
{#if installedDate}
|
|
81
|
+
<p class="row"><span class="label">Installed</span><span>{installedDate}</span></p>
|
|
82
|
+
{/if}
|
|
83
|
+
<p class="row"><span class="label">Contract</span><code>v{installed.contractVersion}</code></p>
|
|
84
|
+
</section>
|
|
85
|
+
{/if}
|
|
86
|
+
|
|
87
|
+
<div class="actions">
|
|
88
|
+
<button type="button" onclick={close}>Close</button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<style>
|
|
93
|
+
.app-info-modal {
|
|
94
|
+
padding: 16px 20px;
|
|
95
|
+
min-width: 360px;
|
|
96
|
+
max-width: 520px;
|
|
97
|
+
color: var(--shell-fg);
|
|
98
|
+
background: var(--shell-bg);
|
|
99
|
+
font: inherit;
|
|
100
|
+
}
|
|
101
|
+
header { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
|
|
102
|
+
header h2 { margin: 0; font-size: 16px; }
|
|
103
|
+
header .id {
|
|
104
|
+
font-size: 12px;
|
|
105
|
+
color: var(--shell-fg-muted);
|
|
106
|
+
}
|
|
107
|
+
.version { margin: 4px 0 12px; font-size: 13px; display: flex; align-items: center; gap: 8px; }
|
|
108
|
+
.description { margin: 8px 0 12px; }
|
|
109
|
+
.description p {
|
|
110
|
+
margin: 4px 0 0;
|
|
111
|
+
font-size: 13px;
|
|
112
|
+
line-height: 1.5;
|
|
113
|
+
white-space: pre-wrap;
|
|
114
|
+
}
|
|
115
|
+
.row { margin: 6px 0; font-size: 13px; display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
|
116
|
+
.label {
|
|
117
|
+
color: var(--shell-fg-muted);
|
|
118
|
+
font-size: 11px;
|
|
119
|
+
text-transform: uppercase;
|
|
120
|
+
letter-spacing: 0.04em;
|
|
121
|
+
flex: 0 0 auto;
|
|
122
|
+
min-width: 90px;
|
|
123
|
+
}
|
|
124
|
+
.chips { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 4px; }
|
|
125
|
+
.chips code {
|
|
126
|
+
padding: 1px 6px;
|
|
127
|
+
border: 1px solid var(--shell-border);
|
|
128
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
129
|
+
}
|
|
130
|
+
.badge {
|
|
131
|
+
font-size: 11px;
|
|
132
|
+
padding: 1px 6px;
|
|
133
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
134
|
+
background: var(--shell-bg-elevated);
|
|
135
|
+
color: var(--shell-fg-muted);
|
|
136
|
+
border: 1px solid var(--shell-border);
|
|
137
|
+
}
|
|
138
|
+
.installed { margin-top: 12px; padding-top: 10px; border-top: 1px solid var(--shell-border); }
|
|
139
|
+
code {
|
|
140
|
+
font-family: var(--shell-font-mono, monospace);
|
|
141
|
+
background: var(--shell-bg-elevated);
|
|
142
|
+
padding: 0 4px;
|
|
143
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
144
|
+
}
|
|
145
|
+
.actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
|
|
146
|
+
.actions button {
|
|
147
|
+
background: var(--shell-bg-elevated);
|
|
148
|
+
color: var(--shell-fg);
|
|
149
|
+
border: 1px solid var(--shell-border);
|
|
150
|
+
border-radius: var(--shell-radius-sm, 3px);
|
|
151
|
+
padding: 6px 14px; font: inherit; cursor: pointer;
|
|
152
|
+
}
|
|
153
|
+
.actions button:hover { border-color: var(--shell-accent); }
|
|
154
|
+
</style>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AppManifest } from '../apps/types';
|
|
2
|
+
import type { InstalledPackage, PackageEntry } from '../registry/types';
|
|
3
|
+
interface Props {
|
|
4
|
+
manifest: AppManifest;
|
|
5
|
+
installed: InstalledPackage | null;
|
|
6
|
+
catalogEntry: PackageEntry | null;
|
|
7
|
+
close: () => void;
|
|
8
|
+
}
|
|
9
|
+
declare const AppInfoView: import("svelte").Component<Props, {}, "">;
|
|
10
|
+
type AppInfoView = ReturnType<typeof AppInfoView>;
|
|
11
|
+
export default AppInfoView;
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
* 'app' (see ShellHome.svelte).
|
|
6
6
|
*
|
|
7
7
|
* Three actions are registered here:
|
|
8
|
-
* - app.info :
|
|
8
|
+
* - app.info : open the AppInfoView modal with manifest + (when
|
|
9
|
+
* installed) catalog/install metadata. Always enabled.
|
|
9
10
|
* - app.checkUpdate : refresh catalog, then prompt update or toast up-to-date.
|
|
10
11
|
* - app.uninstall : open uninstall confirm dialog.
|
|
11
12
|
*
|
|
@@ -25,6 +26,7 @@ import { modalManager } from '../overlays/modal';
|
|
|
25
26
|
import { toastManager } from '../overlays/toast';
|
|
26
27
|
import AppUpdateAvailableModal from '../app/store/AppUpdateAvailableModal.svelte';
|
|
27
28
|
import UninstallAppDialog from '../app/store/UninstallAppDialog.svelte';
|
|
29
|
+
import AppInfoView from './AppInfoView.svelte';
|
|
28
30
|
export function computeAppActionDisabled(g) {
|
|
29
31
|
return !g.admin || g.builtin;
|
|
30
32
|
}
|
|
@@ -50,6 +52,22 @@ function isBuiltin(appId) {
|
|
|
50
52
|
function gateFor(appId) {
|
|
51
53
|
return { admin: isAdmin(), builtin: isBuiltin(appId) };
|
|
52
54
|
}
|
|
55
|
+
function runShowInfo(_ctx) {
|
|
56
|
+
var _a, _b, _c;
|
|
57
|
+
const ref = readSelection();
|
|
58
|
+
if (!ref)
|
|
59
|
+
return;
|
|
60
|
+
const manifest = findApp(ref.appId);
|
|
61
|
+
if (!manifest)
|
|
62
|
+
return;
|
|
63
|
+
const installed = (_a = storeContext.state.ephemeral.installed.find((p) => p.id === ref.appId)) !== null && _a !== void 0 ? _a : null;
|
|
64
|
+
// Catalog entry only exists for apps fetched from a registry — built-in
|
|
65
|
+
// apps will fall through to null and the modal will hide registry-only
|
|
66
|
+
// fields (description, author).
|
|
67
|
+
const catalogEntry = (_c = (_b = storeContext.state.ephemeral.catalog.find((p) => p.entry.id === ref.appId)) === null || _b === void 0 ? void 0 : _b.entry) !== null && _c !== void 0 ? _c : null;
|
|
68
|
+
const props = { manifest, installed, catalogEntry };
|
|
69
|
+
modalManager.open(AppInfoView, props);
|
|
70
|
+
}
|
|
53
71
|
async function runCheckUpdate(_ctx) {
|
|
54
72
|
var _a, _b;
|
|
55
73
|
const ref = readSelection();
|
|
@@ -124,11 +142,11 @@ export function registerAppActions(ctx) {
|
|
|
124
142
|
const infoLabel = () => {
|
|
125
143
|
const ref = readSelection();
|
|
126
144
|
if (!ref)
|
|
127
|
-
return '';
|
|
145
|
+
return 'Info…';
|
|
128
146
|
const m = findApp(ref.appId);
|
|
129
147
|
if (!m)
|
|
130
|
-
return ref.appId
|
|
131
|
-
return
|
|
148
|
+
return `Info — ${ref.appId}`;
|
|
149
|
+
return `Info — ${m.label} v${m.version}`;
|
|
132
150
|
};
|
|
133
151
|
const updateLabel = () => {
|
|
134
152
|
const ref = readSelection();
|
|
@@ -155,7 +173,7 @@ export function registerAppActions(ctx) {
|
|
|
155
173
|
scope: { element: 'app' },
|
|
156
174
|
contextItem: true,
|
|
157
175
|
group: 'info',
|
|
158
|
-
|
|
176
|
+
run: runShowInfo,
|
|
159
177
|
},
|
|
160
178
|
{
|
|
161
179
|
id: 'app.checkUpdate',
|
|
@@ -11,30 +11,51 @@
|
|
|
11
11
|
let { scrollback }: Props = $props();
|
|
12
12
|
|
|
13
13
|
let container: HTMLDivElement | null = $state(null);
|
|
14
|
+
let content: HTMLDivElement | null = $state(null);
|
|
15
|
+
let stuck = true;
|
|
14
16
|
|
|
15
|
-
//
|
|
17
|
+
// scrollHeight - scrollTop - clientHeight can settle on small non-zero
|
|
18
|
+
// values from sub-pixel rounding even when visually at the bottom.
|
|
19
|
+
const STICK_THRESHOLD_PX = 4;
|
|
20
|
+
|
|
21
|
+
function isAtBottom(el: HTMLElement): boolean {
|
|
22
|
+
return el.scrollHeight - el.scrollTop - el.clientHeight <= STICK_THRESHOLD_PX;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function handleScroll(): void {
|
|
26
|
+
if (container) stuck = isAtBottom(container);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ResizeObserver on the inner content wrapper fires on any layout-affecting
|
|
30
|
+
// change regardless of source: text-chunk pushes, mutated rich-entry props
|
|
31
|
+
// (output.stream() / output.rich() handles), image or font load. A reactivity-
|
|
32
|
+
// driven approach (depending on entries.length or chunk counts) misses rich
|
|
33
|
+
// streaming because props are mutated via Object.assign and the outer view
|
|
34
|
+
// never reads them.
|
|
16
35
|
$effect(() => {
|
|
17
|
-
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
36
|
+
if (!content || !container) return;
|
|
37
|
+
const ro = new ResizeObserver(() => {
|
|
38
|
+
if (container && stuck) container.scrollTop = container.scrollHeight;
|
|
39
|
+
});
|
|
40
|
+
ro.observe(content);
|
|
41
|
+
return () => ro.disconnect();
|
|
23
42
|
});
|
|
24
43
|
</script>
|
|
25
44
|
|
|
26
|
-
<div class="shell-scrollback" bind:this={container}>
|
|
27
|
-
|
|
28
|
-
{#
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
<div class="shell-scrollback" bind:this={container} onscroll={handleScroll}>
|
|
46
|
+
<div class="content" bind:this={content}>
|
|
47
|
+
{#each scrollback.entries as entry (entry.id)}
|
|
48
|
+
{#if entry.kind === 'text'}
|
|
49
|
+
<TextEntry stream={entry.stream} chunks={entry.chunks} />
|
|
50
|
+
{:else if entry.kind === 'prompt'}
|
|
51
|
+
<PromptEntry cwd={entry.cwd} line={entry.line} />
|
|
52
|
+
{:else if entry.kind === 'status'}
|
|
53
|
+
<StatusEntry text={entry.text} level={entry.level} />
|
|
54
|
+
{:else if entry.kind === 'rich'}
|
|
55
|
+
<RichEntry component={entry.component} componentProps={entry.props} />
|
|
56
|
+
{/if}
|
|
57
|
+
{/each}
|
|
58
|
+
</div>
|
|
38
59
|
</div>
|
|
39
60
|
|
|
40
61
|
<style>
|