Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions linux/changelog.d/3930.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The tab overview's menu has Close All Tabs.
1 change: 1 addition & 0 deletions linux/changelog.d/3930.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The tab menu's close items were always dimmed and could not be chosen.
23 changes: 23 additions & 0 deletions linux/crates/app/src/ui/workspace/tests/close_tab.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;");
}
117 changes: 116 additions & 1 deletion linux/crates/app/src/ui/workspace/tests/sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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<gtk4::Widget> = 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<String> {
(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::<String>())
})
.collect::<Vec<_>>()
})
.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");
}
17 changes: 14 additions & 3 deletions linux/crates/app/src/ui/workspace/workspace_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::*;
Expand Down Expand Up @@ -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";

Expand Down Expand Up @@ -62,6 +63,14 @@ pub(super) fn install(window: &adw::ApplicationWindow, shell: &WorkspaceShell) {
}
})
.build()]);
let closing = shell.downgrade();
window.add_action_entries([gio::ActionEntry::<adw::ApplicationWindow>::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::<adw::ApplicationWindow>::builder(SWITCH_RECENT_TAB)
.activate(move |_, _, _| {
Expand All @@ -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()
Expand All @@ -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);
}
Expand Down Expand Up @@ -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());
}
Expand Down
49 changes: 42 additions & 7 deletions linux/crates/app/src/ui/workspace/workspace_shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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"));
Expand All @@ -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::<gio::SimpleAction>() {
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();
Expand Down
2 changes: 2 additions & 0 deletions linux/crates/app/src/ui/workspace/workspace_shell_imp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ pub struct WorkspaceShell {
pub(super) sender: OnceCell<InputSender<SessionWindowInput>>,
/// The tab page whose menu is open: the view names it only then.
pub(super) menu_target: RefCell<Option<adw::TabPage>>,
/// The tab menu's actions, which dim for the page the menu opens on.
pub(super) tab_actions: OnceCell<gio::SimpleActionGroup>,
/// The tabs closed last, the newest at the back.
pub(super) closed_tabs: RefCell<VecDeque<ClosedTab>>,
/// The tabs the window showed, the latest first.
Expand Down
4 changes: 4 additions & 0 deletions linux/data/resources/gtk/menus.ui
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@
<attribute name="label" translatable="yes">Reopen Closed Tab</attribute>
<attribute name="action">win.reopen-closed-tab</attribute>
</item>
<item>
<attribute name="label" translatable="yes">Close All Tabs</attribute>
<attribute name="action">win.close-all-tabs</attribute>
</item>
</section>
</menu>
</interface>
Loading