diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index b6a709088c..a04b8ed3ab 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -47,7 +47,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Sorting | A header click sorts tables on the server and query results in the grid | | Cell editing | Editors by column kind: inline text, a multi-line editor, a calendar for dates, a toggle for booleans, JSON checked before saving; values are edited from their exact text, never from what the cell displays | | Rows | Insert, duplicate and delete; Set NULL and Empty, and DEFAULT on a new row; undo and redo; save or discard pending changes, and stop a save | -| Bulk delete | Deleting 10 rows or more asks first | +| Bulk delete | Deleting 10 rows or more asks first, unless Confirm Before Deleting Rows is off in Preferences | | Copy | Copy, and Copy As rows, with headers, JSON, CSV, Markdown, IN clause or INSERT; Show Row as JSON | | Column widths | Kept per table | | CSV import | Into an existing table, with a column mapping, in one write batch | @@ -102,7 +102,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Session restore | The windows open at quit open again at launch, behind a preference | | Closing safely | Unsaved edits, a save in flight or a running export ask before a window closes or the app quits; logout waits while edits are unsaved, a save runs or an export writes | | Notifications | Work that finishes in a tab you are not looking at sends a notification | -| Preferences | Restore session, page size, query history, editor font and scheme, query timeout. A Confirm Before Deleting Rows switch is shown but nothing reads it yet (see the backlog) | +| Preferences | Restore session, page size, the bulk delete confirmation, query history, editor font and scheme, query timeout | | Shortcuts and About | A shortcuts dialog, and About from the metainfo with third-party licenses and debug info | ### Packaging and platform @@ -132,7 +132,6 @@ Not built yet, in no order. - Edit binary columns from and to a file. They are read-only today. - Time and timestamp pickers. Dates have a calendar; times are typed. - Save the column order per table. -- Make the Confirm Before Deleting Rows preference take effect. The switch is stored, but deleting rows does not read it. - Functions, procedures, sequences and triggers in the sidebar. - The CREATE INDEX statement for one index. Show CREATE TABLE lists them after the table on PostgreSQL, SQL Server and SQLite. - Jump from a foreign key to the table it references. diff --git a/linux/crates/app/src/ui/browse_tab/edit_handlers.rs b/linux/crates/app/src/ui/browse_tab/edit_handlers.rs index 1d7ace10ff..bfa6e0aa81 100644 --- a/linux/crates/app/src/ui/browse_tab/edit_handlers.rs +++ b/linux/crates/app/src/ui/browse_tab/edit_handlers.rs @@ -294,7 +294,7 @@ impl BrowseModel { if deletion.is_empty() { return; } - if deletion.len() < BULK_DELETE_CONFIRM_THRESHOLD { + if deletion.len() < BULK_DELETE_CONFIRM_THRESHOLD || !self.settings.confirm_bulk_delete() { fx.input(EditInput::MarkRowsDeleted(deletion)); return; } diff --git a/linux/crates/app/src/ui/browse_tab/load_tests.rs b/linux/crates/app/src/ui/browse_tab/load_tests.rs index 9e93933408..caaaa10542 100644 --- a/linux/crates/app/src/ui/browse_tab/load_tests.rs +++ b/linux/crates/app/src/ui/browse_tab/load_tests.rs @@ -497,11 +497,11 @@ fn the_loading_page_names_a_table_with_an_ampersand() { outcome: Ok(rows(&["late"])), }); let fake = crate::test_support::FakeSession::start(script, [], runtime.tasks()); - let root = tempfile::tempdir().expect("tempdir"); + let stores = super::tests::TabStores::new(); let tab = super::tests::table_tab( &fake.session, &runtime, - &root, + &stores, "R&D", Default::default(), Default::default(), diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 06c32ecb88..017c5210cf 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -54,6 +54,7 @@ mod total_tests; use std::cell::Cell; use std::collections::{BTreeSet, VecDeque}; +use std::rc::Rc; use std::sync::Arc; use gtk4 as gtk; @@ -67,6 +68,7 @@ use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_session::operation::DeadlinePolicy; use tablepro_session::runtime::{Tasks, WatchForwarder}; use tablepro_session::{LinkGeneration, Session}; +use tablepro_storage::AppSettings; use self::browse_actions::BrowseActions; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; @@ -104,13 +106,8 @@ use crate::ui::export::ExportOffer; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; const DEFAULT_PAGE_SIZE: u64 = 1_000; -/// Bulk-delete safety net: when the user marks at least this many -/// rows pending-delete in one shot, surface a confirmation dialog -/// before tracking. The marker is reversible via Discard / Ctrl+Z, -/// but a 200-row Ctrl+A → Delete sequence is destructive enough at -/// a glance that an explicit confirmation matches GNOME Files' -/// "Delete N items?" pattern. -const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; +/// A delete of fewer rows never asks, since Undo and Discard bring them back. +pub(crate) const BULK_DELETE_CONFIRM_THRESHOLD: usize = 10; glib::wrapper! { pub struct BrowseTab(ObjectSubclass) @@ -135,6 +132,7 @@ pub struct BrowseTabInit { pub tasks: Tasks, /// The session's deadlines, which a count that ran out names. pub deadlines: Arc, + pub settings: Rc, } impl BrowseTab { @@ -183,6 +181,7 @@ impl BrowseTab { filter_settings: init.filter_settings, pager: BrowsePager::new(init.page_size, init.initial_offset), deadlines: init.deadlines, + settings: init.settings, current_sort: init.initial_sort, current_filter: initial_filter, current_columns: Vec::new(), @@ -488,6 +487,8 @@ pub(crate) struct BrowseModel { /// Where the page the tab reads starts, and how many rows it holds. pager: BrowsePager, deadlines: Arc, + /// Read at each delete, so a change in Preferences reaches open tabs. + settings: Rc, current_sort: Option<(usize, bool)>, /// The WHERE every page and count reads under, kept per connection, /// schema and table. diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index a6ab4932bc..456142ca38 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -21,7 +21,7 @@ use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; -use crate::test_support::{FakeSession, after_next_frame, drain_main_context, wait_until}; +use crate::test_support::{FakeSession, MemorySettings, after_next_frame, drain_main_context, wait_until}; use crate::ui::clipboard::InsertRow; use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; @@ -152,7 +152,7 @@ pub(super) struct Harness { pub(super) session: Arc, pub(super) connection: Arc, pub(super) fake: FakeSession, - root: tempfile::TempDir, + pub(super) stores: TabStores, runtime: Option, } @@ -184,9 +184,9 @@ impl Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let fake = FakeSession::start(script, reconnects, runtime.tasks()); - let root = tempfile::tempdir().expect("tempdir"); + let stores = TabStores::new(); let tracker = TabTrackerHandle::default(); - let tab = filtered_users_tab(&fake.session, &runtime, &root, saved, tracker.clone(), read_only); + let tab = filtered_users_tab(&fake.session, &runtime, &stores, saved, tracker.clone(), read_only); Self { tab: Some(tab), tracker, @@ -194,7 +194,7 @@ impl Harness { session: fake.session.clone(), connection: fake.connection.clone(), fake, - root, + stores, runtime: Some(runtime), } } @@ -206,7 +206,7 @@ impl Harness { /// Another tab on the same table and session. pub(super) fn open_another(&mut self) -> BrowseTab { let runtime = self.runtime.as_ref().expect("the runtime is running"); - let tab = users_tab(&self.session, runtime, &self.root); + let tab = users_tab(&self.session, runtime, &self.stores); self.others.push(tab.clone()); tab } @@ -298,15 +298,26 @@ impl Drop for Shown { } } -pub(super) fn users_tab( - session: &Arc, - runtime: &crate::runtime::AppRuntime, - root: &tempfile::TempDir, -) -> BrowseTab { +/// The files and preferences a test tab reads. +pub(super) struct TabStores { + root: tempfile::TempDir, + pub(super) settings: MemorySettings, +} + +impl TabStores { + pub(super) fn new() -> Self { + Self { + root: tempfile::tempdir().expect("tempdir"), + settings: MemorySettings::new(), + } + } +} + +pub(super) fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, stores: &TabStores) -> BrowseTab { filtered_users_tab( session, runtime, - root, + stores, FilterSet::default(), TabTrackerHandle::default(), false, @@ -316,18 +327,18 @@ pub(super) fn users_tab( pub(super) fn filtered_users_tab( session: &Arc, runtime: &crate::runtime::AppRuntime, - root: &tempfile::TempDir, + stores: &TabStores, saved: FilterSet, tracker: TabTrackerHandle, read_only: bool, ) -> BrowseTab { - table_tab(session, runtime, root, "users", saved, tracker, read_only) + table_tab(session, runtime, stores, "users", saved, tracker, read_only) } pub(super) fn table_tab( session: &Arc, runtime: &crate::runtime::AppRuntime, - root: &tempfile::TempDir, + stores: &TabStores, table: &str, saved: FilterSet, tracker: TabTrackerHandle, @@ -335,7 +346,8 @@ pub(super) fn table_tab( ) -> BrowseTab { let tasks = runtime.tasks(); let connection_id = uuid::Uuid::new_v4(); - let filter_settings = crate::persistence::FilterSettingsStore::load(root.path().join("filters.json"), &tasks); + let root = stores.root.path(); + let filter_settings = crate::persistence::FilterSettingsStore::load(root.join("filters.json"), &tasks); filter_settings.set_filters(connection_id, None, table, saved); BrowseTab::new(BrowseTabInit { tracker, @@ -346,13 +358,14 @@ pub(super) fn table_tab( page_size: 100, initial_offset: 0, initial_sort: None, - column_widths: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), &tasks), + column_widths: crate::persistence::ColumnWidthStore::load(root.join("column-widths.json"), &tasks), filter_settings, session: session.clone(), tasks, deadlines: Arc::new(tablepro_session::operation::DefaultDeadlines::new( tablepro_core::LivenessPolicy::DESKTOP, )), + settings: stores.settings.get().clone(), }) } @@ -2868,6 +2881,59 @@ fn a_second_delete_replaces_the_waiting_confirmation() { assert_eq!(harness.tracker.pending_count(), DOZEN.len()); } +#[gtk4::test] +fn a_bulk_delete_with_the_confirmation_off_marks_its_rows_at_once() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + + harness + .stores + .settings + .get() + .set_confirm_bulk_delete(false) + .expect("turn the confirmation off"); + harness.selection().select_all(); + delete_rows(harness.tab()); + drain_main_context(); + + assert!( + confirming(harness.tab()).is_none(), + "the delete asked with the switch off" + ); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} + +#[gtk4::test] +fn turning_the_confirmation_back_on_asks_again_in_an_open_tab() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let settings = harness.stores.settings.get(); + settings + .set_confirm_bulk_delete(false) + .expect("turn the confirmation off"); + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + harness.selection().select_all(); + delete_rows(harness.tab()); + drain_main_context(); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + harness.tab().send(EditInput::Undo); + assert_eq!(harness.tracker.pending_count(), 0); + + settings + .set_confirm_bulk_delete(true) + .expect("turn the confirmation on"); + let dialog = ask_to_delete_every_row(&harness); + crate::test_support::respond(&dialog, "delete"); + drain_main_context(); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} + #[gtk4::test] fn insert_row_opens_the_new_rows_first_editable_cell() { let (harness, shown) = one_cell(named_col("name", "text", true), Value::Text("Ada".into())); diff --git a/linux/crates/app/src/ui/connect_dialog/imp.rs b/linux/crates/app/src/ui/connect_dialog/imp.rs index 2dd081ef6b..cfc2d1c1e9 100644 --- a/linux/crates/app/src/ui/connect_dialog/imp.rs +++ b/linux/crates/app/src/ui/connect_dialog/imp.rs @@ -75,6 +75,9 @@ pub struct ConnectDialog { pub mode: RefCell, /// An Edit's save in flight, which keeps Save from running twice. pub saving: Cell, + /// The user can close the dialog while a save runs, and the save + /// must not close it again when it lands. + pub has_closed: Cell, pub pem: PemValidation, /// The TLS settings the last file check read, so a keystroke in an /// unrelated row does not read the files again. @@ -124,6 +127,7 @@ impl WidgetImpl for ConnectDialog {} impl AdwDialogImpl for ConnectDialog { fn closed(&self) { + self.has_closed.set(true); self.tests.cancel(); self.testing.set(false); self.parent_closed(); diff --git a/linux/crates/app/src/ui/connect_dialog/mod.rs b/linux/crates/app/src/ui/connect_dialog/mod.rs index 3ea7fd30ab..dd586a9d79 100644 --- a/linux/crates/app/src/ui/connect_dialog/mod.rs +++ b/linux/crates/app/src/ui/connect_dialog/mod.rs @@ -673,7 +673,9 @@ impl ConnectDialog { match outcome { Ok(Ok(saved)) => { dialog.emit_by_name::<()>("saved", &[&saved.id.to_string()]); - dialog.close(); + if !dialog.imp().has_closed.get() { + dialog.close(); + } } Ok(Err(error)) => { tracing::warn!(%error, "could not save the edited connection"); diff --git a/linux/crates/app/src/ui/connect_dialog/tests.rs b/linux/crates/app/src/ui/connect_dialog/tests.rs index 2af9718bf7..c3bc0172f1 100644 --- a/linux/crates/app/src/ui/connect_dialog/tests.rs +++ b/linux/crates/app/src/ui/connect_dialog/tests.rs @@ -128,6 +128,28 @@ fn caps_of(driver_id: &str) -> &'static DriverCapabilities { .unwrap_or_else(|| panic!("{driver_id} is registered")) } +/// The dialog in a window, as the app shows it before anyone can press +/// Connect or Save. Without animations it closes before the next line. +fn present(dialog: &ConnectDialog) -> crate::test_support::OwnedWindow { + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let window = crate::test_support::OwnedWindow::presented( + adw::Window::builder().default_width(1000).default_height(700).build(), + ); + dialog.present(Some(&*window)); + window +} + +/// Runs `step` with GLib criticals fatal, so a close of a dialog that +/// is not presented aborts the test. +fn without_criticals(step: impl FnOnce()) { + let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); + step(); + crate::test_support::drain_main_context(); + glib::log_set_always_fatal(previous); +} + #[gtk4::test] fn dialog_opens_with_remembered_driver_and_its_port() { let harness = open(Options { @@ -297,10 +319,7 @@ fn encryption_combo_lists_only_capability_modes() { #[gtk4::test] fn a_described_choice_shows_its_whole_value() { let harness = dialog(); - let window = crate::test_support::OwnedWindow::presented( - adw::Window::builder().default_width(1000).default_height(700).build(), - ); - harness.dialog.present(Some(&*window)); + let window = present(&harness.dialog); let ssh = harness.dialog.imp().ssh_section.get(); ssh.enable(); let rows = [ @@ -461,13 +480,16 @@ fn invalid_ca_marks_row_error_and_disables_connect() { #[gtk4::test] fn connect_emits_submission_without_establishing() { let harness = dialog(); + let _window = present(&harness.dialog); fill_network(&harness.dialog); let submissions = SignalLog::connect(&harness.dialog, "submitted", |values| { values[1].get::().expect("a submission") }); + let closed = SignalLog::connect(&harness.dialog, "closed", |_| ()); - harness.dialog.imp().connect_button.emit_clicked(); + without_criticals(|| harness.dialog.imp().connect_button.emit_clicked()); + assert_eq!(closed.count(), 1, "Connect left the dialog open"); let mut sent = submissions.take(); assert_eq!(sent.len(), 1); assert!(harness.connector.calls().is_empty(), "the dialog connected by itself"); @@ -492,11 +514,12 @@ fn connect_emits_submission_without_establishing() { #[gtk4::test] fn a_reopened_entry_keeps_its_id_after_the_host_changes() { let first = dialog(); + let _first_window = present(&first.dialog); fill_network(&first.dialog); let log = SignalLog::connect(&first.dialog, "submitted", |values| { values[1].get::().expect("a submission") }); - first.dialog.imp().connect_button.emit_clicked(); + without_criticals(|| first.dialog.imp().connect_button.emit_clicked()); let saved = log.take().remove(0).pending_save.connection; drop(log); @@ -508,13 +531,14 @@ fn a_reopened_entry_keeps_its_id_after_the_host_changes() { }), ..Options::default() }); + let _reopened_window = present(&reopened.dialog); assert_eq!(reopened.dialog.imp().host_row.text(), "db.example.com"); assert_eq!(reopened.dialog.imp().password_row.text(), "s3cret"); reopened.dialog.imp().host_row.set_text("db2.example.com"); let log = SignalLog::connect(&reopened.dialog, "submitted", |values| { values[1].get::().expect("a submission") }); - reopened.dialog.imp().connect_button.emit_clicked(); + without_criticals(|| reopened.dialog.imp().connect_button.emit_clicked()); let edited = log.take().remove(0).pending_save; assert_eq!(edited.connection.id, saved.id); @@ -530,13 +554,14 @@ fn a_connect_from_new_connection_keeps_a_renamed_entry_s_name() { saved: vec![saved.clone()], ..Options::default() }); + let _window = present(&harness.dialog); fill_network(&harness.dialog); assert_eq!(harness.dialog.imp().name_row.text(), ""); let log = SignalLog::connect(&harness.dialog, "submitted", |values| { values[1].get::().expect("a submission") }); - harness.dialog.imp().connect_button.emit_clicked(); + without_criticals(|| harness.dialog.imp().connect_button.emit_clicked()); let submitted = log.take().remove(0).pending_save; assert_eq!(submitted.connection.id, saved.id); @@ -755,6 +780,7 @@ fn editing(saved: &SavedConnection, connector: FakeConnector) -> Harness { fn edit_dialog_prefills_and_saves_same_id() { let saved = shop(); let harness = editing(&saved, FakeConnector::new([])); + let _window = present(&harness.dialog); let imp = harness.dialog.imp(); assert_eq!(harness.dialog.title(), "Edit Connection"); assert_eq!(imp.connect_button.label().as_deref(), Some("Save")); @@ -770,12 +796,16 @@ fn edit_dialog_prefills_and_saves_same_id() { let log = SignalLog::connect(&harness.dialog, "saved", |values| { values[1].get::().expect("an id") }); + let closed = SignalLog::connect(&harness.dialog, "closed", |_| ()); imp.name_row.set_text("Shop (EU)"); imp.host_row.set_text("db2.example.com"); - imp.connect_button.emit_clicked(); - wait_until(WAIT, || log.count() == 1).expect("the save"); + without_criticals(|| { + imp.connect_button.emit_clicked(); + wait_until(WAIT, || log.count() == 1).expect("the save"); + }); + assert_eq!(closed.count(), 1, "the landed save left the dialog open"); assert_eq!(log.take(), [saved.id.to_string()]); let stored = harness.store.load_blocking().expect("the list"); assert_eq!(stored.len(), 1, "the edit added an entry"); @@ -814,6 +844,30 @@ fn a_refused_save_keeps_the_dialog_open_with_the_reason() { assert_eq!(harness.store.load_blocking().expect("the list")[0], saved); } +#[gtk4::test] +fn a_save_landing_after_the_user_closed_the_dialog_does_not_close_it_again() { + let saved = shop(); + let harness = editing(&saved, FakeConnector::new([])); + let _window = present(&harness.dialog); + let imp = harness.dialog.imp(); + let log = SignalLog::connect(&harness.dialog, "saved", |_| ()); + let closed = SignalLog::connect(&harness.dialog, "closed", |_| ()); + imp.host_row.set_text("db2.example.com"); + + without_criticals(|| { + imp.connect_button.emit_clicked(); + harness.dialog.close(); + wait_until(WAIT, || log.count() == 1).expect("the save"); + }); + + assert_eq!(closed.count(), 1); + assert_eq!( + harness.store.load_blocking().expect("the list")[0].host, + "db2.example.com", + "closing the dialog lost the save" + ); +} + #[gtk4::test] fn an_edit_test_logs_in_with_the_kept_password() { let saved = shop(); diff --git a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs index 3a2c492dd0..866614ef16 100644 --- a/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs +++ b/linux/crates/app/src/ui/preferences_dialog/data_browser_group.rs @@ -6,6 +6,7 @@ use tablepro_storage::AppSettings; use tablepro_storage::settings::keys; mod imp { + use libadwaita::prelude::*; use libadwaita::subclass::prelude::*; use super::*; @@ -34,7 +35,18 @@ mod imp { } } - impl ObjectImpl for DataBrowserGroup {} + impl ObjectImpl for DataBrowserGroup { + fn constructed(&self) { + self.parent_constructed(); + let threshold = crate::ui::browse_tab::BULK_DELETE_CONFIRM_THRESHOLD; + self.confirm_row.set_subtitle(&crate::i18n::ngettext_f( + "Ask before deleting {n} or more rows at once", + "Ask before deleting {n} or more rows at once", + u32::try_from(threshold).unwrap_or(u32::MAX), + &[("n", &threshold.to_string())], + )); + } + } impl WidgetImpl for DataBrowserGroup {} impl PreferencesGroupImpl for DataBrowserGroup {} } @@ -61,7 +73,7 @@ impl DataBrowserGroup { .bind(keys::DEFAULT_PAGE_SIZE, &imp.page_size_row.get(), "value") .build(); gio_settings - .bind(keys::CONFIRM_DESTRUCTIVE, &imp.confirm_row.get(), "active") + .bind(keys::CONFIRM_BULK_DELETE, &imp.confirm_row.get(), "active") .build(); } } @@ -84,7 +96,7 @@ mod tests { fn preference_rows_are_bound() { let settings = MemorySettings::new(); settings.get().set_default_page_size(500).expect("store the page size"); - settings.get().set_confirm_destructive(false).expect("store the flag"); + settings.get().set_confirm_bulk_delete(false).expect("store the flag"); let group = bound_group(&settings); let imp = group.imp(); @@ -96,7 +108,7 @@ mod tests { imp.confirm_row.set_active(true); assert_eq!(settings.get().default_page_size(), 2_500); - assert!(settings.get().confirm_destructive()); + assert!(settings.get().confirm_bulk_delete()); } #[gtk4::test] @@ -107,4 +119,15 @@ mod tests { assert_eq!(group.title(), "Data Browser"); assert_eq!(group.imp().page_size_row.title(), "Default page size"); } + + #[gtk4::test] + fn the_confirm_row_names_the_rows_a_delete_asks_at() { + let settings = MemorySettings::new(); + let group = bound_group(&settings); + + assert_eq!( + group.imp().confirm_row.subtitle().as_deref(), + Some("Ask before deleting 10 or more rows at once") + ); + } } diff --git a/linux/crates/app/src/ui/preferences_dialog/general_page.rs b/linux/crates/app/src/ui/preferences_dialog/general_page.rs index 52d26f1325..28afaad6a4 100644 --- a/linux/crates/app/src/ui/preferences_dialog/general_page.rs +++ b/linux/crates/app/src/ui/preferences_dialog/general_page.rs @@ -160,7 +160,7 @@ mod tests { imp.data_browser_group.imp().confirm_row.set_active(false); imp.history_group.imp().save_history_row.set_active(false); - assert!(!settings.get().confirm_destructive()); + assert!(!settings.get().confirm_bulk_delete()); assert!(!settings.get().save_query_history()); } } diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index e401b2c40f..d7b8208916 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -150,6 +150,7 @@ impl TabFactory { session, tasks: parts.context.tasks.clone(), deadlines: parts.context.deadlines.clone(), + settings: parts.settings.clone(), }); connect_browse(&browse, id, &parts.input); let tab_page = self.append(&browse, id); diff --git a/linux/crates/storage/src/settings/keys.rs b/linux/crates/storage/src/settings/keys.rs index ddfbe67a1c..a61977484f 100644 --- a/linux/crates/storage/src/settings/keys.rs +++ b/linux/crates/storage/src/settings/keys.rs @@ -1,5 +1,5 @@ pub const DEFAULT_PAGE_SIZE: &str = "default-page-size"; -pub const CONFIRM_DESTRUCTIVE: &str = "confirm-destructive"; +pub const CONFIRM_BULK_DELETE: &str = "confirm-bulk-delete"; pub const SAVE_QUERY_HISTORY: &str = "save-query-history"; pub const HISTORY_RETENTION_DAYS: &str = "history-retention-days"; pub const QUERY_TIMEOUT_SECS: &str = "query-timeout-secs"; diff --git a/linux/crates/storage/src/settings/mod.rs b/linux/crates/storage/src/settings/mod.rs index b60e04d100..b0265ae492 100644 --- a/linux/crates/storage/src/settings/mod.rs +++ b/linux/crates/storage/src/settings/mod.rs @@ -67,12 +67,13 @@ impl AppSettings { self.set_uint(keys::DEFAULT_PAGE_SIZE, rows) } - pub fn confirm_destructive(&self) -> bool { - self.settings.boolean(keys::CONFIRM_DESTRUCTIVE) + /// Whether deleting many rows at once asks first; fewer never ask. + pub fn confirm_bulk_delete(&self) -> bool { + self.settings.boolean(keys::CONFIRM_BULK_DELETE) } - pub fn set_confirm_destructive(&self, confirm: bool) -> Result<(), SettingsError> { - self.set_boolean(keys::CONFIRM_DESTRUCTIVE, confirm) + pub fn set_confirm_bulk_delete(&self, confirm: bool) -> Result<(), SettingsError> { + self.set_boolean(keys::CONFIRM_BULK_DELETE, confirm) } /// Whether runs go into the query history. Off records nothing and diff --git a/linux/crates/storage/tests/settings.rs b/linux/crates/storage/tests/settings.rs index 737b56ea7e..71c613e9df 100644 --- a/linux/crates/storage/tests/settings.rs +++ b/linux/crates/storage/tests/settings.rs @@ -43,7 +43,7 @@ fn defaults_match_schema() -> TestResult { let fixture = Fixture::new()?; assert_eq!(fixture.settings.default_page_size(), 1_000); - assert!(fixture.settings.confirm_destructive()); + assert!(fixture.settings.confirm_bulk_delete()); assert_eq!(fixture.settings.history_retention_days(), 30); assert_eq!(fixture.settings.query_timeout(), Some(Duration::from_secs(60))); assert_eq!(fixture.settings.style_scheme(), "Adwaita"); @@ -63,6 +63,17 @@ fn restore_session_round_trip() -> TestResult { Ok(()) } +#[test] +fn confirm_bulk_delete_round_trip() -> TestResult { + let fixture = Fixture::new()?; + + fixture.settings.set_confirm_bulk_delete(false)?; + assert!(!fixture.settings.confirm_bulk_delete()); + fixture.settings.set_confirm_bulk_delete(true)?; + assert!(fixture.settings.confirm_bulk_delete()); + Ok(()) +} + #[test] fn save_query_history_round_trip() -> TestResult { let fixture = Fixture::new()?; diff --git a/linux/data/app.tablepro.TablePro.gschema.xml.in b/linux/data/app.tablepro.TablePro.gschema.xml.in index a2fc526dc0..fecf7c4d5c 100644 --- a/linux/data/app.tablepro.TablePro.gschema.xml.in +++ b/linux/data/app.tablepro.TablePro.gschema.xml.in @@ -52,10 +52,10 @@ How many rows a table page fetches at a time. - + true - Confirm destructive actions - Ask before dropping a table, deleting rows, or discarding pending edits. + Confirm before deleting rows + Ask before deleting 10 or more rows at once in a table tab. Deleted rows stay marked until Save, and Undo brings them back. diff --git a/linux/data/resources/ui/preferences-data-browser-group.ui b/linux/data/resources/ui/preferences-data-browser-group.ui index 133f28539e..e4decd5571 100644 --- a/linux/data/resources/ui/preferences-data-browser-group.ui +++ b/linux/data/resources/ui/preferences-data-browser-group.ui @@ -4,7 +4,6 @@ diff --git a/linux/docs/storage.md b/linux/docs/storage.md index b950e44d19..1ac3863914 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -252,7 +252,7 @@ The schema id is the application id and the path follows it: `app.tablepro.Table | `window-height` | `i` | `760` | 294 to 32767 | | `is-maximized` | `b` | `false` | | | `default-page-size` | `u` | `1000` | 100 to 100000 | -| `confirm-destructive` | `b` | `true` | | +| `confirm-bulk-delete` | `b` | `true` | | | `restore-session` | `b` | `true` | | | `connect-dialog-driver` | `s` | `postgres` | | | `save-query-history` | `b` | `true` | | diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index 53ef873d8a..7f578bc901 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tablepro 0.1.0\n" "Report-Msgid-Bugs-To: https://github.com/TableProApp/TablePro/issues\n" -"POT-Creation-Date: 2026-10-15 16:09+0000\n" +"POT-Creation-Date: 2026-10-16 15:28+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -780,12 +780,13 @@ msgid "How many rows a table page fetches at a time." msgstr "" #: data/app.tablepro.TablePro.gschema.xml.in -msgid "Confirm destructive actions" +msgid "Confirm before deleting rows" msgstr "" #: data/app.tablepro.TablePro.gschema.xml.in msgid "" -"Ask before dropping a table, deleting rows, or discarding pending edits." +"Ask before deleting 10 or more rows at once in a table tab. Deleted rows " +"stay marked until Save, and Undo brings them back." msgstr "" #: data/app.tablepro.TablePro.gschema.xml.in @@ -1514,10 +1515,6 @@ msgstr "" msgid "Data Browser" msgstr "" -#: data/resources/ui/preferences-data-browser-group.ui -msgid "Tunes the row paginator and destructive-action confirmation." -msgstr "" - #: data/resources/ui/preferences-data-browser-group.ui msgid "Default page size" msgstr "" @@ -1530,10 +1527,6 @@ msgstr "" msgid "Confirm Before Deleting Rows" msgstr "" -#: data/resources/ui/preferences-data-browser-group.ui -msgid "Show a confirmation dialog before each destructive action" -msgstr "" - #: data/resources/ui/preferences-editor-page.ui msgid "Editor" msgstr ""