sh3-core 0.14.0 → 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/layout/LayoutRenderer.svelte +1 -1
- package/dist/layout/tree-walk.js +6 -1
- package/dist/layout/types.d.ts +7 -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 +55 -4
- package/dist/shell-shard/contract.d.ts +34 -0
- package/dist/shell-shard/dispatch-custom.test.js +48 -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 +9 -1
- package/dist/shell-shard/dispatch.js +73 -2
- package/dist/shell-shard/output.d.ts +8 -1
- package/dist/shell-shard/output.js +17 -1
- package/dist/shell-shard/output.test.js +24 -5
- 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/terminal-dispatch.test.js +10 -3
- 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/verbs/clear.js +1 -0
- package/dist/shell-shard/verbs/mode.js +1 -0
- package/dist/verbs/types.d.ts +8 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
|
@@ -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>
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { TenantFsClient } from './tenant-fs-client';
|
|
10
10
|
import { ShellModeRegistry } from './modes/registry';
|
|
11
11
|
import { registerBuiltinModes } from './modes/builtin';
|
|
12
|
-
import { resolveInitialMode, writeLastMode } from './modes/prefs';
|
|
12
|
+
import { readLastMode, resolveInitialMode, writeLastMode } from './modes/prefs';
|
|
13
13
|
import type { ShellMode, ShellRole } from './modes/types';
|
|
14
14
|
import type { ContributionsApi } from '../contributions/types';
|
|
15
15
|
import { SHELL_MODE_CONTRIBUTION_POINT, type ShellModeDescriptor } from './contract';
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
import ModeSlot from './toolbar/slots/ModeSlot.svelte';
|
|
23
23
|
import FocusLockSlot from './toolbar/slots/FocusLockSlot.svelte';
|
|
24
24
|
import TargetShardSlot from './toolbar/slots/TargetShardSlot.svelte';
|
|
25
|
+
import BusySlot from './toolbar/slots/BusySlot.svelte';
|
|
25
26
|
|
|
26
27
|
interface Props {
|
|
27
28
|
shell: ShellApi;
|
|
@@ -74,9 +75,24 @@
|
|
|
74
75
|
.map(descriptorToMode),
|
|
75
76
|
]);
|
|
76
77
|
|
|
77
|
-
//
|
|
78
|
+
// Resolve against the merged visible set so a persisted contributed-mode
|
|
79
|
+
// id is honored at boot when its shard activated before this view mounted.
|
|
80
|
+
// Async activations still land on the role default.
|
|
78
81
|
let mode = $state<ShellMode>(
|
|
79
|
-
untrack(() =>
|
|
82
|
+
untrack(() => {
|
|
83
|
+
const persisted = readLastMode(userId);
|
|
84
|
+
if (persisted) {
|
|
85
|
+
const initialVisible: ShellMode[] = [
|
|
86
|
+
...modeRegistry.list(role),
|
|
87
|
+
...contributedModes
|
|
88
|
+
.filter((d) => !d.requiresRole || d.requiresRole === role)
|
|
89
|
+
.map(descriptorToMode),
|
|
90
|
+
];
|
|
91
|
+
const found = initialVisible.find((m) => m.id === persisted);
|
|
92
|
+
if (found) return found;
|
|
93
|
+
}
|
|
94
|
+
return resolveInitialMode(modeRegistry, userId, role);
|
|
95
|
+
}),
|
|
80
96
|
);
|
|
81
97
|
|
|
82
98
|
function setMode(id: string): void {
|
|
@@ -133,14 +149,44 @@
|
|
|
133
149
|
// "referenced outside a closure" warning; the URL never changes at runtime.
|
|
134
150
|
const session = untrack(() => new SessionClient(wsUrl));
|
|
135
151
|
|
|
152
|
+
// Busy controller — feeds the toolbar spinner. Each call to acquireBusy(label)
|
|
153
|
+
// adds an entry to the map; the returned disposer removes it. The slot
|
|
154
|
+
// renders when the map is non-empty.
|
|
155
|
+
let busyCounter = 0;
|
|
156
|
+
let busyEntries = $state(new Map<number, string | undefined>());
|
|
157
|
+
|
|
158
|
+
function acquireBusy(label?: string): () => void {
|
|
159
|
+
const id = ++busyCounter;
|
|
160
|
+
busyEntries.set(id, label);
|
|
161
|
+
busyEntries = new Map(busyEntries);
|
|
162
|
+
let cleared = false;
|
|
163
|
+
return () => {
|
|
164
|
+
if (cleared) return;
|
|
165
|
+
cleared = true;
|
|
166
|
+
busyEntries.delete(id);
|
|
167
|
+
busyEntries = new Map(busyEntries);
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let busyActive = $derived(busyEntries.size > 0);
|
|
172
|
+
let busyLabel = $derived.by<string | null>(() => {
|
|
173
|
+
if (busyEntries.size === 0) return null;
|
|
174
|
+
// Most-recent label wins (insertion-order Map).
|
|
175
|
+
let last: string | undefined;
|
|
176
|
+
for (const v of busyEntries.values()) last = v;
|
|
177
|
+
return last ?? null;
|
|
178
|
+
});
|
|
179
|
+
|
|
136
180
|
const { dispatch, cancel: cancelDispatch } = untrack(() => makeDispatch({
|
|
137
181
|
mode: () => mode,
|
|
182
|
+
role: () => role,
|
|
138
183
|
resolver,
|
|
139
184
|
scrollback,
|
|
140
185
|
session,
|
|
141
186
|
shell: shellWithModes,
|
|
142
187
|
fs,
|
|
143
188
|
cwd: () => session.cwd,
|
|
189
|
+
busy: acquireBusy,
|
|
144
190
|
customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
|
|
145
191
|
}));
|
|
146
192
|
|
|
@@ -155,8 +201,12 @@
|
|
|
155
201
|
let focusLocked = $state(false);
|
|
156
202
|
let targetShard = $state<string | null>(null);
|
|
157
203
|
|
|
158
|
-
// Toolbar slot registry
|
|
204
|
+
// Toolbar slot registry. The 'busy' slot is always-visible at the registry
|
|
205
|
+
// level; the BusySlot component itself gates rendering on `active` so we
|
|
206
|
+
// don't have to invalidate the toolbar's `slots` derivation when the
|
|
207
|
+
// spinner toggles (the toolbar re-derives from ctx, not from busyActive).
|
|
159
208
|
const toolbarRegistry = new ToolbarSlotRegistry();
|
|
209
|
+
toolbarRegistry.register({ id: 'busy', order: 5, visible: () => true, component: BusySlot });
|
|
160
210
|
toolbarRegistry.register({ id: 'mode', order: 10, visible: () => true, component: ModeSlot });
|
|
161
211
|
toolbarRegistry.register({ id: 'focus-lock', order: 20, visible: (ctx) => ctx.mode.id === 'sh3', component: FocusLockSlot });
|
|
162
212
|
toolbarRegistry.register({ id: 'target-shard', order: 30, visible: (ctx) => ctx.mode.id === 'sh3', component: TargetShardSlot });
|
|
@@ -271,6 +321,7 @@
|
|
|
271
321
|
registry={toolbarRegistry}
|
|
272
322
|
ctx={{ mode, role }}
|
|
273
323
|
slotProps={{
|
|
324
|
+
busy: { active: busyActive, label: busyLabel },
|
|
274
325
|
mode: { mode, modes: visibleModes, onSelect: setMode },
|
|
275
326
|
'focus-lock': { locked: focusLocked, onToggle: () => (focusLocked = !focusLocked) },
|
|
276
327
|
'target-shard': { target: targetShard },
|
|
@@ -50,6 +50,36 @@ export interface ShellModeOutput {
|
|
|
50
50
|
* or `error()` is called so the renderer can show a loading affordance.
|
|
51
51
|
*/
|
|
52
52
|
stream(component: Component<any>, initialProps: Record<string, unknown>): StreamHandle;
|
|
53
|
+
/**
|
|
54
|
+
* Show a spinner in the shell header for the duration of a long-running
|
|
55
|
+
* operation. The optional label is rendered next to the spinner; pass
|
|
56
|
+
* undefined for an unlabelled indicator. Returns a clear handle — call
|
|
57
|
+
* clear() exactly once when the operation completes (idempotent).
|
|
58
|
+
*
|
|
59
|
+
* The framework already auto-spawns a spinner while a dispatch is in
|
|
60
|
+
* flight. Use this method for work that runs *outside* a dispatch (e.g.
|
|
61
|
+
* during the descriptor's `activate()` lifecycle hook, or background
|
|
62
|
+
* pre-fetching).
|
|
63
|
+
*/
|
|
64
|
+
busy(label?: string): BusyHandle;
|
|
65
|
+
/**
|
|
66
|
+
* Programmatically dispatch a line through another mode's resolution path.
|
|
67
|
+
*
|
|
68
|
+
* - `'sh3'` — full sh3 verb resolution (bypasses the mode-gating that
|
|
69
|
+
* normally restricts sh3-domain verbs to sh3 mode).
|
|
70
|
+
* - `'bash'` — forward to the WS session. Lazy-connects on first use.
|
|
71
|
+
* Resolves immediately on send (output streams asynchronously to the
|
|
72
|
+
* scrollback via the WS protocol).
|
|
73
|
+
* - any other registered mode id — routes through that mode's `dispatch()`
|
|
74
|
+
* handler. Output flows to the same scrollback.
|
|
75
|
+
*
|
|
76
|
+
* Throws synchronously on:
|
|
77
|
+
* - unknown mode id
|
|
78
|
+
* - role mismatch (e.g. a non-admin context targeting `'bash'`)
|
|
79
|
+
* - self-invocation (a mode invoking its own id — direct calls into the
|
|
80
|
+
* mode's helpers should be used instead)
|
|
81
|
+
*/
|
|
82
|
+
invoke(modeId: string, line: string): Promise<void>;
|
|
53
83
|
}
|
|
54
84
|
export interface RichEntryHandle {
|
|
55
85
|
/** Patch the entry's props. Triggers Svelte reactivity. */
|
|
@@ -63,3 +93,7 @@ export interface StreamHandle {
|
|
|
63
93
|
/** Mark the stream finished with an error; renders an error status. */
|
|
64
94
|
error(err: unknown): void;
|
|
65
95
|
}
|
|
96
|
+
export interface BusyHandle {
|
|
97
|
+
/** Remove this busy indicator. Idempotent. */
|
|
98
|
+
clear(): void;
|
|
99
|
+
}
|