give panels a close signal that only fires on a real close
A panel that owns something on the server — a pty, a lock — has had no way to be told it was closed. `TerminalWrapper` says so in a comment: it cannot kill on unmount, because a drag, a swap, a mobile panel switch and a genuine close are the same event from inside the component. So the signal is raised where the intent is, not where the teardown is. `usePanelClose(panelId, fn)` registers a handler; `WorkspaceView` fires it from `handleRemove` and from `handleSetApp` when the app actually changes, and from nowhere else. Registration is deliberately never torn down — "unmounted" is the ambiguous signal being replaced, so honouring it would reintroduce the bug — and handlers are stamped with the workspace they were registered on so one dashboard's panel id cannot fire another's. `findPanelApp` is what tells a real app change from re-picking the app already there, which `setApp` treats as a no-op; without it every pick from the app menu would close a panel that never closed. No app uses the hook yet. The terminals come next; a chat panel deliberately never will, since a chat panel is a pointer to a server-side session and closing the window must not delete what it points at.
This commit is contained in:
@@ -13,6 +13,10 @@ type WorkspaceContextValue = {
|
||||
// panel that owns it — apps read this through `usePanelConfig`, not directly.
|
||||
panelConfigs: Record<string, PanelConfig>;
|
||||
setPanelConfig: (panelId: string, config: PanelConfig | undefined) => void;
|
||||
// Say what to do when this panel is deliberately closed. Apps call it through `usePanelClose`, never
|
||||
// directly. Registering twice for a panel id replaces the handler; nothing ever unregisters, because
|
||||
// "this component went away" is the signal we are specifically refusing to trust.
|
||||
registerPanelClose: (panelId: string, handler: () => void) => void;
|
||||
swapSourceId: string | null;
|
||||
setSwapSourceId: (id: string | null) => void;
|
||||
onSwap: (sourceId: string, targetId: string) => void;
|
||||
@@ -42,6 +46,7 @@ const noop = () => {};
|
||||
*/
|
||||
export const inertInteraction = {
|
||||
setPanelConfig: noop,
|
||||
registerPanelClose: noop,
|
||||
swapSourceId: null,
|
||||
setSwapSourceId: noop,
|
||||
onSwap: noop,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useIsMobile } from 'hooks/useIsMobile';
|
||||
import { useSessionState } from 'hooks/useSessionState';
|
||||
import type { LayoutNode, DashboardState, EphemeralPanels, PanelComponents, PanelConfig } from './types';
|
||||
import type { DropPosition } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs } from './layout-utils';
|
||||
import { splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, countPanels, setZoom, setPanelConfig, collectPanelConfigs, findPanelApp } from './layout-utils';
|
||||
import { WorkspaceProvider } from './WorkspaceContext';
|
||||
import { parseWorkspaceKey } from './workspace-identity';
|
||||
import { WorkspaceRenderer } from './WorkspaceRenderer';
|
||||
@@ -54,6 +54,37 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, components,
|
||||
}
|
||||
}, [maximizedPanelId]);
|
||||
|
||||
// What each panel wants done when it is closed, by panel id, stamped with the workspace it was
|
||||
// registered on. A ref rather than state: nothing renders from it, and a panel registering during its
|
||||
// own mount effect must not schedule a render of the whole workspace.
|
||||
//
|
||||
// Nothing is ever unregistered. A handler is dropped when it fires and replaced when the same panel id
|
||||
// registers again; a panel that merely unmounted keeps its handler, because "unmounted" is precisely
|
||||
// the signal this exists to avoid trusting. The stamp is what keeps that from leaking across
|
||||
// dashboards — the same panel id can exist on two of them, and a handler from the one you navigated
|
||||
// away from must not fire for the one you are on.
|
||||
const closeHandlers = useRef(new Map<string, { workspaceKey: string; run: () => void }>());
|
||||
const workspaceKeyRef = useRef(workspace.key);
|
||||
workspaceKeyRef.current = workspace.key;
|
||||
|
||||
const registerPanelClose = useCallback((panelId: string, run: () => void) => {
|
||||
closeHandlers.current.set(panelId, { workspaceKey: workspaceKeyRef.current, run });
|
||||
}, []);
|
||||
|
||||
const firePanelClose = useCallback((panelId: string) => {
|
||||
const entry = closeHandlers.current.get(panelId);
|
||||
if (!entry) return;
|
||||
closeHandlers.current.delete(panelId);
|
||||
if (entry.workspaceKey !== workspaceKeyRef.current) return;
|
||||
// A handler that throws must not stop the panel from closing — the layout write is the user's
|
||||
// instruction, and the cleanup is a best effort on top of it.
|
||||
try {
|
||||
entry.run();
|
||||
} catch (err) {
|
||||
console.error(`panel close handler for ${panelId} threw`, err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Every mutation goes through `onLayoutChange` as an UPDATER, never as a computed tree.
|
||||
//
|
||||
// Each of these used to close over `layout` as it was when the callback was made, and two of the paths
|
||||
@@ -66,11 +97,18 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, components,
|
||||
//
|
||||
// The guards below still test the rendered `layout` — they only decide whether a write is worth making,
|
||||
// and being one render stale there costs a redundant no-op write at worst.
|
||||
//
|
||||
// `handleSetApp` and `handleRemove` additionally fire the closing panel's `usePanelClose` handler.
|
||||
// They are the only two places that do, and they are the only two that know a close was *asked for* —
|
||||
// a drag, a swap and a mobile panel switch all tear a panel's component down without closing anything.
|
||||
const handleSetApp = useCallback(
|
||||
(panelId: string, appType: string | null) => {
|
||||
// Re-picking the app that is already there is not a close: `setApp` keeps the config and the app
|
||||
// never goes away. Every other transition retires whatever was running.
|
||||
if (findPanelApp(layout, panelId) !== appType) firePanelClose(panelId);
|
||||
onLayoutChange((prev) => setApp(prev, panelId, appType));
|
||||
},
|
||||
[onLayoutChange],
|
||||
[layout, onLayoutChange, firePanelClose],
|
||||
);
|
||||
|
||||
const handleSplit = useCallback(
|
||||
@@ -83,9 +121,15 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, components,
|
||||
const handleRemove = useCallback(
|
||||
(panelId: string) => {
|
||||
if (countPanels(layout) <= 1) return;
|
||||
// Fired before the write, from the rendered tree, rather than from inside the updater — an updater
|
||||
// has to stay pure and may run twice. The updater's own guard can therefore decline a removal we
|
||||
// already announced, if a concurrent write shrank the tree to one panel in between. That costs a
|
||||
// cleanup for a panel still on screen; it needs two writes to race inside one debounce window, and
|
||||
// the alternative is a side effect in an updater.
|
||||
firePanelClose(panelId);
|
||||
onLayoutChange((prev) => (countPanels(prev) <= 1 ? prev : removePanel(prev, panelId)));
|
||||
},
|
||||
[layout, onLayoutChange],
|
||||
[layout, onLayoutChange, firePanelClose],
|
||||
);
|
||||
|
||||
const handleResized = useCallback(
|
||||
@@ -191,6 +235,7 @@ export const WorkspaceView = ({ workspace, locked, cwd = '~', root, components,
|
||||
root,
|
||||
panelConfigs,
|
||||
setPanelConfig: locked ? noop : handleSetPanelConfig,
|
||||
registerPanelClose,
|
||||
swapSourceId,
|
||||
setSwapSourceId,
|
||||
onSwap: handleSwap,
|
||||
|
||||
@@ -26,10 +26,12 @@ export {
|
||||
hasAnyApp,
|
||||
setPanelConfig,
|
||||
collectPanelConfigs,
|
||||
findPanelApp,
|
||||
} from './layout-utils';
|
||||
export type { WorkspaceIdentity } from './workspace-identity';
|
||||
export { parseWorkspaceKey } from './workspace-identity';
|
||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||
export { usePanelConfig } from './usePanelConfig';
|
||||
export { usePanelClose } from './usePanelClose';
|
||||
export { WorkspaceView } from './WorkspaceView';
|
||||
export { WorkspaceLayout } from './WorkspaceLayout';
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
clampZoom,
|
||||
collectPanelConfigs,
|
||||
countPanels,
|
||||
findPanelApp,
|
||||
hasAnyApp,
|
||||
movePanel,
|
||||
pruneEmptyPanels,
|
||||
@@ -527,3 +528,23 @@ describe('panel ids do not survive a rearrangement', () => {
|
||||
expect(find(next, 'p2')!.appType).toBe('x');
|
||||
});
|
||||
});
|
||||
|
||||
// `WorkspaceView` asks this before firing a panel's close handler, so the three answers have to stay
|
||||
// distinguishable: an app, no app, and no such panel. Collapsing the last two would close an empty panel
|
||||
// on every app pick.
|
||||
describe('findPanelApp', () => {
|
||||
const tree = group('g', 'horizontal', [panel('p1', 'terminal'), panel('p2')]);
|
||||
|
||||
test('reports the app a panel is running', () => {
|
||||
expect(findPanelApp(tree, 'p1')).toBe('terminal');
|
||||
});
|
||||
|
||||
test('distinguishes an empty panel from a missing one', () => {
|
||||
expect(findPanelApp(tree, 'p2')).toBeNull();
|
||||
expect(findPanelApp(tree, 'nope')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('finds a panel that is the whole tree', () => {
|
||||
expect(findPanelApp(panel('only', 'chat'), 'only')).toBe('chat');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -254,6 +254,15 @@ export function setPanelConfig(root: LayoutNode, panelId: string, config: PanelC
|
||||
return setContents(root, panelId, { appType: current.appType, config });
|
||||
}
|
||||
|
||||
/**
|
||||
* Which app a panel is running right now. `null` is a real answer (an empty panel); `undefined` means
|
||||
* there is no panel with that id. Callers that only want to know whether something changed can compare
|
||||
* the result directly, since both absences are distinct from any app key.
|
||||
*/
|
||||
export function findPanelApp(node: LayoutNode, panelId: string): string | null | undefined {
|
||||
return findPanelContents(node, panelId)?.appType;
|
||||
}
|
||||
|
||||
/** Every configured panel in the tree, by id — the reactive view apps read through `usePanelConfig`. */
|
||||
export function collectPanelConfigs(node: LayoutNode): Record<string, PanelConfig> {
|
||||
const out: Record<string, PanelConfig> = {};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { useWorkspace } from './WorkspaceContext';
|
||||
|
||||
/**
|
||||
* Say what to do when this panel is deliberately closed — removed from the workspace, or handed to a
|
||||
* different app. Use it to release whatever the panel owns on the server: a pty, a lock, a job.
|
||||
*
|
||||
* **This is not an unmount hook, and it is deliberately not one.** A panel unmounts when it is dragged
|
||||
* to a new position, when the mobile view switches panels, when a parent re-renders it under a new key
|
||||
* — all of which look identical to a close from inside the component. `movePanel` even mints a new panel
|
||||
* id on the way (see `layout-utils.test.ts`), so the layout cannot be asked either. Only the two call
|
||||
* sites that carry the intent can answer, so only they fire this. The cost of getting it wrong is a
|
||||
* killed terminal or a dropped session, which is why the framework refuses to guess.
|
||||
*
|
||||
* Consequences worth knowing before you use it:
|
||||
*
|
||||
* - The handler runs *while the panel is being torn down*, so it must not set React state. Call an API,
|
||||
* close a socket, drop a map entry — nothing that expects a render afterwards.
|
||||
* - Registration is never undone. A component that unmounts leaves its handler in place, because
|
||||
* "unmounted" is exactly the signal we are refusing to trust; re-registering for the same panel id
|
||||
* replaces it. Handlers are scoped to the workspace they were registered on, and one only ever fires
|
||||
* for the panel id it was registered under.
|
||||
* - `handler` does not need to be memoised — the latest one always runs.
|
||||
* - Nothing fires in a locked or inert workspace, where no panel can be closed in the first place.
|
||||
*
|
||||
* A panel that is a *pointer* to server-side state rather than the owner of it should not use this at
|
||||
* all. Closing a chat panel must not delete the session it points at.
|
||||
*/
|
||||
export function usePanelClose(panelId: string, handler: () => void): void {
|
||||
const { registerPanelClose } = useWorkspace();
|
||||
const latest = useRef(handler);
|
||||
latest.current = handler;
|
||||
|
||||
useEffect(() => {
|
||||
registerPanelClose(panelId, () => latest.current());
|
||||
}, [panelId, registerPanelClose]);
|
||||
}
|
||||
Reference in New Issue
Block a user