fix(workspace): restore layout snapshot on failed template apply; inert late-mounted portals while locked
This commit is contained in:
1 parent
ee667cdc2b
commit
fb65981bff
3 files changed
+136
-39
No files matched your search
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> => {
|
||||
|
||||
Reference in new issue
Block a user