From a7eb0b134c40ce93a809c145bde1d2cfb6df49e2 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:34:42 +0100 Subject: [PATCH 1/3] fix(linux-workspace): make the tab menu items work and add Close All Tabs The tab action group sat on the AdwTabView, but the menu's popover belongs to the tab bar and the overview, siblings of the view, so every item showed as insensitive. The group now lives on the shell. The menu gains Close All Tabs and dims items that would close nothing, as on macOS. --- .../app/src/ui/workspace/tests/sessions.rs | 67 +++++++++++++++++++ .../app/src/ui/workspace/workspace_shell.rs | 36 +++++++--- .../src/ui/workspace/workspace_shell_imp.rs | 4 +- 3 files changed, 97 insertions(+), 10 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/tests/sessions.rs b/linux/crates/app/src/ui/workspace/tests/sessions.rs index 046eb2f954..d7f1b255da 100644 --- a/linux/crates/app/src/ui/workspace/tests/sessions.rs +++ b/linux/crates/app/src/ui/workspace/tests/sessions.rs @@ -154,3 +154,70 @@ fn the_tab_menu_closes_later_tabs_of_any_session_and_ends_with_close() { } } } + +#[gtk4::test] +fn the_tab_bar_and_the_overview_reach_every_tab_menu_action() { + let sessions = two_sessions("TabMenuReach", "Other"); + let shell = &sessions.shell; + open_editor(&sessions.first); + let imp = shell.imp(); + for widget in [ + imp.tab_bar.get().upcast::(), + imp.tab_overview.get().upcast(), + ] { + for action in ["close-others", "close-after", "close-all", "close"] { + assert!( + widget.activate_action(&format!("tab.{action}"), None).is_ok(), + "tab.{action} is out of reach of {}", + widget.type_().name() + ); + } + } +} + +#[gtk4::test] +fn close_all_tabs_closes_the_tabs_of_every_session() { + let sessions = two_sessions("CloseAllAcross", "Other"); + let (shell, first, second) = (&sessions.shell, &sessions.first, &sessions.second); + let view = shell.tab_view(); + let target = open_editor(first); + open_editor(second); + open_editor(first); + shell.imp().menu_target.replace(Some(target)); + + WidgetExt::activate_action(&shell.imp().tab_bar.get(), "tab.close-all", None).expect("Close All Tabs"); + + wait_until(WAIT, || view.n_pages() == 0).expect("every tab to close"); + assert!(!first.has_tabs() && !second.has_tabs()); +} + +#[gtk4::test] +fn the_tab_menu_dims_what_would_close_nothing() { + let sessions = two_sessions("TabMenuDims", "Other"); + let shell = &sessions.shell; + let view = shell.tab_view(); + let only = open_editor(&sessions.first); + let enabled = |action: &str| { + shell + .imp() + .tab_actions + .get() + .is_some_and(|group| group.is_action_enabled(action)) + }; + + view.emit_by_name::<()>("setup-menu", &[&Some(only.clone())]); + assert!( + !enabled("close-others") && !enabled("close-after"), + "one tab has nothing else to close" + ); + assert!(enabled("close-all") && enabled("close")); + + let last = open_editor(&sessions.second); + view.emit_by_name::<()>("setup-menu", &[&Some(only)]); + assert!(enabled("close-others") && enabled("close-after")); + view.emit_by_name::<()>("setup-menu", &[&Some(last)]); + assert!( + enabled("close-others") && !enabled("close-after"), + "the last tab has none after it" + ); +} diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index c6d16a2802..1c070cb965 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -170,12 +170,6 @@ impl WorkspaceShell { /// says left or right, as the GNOME HIG asks. fn connect_tab_menu(&self) { let imp = self.imp(); - let target = self.downgrade(); - imp.tab_view.connect_setup_menu(move |_, page| { - if let Some(shell) = target.upgrade() { - shell.imp().menu_target.replace(page.cloned()); - } - }); let group = gio::SimpleActionGroup::new(); let menu_action = |name: &str, close: fn(&adw::TabView, &adw::TabPage)| { let action = gio::SimpleAction::new(name, None); @@ -191,13 +185,37 @@ impl WorkspaceShell { }); action }; - group.add_action(&menu_action("close-others", |view, page| view.close_other_pages(page))); - group.add_action(&menu_action("close-after", |view, page| view.close_pages_after(page))); + let close_others = menu_action("close-others", |view, page| view.close_other_pages(page)); + let close_after = menu_action("close-after", |view, page| view.close_pages_after(page)); + group.add_action(&close_others); + group.add_action(&close_after); + group.add_action(&menu_action("close-all", |view, _| { + let pages: Vec = (0..view.n_pages()).map(|position| view.nth_page(position)).collect(); + for page in &pages { + view.close_page(page); + } + })); group.add_action(&menu_action("close", |view, page| view.close_page(page))); - imp.tab_view.insert_action_group("tab", Some(&group)); + // The menu's popover belongs to the tab bar or the overview, never + // to the view, so the actions live on the shell above all three. + self.insert_action_group("tab", Some(&group)); + imp.tab_actions.set(group).ok(); + let target = self.downgrade(); + imp.tab_view.connect_setup_menu(move |view, page| { + let Some(shell) = target.upgrade() else { + return; + }; + shell.imp().menu_target.replace(page.cloned()); + if let Some(page) = page { + let last = view.n_pages() - 1; + close_others.set_enabled(last > 0); + close_after.set_enabled(view.page_position(page) < last); + } + }); let others = gio::Menu::new(); others.append(Some(&gettext("Close Other Tabs")), Some("tab.close-others")); others.append(Some(&gettext("Close Tabs After This One")), Some("tab.close-after")); + others.append(Some(&gettext("Close All Tabs")), Some("tab.close-all")); let close = gio::Menu::new(); close.append(Some(&gettext("Close")), Some("tab.close")); let menu = gio::Menu::new(); diff --git a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs index b0759fce2d..d401ce6740 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs @@ -2,9 +2,9 @@ use std::cell::{OnceCell, RefCell}; use std::collections::VecDeque; use gtk4 as gtk; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; use libadwaita as adw; use libadwaita::subclass::prelude::*; @@ -113,6 +113,8 @@ pub struct WorkspaceShell { pub(super) sender: OnceCell>, /// The tab page whose menu is open: the view names it only then. pub(super) menu_target: RefCell>, + /// The tab menu's actions, which dim for the page the menu opens on. + pub(super) tab_actions: OnceCell, /// The tabs closed last, the newest at the back. pub(super) closed_tabs: RefCell>, /// The tabs the window showed, the latest first. From 155e38dad84a45fecf8fa33c0b559af7e07aa49e Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:35:09 +0100 Subject: [PATCH 2/3] docs(linux-workspace): add changelog fragments for the tab menu fix --- linux/changelog.d/3930.added.md | 1 + linux/changelog.d/3930.fixed.md | 1 + 2 files changed, 2 insertions(+) create mode 100644 linux/changelog.d/3930.added.md create mode 100644 linux/changelog.d/3930.fixed.md diff --git a/linux/changelog.d/3930.added.md b/linux/changelog.d/3930.added.md new file mode 100644 index 0000000000..116bc3cdaf --- /dev/null +++ b/linux/changelog.d/3930.added.md @@ -0,0 +1 @@ +The tab menu has Close All Tabs. diff --git a/linux/changelog.d/3930.fixed.md b/linux/changelog.d/3930.fixed.md new file mode 100644 index 0000000000..14754c4699 --- /dev/null +++ b/linux/changelog.d/3930.fixed.md @@ -0,0 +1 @@ +The tab menu's close items were always dimmed and could not be chosen. From 80afb3b66220329ad84b6151a1371a69ca8e006d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 31 Oct 2026 15:38:53 +0700 Subject: [PATCH 3/3] fix(linux-workspace): move Close All Tabs to the tab overview's menu --- linux/changelog.d/3930.added.md | 2 +- .../app/src/ui/workspace/tests/close_tab.rs | 23 ++++ .../app/src/ui/workspace/tests/sessions.rs | 102 +++++++++++++----- .../app/src/ui/workspace/workspace_actions.rs | 17 ++- .../app/src/ui/workspace/workspace_shell.rs | 51 ++++++--- .../src/ui/workspace/workspace_shell_imp.rs | 2 +- linux/data/resources/gtk/menus.ui | 4 + 7 files changed, 152 insertions(+), 49 deletions(-) diff --git a/linux/changelog.d/3930.added.md b/linux/changelog.d/3930.added.md index 116bc3cdaf..6a53fe1e83 100644 --- a/linux/changelog.d/3930.added.md +++ b/linux/changelog.d/3930.added.md @@ -1 +1 @@ -The tab menu has Close All Tabs. +The tab overview's menu has Close All Tabs. diff --git a/linux/crates/app/src/ui/workspace/tests/close_tab.rs b/linux/crates/app/src/ui/workspace/tests/close_tab.rs index 39bb3ad8a1..b98e455209 100644 --- a/linux/crates/app/src/ui/workspace/tests/close_tab.rs +++ b/linux/crates/app/src/ui/workspace/tests/close_tab.rs @@ -311,3 +311,26 @@ fn an_editor_asking_to_close_closes_its_own_tab_not_the_one_in_front() { assert!(tab_is_open(&test, front)); assert_eq!(workspace.selected_tab(), Some(front)); } + +/// Close All Tabs asks about each tab as its close button would. +#[gtk4::test] +fn close_all_tabs_asks_before_closing_unsaved_work() { + let test = TestWindow::connected("CloseAllUnsavedFile"); + let path = test.dir().join("daily.sql"); + crate::test_support::write_text_file(&path, "SELECT 1;"); + let (id, _editor) = editor_on_file(&test, &path); + let workspace = test.workspace(); + super::open_editor(&workspace); + let view = shell_of(&workspace).tab_view(); + + WidgetExt::activate_action(&test.window, "win.close-all-tabs", None).expect("win.close-all-tabs"); + + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some("Save Changes?")) + }) + .expect("the close asks about the file"); + assert!(answer(&test.window, "cancel")); + wait_until(WAIT, || view.n_pages() == 1).expect("the tabs with nothing to lose to close"); + assert!(tab_is_open(&test, id), "the tab with unsaved changes closed"); + assert_eq!(crate::test_support::read_text_file(&path), "SELECT 1;"); +} diff --git a/linux/crates/app/src/ui/workspace/tests/sessions.rs b/linux/crates/app/src/ui/workspace/tests/sessions.rs index d7f1b255da..c350b90838 100644 --- a/linux/crates/app/src/ui/workspace/tests/sessions.rs +++ b/linux/crates/app/src/ui/workspace/tests/sessions.rs @@ -5,7 +5,7 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use super::{open_editor, two_sessions}; -use crate::test_support::wait_until; +use crate::test_support::{descendants, wait_until}; use crate::ui::session_window::tests::WAIT; #[gtk4::test] @@ -155,42 +155,33 @@ fn the_tab_menu_closes_later_tabs_of_any_session_and_ends_with_close() { } } +/// libadwaita parents the tab menu to a tab box in the bar and to a +/// tab grid in the overview, never to the view. #[gtk4::test] -fn the_tab_bar_and_the_overview_reach_every_tab_menu_action() { +fn the_tab_menu_reaches_its_actions_from_the_bar_and_the_overview() { let sessions = two_sessions("TabMenuReach", "Other"); let shell = &sessions.shell; open_editor(&sessions.first); - let imp = shell.imp(); - for widget in [ - imp.tab_bar.get().upcast::(), - imp.tab_overview.get().upcast(), - ] { - for action in ["close-others", "close-after", "close-all", "close"] { + let parents: Vec = descendants(&shell.imp().tab_overview.get()) + .into_iter() + .filter(|widget| ["AdwTabBox", "AdwTabGrid"].contains(&widget.type_().name())) + .collect(); + assert!( + parents.iter().any(|widget| widget.type_().name() == "AdwTabBox") + && parents.iter().any(|widget| widget.type_().name() == "AdwTabGrid"), + "no tab box or tab grid to open the menu from" + ); + for parent in &parents { + for action in ["close-others", "close-after", "close"] { assert!( - widget.activate_action(&format!("tab.{action}"), None).is_ok(), + parent.activate_action(&format!("tab.{action}"), None).is_ok(), "tab.{action} is out of reach of {}", - widget.type_().name() + parent.type_().name() ); } } } -#[gtk4::test] -fn close_all_tabs_closes_the_tabs_of_every_session() { - let sessions = two_sessions("CloseAllAcross", "Other"); - let (shell, first, second) = (&sessions.shell, &sessions.first, &sessions.second); - let view = shell.tab_view(); - let target = open_editor(first); - open_editor(second); - open_editor(first); - shell.imp().menu_target.replace(Some(target)); - - WidgetExt::activate_action(&shell.imp().tab_bar.get(), "tab.close-all", None).expect("Close All Tabs"); - - wait_until(WAIT, || view.n_pages() == 0).expect("every tab to close"); - assert!(!first.has_tabs() && !second.has_tabs()); -} - #[gtk4::test] fn the_tab_menu_dims_what_would_close_nothing() { let sessions = two_sessions("TabMenuDims", "Other"); @@ -210,7 +201,7 @@ fn the_tab_menu_dims_what_would_close_nothing() { !enabled("close-others") && !enabled("close-after"), "one tab has nothing else to close" ); - assert!(enabled("close-all") && enabled("close")); + assert!(enabled("close")); let last = open_editor(&sessions.second); view.emit_by_name::<()>("setup-menu", &[&Some(only)]); @@ -221,3 +212,60 @@ fn the_tab_menu_dims_what_would_close_nothing() { "the last tab has none after it" ); } + +#[gtk4::test] +fn close_all_tabs_closes_the_tabs_of_every_session() { + let sessions = two_sessions("CloseAllAcross", "Other"); + let (shell, first, second) = (&sessions.shell, &sessions.first, &sessions.second); + let view = shell.tab_view(); + open_editor(first); + open_editor(second); + open_editor(first); + + WidgetExt::activate_action(&sessions.test.window, "win.close-all-tabs", None).expect("win.close-all-tabs"); + + wait_until(WAIT, || view.n_pages() == 0).expect("every tab to close"); + assert!(!first.has_tabs() && !second.has_tabs()); +} + +/// As in GNOME Web: the overview lists every tab, and the tab menu +/// holds what acts on its own tab. +#[gtk4::test] +fn close_all_tabs_is_in_the_overview_menu_and_off_with_no_tab() { + let sessions = two_sessions("CloseAllInOverview", "Other"); + let window = &sessions.test.window; + let actions_of = |menu: &gio::MenuModel| -> Vec { + (0..menu.n_items()) + .filter_map(|section| menu.item_link(section, "section")) + .flat_map(|items| { + (0..items.n_items()) + .filter_map(|item| { + items + .item_attribute_value(item, "action", None) + .and_then(|value| value.get::()) + }) + .collect::>() + }) + .collect() + }; + let overview_menu = sessions + .shell + .imp() + .tab_overview + .secondary_menu() + .expect("the overview's menu"); + assert!(actions_of(&overview_menu).contains(&"win.close-all-tabs".to_owned())); + let tab_menu = sessions.shell.tab_view().menu_model().expect("the tab menu"); + assert!(!actions_of(&tab_menu).iter().any(|action| action.contains("close-all"))); + let enabled = || { + window + .lookup_action("close-all-tabs") + .is_some_and(|action| action.is_enabled()) + }; + + assert!(!enabled(), "Close All Tabs is on with no tab to close"); + let only = open_editor(&sessions.first); + assert!(enabled(), "Close All Tabs is off with a tab open"); + sessions.shell.tab_view().close_page(&only); + wait_until(WAIT, || !enabled()).expect("Close All Tabs to go off with the last tab"); +} diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index 5a724c5ea9..c4abf6075e 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -2,8 +2,8 @@ //! them once and each one goes to the session in front, so a change of //! front changes where they go without taking any off the window. Their //! keys are application accelerators; a tab's own keys belong to the tab. -//! Reopen Closed Tab, Switch to Recent Tab and View Open Tabs are the -//! shell's own: the tabs they name may be any session's. +//! Reopen Closed Tab, Close All Tabs, Switch to Recent Tab and View Open +//! Tabs are the shell's own: the tabs they name may be any session's. use gtk4::glib; use gtk4::prelude::*; @@ -32,6 +32,7 @@ const ENTRIES: [(&str, ActionInput); 6] = [ ]; const REOPEN_CLOSED_TAB: &str = "reopen-closed-tab"; +const CLOSE_ALL_TABS: &str = "close-all-tabs"; const SWITCH_RECENT_TAB: &str = "switch-recent-tab"; const SHOW_TABS: &str = "show-tabs"; @@ -62,6 +63,14 @@ pub(super) fn install(window: &adw::ApplicationWindow, shell: &WorkspaceShell) { } }) .build()]); + let closing = shell.downgrade(); + window.add_action_entries([gio::ActionEntry::::builder(CLOSE_ALL_TABS) + .activate(move |_, _, _| { + if let Some(shell) = closing.upgrade() { + shell.close_all_tabs(); + } + }) + .build()]); let switching = shell.downgrade(); window.add_action_entries([gio::ActionEntry::::builder(SWITCH_RECENT_TAB) .activate(move |_, _, _| { @@ -80,7 +89,7 @@ pub(super) fn install(window: &adw::ApplicationWindow, shell: &WorkspaceShell) { .build()]); // The overview refuses to open or close with no tab, and with one tab // Ctrl+E goes on to the tab. - for (name, least) in [(SWITCH_RECENT_TAB, 2), (SHOW_TABS, 1)] { + for (name, least) in [(SWITCH_RECENT_TAB, 2), (SHOW_TABS, 1), (CLOSE_ALL_TABS, 1)] { if let Some(action) = window.lookup_action(name) { shell .tab_view() @@ -102,6 +111,7 @@ pub(super) fn remove(window: &adw::ApplicationWindow) { window.remove_action(name); } window.remove_action(REOPEN_CLOSED_TAB); + window.remove_action(CLOSE_ALL_TABS); window.remove_action(SWITCH_RECENT_TAB); window.remove_action(SHOW_TABS); } @@ -181,6 +191,7 @@ mod tests { remove(&window); assert!(window.lookup_action("new-editor-tab").is_none()); assert!(window.lookup_action(REOPEN_CLOSED_TAB).is_none()); + assert!(window.lookup_action(CLOSE_ALL_TABS).is_none()); assert!(window.lookup_action(SWITCH_RECENT_TAB).is_none()); assert!(window.lookup_action(SHOW_TABS).is_none()); } diff --git a/linux/crates/app/src/ui/workspace/workspace_shell.rs b/linux/crates/app/src/ui/workspace/workspace_shell.rs index 1c070cb965..a44182b629 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell.rs @@ -185,21 +185,13 @@ impl WorkspaceShell { }); action }; - let close_others = menu_action("close-others", |view, page| view.close_other_pages(page)); - let close_after = menu_action("close-after", |view, page| view.close_pages_after(page)); - group.add_action(&close_others); - group.add_action(&close_after); - group.add_action(&menu_action("close-all", |view, _| { - let pages: Vec = (0..view.n_pages()).map(|position| view.nth_page(position)).collect(); - for page in &pages { - view.close_page(page); - } - })); + group.add_action(&menu_action("close-others", |view, page| view.close_other_pages(page))); + group.add_action(&menu_action("close-after", |view, page| view.close_pages_after(page))); group.add_action(&menu_action("close", |view, page| view.close_page(page))); - // The menu's popover belongs to the tab bar or the overview, never - // to the view, so the actions live on the shell above all three. + // The bar and the overview parent the menu's popover, and the view + // is an ancestor of neither. self.insert_action_group("tab", Some(&group)); - imp.tab_actions.set(group).ok(); + let _ = imp.tab_actions.set(group); let target = self.downgrade(); imp.tab_view.connect_setup_menu(move |view, page| { let Some(shell) = target.upgrade() else { @@ -207,15 +199,12 @@ impl WorkspaceShell { }; shell.imp().menu_target.replace(page.cloned()); if let Some(page) = page { - let last = view.n_pages() - 1; - close_others.set_enabled(last > 0); - close_after.set_enabled(view.page_position(page) < last); + shell.dim_tab_menu(view, page); } }); let others = gio::Menu::new(); others.append(Some(&gettext("Close Other Tabs")), Some("tab.close-others")); others.append(Some(&gettext("Close Tabs After This One")), Some("tab.close-after")); - others.append(Some(&gettext("Close All Tabs")), Some("tab.close-all")); let close = gio::Menu::new(); close.append(Some(&gettext("Close")), Some("tab.close")); let menu = gio::Menu::new(); @@ -224,6 +213,34 @@ impl WorkspaceShell { imp.tab_view.set_menu_model(Some(&menu)); } + /// Dim what would close nothing for `page`, whose menu opens. + fn dim_tab_menu(&self, view: &adw::TabView, page: &adw::TabPage) { + let Some(group) = self.imp().tab_actions.get() else { + return; + }; + let last = view.n_pages() - 1; + for (name, enabled) in [ + ("close-others", last > 0), + ("close-after", view.page_position(page) < last), + ] { + if let Some(action) = group.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } + } + } + + /// Close every session's tabs, each through its own session's + /// prompts. + pub(super) fn close_all_tabs(&self) { + let view = self.tab_view(); + // The selected page goes last, so the view does not select each + // neighbour in turn and bring its session forward. + if let Some(selected) = view.selected_page() { + view.close_other_pages(&selected); + view.close_page(&selected); + } + } + fn connect_view(&self) { let view = self.imp().tab_view.get(); let shell = self.downgrade(); diff --git a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs index d401ce6740..da9d8cd4ec 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs @@ -2,9 +2,9 @@ use std::cell::{OnceCell, RefCell}; use std::collections::VecDeque; use gtk4 as gtk; +use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; -use gtk4::{gio, glib}; use libadwaita as adw; use libadwaita::subclass::prelude::*; diff --git a/linux/data/resources/gtk/menus.ui b/linux/data/resources/gtk/menus.ui index cf47648688..6b9a30ab68 100644 --- a/linux/data/resources/gtk/menus.ui +++ b/linux/data/resources/gtk/menus.ui @@ -65,6 +65,10 @@ Reopen Closed Tab win.reopen-closed-tab + + Close All Tabs + win.close-all-tabs +