fix(workspace): 旧版布局模板不再误杀 SSH 会话
legacy(M6.1 前单 dockview)模板只喂 terminal dockview,成功路径却无条件 rebind 两个 dockview 并 kill 全部 previousSessions,SSH 活面板被降级为本地 终端、会话被静默销毁。成功路径改为按 sshTouched 门控 rebind、按 live 集合 过滤收尾 kill(与错误路径同一语义)。 另:handleApplyTemplate 加在途守卫(并发应用会互杀对方刚恢复的会话); 主机密钥队列加 120s TTL 清扫(主进程超时后不再广播,陈旧弹窗会永远占住 只渲染队头的队列),respondHostKey 移出 setState updater。
This commit is contained in:
1 parent
06cfc61392
commit
0289dcbd1d
1 file changed
+163
-99
@@ -99,6 +99,22 @@ const DOCKVIEW_TAB_COMPONENTS = {
|
||||
)
|
||||
}
|
||||
|
||||
/** A queued host-key prompt plus the time it was queued — see the TTL sweep. */
|
||||
interface QueuedHostKeyPrompt extends HostKeyPromptEvent {
|
||||
enqueuedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* How long a queued host-key prompt is worth answering. Mirrors the main
|
||||
* process' `DEFAULT_TIMEOUTS.prompt` (src/main/ssh.ts): once that elapses the
|
||||
* prompt has already been resolved as a reject and dropped there, so a
|
||||
* decision of ours can no longer reach it.
|
||||
*/
|
||||
const HOST_KEY_PROMPT_TTL_MS = 120_000
|
||||
|
||||
/** Expired-prompt sweep interval (rendering draws the queue head only). */
|
||||
const HOST_KEY_SWEEP_MS = 5_000
|
||||
|
||||
/**
|
||||
* Electron re-throws a main-process rejection as
|
||||
* `Error: Error invoking remote method 'x': Error: <real message>`, so the real
|
||||
@@ -333,8 +349,12 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
/** Re-entrancy guard for `connect` — a second attempt while one is in flight
|
||||
* is dropped (openSession cannot be aborted and would orphan a session). */
|
||||
const connectInFlightRef = useRef(0)
|
||||
/** Re-entrancy guard for `handleApplyTemplate` — an overlapping second apply
|
||||
* would collect the sessions the first one just restored as its own
|
||||
* "previous" set and kill them. */
|
||||
const applyTemplateInFlightRef = useRef(false)
|
||||
/** FIFO of pending host-key confirmations — render the head only. */
|
||||
const [hostKeyQueue, setHostKeyQueue] = useState<HostKeyPromptEvent[]>([])
|
||||
const [hostKeyQueue, setHostKeyQueue] = useState<QueuedHostKeyPrompt[]>([])
|
||||
|
||||
/** B's sidebar handle; refresh() after a successful SSH connect */
|
||||
const sidebarRef = useRef<ConnectionSidebarHandle>(null)
|
||||
@@ -647,21 +667,39 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
*/
|
||||
useEffect(() => {
|
||||
return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => {
|
||||
setHostKeyQueue((prev) => [...prev, event])
|
||||
setHostKeyQueue((prev) => [...prev, { ...event, enqueuedAt: Date.now() }])
|
||||
})
|
||||
}, [])
|
||||
|
||||
// A prompt the main process gave up on never gets a decision from the user:
|
||||
// its own timeout resolves the attempt as a reject and stops tracking the
|
||||
// prompt, but nothing tells the renderer. Since only the head is rendered,
|
||||
// such an entry would hold the single visible slot forever and block every
|
||||
// later host's prompt behind it, so sweep the expired ones out.
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
const now = Date.now()
|
||||
setHostKeyQueue((prev) => {
|
||||
const next = prev.filter((p) => now - p.enqueuedAt < HOST_KEY_PROMPT_TTL_MS)
|
||||
return next.length === prev.length ? prev : next
|
||||
})
|
||||
}, HOST_KEY_SWEEP_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null
|
||||
|
||||
const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => {
|
||||
// The queue head's decision is final (accept/reject both consume the prompt).
|
||||
setHostKeyQueue((prev) => {
|
||||
const head = prev[0]
|
||||
if (!head) return prev
|
||||
window.api.respondHostKey(head.promptId, action)
|
||||
return prev.slice(1)
|
||||
})
|
||||
}, [])
|
||||
const handleHostKeyDecision = useCallback(
|
||||
(promptId: string, action: 'accept' | 'reject'): void => {
|
||||
// The head's decision is final (accept/reject both consume the prompt).
|
||||
// The IPC call belongs here, not inside the updater: React may re-run an
|
||||
// updater (it does so deliberately to surface impure ones), which would
|
||||
// answer the prompt — and log it as unanswered — more than once.
|
||||
window.api.respondHostKey(promptId, action)
|
||||
setHostKeyQueue((prev) => prev.filter((p) => p.promptId !== promptId))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
/**
|
||||
* M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
|
||||
@@ -1032,77 +1070,119 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
|
||||
const handleApplyTemplate = useCallback(
|
||||
async (meta: LayoutMetaLike): Promise<void> => {
|
||||
const raw = await window.api.getLayout(meta.id)
|
||||
if (raw == null) return
|
||||
|
||||
// Sessions currently bound to panels — the template load replaces them.
|
||||
const previousSessions = new Set<string>()
|
||||
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
||||
if (!api) continue
|
||||
for (const panel of api.panels) {
|
||||
const sid = sessionIdOf(panel)
|
||||
if (sid) previousSessions.add(sid)
|
||||
}
|
||||
}
|
||||
|
||||
let parsed: unknown
|
||||
// Overlapping applies corrupt each other: the second call would collect
|
||||
// the sessions the first one just restored into `previousSessions` and
|
||||
// kill them on the way out. Drop the second call instead.
|
||||
if (applyTemplateInFlightRef.current) return
|
||||
applyTemplateInFlightRef.current = true
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} 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
|
||||
const raw = await window.api.getLayout(meta.id)
|
||||
if (raw == null) return
|
||||
|
||||
// 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()
|
||||
// Sessions currently bound to panels — the template load replaces them.
|
||||
const previousSessions = new Set<string>()
|
||||
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
||||
if (!api) continue
|
||||
for (const panel of api.panels) {
|
||||
const sid = sessionIdOf(panel)
|
||||
if (sid) previousSessions.add(sid)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} 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
|
||||
|
||||
// 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.
|
||||
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)
|
||||
// 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.
|
||||
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
|
||||
}
|
||||
} 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.
|
||||
|
||||
// Fresh sessions for every restored terminal panel. Only a dockview the
|
||||
// payload actually reached may be rebound: `rebindRestoredPanels`
|
||||
// rewrites each panel's params to a fresh *local* session, so running it
|
||||
// over a legacy template's untouched ssh workspace would demote its live
|
||||
// SSH panes to local terminals.
|
||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||
if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||
|
||||
// A template carries the tab titles it was saved with, which may be in
|
||||
// another language (or written by a build whose language the user has
|
||||
// since switched away from).
|
||||
retitleAutoPanels()
|
||||
|
||||
// Old sessions are unreachable after the layout swap — kill them, but
|
||||
// only the ones no panel shows any more: a legacy template never reached
|
||||
// the ssh dockview, whose panes keep their live SSH sessions (the same
|
||||
// `live` test the failure path uses).
|
||||
const live = new Set<string>()
|
||||
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
||||
for (const panel of api?.panels ?? []) {
|
||||
@@ -1111,32 +1191,13 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
}
|
||||
}
|
||||
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
|
||||
} finally {
|
||||
applyTemplateInFlightRef.current = false
|
||||
}
|
||||
|
||||
// Fresh sessions for every restored terminal panel in both workspaces.
|
||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||
|
||||
// A template carries the tab titles it was saved with, which may be in
|
||||
// another language (or written by a build whose language the user has
|
||||
// since switched away from).
|
||||
retitleAutoPanels()
|
||||
|
||||
// Old sessions are unreachable after the layout swap — kill them.
|
||||
for (const sid of previousSessions) killSession(sid)
|
||||
recountAll()
|
||||
recomputeLocalPanels()
|
||||
const mode = useWorkspaceModeStore.getState().mode
|
||||
activePanelRef.current = apiOfMode(mode, terminalApiRef.current, sshApiRef.current)?.activePanel
|
||||
},
|
||||
[killSession, message, rebindRestoredPanels, recountAll, recomputeLocalPanels, retitleAutoPanels]
|
||||
)
|
||||
@@ -1284,7 +1345,10 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
||||
|
||||
{/* Host-key verification — head of the FIFO queue only. */}
|
||||
{hostKeyHead != null && (
|
||||
<HostKeyModal event={hostKeyHead} onDecision={handleHostKeyDecision} />
|
||||
<HostKeyModal
|
||||
event={hostKeyHead}
|
||||
onDecision={(action) => handleHostKeyDecision(hostKeyHead.promptId, action)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SaveTemplateModal
|
||||
|
||||
Reference in new issue
Block a user