Skip to content
Merged
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
199 changes: 199 additions & 0 deletions apps/desktop/src/features/editor/EditorPaneBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,16 @@ function createChatSession(sessionId: string, title: string) {
};
}

function createNoteTab(id: string, title: string) {
return {
id,
kind: "note" as const,
noteId: `notes/${id}`,
title,
content: title,
};
}

describe("EditorPaneBar", () => {
beforeEach(() => {
if (typeof window.PointerEvent === "undefined") {
Expand Down Expand Up @@ -219,6 +229,195 @@ describe("EditorPaneBar", () => {
).not.toBeInTheDocument();
});

it("closes other tabs only in the pane that opened the tab context menu", async () => {
const user = userEvent.setup();
useEditorStore.getState().hydrateWorkspace(
[
{
id: "primary",
tabs: [
createNoteTab("tab-a", "Alpha"),
createNoteTab("tab-c", "Gamma"),
createNoteTab("tab-d", "Delta"),
],
activeTabId: "tab-a",
},
{
id: "secondary",
tabs: [
createNoteTab("tab-b", "Beta"),
createNoteTab("tab-e", "Epsilon"),
],
activeTabId: "tab-b",
},
],
"primary",
);

renderComponent(<EditorPaneBar paneId="primary" isFocused />);

const tabButton = document.querySelector(
'[data-pane-tab-id="tab-c"]',
) as HTMLElement | null;
expect(tabButton).not.toBeNull();
fireEvent.contextMenu(tabButton!);
await user.click(
await screen.findByRole("button", { name: "Close Others" }),
);

await waitFor(() => {
expect(
useEditorStore
.getState()
.panes.find((pane) => pane.id === "primary")
?.tabs.map((tab) => tab.id),
).toEqual(["tab-c"]);
});
expect(
useEditorStore
.getState()
.panes.find((pane) => pane.id === "secondary")
?.tabs.map((tab) => tab.id),
).toEqual(["tab-b", "tab-e"]);
});

it("closes tabs to the right only in the pane that opened the tab context menu", async () => {
const user = userEvent.setup();
useEditorStore.getState().hydrateWorkspace(
[
{
id: "primary",
tabs: [
createNoteTab("tab-a", "Alpha"),
createNoteTab("tab-c", "Gamma"),
createNoteTab("tab-d", "Delta"),
],
activeTabId: "tab-a",
},
{
id: "secondary",
tabs: [
createNoteTab("tab-b", "Beta"),
createNoteTab("tab-e", "Epsilon"),
],
activeTabId: "tab-b",
},
],
"primary",
);

renderComponent(<EditorPaneBar paneId="primary" isFocused />);

const tabButton = document.querySelector(
'[data-pane-tab-id="tab-c"]',
) as HTMLElement | null;
expect(tabButton).not.toBeNull();
fireEvent.contextMenu(tabButton!);
await user.click(
await screen.findByRole("button", {
name: "Close Tabs to the Right",
}),
);

await waitFor(() => {
expect(
useEditorStore
.getState()
.panes.find((pane) => pane.id === "primary")
?.tabs.map((tab) => tab.id),
).toEqual(["tab-a", "tab-c"]);
});
expect(
useEditorStore
.getState()
.panes.find((pane) => pane.id === "secondary")
?.tabs.map((tab) => tab.id),
).toEqual(["tab-b", "tab-e"]);
});

it("keeps all tabs open when close others confirmation is cancelled", async () => {
const user = userEvent.setup();
useEditorStore.getState().hydrateWorkspace(
[
{
id: "primary",
tabs: [
createNoteTab("tab-a", "Alpha"),
{
id: "tab-chat",
kind: "ai-chat",
sessionId: "session-busy",
title: "Chat",
},
createNoteTab("tab-c", "Gamma"),
],
activeTabId: "tab-a",
},
{
id: "secondary",
tabs: [createNoteTab("tab-b", "Beta")],
activeTabId: "tab-b",
},
],
"primary",
);
useChatStore.setState({
sessionsById: {
"session-busy": {
...createChatSession("session-busy", "Busy agent"),
status: "streaming",
},
},
});
vi.mocked(confirm).mockResolvedValue(false);

renderComponent(<EditorPaneBar paneId="primary" isFocused />);

const tabButton = document.querySelector(
'[data-pane-tab-id="tab-a"]',
) as HTMLElement | null;
expect(tabButton).not.toBeNull();
fireEvent.contextMenu(tabButton!);
await user.click(
await screen.findByRole("button", { name: "Close Others" }),
);

await waitFor(() => {
expect(confirm).toHaveBeenCalledTimes(1);
});
expect(
useEditorStore
.getState()
.panes.find((pane) => pane.id === "primary")
?.tabs.map((tab) => tab.id),
).toEqual(["tab-a", "tab-chat", "tab-c"]);
expect(
useEditorStore
.getState()
.panes.find((pane) => pane.id === "secondary")
?.tabs.map((tab) => tab.id),
).toEqual(["tab-b"]);
});

it("disables pane tab bulk-close actions when they do not apply", async () => {
renderComponent(<EditorPaneBar paneId="primary" isFocused />);

const tabButton = document.querySelector(
'[data-pane-tab-id="tab-a"]',
) as HTMLElement | null;
expect(tabButton).not.toBeNull();
fireEvent.contextMenu(tabButton!);

expect(
await screen.findByRole("button", { name: "Close Others" }),
).toBeDisabled();
expect(
await screen.findByRole("button", {
name: "Close Tabs to the Right",
}),
).toBeDisabled();
});

it("pins a tab per pane and renders it as icon-only chrome", async () => {
const user = userEvent.setup();
useEditorStore.getState().hydrateWorkspace(
Expand Down
88 changes: 88 additions & 0 deletions apps/desktop/src/features/editor/EditorPaneBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,75 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) {
},
[closeTab],
);
const closeTabIdsWithProtection = useCallback(async (tabIds: string[]) => {
const currentTabs = selectEditorWorkspaceTabs(useEditorStore.getState());
const tabsToClose = tabIds
.map(
(tabId) =>
currentTabs.find((candidate) => candidate.id === tabId) ??
null,
)
.filter((tab): tab is (typeof currentTabs)[number] => tab !== null);

if (tabsToClose.length === 0) {
return;
}

const affected = findActiveSessionsAffectedByClose(
tabsToClose,
useChatStore.getState().sessionsById,
);
const confirmationMessage =
getCloseTabsConfirmationMessage(affected);
if (
confirmationMessage !== null &&
!(await confirm(confirmationMessage))
) {
return;
}

for (const tabId of tabIds) {
useEditorStore.getState().closeTab(tabId, {
reason: "bulk-user",
});
}
}, []);
const closeOtherTabsInPane = useCallback(
async (tabId: string) => {
const currentPane = selectEditorPaneState(
useEditorStore.getState(),
paneId,
);
const tabIds = currentPane.tabs
.filter((tab) => tab.id !== tabId)
.map((tab) => tab.id);

await closeTabIdsWithProtection(tabIds);
},
[closeTabIdsWithProtection, paneId],
);
const closeTabsToTheRightInPane = useCallback(
async (tabId: string) => {
const currentPane = selectEditorPaneState(
useEditorStore.getState(),
paneId,
);
const tabIndex = currentPane.tabs.findIndex(
(tab) => tab.id === tabId,
);
if (tabIndex === -1) {
return;
}

const tabIds = currentPane.tabs
.slice(tabIndex + 1)
.map((tab) => tab.id)
.reverse();

await closeTabIdsWithProtection(tabIds);
},
[closeTabIdsWithProtection, paneId],
);

return (
<>
Expand Down Expand Up @@ -857,6 +926,9 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) {
const targetTabPinned = pinnedTabIdSet.has(
targetTab.id,
);
const targetTabIndex = pane.tabs.findIndex(
(candidate) => candidate.id === targetTab.id,
);

const entries: ContextMenuEntry[] = [
{
Expand All @@ -869,6 +941,22 @@ export function EditorPaneBar({ paneId, isFocused }: EditorPaneBarProps) {
action: () =>
void requestCloseTab(targetTab.id),
},
{
label: "Close Others",
disabled: pane.tabs.length <= 1,
action: () =>
void closeOtherTabsInPane(targetTab.id),
},
{
label: "Close Tabs to the Right",
disabled:
targetTabIndex === -1 ||
targetTabIndex >= pane.tabs.length - 1,
action: () =>
void closeTabsToTheRightInPane(
targetTab.id,
),
},
];

if (!targetTabPinned && isChatTab(targetTab)) {
Expand Down
Loading