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.
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])
+18 -1
View File
@@ -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<void> => {
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<LayoutMeta[]>([])
const [busyId, setBusyId] = useState<string | null>(null)
const { message } = App.useApp()
const refresh = async (): Promise<void> => {
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)
}
+89 -29
View File
@@ -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<string, number>()
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<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.
@@ -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<void> => {