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.
|
// 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])
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
Reference in new issue
Block a user