Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 34 additions & 7 deletions src/browser/components/RightSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1151,21 +1151,48 @@ const RightSidebarComponent: React.FC<RightSidebarProps> = ({
// Check if the file is already open
const allTabs = collectAllTabs(layout.root);
if (allTabs.includes(fileTabType)) {
// File already open - just select it
const tabsetId = collectAllTabsWithTabset(layout.root).find(
// File already open — select it and refresh the parent to the current
// active tab so closing always returns to the most recent origin.
// Only update parentTab when the origin is in the same tabset as the
// file tab; a cross-tabset parent can never be activated on close.
const fileTabsetId = collectAllTabsWithTabset(layout.root).find(
(t) => t.tab === fileTabType
)?.tabsetId;
if (tabsetId) {
if (fileTabsetId) {
setLayout((prev) => {
const withFocus = setFocusedTabset(prev, tabsetId);
return selectTabInTabset(withFocus, tabsetId, fileTabType);
const focused = findTabset(prev.root, prev.focusedTabsetId);
const parentTabId = focused?.type === "tabset" ? focused.activeTab : undefined;
const withFocus = setFocusedTabset(prev, fileTabsetId);
const next = selectTabInTabset(withFocus, fileTabsetId, fileTabType);
// Only record the parent when the origin tab lives in the same
// tabset as the file — cross-tabset parents would never activate.
const sameTabset = prev.focusedTabsetId === fileTabsetId;
if (parentTabId && parentTabId !== fileTabType && sameTabset) {
return {
...next,
parentTab: { ...next.parentTab, [fileTabType]: parentTabId },
};
}
return next;
});
}
return;
}

// Add new file tab to the focused tabset
setLayout((prev) => addTabToFocusedTabset(prev, fileTabType));
// Add new file tab to the focused tabset, recording the currently active
// tab as its parent so closing the file returns to where the user was.
setLayout((prev) => {
const focused = findTabset(prev.root, prev.focusedTabsetId);
const parentTabId = focused?.type === "tabset" ? focused.activeTab : undefined;
const next = addTabToFocusedTabset(prev, fileTabType);
if (parentTabId) {
return {
...next,
parentTab: { ...next.parentTab, [fileTabType]: parentTabId },
};
}
return next;
});
},
[layout.root, setLayout]
);
Expand Down
144 changes: 144 additions & 0 deletions src/browser/utils/rightSidebarLayout.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import {
closeSplit,
dockTabToEdge,
getDefaultRightSidebarLayoutState,
isRightSidebarLayoutState,
moveTabToTabset,
removeTabEverywhere,
reorderTabInTabset,
selectTabInFocusedTabset,
splitFocusedTabset,
Expand Down Expand Up @@ -259,3 +261,145 @@ test("closeSplit keeps the specified child", () => {
expect(s2.root.id).toBe("tabset-2");
expect(s2.root.tabs).toEqual(["review"]);
});

// --- Parent-tab tracking tests ---

test("removeTabEverywhere activates parent tab when parentTab entry exists", () => {
const s: RightSidebarLayoutState = {
version: 1,
nextId: 2,
focusedTabsetId: "tabset-1",
root: {
type: "tabset",
id: "tabset-1",
tabs: ["costs", "review", "file:src/foo.ts"],
activeTab: "file:src/foo.ts",
},
parentTab: { "file:src/foo.ts": "review" },
};

const result = removeTabEverywhere(s, "file:src/foo.ts");
if (result.root.type !== "tabset") throw new Error("expected tabset");
// Should activate "review" (the parent) instead of positional adjacency
expect(result.root.activeTab).toBe("review");
expect(result.root.tabs).toEqual(["costs", "review"]);
// parentTab entry should be cleaned up
expect(result.parentTab).toBeUndefined();
});

test("removeTabEverywhere falls back to positional adjacency when parent is not in same tabset", () => {
const s: RightSidebarLayoutState = {
version: 1,
nextId: 3,
focusedTabsetId: "tabset-1",
root: {
type: "split",
id: "split-1",
direction: "vertical",
sizes: [50, 50],
children: [
{
type: "tabset",
id: "tabset-1",
tabs: ["costs", "file:src/foo.ts"],
activeTab: "file:src/foo.ts",
},
{ type: "tabset", id: "tabset-2", tabs: ["review"], activeTab: "review" },
],
},
// Parent "review" is in tabset-2, but the file tab is in tabset-1
parentTab: { "file:src/foo.ts": "review" },
};

const result = removeTabEverywhere(s, "file:src/foo.ts");
// Split remains because tabset-1 still has "costs"
expect(result.root.type).toBe("split");
if (result.root.type !== "split") throw new Error("expected split");
const left = result.root.children[0];
if (left.type !== "tabset") throw new Error("expected tabset");
// Since "review" is not in tabset-1, fallback to positional adjacency → "costs"
expect(left.activeTab).toBe("costs");
expect(left.tabs).toEqual(["costs"]);
});

test("removeTabEverywhere cleans up parentTab entry for the removed tab", () => {
const s: RightSidebarLayoutState = {
version: 1,
nextId: 2,
focusedTabsetId: "tabset-1",
root: {
type: "tabset",
id: "tabset-1",
tabs: ["costs", "review", "file:src/a.ts", "file:src/b.ts"],
activeTab: "file:src/a.ts",
},
parentTab: {
"file:src/a.ts": "review",
"file:src/b.ts": "costs",
},
};

const result = removeTabEverywhere(s, "file:src/a.ts");
// Only the entry for b.ts should remain
expect(result.parentTab).toEqual({ "file:src/b.ts": "costs" });
});

test("removeTabEverywhere cleans up parentTab entries pointing to a removed parent tab", () => {
const s: RightSidebarLayoutState = {
version: 1,
nextId: 2,
focusedTabsetId: "tabset-1",
root: {
type: "tabset",
id: "tabset-1",
tabs: ["costs", "review", "file:src/a.ts", "file:src/b.ts"],
activeTab: "costs",
},
parentTab: {
"file:src/a.ts": "file:src/b.ts",
"file:src/b.ts": "review",
},
};

// Remove file:src/b.ts — the entry for file:src/a.ts should also be cleaned
// because its parent (file:src/b.ts) was the tab that was removed.
const result = removeTabEverywhere(s, "file:src/b.ts");
expect(result.parentTab).toBeUndefined();
});

test("persisted layouts without parentTab field still validate correctly (backward compat)", () => {
const raw = {
version: 1,
nextId: 2,
focusedTabsetId: "tabset-1",
root: { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" },
};
expect(isRightSidebarLayoutState(raw)).toBe(true);
});

test("persisted layouts with valid parentTab field validate correctly", () => {
const raw = {
version: 1,
nextId: 2,
focusedTabsetId: "tabset-1",
root: {
type: "tabset",
id: "tabset-1",
tabs: ["costs", "file:src/foo.ts"],
activeTab: "costs",
},
parentTab: { "file:src/foo.ts": "costs" },
};
expect(isRightSidebarLayoutState(raw)).toBe(true);
});

test("persisted layouts with invalid parentTab field are rejected", () => {
const raw = {
version: 1,
nextId: 2,
focusedTabsetId: "tabset-1",
root: { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" },
parentTab: { "file:src/foo.ts": 42 }, // value is not a string
};
expect(isRightSidebarLayoutState(raw)).toBe(false);
});
45 changes: 38 additions & 7 deletions src/browser/utils/rightSidebarLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,24 @@ export function isRightSidebarLayoutState(value: unknown): value is RightSidebar
if (typeof v.nextId !== "number") return false;
if (typeof v.focusedTabsetId !== "string") return false;
if (!isLayoutNode(v.root)) return false;
// parentTab is optional; if present, must be a Record<string, string>
if (v.parentTab != null) {
if (typeof v.parentTab !== "object" || Array.isArray(v.parentTab)) return false;
for (const val of Object.values(v.parentTab as Record<string, unknown>)) {
if (typeof val !== "string") return false;
}
}
return findTabset(v.root, v.focusedTabsetId) !== null;
}
export interface RightSidebarLayoutState {
version: 1;
nextId: number;
focusedTabsetId: string;
root: RightSidebarLayoutNode;
// Maps file/terminal tabs to the tab that opened them.
// When a tab with a parentTab entry is closed, the parent is activated
// instead of falling back to positional adjacency.
parentTab?: Record<string, string>;
}

export function getDefaultRightSidebarLayoutState(activeTab: TabType): RightSidebarLayoutState {
Expand Down Expand Up @@ -137,18 +148,23 @@ function allocId(state: RightSidebarLayoutState, prefix: "tabset" | "split") {

function removeTabFromNode(
node: RightSidebarLayoutNode,
tab: TabType
tab: TabType,
preferredActiveTab?: string
): RightSidebarLayoutNode | null {
if (node.type === "tabset") {
const oldIndex = node.tabs.indexOf(tab);
const tabs = node.tabs.filter((t) => t !== tab);
if (tabs.length === 0) return null;

// When removing the active tab, focus next tab (or previous if no next)
// When removing the active tab, prefer the parent tab if it exists in this tabset
let activeTab = node.activeTab;
if (node.activeTab === tab) {
// Prefer next tab, fall back to previous
activeTab = tabs[Math.min(oldIndex, tabs.length - 1)];
if (preferredActiveTab && tabs.includes(preferredActiveTab as TabType)) {
activeTab = preferredActiveTab as TabType;
} else {
// Fallback: positional adjacency
activeTab = tabs[Math.min(oldIndex, tabs.length - 1)];
}
}
return {
...node,
Expand All @@ -157,8 +173,8 @@ function removeTabFromNode(
};
}

const left = removeTabFromNode(node.children[0], tab);
const right = removeTabFromNode(node.children[1], tab);
const left = removeTabFromNode(node.children[0], tab, preferredActiveTab);
const right = removeTabFromNode(node.children[1], tab, preferredActiveTab);

if (!left && !right) {
return null;
Expand All @@ -178,7 +194,10 @@ export function removeTabEverywhere(
state: RightSidebarLayoutState,
tab: TabType
): RightSidebarLayoutState {
const nextRoot = removeTabFromNode(state.root, tab);
// Look up parent tab before removal so we can activate it
const parentTab = state.parentTab?.[tab];

const nextRoot = removeTabFromNode(state.root, tab, parentTab);
if (!nextRoot) {
return getDefaultRightSidebarLayoutState("costs");
}
Expand All @@ -188,10 +207,22 @@ export function removeTabEverywhere(
? state.focusedTabsetId
: (findFirstTabsetId(nextRoot) ?? "tabset-1");

// Clean up parentTab entries:
// 1. Remove the entry for the closed tab itself
// 2. Remove any entries whose parent was the closed tab (orphaned children)
let nextParentTab = state.parentTab;
if (nextParentTab) {
const cleaned = Object.fromEntries(
Object.entries(nextParentTab).filter(([key, parent]) => key !== tab && parent !== tab)
);
nextParentTab = Object.keys(cleaned).length > 0 ? cleaned : undefined;
}

return {
...state,
root: nextRoot,
focusedTabsetId,
parentTab: nextParentTab,
};
}
function updateNode(
Expand Down
Loading