[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
365
application/state/sessionWorkspaceDetach.ts
Normal file
365
application/state/sessionWorkspaceDetach.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user