Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
366 lines
11 KiB
TypeScript
366 lines
11 KiB
TypeScript
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<CloseSessionWorkspaceLayoutResult, "lastRemainingSessionId">,
|
|
): 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,
|
|
};
|
|
}
|