From fb65981bff2cde12dd580c56f45211b4e3da251a Mon Sep 17 00:00:00 2001 From: Bill Date: Wed, 7 Oct 2026 20:44:30 +0800 Subject: [PATCH] fix(workspace): restore layout snapshot on failed template apply; inert late-mounted portals while locked --- src/renderer/src/App.tsx | 38 +++++-- src/renderer/src/workspace/TerminalTab.tsx | 19 +++- src/renderer/src/workspace/Workspace.tsx | 118 ++++++++++++++++----- 3 files changed, 136 insertions(+), 39 deletions(-) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 83091d7..0384713 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -105,18 +105,38 @@ export default function App(): React.JSX.Element { // above the inert `.app-root`, and stays reachable. const appRoot = document.getElementById('root') const inerted: Element[] = [] - if (locked) { - for (const el of Array.from(document.body.children)) { - if (el === appRoot) continue - try { - el.setAttribute('inert', '') - inerted.push(el) - } catch { - // a node that refuses the attribute must not break the lock - } + const inertChild = (el: Element): void => { + if (el === appRoot) return + // Already inert for a reason of its own: leave the attribute alone so + // unlocking does not hand back a node whose own guard was removed. + if (el.hasAttribute('inert')) return + try { + el.setAttribute('inert', '') + inerted.push(el) + } catch { + // a node that refuses the attribute must not break the lock } } + let observer: MutationObserver | undefined + if (locked) { + for (const el of Array.from(document.body.children)) inertChild(el) + // The snapshot above only covers portals that already existed. A portal + // mounted *after* the lock engaged stays outside it — the canonical case + // is an SSH connection started just before the idle timer fired, whose + // host-key prompt pops up behind the overlay and whose antd autoFocus + // takes the password field's focus. Watch body children for as long as + // the lock holds; nothing is observed while unlocked. + observer = new MutationObserver((records) => { + for (const record of records) { + for (const node of Array.from(record.addedNodes)) { + if (node instanceof Element) inertChild(node) + } + } + }) + observer.observe(document.body, { childList: true }) + } return () => { + observer?.disconnect() for (const el of inerted) el.removeAttribute('inert') } }, [locked]) diff --git a/src/renderer/src/workspace/TerminalTab.tsx b/src/renderer/src/workspace/TerminalTab.tsx index 758a801..09612e1 100644 --- a/src/renderer/src/workspace/TerminalTab.tsx +++ b/src/renderer/src/workspace/TerminalTab.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react' -import { Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' +import { App, Button, Dropdown, Input, Modal, Popconfirm, Space } from 'antd' import type { MenuProps } from 'antd' import { CloudServerOutlined } from '@ant-design/icons' import type { IDockviewPanelHeaderProps } from 'dockview-react' @@ -132,6 +132,7 @@ export function SaveTemplateModal({ }): React.JSX.Element { const [name, setName] = useState('') const [saving, setSaving] = useState(false) + const { message } = App.useApp() const handleOk = async (): Promise => { const trimmed = name.trim() @@ -141,6 +142,12 @@ export function SaveTemplateModal({ await onConfirm(trimmed) setName('') onCancel() + } catch (error) { + // The ok button calls this through `void`, so a rejection would surface + // as an unhandled rejection instead of telling the user anything. + console.error('[workspace] saving the layout template failed', error) + message.error(t('common.saveFailed')) + onCancel() } finally { setSaving(false) } @@ -183,6 +190,7 @@ export function ApplyTemplateModal({ }): React.JSX.Element { const [items, setItems] = useState([]) const [busyId, setBusyId] = useState(null) + const { message } = App.useApp() const refresh = async (): Promise => { setItems(await list()) @@ -198,6 +206,15 @@ export function ApplyTemplateModal({ try { await onApply(meta) onClose() + } catch (error) { + // `onApply` reports a failed load itself; what reaches here is the + // unexpected rejection (the layout read, an IPC failure), which the + // `void` call site would otherwise drop as an unhandled rejection. The + // workspace has already been put back into a usable state, so closing + // the dialog is safe. + console.error('[workspace] applying the layout template failed', meta.id, error) + message.error(t('workspace.template.applyFailed')) + onClose() } finally { setBusyId(null) } diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index c684687..fbfdd3a 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -21,7 +21,8 @@ import type { IDockviewHeaderActionsProps, IDockviewPanel, IDockviewPanelHeaderProps, - IDockviewPanelProps + IDockviewPanelProps, + SerializedDockview } from 'dockview-react' import 'dockview-react/dist/styles/dockview.css' @@ -112,6 +113,22 @@ function connectFailureMessage(error: unknown): string { return detail ? `${generic}: ${detail}` : generic } +/** + * Put a `toJSON()` snapshot back into a dockview, swallowing a failure. + * + * Only used on the error path of a template load, where the dockview has just + * been wiped: a second throw must not escape into the caller, or it would skip + * the session cleanup that follows. + */ +function restoreLayoutSnapshot(api: DockviewApi | undefined, snapshot: SerializedDockview): void { + if (!api) return + try { + api.fromJSON(snapshot) + } catch (error) { + console.error('[workspace] restoring the layout after a failed template apply failed', error) + } +} + /** * "终端 N" titles fill the lowest free number among live panels: closing a * pane frees its number for the next new terminal, and a session restore can @@ -459,27 +476,14 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX // One pty/ssh session can back several panels — an SSH split mirrors a // single session on purpose. So closing a panel must not kill a session - // another panel is still showing; count the panels per session (seeding - // from the panels already present, e.g. a restored layout) and kill only - // when the last one goes away. - const useCount = new Map() - for (const existing of api.panels) { - const sid = sessionIdOf(existing) - if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1) - } - const countPanel = (panel: IDockviewPanel): void => { - const sid = sessionIdOf(panel) - if (sid) useCount.set(sid, (useCount.get(sid) ?? 0) + 1) - } + // another panel is still showing: ask the panels that exist right now + // rather than keep a running count, so the answer cannot drift away from + // the layout (a panel's session is swapped in place by `updateParameters`, + // and a layout load replaces every panel of a dockview at once). const releaseSession = (panel: IDockviewPanel): void => { const sid = sessionIdOf(panel) if (!sid) return - const left = (useCount.get(sid) ?? 1) - 1 - if (left > 0) { - useCount.set(sid, left) - return - } - useCount.delete(sid) + for (const other of api.panels) if (sessionIdOf(other) === sid) return killSession(sid) // No pane shows this session any more: drop its bookkeeping too, so // neither the dead-session set nor the cwd map grows for the whole run. @@ -515,8 +519,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX recountAll() scheduleSave() }), - api.onDidAddPanel((panel: IDockviewPanel) => { - countPanel(panel) + api.onDidAddPanel(() => { recountAll() scheduleSave() }) @@ -1038,21 +1041,78 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX let parsed: unknown try { parsed = JSON.parse(raw) - } catch { + } catch (error) { + console.error('[workspace] template payload is not valid JSON', meta.id, error) + message.error(t('workspace.template.applyFailed')) return } const payload = parsed as { terminal?: unknown; ssh?: unknown } const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload + // Snapshot both dockviews before they are touched: a payload that + // deserializes half-way (a template written by another build, a panel + // whose component no longer exists) makes dockview clear every group and + // panel it built *and* the layout it replaced, and only then rethrow + // ("failed to deserialize layout. Reverting changes"). The snapshots are + // the way back to the layout the user had. + const terminalSnapshot = terminalApiRef.current?.toJSON() + const sshSnapshot = sshApiRef.current?.toJSON() + + // Which dockview the payload reached — a failed load must not put a + // snapshot back into one it never touched: restoring a dockview wipes the + // panels that are still alive in it (and, through `onDidRemovePanel`, + // kills the sessions behind them). + let terminalTouched = false + let sshTouched = false + // Dual layout (M6.1+): restore each dockview from its own payload. // Legacy layout (pre-M6.1 single toJSON): restore it wholly into the // terminal dockview. - if (hasDualLayout) { - terminalApiRef.current?.fromJSON(payload.terminal as never) - sshApiRef.current?.fromJSON(payload.ssh as never) - } else { - // Legacy single-dockview layout → restore into the terminal workspace. - terminalApiRef.current?.fromJSON((payload as unknown) as never) + try { + if (hasDualLayout) { + terminalTouched = true + terminalApiRef.current?.fromJSON(payload.terminal as never) + sshTouched = true + sshApiRef.current?.fromJSON(payload.ssh as never) + } else { + // Legacy single-dockview layout → restore into the terminal workspace. + terminalTouched = true + terminalApiRef.current?.fromJSON((payload as unknown) as never) + } + } catch (error) { + console.error('[workspace] template apply failed', meta.id, error) + // The wipe took the panels down with it — and `onDidRemovePanel` took + // their sessions, so the user is left with an empty workspace and + // nothing to reattach to. Put each touched dockview back from its + // snapshot, then give every pane that comes back a fresh session: the + // one it used to show is gone. + if (terminalTouched && terminalSnapshot) { + restoreLayoutSnapshot(terminalApiRef.current, terminalSnapshot) + if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current) + } + if (sshTouched && sshSnapshot) { + restoreLayoutSnapshot(sshApiRef.current, sshSnapshot) + if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current) + } + // No session may outlive its panels: the dockview the payload never + // reached still shows its own (they stay), everything else goes. + const live = new Set() + for (const api of [terminalApiRef.current, sshApiRef.current]) { + for (const panel of api?.panels ?? []) { + const sid = sessionIdOf(panel) + if (sid) live.add(sid) + } + } + for (const sid of previousSessions) if (!live.has(sid)) killSession(sid) + // The panel counters and the sidebar list describe the aborted load, + // not the layout that is back; rebuild both from the panels that are + // actually here. + recountAll() + recomputeLocalPanels() + const mode = useWorkspaceModeStore.getState().mode + activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel + message.error(t('workspace.template.applyFailedRestored')) + return } // Fresh sessions for every restored terminal panel in both workspaces. @@ -1066,7 +1126,7 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX const mode = useWorkspaceModeStore.getState().mode activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel }, - [killSession, rebindRestoredPanels, recountAll, recomputeLocalPanels] + [killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels] ) const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise => {