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
|
* Electron re-throws a main-process rejection as
|
||||||
* `Error: Error invoking remote method 'x': Error: <real message>`, so the real
|
* `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
|
/** Re-entrancy guard for `connect` — a second attempt while one is in flight
|
||||||
* is dropped (openSession cannot be aborted and would orphan a session). */
|
* is dropped (openSession cannot be aborted and would orphan a session). */
|
||||||
const connectInFlightRef = useRef(0)
|
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. */
|
/** 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 */
|
/** B's sidebar handle; refresh() after a successful SSH connect */
|
||||||
const sidebarRef = useRef<ConnectionSidebarHandle>(null)
|
const sidebarRef = useRef<ConnectionSidebarHandle>(null)
|
||||||
@@ -647,21 +667,39 @@ export default function Workspace({ onOpenSettings }: WorkspaceProps): React.JSX
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return window.api.onHostKeyPrompt((event: HostKeyPromptEvent) => {
|
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 hostKeyHead = hostKeyQueue.length > 0 ? hostKeyQueue[0] : null
|
||||||
|
|
||||||
const handleHostKeyDecision = useCallback((action: 'accept' | 'reject'): void => {
|
const handleHostKeyDecision = useCallback(
|
||||||
// The queue head's decision is final (accept/reject both consume the prompt).
|
(promptId: string, action: 'accept' | 'reject'): void => {
|
||||||
setHostKeyQueue((prev) => {
|
// The head's decision is final (accept/reject both consume the prompt).
|
||||||
const head = prev[0]
|
// The IPC call belongs here, not inside the updater: React may re-run an
|
||||||
if (!head) return prev
|
// updater (it does so deliberately to surface impure ones), which would
|
||||||
window.api.respondHostKey(head.promptId, action)
|
// answer the prompt — and log it as unanswered — more than once.
|
||||||
return prev.slice(1)
|
window.api.respondHostKey(promptId, action)
|
||||||
})
|
setHostKeyQueue((prev) => prev.filter((p) => p.promptId !== promptId))
|
||||||
}, [])
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* M6: Ctrl+PgUp / Ctrl+PgDn cycle tabs. Registered on `window` with
|
* 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(
|
const handleApplyTemplate = useCallback(
|
||||||
async (meta: LayoutMetaLike): Promise<void> => {
|
async (meta: LayoutMetaLike): Promise<void> => {
|
||||||
const raw = await window.api.getLayout(meta.id)
|
// Overlapping applies corrupt each other: the second call would collect
|
||||||
if (raw == null) return
|
// the sessions the first one just restored into `previousSessions` and
|
||||||
|
// kill them on the way out. Drop the second call instead.
|
||||||
// Sessions currently bound to panels — the template load replaces them.
|
if (applyTemplateInFlightRef.current) return
|
||||||
const previousSessions = new Set<string>()
|
applyTemplateInFlightRef.current = true
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
parsed = JSON.parse(raw)
|
const raw = await window.api.getLayout(meta.id)
|
||||||
} catch (error) {
|
if (raw == null) return
|
||||||
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
|
// Sessions currently bound to panels — the template load replaces them.
|
||||||
// deserializes half-way (a template written by another build, a panel
|
const previousSessions = new Set<string>()
|
||||||
// whose component no longer exists) makes dockview clear every group and
|
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
||||||
// panel it built *and* the layout it replaced, and only then rethrow
|
if (!api) continue
|
||||||
// ("failed to deserialize layout. Reverting changes"). The snapshots are
|
for (const panel of api.panels) {
|
||||||
// the way back to the layout the user had.
|
const sid = sessionIdOf(panel)
|
||||||
const terminalSnapshot = terminalApiRef.current?.toJSON()
|
if (sid) previousSessions.add(sid)
|
||||||
const sshSnapshot = sshApiRef.current?.toJSON()
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Which dockview the payload reached — a failed load must not put a
|
let parsed: unknown
|
||||||
// snapshot back into one it never touched: restoring a dockview wipes the
|
try {
|
||||||
// panels that are still alive in it (and, through `onDidRemovePanel`,
|
parsed = JSON.parse(raw)
|
||||||
// kills the sessions behind them).
|
} catch (error) {
|
||||||
let terminalTouched = false
|
console.error('[workspace] template payload is not valid JSON', meta.id, error)
|
||||||
let sshTouched = false
|
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.
|
// Snapshot both dockviews before they are touched: a payload that
|
||||||
// Legacy layout (pre-M6.1 single toJSON): restore it wholly into the
|
// deserializes half-way (a template written by another build, a panel
|
||||||
// terminal dockview.
|
// whose component no longer exists) makes dockview clear every group and
|
||||||
try {
|
// panel it built *and* the layout it replaced, and only then rethrow
|
||||||
if (hasDualLayout) {
|
// ("failed to deserialize layout. Reverting changes"). The snapshots are
|
||||||
terminalTouched = true
|
// the way back to the layout the user had.
|
||||||
terminalApiRef.current?.fromJSON(payload.terminal as never)
|
const terminalSnapshot = terminalApiRef.current?.toJSON()
|
||||||
sshTouched = true
|
const sshSnapshot = sshApiRef.current?.toJSON()
|
||||||
sshApiRef.current?.fromJSON(payload.ssh as never)
|
|
||||||
} else {
|
// Which dockview the payload reached — a failed load must not put a
|
||||||
// Legacy single-dockview layout → restore into the terminal workspace.
|
// snapshot back into one it never touched: restoring a dockview wipes the
|
||||||
terminalTouched = true
|
// panels that are still alive in it (and, through `onDidRemovePanel`,
|
||||||
terminalApiRef.current?.fromJSON((payload as unknown) as never)
|
// 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)
|
// Fresh sessions for every restored terminal panel. Only a dockview the
|
||||||
// The wipe took the panels down with it — and `onDidRemovePanel` took
|
// payload actually reached may be rebound: `rebindRestoredPanels`
|
||||||
// their sessions, so the user is left with an empty workspace and
|
// rewrites each panel's params to a fresh *local* session, so running it
|
||||||
// nothing to reattach to. Put each touched dockview back from its
|
// over a legacy template's untouched ssh workspace would demote its live
|
||||||
// snapshot, then give every pane that comes back a fresh session: the
|
// SSH panes to local terminals.
|
||||||
// one it used to show is gone.
|
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.current)
|
||||||
if (terminalTouched && terminalSnapshot) {
|
if (sshTouched && sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
||||||
restoreLayoutSnapshot(terminalApiRef.current, terminalSnapshot)
|
|
||||||
if (terminalApiRef.current) await rebindRestoredPanels(terminalApiRef.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
|
||||||
if (sshTouched && sshSnapshot) {
|
// since switched away from).
|
||||||
restoreLayoutSnapshot(sshApiRef.current, sshSnapshot)
|
retitleAutoPanels()
|
||||||
if (sshApiRef.current) await rebindRestoredPanels(sshApiRef.current)
|
|
||||||
}
|
// Old sessions are unreachable after the layout swap — kill them, but
|
||||||
// No session may outlive its panels: the dockview the payload never
|
// only the ones no panel shows any more: a legacy template never reached
|
||||||
// reached still shows its own (they stay), everything else goes.
|
// the ssh dockview, whose panes keep their live SSH sessions (the same
|
||||||
|
// `live` test the failure path uses).
|
||||||
const live = new Set<string>()
|
const live = new Set<string>()
|
||||||
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
for (const api of [terminalApiRef.current, sshApiRef.current]) {
|
||||||
for (const panel of api?.panels ?? []) {
|
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)
|
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()
|
recountAll()
|
||||||
recomputeLocalPanels()
|
recomputeLocalPanels()
|
||||||
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
|
||||||
message.error(t('workspace.template.applyFailedRestored'))
|
} finally {
|
||||||
return
|
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]
|
[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. */}
|
{/* Host-key verification — head of the FIFO queue only. */}
|
||||||
{hostKeyHead != null && (
|
{hostKeyHead != null && (
|
||||||
<HostKeyModal event={hostKeyHead} onDecision={handleHostKeyDecision} />
|
<HostKeyModal
|
||||||
|
event={hostKeyHead}
|
||||||
|
onDecision={(action) => handleHostKeyDecision(hostKeyHead.promptId, action)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<SaveTemplateModal
|
<SaveTemplateModal
|
||||||
|
|||||||
Reference in new issue
Block a user