import type { TerminalSession, Workspace } from "../../domain/models"; import { collectSessionIds, pruneWorkspaceNode } from "../../domain/workspace"; export type DetachSessionFromWorkspaceStateResult = { changed: boolean; sessions: TerminalSession[]; workspaces: Workspace[]; activeTabId?: string; dissolvedWorkspaceId?: string; replacementTabIds?: string[]; }; export type CloseSessionWorkspaceLayoutResult = { workspaces: Workspace[]; removedWorkspaceId?: string; dissolvedWorkspaceId?: string; lastRemainingSessionId?: string; }; export type CloseSessionsStateResult = { sessions: TerminalSession[]; workspaces: Workspace[]; tabOrder: string[]; activeTabId?: string; }; type DetachSessionFromWorkspaceStateOptions = { sessions: TerminalSession[]; workspaces: Workspace[]; sessionId: string; }; export function replaceDissolvedWorkspaceTabOrder( tabOrder: readonly string[], workspaceId: string | undefined, replacementTabIds: readonly string[] | undefined, ): string[] { if (!workspaceId || !replacementTabIds?.length) return [...tabOrder]; const uniqueReplacementIds = replacementTabIds.filter((tabId, index, list) => ( tabId && list.indexOf(tabId) === index )); if (uniqueReplacementIds.length === 0) return [...tabOrder]; if (!tabOrder.includes(workspaceId)) { const hasAllReplacementIds = uniqueReplacementIds.every((tabId) => tabOrder.includes(tabId)); return hasAllReplacementIds ? [...tabOrder] : [ ...tabOrder, ...uniqueReplacementIds.filter((tabId) => !tabOrder.includes(tabId)), ]; } const replacementIdSet = new Set(uniqueReplacementIds); let inserted = false; const nextOrder: string[] = []; for (const tabId of tabOrder) { if (tabId === workspaceId) { if (!inserted) { nextOrder.push(...uniqueReplacementIds); inserted = true; } continue; } if (!replacementIdSet.has(tabId)) { nextOrder.push(tabId); } } return nextOrder; } export function closeSessionWorkspaceLayoutState( workspaces: readonly Workspace[], workspaceId: string | undefined, sessionId: string, ): CloseSessionWorkspaceLayoutResult { if (!workspaceId) return { workspaces: [...workspaces] }; let removedWorkspaceId: string | undefined; let dissolvedWorkspaceId: string | undefined; let lastRemainingSessionId: string | undefined; const nextWorkspaces = workspaces .map((workspace) => { if (workspace.id !== workspaceId) return workspace; const prunedRoot = pruneWorkspaceNode(workspace.root, sessionId); if (!prunedRoot) { removedWorkspaceId = workspace.id; return null; } const remainingSessionIds = collectSessionIds(prunedRoot); if (remainingSessionIds.length === 1) { dissolvedWorkspaceId = workspace.id; lastRemainingSessionId = remainingSessionIds[0]; return null; } // Closing the focused session must hand focus to a remaining one, or a // focus-mode workspace is left with a dangling focusedSessionId: no pane // qualifies as visible and the continuously rendered hidden panes all // paint on top of each other (#3046). const nextFocusedSessionId = workspace.focusedSessionId && !remainingSessionIds.includes(workspace.focusedSessionId) ? remainingSessionIds[0] : workspace.focusedSessionId; const nextFocusSessionOrder = workspace.focusSessionOrder?.filter( (candidateId) => remainingSessionIds.includes(candidateId), ); return { ...workspace, root: prunedRoot, focusedSessionId: nextFocusedSessionId, focusSessionOrder: nextFocusSessionOrder, }; }) .filter((workspace): workspace is Workspace => Boolean(workspace)); return { workspaces: nextWorkspaces, removedWorkspaceId, dissolvedWorkspaceId, lastRemainingSessionId, }; } /** * Apply a close-session action to the session list using the layout result. * When a 2-pane workspace dissolves to one terminal, that remaining session * must become an orphan tab — not stay bound to a deleted workspaceId. */ export function applyCloseSessionToSessions( sessions: readonly TerminalSession[], sessionId: string, layoutResult: Pick, ): TerminalSession[] { const remaining = sessions.filter((session) => session.id !== sessionId); const lastRemainingSessionId = layoutResult.lastRemainingSessionId; if (!lastRemainingSessionId) return remaining; return remaining.map((session) => ( session.id === lastRemainingSessionId ? { ...session, workspaceId: undefined } : session )); } export function isPreferredTabAvailableAfterClose( input: { preferredTabId: string | null | undefined; closedSessionId: string; layoutResult: CloseSessionWorkspaceLayoutResult; remainingSessions: readonly TerminalSession[]; }, ): input is { preferredTabId: string; closedSessionId: string; layoutResult: CloseSessionWorkspaceLayoutResult; remainingSessions: readonly TerminalSession[]; } { const { preferredTabId, closedSessionId, layoutResult, remainingSessions } = input; if (!preferredTabId || preferredTabId === closedSessionId) return false; if ( preferredTabId === layoutResult.dissolvedWorkspaceId || preferredTabId === layoutResult.removedWorkspaceId ) { return false; } if (preferredTabId === "vault" || preferredTabId === "sftp") return true; if (layoutResult.workspaces.some((workspace) => workspace.id === preferredTabId)) return true; return remainingSessions.some((session) => ( session.id === preferredTabId && !session.workspaceId && !session.hiddenFromTabs )); } export function resolveActiveTabAfterCloseSession({ currentActiveTabId, closedSessionId, workspaceId, layoutResult, remainingSessions, preferredTabId, }: { currentActiveTabId: string | null; closedSessionId: string; workspaceId: string | undefined; layoutResult: CloseSessionWorkspaceLayoutResult; remainingSessions: readonly TerminalSession[]; preferredTabId?: string | null; }): string | null { const fallbackWorkspace = layoutResult.workspaces[layoutResult.workspaces.length - 1]; const fallbackSolo = remainingSessions.filter((session) => !session.workspaceId && !session.hiddenFromTabs).slice(-1)[0]; const preferredInput = { preferredTabId, closedSessionId, layoutResult, remainingSessions, }; const preferredFallback = isPreferredTabAvailableAfterClose(preferredInput) ? preferredInput.preferredTabId : undefined; const fallback = layoutResult.lastRemainingSessionId ?? preferredFallback ?? fallbackWorkspace?.id ?? fallbackSolo?.id ?? "vault"; if ( currentActiveTabId === closedSessionId || (layoutResult.dissolvedWorkspaceId && currentActiveTabId === layoutResult.dissolvedWorkspaceId) || (layoutResult.removedWorkspaceId && currentActiveTabId === layoutResult.removedWorkspaceId) || (workspaceId && currentActiveTabId === workspaceId && !layoutResult.workspaces.some((ws) => ws.id === workspaceId)) ) { return fallback; } return null; } export function closeSessionsState({ sessions, workspaces, sessionIds, currentActiveTabId, preferredTabId, tabOrder, }: { sessions: readonly TerminalSession[]; workspaces: readonly Workspace[]; sessionIds: readonly string[]; currentActiveTabId: string | null; preferredTabId?: string | null; tabOrder: readonly string[]; }): CloseSessionsStateResult { let nextSessions = [...sessions]; let nextWorkspaces = [...workspaces]; let nextTabOrder = [...tabOrder]; let nextActiveTabId = currentActiveTabId; let resolvedActiveTabId: string | undefined; for (const sessionId of new Set(sessionIds)) { const targetSession = nextSessions.find((session) => session.id === sessionId); if (!targetSession) continue; const workspaceId = targetSession.workspaceId; const layoutResult = closeSessionWorkspaceLayoutState( nextWorkspaces, workspaceId, sessionId, ); nextWorkspaces = layoutResult.workspaces; nextSessions = applyCloseSessionToSessions(nextSessions, sessionId, layoutResult); if (layoutResult.dissolvedWorkspaceId && layoutResult.lastRemainingSessionId) { nextTabOrder = replaceDissolvedWorkspaceTabOrder( nextTabOrder, layoutResult.dissolvedWorkspaceId, [layoutResult.lastRemainingSessionId], ); } const activeTabAfterClose = resolveActiveTabAfterCloseSession({ currentActiveTabId: nextActiveTabId, closedSessionId: sessionId, workspaceId, layoutResult, remainingSessions: nextSessions, preferredTabId, }); if (activeTabAfterClose) { nextActiveTabId = activeTabAfterClose; resolvedActiveTabId = activeTabAfterClose; } } return { sessions: nextSessions, workspaces: nextWorkspaces, tabOrder: nextTabOrder, activeTabId: resolvedActiveTabId, }; } export function detachSessionFromWorkspaceState({ sessions, workspaces, sessionId, }: DetachSessionFromWorkspaceStateOptions): DetachSessionFromWorkspaceStateResult { const session = sessions.find((candidate) => candidate.id === sessionId); if (!session?.workspaceId) { return { changed: false, sessions, workspaces }; } const workspaceId = session.workspaceId; const targetWorkspace = workspaces.find((workspace) => workspace.id === workspaceId); if (!targetWorkspace) { return { changed: false, sessions, workspaces }; } const prunedRoot = pruneWorkspaceNode(targetWorkspace.root, sessionId); let nextSessions = sessions.map((candidate) => ( candidate.id === sessionId ? { ...candidate, workspaceId: undefined } : candidate )); if (!prunedRoot) { return { changed: true, sessions: nextSessions, workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId), activeTabId: sessionId, dissolvedWorkspaceId: workspaceId, replacementTabIds: [sessionId], }; } const remainingSessionIds = collectSessionIds(prunedRoot); if (remainingSessionIds.length === 1) { nextSessions = nextSessions.map((candidate) => ( candidate.id === remainingSessionIds[0] ? { ...candidate, workspaceId: undefined } : candidate )); return { changed: true, sessions: nextSessions, workspaces: workspaces.filter((workspace) => workspace.id !== workspaceId), activeTabId: sessionId, dissolvedWorkspaceId: workspaceId, replacementTabIds: [sessionId, ...remainingSessionIds], }; } const nextFocusedSessionId = remainingSessionIds.includes(targetWorkspace.focusedSessionId) ? targetWorkspace.focusedSessionId : remainingSessionIds[0]; const nextFocusSessionOrder = (targetWorkspace.focusSessionOrder ?? []) .filter((candidateId, index, list) => ( candidateId !== sessionId && remainingSessionIds.includes(candidateId) && list.indexOf(candidateId) === index )); for (const remainingSessionId of remainingSessionIds) { if (!nextFocusSessionOrder.includes(remainingSessionId)) { nextFocusSessionOrder.push(remainingSessionId); } } return { changed: true, sessions: nextSessions, workspaces: workspaces.map((workspace) => ( workspace.id === workspaceId ? { ...workspace, root: prunedRoot, focusedSessionId: nextFocusedSessionId, focusSessionOrder: nextFocusSessionOrder, } : workspace )), activeTabId: sessionId, }; }