diff --git a/docs/specs/layout.md b/docs/specs/layout.md index a82c3e2c1..7ed4f0ff7 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -279,7 +279,7 @@ That order is load-bearing twice: a rename input suppresses the pane shortcuts b A split from an existing pane (`|`/`%`/`-`/`"` or the header split buttons) spawns the new pane with its source pane's last-known cwd, then selects it and enters passthrough; host New Terminal actions share that focus tail (rationale). Focus-neutral control-plane creation (`dor split -- …`, `dor ensure`, `dor iframe`, `dor ab`) keeps its documented background behavior. -The source cwd is read from `getTerminalPaneState(sourceId).cwd`. **Never inherit a remote cwd** (`isRemote === true`, e.g. an OSC 7 path reported over ssh) — it is not a usable local spawn cwd. The host default applies when the source cwd is unknown, remote, or absent (initial pane creation). The inherited cwd rides `setPendingShellOpts` alongside the inherited shell selection, consumed by `getOrCreateTerminal` on the next `platform.spawnPty`. +The source cwd is read from `getInheritableCwd(sourceId)`. **Never inherit a remote cwd** (`isRemote === true`, e.g. an OSC 7 path reported over ssh) — it is not a usable local spawn cwd. The host default applies when the source cwd is unknown, remote, or absent (initial pane creation). The inherited cwd rides `setPendingShellOpts` alongside the inherited shell selection, consumed by `getOrCreateTerminal` on the next `platform.spawnPty`. ### Kill confirmation @@ -497,7 +497,7 @@ Shell-selection replacement shows a short fixed-position notice over the resulti ### Auto-spawn refill -A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). It spawns with the current default shell selection, matching manual splits. +A store commit that empties the tree (last pane killed or minimized) triggers the "always keep one pane visible" auto-spawn: a Wall effect subscribed to the store spawns one leaf into the emptied tree (`lib/src/components/Wall.tsx`), **re-entrantly on the same commit chain**, so the refill appears with no separate delay (rationale). Like a split, it takes the default shell selection and **the departing pane's local cwd**. **The refill adopts the replacement (`selectPane`) only when the current selection points at nothing real** — null (the kill tail cleared it after a selected last-pane kill) or dangling (still naming the just-removed pane). **A valid selection is left alone** — the just-created door on the minimize path, or a live pane after an unselected kill — because the auto-spawn exists to keep a pane visible, not to steal selection. diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index e244fdd5c..4f89ae3c4 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -1086,6 +1086,29 @@ describe('Wall on the Lath engine', () => { expect(leafCount()).toBe(1); }); + it.each(['Minimize', 'Kill'] as const)('starts the refill after %s of the last pane in that pane\'s cwd', async (control) => { + // The stubbed pane has no registry entry to tear down, so disposal is made to + // drop the pane state as the real teardown does. + vi.spyOn(terminalRegistry, 'disposeSession').mockImplementation((id) => terminalRegistry.removeTerminalPaneState(id)); + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + await act(async () => { + root.render(); + }); + await flush(); + + await clickHeaderControl('pane-a', control); + + const refillId = container.querySelector('[data-lath-leaf]')?.getAttribute('data-lath-leaf'); + try { + expect(refillId).toBeTruthy(); + expect(refillId).not.toBe('pane-a'); + expect(pendingShellOpts.get(refillId!)?.cwd).toBe('/repo'); + } finally { + if (refillId) pendingShellOpts.delete(refillId); + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + } + }); + it('retires the old ref when shell selection replaces an untouched pane', async () => { await act(async () => { root.render(); @@ -3141,15 +3164,15 @@ describe('Wall on the Lath engine', () => { await flush(); } - /** The pane header's Kill button — a user-visible closure, which does prompt. - * `isUntouched` short-circuits the kill confirmation so this is one click. */ - async function clickHeaderKill(paneId: string): Promise { + /** A pane header control; Kill is a user-visible closure, which does prompt. + * `isUntouched` short-circuits the kill confirmation so a kill is one click. */ + async function clickHeaderControl(paneId: string, label: 'Kill' | 'Minimize'): Promise { const untouched = vi.spyOn(terminalRegistry, 'isUntouched').mockReturnValue(true); try { const button = container.querySelector( - `[data-lath-leaf="${paneId}"] button[aria-label="Kill"]`, + `[data-lath-leaf="${paneId}"] button[aria-label="${label}"]`, ); - expect(button, `no Kill button on ${paneId}`).not.toBeNull(); + expect(button, `no ${label} button on ${paneId}`).not.toBeNull(); await act(async () => { button!.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); @@ -3337,7 +3360,7 @@ describe('Wall on the Lath engine', () => { const { dispose, setBusy } = spyOnHelper(); vi.spyOn(fake.notepadArchive, 'save').mockRejectedValue(new Error('disk full')); await renderNotedPane(); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); expect(archiveFailureModal()).not.toBeNull(); setBusy(true); await clickButton('Close anyway'); @@ -3373,7 +3396,7 @@ describe('Wall on the Lath engine', () => { await flush(); act(() => { addPlainNote('pane-a', 'keep me'); }); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); expect(container.querySelector('[data-lath-leaf="pane-a"]')).not.toBeNull(); expect(getNotes('pane-a')).toHaveLength(1); @@ -3408,7 +3431,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); act(() => { addPlainNote('pane-a', 'keep me'); }); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); await clickButton('Keep open'); @@ -3424,7 +3447,7 @@ describe('Wall on the Lath engine', () => { }); await flush(); act(() => { addPlainNote('pane-a', 'expendable'); }); - await clickHeaderKill('pane-a'); + await clickHeaderControl('pane-a', 'Kill'); await clickButton('Close anyway'); @@ -3453,8 +3476,8 @@ describe('Wall on the Lath engine', () => { addPlainNote('pane-b', 'from b'); }); - await clickHeaderKill('pane-a'); - await clickHeaderKill('pane-b'); + await clickHeaderControl('pane-a', 'Kill'); + await clickHeaderControl('pane-b', 'Kill'); // A's prompt is the one on screen; B's is behind it. expect(archiveFailureModal()?.textContent).toContain('a could not be written'); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 1aebb8424..123da0df9 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -45,6 +45,7 @@ import { toggleSessionTodo, setPendingShellOpts, getDefaultShellOpts, + getInheritableCwd, getTerminalPaneState, getTerminalPaneStateSnapshot, getActivitySnapshot, @@ -203,6 +204,13 @@ function createSurfaceRefRegistry( return { refs, nextIndex: Math.max(persistedNext, max + 1) }; } +/** Stage `id`'s shell as the current default selection, started in `cwd`. Stages + * nothing when neither is set, leaving the host's own default shell and cwd. */ +function stageDefaultShell(id: string, cwd: string | undefined): void { + const defaults = getDefaultShellOpts(); + if (defaults?.shell || cwd) setPendingShellOpts(id, { shell: defaults?.shell, args: defaults?.args, cwd }); +} + function compareBySurfaceRef(a: DorSurface, b: DorSurface): number { return (surfaceRefNumber(a.ref) ?? Number.MAX_SAFE_INTEGER) - (surfaceRefNumber(b.ref) ?? Number.MAX_SAFE_INTEGER); @@ -724,12 +732,13 @@ export function Wall({ const exitMs = closingWorkspaceRef.current && workspaceIsCollapsed(effectiveWorkspaceId) ? 0 : lath.exitMs; setTimeout(() => { if (!lath.store.has(id)) return; // superseded meanwhile (e.g. replaced) - disposeSession(id); // Live re-read at removal time: only a kill of the still-selected pane moves // selection; navigating away mid-fade is honored. Removing the last leaf // empties the tree and the auto-spawn effect fills it. const wasSelectedPane = selectedTypeRef.current === 'pane' && selectedIdRef.current === id; lath.store.removeLeaf(id); + // Dispose only after the removal: a refill it triggers reads this pane's cwd. + disposeSession(id); // Forget the ref only now — while the pane is fading it is still in // `listPanes()`, so an earlier delete would let a `dor` projection re-mint a // fresh ref for the dying pane. @@ -1044,13 +1053,13 @@ export function Wall({ ); /** Restore the Wall's "always one pane" rule after a commit empties the tree - * (last pane killed or minimized). A no-op while the tree is non-empty. */ - const refillEmptyTree = useCallback(() => { + * (last pane killed or minimized), starting in `departedId`'s cwd. A no-op + * while the tree is non-empty. */ + const refillEmptyTree = useCallback((departedId?: string) => { if (lath.store.getSnapshot().tree.root !== null) return; const id = generatePaneId(); surfaceRefForId(id); - const defaults = getDefaultShellOpts(); - if (defaults?.shell) setPendingShellOpts(id, { shell: defaults.shell, args: defaults.args }); + stageDefaultShell(id, departedId ? getInheritableCwd(departedId) : undefined); lath.store.setEnterHint(id, 'top-left'); // grows from the top-left as the killed pane shrank to the bottom-right lath.store.addLeaf(id, terminalLeafMeta(), null); // becomes the root // Adopt selection only when it points at nothing real: null, or dangling (a @@ -1086,7 +1095,9 @@ export function Wall({ publishMembership(); } if (closingWorkspaceRef.current) return; - refillEmptyTree(); + // A commit that empties the tree took every pre-commit leaf with it. + const [departedId] = prevIds; + refillEmptyTree(departedId); }); }, [lath, fireEvent, refillEmptyTree, publishMembership]); @@ -1366,16 +1377,15 @@ export function Wall({ } const newId = generatePaneId(); - const defaults = getDefaultShellOpts(); // An explicit cwd (dor ensure --cwd, defaulting to the caller's directory) // wins; otherwise inherit the reference pane's local cwd as dor split does. - const sourceCwd = getTerminalPaneState(referenceId).cwd; - const inheritedCwd = cwd ?? (sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined); + const inheritedCwd = cwd ?? getInheritableCwd(referenceId); if (deferTerminal) { // No pending shell opts at all: the terminal must not spawn when the leaf // mounts, and must not inherit a cwd it will never use. } else if (command) { + const defaults = getDefaultShellOpts(); // Spawn a real interactive shell and type the command into it once it // reaches a prompt (see typeCommandWhenPromptReady in the lifecycle), rather // than launching `shell -c command`. A `-c` invocation has no prompt behind @@ -1394,12 +1404,8 @@ export function Wall({ command, ...(requireIntegration ? { requireIntegration: true } : {}), }); - } else if (defaults?.shell || inheritedCwd) { - setPendingShellOpts(newId, { - shell: defaults?.shell, - args: defaults?.args, - cwd: inheritedCwd, - }); + } else { + stageDefaultShell(newId, inheritedCwd); } if (referenceDoor) { @@ -1855,13 +1861,7 @@ export function Wall({ surfaceRefForId(newId); const ref = id && nav.hasPane(id) ? id : null; // Carry the currently selected shell into every manual split. - const defaults = getDefaultShellOpts(); - // Remote cwds (OSC 7 over ssh) name a path on the remote host, not one the local shell can chdir to. - const sourceCwd = ref ? getTerminalPaneState(ref).cwd : null; - const inheritedCwd = sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined; - if (defaults?.shell || inheritedCwd) { - setPendingShellOpts(newId, { shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd }); - } + stageDefaultShell(newId, ref ? getInheritableCwd(ref) : undefined); const panes = lath.listPanes(); const refId = ref ?? (panes.length > 0 ? panes[panes.length - 1].id : null); const edge: Edge = direction === 'right' ? 'right' : 'bottom'; diff --git a/lib/src/lib/helper-terminal.test.ts b/lib/src/lib/helper-terminal.test.ts index bf49f2ff8..80f9eee4d 100644 --- a/lib/src/lib/helper-terminal.test.ts +++ b/lib/src/lib/helper-terminal.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { registry, pendingShellOpts, type TerminalEntry } from './terminal-store'; -import { applyTerminalSemanticEvents, resetTerminalPaneState, removeTerminalPaneState } from './terminal-state-store'; +import { applyTerminalSemanticEvents, getTerminalPaneState, resetTerminalPaneState, removeTerminalPaneState } from './terminal-state-store'; import { beginPromotion, cancelPromotion, closeHelperParent, disposeHelper, finishPromotion, getHelper, helperHasWork, openHelper, restoreHelper, setHelperVisible, subscribeHelpers } from './helper-terminal'; const host = vi.hoisted(() => ({ writePty: vi.fn(), terminalContext: vi.fn() })); @@ -137,6 +137,16 @@ describe('helper lifecycle', () => { prompt(first.id); await vi.advanceTimersByTimeAsync(500); expect(first.status).toBe('completed'); expect(host.writePty).toHaveBeenCalledTimes(1); }); + it.each([ + ['a local parent cwd', false, '/work'], + ['no cwd from a remote parent', true, undefined], + ])('spawns and records %s', async (_label, isRemote, expected) => { + resetTerminalPaneState('parent', { cwd: { path: '/work', pathKind: 'posix', isRemote, source: 'osc7', updatedAt: 0 } }); + const helper = await openHelper('parent'); + expect(pendingShellOpts.get(helper.id)?.cwd).toBe(expected); + prompt(helper.id); await vi.advanceTimersByTimeAsync(100); + expect(getTerminalPaneState(helper.id).cwd?.path).toBe(expected); + }); it('user input during startup cancels autorun and remains preserved at idle', async () => { const helper = await openHelper('parent'); registry.get(helper.id)!.untouched = false; prompt(helper.id); await vi.advanceTimersByTimeAsync(100); diff --git a/lib/src/lib/helper-terminal.ts b/lib/src/lib/helper-terminal.ts index 0557d4ed0..a7b411d2d 100644 --- a/lib/src/lib/helper-terminal.ts +++ b/lib/src/lib/helper-terminal.ts @@ -2,7 +2,7 @@ import { getPlatform } from './platform'; import { registry } from './terminal-store'; import { disposeSession, getOrCreateTerminal, parkElement, setPendingShellOpts } from './terminal-lifecycle'; import { getDefaultShellOpts } from './shell-defaults'; -import { getTerminalPaneState, isPaneOscDriven, seedLaunchedCommand } from './terminal-state-store'; +import { getInheritableCwd, getTerminalPaneState, isPaneOscDriven, seedLaunchedCommand } from './terminal-state-store'; import { DEFAULT_HELPER_COMMAND, type HelperIdentity } from './terminal-context-types'; export type HelperStatus = 'waiting' | 'running' | 'completed' | 'preserved' | 'off' | 'unsupported' | 'exited'; @@ -156,15 +156,17 @@ export async function openHelper(parentId: string): Promise { const settings = await platform.terminalContext({ op: 'settings' }); if (!parentIsOpen(parentId)) throw new Error('The parent terminal has closed'); const id = `helper-${crypto.randomUUID()}`; - const cwd = getTerminalPaneState(parentId).cwd; + // One cwd for the spawn and the launched command's record, so a remote parent + // leaves the helper in the host default rather than claiming the ssh path. + const cwd = getInheritableCwd(parentId); const command = settings.command ?? DEFAULT_HELPER_COMMAND; const helper: HelperTerminal = { id, parentId, command, status: command ? 'waiting' : 'off' }; helpers.set(parentId, helper); - setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd: cwd && !cwd.isRemote ? cwd.path : undefined, helper: { parentId, command } }); + setPendingShellOpts(id, { ...getDefaultShellOpts(), cwd, helper: { parentId, command } }); getOrCreateTerminal(id); parkElement(id); notifyHelpers(); - watchHelper(helper, cwd?.path); + watchHelper(helper, cwd); return helper; })(); pending.set(parentId, operation); diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index bb1a5c908..d69cd127a 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -103,6 +103,7 @@ export { ensureTerminalPaneState, fillTerminalProcessCwd, getRunningCommandArgv0, + getInheritableCwd, getTerminalPaneState, getTerminalPaneStateSnapshot, isPaneOscDriven, diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 1ee1be979..a569bd330 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -65,6 +65,13 @@ export function getTerminalPaneState(id: string): TerminalPaneState { return paneStates.get(id) ?? createTerminalPaneState(); } +/** The cwd a new local shell may inherit from `id`. A remote cwd (OSC 7 over ssh) + * names a path on the remote host, not one the local shell can chdir to. */ +export function getInheritableCwd(id: string): string | undefined { + const cwd = paneStates.get(id)?.cwd; + return cwd && !cwd.isRemote ? cwd.path : undefined; +} + /** * The bare program name of the pane's foreground command, or null when the pane * is at a prompt (or its shell reported no command line). This is the key the