sh3-core 0.15.1 → 0.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/actions/ctx-actions.svelte.test.js +111 -0
- package/dist/actions/dispatcher.svelte.js +23 -2
- package/dist/actions/dispatcher.test.js +33 -0
- package/dist/actions/listActionsFromEntries.test.js +78 -0
- package/dist/actions/listActive.d.ts +2 -1
- package/dist/actions/listActive.js +43 -17
- package/dist/actions/listeners.d.ts +16 -0
- package/dist/actions/listeners.js +68 -14
- package/dist/actions/programmatic-dispatch.svelte.test.d.ts +1 -0
- package/dist/actions/programmatic-dispatch.svelte.test.js +98 -0
- package/dist/actions/types.d.ts +37 -0
- package/dist/api.d.ts +1 -1
- package/dist/app-appearance/appearanceShard.svelte.js +19 -6
- package/dist/app-appearance/appearanceState.svelte.js +3 -3
- package/dist/host.js +2 -1
- package/dist/layouts-shard/LayoutSaveModal.svelte +145 -0
- package/dist/layouts-shard/LayoutSaveModal.svelte.d.ts +12 -0
- package/dist/layouts-shard/LayoutsSection.svelte +142 -0
- package/dist/layouts-shard/LayoutsSection.svelte.d.ts +3 -0
- package/dist/layouts-shard/filter.d.ts +3 -0
- package/dist/layouts-shard/filter.js +66 -0
- package/dist/layouts-shard/filter.test.d.ts +1 -0
- package/dist/layouts-shard/filter.test.js +123 -0
- package/dist/layouts-shard/index.d.ts +1 -0
- package/dist/layouts-shard/index.js +1 -0
- package/dist/layouts-shard/layoutsApi.d.ts +12 -0
- package/dist/layouts-shard/layoutsApi.js +41 -0
- package/dist/layouts-shard/layoutsApi.test.d.ts +1 -0
- package/dist/layouts-shard/layoutsApi.test.js +74 -0
- package/dist/layouts-shard/layoutsShard.svelte.d.ts +11 -0
- package/dist/layouts-shard/layoutsShard.svelte.js +231 -0
- package/dist/layouts-shard/layoutsShard.svelte.test.d.ts +1 -0
- package/dist/layouts-shard/layoutsShard.svelte.test.js +215 -0
- package/dist/layouts-shard/layoutsState.svelte.d.ts +9 -0
- package/dist/layouts-shard/layoutsState.svelte.js +50 -0
- package/dist/layouts-shard/layoutsState.test.d.ts +1 -0
- package/dist/layouts-shard/layoutsState.test.js +43 -0
- package/dist/layouts-shard/types.d.ts +21 -0
- package/dist/layouts-shard/types.js +6 -0
- package/dist/{app-appearance/AppAppearanceModal.svelte → overlays/EntityAppearanceModal.svelte} +36 -31
- package/dist/overlays/EntityAppearanceModal.svelte.d.ts +19 -0
- package/dist/overlays/EntityAppearanceModal.test.d.ts +1 -0
- package/dist/overlays/EntityAppearanceModal.test.js +57 -0
- package/dist/overlays/FloatFrame.svelte +17 -0
- package/dist/overlays/float.d.ts +17 -1
- package/dist/overlays/float.js +16 -0
- package/dist/overlays/float.test.js +35 -0
- package/dist/sh3core-shard/ShellHome.svelte +3 -0
- package/dist/shards/activate.svelte.js +11 -2
- package/dist/shards/types.d.ts +33 -1
- package/dist/shell-shard/CommandLine.svelte +143 -0
- package/dist/shell-shard/CommandLine.svelte.d.ts +26 -0
- package/dist/shell-shard/CommandLine.svelte.test.d.ts +1 -0
- package/dist/shell-shard/CommandLine.svelte.test.js +43 -0
- package/dist/shell-shard/InputLine.svelte +17 -40
- package/dist/shell-shard/InputLine.svelte.d.ts +2 -0
- package/dist/shell-shard/ScrollbackView.svelte +10 -3
- package/dist/shell-shard/ScrollbackView.svelte.d.ts +1 -0
- package/dist/shell-shard/Terminal.svelte +93 -22
- package/dist/shell-shard/buffer-store.d.ts +15 -0
- package/dist/shell-shard/buffer-store.js +124 -0
- package/dist/shell-shard/buffer-store.svelte.test.d.ts +1 -0
- package/dist/shell-shard/buffer-store.svelte.test.js +107 -0
- package/dist/shell-shard/buffer-zone-state.svelte.d.ts +38 -0
- package/dist/shell-shard/buffer-zone-state.svelte.js +31 -0
- package/dist/shell-shard/contract.d.ts +7 -0
- package/dist/shell-shard/dispatch-custom.test.js +3 -1
- package/dist/shell-shard/dispatch-gating.test.js +6 -2
- package/dist/shell-shard/dispatch-invoke.test.js +10 -8
- package/dist/shell-shard/dispatch.d.ts +7 -2
- package/dist/shell-shard/dispatch.js +23 -27
- package/dist/shell-shard/display-cwd.d.ts +1 -0
- package/dist/shell-shard/display-cwd.js +27 -0
- package/dist/shell-shard/display-cwd.test.d.ts +1 -0
- package/dist/shell-shard/display-cwd.test.js +29 -0
- package/dist/shell-shard/entries/StatusEntry.svelte +2 -0
- package/dist/shell-shard/manifest.js +2 -1
- package/dist/shell-shard/manifest.test.d.ts +1 -0
- package/dist/shell-shard/manifest.test.js +8 -0
- package/dist/shell-shard/mode-buffer.svelte.d.ts +8 -0
- package/dist/shell-shard/mode-buffer.svelte.js +19 -0
- package/dist/shell-shard/mode-buffer.svelte.test.d.ts +1 -0
- package/dist/shell-shard/mode-buffer.svelte.test.js +25 -0
- package/dist/shell-shard/modes/builtin.js +2 -0
- package/dist/shell-shard/modes/types.d.ts +8 -0
- package/dist/shell-shard/protocol.d.ts +12 -6
- package/dist/shell-shard/replay.d.ts +3 -0
- package/dist/shell-shard/replay.js +44 -0
- package/dist/shell-shard/replay.svelte.test.d.ts +1 -0
- package/dist/shell-shard/replay.svelte.test.js +47 -0
- package/dist/shell-shard/rich-registry.d.ts +5 -0
- package/dist/shell-shard/rich-registry.js +25 -0
- package/dist/shell-shard/rich-registry.test.d.ts +1 -0
- package/dist/shell-shard/rich-registry.test.js +31 -0
- package/dist/shell-shard/scrollback.svelte.d.ts +2 -0
- package/dist/shell-shard/scrollback.svelte.js +23 -0
- package/dist/shell-shard/scrollback.svelte.test.d.ts +1 -0
- package/dist/shell-shard/scrollback.svelte.test.js +51 -0
- package/dist/shell-shard/session-client.svelte.d.ts +18 -2
- package/dist/shell-shard/session-client.svelte.js +21 -4
- package/dist/shell-shard/shellApi.d.ts +2 -1
- package/dist/shell-shard/shellApi.js +31 -3
- package/dist/shell-shard/shellApi.svelte.test.d.ts +1 -0
- package/dist/shell-shard/shellApi.svelte.test.js +59 -0
- package/dist/shell-shard/shellShard.svelte.js +11 -1
- package/dist/shell-shard/terminal-dispatch.test.js +3 -1
- package/dist/shell-shard/verbs/apps.js +7 -0
- package/dist/shell-shard/verbs/env.js +4 -0
- package/dist/shell-shard/verbs/help.js +4 -0
- package/dist/shell-shard/verbs/history.js +8 -1
- package/dist/shell-shard/verbs/index.js +0 -8
- package/dist/shell-shard/verbs/shards.js +4 -0
- package/dist/shell-shard/verbs/views.js +4 -0
- package/dist/shell-shard/verbs/zones.js +7 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/dist/app-appearance/AppAppearanceModal.svelte.d.ts +0 -8
- package/dist/shell-shard/verbs/cat.d.ts +0 -2
- package/dist/shell-shard/verbs/cat.js +0 -35
- package/dist/shell-shard/verbs/cd.test.js +0 -56
- package/dist/shell-shard/verbs/ls.d.ts +0 -2
- package/dist/shell-shard/verbs/ls.js +0 -30
- package/dist/shell-shard/verbs/ls.test.js +0 -49
- package/dist/shell-shard/verbs/session.d.ts +0 -4
- package/dist/shell-shard/verbs/session.js +0 -99
- /package/dist/{shell-shard/verbs/cd.test.d.ts → actions/ctx-actions.svelte.test.d.ts} +0 -0
- /package/dist/{shell-shard/verbs/ls.test.d.ts → actions/listActionsFromEntries.test.d.ts} +0 -0
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import type { SessionClient } from './session-client.svelte';
|
|
3
|
+
import CommandLine from './CommandLine.svelte';
|
|
3
4
|
|
|
4
5
|
interface Props {
|
|
5
6
|
cwd: string;
|
|
7
|
+
/** When false, hide the cwd chip in front of the prompt. */
|
|
8
|
+
showCwd?: boolean;
|
|
6
9
|
locked: boolean; // true while a process is running
|
|
7
10
|
history: string[]; // persisted history, newest last
|
|
8
11
|
session: SessionClient;
|
|
9
12
|
onSubmit: (line: string) => void; // called with the raw entered line
|
|
10
13
|
}
|
|
11
|
-
let { cwd, locked, history, session, onSubmit }: Props = $props();
|
|
14
|
+
let { cwd, showCwd = true, locked, history, session, onSubmit }: Props = $props();
|
|
12
15
|
|
|
13
16
|
let draft = $state('');
|
|
14
17
|
let historyIndex = $state<number | null>(null); // null = live draft
|
|
15
18
|
let savedDraft = $state(''); // restored when user returns from history
|
|
16
19
|
|
|
17
|
-
let input: HTMLInputElement | null = $state(null);
|
|
18
|
-
|
|
19
20
|
function submit() {
|
|
20
21
|
if (locked) return;
|
|
21
22
|
const line = draft;
|
|
@@ -74,6 +75,8 @@
|
|
|
74
75
|
e.preventDefault();
|
|
75
76
|
navHistoryDown();
|
|
76
77
|
} else if (e.ctrlKey && e.key === 'c') {
|
|
78
|
+
// CommandLine has already filtered this case to no-selection, so the
|
|
79
|
+
// user is asking to clear the draft, not copy.
|
|
77
80
|
e.preventDefault();
|
|
78
81
|
draft = '';
|
|
79
82
|
historyIndex = null;
|
|
@@ -83,52 +86,26 @@
|
|
|
83
86
|
submit();
|
|
84
87
|
}
|
|
85
88
|
}
|
|
86
|
-
|
|
87
|
-
// Re-focus the input when the locked state flips to false
|
|
88
|
-
$effect(() => {
|
|
89
|
-
if (!locked && input) {
|
|
90
|
-
input.focus();
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
89
|
</script>
|
|
94
90
|
|
|
95
|
-
<div class="shell-input"
|
|
96
|
-
<
|
|
97
|
-
<span class="shell-input-arrow">❯</span>
|
|
98
|
-
<input
|
|
99
|
-
bind:this={input}
|
|
91
|
+
<div class="shell-input-row">
|
|
92
|
+
<CommandLine
|
|
100
93
|
bind:value={draft}
|
|
101
|
-
type="text"
|
|
102
94
|
disabled={locked}
|
|
95
|
+
name="shell-cmdline"
|
|
103
96
|
onkeydown={onKeyDown}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
97
|
+
>
|
|
98
|
+
{#snippet prefix()}
|
|
99
|
+
{#if showCwd}<span class="shell-input-cwd">{cwd}</span>{/if}
|
|
100
|
+
<span class="shell-input-arrow">❯</span>
|
|
101
|
+
{/snippet}
|
|
102
|
+
</CommandLine>
|
|
109
103
|
</div>
|
|
110
104
|
|
|
111
105
|
<style>
|
|
112
|
-
.shell-input {
|
|
113
|
-
display: flex;
|
|
114
|
-
gap: 8px;
|
|
115
|
-
padding: 4px 8px;
|
|
106
|
+
.shell-input-row {
|
|
116
107
|
border-top: 1px solid var(--shell-border, #333);
|
|
117
|
-
font-family: var(--shell-font-mono, monospace);
|
|
118
108
|
}
|
|
119
109
|
.shell-input-cwd { color: var(--shell-fg-muted, #888); }
|
|
120
|
-
.shell-input-arrow { color: var(--shell-accent, #6cf); }
|
|
121
|
-
.shell-input-field {
|
|
122
|
-
padding: 0;
|
|
123
|
-
flex: 1 1 auto;
|
|
124
|
-
background: transparent;
|
|
125
|
-
border: 0;
|
|
126
|
-
outline: 0;
|
|
127
|
-
color: var(--shell-fg, #ddd);
|
|
128
|
-
font: inherit;
|
|
129
|
-
}
|
|
130
|
-
.shell-input.locked .shell-input-field {
|
|
131
|
-
opacity: 0.5;
|
|
132
|
-
cursor: default;
|
|
133
|
-
}
|
|
110
|
+
.shell-input-arrow { color: var(--shell-accent, #6cf); margin-left: 6px; }
|
|
134
111
|
</style>
|
|
@@ -4,11 +4,13 @@
|
|
|
4
4
|
import PromptEntry from './entries/PromptEntry.svelte';
|
|
5
5
|
import StatusEntry from './entries/StatusEntry.svelte';
|
|
6
6
|
import RichEntry from './entries/RichEntry.svelte';
|
|
7
|
+
import { lookupRichComponent } from './rich-registry';
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
9
10
|
scrollback: Scrollback;
|
|
11
|
+
showPromptCwd: boolean;
|
|
10
12
|
}
|
|
11
|
-
let { scrollback }: Props = $props();
|
|
13
|
+
let { scrollback, showPromptCwd }: Props = $props();
|
|
12
14
|
|
|
13
15
|
let container: HTMLDivElement | null = $state(null);
|
|
14
16
|
let content: HTMLDivElement | null = $state(null);
|
|
@@ -48,11 +50,16 @@
|
|
|
48
50
|
{#if entry.kind === 'text'}
|
|
49
51
|
<TextEntry stream={entry.stream} chunks={entry.chunks} />
|
|
50
52
|
{:else if entry.kind === 'prompt'}
|
|
51
|
-
<PromptEntry cwd={entry.cwd} line={entry.line} />
|
|
53
|
+
<PromptEntry cwd={showPromptCwd ? entry.cwd : ''} line={entry.line} />
|
|
52
54
|
{:else if entry.kind === 'status'}
|
|
53
55
|
<StatusEntry text={entry.text} level={entry.level} />
|
|
54
56
|
{:else if entry.kind === 'rich'}
|
|
55
|
-
|
|
57
|
+
{@const comp = entry.component ?? (entry.componentKey ? lookupRichComponent(entry.componentKey) : null)}
|
|
58
|
+
{#if comp}
|
|
59
|
+
<RichEntry component={comp} componentProps={entry.props} />
|
|
60
|
+
{:else}
|
|
61
|
+
<StatusEntry text={`<rich entry: ${entry.componentKey ?? 'unknown'} not registered>`} level="info" />
|
|
62
|
+
{/if}
|
|
56
63
|
{/if}
|
|
57
64
|
{/each}
|
|
58
65
|
</div>
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { onMount, onDestroy, untrack } from 'svelte';
|
|
3
|
-
import {
|
|
3
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
4
|
+
import { BufferStore } from './buffer-store';
|
|
5
|
+
import { applyReplayEvents } from './replay';
|
|
6
|
+
import { shortenCwd } from './display-cwd';
|
|
4
7
|
import ScrollbackView from './ScrollbackView.svelte';
|
|
5
8
|
import InputLine from './InputLine.svelte';
|
|
6
9
|
import { SessionClient } from './session-client.svelte';
|
|
@@ -33,7 +36,24 @@
|
|
|
33
36
|
}
|
|
34
37
|
let { shell, wsUrl, userId, role, contributions }: Props = $props();
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
// Per-mode buffer map. Each ModeBuffer bundles a Scrollback + history +
|
|
40
|
+
// locked flag and is materialized lazily on first switch into that mode.
|
|
41
|
+
// ScrollbackView, InputLine, and dispatch all read from the active mode's
|
|
42
|
+
// buffer; switching mode rebinds without losing the previous buffer.
|
|
43
|
+
// BufferStore hydrates from the workspace zone on first access of each
|
|
44
|
+
// mode's buffer, restoring scrollback + history across page reloads (SH8).
|
|
45
|
+
const bufferStore = untrack(() => new BufferStore());
|
|
46
|
+
const buffers = new Map<string, ModeBuffer>();
|
|
47
|
+
function getBuffer(modeId: string): ModeBuffer {
|
|
48
|
+
let b = buffers.get(modeId);
|
|
49
|
+
if (!b) {
|
|
50
|
+
b = new ModeBuffer(modeId);
|
|
51
|
+
bufferStore.hydrate(b);
|
|
52
|
+
buffers.set(modeId, b);
|
|
53
|
+
}
|
|
54
|
+
return b;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
const resolver = new VerbRegistry();
|
|
38
58
|
const fs = new TenantFsClient();
|
|
39
59
|
|
|
@@ -65,6 +85,7 @@
|
|
|
65
85
|
requiresRole: d.requiresRole,
|
|
66
86
|
transport: 'custom',
|
|
67
87
|
autoRelocate: d.autoRelocate ?? false,
|
|
88
|
+
showCwd: d.showCwd ?? false,
|
|
68
89
|
};
|
|
69
90
|
}
|
|
70
91
|
|
|
@@ -95,6 +116,39 @@
|
|
|
95
116
|
}),
|
|
96
117
|
);
|
|
97
118
|
|
|
119
|
+
// Active-mode buffer derivation. Reads `mode.id` reactively so that
|
|
120
|
+
// mode-switch flips ScrollbackView and InputLine bindings in lockstep.
|
|
121
|
+
const currentBuffer = $derived(getBuffer(mode.id));
|
|
122
|
+
|
|
123
|
+
// Mirror the server's per-mode history bundle into each ModeBuffer.
|
|
124
|
+
// The bundle arrives on attach and re-arrives on each reconnect. We
|
|
125
|
+
// only overwrite a buffer's history when the server has more lines
|
|
126
|
+
// than the client — preserves optimistic appends made between submits
|
|
127
|
+
// and the server's authoritative reply.
|
|
128
|
+
$effect(() => {
|
|
129
|
+
for (const [modeId, lines] of Object.entries(session.historyByMode)) {
|
|
130
|
+
const buf = getBuffer(modeId);
|
|
131
|
+
if (buf.history.length < lines.length) {
|
|
132
|
+
buf.history = [...lines];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Persist the active buffer on every meaningful change. The flush is
|
|
138
|
+
// debounced 200ms inside BufferStore, so a stream of stdout chunks
|
|
139
|
+
// results in a single zone write per quiescent moment. The bash
|
|
140
|
+
// buffer additionally stamps session.lastSeq so a page reload can ask
|
|
141
|
+
// the server for replay events newer than the persisted snapshot.
|
|
142
|
+
$effect(() => {
|
|
143
|
+
void currentBuffer.scrollback.entries.length;
|
|
144
|
+
void currentBuffer.history.length;
|
|
145
|
+
if (currentBuffer.modeId === 'bash') {
|
|
146
|
+
bufferStore.flush(currentBuffer, session.lastSeq);
|
|
147
|
+
} else {
|
|
148
|
+
bufferStore.flush(currentBuffer);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
98
152
|
function setMode(id: string): void {
|
|
99
153
|
const next = visibleModes.find((m) => m.id === id);
|
|
100
154
|
if (!next) return;
|
|
@@ -104,9 +158,6 @@
|
|
|
104
158
|
cancelDispatch();
|
|
105
159
|
mode = next;
|
|
106
160
|
writeLastMode(userId, id);
|
|
107
|
-
if (next.transport !== 'ws') {
|
|
108
|
-
scrollback.push({ kind: 'status', text: 'mode switch: reload to take effect for server-shell changes', level: 'info', ts: Date.now() });
|
|
109
|
-
}
|
|
110
161
|
}
|
|
111
162
|
|
|
112
163
|
// If the active mode disappears (shard unloaded), fall back to sh3 — or
|
|
@@ -118,7 +169,7 @@
|
|
|
118
169
|
const lostId = mode.id;
|
|
119
170
|
mode = fallback;
|
|
120
171
|
writeLastMode(userId, fallback.id);
|
|
121
|
-
scrollback.push({
|
|
172
|
+
getBuffer(fallback.id).scrollback.push({
|
|
122
173
|
kind: 'status',
|
|
123
174
|
text: `mode '${lostId}' is no longer available — switched to '${fallback.id}'`,
|
|
124
175
|
level: 'warn',
|
|
@@ -182,7 +233,7 @@
|
|
|
182
233
|
mode: () => mode,
|
|
183
234
|
role: () => role,
|
|
184
235
|
resolver,
|
|
185
|
-
|
|
236
|
+
buffer: () => currentBuffer,
|
|
186
237
|
session,
|
|
187
238
|
shell: shellWithModes,
|
|
188
239
|
fs,
|
|
@@ -191,8 +242,6 @@
|
|
|
191
242
|
customMode: (id: string) => contributedModes.find((d) => d.id === id) ?? null,
|
|
192
243
|
}));
|
|
193
244
|
|
|
194
|
-
let locked = $state(false);
|
|
195
|
-
|
|
196
245
|
// ---------------------------------------------------------------------------
|
|
197
246
|
// Auto-relocate: track the focused shard and update session.cwd when focus
|
|
198
247
|
// changes to a shard whose documents directory exists. focusLocked and
|
|
@@ -266,18 +315,30 @@
|
|
|
266
315
|
});
|
|
267
316
|
|
|
268
317
|
function handleServerMessage(msg: ServerMessage) {
|
|
318
|
+
if (msg.t === 'replay') {
|
|
319
|
+
// Server's ring-buffer events between the last snapshot and now.
|
|
320
|
+
// Fold into the bash buffer using the same routing as live events.
|
|
321
|
+
applyReplayEvents(getBuffer('bash'), msg.events);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
269
324
|
if (msg.t !== 'event') return;
|
|
270
325
|
const e = msg.event;
|
|
326
|
+
// WS events always route to the bash buffer regardless of the active
|
|
327
|
+
// mode, so output keeps accumulating while the user is viewing sh3 or
|
|
328
|
+
// a custom mode. The bash buffer's locked flag tracks the running
|
|
329
|
+
// process; switching to a non-bash buffer naturally unlocks the input
|
|
330
|
+
// bar because each ModeBuffer carries its own locked state.
|
|
331
|
+
const bashBuf = getBuffer('bash');
|
|
271
332
|
switch (e.kind) {
|
|
272
333
|
case 'prompt':
|
|
273
|
-
scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
|
|
274
|
-
locked = true;
|
|
334
|
+
bashBuf.scrollback.push({ kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts });
|
|
335
|
+
bashBuf.locked = true;
|
|
275
336
|
break;
|
|
276
337
|
case 'stdout':
|
|
277
|
-
scrollback.push({ kind: 'text', stream: 'stdout', chunks: [e.data], ts: e.ts });
|
|
338
|
+
bashBuf.scrollback.push({ kind: 'text', stream: 'stdout', chunks: [e.data], ts: e.ts });
|
|
278
339
|
break;
|
|
279
340
|
case 'stderr':
|
|
280
|
-
scrollback.push({ kind: 'text', stream: 'stderr', chunks: [e.data], ts: e.ts });
|
|
341
|
+
bashBuf.scrollback.push({ kind: 'text', stream: 'stderr', chunks: [e.data], ts: e.ts });
|
|
281
342
|
break;
|
|
282
343
|
case 'exit':
|
|
283
344
|
// Match real-shell UX: stay silent on clean exit. Only surface
|
|
@@ -285,7 +346,7 @@
|
|
|
285
346
|
// processes (SIGINT, spawn errors, etc.). `code === null` with
|
|
286
347
|
// no signal happens on clean close too — treat as success.
|
|
287
348
|
if (e.signal || (e.code !== null && e.code !== 0)) {
|
|
288
|
-
scrollback.push({
|
|
349
|
+
bashBuf.scrollback.push({
|
|
289
350
|
kind: 'status',
|
|
290
351
|
text: e.signal
|
|
291
352
|
? `shell: process exited (${e.signal})`
|
|
@@ -294,10 +355,10 @@
|
|
|
294
355
|
ts: e.ts,
|
|
295
356
|
});
|
|
296
357
|
}
|
|
297
|
-
locked = false;
|
|
358
|
+
bashBuf.locked = false;
|
|
298
359
|
break;
|
|
299
360
|
case 'status':
|
|
300
|
-
scrollback.push({ kind: 'status', text: e.text, level: e.level, ts: e.ts });
|
|
361
|
+
bashBuf.scrollback.push({ kind: 'status', text: e.text, level: e.level, ts: e.ts });
|
|
301
362
|
break;
|
|
302
363
|
}
|
|
303
364
|
}
|
|
@@ -306,9 +367,18 @@
|
|
|
306
367
|
|
|
307
368
|
onMount(() => {
|
|
308
369
|
unsub = session.onMessage(handleServerMessage);
|
|
309
|
-
|
|
310
|
-
|
|
370
|
+
// Pre-warm the bash buffer + seed lastSeq so the WS hello replays
|
|
371
|
+
// only events newer than the persisted snapshot. This closes the
|
|
372
|
+
// gap between last flush and reconnect on page reload.
|
|
373
|
+
getBuffer('bash');
|
|
374
|
+
const persistedSeq = bufferStore.readLastSeq('bash');
|
|
375
|
+
if (persistedSeq !== undefined) {
|
|
376
|
+
session.lastSeq = persistedSeq;
|
|
311
377
|
}
|
|
378
|
+
// Always-connect: shell-shard is admin-gated, idle WS cost is
|
|
379
|
+
// negligible, and non-bash modes still need to log mode-tagged
|
|
380
|
+
// history server-side via history-log messages.
|
|
381
|
+
session.connect();
|
|
312
382
|
});
|
|
313
383
|
|
|
314
384
|
onDestroy(() => {
|
|
@@ -328,11 +398,12 @@
|
|
|
328
398
|
'target-shard': { target: targetShard },
|
|
329
399
|
}}
|
|
330
400
|
/>
|
|
331
|
-
<ScrollbackView {scrollback} />
|
|
401
|
+
<ScrollbackView showPromptCwd={mode.showCwd || false} scrollback={currentBuffer.scrollback} />
|
|
332
402
|
<InputLine
|
|
333
|
-
cwd={session.cwd}
|
|
334
|
-
{
|
|
335
|
-
|
|
403
|
+
cwd={shortenCwd(session.cwd, session.tenantRoot)}
|
|
404
|
+
showCwd={mode.showCwd !== false}
|
|
405
|
+
locked={currentBuffer.locked}
|
|
406
|
+
history={currentBuffer.history}
|
|
336
407
|
{session}
|
|
337
408
|
onSubmit={dispatch}
|
|
338
409
|
/>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
2
|
+
export declare class BufferStore {
|
|
3
|
+
private timers;
|
|
4
|
+
/** Restore a ModeBuffer's contents from the workspace zone, if any. */
|
|
5
|
+
hydrate(buffer: ModeBuffer): void;
|
|
6
|
+
/** Schedule a debounced write of the buffer's current state. */
|
|
7
|
+
flush(buffer: ModeBuffer, lastSeq?: number): void;
|
|
8
|
+
/** Force a synchronous write, bypassing debounce. Used on shard deactivate. */
|
|
9
|
+
forceFlush(buffer: ModeBuffer, lastSeq?: number): void;
|
|
10
|
+
/** Read the persisted lastSeq for a mode, if any. */
|
|
11
|
+
readLastSeq(modeId: string): number | undefined;
|
|
12
|
+
private write;
|
|
13
|
+
private toPersisted;
|
|
14
|
+
private toLive;
|
|
15
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* BufferStore — serialize ModeBuffer state into the shell-shard's
|
|
3
|
+
* workspace zone with a 200ms debounce, cap at 500 entries per mode,
|
|
4
|
+
* and gracefully degrade non-serializable rich entries.
|
|
5
|
+
*
|
|
6
|
+
* Hydrate fills a fresh ModeBuffer from a previously-persisted snapshot.
|
|
7
|
+
* Flush schedules a debounced write of the buffer's current state. The
|
|
8
|
+
* bash buffer additionally carries a `lastSeq` cursor so the WS hello
|
|
9
|
+
* after reload can ask the server for replay events newer than the
|
|
10
|
+
* persisted snapshot.
|
|
11
|
+
*/
|
|
12
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
13
|
+
import { readBuffer, writeBuffer, } from './buffer-zone-state.svelte';
|
|
14
|
+
const PERSIST_CAP = 500;
|
|
15
|
+
const DEBOUNCE_MS = 200;
|
|
16
|
+
export class BufferStore {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.timers = new Map();
|
|
19
|
+
}
|
|
20
|
+
/** Restore a ModeBuffer's contents from the workspace zone, if any. */
|
|
21
|
+
hydrate(buffer) {
|
|
22
|
+
const persisted = readBuffer(buffer.modeId);
|
|
23
|
+
if (!persisted)
|
|
24
|
+
return;
|
|
25
|
+
const live = [];
|
|
26
|
+
for (const p of persisted.entries) {
|
|
27
|
+
live.push(this.toLive(p));
|
|
28
|
+
}
|
|
29
|
+
// restore() splices in place AND bumps the module-scoped id counter past
|
|
30
|
+
// the restored max — without that bump, the next push() after a reload
|
|
31
|
+
// would mint an id colliding with a hydrated key and crash the keyed
|
|
32
|
+
// {#each} in ScrollbackView.
|
|
33
|
+
buffer.scrollback.restore(live);
|
|
34
|
+
buffer.history = [...persisted.history];
|
|
35
|
+
}
|
|
36
|
+
/** Schedule a debounced write of the buffer's current state. */
|
|
37
|
+
flush(buffer, lastSeq) {
|
|
38
|
+
const existing = this.timers.get(buffer.modeId);
|
|
39
|
+
if (existing)
|
|
40
|
+
clearTimeout(existing);
|
|
41
|
+
const timer = setTimeout(() => {
|
|
42
|
+
this.timers.delete(buffer.modeId);
|
|
43
|
+
this.write(buffer, lastSeq);
|
|
44
|
+
}, DEBOUNCE_MS);
|
|
45
|
+
this.timers.set(buffer.modeId, timer);
|
|
46
|
+
}
|
|
47
|
+
/** Force a synchronous write, bypassing debounce. Used on shard deactivate. */
|
|
48
|
+
forceFlush(buffer, lastSeq) {
|
|
49
|
+
const existing = this.timers.get(buffer.modeId);
|
|
50
|
+
if (existing) {
|
|
51
|
+
clearTimeout(existing);
|
|
52
|
+
this.timers.delete(buffer.modeId);
|
|
53
|
+
}
|
|
54
|
+
this.write(buffer, lastSeq);
|
|
55
|
+
}
|
|
56
|
+
/** Read the persisted lastSeq for a mode, if any. */
|
|
57
|
+
readLastSeq(modeId) {
|
|
58
|
+
var _a;
|
|
59
|
+
return (_a = readBuffer(modeId)) === null || _a === void 0 ? void 0 : _a.lastSeq;
|
|
60
|
+
}
|
|
61
|
+
write(buffer, lastSeq) {
|
|
62
|
+
const entries = buffer.scrollback.entries.slice(-PERSIST_CAP).map((e) => this.toPersisted(e));
|
|
63
|
+
const persisted = Object.assign({ entries, history: [...buffer.history] }, (lastSeq !== undefined ? { lastSeq } : {}));
|
|
64
|
+
writeBuffer(buffer.modeId, persisted);
|
|
65
|
+
}
|
|
66
|
+
toPersisted(e) {
|
|
67
|
+
if (e.kind === 'rich') {
|
|
68
|
+
const key = e.componentKey;
|
|
69
|
+
if (!key) {
|
|
70
|
+
return {
|
|
71
|
+
id: e.id,
|
|
72
|
+
kind: 'status',
|
|
73
|
+
text: '<rich entry: not restorable (no componentKey)>',
|
|
74
|
+
level: 'info',
|
|
75
|
+
ts: e.ts,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
const propsJson = JSON.stringify(e.props);
|
|
80
|
+
return {
|
|
81
|
+
id: e.id,
|
|
82
|
+
kind: 'rich',
|
|
83
|
+
componentKey: key,
|
|
84
|
+
props: JSON.parse(propsJson),
|
|
85
|
+
ts: e.ts,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
catch (_a) {
|
|
89
|
+
return {
|
|
90
|
+
id: e.id,
|
|
91
|
+
kind: 'status',
|
|
92
|
+
text: `<rich entry: ${key} not restorable>`,
|
|
93
|
+
level: 'info',
|
|
94
|
+
ts: e.ts,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (e.kind === 'prompt')
|
|
99
|
+
return { id: e.id, kind: 'prompt', cwd: e.cwd, line: e.line, ts: e.ts };
|
|
100
|
+
if (e.kind === 'text')
|
|
101
|
+
return { id: e.id, kind: 'text', stream: e.stream, chunks: [...e.chunks], ts: e.ts };
|
|
102
|
+
return { id: e.id, kind: 'status', text: e.text, level: e.level, ts: e.ts };
|
|
103
|
+
}
|
|
104
|
+
toLive(p) {
|
|
105
|
+
if (p.kind === 'rich') {
|
|
106
|
+
// Component is recovered at render-time via lookupRichComponent in
|
|
107
|
+
// ScrollbackView. The live entry's `component` field is unused after
|
|
108
|
+
// hydration; pass an empty placeholder to satisfy the type.
|
|
109
|
+
return {
|
|
110
|
+
id: p.id,
|
|
111
|
+
kind: 'rich',
|
|
112
|
+
componentKey: p.componentKey,
|
|
113
|
+
component: undefined,
|
|
114
|
+
props: p.props,
|
|
115
|
+
ts: p.ts,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (p.kind === 'prompt')
|
|
119
|
+
return { id: p.id, kind: 'prompt', cwd: p.cwd, line: p.line, ts: p.ts };
|
|
120
|
+
if (p.kind === 'text')
|
|
121
|
+
return { id: p.id, kind: 'text', stream: p.stream, chunks: [...p.chunks], ts: p.ts };
|
|
122
|
+
return { id: p.id, kind: 'status', text: p.text, level: p.level, ts: p.ts };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
|
+
import { __resetForTests as resetZone, readBuffer, writeBuffer, } from './buffer-zone-state.svelte';
|
|
3
|
+
import { BufferStore } from './buffer-store';
|
|
4
|
+
import { ModeBuffer } from './mode-buffer.svelte';
|
|
5
|
+
describe('BufferStore', () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
resetZone();
|
|
8
|
+
vi.useFakeTimers();
|
|
9
|
+
});
|
|
10
|
+
it('hydrates an empty buffer when zone has nothing', () => {
|
|
11
|
+
const store = new BufferStore();
|
|
12
|
+
const buf = new ModeBuffer('sh3');
|
|
13
|
+
store.hydrate(buf);
|
|
14
|
+
expect(buf.scrollback.entries).toEqual([]);
|
|
15
|
+
expect(buf.history).toEqual([]);
|
|
16
|
+
});
|
|
17
|
+
it('persists a serializable buffer after debounce', () => {
|
|
18
|
+
const store = new BufferStore();
|
|
19
|
+
const buf = new ModeBuffer('sh3');
|
|
20
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'ls', ts: 1 });
|
|
21
|
+
buf.history.push('ls');
|
|
22
|
+
store.flush(buf);
|
|
23
|
+
vi.advanceTimersByTime(200);
|
|
24
|
+
const persisted = readBuffer('sh3');
|
|
25
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(1);
|
|
26
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.history).toEqual(['ls']);
|
|
27
|
+
});
|
|
28
|
+
it('coalesces back-to-back flushes within debounce window', () => {
|
|
29
|
+
const store = new BufferStore();
|
|
30
|
+
const buf = new ModeBuffer('sh3');
|
|
31
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'a', ts: 1 });
|
|
32
|
+
store.flush(buf);
|
|
33
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'b', ts: 2 });
|
|
34
|
+
store.flush(buf);
|
|
35
|
+
vi.advanceTimersByTime(200);
|
|
36
|
+
const persisted = readBuffer('sh3');
|
|
37
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(2);
|
|
38
|
+
});
|
|
39
|
+
it('caps persisted entries at 500 (most recent)', () => {
|
|
40
|
+
const store = new BufferStore();
|
|
41
|
+
const buf = new ModeBuffer('sh3');
|
|
42
|
+
for (let i = 0; i < 600; i++) {
|
|
43
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: `cmd${i}`, ts: i });
|
|
44
|
+
}
|
|
45
|
+
store.flush(buf);
|
|
46
|
+
vi.advanceTimersByTime(200);
|
|
47
|
+
const persisted = readBuffer('sh3');
|
|
48
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(500);
|
|
49
|
+
const first = persisted.entries[0];
|
|
50
|
+
expect(first.kind === 'prompt' && first.line).toBe('cmd100');
|
|
51
|
+
});
|
|
52
|
+
it('hydrates a previously persisted buffer', () => {
|
|
53
|
+
writeBuffer('sh3', {
|
|
54
|
+
entries: [{ id: 'e1', kind: 'prompt', cwd: '/', line: 'restored', ts: 1 }],
|
|
55
|
+
history: ['restored'],
|
|
56
|
+
});
|
|
57
|
+
const store = new BufferStore();
|
|
58
|
+
const buf = new ModeBuffer('sh3');
|
|
59
|
+
store.hydrate(buf);
|
|
60
|
+
expect(buf.scrollback.entries).toHaveLength(1);
|
|
61
|
+
expect(buf.history).toEqual(['restored']);
|
|
62
|
+
});
|
|
63
|
+
it('hydrate prevents id collisions on subsequent pushes', () => {
|
|
64
|
+
// Persisted id well above any test-time mkId() counter so the bug
|
|
65
|
+
// would reproduce (duplicate keys) if hydrate did not bump the
|
|
66
|
+
// module-scoped id sequence past the restored max.
|
|
67
|
+
writeBuffer('sh3', {
|
|
68
|
+
entries: [{ id: 'e9999', kind: 'prompt', cwd: '/', line: 'restored', ts: 1 }],
|
|
69
|
+
history: [],
|
|
70
|
+
});
|
|
71
|
+
const store = new BufferStore();
|
|
72
|
+
const buf = new ModeBuffer('sh3');
|
|
73
|
+
store.hydrate(buf);
|
|
74
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'next', ts: 2 });
|
|
75
|
+
const ids = buf.scrollback.entries.map((e) => e.id);
|
|
76
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
77
|
+
expect(Number(ids[1].slice(1))).toBeGreaterThan(9999);
|
|
78
|
+
});
|
|
79
|
+
it('non-serializable rich props degrade to status entry on persist', () => {
|
|
80
|
+
const store = new BufferStore();
|
|
81
|
+
const buf = new ModeBuffer('sh3');
|
|
82
|
+
const circular = {};
|
|
83
|
+
circular.self = circular;
|
|
84
|
+
buf.scrollback.push({
|
|
85
|
+
kind: 'rich',
|
|
86
|
+
componentKey: 'apps-table',
|
|
87
|
+
component: {},
|
|
88
|
+
props: circular,
|
|
89
|
+
ts: 1,
|
|
90
|
+
});
|
|
91
|
+
store.flush(buf);
|
|
92
|
+
vi.advanceTimersByTime(200);
|
|
93
|
+
const persisted = readBuffer('sh3');
|
|
94
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries).toHaveLength(1);
|
|
95
|
+
expect(persisted === null || persisted === void 0 ? void 0 : persisted.entries[0].kind).toBe('status');
|
|
96
|
+
});
|
|
97
|
+
it('persists lastSeq when provided', () => {
|
|
98
|
+
var _a;
|
|
99
|
+
const store = new BufferStore();
|
|
100
|
+
const buf = new ModeBuffer('bash');
|
|
101
|
+
buf.scrollback.push({ kind: 'prompt', cwd: '/', line: 'ls', ts: 1 });
|
|
102
|
+
store.flush(buf, 42);
|
|
103
|
+
vi.advanceTimersByTime(200);
|
|
104
|
+
expect((_a = readBuffer('bash')) === null || _a === void 0 ? void 0 : _a.lastSeq).toBe(42);
|
|
105
|
+
expect(store.readLastSeq('bash')).toBe(42);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StateZones } from '../state/zones.svelte';
|
|
2
|
+
export interface PersistedEntryBase {
|
|
3
|
+
id: string;
|
|
4
|
+
ts: number;
|
|
5
|
+
}
|
|
6
|
+
export type PersistedEntry = (PersistedEntryBase & {
|
|
7
|
+
kind: 'prompt';
|
|
8
|
+
cwd: string;
|
|
9
|
+
line: string;
|
|
10
|
+
}) | (PersistedEntryBase & {
|
|
11
|
+
kind: 'text';
|
|
12
|
+
stream: 'stdout' | 'stderr';
|
|
13
|
+
chunks: string[];
|
|
14
|
+
}) | (PersistedEntryBase & {
|
|
15
|
+
kind: 'rich';
|
|
16
|
+
componentKey: string;
|
|
17
|
+
props: unknown;
|
|
18
|
+
}) | (PersistedEntryBase & {
|
|
19
|
+
kind: 'status';
|
|
20
|
+
text: string;
|
|
21
|
+
level: 'info' | 'warn' | 'error';
|
|
22
|
+
});
|
|
23
|
+
export interface PersistedBuffer {
|
|
24
|
+
entries: PersistedEntry[];
|
|
25
|
+
history: string[];
|
|
26
|
+
/** Only meaningful for the bash buffer — server's seq cursor at last flush. */
|
|
27
|
+
lastSeq?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface ShellWorkspaceZoneSchema {
|
|
30
|
+
workspace: {
|
|
31
|
+
buffers: Record<string, PersistedBuffer>;
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export declare function __bindZone(s: StateZones<ShellWorkspaceZoneSchema>): void;
|
|
35
|
+
export declare function __unbindZone(): void;
|
|
36
|
+
export declare function readBuffer(modeId: string): PersistedBuffer | undefined;
|
|
37
|
+
export declare function writeBuffer(modeId: string, buf: PersistedBuffer): void;
|
|
38
|
+
export declare function __resetForTests(): void;
|