Skip to content

Commit 266e2fa

Browse files
authored
Fix the app bar (especially on Windows) (#18)
2 parents dce0493 + 6369bd0 commit 266e2fa

22 files changed

Lines changed: 655 additions & 121 deletions

docs/specs/layout.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,10 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on moun
263263
Layout, scrollback, cwd, detached items, and alarm state are saved to persistent storage via a debounced save (500ms). Saves are triggered by layout changes, panel add/remove, and a 30s periodic interval. Saves are flushed immediately on PTY exit, `pagehide`, and extension shutdown requests.
264264

265265
On startup, recovery is priority-based:
266-
1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout)
267-
2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback
268-
3. **Empty state**: create a single new pane
266+
1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout). If the saved session covers every live PTY, restore the saved dockview layout when its visible panel set matches and restore saved detached items as doors.
267+
2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback, and spawn each PTY with the current default shell selection
268+
3. **Fallback/manual pane creation**: when no saved layout can be safely applied, add multiple panes as splits from the previous pane rather than tabs
269+
4. **Empty state**: create a single new pane
269270

270271
### Session UI state
271272

docs/specs/vscode.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,9 @@ Extension Host (always running while extension is active)
110110
This means:
111111
- Hiding the MouseTerm panel doesn't kill its PTYs.
112112
- VS Code toggling the panel visibility doesn't destroy sessions.
113-
- When the view becomes visible again, the webview reconnects to still-alive PTYs.
113+
- When the view becomes visible again, the webview reconnects to still-owned PTYs and reapplies the saved visible-pane layout when the saved session covers the live PTY set and the layout's visible panels match.
114114
- Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router.
115+
- Explicitly killed PTYs are tombstoned in the extension host so a late child-process `exit` event cannot recreate their buffer and make them reconnectable.
115116
- Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process.
116117

