diff --git a/linux/changelog.d/3930.added.md b/linux/changelog.d/3930.added.md new file mode 100644 index 0000000000..6a53fe1e83 --- /dev/null +++ b/linux/changelog.d/3930.added.md @@ -0,0 +1 @@ +The tab overview's 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. 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 046eb2f954..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] @@ -154,3 +154,118 @@ 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_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 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!( + parent.activate_action(&format!("tab.{action}"), None).is_ok(), + "tab.{action} is out of reach of {}", + parent.type_().name() + ); + } + } +} + +#[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")); + + 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" + ); +} + +#[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 c6d16a2802..a44182b629 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); @@ -194,7 +188,20 @@ impl WorkspaceShell { 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))); - imp.tab_view.insert_action_group("tab", Some(&group)); + // 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)); + 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 { + return; + }; + shell.imp().menu_target.replace(page.cloned()); + if let Some(page) = page { + 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")); @@ -206,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 b0759fce2d..da9d8cd4ec 100644 --- a/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs +++ b/linux/crates/app/src/ui/workspace/workspace_shell_imp.rs @@ -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. 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 +