fix(workspace): restore layout snapshot on failed template apply; inert late-mounted portals while locked

This commit is contained in:
Bill committed 2026-10-07 20:44:30 +08:00
1 parent ee667cdc2b
commit fb65981bff
3 files changed
+136 -39

No files matched your search

+29 -9
View File
@@ -105,18 +105,38 @@ export default function App(): React.JSX.Element {
// above the inert `.app-root`, and stays reachable. // above the inert `.app-root`, and stays reachable.
const appRoot = document.getElementById('root') const appRoot = document.getElementById('root')
const inerted: Element[] = [] const inerted: Element[] = []
if (locked) { const inertChild = (el: Element): void => {
for (const el of Array.from(document.body.children)) { if (el === appRoot) return
if (el === appRoot) continue // Already inert for a reason of its own: leave the attribute alone so
try { // unlocking does not hand back a node whose own guard was removed.
el.setAttribute('inert', '') if (el.hasAttribute('inert')) return
inerted.push(el) try {
} catch { el.setAttribute('inert', '')
// a node that refuses the attribute must not break the lock 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 () => { return () => {
observer?.disconnect()
for (const el of inerted) el.removeAttribute('inert') for (const el of inerted) el.removeAttribute('inert')
} }
}, [locked]) }, [locked])
+18 -1
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' 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 type { MenuProps } from 'antd'
import { CloudServerOutlined } from '@ant-design/icons' import { CloudServerOutlined } from '@ant-design/icons'
import type { IDockviewPanelHeaderProps } from 'dockview-react' import type { IDockviewPanelHeaderProps } from 'dockview-react'
@@ -132,6 +132,7 @@ export function SaveTemplateModal({
}): React.JSX.Element { }): React.JSX.Element {
const [name, setName] = useState('') const [name, setName] = useState('')
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const { message } = App.useApp()
const handleOk = async (): Promise<void> => { const handleOk = async (): Promise<void> => {
const trimmed = name.trim() const trimmed = name.trim()
@@ -141,6 +142,12 @@ export function SaveTemplateModal({
await onConfirm(trimmed) await onConfirm(trimmed)
setName('') setName('')
onCancel() 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 { } finally {
setSaving(false) setSaving(false)
} }
@@ -183,6 +190,7 @@ export function ApplyTemplateModal({
}): React.JSX.Element { }): React.JSX.Element {
const [items, setItems] = useState<LayoutMeta[]>([]) const [items, setItems] = useState<LayoutMeta[]>([])
const [busyId, setBusyId] = useState<string | null>(null) const [busyId, setBusyId] = useState<string | null>(null)
const { message } = App.useApp()
const refresh = async (): Promise<void> => { const refresh = async (): Promise<void> => {
setItems(await list()) setItems(await list())
@@ -198,6 +206,15 @@ export function ApplyTemplateModal({
try { try {
await onApply(meta) await onApply(meta)
onClose() 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 { } finally {
setBusyId(null) setBusyId(null)
} }
+89 -29
View File
@@ -21,7 +21,8 @@ import type {
IDockviewHeaderActionsProps, IDockviewHeaderActionsProps,
IDockviewPanel, IDockviewPanel,
IDockviewPanelHeaderProps, IDockviewPanelHeaderProps,
IDockviewPanelProps IDockviewPanelProps,
SerializedDockview
} from 'dockview-react' } from 'dockview-react'
import 'dockview-react/dist/styles/dockview.css' import 'dockview-react/dist/styles/dockview.css'
@@ -112,6 +113,22 @@ function connectFailureMessage(error: unknown): string {
return detail ? `${generic}: ${detail}` : generic 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 * "终端 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 * 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 // 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 // single session on purpose. So closing a panel must not kill a session
// another panel is still showing; count the panels per session (seeding // another panel is still showing: ask the panels that exist right now
// from the panels already present, e.g. a restored layout) and kill only // rather than keep a running count, so the answer cannot drift away from
// when the last one goes away. // the layout (a panel's session is swapped in place by `updateParameters`,
const useCount = new Map<string, number>() // and a layout load replaces every panel of a dockview at once).
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)
}
const releaseSession = (panel: IDockviewPanel): void => { const releaseSession = (panel: IDockviewPanel): void => {
const sid = sessionIdOf(panel) const sid = sessionIdOf(panel)
if (!sid) return if (!sid) return
const left = (useCount.get(sid) ?? 1) - 1 for (const other of api.panels) if (sessionIdOf(other) === sid) return
if (left > 0) {
useCount.set(sid, left)
return
}
useCount.delete(sid)
killSession(sid) killSession(sid)
// No pane shows this session any more: drop its bookkeeping too, so // 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. // 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() recountAll()
scheduleSave() scheduleSave()
}), }),
api.onDidAddPanel((panel: IDockviewPanel) => { api.onDidAddPanel(() => {
countPanel(panel)
recountAll() recountAll()
scheduleSave() scheduleSave()
}) })
@@ -1038,21 +1041,78 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
let parsed: unknown let parsed: unknown
try { try {
parsed = JSON.parse(raw) 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 return
} }
const payload = parsed as { terminal?: unknown; ssh?: unknown } const payload = parsed as { terminal?: unknown; ssh?: unknown }
const hasDualLayout = payload !== null && typeof payload === 'object' && 'terminal' in payload && 'ssh' in payload 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. // Dual layout (M6.1+): restore each dockview from its own payload.
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the // Legacy layout (pre-M6.1 single toJSON): restore it wholly into the
// terminal dockview. // terminal dockview.
if (hasDualLayout) { try {
terminalApiRef.current?.fromJSON(payload.terminal as never) if (hasDualLayout) {
sshApiRef.current?.fromJSON(payload.ssh as never) terminalTouched = true
} else { terminalApiRef.current?.fromJSON(payload.terminal as never)
// Legacy single-dockview layout → restore into the terminal workspace. sshTouched = true
terminalApiRef.current?.fromJSON((payload as unknown) as never) 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<string>()
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. // 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 const mode = useWorkspaceModeStore.getState().mode
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel 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<void> => { const handleDeleteTemplate = useCallback(async (meta: LayoutMetaLike): Promise<void> => {