mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-04 23:44:41 +00:00
* fix(app): stop workspace tab reconciliation loop Duplicate startup tabs could move workspace focus into Explorer. Once hydration removed the stale agent, empty-workspace reconciliation kept seeding drafts into Explorer and retriggering itself. Preserve ordinary-pane focus at the reconciliation boundary. * fix(app): normalize workspace focus before reconciliation Persisted Explorer focus must not steer newly reconciled tabs into the sidebar before the output invariant is applied.
4114 lines
141 KiB
TypeScript
4114 lines
141 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("@react-native-async-storage/async-storage", () => {
|
|
const storage = new Map<string, string>();
|
|
return {
|
|
default: {
|
|
getItem: vi.fn(async (key: string) => storage.get(key) ?? null),
|
|
setItem: vi.fn(async (key: string, value: string) => {
|
|
storage.set(key, value);
|
|
}),
|
|
removeItem: vi.fn(async (key: string) => {
|
|
storage.delete(key);
|
|
}),
|
|
},
|
|
};
|
|
});
|
|
|
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
|
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
|
|
import { defaultChangesState, type ChangesState } from "@/panels/changes/state";
|
|
import { defaultFileState, type FileState } from "@/panels/file/state";
|
|
import {
|
|
canDismissPaneInLayout,
|
|
collectAllPanes,
|
|
collectAllTabs,
|
|
createWorkspaceLayoutStore,
|
|
createDefaultLayout,
|
|
createWorkspaceLayoutWithExplorerSidebar,
|
|
findPaneById,
|
|
findPaneContainingTab,
|
|
FOCUSED_PANE_PLACEMENT,
|
|
getFocusedBrowserId,
|
|
getTreeDepth,
|
|
insertSplit,
|
|
normalizeLayout,
|
|
removePaneFromTree,
|
|
removeTabFromTree,
|
|
stripEphemeralTabsFromLayout,
|
|
type SplitNode,
|
|
type SplitPane,
|
|
} from "@/stores/workspace-layout-store";
|
|
|
|
const SERVER_ID = "server-1";
|
|
const WORKSPACE_ID = "ws-main";
|
|
|
|
function createDeterministicWorkspaceLayoutIds() {
|
|
let values: string[] = [];
|
|
let fallbackIndex = 0;
|
|
|
|
function nextValue(): string {
|
|
const value = values.shift();
|
|
if (value) {
|
|
return value;
|
|
}
|
|
fallbackIndex += 1;
|
|
return `generated-${fallbackIndex}`;
|
|
}
|
|
|
|
return {
|
|
useValues: (nextValues: string[]) => {
|
|
values = nextValues.slice();
|
|
fallbackIndex = 0;
|
|
},
|
|
reset: () => {
|
|
values = [];
|
|
fallbackIndex = 0;
|
|
},
|
|
createNodeId: (prefix: "pane" | "group") => `${prefix}_${nextValue()}`,
|
|
createFocusRestorationToken: () => `workspace-focus-${nextValue()}`,
|
|
};
|
|
}
|
|
|
|
const workspaceLayoutIds = createDeterministicWorkspaceLayoutIds();
|
|
const workspaceLayoutStore = createWorkspaceLayoutStore(workspaceLayoutIds);
|
|
|
|
function useWorkspaceLayoutIds(...values: string[]) {
|
|
workspaceLayoutIds.useValues(values);
|
|
}
|
|
|
|
function createTab(
|
|
tabId: string,
|
|
target?: WorkspaceTab["target"],
|
|
state?: WorkspaceTab["state"],
|
|
): WorkspaceTab {
|
|
return {
|
|
tabId,
|
|
target: target ?? { kind: "draft", draftId: tabId },
|
|
createdAt: 1,
|
|
...(state === undefined ? {} : { state }),
|
|
};
|
|
}
|
|
|
|
function createPane(input: {
|
|
id: string;
|
|
tabIds: string[];
|
|
focusedTabId?: string | null;
|
|
hidden?: boolean;
|
|
targetsByTabId?: Record<string, WorkspaceTab["target"]>;
|
|
stateByTabId?: Record<string, WorkspaceTab["state"]>;
|
|
}): SplitNode {
|
|
const tabs = input.tabIds.map((tabId) =>
|
|
createTab(tabId, input.targetsByTabId?.[tabId], input.stateByTabId?.[tabId]),
|
|
);
|
|
return {
|
|
kind: "pane",
|
|
pane: {
|
|
id: input.id,
|
|
tabIds: input.tabIds,
|
|
focusedTabId: input.focusedTabId ?? input.tabIds[input.tabIds.length - 1] ?? null,
|
|
...(input.hidden === true ? { hidden: true } : {}),
|
|
tabs,
|
|
} as SplitPane,
|
|
};
|
|
}
|
|
|
|
function createWorkspaceKey(): string {
|
|
const key = buildWorkspaceTabPersistenceKey({
|
|
serverId: SERVER_ID,
|
|
workspaceId: WORKSPACE_ID,
|
|
});
|
|
expect(key).toBeTruthy();
|
|
return key as string;
|
|
}
|
|
|
|
function collectTabIds(root: SplitNode): string[] {
|
|
return collectAllTabs(root).map((tab) => tab.tabId);
|
|
}
|
|
|
|
function collectContentTabs(root: SplitNode): WorkspaceTab[] {
|
|
return contentTabs(collectAllTabs(root));
|
|
}
|
|
|
|
function contentTabs(tabs: WorkspaceTab[]): WorkspaceTab[] {
|
|
return tabs.filter(
|
|
(tab) =>
|
|
tab.target.kind !== "new_tab" &&
|
|
tab.target.kind !== "files" &&
|
|
tab.target.kind !== "changes_tree",
|
|
);
|
|
}
|
|
|
|
function expectGroup(node: SplitNode): Extract<SplitNode, { kind: "group" }> {
|
|
expect(node.kind).toBe("group");
|
|
return node as Extract<SplitNode, { kind: "group" }>;
|
|
}
|
|
|
|
describe("workspace-layout-store helpers", () => {
|
|
it("seeds Files and Changes in the default Explorer sidebar", () => {
|
|
const layout = createWorkspaceLayoutWithExplorerSidebar();
|
|
const tabs = collectAllTabs(layout.root);
|
|
|
|
expect(tabs.map((tab) => tab.target)).toEqual([
|
|
{ kind: "new_tab" },
|
|
{ kind: "files" },
|
|
{ kind: "changes_tree" },
|
|
]);
|
|
expect(findPaneById(layout.root, "explorer")?.focusedTabId).toBe(
|
|
tabs.find((tab) => tab.target.kind === "changes_tree")?.tabId,
|
|
);
|
|
});
|
|
|
|
it("discards persisted New tabs and restores each empty pane with a fresh identity", async () => {
|
|
const persisted = createWorkspaceLayoutWithExplorerSidebar();
|
|
const persistedIds = new Set(collectAllTabs(persisted.root).map((tab) => tab.tabId));
|
|
await AsyncStorage.setItem(
|
|
"workspace-layout-state",
|
|
JSON.stringify({
|
|
state: {
|
|
layoutByWorkspace: { workspace: persisted },
|
|
splitSizesByWorkspace: {},
|
|
explorerPaneIdByWorkspace: { workspace: "explorer" },
|
|
},
|
|
version: 1,
|
|
}),
|
|
);
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await restored.persist.rehydrate();
|
|
|
|
const restoredTabs = collectAllTabs(restored.getState().layoutByWorkspace.workspace.root);
|
|
expect(restoredTabs.map((tab) => tab.target)).toEqual([
|
|
{ kind: "new_tab" },
|
|
{ kind: "files" },
|
|
{ kind: "changes_tree" },
|
|
]);
|
|
const restoredNewTab = restoredTabs.find((tab) => tab.target.kind === "new_tab");
|
|
expect(restoredNewTab).toBeTruthy();
|
|
expect(persistedIds.has(restoredNewTab?.tabId ?? "")).toBe(false);
|
|
});
|
|
|
|
it("finds panes and tabs across nested groups", () => {
|
|
const root: SplitNode = {
|
|
kind: "group",
|
|
group: {
|
|
id: "group-root",
|
|
direction: "horizontal",
|
|
sizes: [0.4, 0.6],
|
|
children: [
|
|
createPane({ id: "left", tabIds: ["tab-a", "tab-b"], focusedTabId: "tab-a" }),
|
|
{
|
|
kind: "group",
|
|
group: {
|
|
id: "group-right",
|
|
direction: "vertical",
|
|
sizes: [0.5, 0.5],
|
|
children: [
|
|
createPane({ id: "top-right", tabIds: ["tab-c"] }),
|
|
createPane({ id: "bottom-right", tabIds: ["tab-d"] }),
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(findPaneById(root, "top-right")?.tabIds).toEqual(["tab-c"]);
|
|
expect(findPaneContainingTab(root, "tab-b")?.id).toBe("left");
|
|
expect(getTreeDepth(root)).toBe(3);
|
|
expect(collectAllPanes(root).map((pane) => pane.id)).toEqual([
|
|
"left",
|
|
"top-right",
|
|
"bottom-right",
|
|
]);
|
|
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual([
|
|
"tab-a",
|
|
"tab-b",
|
|
"tab-c",
|
|
"tab-d",
|
|
]);
|
|
});
|
|
|
|
it("derives the focused browser id from the focused pane active tab", () => {
|
|
const root: SplitNode = {
|
|
kind: "group",
|
|
group: {
|
|
id: "group-root",
|
|
direction: "horizontal",
|
|
sizes: [0.5, 0.5],
|
|
children: [
|
|
createPane({
|
|
id: "left",
|
|
tabIds: ["agent-a", "browser-a"],
|
|
focusedTabId: "browser-a",
|
|
targetsByTabId: {
|
|
"agent-a": { kind: "agent", agentId: "agent-a" },
|
|
"browser-a": { kind: "browser", browserId: "browser-a-id" },
|
|
},
|
|
}),
|
|
createPane({
|
|
id: "right",
|
|
tabIds: ["browser-b"],
|
|
focusedTabId: "browser-b",
|
|
targetsByTabId: {
|
|
"browser-b": { kind: "browser", browserId: "browser-b-id" },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
};
|
|
|
|
expect(getFocusedBrowserId({ root, focusedPaneId: "left" })).toBe("browser-a-id");
|
|
expect(getFocusedBrowserId({ root, focusedPaneId: "right" })).toBe("browser-b-id");
|
|
});
|
|
|
|
it("returns null when the focused pane active tab is not a browser", () => {
|
|
const root = createPane({
|
|
id: "main",
|
|
tabIds: ["browser-a", "agent-a"],
|
|
focusedTabId: "agent-a",
|
|
targetsByTabId: {
|
|
"browser-a": { kind: "browser", browserId: "browser-a-id" },
|
|
"agent-a": { kind: "agent", agentId: "agent-a" },
|
|
},
|
|
});
|
|
|
|
expect(getFocusedBrowserId({ root, focusedPaneId: "main" })).toBeNull();
|
|
});
|
|
|
|
it("keeps tabs in hidden panes while excluding those panes from focus helpers", () => {
|
|
const root = createPane({ id: "hidden", tabIds: ["tab-a"], hidden: true });
|
|
|
|
expect(collectAllTabs(root).map((tab) => tab.tabId)).toEqual(["tab-a"]);
|
|
expect(collectAllPanes(root)).toEqual([]);
|
|
expect(getFocusedBrowserId({ root, focusedPaneId: "hidden" })).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("workspace-layout-store version 2 migration", () => {
|
|
const workspaceKey = "workspace";
|
|
const preservedFileState = { treeVisible: true, treeWidth: 333 };
|
|
|
|
function createVersionOneLayout(includeExplorerDefaults: boolean) {
|
|
const explorerTabIds = [
|
|
...(includeExplorerDefaults ? ["files", "changes_tree"] : []),
|
|
"legacy-file",
|
|
"legacy-agent",
|
|
];
|
|
return {
|
|
root: {
|
|
kind: "group" as const,
|
|
group: {
|
|
id: "workspace-root",
|
|
direction: "horizontal" as const,
|
|
sizes: [0.61, 0.39],
|
|
children: [
|
|
createPane({
|
|
id: "main",
|
|
tabIds: ["main-draft"],
|
|
targetsByTabId: {
|
|
"main-draft": { kind: "draft", draftId: "main-draft" },
|
|
},
|
|
}),
|
|
createPane({
|
|
id: "explorer",
|
|
tabIds: explorerTabIds,
|
|
focusedTabId: "legacy-file",
|
|
hidden: true,
|
|
targetsByTabId: {
|
|
files: { kind: "files" },
|
|
changes_tree: { kind: "changes_tree" },
|
|
"legacy-file": { kind: "file", path: "/repo/preserved.ts" },
|
|
"legacy-agent": { kind: "agent", agentId: "preserved-agent" },
|
|
},
|
|
stateByTabId: { "legacy-file": preservedFileState },
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
focusedPaneId: "main",
|
|
};
|
|
}
|
|
|
|
async function persistVersionOneLayout(
|
|
source: "v0.5" | "v0.6",
|
|
options: { withSidePane?: boolean } = {},
|
|
) {
|
|
const layout = createVersionOneLayout(source === "v0.6");
|
|
const persistedLayout = options.withSidePane
|
|
? {
|
|
...layout,
|
|
root: {
|
|
kind: "group" as const,
|
|
group: {
|
|
id: "side-root",
|
|
direction: "horizontal" as const,
|
|
sizes: [0.7, 0.3],
|
|
children: [
|
|
layout.root,
|
|
createPane({
|
|
id: "existing-side",
|
|
tabIds: ["existing-side-file"],
|
|
targetsByTabId: {
|
|
"existing-side-file": { kind: "file", path: "/repo/already-beside.ts" },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
}
|
|
: layout;
|
|
await AsyncStorage.removeItem("workspace-layout-state");
|
|
await AsyncStorage.setItem(
|
|
"workspace-layout-state",
|
|
JSON.stringify({
|
|
state: {
|
|
layoutByWorkspace: {
|
|
[workspaceKey]: persistedLayout,
|
|
},
|
|
splitSizesByWorkspace: {},
|
|
explorerPaneIdByWorkspace: { [workspaceKey]: "explorer" },
|
|
...(source === "v0.6"
|
|
? {
|
|
explorerSidebarWidthByWorkspace: {},
|
|
sidePaneIdByWorkspace: options.withSidePane
|
|
? { [workspaceKey]: "existing-side" }
|
|
: {},
|
|
}
|
|
: {}),
|
|
},
|
|
version: 1,
|
|
}),
|
|
);
|
|
}
|
|
|
|
function expectMigratedLayout(
|
|
store: {
|
|
getState: () => {
|
|
layoutByWorkspace: Record<string, { root: SplitNode }>;
|
|
explorerSidebarPaneIdByWorkspace: Record<string, string | null>;
|
|
sidePaneIdByWorkspace: Record<string, string | null>;
|
|
};
|
|
},
|
|
expected: {
|
|
sidePaneId?: string;
|
|
sideTabIds?: string[];
|
|
} = {},
|
|
) {
|
|
const state = store.getState();
|
|
const layout = state.layoutByWorkspace[workspaceKey];
|
|
const explorerPaneId = state.explorerSidebarPaneIdByWorkspace[workspaceKey];
|
|
const sidePaneId = state.sidePaneIdByWorkspace[workspaceKey];
|
|
expect(explorerPaneId).toBe("explorer");
|
|
expect(sidePaneId).toBeTruthy();
|
|
expect(sidePaneId).not.toBe(explorerPaneId);
|
|
|
|
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
|
|
const explorerPane = findPaneById(layout.root, explorerPaneId);
|
|
expect(explorerPane?.tabIds.map((tabId) => tabsById.get(tabId)?.target)).toEqual([
|
|
{ kind: "files" },
|
|
{ kind: "changes_tree" },
|
|
]);
|
|
|
|
const sidePane = findPaneById(layout.root, sidePaneId);
|
|
expect(sidePane?.hidden).not.toBe(true);
|
|
expect(sidePane?.tabIds).toEqual(expected.sideTabIds ?? ["legacy-file", "legacy-agent"]);
|
|
expect(tabsById.get("legacy-file")).toMatchObject({
|
|
target: { kind: "file", path: "/repo/preserved.ts" },
|
|
state: preservedFileState,
|
|
});
|
|
expect(sidePaneId).toBe(expected.sidePaneId ?? sidePaneId);
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main", sidePaneId]);
|
|
}
|
|
|
|
it.each(["v0.5", "v0.6"] as const)(
|
|
"replaces Explorer once and preserves persisted side content when upgrading from %s",
|
|
async (source) => {
|
|
await persistVersionOneLayout(source);
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
|
|
await restored.persist.rehydrate();
|
|
|
|
expectMigratedLayout(restored);
|
|
await vi.waitFor(async () => {
|
|
const persisted = JSON.parse(
|
|
(await AsyncStorage.getItem("workspace-layout-state")) ?? "{}",
|
|
);
|
|
expect(persisted.version).toBe(2);
|
|
});
|
|
},
|
|
);
|
|
|
|
it("reuses an ordinary side pane already created by v0.6", async () => {
|
|
await persistVersionOneLayout("v0.6", { withSidePane: true });
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
|
|
await restored.persist.rehydrate();
|
|
|
|
expectMigratedLayout(restored, {
|
|
sidePaneId: "existing-side",
|
|
sideTabIds: ["existing-side-file", "legacy-file", "legacy-agent"],
|
|
});
|
|
});
|
|
|
|
it("writes version 2 with the persisted Explorer key accepted by released v0.6", async () => {
|
|
await persistVersionOneLayout("v0.6");
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
|
|
await restored.persist.rehydrate();
|
|
|
|
await vi.waitFor(async () => {
|
|
const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}");
|
|
expect(persisted.version).toBe(2);
|
|
expect(Object.keys(persisted.state).sort()).toEqual([
|
|
"explorerPaneIdByWorkspace",
|
|
"explorerSidebarWidthByWorkspace",
|
|
"layoutByWorkspace",
|
|
"sidePaneIdByWorkspace",
|
|
"splitSizesByWorkspace",
|
|
]);
|
|
expect(persisted.state.explorerPaneIdByWorkspace).toEqual({
|
|
[workspaceKey]: "explorer",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("does not replace Explorer again when the migrated version 2 layout reloads", async () => {
|
|
await persistVersionOneLayout("v0.6");
|
|
const migrated = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await migrated.persist.rehydrate();
|
|
expectMigratedLayout(migrated);
|
|
const explorerPaneId = migrated.getState().explorerSidebarPaneIdByWorkspace[workspaceKey];
|
|
if (!explorerPaneId) {
|
|
throw new Error("Migrated Explorer pane is missing");
|
|
}
|
|
const addedTabId = migrated.getState().openTab({
|
|
workspaceKey,
|
|
target: { kind: "agent", agentId: "added-after-migration" },
|
|
intent: "new",
|
|
placement: { mode: "pane", paneId: explorerPaneId },
|
|
});
|
|
expect(addedTabId).toBeTruthy();
|
|
|
|
await vi.waitFor(async () => {
|
|
const persisted = JSON.parse((await AsyncStorage.getItem("workspace-layout-state")) ?? "{}");
|
|
expect(persisted.version).toBe(2);
|
|
});
|
|
const reloaded = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await reloaded.persist.rehydrate();
|
|
|
|
const reloadedState = reloaded.getState();
|
|
const reloadedLayout = reloadedState.layoutByWorkspace[workspaceKey];
|
|
const reloadedExplorer = findPaneById(
|
|
reloadedLayout.root,
|
|
reloadedState.explorerSidebarPaneIdByWorkspace[workspaceKey],
|
|
);
|
|
expect(reloadedExplorer?.tabIds).toContain(addedTabId);
|
|
expect(findPaneContainingTab(reloadedLayout.root, addedTabId as string)?.id).toBe(
|
|
reloadedExplorer?.id,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("workspace-layout-store tree transforms", () => {
|
|
beforeEach(() => {
|
|
workspaceLayoutIds.reset();
|
|
});
|
|
|
|
it("insertSplit adds root-level same-direction splits as a flat sibling", () => {
|
|
useWorkspaceLayoutIds("11111111-1111-1111-1111-111111111111");
|
|
|
|
const root: SplitNode = {
|
|
kind: "group",
|
|
group: {
|
|
id: "group-root",
|
|
direction: "horizontal",
|
|
sizes: [0.25, 0.75],
|
|
children: [
|
|
createPane({ id: "left", tabIds: ["tab-a"] }),
|
|
createPane({ id: "right", tabIds: ["tab-b", "tab-c"] }),
|
|
],
|
|
},
|
|
};
|
|
|
|
const nextRoot = insertSplit(root, "right", "tab-c", "right", workspaceLayoutIds.createNodeId);
|
|
const nextGroup = expectGroup(nextRoot);
|
|
|
|
expect(nextGroup.group.id).toBe("group-root");
|
|
expect(nextGroup.group.direction).toBe("horizontal");
|
|
expect(nextGroup.group.children).toHaveLength(3);
|
|
expect(nextGroup.group.sizes).toEqual([0.25, 0.375, 0.375]);
|
|
expect(getTreeDepth(nextRoot)).toBe(getTreeDepth(root));
|
|
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual([
|
|
"left",
|
|
"right",
|
|
"pane_11111111-1111-1111-1111-111111111111",
|
|
]);
|
|
expect(findPaneById(nextRoot, "right")?.tabIds).toEqual(["tab-b"]);
|
|
expect(findPaneById(nextRoot, "pane_11111111-1111-1111-1111-111111111111")?.tabIds).toEqual([
|
|
"tab-c",
|
|
]);
|
|
});
|
|
|
|
/**
|
|
* The renderer keys panes by id and reconciles them positionally, so a pane that changes render
|
|
* path is unmounted and rebuilt — the composer, terminal, and scroll state in it are lost. A
|
|
* same-direction split has no reason to move anything: it appends a sibling into the group that
|
|
* is already running in that direction. Perpendicular splits are excluded because nesting is the
|
|
* only way to change axis.
|
|
*/
|
|
describe("same-direction splits never move an existing pane", () => {
|
|
function panePathsById(root: SplitNode, path: string[] = []): Map<string, string> {
|
|
if (root.kind === "pane") {
|
|
return new Map([[root.pane.id, path.join("/")]]);
|
|
}
|
|
const paths = new Map<string, string>();
|
|
for (const child of root.group.children) {
|
|
const childKey = child.kind === "pane" ? child.pane.id : child.group.id;
|
|
for (const [paneId, childPath] of panePathsById(child, [...path, childKey])) {
|
|
paths.set(paneId, childPath);
|
|
}
|
|
}
|
|
return paths;
|
|
}
|
|
|
|
function withPaneTabs(root: SplitNode, paneId: string, tabIds: string[]): SplitNode {
|
|
if (root.kind === "pane") {
|
|
return root.pane.id === paneId ? createPane({ id: paneId, tabIds }) : root;
|
|
}
|
|
return {
|
|
kind: "group",
|
|
group: {
|
|
...root.group,
|
|
children: root.group.children.map((child) => withPaneTabs(child, paneId, tabIds)),
|
|
},
|
|
};
|
|
}
|
|
|
|
function expectExistingPanesUnmoved(before: SplitNode, after: SplitNode) {
|
|
const beforePaths = panePathsById(before);
|
|
const afterPaths = panePathsById(after);
|
|
for (const [paneId, panePath] of beforePaths) {
|
|
expect(afterPaths.get(paneId)).toBe(panePath);
|
|
}
|
|
expect(getTreeDepth(after)).toBe(getTreeDepth(before));
|
|
}
|
|
|
|
for (const position of ["left", "right"] as const) {
|
|
it(`keeps every pane in place when a tab is dragged ${position} from the default layout`, () => {
|
|
workspaceLayoutIds.reset();
|
|
const mainPaneId = collectAllPanes(createWorkspaceLayoutWithExplorerSidebar().root)[0]?.id;
|
|
expect(mainPaneId).toBeTruthy();
|
|
const root = withPaneTabs(
|
|
createWorkspaceLayoutWithExplorerSidebar().root,
|
|
mainPaneId as string,
|
|
["tab-a", "tab-b"],
|
|
);
|
|
|
|
const nextRoot = insertSplit(
|
|
root,
|
|
mainPaneId as string,
|
|
"tab-a",
|
|
position,
|
|
workspaceLayoutIds.createNodeId,
|
|
);
|
|
|
|
expectExistingPanesUnmoved(root, nextRoot);
|
|
});
|
|
}
|
|
|
|
it("keeps every pane in place when splitPaneEmpty opens the first split pane", () => {
|
|
workspaceLayoutIds.reset();
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const before = createWorkspaceLayoutWithExplorerSidebar();
|
|
const mainPaneId = collectAllPanes(before.root)[0]?.id;
|
|
expect(mainPaneId).toBeTruthy();
|
|
|
|
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
|
targetPaneId: mainPaneId as string,
|
|
position: "right",
|
|
});
|
|
|
|
expect(newPaneId).not.toBeNull();
|
|
const after = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expectExistingPanesUnmoved(before.root, after.root);
|
|
});
|
|
});
|
|
|
|
it("removePaneFromTree unwraps single-child groups and renormalizes siblings", () => {
|
|
const root: SplitNode = {
|
|
kind: "group",
|
|
group: {
|
|
id: "group-root",
|
|
direction: "horizontal",
|
|
sizes: [0.2, 0.8],
|
|
children: [
|
|
createPane({ id: "left", tabIds: ["tab-a"] }),
|
|
{
|
|
kind: "group",
|
|
group: {
|
|
id: "group-right",
|
|
direction: "vertical",
|
|
sizes: [0.5, 0.5],
|
|
children: [
|
|
createPane({ id: "top-right", tabIds: ["tab-b"] }),
|
|
createPane({ id: "bottom-right", tabIds: ["tab-c"] }),
|
|
],
|
|
},
|
|
},
|
|
],
|
|
},
|
|
};
|
|
|
|
const nextRoot = removePaneFromTree(root, "top-right");
|
|
const nextGroup = expectGroup(nextRoot);
|
|
|
|
expect(nextGroup.group.sizes).toEqual([0.2, 0.8]);
|
|
expect(collectAllPanes(nextRoot).map((pane) => pane.id)).toEqual(["left", "bottom-right"]);
|
|
expect(nextGroup.group.children[1]).toEqual(
|
|
createPane({ id: "bottom-right", tabIds: ["tab-c"] }),
|
|
);
|
|
});
|
|
|
|
it("removeTabFromTree collapses empty panes but keeps the final root pane", () => {
|
|
const splitRoot: SplitNode = {
|
|
kind: "group",
|
|
group: {
|
|
id: "group-root",
|
|
direction: "horizontal",
|
|
sizes: [0.5, 0.5],
|
|
children: [
|
|
createPane({ id: "left", tabIds: ["tab-a"] }),
|
|
createPane({ id: "right", tabIds: ["tab-b"] }),
|
|
],
|
|
},
|
|
};
|
|
|
|
const collapsed = removeTabFromTree(splitRoot, "tab-a");
|
|
expect(collapsed).toEqual(createPane({ id: "right", tabIds: ["tab-b"] }));
|
|
|
|
const singlePaneRoot = createPane({ id: "main", tabIds: ["tab-a"] });
|
|
const emptied = removeTabFromTree(singlePaneRoot, "tab-a");
|
|
expect(collectAllTabs(emptied)).toHaveLength(1);
|
|
expect(collectAllTabs(emptied)[0]?.target).toEqual({ kind: "new_tab" });
|
|
});
|
|
});
|
|
|
|
describe("workspace-layout-store actions", () => {
|
|
it("creates duplicate Changes instances while reveal keeps the first instance", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const first = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "new",
|
|
});
|
|
const second = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "new",
|
|
});
|
|
|
|
expect(first).not.toBe(second);
|
|
expect(
|
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).filter(
|
|
(tab) => tab.target.kind === "working_diff",
|
|
),
|
|
).toHaveLength(2);
|
|
|
|
expect(
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
}),
|
|
).toBe(first);
|
|
});
|
|
|
|
it("creates duplicate Pull request instances explicitly", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const first = store.openTab({ workspaceKey, target: { kind: "pull_request" }, intent: "new" });
|
|
const second = store.openTab({ workspaceKey, target: { kind: "pull_request" }, intent: "new" });
|
|
expect(first).not.toBe(second);
|
|
expect(
|
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).filter(
|
|
(tab) => tab.target.kind === "pull_request",
|
|
),
|
|
).toHaveLength(2);
|
|
});
|
|
|
|
it("preserves same-kind state, clears cross-kind state, and accepts an explicit override", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const file = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts" },
|
|
intent: "new",
|
|
})!;
|
|
const fileState: FileState = { treeVisible: true, treeWidth: 240 };
|
|
store.setTabState(workspaceKey, file, fileState);
|
|
const sameKind = store.replaceTab(workspaceKey, file, { kind: "file", path: "/repo/b.ts" });
|
|
expect(sameKind).toBe(file);
|
|
expect(
|
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
|
(tab) => tab.tabId === sameKind,
|
|
)?.state,
|
|
).toEqual(fileState);
|
|
const crossKind = store.replaceTab(workspaceKey, sameKind!, { kind: "working_diff" });
|
|
expect(
|
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
|
(tab) => tab.tabId === crossKind,
|
|
)?.state,
|
|
).toBeUndefined();
|
|
const override: ChangesState = { ...defaultChangesState, treeVisible: true };
|
|
const overridden = store.replaceTab(
|
|
workspaceKey,
|
|
crossKind!,
|
|
{ kind: "working_diff" },
|
|
override,
|
|
);
|
|
expect(
|
|
collectAllTabs(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root).find(
|
|
(tab) => tab.tabId === overridden,
|
|
)?.state,
|
|
).toEqual(override);
|
|
});
|
|
|
|
it("keeps the tab instance when a panel receives a same-kind replacement", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const tabId = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "working_diff", focusPath: "src/a.ts", focusRequestId: 1 },
|
|
intent: "new",
|
|
})!;
|
|
const state: ChangesState = { ...defaultChangesState, collapsedFilePaths: ["src/a.ts"] };
|
|
store.setTabState(workspaceKey, tabId, state);
|
|
|
|
const replacementTabId = store.replaceTab(workspaceKey, tabId, {
|
|
kind: "working_diff",
|
|
focusPath: "src/b.ts",
|
|
focusRequestId: 2,
|
|
});
|
|
const replacement = collectAllTabs(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
|
|
).find((tab) => tab.tabId === tabId);
|
|
|
|
expect(replacementTabId).toBe(tabId);
|
|
expect(replacement).toMatchObject({
|
|
tabId,
|
|
target: { kind: "working_diff", focusPath: "src/b.ts", focusRequestId: 2 },
|
|
state,
|
|
});
|
|
});
|
|
|
|
it("keeps every local Changes field independent across explicit instances", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const first = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "new",
|
|
})!;
|
|
const second = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "new",
|
|
})!;
|
|
const changed: ChangesState = {
|
|
treeVisible: true,
|
|
treeWidth: 320,
|
|
collapsedFilePaths: ["a.ts"],
|
|
collapsedFolderPaths: ["src"],
|
|
commitsCollapsed: false,
|
|
};
|
|
store.setTabState(workspaceKey, first, changed);
|
|
const tabs = collectAllTabs(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
|
|
);
|
|
expect(tabs.find((tab) => tab.tabId === first)?.state).toEqual(changed);
|
|
expect(tabs.find((tab) => tab.tabId === second)?.state).toEqual(undefined);
|
|
});
|
|
|
|
it("restores a revealed file's parent after local replacement", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const parent = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "parent" },
|
|
intent: "new",
|
|
})!;
|
|
const file = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts", lineStart: 1 },
|
|
intent: "new",
|
|
})!;
|
|
store.setTabState(workspaceKey, file, {
|
|
treeVisible: true,
|
|
treeWidth: 300,
|
|
});
|
|
expect(
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts", lineStart: 9 },
|
|
intent: "reveal",
|
|
placement: undefined,
|
|
parentTabId: parent,
|
|
}),
|
|
).toBe(file);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === file)).toMatchObject({
|
|
target: { path: "/repo/a.ts", lineStart: 9 },
|
|
state: { ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
|
});
|
|
expect(layout.parentTabIdByTabId?.[file]).toBe(parent);
|
|
|
|
const replacement = store.replaceTab(
|
|
workspaceKey,
|
|
file,
|
|
{ kind: "file", path: "/repo/b.ts" },
|
|
{ ...defaultFileState, treeVisible: true, treeWidth: 300 },
|
|
);
|
|
expect(replacement).toBe(file);
|
|
const replacedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(replacedLayout.parentTabIdByTabId?.[file]).toBe(parent);
|
|
|
|
store.closeTab(workspaceKey, replacement as string);
|
|
expect(
|
|
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main")
|
|
?.focusedTabId,
|
|
).toBe(parent);
|
|
});
|
|
|
|
it("keeps existing child edges attached to a replacement tab", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const replaced = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "pull_request" },
|
|
intent: "new",
|
|
})!;
|
|
const child = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts" },
|
|
intent: "reveal",
|
|
parentTabId: replaced,
|
|
})!;
|
|
const unrelatedParent = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "unrelated" },
|
|
intent: "new",
|
|
})!;
|
|
const unrelatedChild = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/unrelated.ts" },
|
|
intent: "reveal",
|
|
parentTabId: unrelatedParent,
|
|
})!;
|
|
|
|
const replacement = store.replaceTab(
|
|
workspaceKey,
|
|
replaced,
|
|
{ kind: "file", path: "/repo/b.ts" },
|
|
{ ...defaultFileState, treeVisible: true },
|
|
)!;
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(layout.parentTabIdByTabId?.[child]).toBe(replacement);
|
|
expect(layout.parentTabIdByTabId?.[unrelatedChild]).toBe(unrelatedParent);
|
|
expect(Object.entries(layout.parentTabIdByTabId ?? {}).flat()).not.toContain(replaced);
|
|
|
|
store.focusTab(workspaceKey, child);
|
|
store.closeTab(workspaceKey, child);
|
|
expect(
|
|
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main")
|
|
?.focusedTabId,
|
|
).toBe(replacement);
|
|
});
|
|
|
|
it("reveals provider children through the child capability in a compact pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const parent = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "parent" },
|
|
intent: "new",
|
|
})!;
|
|
const target = {
|
|
kind: "provider_subagent" as const,
|
|
parentAgentId: "parent",
|
|
subagentId: "provider-child",
|
|
};
|
|
const compactPanePlacement = { mode: "pane" as const, paneId: "main" };
|
|
|
|
const child = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: target,
|
|
intent: "reveal",
|
|
parentTabId: parent,
|
|
placement: compactPanePlacement,
|
|
})!;
|
|
expect(
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: target,
|
|
intent: "reveal",
|
|
parentTabId: parent,
|
|
placement: compactPanePlacement,
|
|
}),
|
|
).toBe(child);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, child)?.id).toBe("main");
|
|
expect(layout.parentTabIdByTabId?.[child]).toBe(parent);
|
|
|
|
store.closeTab(workspaceKey, child);
|
|
expect(
|
|
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main")
|
|
?.focusedTabId,
|
|
).toBe(parent);
|
|
});
|
|
|
|
it("replaces only the supplied file slot and carries its state", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const preserved = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts" },
|
|
intent: "new",
|
|
});
|
|
const local = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/b.ts" },
|
|
intent: "new",
|
|
});
|
|
|
|
const replacement = store.replaceTab(
|
|
workspaceKey,
|
|
local as string,
|
|
{ kind: "file", path: "/repo/a.ts" },
|
|
{ ...defaultFileState, treeVisible: true, treeWidth: 280 },
|
|
);
|
|
|
|
const tabs = collectAllTabs(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
|
|
);
|
|
expect(replacement).not.toBe(preserved);
|
|
expect(tabs.find((tab) => tab.tabId === preserved)?.target).toEqual({
|
|
kind: "file",
|
|
path: "/repo/a.ts",
|
|
});
|
|
expect(tabs.find((tab) => tab.tabId === replacement)?.state).toEqual({
|
|
...defaultFileState,
|
|
treeVisible: true,
|
|
treeWidth: 280,
|
|
});
|
|
});
|
|
beforeEach(() => {
|
|
workspaceLayoutIds.reset();
|
|
workspaceLayoutStore.setState({
|
|
layoutByWorkspace: {},
|
|
splitSizesByWorkspace: {},
|
|
pinnedAgentIdsByWorkspace: {},
|
|
hiddenAgentIdsByWorkspace: {},
|
|
focusRestorationByWorkspace: {},
|
|
explorerSidebarPaneIdByWorkspace: {},
|
|
});
|
|
});
|
|
|
|
it("replaces a pane's sole New tab when real content opens", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const initialLayout = createWorkspaceLayoutWithExplorerSidebar();
|
|
workspaceLayoutStore.setState({ layoutByWorkspace: { [workspaceKey]: initialLayout } });
|
|
const initialMainTabId = findPaneById(initialLayout.root, "main")?.focusedTabId;
|
|
|
|
const agentTabId = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "background",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId]);
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId);
|
|
expect(collectTabIds(layout.root)).not.toContain(initialMainTabId);
|
|
});
|
|
|
|
it("keeps a New tab's random identity when its launcher selects content", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const initialLayout = createWorkspaceLayoutWithExplorerSidebar();
|
|
workspaceLayoutStore.setState({ layoutByWorkspace: { [workspaceKey]: initialLayout } });
|
|
const newTabId = findPaneById(initialLayout.root, "main")?.focusedTabId as string;
|
|
|
|
const resultTabId = store.replaceTab(workspaceKey, newTabId, { kind: "files" });
|
|
|
|
expect(resultTabId).toBe(newTabId);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([newTabId]);
|
|
expect(collectAllTabs(layout.root).find((tab) => tab.tabId === newTabId)?.target).toEqual({
|
|
kind: "files",
|
|
});
|
|
});
|
|
|
|
it("closes a split through its sole New tab but keeps the final visible split", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
useWorkspaceLayoutIds("new-pane", "new-group");
|
|
const splitPaneId = store.splitPaneEmpty(workspaceKey, {
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
}) as string;
|
|
const splitNewTabId = findPaneById(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
|
|
splitPaneId,
|
|
)?.focusedTabId as string;
|
|
|
|
store.closeTab(workspaceKey, splitNewTabId);
|
|
|
|
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, splitPaneId)).toBeNull();
|
|
store.closeTab(workspaceKey, findPaneById(layout.root, "main")?.focusedTabId as string);
|
|
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const finalNewTabId = findPaneById(layout.root, "main")?.focusedTabId as string;
|
|
const before = layout;
|
|
|
|
store.closeTab(workspaceKey, finalNewTabId);
|
|
|
|
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before);
|
|
});
|
|
|
|
it("migrates legacy layouts to include the hidden registered explorer pane", async () => {
|
|
const legacyLayout = createDefaultLayout();
|
|
await AsyncStorage.setItem(
|
|
"workspace-layout-state",
|
|
JSON.stringify({
|
|
state: { layoutByWorkspace: { legacy: legacyLayout } },
|
|
version: 1,
|
|
}),
|
|
);
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
|
|
await restored.persist.rehydrate();
|
|
|
|
const state = restored.getState();
|
|
const layout = state.layoutByWorkspace.legacy;
|
|
const explorerSidebarPaneId = state.explorerSidebarPaneIdByWorkspace.legacy;
|
|
expect(findPaneById(layout.root, "main")).toBeTruthy();
|
|
expect(explorerSidebarPaneId).toBeTruthy();
|
|
const explorerPane = findPaneById(layout.root, explorerSidebarPaneId);
|
|
expect(explorerPane?.hidden).toBe(true);
|
|
const tabsById = new Map(collectAllTabs(layout.root).map((tab) => [tab.tabId, tab]));
|
|
expect(explorerPane?.tabIds.map((tabId) => tabsById.get(tabId)?.target)).toEqual([
|
|
{ kind: "files" },
|
|
{ kind: "changes_tree" },
|
|
]);
|
|
expect(restored.getState().splitSizesByWorkspace).toEqual({});
|
|
await expect(AsyncStorage.getItem("workspace-layout-state")).resolves.not.toBeNull();
|
|
});
|
|
|
|
it("restores a layout saved before the Side panel rename, PR bookkeeping and all", async () => {
|
|
const savedLayout = createWorkspaceLayoutWithExplorerSidebar();
|
|
await AsyncStorage.setItem(
|
|
"workspace-layout-state",
|
|
JSON.stringify({
|
|
state: {
|
|
layoutByWorkspace: { renamed: savedLayout },
|
|
explorerPaneIdByWorkspace: { renamed: "explorer" },
|
|
acknowledgedPullRequestByWorkspace: { renamed: "url:https://example.test/pulls/1" },
|
|
},
|
|
version: 1,
|
|
}),
|
|
);
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
|
|
await restored.persist.rehydrate();
|
|
|
|
const state = restored.getState();
|
|
expect(state.layoutByWorkspace.renamed).toBeTruthy();
|
|
expect(state.explorerSidebarPaneIdByWorkspace.renamed).toBe("explorer");
|
|
});
|
|
|
|
it("persists first-class pane targets, visibility, and focus", async () => {
|
|
await AsyncStorage.removeItem("workspace-layout-state");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await source.persist.rehydrate();
|
|
|
|
source.getState().openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const focusedAgentTabId = source.getState().openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-2" },
|
|
intent: "reveal",
|
|
});
|
|
const explorerSidebarPaneId = source.getState().showExplorerSidebar(workspaceKey);
|
|
expect(explorerSidebarPaneId).toBeTruthy();
|
|
source
|
|
.getState()
|
|
.openTab({ workspaceKey: workspaceKey, target: { kind: "files" }, intent: "reveal" });
|
|
source.getState().openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "pull_request" },
|
|
intent: "background",
|
|
placement: { mode: "prefer", paneId: explorerSidebarPaneId as string },
|
|
});
|
|
source.getState().hideExplorerSidebar(workspaceKey);
|
|
|
|
await vi.waitFor(async () => {
|
|
expect(await AsyncStorage.getItem("workspace-layout-state")).not.toBeNull();
|
|
});
|
|
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await restored.persist.rehydrate();
|
|
const state = restored.getState();
|
|
const layout = state.layoutByWorkspace[workspaceKey];
|
|
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(focusedAgentTabId);
|
|
expect(findPaneById(layout.root, explorerSidebarPaneId)?.hidden).toBe(true);
|
|
expect(collectAllTabs(layout.root).map((tab) => tab.target.kind)).toEqual([
|
|
"agent",
|
|
"agent",
|
|
"files",
|
|
"changes_tree",
|
|
"pull_request",
|
|
]);
|
|
expect(state.explorerSidebarPaneIdByWorkspace[workspaceKey]).toBe(explorerSidebarPaneId);
|
|
});
|
|
|
|
it("persists and rehydrates independent Changes state through validated storage", async () => {
|
|
await AsyncStorage.removeItem("workspace-layout-state");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const source = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await source.persist.rehydrate();
|
|
const first = source
|
|
.getState()
|
|
.openTab({ workspaceKey: workspaceKey, target: { kind: "working_diff" }, intent: "new" })!;
|
|
const second = source
|
|
.getState()
|
|
.openTab({ workspaceKey: workspaceKey, target: { kind: "working_diff" }, intent: "new" })!;
|
|
const firstState: ChangesState = {
|
|
treeVisible: true,
|
|
treeWidth: 320,
|
|
collapsedFilePaths: ["src/a.ts"],
|
|
collapsedFolderPaths: ["src"],
|
|
commitsCollapsed: false,
|
|
};
|
|
const secondState: ChangesState = {
|
|
treeVisible: false,
|
|
collapsedFilePaths: ["README.md"],
|
|
collapsedFolderPaths: ["docs"],
|
|
commitsCollapsed: true,
|
|
};
|
|
source.getState().setTabState(workspaceKey, first, firstState);
|
|
source.getState().setTabState(workspaceKey, second, secondState);
|
|
|
|
await vi.waitFor(async () => {
|
|
const persisted = await AsyncStorage.getItem("workspace-layout-state");
|
|
expect(persisted).not.toBeNull();
|
|
const root = JSON.parse(persisted ?? "{}").state.layoutByWorkspace[workspaceKey].root;
|
|
expect(collectTabIds(root)).toEqual([first, second, "files", "changes_tree"]);
|
|
});
|
|
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
await restored.persist.rehydrate();
|
|
const tabs = collectAllTabs(restored.getState().layoutByWorkspace[workspaceKey].root);
|
|
expect(tabs.find((tab) => tab.tabId === first)?.state).toEqual(firstState);
|
|
expect(tabs.find((tab) => tab.tabId === second)?.state).toEqual(secondState);
|
|
});
|
|
|
|
it("leaves an Explorer background tab in the only other pane instead of emptying it", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const setupTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId: "main" },
|
|
});
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
|
intent: "background",
|
|
placement: { mode: "prefer", paneId: "explorer" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, setupTabId as string)?.id).toBe("main");
|
|
expect(findPaneById(layout.root, "main")).toBeTruthy();
|
|
expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("keeps ambient entity tabs out of Explorer after a background tab lands there", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "pull_request" },
|
|
intent: "background",
|
|
placement: { mode: "prefer", paneId: "explorer" },
|
|
});
|
|
store.focusPane(workspaceKey, "explorer");
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(agentTabId).toBeTruthy();
|
|
expect(findPaneContainingTab(layout.root, agentTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("places a background terminal tab in Explorer without stealing workspace focus", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.showExplorerSidebar(workspaceKey);
|
|
|
|
const terminalTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "terminal",
|
|
terminalId: "terminal-1",
|
|
},
|
|
intent: "background",
|
|
placement: { mode: "pane", paneId: "explorer" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, terminalTabId as string)?.id).toBe("explorer");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("places a background setup tab in the main pane without moving focus", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const setupTabId = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
|
intent: "background",
|
|
placement: { mode: "prefer", paneId: "main" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, setupTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("keeps a failed setup tab out of the focused Explorer when main was removed", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
workspaceLayoutStore.setState((state) => ({
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: normalizeLayout({
|
|
root: {
|
|
kind: "group",
|
|
group: {
|
|
id: "root",
|
|
direction: "horizontal",
|
|
sizes: [0.5, 0.5],
|
|
children: [
|
|
createPane({ id: "secondary", tabIds: [] }),
|
|
createPane({ id: "explorer", tabIds: [] }),
|
|
],
|
|
},
|
|
},
|
|
focusedPaneId: "explorer",
|
|
}),
|
|
},
|
|
explorerSidebarPaneIdByWorkspace: {
|
|
...state.explorerSidebarPaneIdByWorkspace,
|
|
[workspaceKey]: "explorer",
|
|
},
|
|
}));
|
|
|
|
const setupTabId = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
|
intent: "background",
|
|
placement: { mode: "prefer", paneId: "main" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, setupTabId as string)?.id).toBe("secondary");
|
|
expect(layout.focusedPaneId).toBe("secondary");
|
|
});
|
|
|
|
it("keeps ambient browser opens out of the focused explorer pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "browser", browserId: "browser-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.focusPane(workspaceKey, "explorer");
|
|
|
|
const browserTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "browser",
|
|
browserId: "browser-2",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, browserTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("keeps ambient draft opens out of the focused explorer pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.focusPane(workspaceKey, "explorer");
|
|
|
|
const draftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, draftTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("routes an unsupported explicit Explorer placement to main", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const setupTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "setup", workspaceId: "workspace-1" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId: "explorer" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, setupTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("keeps focused placement in main because Explorer is not workspace focus", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.focusPane(workspaceKey, "explorer");
|
|
|
|
const terminalTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "terminal", terminalId: "terminal-1" },
|
|
intent: "reveal",
|
|
placement: FOCUSED_PANE_PLACEMENT,
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, terminalTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("keeps an existing user-created entity tab in its original pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const terminalTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "terminal",
|
|
terminalId: "terminal-1",
|
|
},
|
|
intent: "background",
|
|
});
|
|
store.showExplorerSidebar(workspaceKey);
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "terminal", terminalId: "terminal-1" },
|
|
intent: "reveal",
|
|
placement: FOCUSED_PANE_PLACEMENT,
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, terminalTabId as string)?.id).toBe("main");
|
|
expect(findPaneById(layout.root, "main")).toBeTruthy();
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("defers terminal reconciliation while a user terminal is being created", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.showExplorerSidebar(workspaceKey);
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: ["terminal-1"],
|
|
standaloneTerminalIds: ["terminal-1"],
|
|
hasActivePendingTerminalCreate: true,
|
|
});
|
|
|
|
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, "terminal_terminal-1")).toBeNull();
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: ["terminal-1"],
|
|
standaloneTerminalIds: ["terminal-1"],
|
|
});
|
|
|
|
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, "terminal_terminal-1")?.id).toBe("main");
|
|
});
|
|
|
|
it("keeps non-entity tabs in the focused explorer pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.focusPane(workspaceKey, "explorer");
|
|
|
|
const filesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "files" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, filesTabId as string)?.id).toBe("explorer");
|
|
});
|
|
|
|
it("keeps an agent where the user moved it in Explorer", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.showExplorerSidebar(workspaceKey);
|
|
store.moveTabToPane(workspaceKey, agentTabId as string, "explorer");
|
|
store.focusPane(workspaceKey, "main");
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, agentTabId as string)?.id).toBe("explorer");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it.each([
|
|
{ kind: "file", path: "src/app.ts" } as const,
|
|
{ kind: "working_diff" } as const,
|
|
{ kind: "commit_diff", sha: "abc1234" } as const,
|
|
])("allows $kind tabs to move into Explorer", (target) => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const tabId = store.openTab({ workspaceKey, target, intent: "reveal" });
|
|
|
|
store.showExplorerSidebar(workspaceKey);
|
|
store.moveTabToPane(workspaceKey, tabId as string, "explorer");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, tabId as string)?.id).toBe("explorer");
|
|
});
|
|
|
|
it("keeps reconcile auto-opened agents out of the focused explorer pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.focusPane(workspaceKey, "explorer");
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["agent-1", "agent-2"],
|
|
autoOpenAgentIds: ["agent-1", "agent-2"],
|
|
knownAgentIds: ["agent-1", "agent-2"],
|
|
standaloneTerminalIds: [],
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, "agent_agent-2")?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("opens an Explorer-supported tab there when Explorer is the only pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
workspaceLayoutStore.setState((state) => ({
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: normalizeLayout({
|
|
root: createPane({ id: "explorer", tabIds: [] }),
|
|
focusedPaneId: "explorer",
|
|
}),
|
|
},
|
|
explorerSidebarPaneIdByWorkspace: {
|
|
...state.explorerSidebarPaneIdByWorkspace,
|
|
[workspaceKey]: "explorer",
|
|
},
|
|
}));
|
|
|
|
const terminalTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "terminal", terminalId: "terminal-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, terminalTabId as string)?.id).toBe("explorer");
|
|
});
|
|
|
|
it("keeps setup in main when Explorer is hidden", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const tabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "setup", workspaceId: WORKSPACE_ID },
|
|
intent: "background",
|
|
placement: { mode: "prefer", paneId: "explorer" },
|
|
});
|
|
|
|
const state = workspaceLayoutStore.getState();
|
|
const layout = state.layoutByWorkspace[workspaceKey];
|
|
const explorerSidebarPaneId =
|
|
state.explorerSidebarPaneIdByWorkspace[workspaceKey] ?? "explorer";
|
|
expect(tabId).toBe("setup_ws-main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, explorerSidebarPaneId)?.hidden).toBe(true);
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(agentTabId);
|
|
expect(findPaneContainingTab(layout.root, tabId!)?.id).toBe("main");
|
|
});
|
|
|
|
it("opens file content in Explorer when explicitly preferred there", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const tabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
parentTabId: "agent_agent-a",
|
|
placement: { mode: "prefer", paneId },
|
|
});
|
|
const createdState = workspaceLayoutStore.getState();
|
|
const explorerSidebarPaneId = createdState.explorerSidebarPaneIdByWorkspace[workspaceKey];
|
|
|
|
expect(explorerSidebarPaneId).toBe("explorer");
|
|
expect(
|
|
findPaneContainingTab(createdState.layoutByWorkspace[workspaceKey].root, tabId!)?.id,
|
|
).toBe("explorer");
|
|
expect(collectAllTabs(createdState.layoutByWorkspace[workspaceKey].root)).toContainEqual({
|
|
tabId,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
createdAt: expect.any(Number),
|
|
});
|
|
|
|
store.hideExplorerSidebar(workspaceKey);
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/b.ts" },
|
|
intent: "reveal",
|
|
placement: { mode: "prefer", paneId: store.showExplorerSidebar(workspaceKey) as string },
|
|
});
|
|
const revealedLayout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(findPaneById(revealedLayout.root, explorerSidebarPaneId)?.hidden).toBeUndefined();
|
|
expect(findPaneContainingTab(revealedLayout.root, "file_/repo/worktree/b.ts")?.id).toBe(
|
|
"explorer",
|
|
);
|
|
});
|
|
|
|
it("leaves a canonical duplicate file tab where the user placed it in Explorer", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
useWorkspaceLayoutIds("explorer");
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const tabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
});
|
|
store.hideExplorerSidebar(workspaceKey);
|
|
|
|
store.showExplorerSidebar(workspaceKey);
|
|
const duplicateTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts", lineStart: 12 },
|
|
intent: "reveal",
|
|
parentTabId: "agent_agent-a",
|
|
placement: { mode: "prefer", paneId },
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(duplicateTabId).toBe(tabId);
|
|
expect(collectAllTabs(layout.root).filter((tab) => tab.tabId === tabId)).toHaveLength(1);
|
|
expect(findPaneContainingTab(layout.root, tabId as string)?.id).toBe("explorer");
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, paneId)?.focusedTabId).toBe(tabId);
|
|
});
|
|
|
|
it("restores workspace and agent plugin panel targets", async () => {
|
|
const workspaceTarget = {
|
|
kind: "plugin",
|
|
pluginId: "review",
|
|
panelId: "summary",
|
|
context: "workspace",
|
|
};
|
|
const agentTarget = {
|
|
kind: "plugin",
|
|
pluginId: "review",
|
|
panelId: "details",
|
|
context: "agent",
|
|
agentId: "agent-1",
|
|
};
|
|
await AsyncStorage.setItem(
|
|
"workspace-layout-state",
|
|
JSON.stringify({
|
|
state: {
|
|
layoutByWorkspace: {
|
|
workspace: {
|
|
root: {
|
|
kind: "pane",
|
|
pane: {
|
|
id: "main",
|
|
tabIds: ["workspace-panel", "agent-panel"],
|
|
focusedTabId: "agent-panel",
|
|
tabs: [
|
|
{ tabId: "workspace-panel", target: workspaceTarget, createdAt: 1 },
|
|
{ tabId: "agent-panel", target: agentTarget, createdAt: 2 },
|
|
],
|
|
},
|
|
},
|
|
focusedPaneId: "main",
|
|
},
|
|
},
|
|
splitSizesByWorkspace: {},
|
|
},
|
|
version: 1,
|
|
}),
|
|
);
|
|
const restored = createWorkspaceLayoutStore(createDeterministicWorkspaceLayoutIds());
|
|
|
|
await restored.persist.rehydrate();
|
|
|
|
const layout = restored.getState().layoutByWorkspace.workspace;
|
|
expect(layout && collectContentTabs(layout.root).map((tab) => tab.target)).toEqual([
|
|
workspaceTarget,
|
|
agentTarget,
|
|
]);
|
|
});
|
|
|
|
it("opens tabs into the focused pane and focuses duplicate opens instead of creating them", () => {
|
|
useWorkspaceLayoutIds("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: secondTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
expect(splitPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
|
|
store.focusPane(workspaceKey, "main");
|
|
const duplicateTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
|
expect(secondTabId).toBe("file_/repo/worktree/b.ts");
|
|
expect(duplicateTabId).toBe(secondTabId);
|
|
expect(layout.focusedPaneId).toBe("pane_aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa");
|
|
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
|
"file_/repo/worktree/a.ts",
|
|
"file_/repo/worktree/b.ts",
|
|
]);
|
|
});
|
|
|
|
it("updates an existing file tab when opening the same path at a new line range", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
lineStart: 5,
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
lineStart: 10,
|
|
lineEnd: 12,
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(firstTabId).toBe("file_/repo/worktree/a.ts");
|
|
expect(secondTabId).toBe(firstTabId);
|
|
expect(collectContentTabs(layout.root)).toEqual([
|
|
{
|
|
tabId: "file_/repo/worktree/a.ts",
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
lineStart: 10,
|
|
lineEnd: 12,
|
|
},
|
|
createdAt: expect.any(Number),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("openTab background intent inserts a tab without stealing focus", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const setupTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "setup",
|
|
workspaceId: "ws-main",
|
|
},
|
|
intent: "background",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const pane = findPaneById(layout.root, "main")!;
|
|
|
|
expect(agentTabId).toBe("agent_agent-1");
|
|
expect(setupTabId).toBe("setup_ws-main");
|
|
expect(pane.tabIds).toEqual([agentTabId, setupTabId]);
|
|
expect(pane.focusedTabId).toBe(agentTabId);
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
|
|
it("openTab background intent on an existing target is a no-op", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const duplicateTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
},
|
|
intent: "background",
|
|
});
|
|
const layoutAfter = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const pane = findPaneById(layoutAfter.root, "main")!;
|
|
|
|
expect(duplicateTabId).toBe(firstTabId);
|
|
expect(pane.tabIds).toEqual([firstTabId, secondTabId]);
|
|
expect(pane.focusedTabId).toBe(secondTabId);
|
|
});
|
|
|
|
it("closing a focused middle tab selects the tab to its right", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const firstTabId = "draft-1";
|
|
const closedTabId = "draft-2";
|
|
const rightTabId = "draft-3";
|
|
|
|
workspaceLayoutStore.setState((state) => ({
|
|
...state,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: {
|
|
root: createPane({
|
|
id: "main",
|
|
tabIds: [firstTabId, closedTabId, rightTabId],
|
|
focusedTabId: closedTabId,
|
|
}),
|
|
focusedPaneId: "main",
|
|
},
|
|
},
|
|
}));
|
|
|
|
workspaceLayoutStore.getState().closeTab(workspaceKey, closedTabId);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const pane = findPaneById(layout.root, "main")!;
|
|
|
|
expect(pane.tabIds).toEqual([firstTabId, rightTabId]);
|
|
expect(pane.focusedTabId).toBe(rightTabId);
|
|
});
|
|
|
|
it("closing a focused child tab returns to its parent before using tab-strip order", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const parentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-parent",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const childTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-child" },
|
|
intent: "reveal",
|
|
parentTabId: parentTabId!,
|
|
});
|
|
const rightTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-right",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
store.focusTab(workspaceKey, childTabId!);
|
|
|
|
workspaceLayoutStore.getState().closeTab(workspaceKey, childTabId!);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const pane = findPaneById(layout.root, "main")!;
|
|
|
|
expect(pane.tabIds).toEqual([parentTabId, rightTabId]);
|
|
expect(pane.focusedTabId).toBe(parentTabId);
|
|
});
|
|
|
|
it("closing a focused last tab selects the tab to its left", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const leftTabId = "draft-1";
|
|
const closedTabId = "draft-2";
|
|
|
|
workspaceLayoutStore.setState((state) => ({
|
|
...state,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: {
|
|
root: createPane({
|
|
id: "main",
|
|
tabIds: [leftTabId, closedTabId],
|
|
focusedTabId: closedTabId,
|
|
}),
|
|
focusedPaneId: "main",
|
|
},
|
|
},
|
|
}));
|
|
|
|
workspaceLayoutStore.getState().closeTab(workspaceKey, closedTabId);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const pane = findPaneById(layout.root, "main")!;
|
|
|
|
expect(pane.tabIds).toEqual([leftTabId]);
|
|
expect(pane.focusedTabId).toBe(leftTabId);
|
|
});
|
|
|
|
it("unfocuses and restores the previous focused pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const token = store.unfocusPane(workspaceKey);
|
|
expect(token).toBeTruthy();
|
|
expect(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
|
).toBeNull();
|
|
|
|
store.restorePaneFocus(workspaceKey, token!);
|
|
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
|
"main",
|
|
);
|
|
});
|
|
|
|
it("does not restore stale focus after another pane is focused", () => {
|
|
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.splitPane(workspaceKey, {
|
|
tabId: firstTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
store.focusPane(workspaceKey, "main");
|
|
|
|
const token = store.unfocusPane(workspaceKey);
|
|
store.focusPane(workspaceKey, "pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
store.restorePaneFocus(workspaceKey, token!);
|
|
|
|
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
|
"pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb",
|
|
);
|
|
});
|
|
|
|
it("waits for nested focus restorations before restoring", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const outerToken = store.unfocusPane(workspaceKey);
|
|
const innerToken = store.unfocusPane(workspaceKey);
|
|
|
|
store.restorePaneFocus(workspaceKey, outerToken!);
|
|
expect(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId,
|
|
).toBeNull();
|
|
|
|
store.restorePaneFocus(workspaceKey, innerToken!);
|
|
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]?.focusedPaneId).toBe(
|
|
"main",
|
|
);
|
|
});
|
|
|
|
it("openTab creates distinct draft tabs for repeated Cmd+T/new-tab opens", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-1" },
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-2" },
|
|
intent: "reveal",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(firstTabId).toBe("draft-1");
|
|
expect(secondTabId).toBe("draft-2");
|
|
expect(firstTabId).not.toBe(secondTabId);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([firstTabId, secondTabId]);
|
|
expect(collectContentTabs(layout.root)).toEqual([
|
|
{
|
|
tabId: firstTabId,
|
|
target: { kind: "draft", draftId: "draft-1" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
{
|
|
tabId: secondTabId,
|
|
target: { kind: "draft", draftId: "draft-2" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("splitPaneEmpty plus openTab opens a draft tab in the new pane", () => {
|
|
useWorkspaceLayoutIds("77777777-7777-7777-7777-777777777777");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const newPaneId = store.splitPaneEmpty(workspaceKey, {
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const draftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-split",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(newPaneId).toBe("pane_77777777-7777-7777-7777-777777777777");
|
|
expect(draftTabId).toBe("draft-split");
|
|
expect(layout.focusedPaneId).toBe(newPaneId);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
|
|
expect(findPaneById(layout.root, newPaneId)?.tabIds).toEqual([draftTabId!]);
|
|
expect(findPaneById(layout.root, newPaneId)?.focusedTabId).toBe(draftTabId);
|
|
});
|
|
|
|
it("hides and shows Explorer without changing its default tabs or split sizes", () => {
|
|
useWorkspaceLayoutIds("88888888-8888-8888-8888-888888888888");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "main-tab" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey)!;
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
});
|
|
const sizes = expectGroup(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root)
|
|
.group.sizes;
|
|
|
|
store.hideExplorerSidebar(workspaceKey);
|
|
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, paneId)).toMatchObject({
|
|
hidden: true,
|
|
tabIds: ["files", "changes_tree"],
|
|
});
|
|
expect(expectGroup(layout.root).group.sizes).toEqual(sizes);
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
|
|
store.showExplorerSidebar(workspaceKey);
|
|
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, paneId)).toMatchObject({
|
|
tabIds: ["files", "changes_tree"],
|
|
});
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
expect(expectGroup(layout.root).group.sizes).toEqual(sizes);
|
|
});
|
|
|
|
it("focusTab moves workspace focus to the pane containing the tab", () => {
|
|
useWorkspaceLayoutIds("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const fileTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const terminalTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "terminal",
|
|
terminalId: "term-1",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: terminalTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
store.focusTab(workspaceKey, fileTabId!);
|
|
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
|
|
store.focusTab(workspaceKey, terminalTabId!);
|
|
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
|
expect(splitPaneId).toBe("pane_bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb");
|
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
|
expect(findPaneById(layout.root, splitPaneId)?.focusedTabId).toBe(terminalTabId);
|
|
});
|
|
|
|
it("focusTab reveals an Explorer tab without moving workspace focus", () => {
|
|
useWorkspaceLayoutIds("89898989-8989-8989-8989-898989898989");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const targetTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "terminal",
|
|
terminalId: "term-hidden",
|
|
},
|
|
intent: "reveal",
|
|
})!;
|
|
const paneId = store.showExplorerSidebar(workspaceKey)!;
|
|
store.moveTabToPane(workspaceKey, targetTabId, paneId);
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
});
|
|
store.focusPane(workspaceKey, "main");
|
|
const group = expectGroup(
|
|
workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root,
|
|
).group;
|
|
store.resizeSplit(workspaceKey, group.id, [0.7, 0.3]);
|
|
store.hideExplorerSidebar(workspaceKey);
|
|
const splitSizes = workspaceLayoutStore.getState().splitSizesByWorkspace[workspaceKey];
|
|
|
|
store.focusTab(workspaceKey, targetTabId);
|
|
|
|
const state = workspaceLayoutStore.getState();
|
|
const layout = state.layoutByWorkspace[workspaceKey];
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, paneId)).toMatchObject({
|
|
focusedTabId: targetTabId,
|
|
tabIds: ["files", "changes_tree", targetTabId],
|
|
});
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
expect(expectGroup(layout.root).group.sizes).toEqual(group.sizes);
|
|
expect(state.splitSizesByWorkspace[workspaceKey]).toBe(splitSizes);
|
|
});
|
|
|
|
it("convertDraftToAgent replaces the draft tab with a canonical agent tab in the same pane", () => {
|
|
useWorkspaceLayoutIds("12121212-1212-1212-1212-121212121212");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-2" },
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: secondTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
const nextTabId = store.convertDraftToAgent(workspaceKey, secondTabId!, "agent-1");
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const splitPane = findPaneById(layout.root, splitPaneId);
|
|
const convertedTab = collectAllTabs(layout.root).find((tab) => tab.tabId === nextTabId);
|
|
|
|
expect(splitPaneId).toBe("pane_12121212-1212-1212-1212-121212121212");
|
|
expect(nextTabId).toBe("agent_agent-1");
|
|
expect(splitPane?.tabIds).toEqual(["agent_agent-1"]);
|
|
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
|
|
expect(convertedTab).toEqual({
|
|
tabId: "agent_agent-1",
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
createdAt: expect.any(Number),
|
|
});
|
|
});
|
|
|
|
it("replaceTab keeps a draft tab in place while updating its target", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const draftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-retarget",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const nextTabId = store.replaceTab(workspaceKey, draftTabId!, {
|
|
kind: "file",
|
|
path: "/repo/worktree/retargeted.ts",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(draftTabId).toBe("draft-retarget");
|
|
expect(nextTabId).toBe(draftTabId);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([draftTabId!]);
|
|
expect(collectContentTabs(layout.root)).toEqual([
|
|
{
|
|
tabId: draftTabId!,
|
|
target: { kind: "file", path: "/repo/worktree/retargeted.ts" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("replaceTab gives a non-draft tab the new target identity", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "agent",
|
|
agentId: "agent-retarget",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const nextTabId = store.replaceTab(workspaceKey, agentTabId!, {
|
|
kind: "draft",
|
|
draftId: "draft-from-agent",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(agentTabId).toBe("agent_agent-retarget");
|
|
expect(nextTabId).toBe("draft-from-agent");
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["draft-from-agent"]);
|
|
expect(collectContentTabs(layout.root)).toEqual([
|
|
{
|
|
tabId: "draft-from-agent",
|
|
target: { kind: "draft", draftId: "draft-from-agent" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("replaceTab keeps a pane-local replacement beside an existing target", () => {
|
|
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const existingFileTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/existing.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const draftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-dup" },
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: draftTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const secondDraftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-dup-2",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
|
|
const nextTabId = store.replaceTab(workspaceKey, secondDraftTabId!, {
|
|
kind: "file",
|
|
path: "/repo/worktree/existing.ts",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(existingFileTabId).toBe("file_/repo/worktree/existing.ts");
|
|
expect(draftTabId).toBe("draft-dup");
|
|
expect(splitPaneId).toBe("pane_55555555-5555-5555-5555-555555555555");
|
|
expect(nextTabId).not.toBe(existingFileTabId);
|
|
expect(collectContentTabs(layout.root)).toHaveLength(3);
|
|
expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "file")).toHaveLength(2);
|
|
});
|
|
|
|
it("replaceTab keeps a pane-local replacement beside an existing matching target", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstDraftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-agent-1",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const firstAgentTabId = store.replaceTab(workspaceKey, firstDraftTabId!, {
|
|
kind: "agent",
|
|
agentId: "agent-1",
|
|
});
|
|
const secondDraftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-agent-2",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
|
|
const nextTabId = store.replaceTab(workspaceKey, secondDraftTabId!, {
|
|
kind: "agent",
|
|
agentId: "agent-1",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(firstAgentTabId).toBe(firstDraftTabId);
|
|
expect(nextTabId).not.toBe(firstDraftTabId);
|
|
expect(collectContentTabs(layout.root)).toHaveLength(2);
|
|
expect(collectAllTabs(layout.root).filter((tab) => tab.target.kind === "agent")).toHaveLength(
|
|
2,
|
|
);
|
|
});
|
|
|
|
it("reorderTabs reorders tabs within the focused pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const firstTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const thirdTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/c.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
|
|
store.reorderTabs(workspaceKey, [thirdTabId!, firstTabId!]);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(findPaneById(layout.root, "main")).toEqual({
|
|
id: "main",
|
|
tabIds: [thirdTabId!, firstTabId!, secondTabId!],
|
|
focusedTabId: thirdTabId,
|
|
tabs: [
|
|
{
|
|
tabId: thirdTabId,
|
|
target: { kind: "file", path: "/repo/worktree/c.ts" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
{
|
|
tabId: firstTabId,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
{
|
|
tabId: secondTabId,
|
|
target: { kind: "file", path: "/repo/worktree/b.ts" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("reorderTabsInPane reorders tabs in the requested pane without changing focused pane", () => {
|
|
useWorkspaceLayoutIds("34343434-3434-3434-3434-343434343434");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const thirdTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/c.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const fourthTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/d.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: thirdTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
store.moveTabToPane(workspaceKey, fourthTabId!, splitPaneId!);
|
|
store.focusPane(workspaceKey, "main");
|
|
store.reorderTabsInPane(workspaceKey, splitPaneId!, [fourthTabId!, thirdTabId!]);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(splitPaneId).toBe("pane_34343434-3434-3434-3434-343434343434");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, splitPaneId)).toEqual({
|
|
id: splitPaneId,
|
|
tabIds: [fourthTabId!, thirdTabId!],
|
|
focusedTabId: fourthTabId,
|
|
tabs: [
|
|
{
|
|
tabId: fourthTabId,
|
|
target: { kind: "file", path: "/repo/worktree/d.ts" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
{
|
|
tabId: thirdTabId,
|
|
target: { kind: "file", path: "/repo/worktree/c.ts" },
|
|
createdAt: expect.any(Number),
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
it("focusPane switches workspace focus to a different pane", () => {
|
|
useWorkspaceLayoutIds("56565656-5656-5656-5656-565656565656");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: secondTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
store.focusPane(workspaceKey, "main");
|
|
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
|
|
store.focusPane(workspaceKey, splitPaneId!);
|
|
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]!;
|
|
|
|
expect(splitPaneId).toBe("pane_56565656-5656-5656-5656-565656565656");
|
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
|
});
|
|
|
|
it("focusPane does not turn Explorer into workspace focus", () => {
|
|
useWorkspaceLayoutIds("57575757-5757-5757-5757-575757575757");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey)!;
|
|
store.focusPane(workspaceKey, "main");
|
|
store.hideExplorerSidebar(workspaceKey);
|
|
|
|
store.focusPane(workspaceKey, paneId);
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBe(true);
|
|
});
|
|
|
|
it("closeTab collapses an emptied pane and keeps the nearest sibling focused", () => {
|
|
useWorkspaceLayoutIds("cccccccc-cccc-cccc-cccc-cccccccccccc");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: secondTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
store.closeTab(workspaceKey, secondTabId!);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(splitPaneId).toBe("pane_cccccccc-cccc-cccc-cccc-cccccccccccc");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
|
|
});
|
|
|
|
it("splitPane preserves four user-created levels beneath the explorer split", () => {
|
|
useWorkspaceLayoutIds(
|
|
"11111111-1111-1111-1111-111111111111",
|
|
"22222222-2222-2222-2222-222222222222",
|
|
"33333333-3333-3333-3333-333333333333",
|
|
"44444444-4444-4444-4444-444444444444",
|
|
"55555555-5555-5555-5555-555555555555",
|
|
"66666666-6666-6666-6666-666666666666",
|
|
"77777777-7777-7777-7777-777777777777",
|
|
"88888888-8888-8888-8888-888888888888",
|
|
);
|
|
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const a = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const b = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/b.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const c = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/c.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const d = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/d.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const e = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/e.ts" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
expect(a).toBeTruthy();
|
|
const pane1 = store.splitPane(workspaceKey, {
|
|
tabId: b!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const pane2 = store.splitPane(workspaceKey, {
|
|
tabId: c!,
|
|
targetPaneId: pane1!,
|
|
position: "bottom",
|
|
});
|
|
const pane3 = store.splitPane(workspaceKey, {
|
|
tabId: d!,
|
|
targetPaneId: pane2!,
|
|
position: "right",
|
|
});
|
|
const pane4 = store.splitPane(workspaceKey, {
|
|
tabId: e!,
|
|
targetPaneId: pane3!,
|
|
position: "bottom",
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
// The first split joins the root group instead of nesting under it, so it spends a pane id and
|
|
// no group id, and the four user splits all fit inside the depth cap.
|
|
expect(pane1).toBe("pane_11111111-1111-1111-1111-111111111111");
|
|
expect(pane2).toBe("pane_22222222-2222-2222-2222-222222222222");
|
|
expect(pane3).toBe("pane_44444444-4444-4444-4444-444444444444");
|
|
expect(pane4).toBe("pane_66666666-6666-6666-6666-666666666666");
|
|
expect(getTreeDepth(layout.root)).toBe(5);
|
|
});
|
|
|
|
it("moveTabToPane collapses the source pane when its last tab moves out", () => {
|
|
useWorkspaceLayoutIds("dddddddd-dddd-dddd-dddd-dddddddddddd");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const leftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/a.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const rightTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: rightTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
store.moveTabToPane(workspaceKey, leftTabId!, splitPaneId!);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId!]);
|
|
expect(findPaneById(layout.root, splitPaneId)?.tabIds).toEqual([
|
|
"file_/repo/worktree/b.ts",
|
|
"file_/repo/worktree/a.ts",
|
|
]);
|
|
});
|
|
|
|
it("moving a pane's sole New tab closes that pane instead of moving the tab", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const fileTabId = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
}) as string;
|
|
const splitPaneId = store.splitPaneEmpty(workspaceKey, {
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
}) as string;
|
|
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const newTabId = findPaneById(before.root, splitPaneId)?.focusedTabId as string;
|
|
|
|
store.moveTabToPane(workspaceKey, newTabId, "main");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([fileTabId]);
|
|
expect(collectAllTabs(layout.root).some((tab) => tab.tabId === newTabId)).toBe(false);
|
|
});
|
|
|
|
it("does not move Explorer-only default tabs into main", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const fileTabId = store.openTab({
|
|
workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
}) as string;
|
|
const explorerSidebarPaneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const changesTabId = findPaneById(before.root, explorerSidebarPaneId)?.focusedTabId as string;
|
|
|
|
store.moveTabToPane(workspaceKey, changesTabId, "main");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, explorerSidebarPaneId)?.hidden).toBeUndefined();
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([fileTabId]);
|
|
expect(findPaneById(layout.root, explorerSidebarPaneId)?.tabIds).toEqual([
|
|
"files",
|
|
"changes_tree",
|
|
]);
|
|
});
|
|
|
|
it("closeTab cascades group unwrapping when an inner split collapses to a single pane", () => {
|
|
useWorkspaceLayoutIds(
|
|
"78787878-7878-7878-7878-787878787878",
|
|
"89898989-8989-8989-8989-898989898989",
|
|
"9a9a9a9a-9a9a-9a9a-9a9a-9a9a9a9a9a9a",
|
|
);
|
|
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const thirdTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/c.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const paneBId = store.splitPane(workspaceKey, {
|
|
tabId: secondTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const paneCId = store.splitPane(workspaceKey, {
|
|
tabId: thirdTabId!,
|
|
targetPaneId: paneBId!,
|
|
position: "bottom",
|
|
});
|
|
|
|
store.closeTab(workspaceKey, secondTabId!);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(paneBId).toBe("pane_78787878-7878-7878-7878-787878787878");
|
|
expect(paneCId).toBe("pane_89898989-8989-8989-8989-898989898989");
|
|
expect(layout.focusedPaneId).toBe(paneCId);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["file_/repo/worktree/a.ts"]);
|
|
expect(findPaneById(layout.root, paneCId)?.tabIds).toEqual(["file_/repo/worktree/c.ts"]);
|
|
expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
|
|
});
|
|
|
|
it("openTab focuses the existing tab instead of creating a duplicate entry", () => {
|
|
useWorkspaceLayoutIds("abababab-abab-abab-abab-abababababab");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const secondTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: secondTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
store.focusPane(workspaceKey, "main");
|
|
const duplicateTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "file",
|
|
path: "/repo/worktree/b.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(splitPaneId).toBe("pane_abababab-abab-abab-abab-abababababab");
|
|
expect(duplicateTabId).toBe(secondTabId);
|
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
|
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
|
"file_/repo/worktree/a.ts",
|
|
"file_/repo/worktree/b.ts",
|
|
]);
|
|
});
|
|
|
|
it("persists working diff tabs while stripping commit diff tabs", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "working_diff",
|
|
focusPath: "src/a.ts",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "commit_diff", sha: "abc123" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
|
expect(partialize).toBeTypeOf("function");
|
|
if (!partialize) {
|
|
throw new Error("Workspace layout partialize function is missing");
|
|
}
|
|
const currentState = workspaceLayoutStore.getState();
|
|
const layout = stripEphemeralTabsFromLayout(currentState.layoutByWorkspace[workspaceKey]);
|
|
const persisted = partialize(currentState);
|
|
|
|
expect(persisted).toEqual({
|
|
layoutByWorkspace: { [workspaceKey]: layout },
|
|
splitSizesByWorkspace: currentState.splitSizesByWorkspace,
|
|
explorerSidebarWidthByWorkspace: currentState.explorerSidebarWidthByWorkspace,
|
|
explorerPaneIdByWorkspace: {},
|
|
sidePaneIdByWorkspace: currentState.sidePaneIdByWorkspace,
|
|
});
|
|
expect(layout && collectAllTabs(layout.root).map((tab) => tab.target)).toEqual([
|
|
{
|
|
kind: "working_diff",
|
|
focusPath: "src/a.ts",
|
|
},
|
|
{ kind: "files" },
|
|
{ kind: "changes_tree" },
|
|
]);
|
|
});
|
|
|
|
it("hydrates duplicate Changes and Pull request instances without collapsing their ids or state", () => {
|
|
const tabs = [
|
|
{
|
|
tabId: "changes-a",
|
|
createdAt: 1,
|
|
target: { kind: "working_diff" },
|
|
state: {
|
|
mode: "base",
|
|
baseRef: "main",
|
|
layout: "split",
|
|
wrapLines: true,
|
|
hideWhitespace: true,
|
|
treeVisible: true,
|
|
treeWidth: 300,
|
|
collapsedFilePaths: ["a"],
|
|
collapsedFolderPaths: ["src"],
|
|
commitsCollapsed: false,
|
|
},
|
|
},
|
|
{
|
|
tabId: "changes-b",
|
|
createdAt: 2,
|
|
target: { kind: "working_diff" },
|
|
state: {
|
|
mode: "uncommitted",
|
|
layout: "unified",
|
|
wrapLines: false,
|
|
hideWhitespace: false,
|
|
treeVisible: false,
|
|
collapsedFilePaths: [],
|
|
collapsedFolderPaths: [],
|
|
commitsCollapsed: true,
|
|
},
|
|
},
|
|
{ tabId: "pr-a", createdAt: 3, target: { kind: "pull_request" } },
|
|
{ tabId: "pr-b", createdAt: 4, target: { kind: "pull_request" } },
|
|
];
|
|
const layout = normalizeLayout({
|
|
root: {
|
|
kind: "pane",
|
|
pane: { id: "main", tabIds: tabs.map((tab) => tab.tabId), focusedTabId: "changes-a", tabs },
|
|
},
|
|
focusedPaneId: "main",
|
|
});
|
|
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toEqual([
|
|
"changes-a",
|
|
"changes-b",
|
|
"pr-a",
|
|
"pr-b",
|
|
]);
|
|
expect(
|
|
collectAllTabs(layout.root).find((tab) => tab.tabId === "changes-a")?.state,
|
|
).toMatchObject({ baseRef: "main", treeWidth: 300, collapsedFolderPaths: ["src"] });
|
|
});
|
|
|
|
it("resizeSplit keeps sizes normalized while enforcing the minimum proportion", () => {
|
|
useWorkspaceLayoutIds(
|
|
"eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee",
|
|
"ffffffff-ffff-ffff-ffff-ffffffffffff",
|
|
"11111111-1111-1111-1111-111111111111",
|
|
);
|
|
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const a = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const b = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/b.ts" },
|
|
intent: "reveal",
|
|
});
|
|
const c = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/c.ts" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
expect(a).toBeTruthy();
|
|
const rightPaneId = store.splitPane(workspaceKey, {
|
|
tabId: b!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const farRightPaneId = store.splitPane(workspaceKey, {
|
|
tabId: c!,
|
|
targetPaneId: rightPaneId!,
|
|
position: "bottom",
|
|
});
|
|
|
|
const splitRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
|
const splitGroup = expectGroup(splitRoot);
|
|
const nestedGroup = expectGroup(
|
|
splitGroup.group.children.find((child) => child.kind === "group")!,
|
|
);
|
|
store.resizeSplit(workspaceKey, nestedGroup.group.id, [0.01, 0.99]);
|
|
|
|
const resizedRoot = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root;
|
|
const resizedGroup = expectGroup(resizedRoot);
|
|
const resizedNestedGroup = expectGroup(
|
|
resizedGroup.group.children.find((child) => child.kind === "group")!,
|
|
);
|
|
const total = resizedNestedGroup.group.sizes.reduce((sum, size) => sum + size, 0);
|
|
|
|
expect(rightPaneId).toBe("pane_eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
|
|
expect(farRightPaneId).toBe("pane_ffffffff-ffff-ffff-ffff-ffffffffffff");
|
|
expect(resizedNestedGroup.group.sizes[0]).toBeGreaterThanOrEqual(0.1);
|
|
expect(resizedNestedGroup.group.sizes[1]).toBeGreaterThanOrEqual(0.1);
|
|
expect(total).toBeCloseTo(1, 10);
|
|
});
|
|
|
|
it("closing the last content tab creates a fresh New tab in the retained pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const tabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "draft", draftId: "draft-1" },
|
|
intent: "reveal",
|
|
});
|
|
store.closeTab(workspaceKey, tabId!);
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
const mainTab = collectAllTabs(layout.root).find(
|
|
(tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main",
|
|
);
|
|
expect(mainTab?.target).toEqual({ kind: "new_tab" });
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
|
|
expect(findPaneById(layout.root, "explorer")?.hidden).toBe(true);
|
|
});
|
|
|
|
it("keeps pinned archived agents in memory per workspace without persisting them", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
|
|
serverId: SERVER_ID,
|
|
workspaceId: "ws-other-worktree",
|
|
});
|
|
|
|
expect(otherWorkspaceKey).toBeTruthy();
|
|
|
|
const store = workspaceLayoutStore.getState();
|
|
store.pinAgent(workspaceKey, "agent-1");
|
|
store.pinAgent(workspaceKey, "agent-1");
|
|
store.pinAgent(otherWorkspaceKey as string, "agent-2");
|
|
|
|
let state = workspaceLayoutStore.getState();
|
|
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
|
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
|
"agent-2",
|
|
]);
|
|
|
|
store.unpinAgent(workspaceKey, "agent-1");
|
|
|
|
state = workspaceLayoutStore.getState();
|
|
expect(state.pinnedAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
|
expect(Array.from(state.pinnedAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
|
"agent-2",
|
|
]);
|
|
|
|
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
|
expect(partialize).toBeTypeOf("function");
|
|
expect(partialize?.(state)).toEqual({
|
|
layoutByWorkspace: {},
|
|
splitSizesByWorkspace: {},
|
|
explorerSidebarWidthByWorkspace: {},
|
|
explorerPaneIdByWorkspace: {},
|
|
sidePaneIdByWorkspace: {},
|
|
});
|
|
});
|
|
|
|
it("keeps hidden agent intents in memory per workspace without persisting them", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const otherWorkspaceKey = buildWorkspaceTabPersistenceKey({
|
|
serverId: SERVER_ID,
|
|
workspaceId: "ws-other-worktree",
|
|
});
|
|
|
|
expect(otherWorkspaceKey).toBeTruthy();
|
|
|
|
const store = workspaceLayoutStore.getState();
|
|
store.hideAgent(workspaceKey, "agent-1");
|
|
store.hideAgent(workspaceKey, "agent-1");
|
|
store.hideAgent(otherWorkspaceKey as string, "agent-2");
|
|
|
|
let state = workspaceLayoutStore.getState();
|
|
expect(Array.from(state.hiddenAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
|
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
|
"agent-2",
|
|
]);
|
|
|
|
store.unhideAgent(workspaceKey, "agent-1");
|
|
|
|
state = workspaceLayoutStore.getState();
|
|
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
|
expect(Array.from(state.hiddenAgentIdsByWorkspace[otherWorkspaceKey as string] ?? [])).toEqual([
|
|
"agent-2",
|
|
]);
|
|
|
|
const partialize = workspaceLayoutStore.persist.getOptions().partialize;
|
|
expect(partialize).toBeTypeOf("function");
|
|
expect(partialize?.(state)).toEqual({
|
|
layoutByWorkspace: {},
|
|
splitSizesByWorkspace: {},
|
|
explorerSidebarWidthByWorkspace: {},
|
|
explorerPaneIdByWorkspace: {},
|
|
sidePaneIdByWorkspace: {},
|
|
});
|
|
});
|
|
|
|
it("convertDraftToAgent removes the draft and focuses the existing canonical agent tab", () => {
|
|
useWorkspaceLayoutIds("67676767-6767-6767-6767-676767676767");
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const draftTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "draft",
|
|
draftId: "draft-existing",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: agentTabId!,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
|
|
const nextTabId = store.convertDraftToAgent(workspaceKey, draftTabId!, "agent-1");
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(splitPaneId).toBe("pane_67676767-6767-6767-6767-676767676767");
|
|
expect(nextTabId).toBe("agent_agent-1");
|
|
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual(["agent_agent-1"]);
|
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
|
expect(findPaneContainingTab(layout.root, "agent_agent-1")?.id).toBe(splitPaneId);
|
|
});
|
|
|
|
it("reconcileTabs canonicalizes duplicates and prunes stale entity tabs from hydrated snapshots", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.setState((state) => ({
|
|
...state,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: {
|
|
root: {
|
|
kind: "pane",
|
|
pane: {
|
|
id: "main",
|
|
tabIds: ["draft_agent", "agent_agent-1", "terminal_orphan", "draft-1"],
|
|
focusedTabId: "draft_agent",
|
|
tabs: [
|
|
{
|
|
tabId: "draft_agent",
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
createdAt: 1,
|
|
},
|
|
{
|
|
tabId: "agent_agent-1",
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
createdAt: 2,
|
|
},
|
|
{
|
|
tabId: "terminal_orphan",
|
|
target: { kind: "terminal", terminalId: "term-stale" },
|
|
createdAt: 3,
|
|
},
|
|
{
|
|
tabId: "draft-1",
|
|
target: { kind: "draft", draftId: "draft-1" },
|
|
createdAt: 4,
|
|
},
|
|
],
|
|
} as SplitPane,
|
|
},
|
|
focusedPaneId: "main",
|
|
},
|
|
},
|
|
pinnedAgentIdsByWorkspace: {
|
|
[workspaceKey]: new Set<string>(["agent-2"]),
|
|
},
|
|
}));
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["agent-1"],
|
|
autoOpenAgentIds: ["agent-1"],
|
|
knownAgentIds: ["agent-1", "agent-2"],
|
|
standaloneTerminalIds: ["term-1"],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const tabs = collectAllTabs(layout.root);
|
|
|
|
expect(tabs.map((tab) => tab.tabId)).toEqual([
|
|
"agent_agent-1",
|
|
"draft-1",
|
|
"agent_agent-2",
|
|
"terminal_term-1",
|
|
]);
|
|
expect(tabs.find((tab) => tab.tabId === "agent_agent-1")).toEqual({
|
|
tabId: "agent_agent-1",
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
createdAt: 2,
|
|
});
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
|
|
});
|
|
|
|
it("reconcileTabs preserves a draft-origin agent tab id when there is no duplicate", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.setState((state) => ({
|
|
...state,
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: {
|
|
root: {
|
|
kind: "pane",
|
|
pane: {
|
|
id: "main",
|
|
tabIds: ["draft-agent"],
|
|
focusedTabId: "draft-agent",
|
|
tabs: [
|
|
{
|
|
tabId: "draft-agent",
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
createdAt: 1,
|
|
},
|
|
],
|
|
} as SplitPane,
|
|
},
|
|
focusedPaneId: "main",
|
|
},
|
|
},
|
|
}));
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["agent-1"],
|
|
autoOpenAgentIds: ["agent-1"],
|
|
knownAgentIds: ["agent-1"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(collectContentTabs(layout.root)).toEqual([
|
|
{
|
|
tabId: "draft-agent",
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
createdAt: 1,
|
|
},
|
|
]);
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("draft-agent");
|
|
});
|
|
|
|
it("reconcileTabs does not re-add locally hidden agent tabs", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.setState((state) => ({
|
|
...state,
|
|
hiddenAgentIdsByWorkspace: {
|
|
[workspaceKey]: new Set<string>(["agent-1"]),
|
|
},
|
|
}));
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["agent-1"],
|
|
autoOpenAgentIds: ["agent-1"],
|
|
knownAgentIds: ["agent-1"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]);
|
|
});
|
|
|
|
it("reconcileTabs lands on an existing agent instead of the initial New tab", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["agent-1"],
|
|
autoOpenAgentIds: ["agent-1"],
|
|
knownAgentIds: ["agent-1"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["agent_agent-1"]);
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe("agent_agent-1");
|
|
});
|
|
|
|
it("reconcileTabs lands on a draft when the hydrated workspace is empty", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: [],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const mainTab = collectAllTabs(layout.root).find(
|
|
(tab) => findPaneContainingTab(layout.root, tab.tabId)?.id === "main",
|
|
);
|
|
expect(mainTab?.target.kind).toBe("draft");
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(mainTab?.tabId);
|
|
});
|
|
|
|
it("reconcileTabs stays stable when startup removes a duplicate agent kept in Explorer", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
workspaceLayoutStore.setState((state) => ({
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: {
|
|
root: {
|
|
kind: "group",
|
|
group: {
|
|
id: "root",
|
|
direction: "horizontal",
|
|
sizes: [0.78, 0.22],
|
|
children: [
|
|
createPane({
|
|
id: "main",
|
|
tabIds: ["new", "draft-origin-agent"],
|
|
focusedTabId: "draft-origin-agent",
|
|
targetsByTabId: {
|
|
new: { kind: "new_tab" },
|
|
"draft-origin-agent": { kind: "agent", agentId: "agent-1" },
|
|
},
|
|
}),
|
|
createPane({
|
|
id: "explorer",
|
|
tabIds: ["files", "changes_tree", "agent_agent-1"],
|
|
focusedTabId: "agent_agent-1",
|
|
targetsByTabId: {
|
|
files: { kind: "files" },
|
|
changes_tree: { kind: "changes_tree" },
|
|
"agent_agent-1": { kind: "agent", agentId: "agent-1" },
|
|
},
|
|
}),
|
|
],
|
|
},
|
|
},
|
|
focusedPaneId: "main",
|
|
},
|
|
},
|
|
explorerSidebarPaneIdByWorkspace: {
|
|
...state.explorerSidebarPaneIdByWorkspace,
|
|
[workspaceKey]: "explorer",
|
|
},
|
|
}));
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: false,
|
|
terminalsHydrated: false,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: [],
|
|
standaloneTerminalIds: [],
|
|
});
|
|
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].focusedPaneId).toBe(
|
|
"main",
|
|
);
|
|
|
|
workspaceLayoutStore.setState((state) => ({
|
|
layoutByWorkspace: {
|
|
...state.layoutByWorkspace,
|
|
[workspaceKey]: {
|
|
...state.layoutByWorkspace[workspaceKey],
|
|
focusedPaneId: "explorer",
|
|
},
|
|
},
|
|
}));
|
|
|
|
const emptySnapshot = {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: [],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
};
|
|
store.reconcileTabs(workspaceKey, emptySnapshot);
|
|
store.reconcileTabs(workspaceKey, emptySnapshot);
|
|
const afterSecondReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
store.reconcileTabs(workspaceKey, emptySnapshot);
|
|
const afterThirdReconcile = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
expect(afterThirdReconcile).toBe(afterSecondReconcile);
|
|
const mainTabs = collectAllTabs(afterThirdReconcile.root).filter(
|
|
(tab) => findPaneContainingTab(afterThirdReconcile.root, tab.tabId)?.id === "main",
|
|
);
|
|
expect(mainTabs).toEqual([
|
|
expect.objectContaining({ target: expect.objectContaining({ kind: "draft" }) }),
|
|
]);
|
|
});
|
|
|
|
it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["parent-agent", "child-agent"],
|
|
autoOpenAgentIds: ["parent-agent"],
|
|
knownAgentIds: ["parent-agent", "child-agent"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
expect(
|
|
contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map(
|
|
(tab) => tab.tabId,
|
|
),
|
|
).toEqual(["agent_parent-agent"]);
|
|
});
|
|
|
|
it("reconcileTabs keeps manually opened subagent tabs that remain active", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "child-agent" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["parent-agent", "child-agent"],
|
|
autoOpenAgentIds: ["parent-agent"],
|
|
knownAgentIds: ["parent-agent", "child-agent"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
expect(
|
|
contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map(
|
|
(tab) => tab.tabId,
|
|
),
|
|
).toEqual(["agent_child-agent", "agent_parent-agent"]);
|
|
});
|
|
|
|
it("reconcileTabs prunes archived subagent tabs that are no longer active", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "child-agent" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["parent-agent"],
|
|
autoOpenAgentIds: ["parent-agent"],
|
|
knownAgentIds: ["parent-agent", "child-agent"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
expect(
|
|
contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).map(
|
|
(tab) => tab.tabId,
|
|
),
|
|
).toEqual(["agent_parent-agent"]);
|
|
});
|
|
|
|
it("openTab reveal intent reopens hidden subagent tabs and clears hidden intent", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.hideAgent(workspaceKey, "child-agent");
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: ["child-agent"],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: ["child-agent"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]);
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "child-agent" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const state = workspaceLayoutStore.getState();
|
|
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
|
expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toEqual([
|
|
"agent_child-agent",
|
|
]);
|
|
});
|
|
|
|
it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
const scriptTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "terminal",
|
|
terminalId: "term-script",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: ["term-script", "term-manual"],
|
|
standaloneTerminalIds: ["term-manual"],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
const tabs = contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey));
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(tabs.map((tab) => tab.tabId)).toEqual(["terminal_term-script", "terminal_term-manual"]);
|
|
expect(findPaneById(layout.root, layout.focusedPaneId)?.focusedTabId).toBe(scriptTabId);
|
|
});
|
|
|
|
it("reconcileTabs does not auto-open live non-standalone terminals", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
|
|
workspaceLayoutStore.getState().reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
knownTerminalIds: ["term-script"],
|
|
standaloneTerminalIds: [],
|
|
hasActivePendingDraftCreate: false,
|
|
});
|
|
|
|
expect(contentTabs(workspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey))).toEqual([]);
|
|
});
|
|
|
|
it("explicitly opening an agent tab clears hidden intent", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.hideAgent(workspaceKey, "agent-1");
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
|
|
const state = workspaceLayoutStore.getState();
|
|
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
|
expect(contentTabs(state.getWorkspaceTabs(workspaceKey)).map((tab) => tab.tabId)).toEqual([
|
|
"agent_agent-1",
|
|
]);
|
|
});
|
|
|
|
it("pinning an agent clears hidden intent", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.hideAgent(workspaceKey, "agent-1");
|
|
expect(workspaceLayoutStore.getState().hiddenAgentIdsByWorkspace[workspaceKey]).toBeDefined();
|
|
|
|
store.pinAgent(workspaceKey, "agent-1");
|
|
|
|
const state = workspaceLayoutStore.getState();
|
|
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
|
expect(Array.from(state.pinnedAgentIdsByWorkspace[workspaceKey] ?? [])).toEqual(["agent-1"]);
|
|
});
|
|
|
|
it("keeps an explicitly pinned archived agent before its detail is hydrated", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "archived-agent" },
|
|
intent: "reveal",
|
|
});
|
|
store.pinAgent(workspaceKey, "archived-agent");
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
standaloneTerminalIds: [],
|
|
});
|
|
|
|
expect(
|
|
store
|
|
.getWorkspaceTabs(workspaceKey)
|
|
.filter((tab) => tab.target.kind === "agent")
|
|
.map((tab) => tab.tabId),
|
|
).toEqual(["agent_archived-agent"]);
|
|
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
standaloneTerminalIds: [],
|
|
});
|
|
|
|
expect(
|
|
store
|
|
.getWorkspaceTabs(workspaceKey)
|
|
.filter((tab) => tab.target.kind === "agent")
|
|
.map((tab) => tab.tabId),
|
|
).toEqual(["agent_archived-agent"]);
|
|
|
|
store.resolvePendingAgent(workspaceKey, "archived-agent");
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
standaloneTerminalIds: [],
|
|
});
|
|
|
|
expect(
|
|
store
|
|
.getWorkspaceTabs(workspaceKey)
|
|
.filter((tab) => tab.target.kind === "agent")
|
|
.map((tab) => tab.tabId),
|
|
).toEqual([]);
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "archived-agent" },
|
|
intent: "reveal",
|
|
});
|
|
store.pinAgent(workspaceKey, "archived-agent");
|
|
store.reconcileTabs(workspaceKey, {
|
|
agentsHydrated: true,
|
|
terminalsHydrated: true,
|
|
activeAgentIds: [],
|
|
autoOpenAgentIds: [],
|
|
knownAgentIds: [],
|
|
standaloneTerminalIds: [],
|
|
});
|
|
|
|
expect(
|
|
store
|
|
.getWorkspaceTabs(workspaceKey)
|
|
.filter((tab) => tab.target.kind === "agent")
|
|
.map((tab) => tab.tabId),
|
|
).toEqual(["agent_archived-agent"]);
|
|
});
|
|
|
|
it("retargeting a tab to an agent clears hidden intent", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
|
|
store.hideAgent(workspaceKey, "agent-1");
|
|
const tabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/worktree/a.ts" },
|
|
intent: "reveal",
|
|
});
|
|
store.replaceTab(workspaceKey, tabId!, { kind: "agent", agentId: "agent-1" });
|
|
|
|
const state = workspaceLayoutStore.getState();
|
|
expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined();
|
|
});
|
|
|
|
it("closePane removes an emptied main pane and moves focus to the surviving pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const keptTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "kept" },
|
|
intent: "reveal",
|
|
});
|
|
useWorkspaceLayoutIds("split", "group-1");
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: keptTabId as string,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const strandedTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: {
|
|
kind: "agent",
|
|
agentId: "stranded",
|
|
},
|
|
intent: "reveal",
|
|
});
|
|
store.moveTabToPane(workspaceKey, strandedTabId as string, "main");
|
|
store.closeTab(workspaceKey, strandedTabId as string);
|
|
expect(
|
|
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, "main"),
|
|
).toBeTruthy();
|
|
|
|
store.closePane(workspaceKey, "main");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId]);
|
|
expect(layout.focusedPaneId).toBe(splitPaneId);
|
|
});
|
|
|
|
it("closePane drops the tabs the pane was still holding", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const keptTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "kept" },
|
|
intent: "reveal",
|
|
});
|
|
useWorkspaceLayoutIds("split", "group-1");
|
|
const splitPaneId = store.splitPane(workspaceKey, {
|
|
tabId: keptTabId as string,
|
|
targetPaneId: "main",
|
|
position: "right",
|
|
});
|
|
const doomedTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "doomed" },
|
|
intent: "reveal",
|
|
});
|
|
store.moveTabToPane(workspaceKey, doomedTabId as string, "main");
|
|
|
|
store.closePane(workspaceKey, "main");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(collectContentTabs(layout.root).map((tab) => tab.tabId)).toEqual([keptTabId]);
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual([splitPaneId]);
|
|
});
|
|
|
|
it("closePane refuses to close the last visible pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "only" },
|
|
intent: "reveal",
|
|
});
|
|
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
store.closePane(workspaceKey, "main");
|
|
|
|
expect(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]).toBe(before);
|
|
});
|
|
|
|
it("closePane hides Explorer instead of removing it, keeping its tabs", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "kept" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const filesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "files" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
});
|
|
|
|
store.closePane(workspaceKey, paneId);
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBe(true);
|
|
expect(collectAllTabs(layout.root).map((tab) => tab.tabId)).toContain(filesTabId);
|
|
expect(store.showExplorerSidebar(workspaceKey)).toBe(paneId);
|
|
expect(
|
|
findPaneById(workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey].root, paneId)
|
|
?.hidden,
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("closing Files leaves Changes available in Explorer", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "kept" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const filesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "files" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
}) as string;
|
|
|
|
store.closeTab(workspaceKey, filesTabId);
|
|
|
|
let layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
const hiddenPane = findPaneById(layout.root, paneId);
|
|
expect(hiddenPane?.hidden).toBeUndefined();
|
|
expect(hiddenPane?.tabIds).toHaveLength(1);
|
|
expect(
|
|
collectAllTabs(layout.root).find((tab) => tab.tabId === hiddenPane?.focusedTabId)?.target,
|
|
).toEqual({ kind: "changes_tree" });
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main", paneId]);
|
|
|
|
expect(store.showExplorerSidebar(workspaceKey)).toBe(paneId);
|
|
layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(hiddenPane?.tabIds);
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
});
|
|
|
|
it("closing one of several Explorer tabs leaves the sidebar visible", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "kept" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const filesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "files" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
}) as string;
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
});
|
|
|
|
store.closeTab(workspaceKey, filesTabId);
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
expect(findPaneById(layout.root, paneId)?.tabIds).toEqual(["changes_tree", "working_diff"]);
|
|
});
|
|
|
|
it("closing Files keeps Changes on screen without removing the final ordinary pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "only" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const filesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "files" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
}) as string;
|
|
store.closePane(workspaceKey, "main");
|
|
|
|
store.closeTab(workspaceKey, filesTabId);
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main", paneId]);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toHaveLength(1);
|
|
const finalPane = findPaneById(layout.root, paneId);
|
|
expect(finalPane?.tabIds).toHaveLength(1);
|
|
expect(
|
|
collectAllTabs(layout.root).find((tab) => tab.tabId === finalPane?.focusedTabId)?.target,
|
|
).toEqual({ kind: "changes_tree" });
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
});
|
|
|
|
it("an explicit pane-local open moves an existing tab into that pane", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const explorerSidebarPaneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const changesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId: explorerSidebarPaneId },
|
|
});
|
|
|
|
const reopened = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId: "main" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(reopened).toBe(changesTabId);
|
|
expect(collectAllTabs(layout.root).filter((tab) => tab.tabId === changesTabId)).toHaveLength(1);
|
|
expect(findPaneContainingTab(layout.root, changesTabId as string)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
expect(findPaneById(layout.root, "main")?.focusedTabId).toBe(changesTabId);
|
|
});
|
|
|
|
it("a preferred open focuses an existing tab where the user left it", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const explorerSidebarPaneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const fileTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts" },
|
|
intent: "reveal",
|
|
placement: { mode: "prefer", paneId: explorerSidebarPaneId },
|
|
}) as string;
|
|
store.moveTabToPane(workspaceKey, fileTabId, "main");
|
|
|
|
const reopened = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "file", path: "/repo/a.ts" },
|
|
intent: "reveal",
|
|
placement: { mode: "prefer", paneId: explorerSidebarPaneId },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(reopened).toBe(fileTabId);
|
|
expect(findPaneContainingTab(layout.root, fileTabId)?.id).toBe("main");
|
|
expect(layout.focusedPaneId).toBe("main");
|
|
});
|
|
it("leaves the default tabs in Explorer when content is claimed by main", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "agent-1" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const changesTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId },
|
|
}) as string;
|
|
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "working_diff" },
|
|
intent: "reveal",
|
|
placement: { mode: "pane", paneId: "main" },
|
|
});
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(findPaneContainingTab(layout.root, changesTabId)?.id).toBe("main");
|
|
const sidePane = findPaneById(layout.root, paneId);
|
|
expect(sidePane?.tabIds).toEqual(["files", "changes_tree"]);
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
});
|
|
it("keeps the final ordinary pane when Explorer is visible", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "only" },
|
|
intent: "reveal",
|
|
});
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
store.closePane(workspaceKey, "main");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(layout).toBe(before);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId]);
|
|
expect(findPaneById(layout.root, paneId)?.hidden).toBeUndefined();
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main", paneId]);
|
|
});
|
|
|
|
it("canDismissPaneInLayout treats Explorer separately from ordinary panes", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "only" },
|
|
intent: "reveal",
|
|
});
|
|
const loadedFinal = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
// Only `main` is on screen; Explorer is present but hidden.
|
|
expect(collectAllPanes(loadedFinal.root).map((pane) => pane.id)).toEqual(["main"]);
|
|
expect(canDismissPaneInLayout(loadedFinal, "main", "explorer")).toBe(false);
|
|
expect(canDismissPaneInLayout(loadedFinal, "explorer", "explorer")).toBe(false);
|
|
|
|
const paneId = store.showExplorerSidebar(workspaceKey) as string;
|
|
const bothVisible = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(canDismissPaneInLayout(bothVisible, "main", paneId)).toBe(false);
|
|
expect(canDismissPaneInLayout(bothVisible, paneId, paneId)).toBe(true);
|
|
});
|
|
|
|
it("closePane on a loaded final pane keeps the pane and every tab in it", () => {
|
|
const workspaceKey = createWorkspaceKey();
|
|
const store = workspaceLayoutStore.getState();
|
|
const agentTabId = store.openTab({
|
|
workspaceKey: workspaceKey,
|
|
target: { kind: "agent", agentId: "only" },
|
|
intent: "reveal",
|
|
});
|
|
const before = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
|
|
store.closePane(workspaceKey, "main");
|
|
|
|
const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey];
|
|
expect(layout).toBe(before);
|
|
expect(findPaneById(layout.root, "main")?.tabIds).toEqual([agentTabId]);
|
|
expect(collectAllPanes(layout.root).map((pane) => pane.id)).toEqual(["main"]);
|
|
});
|
|
});
|