mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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>
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -77,6 +77,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<String>) -> 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<String>) -> IdeResult<()> {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
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::<WindowsState>();
|
||||
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::<WindowsState>();
|
||||
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)]
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct WindowsState {
|
||||
last_focused: Mutex<Option<String>>,
|
||||
/// 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<Option<crate::window_groups::TabCloses>>,
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 服务器",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
]);
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user