From 291fa9affebe389ec80726d3f59daf1ec2430a00 Mon Sep 17 00:00:00 2001 From: Jaw <53564781+jaw52@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:57:42 +0800 Subject: [PATCH] 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 <53564781+jaw52@users.noreply.github.com> --- docs/workspaces-and-window-groups.md | 2 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/window_group.rs | 14 +++++ src-tauri/src/window_groups.rs | 77 ++++++++++++++++++++++++++ src-tauri/src/windows.rs | 6 +- src/bindings.ts | 4 ++ src/features/app/closeGuard.ts | 3 +- src/features/app/windows.ts | 37 ++++++++++++- src/platform/locales/text/zh.json | 3 + src/workbench/WindowGroupTabs.test.tsx | 27 +++++++++ src/workbench/WindowGroupTabs.tsx | 13 +++++ 11 files changed, 184 insertions(+), 4 deletions(-) diff --git a/docs/workspaces-and-window-groups.md b/docs/workspaces-and-window-groups.md index be9cf6a2..db86800e 100644 --- a/docs/workspaces-and-window-groups.md +++ b/docs/workspaces-and-window-groups.md @@ -28,7 +28,7 @@ The specialized Maven/Gradle tool-window model still describes the workspace dir Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux. -Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. +Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Right-click a tab for **Close Other Tabs**, **Close Tabs to the Left** and **Close Tabs to the Right**, which close the projects one at a time and keep the clicked tab; cancelling one project's unsaved-changes prompt leaves it and the rest open. The same commands are in the Command Palette and can be assigned shortcuts. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members. diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 4a8f3c06..0a410b34 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -77,6 +77,8 @@ pub fn builder() -> tauri_specta::Builder { window_group::window_group_select, window_group::window_group_detach, window_group::window_group_close, + window_group::window_group_close_tabs, + window_group::window_group_cancel_close, window_group::window_group_reorder, window_group::window_group_restore, app::light_edit_open, diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs index 210797dc..f6d4d29a 100644 --- a/src-tauri/src/commands/window_group.rs +++ b/src-tauri/src/commands/window_group.rs @@ -41,6 +41,20 @@ pub fn window_group_close(window: WebviewWindow, label: String) -> IdeResult<()> .map_err(|e| IdeError::internal(e.to_string())) } +/// Closes several project tabs one by one (each asks about its own unsaved changes), then selects `keep`. +#[tauri::command] +#[specta::specta] +pub fn window_group_close_tabs(window: WebviewWindow, keep: String, labels: Vec) -> IdeResult<()> { + groups::close_tabs(window.app_handle(), window.label(), &keep, labels) +} + +/// This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. +#[tauri::command] +#[specta::specta] +pub fn window_group_cancel_close(window: WebviewWindow) { + groups::cancel_close(window.app_handle(), window.label()); +} + #[tauri::command] #[specta::specta] pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index fef00fa1..d8c47c2b 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -6,6 +6,7 @@ use crate::{ use ide_core::{IdeError, IdeEvent, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; +use std::collections::VecDeque; use std::path::PathBuf; use std::sync::atomic::Ordering; use tauri::{AppHandle, Manager, WebviewWindow}; @@ -170,6 +171,82 @@ pub fn destroyed(app: &AppHandle, caller: &str) { }); } changed(app); + let advance = state.tab_closes.lock().as_mut().is_some_and(|batch| { + let closing = batch.pending.front().is_some_and(|l| l == caller); + if closing { + batch.pending.pop_front(); + } + closing + }); + if advance { + close_next(app); + } +} + +/// Tabs left to close; the first one is closing now. +pub struct TabCloses { + keep: String, + pending: VecDeque, +} + +/// Closes `labels` one at a time so each project's unsaved-changes prompt is shown in turn, then selects `keep`. +pub fn close_tabs(app: &AppHandle, caller: &str, keep: &str, labels: Vec) -> IdeResult<()> { + let state = app.state::(); + let members = state + .groups + .lock() + .group(caller) + .map(|g| g.members.clone()) + .ok_or_else(|| IdeError::not_found("This window is not grouped"))?; + if !members.iter().any(|m| m == keep) { + return Err(IdeError::invalid("The window is not in this group")); + } + let pending = members.into_iter().filter(|m| m != keep && labels.contains(m)).collect(); + *state.tab_closes.lock() = Some(TabCloses { keep: keep.to_owned(), pending }); + close_next(app); + Ok(()) +} + +/// The closing tab's project vetoed (its unsaved-changes prompt was cancelled): leave the rest open. +pub fn cancel_close(app: &AppHandle, caller: &str) { + let state = app.state::(); + let mut batch = state.tab_closes.lock(); + if batch.as_ref().is_some_and(|b| b.pending.front().is_some_and(|l| l == caller)) { + *batch = None; + } +} + +fn close_next(app: &AppHandle) { + let state = app.state::(); + loop { + let next = { + let mut batch = state.tab_closes.lock(); + let Some(b) = batch.as_mut() else { return }; + match b.pending.front() { + Some(label) => Ok(label.clone()), + None => Err(batch.take().unwrap().keep), + } + }; + match next { + Ok(label) => { + if let Some(window) = app.get_webview_window(&label) { + windows::focus(&window); + if window.close().is_ok() { + return; + } + } + if let Some(b) = state.tab_closes.lock().as_mut() { + b.pending.pop_front(); + } + } + Err(keep) => { + if let Some(window) = app.get_webview_window(&keep) { + windows::focus(&window); + } + return; + } + } + } } #[derive(Serialize, Deserialize)] diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index b9d14c12..d6bc2051 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -67,6 +67,8 @@ pub struct WindowsState { last_focused: Mutex>, /// Set by Quit while windows close one by one. quitting: AtomicBool, + /// Project tabs closing one by one (Close Other Tabs and friends). + pub tab_closes: Mutex>, /// Relaunch once the quit completes (after installing an update). restart: AtomicBool, } @@ -109,7 +111,9 @@ impl WindowsState { pub fn set_quitting(&self, on: bool) { self.quitting.store(on, Ordering::SeqCst); - if !on { + if on { + *self.tab_closes.lock() = None; + } else { self.restart.store(false, Ordering::SeqCst); } } diff --git a/src/bindings.ts b/src/bindings.ts index 7f3e69b7..bb162c12 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -23,6 +23,10 @@ export const commands = { windowGroupSelect: (label: string) => typedError(__TAURI_INVOKE("window_group_select", { label })), windowGroupDetach: () => typedError(__TAURI_INVOKE("window_group_detach")), windowGroupClose: (label: string) => typedError(__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(__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("window_group_cancel_close"), windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 4194a168..2eedcb92 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -125,10 +125,11 @@ async function onCloseRequested(deps: CloseDeps): Promise { }, 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 { diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 3b39eaf4..0125e469 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -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 { } } +/** 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", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index f965fcad..d7b36d69 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -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 服务器", diff --git a/src/workbench/WindowGroupTabs.test.tsx b/src/workbench/WindowGroupTabs.test.tsx index 6db78e67..4f83e5cf 100644 --- a/src/workbench/WindowGroupTabs.test.tsx +++ b/src/workbench/WindowGroupTabs.test.tsx @@ -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(); 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(); + 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); +}); diff --git a/src/workbench/WindowGroupTabs.tsx b/src/workbench/WindowGroupTabs.tsx index f10f28e1..49e873e3 100644 --- a/src/workbench/WindowGroupTabs.tsx +++ b/src/workbench/WindowGroupTabs.tsx @@ -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), + }, ]); }} >