336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
|
|
import assert from "node:assert/strict";
|
||
|
|
import test from "node:test";
|
||
|
|
import type { TerminalSession, Workspace } from "../../domain/models";
|
||
|
|
import {
|
||
|
|
applyCloseSessionToSessions,
|
||
|
|
closeSessionsState,
|
||
|
|
closeSessionWorkspaceLayoutState,
|
||
|
|
detachSessionFromWorkspaceState,
|
||
|
|
replaceDissolvedWorkspaceTabOrder,
|
||
|
|
resolveActiveTabAfterCloseSession,
|
||
|
|
} from "./sessionWorkspaceDetach";
|
||
|
|
|
||
|
|
const session = (id: string, workspaceId = "ws-1"): TerminalSession => ({
|
||
|
|
id,
|
||
|
|
hostId: id,
|
||
|
|
hostLabel: id,
|
||
|
|
status: "connected",
|
||
|
|
workspaceId,
|
||
|
|
});
|
||
|
|
|
||
|
|
const workspace = (sessionIds: string[]): Workspace => ({
|
||
|
|
id: "ws-1",
|
||
|
|
title: "Workspace",
|
||
|
|
focusedSessionId: sessionIds[0],
|
||
|
|
focusSessionOrder: sessionIds,
|
||
|
|
root: sessionIds.length === 1
|
||
|
|
? { id: "pane-1", type: "pane", sessionId: sessionIds[0] }
|
||
|
|
: {
|
||
|
|
id: "split-1",
|
||
|
|
type: "split",
|
||
|
|
direction: "vertical",
|
||
|
|
children: sessionIds.map((sessionId, index) => ({
|
||
|
|
id: `pane-${index + 1}`,
|
||
|
|
type: "pane" as const,
|
||
|
|
sessionId,
|
||
|
|
})),
|
||
|
|
sizes: sessionIds.map(() => 1),
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
test("detach dissolves the original workspace when one session remains", () => {
|
||
|
|
const result = detachSessionFromWorkspaceState({
|
||
|
|
sessions: [session("s1"), session("s2")],
|
||
|
|
workspaces: [workspace(["s1", "s2"])],
|
||
|
|
sessionId: "s1",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(result.changed, true);
|
||
|
|
assert.equal(result.activeTabId, "s1");
|
||
|
|
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||
|
|
["s1", undefined],
|
||
|
|
["s2", undefined],
|
||
|
|
]);
|
||
|
|
assert.equal(result.workspaces.length, 0);
|
||
|
|
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||
|
|
assert.deepEqual(result.replacementTabIds, ["s1", "s2"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("detach preserves the other sessions in a multi-pane workspace", () => {
|
||
|
|
const result = detachSessionFromWorkspaceState({
|
||
|
|
sessions: [session("s1"), session("s2"), session("s3")],
|
||
|
|
workspaces: [workspace(["s1", "s2", "s3"])],
|
||
|
|
sessionId: "s2",
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(result.changed, true);
|
||
|
|
assert.deepEqual(result.sessions.map((s) => [s.id, s.workspaceId]), [
|
||
|
|
["s1", "ws-1"],
|
||
|
|
["s2", undefined],
|
||
|
|
["s3", "ws-1"],
|
||
|
|
]);
|
||
|
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s3"]);
|
||
|
|
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||
|
|
assert.deepEqual(
|
||
|
|
result.workspaces[0].root.type === "split"
|
||
|
|
? result.workspaces[0].root.children.map((child) => child.type === "pane" ? child.sessionId : null)
|
||
|
|
: [],
|
||
|
|
["s1", "s3"],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("dissolved workspace replacement preserves its tab position", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||
|
|
["log-1", "s1", "s2", "session-3"],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("dissolved workspace replacement removes duplicate replacement ids", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
replaceDissolvedWorkspaceTabOrder(["s1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]),
|
||
|
|
["s1", "s2", "session-3"],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("dissolved workspace replacement is idempotent", () => {
|
||
|
|
const once = replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s1", "s2"]);
|
||
|
|
|
||
|
|
assert.deepEqual(
|
||
|
|
replaceDissolvedWorkspaceTabOrder(once, "ws-1", ["s1", "s2"]),
|
||
|
|
once,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("single remaining session preserves dissolved workspace tab position", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
replaceDissolvedWorkspaceTabOrder(["log-1", "ws-1", "session-3"], "ws-1", ["s2"]),
|
||
|
|
["log-1", "s2", "session-3"],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing a workspace session dissolves the workspace when one terminal remains", () => {
|
||
|
|
const result = closeSessionWorkspaceLayoutState([workspace(["s1", "s2"])], "ws-1", "s1");
|
||
|
|
|
||
|
|
assert.equal(result.dissolvedWorkspaceId, "ws-1");
|
||
|
|
assert.equal(result.lastRemainingSessionId, "s2");
|
||
|
|
assert.deepEqual(result.workspaces, []);
|
||
|
|
assert.deepEqual(
|
||
|
|
replaceDissolvedWorkspaceTabOrder(
|
||
|
|
["log-1", result.dissolvedWorkspaceId!, "session-3"],
|
||
|
|
result.dissolvedWorkspaceId,
|
||
|
|
result.lastRemainingSessionId ? [result.lastRemainingSessionId] : undefined,
|
||
|
|
),
|
||
|
|
["log-1", "s2", "session-3"],
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing the focused session hands focus to a remaining session (#3046)", () => {
|
||
|
|
const result = closeSessionWorkspaceLayoutState(
|
||
|
|
[workspace(["s1", "s2", "s3", "s4"])],
|
||
|
|
"ws-1",
|
||
|
|
"s1",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(result.workspaces.length, 1);
|
||
|
|
assert.equal(result.workspaces[0].focusedSessionId, "s2");
|
||
|
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s2", "s3", "s4"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing a non-focused session keeps the focused session", () => {
|
||
|
|
const result = closeSessionWorkspaceLayoutState(
|
||
|
|
[workspace(["s1", "s2", "s3", "s4"])],
|
||
|
|
"ws-1",
|
||
|
|
"s3",
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.equal(result.workspaces.length, 1);
|
||
|
|
assert.equal(result.workspaces[0].focusedSessionId, "s1");
|
||
|
|
assert.deepEqual(result.workspaces[0].focusSessionOrder, ["s1", "s2", "s4"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing one split pane keeps the other terminal as an orphan tab", () => {
|
||
|
|
const layoutResult = closeSessionWorkspaceLayoutState(
|
||
|
|
[workspace(["s1", "s2"])],
|
||
|
|
"ws-1",
|
||
|
|
"s1",
|
||
|
|
);
|
||
|
|
const nextSessions = applyCloseSessionToSessions(
|
||
|
|
[session("s1"), session("s2")],
|
||
|
|
"s1",
|
||
|
|
layoutResult,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(nextSessions.map((s) => [s.id, s.workspaceId]), [
|
||
|
|
["s2", undefined],
|
||
|
|
]);
|
||
|
|
assert.equal(
|
||
|
|
resolveActiveTabAfterCloseSession({
|
||
|
|
currentActiveTabId: "ws-1",
|
||
|
|
closedSessionId: "s1",
|
||
|
|
workspaceId: "ws-1",
|
||
|
|
layoutResult,
|
||
|
|
remainingSessions: nextSessions,
|
||
|
|
preferredTabId: "s-outside",
|
||
|
|
}),
|
||
|
|
"s2",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing the last workspace session does not invent a surviving terminal", () => {
|
||
|
|
const layoutResult = closeSessionWorkspaceLayoutState(
|
||
|
|
[workspace(["s1"])],
|
||
|
|
"ws-1",
|
||
|
|
"s1",
|
||
|
|
);
|
||
|
|
const nextSessions = applyCloseSessionToSessions(
|
||
|
|
[session("s1")],
|
||
|
|
"s1",
|
||
|
|
layoutResult,
|
||
|
|
);
|
||
|
|
|
||
|
|
assert.deepEqual(nextSessions, []);
|
||
|
|
assert.equal(layoutResult.removedWorkspaceId, "ws-1");
|
||
|
|
assert.equal(layoutResult.lastRemainingSessionId, undefined);
|
||
|
|
assert.equal(
|
||
|
|
resolveActiveTabAfterCloseSession({
|
||
|
|
currentActiveTabId: "ws-1",
|
||
|
|
closedSessionId: "s1",
|
||
|
|
workspaceId: "ws-1",
|
||
|
|
layoutResult,
|
||
|
|
remainingSessions: nextSessions,
|
||
|
|
}),
|
||
|
|
"vault",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing the last visible solo tab does not fall back to a hidden MCP session", () => {
|
||
|
|
const visibleSession: TerminalSession = { ...session("s1", undefined) };
|
||
|
|
const hiddenSession: TerminalSession = { ...session("s2", undefined), hiddenFromTabs: true };
|
||
|
|
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s1");
|
||
|
|
const remainingSessions = [hiddenSession];
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
resolveActiveTabAfterCloseSession({
|
||
|
|
currentActiveTabId: visibleSession.id,
|
||
|
|
closedSessionId: "s1",
|
||
|
|
workspaceId: undefined,
|
||
|
|
layoutResult,
|
||
|
|
remainingSessions,
|
||
|
|
}),
|
||
|
|
"vault",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing a temporary solo tab restores the previously focused tab", () => {
|
||
|
|
const standalone = (id: string, status: TerminalSession["status"] = "connected"): TerminalSession => ({
|
||
|
|
...session(id),
|
||
|
|
workspaceId: undefined,
|
||
|
|
status,
|
||
|
|
});
|
||
|
|
const remainingSessions = [
|
||
|
|
standalone("s1", "disconnected"),
|
||
|
|
standalone("s2"),
|
||
|
|
standalone("s3"),
|
||
|
|
standalone("s4"),
|
||
|
|
standalone("s5", "disconnected"),
|
||
|
|
standalone("s6"),
|
||
|
|
];
|
||
|
|
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
resolveActiveTabAfterCloseSession({
|
||
|
|
currentActiveTabId: "s7",
|
||
|
|
closedSessionId: "s7",
|
||
|
|
workspaceId: undefined,
|
||
|
|
layoutResult,
|
||
|
|
remainingSessions,
|
||
|
|
preferredTabId: "s5",
|
||
|
|
}),
|
||
|
|
"s5",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing a temporary solo tab ignores a preferred tab that no longer exists", () => {
|
||
|
|
const standalone = (id: string): TerminalSession => ({
|
||
|
|
...session(id),
|
||
|
|
workspaceId: undefined,
|
||
|
|
});
|
||
|
|
const remainingSessions = [standalone("s1"), standalone("s2"), standalone("s6")];
|
||
|
|
const layoutResult = closeSessionWorkspaceLayoutState([], undefined, "s7");
|
||
|
|
|
||
|
|
assert.equal(
|
||
|
|
resolveActiveTabAfterCloseSession({
|
||
|
|
currentActiveTabId: "s7",
|
||
|
|
closedSessionId: "s7",
|
||
|
|
workspaceId: undefined,
|
||
|
|
layoutResult,
|
||
|
|
remainingSessions,
|
||
|
|
preferredTabId: "s5",
|
||
|
|
}),
|
||
|
|
"s6",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closeSessionsState restores the previous tab instead of the newest remaining tab", () => {
|
||
|
|
const standaloneSession = (id: string): TerminalSession => ({
|
||
|
|
...session(id),
|
||
|
|
workspaceId: undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = closeSessionsState({
|
||
|
|
sessions: [
|
||
|
|
standaloneSession("s1"),
|
||
|
|
standaloneSession("s2"),
|
||
|
|
standaloneSession("s5"),
|
||
|
|
standaloneSession("s6"),
|
||
|
|
standaloneSession("s7"),
|
||
|
|
],
|
||
|
|
workspaces: [],
|
||
|
|
sessionIds: ["s7"],
|
||
|
|
currentActiveTabId: "s7",
|
||
|
|
preferredTabId: "s5",
|
||
|
|
tabOrder: ["s1", "s2", "s5", "s6", "s7"],
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.equal(result.activeTabId, "s5");
|
||
|
|
assert.deepEqual(result.sessions.map((candidate) => candidate.id), ["s1", "s2", "s5", "s6"]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing several standalone sessions removes the whole batch", () => {
|
||
|
|
const standaloneSession = (id: string): TerminalSession => ({
|
||
|
|
...session(id),
|
||
|
|
workspaceId: undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = closeSessionsState({
|
||
|
|
sessions: [standaloneSession("s1"), standaloneSession("s2"), standaloneSession("s3")],
|
||
|
|
workspaces: [],
|
||
|
|
sessionIds: ["s1", "s2", "s3"],
|
||
|
|
currentActiveTabId: "s1",
|
||
|
|
tabOrder: ["s1", "s2", "s3"],
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(result.sessions, []);
|
||
|
|
assert.deepEqual(result.workspaces, []);
|
||
|
|
assert.equal(result.activeTabId, "vault");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("closing a subset of standalone sessions keeps tabs outside the batch", () => {
|
||
|
|
const standaloneSession = (id: string): TerminalSession => ({
|
||
|
|
...session(id),
|
||
|
|
workspaceId: undefined,
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = closeSessionsState({
|
||
|
|
sessions: [standaloneSession("s1"), standaloneSession("s2"), standaloneSession("s3")],
|
||
|
|
workspaces: [],
|
||
|
|
sessionIds: ["s2", "s3"],
|
||
|
|
currentActiveTabId: "s1",
|
||
|
|
tabOrder: ["s1", "s2", "s3"],
|
||
|
|
});
|
||
|
|
|
||
|
|
assert.deepEqual(result.sessions.map((candidate) => candidate.id), ["s1"]);
|
||
|
|
assert.equal(result.activeTabId, undefined);
|
||
|
|
});
|