feat(window): close other / left / right project tabs from the window-group tab menu (#965)

Right-clicking a project tab in a merged window group offers Close Other Tabs, Close Tabs to the Left and Close Tabs to the Right (left/right disabled at the ends); the same commands are in the Command Palette and can take shortcuts.

The backend closes the projects one at a time, focusing each so its own unsaved-changes prompt is visible, as Quit does. Cancelling a prompt stops the batch and leaves that project and the rest open; the clicked tab stays and is selected when the batch finishes. Starting Quit clears a pending batch.

Closes #942.

Co-authored-by: jaw52 <[email protected]>
Цей коміт міститься в:
Jaw authored and GitHub committed 2026-11-03 17:57:42 +08:00
1 джерело ecdd43a55a
коміт 291fa9affe
11 files changed
+184 -4

No files matched your search

+4
Переглянути файл
@@ -23,6 +23,10 @@ export const commands = {
windowGroupSelect: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_select", { label })),
windowGroupDetach: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_detach")),
windowGroupClose: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close", { label })),
/** Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. */
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
/**
+2 -1
Переглянути файл
@@ -125,10 +125,11 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
},
deps,
);
if (!ok && ctx.quitting) await commands.appCancelQuit();
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
return ok;
} catch (e) {
maybeEditorService()?.cancelClose?.();
void commands.windowGroupCancelClose().catch(() => {});
deps.onSaveFailed(e);
return false;
} finally {
+36 -1
Переглянути файл
@@ -23,7 +23,7 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { holdSplash } from "@/platform/splash";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
import { chooseProjectWindow } from "@/workbench/projectWindow";
import { cycleWindowTab } from "@/platform/windowGroups";
import { cycleWindowTab, useWindowGroup } from "@/platform/windowGroups";
import { showWorkspaceDialog } from "./WorkspaceDialog";
export const LAUNCH_EVENT = "openidea://launch";
@@ -222,6 +222,20 @@ export async function consumeLaunch(): Promise<void> {
}
}
/** Closes the project tabs on `side` of the `arg` tab (default: the current one), keeping that tab. */
function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] {
return async ({ arg }) => {
const tabs = useWindowGroup.getState().tabs;
const keep = typeof arg === "string" ? arg : tabs.find((tab) => tab.current)?.label;
const index = tabs.findIndex((tab) => tab.label === keep);
if (!keep || index < 0) return;
const labels = tabs
.filter((_, i) => (side === "left" ? i < index : side === "right" ? i > index : i !== index))
.map((tab) => tab.label);
if (labels.length) await call(commands.windowGroupCloseTabs(keep, labels));
};
}
export function windowActions(): Action[] {
return [
{
@@ -302,6 +316,27 @@ export function windowActions(): Action[] {
if (typeof arg === "string") await call(commands.windowGroupClose(arg));
},
},
{
id: "window.closeOtherTabs",
title: "Close Other Project Tabs",
category: t("app.category.window"),
when: "windowGrouped",
run: closeProjectTabs("others"),
},
{
id: "window.closeTabsToLeft",
title: "Close Project Tabs to the Left",
category: t("app.category.window"),
when: "windowGrouped",
run: closeProjectTabs("left"),
},
{
id: "window.closeTabsToRight",
title: "Close Project Tabs to the Right",
category: t("app.category.window"),
when: "windowGrouped",
run: closeProjectTabs("right"),
},
{
id: "window.reorderTabs",
title: "Reorder Project Tabs",
+3
Переглянути файл
@@ -5008,6 +5008,9 @@
"Window group": "窗口组",
"Select Project Tab": "选择项目标签页",
"Close Project Tab": "关闭项目标签页",
"Close Other Project Tabs": "关闭其他项目标签页",
"Close Project Tabs to the Left": "关闭左侧项目标签页",
"Close Project Tabs to the Right": "关闭右侧项目标签页",
"Reorder Project Tabs": "重新排列项目标签页",
"Close {name}": "关闭 {name}",
"Tomcat Server": "Tomcat 服务器",
+27
Переглянути файл
@@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { executeAction } from "@/platform/actions";
import { useWindowGroup } from "@/platform/windowGroups";
import { useContextMenuStore } from "@/ui/Menu";
import { WindowGroupTabs } from "./WindowGroupTabs";
vi.mock("@/platform/actions", async (importOriginal) => ({
@@ -37,3 +38,29 @@ it("removes the tab row when the group dissolves", () => {
render(<WindowGroupTabs />);
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
});
it("offers closing the other tabs and those on either side of the clicked tab", () => {
useWindowGroup.setState({
tabs: [
{ label: "main", name: "Frontend", root: "/front", current: true },
{ label: "project-1", name: "Backend", root: "/back", current: false },
{ label: "project-2", name: "Docs", root: "/docs", current: false },
],
});
render(<WindowGroupTabs />);
const menuFor = (name: string) => {
fireEvent.contextMenu(screen.getByRole("tab", { name }));
const items = useContextMenuStore.getState().menu!.items;
return (label: string) =>
items.find((i) => "label" in i && i.label === label) as { disabled?: boolean; run?: () => unknown };
};
const first = menuFor("Frontend");
expect(first("Close Tabs to the Left").disabled).toBe(true);
expect(first("Close Tabs to the Right").disabled).toBe(false);
const middle = menuFor("Backend");
void middle("Close Other Tabs").run?.();
expect(executeAction).toHaveBeenLastCalledWith("window.closeOtherTabs", "project-1");
void middle("Close Tabs to the Left").run?.();
expect(executeAction).toHaveBeenLastCalledWith("window.closeTabsToLeft", "project-1");
expect(menuFor("Docs")("Close Tabs to the Right").disabled).toBe(true);
});
+13
Переглянути файл
@@ -61,13 +61,26 @@ export function WindowGroupTabs() {
}}
onContextMenu={(e) => {
e.preventDefault();
const index = tabs.indexOf(tab);
showContextMenu(e, [
{
label: "Move Tab to New Window",
disabled: !tab.current,
run: () => executeAction("window.detachTab"),
},
{ kind: "separator" },
{ label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) },
{ label: "Close Other Tabs", run: () => executeAction("window.closeOtherTabs", tab.label) },
{
label: "Close Tabs to the Left",
disabled: index <= 0,
run: () => executeAction("window.closeTabsToLeft", tab.label),
},
{
label: "Close Tabs to the Right",
disabled: index >= tabs.length - 1,
run: () => executeAction("window.closeTabsToRight", tab.label),
},
]);
}}
>