117118
#### PTY buffering
@@ -132,9 +133,10 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are
132133
- { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs
133134
- { type: 'pty:replay', id, data } // buffered output per PTY
134135
4. Webview restores terminals from replay data, resumes live stream
136+
5. If the saved session covers those live PTYs, the frontend uses the saved dockview layout when its visible panels match and restores saved detached doors; detached PTYs reconnect into the registry but remain doors instead of visible panes
135137
```
136138

137-
For cold-start restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs, injects saved scrollback (with trailing newline to avoid zsh `%` artifact), and restores dockview layout. The reconnect module (`reconnect.ts`) uses a 500ms timeout when waiting for the PTY list.
139+
For cold-start restore (no live PTYs), the webview falls back to saved session state: spawns new PTYs in saved CWDs using the currently selected MouseTerm shell, injects saved scrollback (with trailing newline to avoid zsh `%` artifact), and restores dockview layout. The reconnect module (`reconnect.ts`) uses a 500ms timeout when waiting for the PTY list.
138140

139141
### Message protocol
140142

lib/src/components/Pond.tsx

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ import {
4646
destroyTerminal,
4747
swapTerminals,
4848
setPendingShellOpts,
49+
getDefaultShellOpts,
4950
type SessionStatus,
5051
isSoftTodo,
5152
isHardTodo,
@@ -1515,6 +1516,26 @@ export function Pond({
15151516
detachedRef.current = restoredDetached;
15161517
setDetached(restoredDetached);
15171518

1519+
// Apply the currently-selected shell to a freshly-added panel. Panels
1520+
// that are reconnecting to an existing PTY already have a running shell,
1521+
// so their pendingShellOpts are never consumed — only first-time spawns
1522+
// use this.
1523+
const addTerminalPanel = (id: string) => {
1524+
const defaults = getDefaultShellOpts();
1525+
if (defaults?.shell) {
1526+
setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args });
1527+
}
1528+
const referencePanel = e.api.panels[e.api.panels.length - 1] ?? null;
1529+
const direction = referencePanel && referencePanel.api.width - referencePanel.api.height > 0 ? 'right' : 'below';
1530+
e.api.addPanel({
1531+
id,
1532+
component: 'terminal',
1533+
tabComponent: 'terminal',
1534+
title: '<unnamed>',
1535+
position: referencePanel ? { referencePanel: referencePanel.id, direction } : undefined,
1536+
});
1537+
};
1538+
15181539
if (layout && restored && restored.length > 0) {
15191540
// Cold-start restore: apply saved dockview layout (includes panel arrangement)
15201541
try {
@@ -1523,7 +1544,7 @@ export function Pond({
15231544
} catch {
15241545
// Layout restore failed — fall back to creating panels manually
15251546
for (const id of restored) {
1526-
e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '<unnamed>' });
1547+
addTerminalPanel(id);
15271548
}
15281549
setSelectedId(restored[0]);
15291550
}
@@ -1533,7 +1554,7 @@ export function Pond({
15331554
? restored
15341555
: [generatePaneId()];
15351556
for (const id of paneIds) {
1536-
e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '<unnamed>' });
1557+
addTerminalPanel(id);
15371558
}
15381559
setSelectedId(paneIds[0]);
15391560
}
@@ -2122,6 +2143,11 @@ export function Pond({
21222143
if (!api) return;
21232144
const newId = generatePaneId();
21242145
const ref = id && api.getPanel(id) ? id : null;
2146+
// Carry the currently-selected shell into the split, same as [+].
2147+
const defaults = getDefaultShellOpts();
2148+
if (defaults?.shell) {
2149+
setPendingShellOpts(newId, { shell: defaults.shell, args: defaults.args });
2150+
}
21252151
// Horizontal split places the new pane to the right → reveal from its left edge.
21262152
// Vertical split places it below → reveal from its top edge.
21272153
freshlySpawnedRef.current.set(newId, direction === 'right' ? 'left' : 'top');
@@ -2134,7 +2160,7 @@ export function Pond({
21342160
});
21352161
selectPanel(newId);
21362162
onEventRef.current?.({ type: 'split', direction: splitDirection, source });
2137-
}, [selectPanel]);
2163+
}, [selectPanel, generatePaneId]);
21382164

21392165
// --- Pond actions (for tab buttons) ---
21402166

lib/src/lib/platform/vscode-adapter.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { AlarmStateDetail, PlatformAdapter, PtyInfo } from './types';
2+
import { setDefaultShellOpts } from '../shell-defaults';
23

34
export class VSCodeAdapter implements PlatformAdapter {
45
private vscode: ReturnType<typeof acquireVsCodeApi>;
@@ -13,6 +14,16 @@ export class VSCodeAdapter implements PlatformAdapter {
1314
constructor() {
1415
this.vscode = acquireVsCodeApi();
1516

17+
// Seed the default shell from the extension-injected global so that
18+
// the first terminal on startup (which spawns synchronously on Pond
19+
// mount) picks up the selected shell, not the platform default.
20+
const injectedShell = (globalThis as typeof globalThis & {
21+
__MOUSETERM_SELECTED_SHELL__?: { shell?: string; args?: string[] } | null;
22+
}).__MOUSETERM_SELECTED_SHELL__;
23+
if (injectedShell?.shell) {
24+
setDefaultShellOpts({ shell: injectedShell.shell, args: injectedShell.args });
25+
}
26+
1627
window.addEventListener('message', (event: MessageEvent) => {
1728
const msg = event.data;
1829
if (!msg || !msg.type) return;
@@ -41,6 +52,12 @@ export class VSCodeAdapter implements PlatformAdapter {
4152
for (const handler of this.alarmStateHandlers) {
4253
handler({ id: msg.id, status: msg.status, todo: msg.todo, attentionDismissedRing: msg.attentionDismissedRing });
4354
}
55+
} else if (msg.type === 'mouseterm:newTerminal') {
56+
window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', {
57+
detail: { shell: msg.shell, args: msg.args },
58+
}));
59+
} else if (msg.type === 'mouseterm:selectedShell') {
60+
setDefaultShellOpts(msg.shell ? { shell: msg.shell, args: msg.args } : null);
4461
}
4562
});
4663
}
@@ -228,6 +245,12 @@ export class VSCodeAdapter implements PlatformAdapter {
228245
}
229246

230247
getState(): unknown {
231-
return this.hostState ?? this.vscode.getState();
248+
// vscode.getState() is VSCode's own per-webview storage and persists
249+
// across re-mount (e.g. panel collapsed then re-expanded). Prefer it
250+
// so splits made after initial resolve aren't lost — the injected
251+
// hostState only reflects what the extension put in the HTML at the
252+
// first resolveWebviewView call. Fall back to hostState on the very
253+
// first load, before any setState has run.
254+
return this.vscode.getState() ?? this.hostState;
232255
}
233256
}

lib/src/lib/reconnect.test.ts

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest';
2+
import type { PlatformAdapter, PtyInfo } from './platform/types';
3+
import type { PersistedSession } from './session-types';
4+
5+
const terminalRegistryMocks = vi.hoisted(() => ({
6+
reconnectTerminal: vi.fn(),
7+
restoreTerminal: vi.fn(),
8+
}));
9+
10+
vi.mock('./terminal-registry', () => ({
11+
reconnectTerminal: terminalRegistryMocks.reconnectTerminal,
12+
restoreTerminal: terminalRegistryMocks.restoreTerminal,
13+
}));
14+
15+
import { reconnectFromInit } from './reconnect';
16+
17+
function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): PlatformAdapter {
18+
const listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>();
19+
const replayHandlers = new Set<(detail: { id: string; data: string }) => void>();
20+
21+
return {
22+
init: async () => {},
23+
shutdown: () => {},
24+
getAvailableShells: vi.fn(async () => []),
25+
spawnPty: vi.fn(),
26+
writePty: vi.fn(),
27+
resizePty: vi.fn(),
28+
killPty: vi.fn(),
29+
getCwd: vi.fn(async () => null),
30+
getScrollback: vi.fn(async () => null),
31+
onPtyData: vi.fn(),
32+
offPtyData: vi.fn(),
33+
onPtyExit: vi.fn(),
34+
offPtyExit: vi.fn(),
35+
requestInit: vi.fn(() => {
36+
for (const handler of listHandlers) handler({ ptys });
37+
for (const pty of ptys) {
38+
for (const handler of replayHandlers) handler({ id: pty.id, data: `${pty.id}-replay` });
39+
}
40+
}),
41+
onPtyList: (handler) => { listHandlers.add(handler); },
42+
offPtyList: (handler) => { listHandlers.delete(handler); },
43+
onPtyReplay: (handler) => { replayHandlers.add(handler); },
44+
offPtyReplay: (handler) => { replayHandlers.delete(handler); },
45+
onRequestSessionFlush: vi.fn(),
46+
offRequestSessionFlush: vi.fn(),
47+
notifySessionFlushComplete: vi.fn(),
48+
alarmRemove: vi.fn(),
49+
alarmToggle: vi.fn(),
50+
alarmDisable: vi.fn(),
51+
alarmDismiss: vi.fn(),
52+
alarmDismissOrToggle: vi.fn(),
53+
alarmAttend: vi.fn(),
54+
alarmResize: vi.fn(),
55+
alarmClearAttention: vi.fn(),
56+
alarmToggleTodo: vi.fn(),
57+
alarmMarkTodo: vi.fn(),
58+
alarmClearTodo: vi.fn(),
59+
alarmDrainTodoBucket: vi.fn(),
60+
onAlarmState: vi.fn(),
61+
offAlarmState: vi.fn(),
62+
saveState: vi.fn(),
63+
getState: vi.fn(() => savedState),
64+
};
65+
}
66+
67+
describe('reconnectFromInit', () => {
68+
beforeEach(() => {
69+
vi.clearAllMocks();
70+
});
71+
72+
it('restores saved visible layout and detached doors for matching live PTYs', async () => {
73+
const layout = {
74+
panels: {
75+
'pane-a': {},
76+
'pane-b': {},
77+
},
78+
};
79+
const detached = [{
80+
id: 'pane-c',
81+
title: 'Pane C',
82+
neighborId: 'pane-b',
83+
direction: 'right' as const,
84+
remainingPanelIds: ['pane-a', 'pane-b'],
85+
restoreLayout: layout,
86+
detachedLayoutSignature: 'sig',
87+
}];
88+
const saved: PersistedSession = {
89+
version: 1,
90+
layout,
91+
detached,
92+
panes: [
93+
{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null },
94+
{ id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null },
95+
{ id: 'pane-c', title: 'Pane C', cwd: null, scrollback: null, resumeCommand: null },
96+
],
97+
};
98+
99+
const result = await reconnectFromInit(createPlatform([
100+
{ id: 'pane-a', alive: true },
101+
{ id: 'pane-b', alive: true },
102+
{ id: 'pane-c', alive: true },
103+
], saved));
104+
105+
expect(result).toEqual({
106+
paneIds: ['pane-a', 'pane-b'],
107+
detached,
108+
layout,
109+
});
110+
expect(terminalRegistryMocks.reconnectTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', {
111+
alive: true,
112+
exitCode: undefined,
113+
});
114+
});
115+
116+
it('does not reuse a saved layout when live PTYs do not match saved panes', async () => {
117+
const saved: PersistedSession = {
118+
version: 1,
119+
layout: { panels: { 'pane-a': {}, 'pane-b': {} } },
120+
panes: [
121+
{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null },
122+
{ id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null },
123+
],
124+
};
125+
126+
const result = await reconnectFromInit(createPlatform([
127+
{ id: 'pane-a', alive: true },
128+
{ id: 'pane-b', alive: true },
129+
{ id: 'extra-pane', alive: true },
130+
], saved));
131+
132+
expect(result).toEqual({
133+
paneIds: ['pane-a', 'pane-b', 'extra-pane'],
134+
detached: [],
135+
});
136+
});
137+
138+
it('ignores stale saved panes when the saved layout still matches live visible panes', async () => {
139+
const layout = { panels: { 'pane-a': {}, 'pane-b': {} } };
140+
const saved: PersistedSession = {
141+
version: 1,
142+
layout,
143+
panes: [
144+
{ id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null },
145+
{ id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null },
146+
{ id: 'stale-pane', title: 'Stale Pane', cwd: null, scrollback: null, resumeCommand: null },
147+
],
148+
};
149+
150+
const result = await reconnectFromInit(createPlatform([
151+
{ id: 'pane-a', alive: true },
152+
{ id: 'pane-b', alive: true },
153+
], saved));
154+
155+
expect(result).toEqual({
156+
paneIds: ['pane-a', 'pane-b'],
157+
detached: [],
158+
layout,
159+
});
160+
});
161+
});

lib/src/lib/reconnect.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { PlatformAdapter, PtyInfo } from './platform/types';
22
import { reconnectTerminal } from './terminal-registry';
3-
import type { PersistedDetachedItem } from './session-types';
3+
import type { PersistedDetachedItem, PersistedSession } from './session-types';
44
import { restoreSession } from './session-restore';
55

66
export interface ReconnectResult {
@@ -71,6 +71,15 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise<ReconnectResult>
7171
});
7272
ids.push(pty.id);
7373
}
74+
// Pull saved visible/detached state so reconnect (e.g. after panel
75+
// close/reopen) restores splits and doors instead of stacking every live
76+
// PTY into one tab group.
77+
const savedPlan = getSavedLiveReconnectPlan(platform.getState(), ids);
78+
if (savedPlan) {
79+
resolve(savedPlan);
80+
return;
81+
}
82+
7483
resolve({ paneIds: ids, detached: [] });
7584
}
7685

@@ -79,3 +88,39 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise<ReconnectResult>
7988
platform.requestInit();
8089
});
8190
}
91+
92+
function getSavedLiveReconnectPlan(savedState: unknown, liveIds: string[]): ReconnectResult | null {
93+
const saved = savedState as PersistedSession | null;
94+
if (!saved || saved.version !== 1 || !Array.isArray(saved.panes)) return null;
95+
96+
// Reuse persisted visible/detached state only when every live PTY is covered
97+
// by the saved session. Extra saved panes can be stale, but extra live panes
98+
// have no reliable saved layout position.
99+
const liveSet = new Set(liveIds);
100+
const savedSet = new Set(saved.panes.map((p) => p.id));
101+
if (!liveIds.every((id) => savedSet.has(id))) return null;
102+
103+
const detached = (saved.detached ?? []).filter((item) => liveSet.has(item.id));
104+
const detachedIds = new Set(detached.map((item) => item.id));
105+
const paneIds = saved.panes
106+
.filter((pane) => liveSet.has(pane.id) && !detachedIds.has(pane.id))
107+
.map((pane) => pane.id);
108+
const layoutPanelIds = getLayoutPanelIds(saved.layout);
109+
const layoutMatchesVisiblePanes =
110+
!!layoutPanelIds &&
111+
layoutPanelIds.length === paneIds.length &&
112+
layoutPanelIds.every((id) => paneIds.includes(id));
113+
114+
return {
115+
paneIds,
116+
detached,
117+
layout: layoutMatchesVisiblePanes ? saved.layout : undefined,
118+
};
119+
}
120+
121+
function getLayoutPanelIds(layout: unknown): string[] | null {
122+
if (!layout || typeof layout !== 'object') return null;
123+
const panels = (layout as { panels?: unknown }).panels;
124+
if (!panels || typeof panels !== 'object' || Array.isArray(panels)) return null;
125+
return Object.keys(panels);
126+
}

0 commit comments

Comments
 (0)