From 0289dcbd1dbbbec1d652be7207900933e18ea7ab Mon Sep 17 00:00:00 2001 From: Bill Date: Fri, 9 Oct 2026 11:30:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(workspace):=20=E6=97=A7=E7=89=88=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=A8=A1=E6=9D=BF=E4=B8=8D=E5=86=8D=E8=AF=AF=E6=9D=80?= =?UTF-8?q?=20SSH=20=E4=BC=9A=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit legacy(M6.1 前单 dockview)模板只喂 terminal dockview,成功路径却无条件 rebind 两个 dockview 并 kill 全部 previousSessions,SSH 活面板被降级为本地 终端、会话被静默销毁。成功路径改为按 sshTouched 门控 rebind、按 live 集合 过滤收尾 kill(与错误路径同一语义)。 另:handleApplyTemplate 加在途守卫(并发应用会互杀对方刚恢复的会话); 主机密钥队列加 120s TTL 清扫(主进程超时后不再广播,陈旧弹窗会永远占住 只渲染队头的队列),respondHostKey 移出 setState updater。 --- src/renderer/src/workspace/Workspace.tsx | 262 ++++++++++++++--------- 1 file changed, 163 insertions(+), 99 deletions(-) diff --git a/src/renderer/src/workspace/Workspace.tsx b/src/renderer/src/workspace/Workspace.tsx index 453c350..3bc5e76 100644 --- a/src/renderer/src/workspace/Workspace.tsx +++ b/src/renderer/src/workspace/Workspace.tsx @@ -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: `, 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([]) + const [hostKeyQueue, setHostKeyQueue] = useState([]) /** B's sidebar handle; refresh() after a successful SSH connect */ const sidebarRef = useRef(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 => { - 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() - 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() + 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() + 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() 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 && ( - + handleHostKeyDecision(hostKeyHead.promptId, action)} + /> )}