diff --git a/docs/specs/layout.md b/docs/specs/layout.md index 8575b3e..38285c9 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -263,9 +263,10 @@ Pane IDs are session IDs. `TerminalPane` calls `getOrCreateTerminal(id)` on moun 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. On startup, recovery is priority-based: -1. **Live PTYs** (webview hidden/shown): request PTY list + replay data from platform, `reconnectTerminal()` for each (500ms timeout) -2. **Saved session** (app restart): restore layout from serialized dockview state, `restoreTerminal()` for each pane with saved cwd + scrollback -3. **Empty state**: create a single new pane +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. +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 +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 +4. **Empty state**: create a single new pane ### Session UI state diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 82528d3..d6ebef6 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -110,8 +110,9 @@ Extension Host (always running while extension is active) This means: - Hiding the MouseTerm panel doesn't kill its PTYs. - VS Code toggling the panel visibility doesn't destroy sessions. -- When the view becomes visible again, the webview reconnects to still-alive PTYs. +- 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. - Each message router tracks which PTYs it owns; PTYs cannot be stolen by another router. +- Explicitly killed PTYs are tombstoned in the extension host so a late child-process `exit` event cannot recreate their buffer and make them reconnectable. - Multiple VS Code windows each get their own extension host process, and therefore their own pty-host child process. #### PTY buffering @@ -132,9 +133,10 @@ Both are capped at 1M chars per PTY. When the cap is reached, oldest chunks are - { type: 'pty:list', ptys: [{ id, alive, exitCode }] } // all owned PTYs - { type: 'pty:replay', id, data } // buffered output per PTY 4. Webview restores terminals from replay data, resumes live stream +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 ``` -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. +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. ### Message protocol diff --git a/lib/src/components/Pond.tsx b/lib/src/components/Pond.tsx index 49a926a..5e048bf 100644 --- a/lib/src/components/Pond.tsx +++ b/lib/src/components/Pond.tsx @@ -33,6 +33,7 @@ import { destroyTerminal, swapTerminals, setPendingShellOpts, + getDefaultShellOpts, type SessionStatus, isSoftTodo, isHardTodo, @@ -1399,6 +1400,26 @@ export function Pond({ detachedRef.current = restoredDetached; setDetached(restoredDetached); + // Apply the currently-selected shell to a freshly-added panel. Panels + // that are reconnecting to an existing PTY already have a running shell, + // so their pendingShellOpts are never consumed — only first-time spawns + // use this. + const addTerminalPanel = (id: string) => { + const defaults = getDefaultShellOpts(); + if (defaults?.shell) { + setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); + } + const referencePanel = e.api.panels[e.api.panels.length - 1] ?? null; + const direction = referencePanel && referencePanel.api.width - referencePanel.api.height > 0 ? 'right' : 'below'; + e.api.addPanel({ + id, + component: 'terminal', + tabComponent: 'terminal', + title: '', + position: referencePanel ? { referencePanel: referencePanel.id, direction } : undefined, + }); + }; + if (layout && restored && restored.length > 0) { // Cold-start restore: apply saved dockview layout (includes panel arrangement) try { @@ -1407,7 +1428,7 @@ export function Pond({ } catch { // Layout restore failed — fall back to creating panels manually for (const id of restored) { - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + addTerminalPanel(id); } setSelectedId(restored[0]); } @@ -1417,7 +1438,7 @@ export function Pond({ ? restored : [generatePaneId()]; for (const id of paneIds) { - e.api.addPanel({ id, component: 'terminal', tabComponent: 'terminal', title: '' }); + addTerminalPanel(id); } setSelectedId(paneIds[0]); } @@ -1948,6 +1969,11 @@ export function Pond({ if (!api) return; const newId = generatePaneId(); const ref = id && api.getPanel(id) ? id : null; + // Carry the currently-selected shell into the split, same as [+]. + const defaults = getDefaultShellOpts(); + if (defaults?.shell) { + setPendingShellOpts(newId, { shell: defaults.shell, args: defaults.args }); + } // Horizontal split places the new pane to the right → reveal from its left edge. // Vertical split places it below → reveal from its top edge. freshlySpawnedRef.current.set(newId, direction === 'right' ? 'left' : 'top'); @@ -1960,7 +1986,7 @@ export function Pond({ }); selectPanel(newId); onEventRef.current?.({ type: 'split', direction: splitDirection, source }); - }, [selectPanel]); + }, [selectPanel, generatePaneId]); // --- Pond actions (for tab buttons) --- diff --git a/lib/src/lib/platform/vscode-adapter.ts b/lib/src/lib/platform/vscode-adapter.ts index 9d8698a..fae3956 100644 --- a/lib/src/lib/platform/vscode-adapter.ts +++ b/lib/src/lib/platform/vscode-adapter.ts @@ -1,4 +1,5 @@ import type { AlarmStateDetail, PlatformAdapter, PtyInfo } from './types'; +import { setDefaultShellOpts } from '../shell-defaults'; export class VSCodeAdapter implements PlatformAdapter { private vscode: ReturnType; @@ -13,6 +14,16 @@ export class VSCodeAdapter implements PlatformAdapter { constructor() { this.vscode = acquireVsCodeApi(); + // Seed the default shell from the extension-injected global so that + // the first terminal on startup (which spawns synchronously on Pond + // mount) picks up the selected shell, not the platform default. + const injectedShell = (globalThis as typeof globalThis & { + __MOUSETERM_SELECTED_SHELL__?: { shell?: string; args?: string[] } | null; + }).__MOUSETERM_SELECTED_SHELL__; + if (injectedShell?.shell) { + setDefaultShellOpts({ shell: injectedShell.shell, args: injectedShell.args }); + } + window.addEventListener('message', (event: MessageEvent) => { const msg = event.data; if (!msg || !msg.type) return; @@ -41,6 +52,12 @@ export class VSCodeAdapter implements PlatformAdapter { for (const handler of this.alarmStateHandlers) { handler({ id: msg.id, status: msg.status, todo: msg.todo, attentionDismissedRing: msg.attentionDismissedRing }); } + } else if (msg.type === 'mouseterm:newTerminal') { + window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { + detail: { shell: msg.shell, args: msg.args }, + })); + } else if (msg.type === 'mouseterm:selectedShell') { + setDefaultShellOpts(msg.shell ? { shell: msg.shell, args: msg.args } : null); } }); } @@ -228,6 +245,12 @@ export class VSCodeAdapter implements PlatformAdapter { } getState(): unknown { - return this.hostState ?? this.vscode.getState(); + // vscode.getState() is VSCode's own per-webview storage and persists + // across re-mount (e.g. panel collapsed then re-expanded). Prefer it + // so splits made after initial resolve aren't lost — the injected + // hostState only reflects what the extension put in the HTML at the + // first resolveWebviewView call. Fall back to hostState on the very + // first load, before any setState has run. + return this.vscode.getState() ?? this.hostState; } } diff --git a/lib/src/lib/reconnect.test.ts b/lib/src/lib/reconnect.test.ts new file mode 100644 index 0000000..3e55794 --- /dev/null +++ b/lib/src/lib/reconnect.test.ts @@ -0,0 +1,161 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PlatformAdapter, PtyInfo } from './platform/types'; +import type { PersistedSession } from './session-types'; + +const terminalRegistryMocks = vi.hoisted(() => ({ + reconnectTerminal: vi.fn(), + restoreTerminal: vi.fn(), +})); + +vi.mock('./terminal-registry', () => ({ + reconnectTerminal: terminalRegistryMocks.reconnectTerminal, + restoreTerminal: terminalRegistryMocks.restoreTerminal, +})); + +import { reconnectFromInit } from './reconnect'; + +function createPlatform(ptys: PtyInfo[], savedState: PersistedSession | null): PlatformAdapter { + const listHandlers = new Set<(detail: { ptys: PtyInfo[] }) => void>(); + const replayHandlers = new Set<(detail: { id: string; data: string }) => void>(); + + return { + init: async () => {}, + shutdown: () => {}, + getAvailableShells: vi.fn(async () => []), + spawnPty: vi.fn(), + writePty: vi.fn(), + resizePty: vi.fn(), + killPty: vi.fn(), + getCwd: vi.fn(async () => null), + getScrollback: vi.fn(async () => null), + onPtyData: vi.fn(), + offPtyData: vi.fn(), + onPtyExit: vi.fn(), + offPtyExit: vi.fn(), + requestInit: vi.fn(() => { + for (const handler of listHandlers) handler({ ptys }); + for (const pty of ptys) { + for (const handler of replayHandlers) handler({ id: pty.id, data: `${pty.id}-replay` }); + } + }), + onPtyList: (handler) => { listHandlers.add(handler); }, + offPtyList: (handler) => { listHandlers.delete(handler); }, + onPtyReplay: (handler) => { replayHandlers.add(handler); }, + offPtyReplay: (handler) => { replayHandlers.delete(handler); }, + onRequestSessionFlush: vi.fn(), + offRequestSessionFlush: vi.fn(), + notifySessionFlushComplete: vi.fn(), + alarmRemove: vi.fn(), + alarmToggle: vi.fn(), + alarmDisable: vi.fn(), + alarmDismiss: vi.fn(), + alarmDismissOrToggle: vi.fn(), + alarmAttend: vi.fn(), + alarmResize: vi.fn(), + alarmClearAttention: vi.fn(), + alarmToggleTodo: vi.fn(), + alarmMarkTodo: vi.fn(), + alarmClearTodo: vi.fn(), + alarmDrainTodoBucket: vi.fn(), + onAlarmState: vi.fn(), + offAlarmState: vi.fn(), + saveState: vi.fn(), + getState: vi.fn(() => savedState), + }; +} + +describe('reconnectFromInit', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('restores saved visible layout and detached doors for matching live PTYs', async () => { + const layout = { + panels: { + 'pane-a': {}, + 'pane-b': {}, + }, + }; + const detached = [{ + id: 'pane-c', + title: 'Pane C', + neighborId: 'pane-b', + direction: 'right' as const, + remainingPanelIds: ['pane-a', 'pane-b'], + restoreLayout: layout, + detachedLayoutSignature: 'sig', + }]; + const saved: PersistedSession = { + version: 1, + layout, + detached, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-c', title: 'Pane C', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await reconnectFromInit(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + { id: 'pane-c', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: ['pane-a', 'pane-b'], + detached, + layout, + }); + expect(terminalRegistryMocks.reconnectTerminal).toHaveBeenCalledWith('pane-c', 'pane-c-replay', { + alive: true, + exitCode: undefined, + }); + }); + + it('does not reuse a saved layout when live PTYs do not match saved panes', async () => { + const saved: PersistedSession = { + version: 1, + layout: { panels: { 'pane-a': {}, 'pane-b': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await reconnectFromInit(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + { id: 'extra-pane', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: ['pane-a', 'pane-b', 'extra-pane'], + detached: [], + }); + }); + + it('ignores stale saved panes when the saved layout still matches live visible panes', async () => { + const layout = { panels: { 'pane-a': {}, 'pane-b': {} } }; + const saved: PersistedSession = { + version: 1, + layout, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'pane-b', title: 'Pane B', cwd: null, scrollback: null, resumeCommand: null }, + { id: 'stale-pane', title: 'Stale Pane', cwd: null, scrollback: null, resumeCommand: null }, + ], + }; + + const result = await reconnectFromInit(createPlatform([ + { id: 'pane-a', alive: true }, + { id: 'pane-b', alive: true }, + ], saved)); + + expect(result).toEqual({ + paneIds: ['pane-a', 'pane-b'], + detached: [], + layout, + }); + }); +}); diff --git a/lib/src/lib/reconnect.ts b/lib/src/lib/reconnect.ts index 501fa4c..0a999e4 100644 --- a/lib/src/lib/reconnect.ts +++ b/lib/src/lib/reconnect.ts @@ -1,6 +1,6 @@ import type { PlatformAdapter, PtyInfo } from './platform/types'; import { reconnectTerminal } from './terminal-registry'; -import type { PersistedDetachedItem } from './session-types'; +import type { PersistedDetachedItem, PersistedSession } from './session-types'; import { restoreSession } from './session-restore'; export interface ReconnectResult { @@ -71,6 +71,15 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise }); ids.push(pty.id); } + // Pull saved visible/detached state so reconnect (e.g. after panel + // close/reopen) restores splits and doors instead of stacking every live + // PTY into one tab group. + const savedPlan = getSavedLiveReconnectPlan(platform.getState(), ids); + if (savedPlan) { + resolve(savedPlan); + return; + } + resolve({ paneIds: ids, detached: [] }); } @@ -79,3 +88,39 @@ function reconnectLivePtys(platform: PlatformAdapter): Promise platform.requestInit(); }); } + +function getSavedLiveReconnectPlan(savedState: unknown, liveIds: string[]): ReconnectResult | null { + const saved = savedState as PersistedSession | null; + if (!saved || saved.version !== 1 || !Array.isArray(saved.panes)) return null; + + // Reuse persisted visible/detached state only when every live PTY is covered + // by the saved session. Extra saved panes can be stale, but extra live panes + // have no reliable saved layout position. + const liveSet = new Set(liveIds); + const savedSet = new Set(saved.panes.map((p) => p.id)); + if (!liveIds.every((id) => savedSet.has(id))) return null; + + const detached = (saved.detached ?? []).filter((item) => liveSet.has(item.id)); + const detachedIds = new Set(detached.map((item) => item.id)); + const paneIds = saved.panes + .filter((pane) => liveSet.has(pane.id) && !detachedIds.has(pane.id)) + .map((pane) => pane.id); + const layoutPanelIds = getLayoutPanelIds(saved.layout); + const layoutMatchesVisiblePanes = + !!layoutPanelIds && + layoutPanelIds.length === paneIds.length && + layoutPanelIds.every((id) => paneIds.includes(id)); + + return { + paneIds, + detached, + layout: layoutMatchesVisiblePanes ? saved.layout : undefined, + }; +} + +function getLayoutPanelIds(layout: unknown): string[] | null { + if (!layout || typeof layout !== 'object') return null; + const panels = (layout as { panels?: unknown }).panels; + if (!panels || typeof panels !== 'object' || Array.isArray(panels)) return null; + return Object.keys(panels); +} diff --git a/lib/src/lib/session-restore.test.ts b/lib/src/lib/session-restore.test.ts new file mode 100644 index 0000000..5fdc957 --- /dev/null +++ b/lib/src/lib/session-restore.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PlatformAdapter } from './platform/types'; +import type { PersistedSession } from './session-types'; + +const terminalRegistryMocks = vi.hoisted(() => ({ + getDefaultShellOpts: vi.fn(), + restoreTerminal: vi.fn(), +})); + +vi.mock('./terminal-registry', () => ({ + getDefaultShellOpts: terminalRegistryMocks.getDefaultShellOpts, + restoreTerminal: terminalRegistryMocks.restoreTerminal, +})); + +import { restoreSession } from './session-restore'; + +function createPlatform(savedState: PersistedSession | null): PlatformAdapter { + return { + init: async () => {}, + shutdown: () => {}, + getAvailableShells: vi.fn(async () => []), + spawnPty: vi.fn(), + writePty: vi.fn(), + resizePty: vi.fn(), + killPty: vi.fn(), + getCwd: vi.fn(async () => null), + getScrollback: vi.fn(async () => null), + onPtyData: vi.fn(), + offPtyData: vi.fn(), + onPtyExit: vi.fn(), + offPtyExit: vi.fn(), + requestInit: vi.fn(), + onPtyList: vi.fn(), + offPtyList: vi.fn(), + onPtyReplay: vi.fn(), + offPtyReplay: vi.fn(), + onRequestSessionFlush: vi.fn(), + offRequestSessionFlush: vi.fn(), + notifySessionFlushComplete: vi.fn(), + alarmRemove: vi.fn(), + alarmToggle: vi.fn(), + alarmDisable: vi.fn(), + alarmDismiss: vi.fn(), + alarmDismissOrToggle: vi.fn(), + alarmAttend: vi.fn(), + alarmResize: vi.fn(), + alarmClearAttention: vi.fn(), + alarmToggleTodo: vi.fn(), + alarmMarkTodo: vi.fn(), + alarmClearTodo: vi.fn(), + alarmDrainTodoBucket: vi.fn(), + onAlarmState: vi.fn(), + offAlarmState: vi.fn(), + saveState: vi.fn(), + getState: vi.fn(() => savedState), + }; +} + +describe('restoreSession', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('spawns restored terminals with the configured default shell', () => { + terminalRegistryMocks.getDefaultShellOpts.mockReturnValue({ + shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + args: ['-NoLogo'], + }); + const saved: PersistedSession = { + version: 1, + layout: { panels: { 'pane-a': {} } }, + panes: [ + { id: 'pane-a', title: 'Pane A', cwd: 'C:\\repo', scrollback: 'hello', resumeCommand: null }, + ], + }; + + restoreSession(createPlatform(saved)); + + expect(terminalRegistryMocks.restoreTerminal).toHaveBeenCalledWith('pane-a', { + cwd: 'C:\\repo', + scrollback: 'hello', + title: 'Pane A', + shell: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + args: ['-NoLogo'], + }); + }); +}); diff --git a/lib/src/lib/session-restore.ts b/lib/src/lib/session-restore.ts index 96011bf..a5dbb7c 100644 --- a/lib/src/lib/session-restore.ts +++ b/lib/src/lib/session-restore.ts @@ -1,6 +1,6 @@ import type { PlatformAdapter } from './platform/types'; import type { PersistedDetachedItem, PersistedSession } from './session-types'; -import { restoreTerminal } from './terminal-registry'; +import { getDefaultShellOpts, restoreTerminal } from './terminal-registry'; export interface RestoredSession { paneIds: string[]; @@ -13,12 +13,15 @@ export function restoreSession(platform: PlatformAdapter): RestoredSession | nul if (!saved || saved.version !== 1 || !saved.panes || saved.panes.length === 0) return null; const detached = saved.detached ?? []; const detachedIds = new Set(detached.map((item) => item.id)); + const shellOpts = getDefaultShellOpts(); for (const pane of saved.panes) { restoreTerminal(pane.id, { cwd: pane.cwd, scrollback: pane.scrollback, title: pane.title, + shell: shellOpts?.shell, + args: shellOpts?.args, }); } diff --git a/lib/src/lib/shell-defaults.ts b/lib/src/lib/shell-defaults.ts new file mode 100644 index 0000000..3b29998 --- /dev/null +++ b/lib/src/lib/shell-defaults.ts @@ -0,0 +1,16 @@ +// Shared "currently selected" shell, used when spawning without an explicit +// choice (e.g. a keyboard-driven split). Updated by AppBar's ShellDropdown in +// standalone and by the VSCode extension pushing mouseterm:selectedShell. +// +// Extracted into its own module to avoid circular dependencies between +// terminal-registry and platform/vscode-adapter. + +let defaultShellOpts: { shell?: string; args?: string[] } | null = null; + +export function setDefaultShellOpts(opts: { shell?: string; args?: string[] } | null): void { + defaultShellOpts = opts; +} + +export function getDefaultShellOpts(): { shell?: string; args?: string[] } | null { + return defaultShellOpts; +} diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index 64e8c1d..a79b9a9 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -40,6 +40,11 @@ const registry = new Map(); const pendingShellOpts = new Map(); const primedSessionStates = new Map>(); +// Re-export from shell-defaults to preserve the public API surface. +// The actual state lives in shell-defaults.ts to avoid a circular dependency +// (terminal-registry → platform → vscode-adapter → terminal-registry). +export { setDefaultShellOpts, getDefaultShellOpts } from './shell-defaults'; + // --- Watch for VSCode theme changes and re-apply xterm themes --- // VSCode signals theme changes by updating CSS variables and body classes. let themeObserverStarted = false; @@ -454,7 +459,7 @@ export function reconnectTerminal( */ export function restoreTerminal( id: string, - opts: { cwd?: string | null; scrollback?: string | null; title?: string; cwdWarning?: string | null }, + opts: { cwd?: string | null; scrollback?: string | null; title?: string; cwdWarning?: string | null; shell?: string; args?: string[] }, ): TerminalEntry { const existing = registry.get(id); if (existing) return existing; @@ -480,6 +485,8 @@ export function restoreTerminal( cols: dims?.cols || 80, rows: dims?.rows || 30, cwd: opts.cwd ?? undefined, + shell: opts.shell, + args: opts.args, }); return entry; diff --git a/lib/src/stories/AppBar.stories.tsx b/lib/src/stories/AppBar.stories.tsx index 2268f74..44cc60f 100644 --- a/lib/src/stories/AppBar.stories.tsx +++ b/lib/src/stories/AppBar.stories.tsx @@ -19,8 +19,6 @@ const meta: Meta = { title: 'Components/AppBar', component: AppBarStory, args: { - projectDir: '/home/user/projects/mouseterm', - homeDir: '/home/user', shells: DEFAULT_SHELLS, }, }; @@ -30,20 +28,6 @@ type Story = StoryObj; export const Default: Story = {}; -export const HomeDirectory: Story = { - args: { - projectDir: '/home/user', - homeDir: '/home/user', - }, -}; - -export const LongPath: Story = { - args: { - projectDir: '/home/user/projects/very-deep/nested/directory/structure/my-project', - homeDir: '/home/user', - }, -}; - export const SingleShell: Story = { args: { shells: [{ name: 'bash', path: '/bin/bash' }], @@ -61,10 +45,3 @@ export const ManyShells: Story = { ], }, }; - -export const AbsolutePathOutsideHome: Story = { - args: { - projectDir: '/var/www/my-site', - homeDir: '/home/user', - }, -}; diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 7e48f68..99aad47 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -240,13 +240,6 @@ fn kill_process_tree(pid: u32) { } } -#[tauri::command] -fn get_project_dir() -> String { - std::env::var("HOME") - .or_else(|_| std::env::var("USERPROFILE")) - .unwrap_or_default() -} - #[derive(Serialize, Deserialize, Clone)] struct ShellInfo { name: String, @@ -459,7 +452,6 @@ pub fn run() { pty_get_scrollback, pty_request_init, shutdown_sidecar, - get_project_dir, get_available_shells, ]) .build(tauri::generate_context!()) diff --git a/standalone/src/AppBar.tsx b/standalone/src/AppBar.tsx index 47e5dac..0810996 100644 --- a/standalone/src/AppBar.tsx +++ b/standalone/src/AppBar.tsx @@ -1,7 +1,8 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { getCurrentWindow } from '@tauri-apps/api/window'; -import { CaretDownIcon, MinusIcon, CornersOutIcon, CornersInIcon, XIcon, TerminalWindowIcon, PlusIcon } from '@phosphor-icons/react'; +import { CaretDownIcon, MinusIcon, CornersOutIcon, CornersInIcon, XIcon, PlusIcon, CheckIcon } from '@phosphor-icons/react'; import { ThemePicker } from '../../lib/src/components/ThemePicker'; +import { setDefaultShellOpts } from '../../lib/src/lib/shell-defaults'; export interface ShellEntry { name: string; @@ -10,8 +11,6 @@ export interface ShellEntry { } interface AppBarProps { - projectDir: string; - homeDir: string; shells: ShellEntry[]; } @@ -20,13 +19,6 @@ const IS_MAC = typeof (navigator as any).userAgentData?.platform === 'string' : /Mac/.test(navigator.platform); const appWindow = getCurrentWindow(); -function abbreviateHome(dir: string, home: string): string { - if (dir === home) return '~'; - if (dir.startsWith(home + '/')) return '~' + dir.slice(home.length); - if (dir.startsWith(home + '\\')) return '~' + dir.slice(home.length); - return dir; -} - // ── Tooltip wrapper ──────────────────────────────────────────────────────── function Tip({ label, children }: { label: string; children: React.ReactNode }) { @@ -92,11 +84,15 @@ function WinControls() { function ShellDropdown({ shells }: { shells: ShellEntry[] }) { const [open, setOpen] = useState(false); + const [selected, setSelected] = useState(shells[0]); const ref = useRef(null); - const defaultShell = shells[0]; - const handleSelect = useCallback((shell: ShellEntry) => { - setOpen(false); + // Publish the selection so splits (and other spawn paths) can reuse it. + useEffect(() => { + setDefaultShellOpts(selected ? { shell: selected.path, args: selected.args } : null); + }, [selected]); + + const spawn = useCallback((shell: ShellEntry) => { window.dispatchEvent(new CustomEvent('mouseterm:new-terminal', { detail: { shell: shell.path, args: shell.args } })); }, []); @@ -122,41 +118,50 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { return (
- {/* Primary action: click to open a new terminal with the default shell */} - + {/* Primary action: [+] spawns a new terminal with the selected shell */} + - {/* Dropdown caret: pick a different shell type */} + {/* Selector: shows current shell name + caret; click to choose a different shell */} {open && ( -
- {shells.map((shell) => ( - - ))} +
+ {shells.map((shell) => { + const isSelected = shell.path === selected?.path; + return ( + + ); + })}
)}
@@ -165,18 +170,7 @@ function ShellDropdown({ shells }: { shells: ShellEntry[] }) { // ── AppBar ───────────────────────────────────────────────────────────────── -function projectName(dir: string): string { - const sep = dir.includes('\\') ? '\\' : '/'; - const parts = dir.split(sep).filter(Boolean); - return parts[parts.length - 1] ?? dir; -} - -export function AppBar({ projectDir, homeDir, shells }: AppBarProps) { - const displayDir = abbreviateHome(projectDir, homeDir); - const name = projectName(projectDir); - // Show just the directory name when it's the home dir (avoids bare "~") - const isHome = projectDir === homeDir; - +export function AppBar({ shells }: AppBarProps) { return (
)} - {/* Project directory — centered */} - -
- - {isHome ? '~' : name} - - {!isHome && ( - - {displayDir} - - )} -
-
+ {/* Draggable spacer */} +
{/* Shell dropdown on the right (macOS) or window controls (Windows/Linux) */} {IS_MAC ? ( -
+
) : ( -
+
diff --git a/standalone/src/main.tsx b/standalone/src/main.tsx index d85ec2c..a985583 100644 --- a/standalone/src/main.tsx +++ b/standalone/src/main.tsx @@ -45,16 +45,12 @@ async function bootstrap() { startUpdateCheck(); // Fetch app bar data from Rust backend - const [homeDir, detectedShells] = await Promise.all([ - invoke("get_project_dir"), - invoke("get_available_shells"), - ]); - const projectDir = homeDir; // For now, project dir defaults to home + const detectedShells = await invoke("get_available_shells"); const shells: ShellEntry[] = detectedShells.length > 0 ? detectedShells : [{ name: 'shell', path: '' }]; createRoot(document.getElementById("root")!).render( - + { shell?: string; args?: string[] } | null, ) { const mediaPath = path.join(context.extensionPath, 'media'); @@ -39,12 +41,13 @@ function setupPanel( light: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-light.png')), dark: vscode.Uri.file(path.join(context.extensionPath, 'icon-tiny-dark.png')), }; - panel.webview.html = getWebviewHtml(panel.webview, mediaPath, initialState); + panel.webview.html = getWebviewHtml(panel.webview, mediaPath, initialState, getSelectedShell?.()); const router = attachRouter(panel.webview, { reconnect: !!savedState, killOnDispose: true, savedSession: isPersistedSession(initialState) ? initialState : null, + getSelectedShell, // Panels persist via vscode.setState() (per-panel, managed by VS Code). // Don't write to workspaceState — that's for the WebviewView only. }); @@ -58,11 +61,27 @@ export function activate(context: vscode.ExtensionContext) { const provider = new MouseTermViewProvider(context); + // Updates the shell-derived state in one place: the view header (shell + // name appears next to the title via description) and the webview's + // default-shell slot that split-spawns read from. + const applyShell = (shell: { name: string; path: string; args: string[] } | undefined) => { + provider.setDescription(shell?.name); + provider.setSelectedShell(shell ? { shell: shell.path, args: shell.args } : null); + }; + + // Warm up shell detection in the background so the picker/+ buttons + // don't pay the cold-start cost (child fork + WSL probe) when the user + // first clicks them. Also seeds the view description / webview state + // with the current shell. + void ptyManager.getAvailableShells().then((shells) => { + applyShell(resolveSelectedShell(context, shells)); + }); + context.subscriptions.push( vscode.window.registerWebviewViewProvider('mouseterm.view', provider), vscode.window.registerWebviewPanelSerializer('mouseterm', { async deserializeWebviewPanel(panel: vscode.WebviewPanel, state: unknown) { - setupPanel(context, panel, state); + setupPanel(context, panel, state, () => provider.getSelectedShell()); }, }), vscode.commands.registerCommand('mouseterm.focus', () => { @@ -80,7 +99,53 @@ export function activate(context: vscode.ExtensionContext) { localResourceRoots: [vscode.Uri.file(mediaPath)], }, ); - setupPanel(context, panel); + setupPanel(context, panel, undefined, () => provider.getSelectedShell()); + }), + vscode.commands.registerCommand('mouseterm.newTerminal', async () => { + await vscode.commands.executeCommand('mouseterm.view.focus'); + const shells = await ptyManager.getAvailableShells(); + const shell = resolveSelectedShell(context, shells); + await provider.postMessage({ + type: 'mouseterm:newTerminal', + shell: shell?.path, + args: shell?.args, + }); + }), + vscode.commands.registerCommand('mouseterm.selectShell', async () => { + const shells = await ptyManager.getAvailableShells(); + if (shells.length === 0) { + void vscode.window.showWarningMessage('MouseTerm: no shells detected.'); + return; + } + const currentPath = getSelectedShellPath(context) ?? shells[0].path; + const items: (vscode.QuickPickItem & { path: string; args: string[] })[] = shells.map((s) => ({ + label: s.name, + description: s.path, + picked: s.path === currentPath, + path: s.path, + args: s.args, + })); + const picked = await vscode.window.showQuickPick(items, { + title: 'Select default shell for MouseTerm', + placeHolder: 'The [+] button will spawn a terminal with this shell.', + }); + if (!picked) return; + + const hasWorkspace = (vscode.workspace.workspaceFolders?.length ?? 0) > 0; + let scope: 'workspace' | 'global' = 'global'; + if (hasWorkspace) { + const scopeChoice = await vscode.window.showQuickPick( + [ + { label: 'Apply globally (default)', value: 'global' as const }, + { label: 'Apply to this workspace only', value: 'workspace' as const }, + ], + { title: 'Where should this apply?' }, + ); + if (!scopeChoice) return; + scope = scopeChoice.value; + } + await setSelectedShellPath(context, picked.path, scope); + applyShell({ name: picked.label, path: picked.path, args: picked.args }); }), ); } diff --git a/vscode-ext/src/message-router.ts b/vscode-ext/src/message-router.ts index 1d8c0b9..e3bc55c 100644 --- a/vscode-ext/src/message-router.ts +++ b/vscode-ext/src/message-router.ts @@ -52,6 +52,7 @@ export function attachRouter( killOnDispose?: boolean; onSaveState?: (state: unknown) => void; savedSession?: PersistedSession | null; + getSelectedShell?: () => { shell?: string; args?: string[] } | null; }, ): vscode.Disposable { const reconnect = options?.reconnect ?? false; @@ -186,6 +187,17 @@ export function attachRouter( disconnectWebview?.(); disconnectWebview = connectWebview(); + // Re-publish the currently-selected shell so split-spawns in the + // freshly-mounted webview know what to use. + const selected = options?.getSelectedShell?.(); + if (selected) { + webview.postMessage({ + type: 'mouseterm:selectedShell', + shell: selected.shell, + args: selected.args, + } satisfies ExtensionMessage); + } + if (!reconnect) { // Fresh instance — no existing PTYs to restore webview.postMessage({ type: 'pty:list', ptys: [] } satisfies ExtensionMessage); diff --git a/vscode-ext/src/message-types.ts b/vscode-ext/src/message-types.ts index 624f1f2..1136145 100644 --- a/vscode-ext/src/message-types.ts +++ b/vscode-ext/src/message-types.ts @@ -41,6 +41,8 @@ export type ExtensionMessage = | { type: 'pty:cwd'; id: string; cwd: string | null; requestId?: string } | { type: 'pty:scrollback'; id: string; data: string | null; requestId?: string } | { type: 'pty:shells'; shells: Array<{ name: string; path: string; args: string[] }>; requestId?: string } + | { type: 'mouseterm:newTerminal'; shell?: string; args?: string[] } + | { type: 'mouseterm:selectedShell'; shell?: string; args?: string[] } | { type: 'mouseterm:flushSessionSave'; requestId: string } // Alarm state updates | { type: 'alarm:state'; id: string; status: SessionStatus; todo: TodoState; attentionDismissedRing: boolean }; diff --git a/vscode-ext/src/pty-manager.ts b/vscode-ext/src/pty-manager.ts index 8e11628..d552651 100644 --- a/vscode-ext/src/pty-manager.ts +++ b/vscode-ext/src/pty-manager.ts @@ -19,6 +19,7 @@ interface PtyBufferEntry { const MAX_BUFFER_CHARS = 1_000_000; const ptyBuffers = new Map(); +const killedPtyIds = new Set(); function trimChunks(chunks: string[], totalChars: number): number { while (totalChars > MAX_BUFFER_CHARS && chunks.length > 1) { @@ -40,6 +41,7 @@ function createBufferEntry(alive: boolean, exitCode?: number): PtyBufferEntry { } function bufferData(id: string, data: string): void { + if (killedPtyIds.has(id)) return; let entry = ptyBuffers.get(id); if (!entry) { entry = createBufferEntry(true); @@ -55,6 +57,7 @@ function bufferData(id: string, data: string): void { } function bufferExit(id: string, exitCode: number): void { + if (killedPtyIds.has(id)) return; let entry = ptyBuffers.get(id); if (!entry) { entry = createBufferEntry(false, exitCode); @@ -97,6 +100,15 @@ let cachedNodePath: string | null = null; function findSystemNode(): string { if (cachedNodePath) return cachedNodePath; + // On Windows, use the host's execPath (Electron's Node). VSCode's own + // integrated terminal uses node-pty against Electron, so this works for us + // too — and avoids the bogus Unix-path fallback below that was causing + // multi-second fork stalls. + if (process.platform === 'win32') { + cachedNodePath = process.execPath; + return cachedNodePath; + } + // Try common locations first (avoids shell invocation) const candidates = [ process.env.NVM_BIN && path.join(process.env.NVM_BIN, 'node'), @@ -167,6 +179,7 @@ function ensureChild(extensionPath: string): ChildProcess { child = null; childReady = false; pendingMessages = []; + shellsCache = null; }); child.stderr?.on('data', (data: Buffer) => { @@ -197,6 +210,7 @@ function sendToChild(msg: any): void { } export function spawn(id: string, options?: { cols?: number; rows?: number; cwd?: string; shell?: string; args?: string[] }): void { + killedPtyIds.delete(id); ptyBuffers.set(id, createBufferEntry(true)); sendToChild({ type: 'spawn', id, cols: options?.cols || 80, rows: options?.rows || 30, cwd: options?.cwd, shell: options?.shell, args: options?.args }); } @@ -207,13 +221,20 @@ export interface ShellEntry { args: string[]; } +let shellsCache: Promise | null = null; + export function getAvailableShells(): Promise { - return new Promise((resolve) => { + if (shellsCache) return shellsCache; + const pending = new Promise((resolve) => { const requestId = `shells-${Date.now()}`; + // Ensure the child process is forked before attaching the listener — + // otherwise `child` is null on the cold path and the handler is never + // registered, causing the timeout to fire with an empty list. + sendToChild({ type: 'getShells', requestId }); const timeout = setTimeout(() => { child?.off('message', handler); resolve([]); - }, 5000); + }, 15000); const handler = (msg: any) => { if (msg.type === 'shells' && msg.requestId === requestId) { clearTimeout(timeout); @@ -222,12 +243,19 @@ export function getAvailableShells(): Promise { } }; child?.on('message', handler); - sendToChild({ type: 'getShells', requestId }); }); + shellsCache = pending; + // Don't pin an empty result in the cache — lets a subsequent call retry + // if the first one timed out or the child was still warming up. + void pending.then((shells) => { + if (shells.length === 0 && shellsCache === pending) shellsCache = null; + }); + return pending; } export function getCwd(id: string): Promise { return new Promise((resolve) => { + sendToChild({ type: 'getCwd', id }); const timeout = setTimeout(() => { child?.off('message', handler); resolve(null); @@ -240,7 +268,6 @@ export function getCwd(id: string): Promise { } }; child?.on('message', handler); - sendToChild({ type: 'getCwd', id }); }); } @@ -253,6 +280,7 @@ export function resize(id: string, cols: number, rows: number): void { } export function kill(id: string): void { + killedPtyIds.add(id); ptyBuffers.delete(id); sendToChild({ type: 'kill', id }); } @@ -278,6 +306,7 @@ export function gracefulKillAll(timeoutMs = 2000): Promise { export function killAll(): void { ptyBuffers.clear(); + killedPtyIds.clear(); if (child?.connected) { child.send({ type: 'killAll' }); child.kill(); diff --git a/vscode-ext/src/shell-selection.ts b/vscode-ext/src/shell-selection.ts new file mode 100644 index 0000000..7fe861c --- /dev/null +++ b/vscode-ext/src/shell-selection.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; + +export interface ShellEntry { + name: string; + path: string; + args: string[]; +} + +const KEY = 'mouseterm.selectedShellPath'; + +export function getSelectedShellPath(context: vscode.ExtensionContext): string | undefined { + return context.workspaceState.get(KEY) ?? context.globalState.get(KEY); +} + +export async function setSelectedShellPath( + context: vscode.ExtensionContext, + path: string, + scope: 'workspace' | 'global', +): Promise { + if (scope === 'workspace') { + await context.workspaceState.update(KEY, path); + } else { + // Clear any workspace-scoped value so it doesn't shadow the new global + // setting (getSelectedShellPath checks workspaceState first). + await context.workspaceState.update(KEY, undefined); + await context.globalState.update(KEY, path); + } +} + +export function resolveSelectedShell( + context: vscode.ExtensionContext, + shells: ShellEntry[], +): ShellEntry | undefined { + const saved = getSelectedShellPath(context); + return shells.find((s) => s.path === saved) ?? shells[0]; +} diff --git a/vscode-ext/src/webview-html.ts b/vscode-ext/src/webview-html.ts index 6665781..5c0a056 100644 --- a/vscode-ext/src/webview-html.ts +++ b/vscode-ext/src/webview-html.ts @@ -9,7 +9,12 @@ function serializeForInlineScript(value: unknown): string { .replace(/\u2029/g, '\\u2029'); } -export function getWebviewHtml(webview: vscode.Webview, mediaPath: string, initialState?: unknown): string { +export function getWebviewHtml( + webview: vscode.Webview, + mediaPath: string, + initialState?: unknown, + selectedShell?: { shell?: string; args?: string[] } | null, +): string { const indexPath = path.join(mediaPath, 'index.html'); let html = fs.readFileSync(indexPath, 'utf-8'); @@ -40,7 +45,7 @@ export function getWebviewHtml(webview: vscode.Webview, mediaPath: string, initi // get a duplicate nonce attribute from the regex above. html = html.replace( '', - ` \n `, + ` \n `, ); return html; diff --git a/vscode-ext/src/webview-view-provider.ts b/vscode-ext/src/webview-view-provider.ts index 49875a6..15917c3 100644 --- a/vscode-ext/src/webview-view-provider.ts +++ b/vscode-ext/src/webview-view-provider.ts @@ -3,19 +3,47 @@ import * as path from 'path'; import { attachRouter, getAlarmStates } from './message-router'; import { getWebviewHtml } from './webview-html'; import { getSavedSessionState, saveSessionState, mergeAlarmStates } from './session-state'; +import type { ExtensionMessage } from './message-types'; +import * as ptyManager from './pty-manager'; +import { resolveSelectedShell } from './shell-selection'; export class MouseTermViewProvider implements vscode.WebviewViewProvider { private view: vscode.WebviewView | undefined; private routerDisposable: vscode.Disposable | undefined; + private description: string | undefined; + private selectedShell: { shell?: string; args?: string[] } | null = null; constructor(private readonly context: vscode.ExtensionContext) {} - resolveWebviewView( + postMessage(msg: ExtensionMessage): Thenable { + return this.view?.webview.postMessage(msg) ?? Promise.resolve(false); + } + + setDescription(text: string | undefined): void { + this.description = text; + if (this.view) this.view.description = text; + } + + setSelectedShell(opts: { shell?: string; args?: string[] } | null): void { + this.selectedShell = opts; + void this.postMessage({ + type: 'mouseterm:selectedShell', + shell: opts?.shell, + args: opts?.args, + }); + } + + getSelectedShell(): { shell?: string; args?: string[] } | null { + return this.selectedShell; + } + + async resolveWebviewView( view: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken, - ): void { + ): Promise { this.view = view; + if (this.description !== undefined) view.description = this.description; const mediaPath = path.join(this.context.extensionPath, 'media'); @@ -24,8 +52,21 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { localResourceRoots: [vscode.Uri.file(mediaPath)], }; + // Resolve the selected shell before serving the HTML so Pond's + // first-terminal spawn on mount uses the right shell. getAvailableShells + // is cached; this blocks only on a true cold start. + if (!this.selectedShell) { + const shells = await ptyManager.getAvailableShells(); + const shell = resolveSelectedShell(this.context, shells); + this.selectedShell = shell ? { shell: shell.path, args: shell.args } : null; + if (shell) { + this.description = shell.name; + view.description = shell.name; + } + } + const savedSession = getSavedSessionState(this.context); - view.webview.html = getWebviewHtml(view.webview, mediaPath, savedSession); + view.webview.html = getWebviewHtml(view.webview, mediaPath, savedSession, this.selectedShell); this.routerDisposable?.dispose(); this.routerDisposable = attachRouter(view.webview, { @@ -34,6 +75,7 @@ export class MouseTermViewProvider implements vscode.WebviewViewProvider { onSaveState: (state) => { void saveSessionState(this.context, mergeAlarmStates(state, getAlarmStates())); }, + getSelectedShell: () => this.selectedShell, }); view.onDidDispose(() => {