From 004f3acb731396445a3c8120f49914718bcc8641 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 09:58:12 +0700 Subject: [PATCH 001/301] fix(linux-grid): remove discarded drafts and act on the menu's row set TrackerEdit::DeleteRows replaces Delete and DiscardDraft and is one undo step. TrackerDelta lists inserted and removed drafts for the one store path. Delete is tab.delete-rows, enabled from the selection and the delta. --- linux/CHANGELOG.md | 3 + .../crates/app/src/services/change_tracker.rs | 406 +++++++++++------- .../app/src/services/tab_tracker_handle.rs | 42 +- .../crates/app/src/services/tracker_delta.rs | 14 +- linux/crates/app/src/services/tracker_edit.rs | 28 +- linux/crates/app/src/services/undo_op.rs | 38 +- linux/crates/app/src/ui/browse_tab/imp.rs | 7 +- linux/crates/app/src/ui/browse_tab/mod.rs | 356 ++++++++------- .../app/src/ui/browse_tab/row_deletion.rs | 69 +++ linux/crates/app/src/ui/browse_tab/tests.rs | 277 +++++++++++- .../crates/app/src/ui/grid/column_factory.rs | 6 +- linux/crates/app/src/ui/grid/context_menu.rs | 3 +- .../app/src/ui/grid/delete_availability.rs | 92 ++++ linux/crates/app/src/ui/grid/grid_menus.rs | 68 ++- linux/crates/app/src/ui/grid/grid_msg.rs | 12 +- linux/crates/app/src/ui/grid/menu_rows.rs | 81 ++++ linux/crates/app/src/ui/grid/mod.rs | 3 + linux/crates/app/src/ui/grid/selection.rs | 23 +- .../app/src/ui/grid/tab_grid_context.rs | 3 +- linux/crates/app/src/ui/icons.rs | 2 + .../crates/app/tests/tracker_save_postgres.rs | 9 +- linux/data/resources/ui/browse-tab.ui | 11 + linux/po/POTFILES.in | 3 + 23 files changed, 1117 insertions(+), 439 deletions(-) create mode 100644 linux/crates/app/src/ui/browse_tab/row_deletion.rs create mode 100644 linux/crates/app/src/ui/grid/delete_availability.rs create mode 100644 linux/crates/app/src/ui/grid/menu_rows.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 71337c5a22..13c3d95b56 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. - Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. - Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. +- Delete button in the table grid, with the reason in its tooltip when the selected rows can't be deleted. ### Changed @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. - Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. +- Delete, Duplicate and Copy as in a grid cell's menu act on every selected row when the clicked row is selected. ### Fixed @@ -47,6 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse DateTime values showed as UTC text when the server profile or session set ISO output. - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. +- Undoing a delete of many rows took one Ctrl+Z per row and could not bring back more than 50. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. - Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. - Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 60009957ad..56dc07d2e5 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -3,6 +3,7 @@ //! only through `TabTrackerHandle`. use std::collections::{HashMap, HashSet, VecDeque}; +use std::sync::Arc; use tablepro_core::dml::BuildSqlError; use tablepro_core::edit::CellInput; @@ -14,9 +15,9 @@ use super::cell_overlay::CellOverlay; use super::column_relayout::{ColumnRelayout, Misfit}; use super::save_batch::{SaveBatch, SaveTarget}; use super::touch::Touch; -use super::tracker_delta::{TrackerDelta, VisualChange}; +use super::tracker_delta::TrackerDelta; use super::tracker_edit::TrackerEdit; -use super::undo_op::UndoOp; +use super::undo_op::{DeleteStep, UndoOp}; use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot, UnknownCause}; const UNDO_LIMIT: usize = 50; @@ -158,10 +159,9 @@ impl TabChangeTracker { /// An edit the running save holds back comes back as a redraw of its /// row, so the grid stops showing what was typed into it. pub fn apply(&mut self, edit: TrackerEdit) -> TrackerDelta { - if self.edit_is_in_flight(&edit) { - return self.delta(edit.row_key().into_iter().collect(), Vec::new()); - } match edit { + TrackerEdit::DeleteRows { persisted, drafts } => self.delete_rows(persisted, drafts), + edit if self.edit_is_in_flight(&edit) => self.delta(edit.row_keys()), TrackerEdit::Cell { row_key, col, @@ -170,49 +170,50 @@ impl TabChangeTracker { } => self.track_cell_edit(row_key, col, original, value), TrackerEdit::DraftCell { draft_id, col, value } => self.track_draft_cell_edit(draft_id, col, value), TrackerEdit::Insert { values } => self.track_insert(values), - TrackerEdit::Delete { - row_key, - original_values, - } => self.track_delete(row_key, original_values), - TrackerEdit::DiscardDraft { draft_id } => self.discard_draft(draft_id), } } /// An edit that would change what the running save already sent. /// An insert or a delete cannot be sent twice, so a draft or a /// delete in the save stays as it is until the save settles; a cell - /// edit is a new value, which the next save writes. + /// edit is a new value, which the next save writes. A Delete is held + /// back row by row, so this says whether any of its rows is. pub fn edit_is_in_flight(&self, edit: &TrackerEdit) -> bool { - let Some(snapshot) = self.saving.as_ref().map(|save| save.snapshot) else { - return false; - }; - let draft_in_flight = |draft_id: u64| { - self.drafts - .iter() - .any(|draft| draft.draft_id == draft_id && snapshot.includes(draft.touched)) - }; - let delete_in_flight = |row_key: &RowKey| { - self.deletes - .get(row_key) - .is_some_and(|mark| snapshot.includes(mark.touched)) - }; match edit { - TrackerEdit::DraftCell { draft_id, .. } | TrackerEdit::DiscardDraft { draft_id } => { - draft_in_flight(*draft_id) + TrackerEdit::DraftCell { draft_id, .. } => self.draft_in_flight(*draft_id), + TrackerEdit::Cell { row_key, .. } => self.row_in_flight(row_key), + TrackerEdit::DeleteRows { persisted, drafts } => { + persisted.iter().any(|(row_key, _)| self.row_in_flight(row_key)) + || drafts.iter().any(|draft_id| self.draft_in_flight(*draft_id)) } - TrackerEdit::Cell { row_key, .. } | TrackerEdit::Delete { row_key, .. } => match row_key { - RowKey::Draft(draft_id) => draft_in_flight(*draft_id), - RowKey::Persisted(_) => delete_in_flight(row_key), - }, TrackerEdit::Insert { .. } => false, } } - fn delta(&self, rows: Vec, visual: Vec) -> TrackerDelta { + fn draft_in_flight(&self, draft_id: u64) -> bool { + self.saving.as_ref().is_some_and(|save| { + self.drafts + .iter() + .any(|draft| draft.draft_id == draft_id && save.snapshot.includes(draft.touched)) + }) + } + + fn row_in_flight(&self, row_key: &RowKey) -> bool { + match row_key { + RowKey::Draft(draft_id) => self.draft_in_flight(*draft_id), + RowKey::Persisted(_) => self.saving.as_ref().is_some_and(|save| { + self.deletes + .get(row_key) + .is_some_and(|mark| save.snapshot.includes(mark.touched)) + }), + } + } + + fn delta(&self, changed: Vec) -> TrackerDelta { TrackerDelta { - rows, - visual, + changed, pending_count: self.pending_count(), + ..TrackerDelta::default() } } @@ -260,26 +261,48 @@ impl TabChangeTracker { self.put_draft(draft) } - fn track_delete(&mut self, row_key: RowKey, original_values: Vec) -> TrackerDelta { - let after = DeleteMark { - original_values, - touched: self.touch(), - }; - self.push_undo(UndoOp::Delete { - row_key: row_key.clone(), - before: self.deletes.get(&row_key).cloned(), - after: after.clone(), - }); - self.put_delete_mark(&row_key, Some(after)) + /// Marks the saved rows and drops the drafts. The marks are one undo + /// step; a row the running save is deleting already is held back and + /// only drawn again. + fn delete_rows(&mut self, persisted: Vec<(RowKey, Arc<[Value]>)>, drafts: Vec) -> TrackerDelta { + let mut changed = Vec::new(); + let mut removed_drafts = Vec::new(); + for draft_id in drafts { + if !self.draft_in_flight(draft_id) && self.drop_draft(draft_id) { + removed_drafts.push(draft_id); + } + changed.push(RowKey::Draft(draft_id)); + } + let mut steps = Vec::new(); + for (row_key, original) in persisted { + changed.push(row_key.clone()); + if self.row_in_flight(&row_key) { + continue; + } + let after = DeleteMark { + original_values: original.to_vec(), + touched: self.touch(), + }; + let before = self.deletes.insert(row_key.clone(), after.clone()); + steps.push(DeleteStep { row_key, before, after }); + } + if !steps.is_empty() { + self.push_undo(UndoOp::Delete { steps }); + } + dedup(&mut changed); + TrackerDelta { + removed_drafts, + ..self.delta(changed) + } } // The draft's undo and redo entries go with it, so Ctrl+Z cannot // bring it back half-edited. - fn discard_draft(&mut self, draft_id: u64) -> TrackerDelta { + fn drop_draft(&mut self, draft_id: u64) -> bool { let before = self.drafts.len(); self.drafts.retain(|d| d.draft_id != draft_id); if self.drafts.len() == before { - return self.delta(Vec::new(), Vec::new()); + return false; } self.updates .retain(|(k, _), _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); @@ -287,15 +310,12 @@ impl TabChangeTracker { .retain(|k, _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); self.undo.retain(|op| !op.is_about_draft(draft_id)); self.redo.retain(|op| !op.is_about_draft(draft_id)); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::RemoveDraft { draft_id }], - ) + true } fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, value: Value) -> TrackerDelta { let Some((before, before_touch)) = self.draft_cell(draft_id, col) else { - return self.delta(Vec::new(), Vec::new()); + return self.delta(Vec::new()); }; let after_touch = self.touch(); self.push_undo(UndoOp::DraftCell { @@ -328,44 +348,55 @@ impl TabChangeTracker { Some(edit) => self.updates.insert(key, edit), None => self.updates.remove(&key), }; - self.delta(vec![row_key.clone()], Vec::new()) + self.delta(vec![row_key.clone()]) } - fn put_delete_mark(&mut self, row_key: &RowKey, mark: Option) -> TrackerDelta { - match mark { + /// Undo puts each step's `before` back, last step first; redo puts + /// each `after` back in order. + fn put_delete_marks(&mut self, steps: &[DeleteStep], undo: bool) -> TrackerDelta { + let mut put = |row_key: &RowKey, mark: Option| match mark { Some(mark) => self.deletes.insert(row_key.clone(), mark), None => self.deletes.remove(row_key), }; - self.delta(vec![row_key.clone()], Vec::new()) + if undo { + for step in steps.iter().rev() { + put(&step.row_key, step.before.clone()); + } + } else { + for step in steps { + put(&step.row_key, Some(step.after.clone())); + } + } + self.delta(steps.iter().map(|step| step.row_key.clone()).collect()) } fn put_draft(&mut self, draft: DraftRow) -> TrackerDelta { let draft_id = draft.draft_id; self.drafts.push(draft); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::InsertDraft { draft_id }], - ) + TrackerDelta { + inserted_drafts: vec![draft_id], + ..self.delta(vec![RowKey::Draft(draft_id)]) + } } fn remove_draft(&mut self, draft_id: u64) -> TrackerDelta { self.drafts.retain(|d| d.draft_id != draft_id); - self.delta( - vec![RowKey::Draft(draft_id)], - vec![VisualChange::RemoveDraft { draft_id }], - ) + TrackerDelta { + removed_drafts: vec![draft_id], + ..self.delta(vec![RowKey::Draft(draft_id)]) + } } fn put_draft_cell(&mut self, draft_id: u64, col: usize, value: Value, touched: Touch) -> TrackerDelta { let Some(draft) = self.drafts.iter_mut().find(|d| d.draft_id == draft_id) else { - return self.delta(Vec::new(), Vec::new()); + return self.delta(Vec::new()); }; let Some(cell) = draft.values.get_mut(col) else { - return self.delta(Vec::new(), Vec::new()); + return self.delta(Vec::new()); }; *cell = value; draft.touched = touched; - self.delta(vec![RowKey::Draft(draft_id)], Vec::new()) + self.delta(vec![RowKey::Draft(draft_id)]) } /// Nothing while a save runs: stepping back through edits the save @@ -387,7 +418,7 @@ impl TabChangeTracker { .. } => self.put_draft_cell(*draft_id, *col, before.clone(), *before_touch), UndoOp::Insert { draft } => self.remove_draft(draft.draft_id), - UndoOp::Delete { row_key, before, .. } => self.put_delete_mark(row_key, before.clone()), + UndoOp::Delete { steps } => self.put_delete_marks(steps, true), }; self.redo.push_back(op); Some(delta) @@ -410,7 +441,7 @@ impl TabChangeTracker { .. } => self.put_draft_cell(*draft_id, *col, after.clone(), *after_touch), UndoOp::Insert { draft } => self.put_draft(draft.clone()), - UndoOp::Delete { row_key, after, .. } => self.put_delete_mark(row_key, Some(after.clone())), + UndoOp::Delete { steps } => self.put_delete_marks(steps, false), }; self.undo.push_back(op); Some(delta) @@ -440,20 +471,17 @@ impl TabChangeTracker { .chain(self.drafts.iter().map(|draft| RowKey::Draft(draft.draft_id))) .filter(|row_key| seen.insert(row_key.clone())) .collect(); - let visual = self - .drafts - .iter() - .map(|draft| VisualChange::RemoveDraft { - draft_id: draft.draft_id, - }) - .collect(); + let removed_drafts = self.drafts.iter().map(|draft| draft.draft_id).collect(); self.drafts.clear(); self.deletes.clear(); self.updates.clear(); self.set_aside.clear(); self.undo.clear(); self.redo.clear(); - self.delta(rows, visual) + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } } pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { @@ -631,13 +659,13 @@ impl TabChangeTracker { Some(previous) if previous.columns != columns => { self.relayout(&ColumnRelayout::new(&previous.columns, &columns)) } - _ => self.delta(Vec::new(), Vec::new()), + _ => self.delta(Vec::new()), } } fn relayout(&mut self, layout: &ColumnRelayout<'_>) -> TrackerDelta { let mut rows = Vec::new(); - let mut visual = Vec::new(); + let mut removed_drafts = Vec::new(); let set_aside = self.set_aside.len(); for ((row_key, col), edit) in std::mem::take(&mut self.updates) { match layout.key(&row_key).and_then(|()| layout.cell(col, &edit.pending)) { @@ -671,9 +699,7 @@ impl TabChangeTracker { self.drafts.push(draft); } Err(misfit) => { - visual.push(VisualChange::RemoveDraft { - draft_id: draft.draft_id, - }); + removed_drafts.push(draft.draft_id); self.set_aside.push(SetAside { touched: draft.touched, misfit, @@ -704,7 +730,10 @@ impl TabChangeTracker { } } dedup(&mut rows); - self.delta(rows, visual) + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } } /// Why the changes set aside cannot be saved, the key first since @@ -794,18 +823,18 @@ impl TabChangeTracker { match outcome { SaveOutcome::Committed { .. } => { let mut delta = self.drop_saved(snapshot); - delta.rows.extend(self.rebase(written, true)); - dedup(&mut delta.rows); + delta.changed.extend(self.rebase(written, true)); + dedup(&mut delta.changed); delta.pending_count = self.pending_count(); delta } SaveOutcome::RolledBack(_) => { let rows = self.rebase(written, false); - self.delta(rows, Vec::new()) + self.delta(rows) } SaveOutcome::Unknown(cause) => { self.mark_outcome_unknown(cause.clone()); - self.delta(Vec::new(), Vec::new()) + self.delta(Vec::new()) } } } @@ -838,7 +867,7 @@ impl TabChangeTracker { fn drop_saved(&mut self, snapshot: SaveSnapshot) -> TrackerDelta { let mut rows = Vec::new(); - let mut visual = Vec::new(); + let mut removed_drafts = Vec::new(); self.updates.retain(|(row_key, _), edit| { let saved = snapshot.includes(edit.touched); if saved { @@ -858,9 +887,7 @@ impl TabChangeTracker { let saved = snapshot.includes(draft.touched); if saved { rows.push(RowKey::Draft(draft.draft_id)); - visual.push(VisualChange::RemoveDraft { - draft_id: draft.draft_id, - }); + removed_drafts.push(draft.draft_id); } !saved }); @@ -869,7 +896,10 @@ impl TabChangeTracker { self.undo.clear(); self.redo.clear(); dedup(&mut rows); - self.delta(rows, visual) + TrackerDelta { + removed_drafts, + ..self.delta(rows) + } } } @@ -1002,12 +1032,26 @@ mod tests { } fn insert(t: &mut TabChangeTracker, values: Vec) -> u64 { - match t.track_insert(values).visual.as_slice() { - [VisualChange::InsertDraft { draft_id, .. }] => *draft_id, + match t.track_insert(values).inserted_drafts.as_slice() { + [draft_id] => *draft_id, other => panic!("an insert adds one draft row: {other:?}"), } } + fn delete(t: &mut TabChangeTracker, row_key: RowKey, original: Vec) -> TrackerDelta { + t.apply(TrackerEdit::DeleteRows { + persisted: vec![(row_key, original.into())], + drafts: Vec::new(), + }) + } + + fn drop_drafts(t: &mut TabChangeTracker, drafts: Vec) -> TrackerDelta { + t.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts, + }) + } + #[test] fn cell_edit_creates_modified_state() { let mut t = TabChangeTracker::default(); @@ -1045,7 +1089,7 @@ mod tests { fn delete_marks_row_pending() { let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(7)]); - t.track_delete(key.clone(), vec![Value::Int(7), Value::Text("dave".into())]); + delete(&mut t, key.clone(), vec![Value::Int(7), Value::Text("dave".into())]); assert_eq!(t.row_state(&key), RowState::PendingDelete); assert_eq!(t.pending_count(), 1); } @@ -1056,7 +1100,7 @@ mod tests { let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); let undone = t.undo(); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![key.clone()])); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![key.clone()])); assert_eq!(t.cell_state(&key, 1), CellState::Clean); assert!(t.can_redo()); } @@ -1087,7 +1131,7 @@ mod tests { let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); t.track_insert(vec![Value::Null]); - t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2)]); + delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); t.discard(); assert_eq!(t.pending_count(), 0); assert!(!t.can_undo()); @@ -1145,7 +1189,7 @@ mod tests { let undone = t.undo(); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![rk(&[Value::Int(1)])])); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![rk(&[Value::Int(1)])])); assert_eq!(pending(&t, 1), text("b")); assert_eq!(t.cell_state(&rk(&[Value::Int(1)]), 1), CellState::Modified); assert_eq!(t.pending_count(), 1); @@ -1202,9 +1246,9 @@ mod tests { let mut t = TabChangeTracker::default(); let marked_twice = rk(&[Value::Int(1)]); let marked_once = rk(&[Value::Int(2)]); - t.track_delete(marked_twice.clone(), vec![Value::Int(1)]); - t.track_delete(marked_twice.clone(), vec![Value::Int(1)]); - t.track_delete(marked_once.clone(), vec![Value::Int(2)]); + delete(&mut t, marked_twice.clone(), vec![Value::Int(1)]); + delete(&mut t, marked_twice.clone(), vec![Value::Int(1)]); + delete(&mut t, marked_once.clone(), vec![Value::Int(2)]); let _ = t.undo(); assert_eq!(t.row_state(&marked_once), RowState::Clean); @@ -1232,9 +1276,9 @@ mod tests { let undone = t.undo(); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); assert_eq!(t.drafts()[0].values[1], text("b")); - let redone = t.redo().map(|delta| delta.rows); + let redone = t.redo().map(|delta| delta.changed); assert_eq!(redone, Some(vec![RowKey::Draft(draft_id)])); assert_eq!(t.drafts()[0].values[1], text("c")); } @@ -1245,7 +1289,7 @@ mod tests { let columns = vec![pk_col("id"), data_col("name")]; edit(&mut t, 1, "a", "b"); let draft_id = insert(&mut t, vec![Value::Null, text("draft")]); - t.track_delete(rk(&[Value::Int(3)]), vec![Value::Int(3)]); + delete(&mut t, rk(&[Value::Int(3)]), vec![Value::Int(3)]); edit(&mut t, 2, "a", "b"); // Row 1 moves behind everything it was ahead of. edit(&mut t, 1, "a", "c"); @@ -1269,7 +1313,7 @@ mod tests { fn change_set_places_delete_before_later_insert() { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("email")]; - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); let draft_id = insert(&mut t, vec![Value::Null, text("a")]); let (tracked, plan) = plan(&t, &columns); @@ -1288,7 +1332,7 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("email")]; let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, @@ -1306,7 +1350,7 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("email")]; let draft_id = insert(&mut t, vec![Value::Null, Value::Null]); - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, @@ -1330,7 +1374,7 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; edit(&mut t, 1, "a", "b"); - t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2), text("a")]); + delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2), text("a")]); edit(&mut t, 1, "a", "c"); let edited = vec!["delete [Int(2)]".to_owned(), "update [Int(1)]".to_owned()]; assert_eq!(script(&t, &columns), edited); @@ -1354,7 +1398,7 @@ mod tests { let edit_script = |t: &mut TabChangeTracker| { for id in (0..40).rev() { match id % 3 { - 0 => t.track_delete(rk(&[Value::Int(id)]), vec![Value::Int(id)]), + 0 => delete(t, rk(&[Value::Int(id)]), vec![Value::Int(id)]), _ => t.track_cell_edit(rk(&[Value::Int(id)]), 1, text("a"), text("b")), }; } @@ -1383,7 +1427,7 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; edit(&mut t, 1, "a", "b"); - t.track_delete(rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); + delete(&mut t, rk(&[Value::Int(1)]), vec![Value::Int(1), text("a")]); assert_eq!(script(&t, &columns), vec!["delete [Int(1)]".to_owned()]); @@ -1398,7 +1442,11 @@ mod tests { let columns = vec![pk_col("id"), data_col("name")]; let doomed = rk(&[Value::Int(9)]); t.track_insert(vec![Value::Null, Value::Text("draft".into())]); - t.track_delete(doomed.clone(), vec![Value::Int(9), Value::Text("doomed".into())]); + delete( + &mut t, + doomed.clone(), + vec![Value::Int(9), Value::Text("doomed".into())], + ); let (tracked, plan) = plan(&t, &columns); @@ -1500,9 +1548,9 @@ mod tests { assert_eq!(t.drafts().len(), 1); assert!(t.can_undo()); - let removed = t.discard_draft(draft_id); + let removed = drop_drafts(&mut t, vec![draft_id]); - assert_eq!(removed.visual, vec![VisualChange::RemoveDraft { draft_id }]); + assert_eq!(removed.removed_drafts, vec![draft_id]); assert!(t.drafts().is_empty(), "draft must be gone"); assert!(!t.can_undo(), "undo entries scoped to the draft must be cleared"); assert_eq!(t.pending_count(), 0, "no pending state after discard"); @@ -1511,8 +1559,11 @@ mod tests { #[test] fn discard_draft_no_op_for_unknown_id() { let mut t = TabChangeTracker::default(); - assert_eq!(t.discard_draft(99_999), TrackerDelta::default()); - assert_eq!(t.pending_count(), 0); + let delta = drop_drafts(&mut t, vec![99_999]); + + assert!(delta.removed_drafts.is_empty()); + assert_eq!(delta.pending_count, 0); + assert!(!t.can_undo()); } #[test] @@ -1521,7 +1572,7 @@ mod tests { let a = insert(&mut t, vec![Value::Int(1)]); let b = insert(&mut t, vec![Value::Int(2)]); assert_eq!(t.drafts().len(), 2); - assert_eq!(t.discard_draft(a).pending_count, 1); + assert_eq!(drop_drafts(&mut t, vec![a]).pending_count, 1); assert_eq!(t.drafts().len(), 1); assert_eq!(t.drafts()[0].draft_id, b); // The remaining draft is still undoable on its own. @@ -1529,26 +1580,54 @@ mod tests { } #[test] - fn bulk_delete_via_track_delete_is_per_row_undoable() { - // Multi-row delete sequence (what the UI does on Ctrl+A → Delete - // for persisted rows): track_delete N times. Each push is its - // own undoable op so Ctrl+Z restores rows one at a time — - // matches the per-op undo contract elsewhere. + fn one_delete_over_many_rows_is_one_undo_step() { let mut t = TabChangeTracker::default(); - let k1 = rk(&[Value::Int(1)]); - let k2 = rk(&[Value::Int(2)]); - let k3 = rk(&[Value::Int(3)]); - t.track_delete(k1.clone(), vec![Value::Int(1)]); - t.track_delete(k2.clone(), vec![Value::Int(2)]); - t.track_delete(k3.clone(), vec![Value::Int(3)]); - assert_eq!(t.pending_count(), 3); - - // First undo restores the most-recent delete (k3). - let _ = t.undo(); - assert_eq!(t.pending_count(), 2); - assert!(matches!(t.row_state(&k3), RowState::Clean)); - assert!(matches!(t.row_state(&k1), RowState::PendingDelete)); - assert!(matches!(t.row_state(&k2), RowState::PendingDelete)); + let earlier = rk(&[Value::Int(9)]); + delete(&mut t, earlier.clone(), vec![Value::Int(9)]); + let keys: Vec = (10..70).map(|id| rk(&[Value::Int(id)])).collect(); + let draft_id = insert(&mut t, vec![Value::Null]); + + let deleted = t.apply(TrackerEdit::DeleteRows { + persisted: keys + .iter() + .map(|key| (key.clone(), vec![Value::Int(0)].into())) + .collect(), + drafts: vec![draft_id], + }); + assert_eq!(deleted.removed_drafts, vec![draft_id]); + assert_eq!(deleted.changed.len(), 61); + assert_eq!(t.pending_count(), 61); + + let undone = t.undo().expect("the delete to undo"); + assert_eq!(undone.changed, keys, "the undo left some of the rows marked"); + assert_eq!(t.pending_count(), 1); + assert_eq!(t.row_state(&earlier), RowState::PendingDelete); + let _ = t.redo(); + assert!(keys.iter().all(|key| t.row_state(key) == RowState::PendingDelete)); + } + + #[test] + fn a_delete_during_a_save_holds_back_only_the_rows_in_it() { + let columns = vec![pk_col("id"), data_col("name")]; + let mut t = saving_on(columns); + let saved_draft = insert(&mut t, vec![Value::Null, text("a")]); + let _batch = t.begin_save().unwrap().unwrap(); + let new_draft = insert(&mut t, vec![Value::Null, text("b")]); + let edit = TrackerEdit::DeleteRows { + persisted: vec![(rk(&[Value::Int(1)]), vec![Value::Int(1)].into())], + drafts: vec![saved_draft, new_draft], + }; + assert!(t.edit_is_in_flight(&edit)); + + let delta = t.apply(edit); + + assert_eq!(delta.removed_drafts, vec![new_draft]); + assert!( + delta.changed.contains(&RowKey::Draft(saved_draft)), + "the held draft is not drawn again" + ); + assert_eq!(t.drafts().len(), 1); + assert_eq!(t.row_state(&rk(&[Value::Int(1)])), RowState::PendingDelete); } #[test] @@ -1562,15 +1641,12 @@ mod tests { original: Value::Text("a".into()), value: Value::Text("b".into()), }); - let deleted = t.apply(TrackerEdit::Delete { - row_key: rk(&[Value::Int(2)]), - original_values: vec![Value::Int(2)], - }); + let deleted = delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); - assert_eq!(edited.rows, vec![key]); - assert!(edited.visual.is_empty()); + assert_eq!(edited.changed, vec![key]); + assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); assert_eq!(edited.pending_count, 1); - assert_eq!(deleted.rows, vec![rk(&[Value::Int(2)])]); + assert_eq!(deleted.changed, vec![rk(&[Value::Int(2)])]); assert_eq!(deleted.pending_count, 2); } @@ -1586,11 +1662,9 @@ mod tests { }); let undone = t.undo(); - assert_eq!( - (edited.rows, edited.visual), - (vec![RowKey::Draft(draft_id)], Vec::new()) - ); - assert_eq!(undone.map(|delta| delta.rows), Some(vec![RowKey::Draft(draft_id)])); + assert_eq!(edited.changed, vec![RowKey::Draft(draft_id)]); + assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); + assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); assert_eq!(t.drafts()[0].values[1], Value::Text("a".into())); } @@ -1625,7 +1699,13 @@ mod tests { value: Value::Int(1), }); - assert!(delta.rows.is_empty() && delta.visual.is_empty()); + assert_eq!( + delta, + TrackerDelta { + pending_count: 1, + ..TrackerDelta::default() + } + ); assert_eq!(delta.pending_count, 1); } @@ -1634,11 +1714,17 @@ mod tests { let mut t = TabChangeTracker::default(); let draft_id = insert(&mut t, vec![Value::Int(7)]); - let undone = t.undo().map(|delta| delta.visual); - let redone = t.redo().map(|delta| delta.visual); + let undone = t.undo().expect("the insert to undo"); + let redone = t.redo().expect("the insert to redo"); - assert_eq!(undone, Some(vec![VisualChange::RemoveDraft { draft_id }])); - assert_eq!(redone, Some(vec![VisualChange::InsertDraft { draft_id }])); + assert_eq!( + (undone.inserted_drafts, undone.removed_drafts), + (Vec::new(), vec![draft_id]) + ); + assert_eq!( + (redone.inserted_drafts, redone.removed_drafts), + (vec![draft_id], Vec::new()) + ); } #[test] @@ -1651,8 +1737,8 @@ mod tests { let redone = t.redo(); assert_eq!( - redone.map(|delta| (delta.rows, delta.visual)), - Some((vec![key], Vec::new())) + redone.map(|delta| (delta.changed, delta.inserted_drafts, delta.removed_drafts)), + Some((vec![key], Vec::new(), Vec::new())) ); } @@ -1667,9 +1753,9 @@ mod tests { let delta = t.discard(); assert_eq!(delta.pending_count, 0); - assert_eq!(delta.visual, vec![VisualChange::RemoveDraft { draft_id }]); - assert_eq!(delta.rows.len(), 2, "{:?}", delta.rows); - assert!(delta.rows.contains(&key) && delta.rows.contains(&RowKey::Draft(draft_id))); + assert_eq!(delta.removed_drafts, vec![draft_id]); + assert_eq!(delta.changed.len(), 2, "{:?}", delta.changed); + assert!(delta.changed.contains(&key) && delta.changed.contains(&RowKey::Draft(draft_id))); } #[test] @@ -1752,7 +1838,7 @@ mod tests { assert_eq!(delta.pending_count, 0); assert_eq!(t.pending_count(), 0); - assert_eq!(delta.rows, vec![rk(&[Value::Int(1)])]); + assert_eq!(delta.changed, vec![rk(&[Value::Int(1)])]); } #[test] @@ -1878,7 +1964,11 @@ mod tests { let after = vec![plain_id, legacy_key, data_col("name")]; let mut t = saving_on(before); t.track_cell_edit(rk(&[Value::Int(1)]), 2, text("Ada"), text("Grace")); - t.track_delete(rk(&[Value::Int(2)]), vec![Value::Int(2), Value::Int(9), text("Bob")]); + delete( + &mut t, + rk(&[Value::Int(2)]), + vec![Value::Int(2), Value::Int(9), text("Bob")], + ); retarget(&mut t, &after); diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 14d73edd59..010105db52 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -108,7 +108,6 @@ mod tests { use tablepro_core::meta::TableRef; use super::*; - use crate::services::tracker_delta::VisualChange; use crate::workspace::{RollbackCause, UnknownCause}; fn row(id: i64) -> RowKey { @@ -205,7 +204,7 @@ mod tests { let delta = tracker.settle_save(&SaveOutcome::Committed { rows: Some(2) }, batch.snapshot); assert_eq!(delta.pending_count, 2); - assert!(matches!(delta.visual.as_slice(), [VisualChange::RemoveDraft { .. }])); + assert_eq!(delta.removed_drafts.len(), 1); assert!(tracker.drafts().is_empty(), "the inserted draft stayed pending"); assert_eq!(tracker.pending_state(), PendingState::Unsaved); assert_eq!(tracker.cell_overlay(&row(1), 1, &Value::Int(0)).value, Value::Int(3)); @@ -218,8 +217,8 @@ mod tests { let inserted = tracker.apply(TrackerEdit::Insert { values: vec![Value::Int(9), Value::Int(9)], }); - let draft_id = match inserted.visual.as_slice() { - [VisualChange::InsertDraft { draft_id, .. }] => *draft_id, + let draft_id = match inserted.inserted_drafts.as_slice() { + [draft_id] => *draft_id, other => panic!("an insert adds one draft row: {other:?}"), }; let batch = tracker.begin_save().expect("a save").expect("changes to save"); @@ -231,11 +230,18 @@ mod tests { }; assert!(tracker.refuses(&typed)); let held = tracker.apply(typed); - let _ = tracker.apply(TrackerEdit::DiscardDraft { draft_id }); + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: Vec::new(), + drafts: vec![draft_id], + }); assert!(tracker.undo().is_none(), "undo ran while a save was in flight"); - assert_eq!(held.rows, [RowKey::Draft(draft_id)], "the typed value stayed on screen"); - assert!(held.visual.is_empty()); + assert_eq!( + held.changed, + [RowKey::Draft(draft_id)], + "the typed value stayed on screen" + ); + assert!(held.inserted_drafts.is_empty() && held.removed_drafts.is_empty()); assert_eq!(tracker.drafts()[0].values[1], Value::Int(9)); let _ = tracker.settle_save(&rolled_back(), batch.snapshot); @@ -256,11 +262,11 @@ mod tests { values: vec![Value::Null, Value::Text("c".into())], }); - assert_eq!(edited.rows, vec![row(1)]); + assert_eq!(edited.changed, vec![row(1)]); assert_eq!(edited.pending_count, 1); assert_eq!(inserted.pending_count, 2); - let [VisualChange::InsertDraft { draft_id }] = inserted.visual.as_slice() else { - panic!("an insert adds one draft row: {:?}", inserted.visual); + let [draft_id] = inserted.inserted_drafts.as_slice() else { + panic!("an insert adds one draft row: {:?}", inserted.inserted_drafts); }; assert_eq!( tracker.draft_values(*draft_id), @@ -284,7 +290,7 @@ mod tests { let undone = tracker.undo().expect("an edit to undo"); - assert_eq!(undone.rows, vec![row(1)]); + assert_eq!(undone.changed, vec![row(1)]); assert_eq!(undone.pending_count, 1); assert_eq!( tracker.cell_overlay(&row(1), 1, &fetched).value, @@ -297,9 +303,9 @@ mod tests { let tracker = TabTrackerHandle::default(); let grid_copy = tracker.clone(); - let _ = tracker.apply(TrackerEdit::Delete { - row_key: row(4), - original_values: vec![Value::Int(4)], + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(row(4), vec![Value::Int(4)].into())], + drafts: Vec::new(), }); assert_eq!(grid_copy.row_state(&row(4)), RowState::PendingDelete); @@ -315,10 +321,10 @@ mod tests { value: Value::Int(2), }); - for key in &delta.rows { - let _ = tracker.apply(TrackerEdit::Delete { - row_key: key.clone(), - original_values: vec![Value::Int(1)], + for key in &delta.changed { + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![(key.clone(), vec![Value::Int(1)].into())], + drafts: Vec::new(), }); assert_eq!(tracker.cell_overlay(key, 0, &Value::Int(1)).value, Value::Int(2)); } diff --git a/linux/crates/app/src/services/tracker_delta.rs b/linux/crates/app/src/services/tracker_delta.rs index 13fd2c8686..5db29b8fff 100644 --- a/linux/crates/app/src/services/tracker_delta.rs +++ b/linux/crates/app/src/services/tracker_delta.rs @@ -5,14 +5,10 @@ use super::change_tracker::RowKey; #[derive(Debug, Clone, Default, PartialEq)] pub struct TrackerDelta { /// Rows whose pending state changed and need a redraw. - pub rows: Vec, - /// Draft rows the list store has to gain or lose. - pub visual: Vec, + pub changed: Vec, + /// Draft rows the list store gains, oldest first. + pub inserted_drafts: Vec, + /// Draft rows the list store loses. + pub removed_drafts: Vec, pub pending_count: usize, } - -#[derive(Debug, Clone, PartialEq)] -pub enum VisualChange { - RemoveDraft { draft_id: u64 }, - InsertDraft { draft_id: u64 }, -} diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs index 4de95bd657..edd172bc17 100644 --- a/linux/crates/app/src/services/tracker_edit.rs +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use tablepro_core::Value; use super::change_tracker::RowKey; @@ -19,22 +21,26 @@ pub enum TrackerEdit { Insert { values: Vec, }, - Delete { - row_key: RowKey, - original_values: Vec, - }, - DiscardDraft { - draft_id: u64, + /// One Delete: the saved rows are marked, each with the row as it + /// was read, and the drafts are dropped. + DeleteRows { + persisted: Vec<(RowKey, Arc<[Value]>)>, + drafts: Vec, }, } impl TrackerEdit { - /// The row the edit changes. An insert makes a new one. - pub fn row_key(&self) -> Option { + /// The rows the edit changes. An insert makes a new one. + pub fn row_keys(&self) -> Vec { match self { - Self::Cell { row_key, .. } | Self::Delete { row_key, .. } => Some(row_key.clone()), - Self::DraftCell { draft_id, .. } | Self::DiscardDraft { draft_id } => Some(RowKey::Draft(*draft_id)), - Self::Insert { .. } => None, + Self::Cell { row_key, .. } => vec![row_key.clone()], + Self::DraftCell { draft_id, .. } => vec![RowKey::Draft(*draft_id)], + Self::DeleteRows { persisted, drafts } => persisted + .iter() + .map(|(row_key, _)| row_key.clone()) + .chain(drafts.iter().map(|draft_id| RowKey::Draft(*draft_id))) + .collect(), + Self::Insert { .. } => Vec::new(), } } } diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs index e8e65cda0a..d7db5ca814 100644 --- a/linux/crates/app/src/services/undo_op.rs +++ b/linux/crates/app/src/services/undo_op.rs @@ -29,13 +29,21 @@ pub enum UndoOp { Insert { draft: DraftRow, }, + /// One Delete over every saved row it marked, so one undo brings + /// them all back. Delete { - row_key: RowKey, - before: Option, - after: DeleteMark, + steps: Vec, }, } +/// One row's delete mark on both sides of a Delete. +#[derive(Debug, Clone, PartialEq)] +pub struct DeleteStep { + pub row_key: RowKey, + pub before: Option, + pub after: DeleteMark, +} + impl UndoOp { /// The step under the columns `layout` moves to, or `None` when a /// value in it has no place there. @@ -87,14 +95,19 @@ impl UndoOp { ..draft.clone() }, }), - UndoOp::Delete { row_key, before, after } => { - layout.key(row_key).ok()?; - Some(UndoOp::Delete { - row_key: row_key.clone(), - before: before.as_ref().map(delete_mark), - after: delete_mark(after), - }) - } + UndoOp::Delete { steps } => Some(UndoOp::Delete { + steps: steps + .iter() + .map(|step| { + layout.key(&step.row_key).ok()?; + Some(DeleteStep { + row_key: step.row_key.clone(), + before: step.before.as_ref().map(delete_mark), + after: delete_mark(&step.after), + }) + }) + .collect::>()?, + }), } } @@ -102,7 +115,8 @@ impl UndoOp { match self { UndoOp::Insert { draft } => draft.draft_id == draft_id, UndoOp::DraftCell { draft_id: id, .. } => *id == draft_id, - UndoOp::CellEdit { row_key, .. } | UndoOp::Delete { row_key, .. } => *row_key == RowKey::Draft(draft_id), + UndoOp::CellEdit { row_key, .. } => *row_key == RowKey::Draft(draft_id), + UndoOp::Delete { steps } => steps.iter().any(|step| step.row_key == RowKey::Draft(draft_id)), } } } diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 2921376c7d..ef75dac899 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -34,6 +34,8 @@ pub struct BrowseTab { #[template_child] pub insert_button: TemplateChild, #[template_child] + pub delete_button: TemplateChild, + #[template_child] pub first_button: TemplateChild, #[template_child] pub prev_button: TemplateChild, @@ -107,11 +109,6 @@ fn install_bindings(klass: &mut ::Class) { none, send(|| BrowseTabInput::ClearSelection, glib::Propagation::Proceed), ); - klass.add_binding( - gdk::Key::Delete, - none, - send(|| BrowseTabInput::DeleteSelectedRow, glib::Propagation::Stop), - ); klass.add_binding( gdk::Key::n, ctrl, diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 1196cd2a61..02a3b5cf62 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -10,6 +10,7 @@ mod browse_request_kind; mod fetch; mod imp; mod page_reply; +mod row_deletion; #[cfg(test)] mod tests; @@ -19,9 +20,9 @@ use std::rc::Rc; use std::sync::Arc; use gtk4 as gtk; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gdk, gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::dialect::SqlDialect; @@ -35,15 +36,16 @@ use tablepro_session::{LinkGeneration, Session}; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; pub(crate) use self::page_reply::PageReply; +use self::row_deletion::{RowDeletion, plan_row_deletion}; use super::grid::{ - CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridRowId, GridRowIndex, GridSink, RowObject, TabGridContext, - build_column_view, cell_editability, + CellPreset, ColumnWidthBinding, DeleteAvailability, GridCell, GridMsg, GridRowId, GridRowIndex, GridSink, + RowObject, TabGridContext, build_column_view, cell_editability, delete_availability, }; use crate::runtime::{FailedRequests, RequestSequencer}; use crate::services::change_tracker::{RowKey, StatementSource, pk_identity}; use crate::services::save_batch::SaveTarget; use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; @@ -106,6 +108,7 @@ impl BrowseTab { ))); imp.inner_stack.set_visible_child_name("loading"); imp.read_only_banner.set_revealed(init.read_only); + imp.delete_button.set_icon_name(crate::ui::icons::LIST_REMOVE); let page_size_labels: Vec = PAGE_SIZE_OPTIONS.iter().map(|n| format_thousands(*n)).collect(); let page_size_strs: Vec<&str> = page_size_labels.iter().map(String::as_str).collect(); @@ -138,6 +141,7 @@ impl BrowseTab { imp.toolbar.add_top_bar(&filter_strip.widget); tab.collapse_top_bars_with(&filter_strip.widget); tab.connect_widgets(); + let delete_action = tab.install_delete_action(); // Handled at once: a tracked change redraws the cells bound to its // row in place, so the cell that sent it is still there for the @@ -201,6 +205,8 @@ impl BrowseTab { filter_badge: imp.filter_badge.get(), filter_strip: Some(filter_strip), insert_button: imp.insert_button.get(), + delete_button: imp.delete_button.get(), + delete_action, pending_revealer: imp.pending_revealer.get(), save_button: imp.save_button.get(), discard_button: imp.discard_button.get(), @@ -444,6 +450,32 @@ impl BrowseTab { }); } + /// `tab.delete-rows`, which the Delete key and the Delete button + /// both name. While it is disabled the key goes on to the window. + fn install_delete_action(&self) -> gio::SimpleAction { + let action = gio::SimpleAction::new("delete-rows", None); + action.set_enabled(false); + let tab = self.downgrade(); + action.connect_activate(move |_, _| { + let Some(tab) = tab.upgrade() else { + return; + }; + let rows = tab.read(BrowseModel::selected_rows).unwrap_or_default(); + tab.send(BrowseTabInput::DeleteRows(rows)); + }); + let group = gio::SimpleActionGroup::new(); + group.add_action(&action); + self.insert_action_group("tab", Some(&group)); + + let shortcuts = gtk::ShortcutController::new(); + shortcuts.add_shortcut(gtk::Shortcut::new( + Some(gtk::KeyvalTrigger::new(gdk::Key::Delete, gdk::ModifierType::empty())), + Some(gtk::NamedAction::new("tab.delete-rows")), + )); + self.add_controller(shortcuts); + action + } + /// Set the focused cell to NULL, for Ctrl+Shift+N. A locked cell /// lets the key go on. fn set_focused_cell_null(&self) -> glib::Propagation { @@ -474,12 +506,12 @@ impl From for BrowseTabInput { GridMsg::CellEdited { row, column, input } => Self::GridCellEdited { row, column, input }, GridMsg::CopyToClipboard(text) => Self::GridCopyToClipboard(text), GridMsg::ShowToast(text) => Self::GridShowToast(text), - GridMsg::CopyRowAsInsert { row } => Self::GridCopyRowAsInsert { row }, + GridMsg::CopyRowsAsInsert { rows } => Self::GridCopyRowsAsInsert(rows), GridMsg::SetCellValue { row, column, preset } => Self::GridSetCellValue { row, column, preset }, GridMsg::ExportResults(result) => Self::GridExportResults(result), - GridMsg::DeleteRowAt { row } => Self::GridDeleteRowAt { row }, + GridMsg::DeleteRows { rows } => Self::DeleteRows(rows), GridMsg::InsertRow => Self::InsertRow, - GridMsg::DuplicateRow { row } => Self::DuplicateRow { row }, + GridMsg::DuplicateRows { rows } => Self::DuplicateRows(rows), } } } @@ -588,6 +620,9 @@ pub(crate) struct BrowseModel { filter_badge: gtk::Label, filter_strip: Option, insert_button: gtk::Button, + delete_button: gtk::Button, + /// `tab.delete-rows`, enabled while the selection can be deleted. + delete_action: gio::SimpleAction, pending_revealer: gtk::Revealer, save_button: gtk::Button, discard_button: gtk::Button, @@ -632,7 +667,8 @@ pub(crate) enum BrowseTabInput { /// Queued after an insert, so the focus lands on the column view /// as it is once the insert is drawn. FocusInsertedDraft, - DeleteSelectedRow, + /// Delete over these rows, from the key, the button or the menu. + DeleteRows(Vec), GridCellEdited { row: RowObject, column: usize, @@ -650,15 +686,8 @@ pub(crate) enum BrowseTabInput { GridShowToast(String), GridExportResults(QueryResult), ExportCurrentPage, - GridDeleteRowAt { - row: RowObject, - }, - GridCopyRowAsInsert { - row: RowObject, - }, - DuplicateRow { - row: RowObject, - }, + GridCopyRowsAsInsert(Vec), + DuplicateRows(Vec), GridCopyToClipboard(String), Undo, Redo, @@ -670,10 +699,7 @@ pub(crate) enum BrowseTabInput { DiscardAndRefresh, /// A delete the user confirmed, or one small enough to need no /// confirmation. - MarkRowsDeleted { - rows: Vec<(RowKey, Vec)>, - drafts: Vec, - }, + MarkRowsDeleted(RowDeletion), /// The save landed. Edits made while it ran stay pending. SaveCommitted, /// The pending changes stay, so the user can fix the cause and @@ -855,16 +881,30 @@ impl BrowseModel { /// row keeps its object, so the selection and the focus stay on it. fn apply_tracker_delta(&mut self, delta: &TrackerDelta, fx: &mut Effects) { if let Some(store) = self.list_store() { - for change in &delta.visual { - apply_visual_change(&store, &mut self.row_index, change); - } + apply_draft_changes(&store, &mut self.row_index, delta); } - for key in &delta.rows { + for key in &delta.changed { if let Some(row) = self.row_index.get(key) { row.bump_revision(); } } self.sync_pending_chrome(delta.pending_count, fx); + self.sync_delete_action(); + } + + fn selected_rows(&self) -> Vec { + self.current_selection.as_ref().map(selected_rows).unwrap_or_default() + } + + /// The rows on screen can change under the selection without it + /// changing, so this runs on every delta and page as well. + fn sync_delete_action(&self) { + show_delete_availability( + &self.delete_action, + &self.delete_button, + &self.selected_rows(), + self.read_only, + ); } /// The footer and the tab's dirty flag, which App turns into the @@ -1108,12 +1148,20 @@ impl BrowseModel { self.rendered_columns = self.current_columns.clone(); // The selection model is new per rebuild, so the previous - // handler goes with the old one. - let selection_label_for_signal = self.selection_label.clone(); + // handler goes with the old one. The tab holds the selection, so + // the handler holds the tab's widgets weakly. if let Some(sel) = self.current_selection.as_ref() { + let label = self.selection_label.downgrade(); + let button = self.delete_button.downgrade(); + let action = self.delete_action.clone(); + let read_only = self.read_only; sel.connect_selection_changed(move |sel, _, _| { + let (Some(label), Some(button)) = (label.upgrade(), button.upgrade()) else { + return; + }; let n = u32::try_from(sel.selection().size()).unwrap_or(u32::MAX); - update_selection_chrome(&selection_label_for_signal, n); + update_selection_chrome(&label, n); + show_delete_availability(&action, &button, &selected_rows(sel), read_only); }); update_selection_chrome(&self.selection_label, 0); } @@ -1122,6 +1170,7 @@ impl BrowseModel { // the tracker holds them, and the rebuilt store would not. self.reprepend_drafts(); self.reindex_rows(); + self.sync_delete_action(); let scrolled = gtk::ScrolledWindow::builder() .child(&column_view) @@ -1159,6 +1208,7 @@ impl BrowseModel { self.rendered_columns.clear(); self.row_index = GridRowIndex::default(); update_selection_chrome(&self.selection_label, 0); + self.sync_delete_action(); self.show_loading(); } @@ -1171,6 +1221,7 @@ impl BrowseModel { } self.reprepend_drafts(); self.reindex_rows(); + self.sync_delete_action(); } fn reindex_rows(&mut self) { @@ -1519,28 +1570,31 @@ impl BrowseModel { self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } - BrowseTabInput::DuplicateRow { row } => { - if self.current_columns.is_empty() || self.grid_is_stale() { + BrowseTabInput::DuplicateRows(rows) => { + if self.current_columns.is_empty() || self.grid_is_stale() || rows.is_empty() { return; } - // With its pending edits, since the copy is of the row the - // user is looking at. - let source_cells = self.grid_context().effective_cells(&row); - // The duplicate is a new row, so values the database owns - // (key, identity, generated) are left to it. - let values: Vec = self - .current_columns - .iter() - .enumerate() - .map(|(i, col)| { - if col.primary_key || col.is_auto_increment || col.is_generated { - Value::Null - } else { - source_cells.get(i).cloned().unwrap_or(Value::Null) - } - }) - .collect(); - self.apply_edit(TrackerEdit::Insert { values }, fx); + let ctx = self.grid_context(); + for row in &rows { + // With its pending edits, since the copy is of the row + // the user is looking at. + let source_cells = ctx.effective_cells(row); + // The duplicate is a new row, so values the database + // owns (key, identity, generated) are left to it. + let values: Vec = self + .current_columns + .iter() + .enumerate() + .map(|(i, col)| { + if col.primary_key || col.is_auto_increment || col.is_generated { + Value::Null + } else { + source_cells.get(i).cloned().unwrap_or(Value::Null) + } + }) + .collect(); + self.apply_edit(TrackerEdit::Insert { values }, fx); + } self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } @@ -1553,7 +1607,7 @@ impl BrowseModel { self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } - BrowseTabInput::DeleteSelectedRow => self.delete_selected_rows(fx), + BrowseTabInput::DeleteRows(rows) => self.delete_rows(&rows, fx), BrowseTabInput::GridCellEdited { row, column, input } => { // The cell shows what was typed until its row is drawn again. if self.refuses_edit(&row, column) { @@ -1618,24 +1672,9 @@ impl BrowseModel { target: Some(self.sql_target()), })); } - BrowseTabInput::GridDeleteRowAt { row } => { - if self.grid_is_stale() { - return; - } - let (Some(row_key), Some(cells)) = (row.key(), row.cells()) else { - return; - }; - self.apply_edit( - TrackerEdit::Delete { - row_key: row_key.clone(), - original_values: cells.to_vec(), - }, - fx, - ); - } - BrowseTabInput::GridCopyRowAsInsert { row } => { + BrowseTabInput::GridCopyRowsAsInsert(rows) => { if !self.grid_is_stale() { - self.copy_row_as_insert(&row, fx); + self.copy_rows_as_insert(&rows, fx); } } BrowseTabInput::GridCopyToClipboard(text) => { @@ -1664,19 +1703,14 @@ impl BrowseModel { self.apply_tracker_delta(&delta, fx); self.fetch(BrowseRequestKind::Page, fx); } - BrowseTabInput::MarkRowsDeleted { rows, drafts } => { - for (row_key, original_values) in rows { - self.apply_edit( - TrackerEdit::Delete { - row_key, - original_values, - }, - fx, - ); - } - for draft_id in drafts { - self.apply_edit(TrackerEdit::DiscardDraft { draft_id }, fx); - } + BrowseTabInput::MarkRowsDeleted(deletion) => { + self.apply_edit( + TrackerEdit::DeleteRows { + persisted: deletion.persisted, + drafts: deletion.drafts, + }, + fx, + ); // A bulk action ends the selection it acted on. if let Some(selection) = self.current_selection.as_ref() { selection.unselect_all(); @@ -1742,50 +1776,34 @@ impl BrowseModel { ); } - /// Mark the selected rows for deletion: persisted rows go to the - /// tracker, drafts are dropped. Nothing leaves the tab until Save. - fn delete_selected_rows(&self, fx: &mut Effects) { - let Some(selection) = self.current_selection.as_ref() else { - return; - }; - let positions = super::grid::selected_positions(selection); - if positions.is_empty() { + /// Mark saved rows for deletion and drop drafts. Nothing leaves the + /// tab until Save. The action is off for rows this cannot delete, so + /// such rows reaching here is a bug, which changes nothing. + fn delete_rows(&self, rows: &[RowObject], fx: &mut Effects) { + if self.grid_is_stale() { return; } - let mut snapshot: Vec<(RowKey, Vec)> = Vec::new(); - let mut draft_ids: Vec = Vec::new(); - let mut had_unkeyed_row = false; - for row in positions - .iter() - .filter_map(|position| selection.item(*position).and_downcast::()) - { - match row.id() { - GridRowId::Draft(draft_id) => draft_ids.push(*draft_id), - GridRowId::Persisted(key) => { - snapshot.push((key.clone(), row.cells().map(|cells| cells.to_vec()).unwrap_or_default())); - } - GridRowId::Unkeyed | GridRowId::Result => had_unkeyed_row = true, - } + if self.read_only { + tracing::error!("Delete reached a read-only tab"); + return; } - // A selection of drafts only needs no key. - if had_unkeyed_row { - fx.output(BrowseTabOutput::ShowSelectionAlert { - title: crate::i18n::gettext("Cannot delete"), - body: crate::i18n::gettext("This table has no primary key, so editing is disabled."), - }); + let deletion = plan_row_deletion(rows); + if deletion.unkeyed > 0 { + tracing::error!(unkeyed = deletion.unkeyed, "Delete reached rows with no key"); return; } - if snapshot.is_empty() && draft_ids.is_empty() { + if deletion.is_empty() { return; } - let count = snapshot.len() + draft_ids.len(); - if count < BULK_DELETE_CONFIRM_THRESHOLD { - fx.input(BrowseTabInput::MarkRowsDeleted { - rows: snapshot, - drafts: draft_ids, - }); + if deletion.len() < BULK_DELETE_CONFIRM_THRESHOLD { + fx.input(BrowseTabInput::MarkRowsDeleted(deletion)); return; } + self.confirm_delete(deletion); + } + + fn confirm_delete(&self, deletion: RowDeletion) { + let count = deletion.len(); let title = crate::i18n::ngettext_f( "Delete {n} row?", "Delete {n} rows?", @@ -1800,55 +1818,58 @@ impl BrowseModel { dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); - let pending = RefCell::new(Some((snapshot, draft_ids))); let tab = self.this.clone(); - dialog.connect_response(None, move |dlg, response| { - dlg.close(); + let parent = self.inner_stack.clone(); + glib::spawn_future_local(async move { + let response = dialog.choose_future(Some(&parent)).await; if response == "delete" - && let Some((rows, drafts)) = pending.borrow_mut().take() && let Some(tab) = tab.upgrade() { - tab.send(BrowseTabInput::MarkRowsDeleted { rows, drafts }); + tab.send(BrowseTabInput::MarkRowsDeleted(deletion)); } }); - dialog.present(Some(&self.inner_stack)); } - /// One row as an INSERT for the server the tab is connected to, + /// Each row as an INSERT for the server the tab is connected to, /// pending edits included. A draft's is the INSERT its save would /// run, so the columns the server fills stay out of it. - fn copy_row_as_insert(&self, row: &RowObject, fx: &mut Effects) { + fn copy_rows_as_insert(&self, rows: &[RowObject], fx: &mut Effects) { let Some(dialect) = self.session.current_dialect() else { fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( "Connect to a database first.", ))); return; }; - let cells = match row.id() { - GridRowId::Draft(draft_id) => self - .tracker - .draft_cells(*draft_id, &self.current_columns) - .unwrap_or_default(), - _ => self - .grid_context() - .effective_cells(row) - .into_iter() - .map(CellInput::Value) - .collect(), - }; - match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { - Ok(sql) => fx.output(BrowseTabOutput::CopyToClipboard(sql)), - Err(error) => { - tracing::debug!(%error, "a row could not be written as an INSERT"); - let message = match error { - RenderInsertError::Literal(_) => { - crate::i18n::gettext("This row has a value that can't be written as SQL") - } - RenderInsertError::NoValues => crate::i18n::gettext("This row has no value to put in an INSERT"), - }; - fx.output(BrowseTabOutput::ShowToast(message)); + let ctx = self.grid_context(); + let mut statements = Vec::with_capacity(rows.len()); + for row in rows { + let cells = match row.id() { + GridRowId::Draft(draft_id) => self + .tracker + .draft_cells(*draft_id, &self.current_columns) + .unwrap_or_default(), + _ => ctx.effective_cells(row).into_iter().map(CellInput::Value).collect(), + }; + match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { + Ok(sql) => statements.push(sql), + Err(error) => { + tracing::debug!(%error, "a row could not be written as an INSERT"); + let message = match error { + RenderInsertError::Literal(_) => { + crate::i18n::gettext("This row has a value that can't be written as SQL") + } + RenderInsertError::NoValues => { + crate::i18n::gettext("This row has no value to put in an INSERT") + } + }; + fx.output(BrowseTabOutput::ShowToast(message)); + return; + } } } + if !statements.is_empty() { + fx.output(BrowseTabOutput::CopyToClipboard(statements.join("\n"))); + } } /// Same renderer as the context menu's Copy as > Rows: one @@ -1887,21 +1908,40 @@ fn qualified(schema: Option<&str>, table: &str) -> String { } } -fn apply_visual_change(store: >k::gio::ListStore, index: &mut GridRowIndex, change: &VisualChange) { - match change { - VisualChange::InsertDraft { draft_id } => { - let row = RowObject::draft(*draft_id); - store.insert(0, &row); - index.insert(&row); - } - VisualChange::RemoveDraft { draft_id } => { - let key = RowKey::Draft(*draft_id); - if let Some(position) = index.position_in(store, &key) { - store.remove(position); - } - index.remove(&key); +/// The one place the store gains or loses draft rows. +fn apply_draft_changes(store: >k::gio::ListStore, index: &mut GridRowIndex, delta: &TrackerDelta) { + for draft_id in &delta.removed_drafts { + let key = RowKey::Draft(*draft_id); + if let Some(position) = index.position_in(store, &key) { + store.remove(position); } + index.remove(&key); } + for draft_id in &delta.inserted_drafts { + let row = RowObject::draft(*draft_id); + store.insert(0, &row); + index.insert(&row); + } +} + +fn selected_rows(selection: >k::MultiSelection) -> Vec { + super::grid::selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() +} + +/// The Delete key and button follow the action; the button's tooltip +/// says why it is off. +fn show_delete_availability(action: &gio::SimpleAction, button: >k::Button, rows: &[RowObject], read_only: bool) { + let availability = delete_availability(rows.iter().map(RowObject::id), read_only); + action.set_enabled(availability == DeleteAvailability::Available); + let explanation = match availability { + DeleteAvailability::Unavailable(block) => block.explanation(), + DeleteAvailability::Available => None, + }; + let tooltip = explanation.unwrap_or_else(|| crate::i18n::gettext("Delete selected rows (Delete)")); + button.set_tooltip_text(Some(&tooltip)); } fn clear_box(b: >k::Box) { diff --git a/linux/crates/app/src/ui/browse_tab/row_deletion.rs b/linux/crates/app/src/ui/browse_tab/row_deletion.rs new file mode 100644 index 0000000000..f0f696bd97 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/row_deletion.rs @@ -0,0 +1,69 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +use crate::services::change_tracker::RowKey; +use crate::ui::grid::{GridRowId, RowObject}; + +/// What one Delete does to the rows it was given. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RowDeletion { + /// Saved rows to mark, each with the row as it was read. + pub persisted: Vec<(RowKey, Arc<[Value]>)>, + pub drafts: Vec, + /// Rows a DELETE has no key to name by. + pub unkeyed: usize, +} + +impl RowDeletion { + pub fn len(&self) -> usize { + self.persisted.len() + self.drafts.len() + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +pub fn plan_row_deletion(rows: &[RowObject]) -> RowDeletion { + let mut deletion = RowDeletion::default(); + for row in rows { + match row.id() { + GridRowId::Draft(draft_id) => deletion.drafts.push(*draft_id), + GridRowId::Persisted(key) => deletion.persisted.push((key.clone(), row.cells().unwrap_or_default())), + GridRowId::Unkeyed | GridRowId::Result => deletion.unkeyed += 1, + } + } + deletion +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::plan_row_deletion; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + #[test] + fn plan_row_deletion_partitions_drafts_persisted_and_unkeyed() { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let rows = [ + RowObject::draft(4), + RowObject::persisted(Some(key.clone()), vec![Value::Int(1), Value::Text("a".into())].into()), + RowObject::persisted(None, vec![Value::Int(2)].into()), + RowObject::result(vec![Value::Int(3)].into()), + RowObject::draft(5), + ]; + + let deletion = plan_row_deletion(&rows); + + assert_eq!(deletion.drafts, vec![4, 5]); + assert_eq!( + deletion.persisted, + vec![(key, vec![Value::Int(1), Value::Text("a".into())].into())] + ); + assert_eq!(deletion.unkeyed, 2); + assert_eq!(deletion.len(), 3); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 2857592b22..8d09ec66bf 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -11,13 +11,14 @@ use tablepro_session::Session; use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; +use super::row_deletion::RowDeletion; use super::{ - BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_visual_change, format_thousands, parse_input_for_column, + BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands, parse_input_for_column, persisted_key, }; use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; -use crate::services::tracker_delta::{TrackerDelta, VisualChange}; +use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; use crate::test_support::{FakeSession, drain_main_context, wait_until}; use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; @@ -262,16 +263,24 @@ fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { } #[test] -fn visual_changes_add_and_remove_draft_rows() { +fn draft_changes_add_and_remove_draft_rows() { let store = gtk4::gio::ListStore::new::(); store.append(&RowObject::persisted(None, vec![Value::Int(1)].into())); let mut index = GridRowIndex::default(); + let inserted = TrackerDelta { + inserted_drafts: vec![4], + ..TrackerDelta::default() + }; + let removed = TrackerDelta { + removed_drafts: vec![4], + ..TrackerDelta::default() + }; - apply_visual_change(&store, &mut index, &VisualChange::InsertDraft { draft_id: 4 }); + apply_draft_changes(&store, &mut index, &inserted); assert_eq!(row_at(&store, 0).draft_id(), Some(4)); assert_eq!(index.get(&RowKey::Draft(4)), Some(row_at(&store, 0))); - apply_visual_change(&store, &mut index, &VisualChange::RemoveDraft { draft_id: 4 }); + apply_draft_changes(&store, &mut index, &removed); assert_eq!(store.n_items(), 1); assert_eq!(row_at(&store, 0).draft_id(), None); assert_eq!(index.get(&RowKey::Draft(4)), None); @@ -862,10 +871,10 @@ fn an_edit_held_for_the_running_save_says_so_once() { let _saving = tracker.begin_save().expect("a save").expect("the drafts"); let drafts: Vec = tracker.drafts().iter().map(|draft| draft.draft_id).collect(); - harness.tab().send(BrowseTabInput::MarkRowsDeleted { - rows: Vec::new(), + harness.tab().send(BrowseTabInput::MarkRowsDeleted(RowDeletion { drafts, - }); + ..RowDeletion::default() + })); assert_eq!(tracker.drafts().len(), 2, "a draft the save holds was dropped"); assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); @@ -910,7 +919,7 @@ fn duplicate_row_copies_pending_edits_of_persisted_row() { column: 1, input: "Ada Lovelace".to_owned(), }); - harness.tab().send(BrowseTabInput::DuplicateRow { row: ada }); + harness.tab().send(BrowseTabInput::DuplicateRows(vec![ada])); let drafts = harness.tracker.drafts(); assert_eq!(drafts.len(), 1, "{drafts:?}"); @@ -942,7 +951,7 @@ fn copy_row_as_insert_uses_row_below_draft() { let below_draft = harness.grid_row(1); harness .tab() - .send(BrowseTabInput::GridCopyRowAsInsert { row: below_draft }); + .send(BrowseTabInput::GridCopyRowsAsInsert(vec![below_draft])); assert_eq!( *copied.borrow(), @@ -1201,7 +1210,7 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { column: 1, input: "x".to_owned(), }); - harness.tab().send(BrowseTabInput::GridCopyRowAsInsert { row: draft }); + harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(vec![draft])); assert_eq!( *copied.borrow(), @@ -1362,3 +1371,249 @@ fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { ); assert_eq!(harness.visible_page().as_deref(), Some("loading")); } + +fn delete_action(tab: &BrowseTab) -> gtk4::gio::SimpleAction { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp() + .model + .borrow() + .as_ref() + .map(|model| model.delete_action.clone()) + .expect("the tab has a model") +} + +fn delete_rows(tab: &BrowseTab) { + tab.activate_action("tab.delete-rows", None) + .expect("the tab has a delete action"); +} + +fn draft_rows(store: >k4::gio::ListStore) -> usize { + (0..store.n_items()) + .filter(|position| row_at(store, *position).draft_id().is_some()) + .count() +} + +fn pending_bar_shown(tab: &BrowseTab) -> bool { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().pending_revealer.reveals_child() +} + +#[gtk4::test] +fn deleting_a_draft_removes_its_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let store = harness.store(); + assert_eq!(store.n_items(), 3); + harness.selection().select_item(0, true); + assert!( + delete_action(harness.tab()).is_enabled(), + "a draft could not be deleted" + ); + + delete_rows(harness.tab()); + + assert_eq!(store.n_items(), 2, "the dropped draft stayed in the grid"); + assert_eq!(draft_rows(&store), 0); + assert_eq!(harness.tracker.pending_count(), 0); + assert!( + !pending_bar_shown(harness.tab()), + "the footer still counts the dropped draft" + ); +} + +#[gtk4::test] +fn the_delete_key_names_the_tab_action() { + let harness = Harness::open(serving_table(), []); + let controllers = harness.tab().observe_controllers(); + let named: Vec = (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .flat_map(|controller| { + (0..controller.n_items()) + .filter_map(move |index| controller.item(index).and_downcast::()) + .collect::>() + }) + .filter(|shortcut| { + shortcut + .trigger() + .and_downcast::() + .is_some_and(|trigger| trigger.keyval() == gtk4::gdk::Key::Delete) + }) + .filter_map(|shortcut| shortcut.action().and_downcast::()) + .map(|action| action.action_name().to_string()) + .collect(); + + assert_eq!(named, ["tab.delete-rows"]); +} + +#[gtk4::test] +fn deleting_mixed_selection_removes_drafts_and_marks_persisted() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + harness.selection().select_range(0, 2, true); + + delete_rows(harness.tab()); + + let store = harness.store(); + assert_eq!(store.n_items(), 2); + assert_eq!(draft_rows(&store), 0); + assert_eq!( + harness.tracker.row_state(&key(0)), + crate::services::change_tracker::RowState::PendingDelete + ); + assert_eq!( + harness.tracker.row_state(&key(1)), + crate::services::change_tracker::RowState::Clean + ); + assert_eq!(harness.tracker.pending_count(), 1); + assert!( + crate::ui::grid::selected_positions(&harness.selection()).is_empty(), + "the delete kept the selection it acted on" + ); +} + +fn menu_gesture(cell: &GridCell) -> gtk4::GestureClick { + let controllers = cell.observe_controllers(); + (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + .find(|gesture| gesture.button() == 3) + .expect("a cell answers a right-click") +} + +fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { + crate::test_support::first_descendant_of_type::(tab).expect("the grid") +} + +#[gtk4::test] +fn menu_delete_inside_selection_marks_whole_selection() { + let names: Vec = (0..20).map(|n| format!("user {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&names))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 20).expect("the first page"); + use libadwaita::prelude::{AdwDialogExt, AdwWindowExt}; + let window = libadwaita::Window::new(); + window.set_default_size(600, 400); + window.set_content(Some(harness.tab())); + window.present(); + harness.selection().select_all(); + let cell = grid_cell_of(harness.tab(), 2, 1); + + menu_gesture(&cell).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + column_view(harness.tab()) + .activate_action("cell.delete-row", None) + .expect("the menu's Delete"); + let mut dialog = None; + wait_until(WAIT, || { + dialog = window.visible_dialog().and_downcast::(); + dialog.is_some() + }) + .expect("a delete of 20 rows was not confirmed first"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "the rows were marked before the answer" + ); + let dialog = dialog.expect("the confirmation"); + dialog.emit_by_name::<()>("response", &[&"delete"]); + + wait_until(WAIT, || harness.tracker.pending_count() == 20).expect("the whole selection was not marked"); + dialog.force_close(); + window.set_content(None::<>k4::Widget>); + window.destroy(); +} + +#[gtk4::test] +fn undo_insert_removes_draft_row() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let store = harness.store(); + assert_eq!(store.n_items(), 3); + + harness.tab().send(BrowseTabInput::Undo); + + assert_eq!(store.n_items(), 2, "the undone draft stayed in the grid"); + assert_eq!(draft_rows(&store), 0); + assert_eq!(harness.tracker.pending_count(), 0); +} + +#[gtk4::test] +fn redo_insert_restores_draft_row_once() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + harness.tab().send(BrowseTabInput::Undo); + + harness.tab().send(BrowseTabInput::Redo); + + let store = harness.store(); + assert_eq!(store.n_items(), 3); + assert_eq!(draft_rows(&store), 1, "the redone draft is in the grid more than once"); + assert!(row_at(&store, 0).draft_id().is_some(), "the redone draft is not on top"); +} + +#[gtk4::test] +fn delete_action_disabled_for_unkeyed_selection() { + let columns = [column("id", false), column("name", false)]; + let mut script = serving_table(); + script.fetch_columns.always(FakeReply::ok(columns.to_vec())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![ + vec![Value::Int(0), Value::Text("Ada".into())], + vec![Value::Int(1), Value::Text("Grace".into())], + ], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let action = delete_action(harness.tab()); + + harness.selection().select_item(0, true); + + assert!(!action.is_enabled(), "Delete was offered for a row with no key"); + let button = { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + harness.tab().imp().delete_button.get() + }; + assert_eq!( + button.tooltip_text().as_deref(), + Some("Rows without a primary key or unique key can't be deleted here") + ); + let view = column_view(harness.tab()); + let mut cell = None; + wait_until(WAIT, || { + cell = crate::test_support::descendants(&view) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.position() == Some(0)); + cell.is_some() + }) + .expect("the row's cell is drawn"); + menu_gesture(&cell.expect("a cell")).emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); + // A menu item is a model button bound to the action's enabled state, + // with the item's label as its text. + let items: Vec = crate::test_support::descendants(&view) + .into_iter() + .filter(|widget| widget.type_().name() == "GtkModelButton") + .filter(|widget| widget.property::>("text").as_deref() == Some("Delete")) + .collect(); + assert!(!items.is_empty(), "the menu has no Delete item"); + wait_until(WAIT, || items.iter().all(|item| !item.is_sensitive())) + .expect("the menu offered Delete for a row with no key"); + view.activate_action("cell.delete-row", None) + .expect("the menu's Delete"); + delete_rows(harness.tab()); + assert_eq!(harness.tracker.pending_count(), 0); +} diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 659e8c861f..a95d8abf9a 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -307,7 +307,7 @@ mod tests { use super::build_column_view; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_delta::{TrackerDelta, VisualChange}; + use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; use crate::test_support::{descendants, drain_main_context, wait_until}; use crate::ui::grid::cell_editability::column_takes_edits; @@ -452,7 +452,7 @@ mod tests { let delta = self.tracker.apply(TrackerEdit::Insert { values: vec![Value::Null, Value::Null], }); - let Some(VisualChange::InsertDraft { draft_id }) = delta.visual.into_iter().next() else { + let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); }; self.store.insert(0, &RowObject::draft(draft_id)); @@ -475,7 +475,7 @@ mod tests { fn redraw(&self, delta: &TrackerDelta) { for position in 0..self.store.n_items() { if let Some(row) = self.store.item(position).and_downcast::() - && row.id().tracker_key().is_some_and(|key| delta.rows.contains(&key)) + && row.id().tracker_key().is_some_and(|key| delta.changed.contains(&key)) { row.bump_revision(); } diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs index c12b256f10..4bc3be8d21 100644 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -3,6 +3,7 @@ use gtk4::{self as gtk, glib}; use super::GridCell; use super::grid_menus::GridMenus; +use super::menu_rows::menu_rows_of; use super::selection::select_row_for_menu; /// The cell a menu was opened on. The cell holds the menu through its @@ -42,10 +43,10 @@ pub(super) fn attach_cell_gesture( let menus = menus.clone(); move |cell: &GridCell, column_view: >k::ColumnView| -> gtk::PopoverMenu { menus.context.set(cell); - menus.actions.arm(&target); if let Some(position) = cell.position() { select_row_for_menu(column_view, position); } + menus.actions.arm(&target, &menu_rows_of(column_view, cell)); match cell.lock() { None => menus.editable_popover.clone(), Some(_) => menus.readonly_popover.clone(), diff --git a/linux/crates/app/src/ui/grid/delete_availability.rs b/linux/crates/app/src/ui/grid/delete_availability.rs new file mode 100644 index 0000000000..89d760f7a5 --- /dev/null +++ b/linux/crates/app/src/ui/grid/delete_availability.rs @@ -0,0 +1,92 @@ +use super::GridRowId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteAvailability { + Available, + Unavailable(DeleteBlock), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeleteBlock { + NoRows, + /// A DELETE names a row by its key, and these rows have none. + UnkeyedRows, + ReadOnlySession, +} + +impl DeleteBlock { + /// Why Delete is off, for its button's tooltip. `None` when nothing + /// needs saying: there is just nothing selected. + pub fn explanation(self) -> Option { + match self { + Self::NoRows => None, + Self::UnkeyedRows => Some(crate::i18n::gettext( + "Rows without a primary key or unique key can't be deleted here", + )), + Self::ReadOnlySession => Some(crate::i18n::gettext("This connection is read-only")), + } + } +} + +/// Whether Delete can act on `rows`. A draft needs no key: deleting it +/// only drops it. +pub fn delete_availability<'a>(rows: impl IntoIterator, read_only: bool) -> DeleteAvailability { + if read_only { + return DeleteAvailability::Unavailable(DeleteBlock::ReadOnlySession); + } + let mut any = false; + for row in rows { + match row { + GridRowId::Persisted(_) | GridRowId::Draft(_) => any = true, + GridRowId::Unkeyed | GridRowId::Result => { + return DeleteAvailability::Unavailable(DeleteBlock::UnkeyedRows); + } + } + } + match any { + true => DeleteAvailability::Available, + false => DeleteAvailability::Unavailable(DeleteBlock::NoRows), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + + use super::{DeleteAvailability, DeleteBlock, delete_availability}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + #[test] + fn delete_availability_blocks_unkeyed_result_and_read_only() { + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft = GridRowId::Draft(2); + let blocked = |block| DeleteAvailability::Unavailable(block); + + assert_eq!( + delete_availability(&[saved.clone(), draft.clone()], false), + DeleteAvailability::Available + ); + assert_eq!( + delete_availability(std::slice::from_ref(&draft), false), + DeleteAvailability::Available, + "a draft needs no key to be dropped" + ); + assert_eq!(delete_availability(&[], false), blocked(DeleteBlock::NoRows)); + assert_eq!( + delete_availability(&[saved.clone(), GridRowId::Unkeyed], false), + blocked(DeleteBlock::UnkeyedRows) + ); + assert_eq!( + delete_availability(&[GridRowId::Result], false), + blocked(DeleteBlock::UnkeyedRows) + ); + assert_eq!( + delete_availability(&[saved], true), + blocked(DeleteBlock::ReadOnlySession) + ); + assert_eq!(DeleteBlock::NoRows.explanation(), None); + assert!(DeleteBlock::UnkeyedRows.explanation().is_some()); + assert!(DeleteBlock::ReadOnlySession.explanation().is_some()); + } +} diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index 5a8643c219..ef3bcd6655 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -7,27 +7,37 @@ use tablepro_core::column::ResultColumn; use super::context_menu::{CellContext, CellMenuTarget}; use super::inline_edit::enter_edit_mode; +use super::menu_rows::menu_rows_of; use super::row_json_dialog::show_row_json_dialog; -use super::selection::{export_snapshot, rows_for_menu}; -use super::{CellPreset, GridMsg, GridSink, TabGridContext}; +use super::selection::export_snapshot; +use super::{CellPreset, DeleteAvailability, GridMsg, GridSink, RowObject, TabGridContext, delete_availability}; -/// Enabled per right-clicked cell. Their items are hidden while -/// disabled, so a cell they do not apply to shows no dead entry. `None` -/// on a read-only grid, which registers neither. +/// Enabled per right-clicked cell. `None` on a read-only grid, which +/// registers none of them. #[derive(Clone, Default)] pub(super) struct CellActions { + /// Hidden while disabled, so a cell they do not apply to shows no + /// dead entry. edit: Option, set_empty: Option, + /// Shown insensitive while disabled, since a menu item cannot carry + /// a tooltip to say why. + delete_row: Option, } impl CellActions { - pub(super) fn arm(&self, target: &CellMenuTarget) { + /// `rows` is what the menu acts on, read when it opens. + pub(super) fn arm(&self, target: &CellMenuTarget, rows: &[RowObject]) { if let Some(edit) = &self.edit { edit.set_enabled(target.text_editable); } if let Some(set_empty) = &self.set_empty { set_empty.set_enabled(target.accepts_empty); } + if let Some(delete_row) = &self.delete_row { + let availability = delete_availability(rows.iter().map(RowObject::id), false); + delete_row.set_enabled(availability == DeleteAvailability::Available); + } } } @@ -187,14 +197,24 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { )), } } + // The rows each item acts on are read when it is chosen, from the + // row the cell shows then. + macro_rules! rows_action { + ($name:literal, |$cell:ident, $rows:ident| $body:expr) => {{ + let view = view.clone(); + cell_action!($name, |$cell, _row| { + let Some(cv) = view.upgrade() else { return }; + let $rows = menu_rows_of(&cv, $cell); + $body; + }) + }}; + } macro_rules! copy_action { ($name:literal, |$rows:ident| $message:expr) => {{ let s = sink.clone(); - let view = view.clone(); let tab = tab.clone(); - cell_action!($name, |_cell, row| { - let Some(cv) = view.upgrade() else { return }; - let $rows = rows_for_menu(&cv, &tab, &row); + rows_action!($name, |_cell, rows| { + let $rows: Vec<_> = rows.iter().map(|row| tab.effective_cells(row)).collect(); s($message); }) }}; @@ -213,12 +233,10 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { // at the 10k display cap. let copy_action = { let s = sink.clone(); - let view = view.clone(); let tab = tab.clone(); let cols = columns.clone(); - cell_action!("copy", |cell, row| { - let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, &row); + rows_action!("copy", |cell, rows| { + let rows: Vec<_> = rows.iter().map(|row| tab.effective_cells(row)).collect(); let column = cell.column(); let message = match rows.as_slice() { [row] => GridMsg::CopyToClipboard( @@ -277,11 +295,9 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { // shorter list that quietly selects different rows. let copy_in_clause_action = { let s = sink.clone(); - let view = view.clone(); let tab = tab.clone(); - cell_action!("copy-in-clause", |cell, row| { - let Some(cv) = view.upgrade() else { return }; - let rows = rows_for_menu(&cv, &tab, &row); + rows_action!("copy-in-clause", |cell, rows| { + let rows: Vec<_> = rows.iter().map(|row| tab.effective_cells(row)).collect(); let clause = tablepro_core::export::render_in_clause(&rows, cell.column()); if clause.sql.is_empty() { s(GridMsg::ShowToast(crate::i18n::gettext( @@ -306,7 +322,10 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { .unwrap_or_default() )) }; - let copy_row_insert_action = send_action!("copy-row-insert", |_cell, row| GridMsg::CopyRowAsInsert { row }); + let copy_row_insert_action = { + let s = sink.clone(); + rows_action!("copy-row-insert", |_cell, rows| s(GridMsg::CopyRowsAsInsert { rows })) + }; let show_row_json_action = { let cols = columns.clone(); let view = view.clone(); @@ -345,8 +364,14 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { .activate(move |_, _, _| s(GridMsg::InsertRow)) .build() }; - let delete_row_action = send_action!("delete-row", |_cell, row| GridMsg::DeleteRowAt { row }); - let duplicate_row_action = send_action!("duplicate-row", |_cell, row| GridMsg::DuplicateRow { row }); + let delete_row_action = { + let s = sink.clone(); + rows_action!("delete-row", |_cell, rows| s(GridMsg::DeleteRows { rows })) + }; + let duplicate_row_action = { + let s = sink.clone(); + rows_action!("duplicate-row", |_cell, rows| s(GridMsg::DuplicateRows { rows })) + }; let mut entries = vec![ copy_action, @@ -381,6 +406,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let actions = CellActions { edit: editable.then(|| simple_action(&group, "edit")).flatten(), set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), + delete_row: editable.then(|| simple_action(&group, "delete-row")).flatten(), }; // Popovers are parented eagerly so each PopoverMenu's action muxer diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs index 9cb92366ea..17b11b910d 100644 --- a/linux/crates/app/src/ui/grid/grid_msg.rs +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -20,8 +20,8 @@ pub enum GridMsg { }, CopyToClipboard(String), ShowToast(String), - CopyRowAsInsert { - row: RowObject, + CopyRowsAsInsert { + rows: Vec, }, /// An intent, not a `Value`: the grid does not know the column's /// declared type, and `Text("")` in an `int` or a `date` column is an @@ -32,11 +32,11 @@ pub enum GridMsg { preset: CellPreset, }, ExportResults(QueryResult), - DeleteRowAt { - row: RowObject, + DeleteRows { + rows: Vec, }, InsertRow, - DuplicateRow { - row: RowObject, + DuplicateRows { + rows: Vec, }, } diff --git a/linux/crates/app/src/ui/grid/menu_rows.rs b/linux/crates/app/src/ui/grid/menu_rows.rs new file mode 100644 index 0000000000..6fc794b514 --- /dev/null +++ b/linux/crates/app/src/ui/grid/menu_rows.rs @@ -0,0 +1,81 @@ +use gtk4 as gtk; +use gtk4::prelude::*; + +use super::selection::{selected_positions, selection_of}; +use super::{GridCell, RowObject}; + +/// The rows a context menu item acts on: the whole selection when the +/// clicked row is in it, else the clicked row alone, so rows that moved +/// since the click never stand in for it. +pub fn menu_rows(selection: >k::MultiSelection, clicked: &RowObject, clicked_position: u32) -> Vec { + let clicked_is_selected = selection.is_selected(clicked_position) + && selection.item(clicked_position).and_downcast_ref::() == Some(clicked); + if !clicked_is_selected { + return vec![clicked.clone()]; + } + selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .collect() +} + +/// The rows a menu opened on `cell` acts on, from the row the cell +/// shows when the item is chosen. +pub(super) fn menu_rows_of(column_view: >k::ColumnView, cell: &GridCell) -> Vec { + let Some(row) = cell.row() else { + return Vec::new(); + }; + match (selection_of(column_view), cell.position()) { + (Some(selection), Some(position)) => menu_rows(&selection, &row, position), + _ => vec![row], + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use gtk4::gio; + use gtk4::prelude::*; + use tablepro_core::Value; + + use super::menu_rows; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::RowObject; + + fn rows(n: i64) -> (gtk::MultiSelection, Vec) { + let store = gio::ListStore::new::(); + let rows: Vec = (0..n) + .map(|id| RowObject::persisted(RowKey::from_pk_values(&[Value::Int(id)]), vec![Value::Int(id)].into())) + .collect(); + for row in &rows { + store.append(row); + } + (gtk::MultiSelection::new(Some(store)), rows) + } + + #[gtk4::test] + fn menu_rows_uses_selection_when_clicked_row_is_selected() { + let (selection, rows) = rows(5); + selection.select_item(3, false); + selection.select_item(1, false); + + assert_eq!( + menu_rows(&selection, &rows[3], 3), + vec![rows[1].clone(), rows[3].clone()], + "the rows are not the selection in position order" + ); + } + + #[gtk4::test] + fn menu_rows_uses_only_clicked_row_outside_selection() { + let (selection, rows) = rows(5); + selection.select_range(0, 2, true); + + assert_eq!(menu_rows(&selection, &rows[4], 4), vec![rows[4].clone()]); + assert_eq!( + menu_rows(&selection, &rows[4], 1), + vec![rows[4].clone()], + "a selected row at the clicked position stood in for the clicked one" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 6b41ae1917..63c6a1eb4e 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -9,6 +9,7 @@ mod column_drawing; mod column_factory; mod column_width_binding; mod context_menu; +mod delete_availability; mod display_text; mod edit_snapshot; mod grid_cell; @@ -17,6 +18,7 @@ mod grid_msg; mod grid_row_id; mod grid_row_index; mod inline_edit; +mod menu_rows; mod row_json_dialog; mod row_object; mod selection; @@ -28,6 +30,7 @@ pub(crate) use self::cell_lock::CellLock; pub(crate) use self::cell_preset::CellPreset; pub(crate) use self::column_factory::build_column_view; pub(crate) use self::column_width_binding::ColumnWidthBinding; +pub(crate) use self::delete_availability::{DeleteAvailability, delete_availability}; pub(crate) use self::edit_snapshot::EditSnapshot; pub(crate) use self::grid_cell::GridCell; pub(crate) use self::grid_msg::{GridMsg, GridSink}; diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs index d6532de30f..69621ceb8f 100644 --- a/linux/crates/app/src/ui/grid/selection.rs +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -1,8 +1,8 @@ use gtk4 as gtk; use gtk4::prelude::*; +use tablepro_core::QueryResult; use tablepro_core::column::ResultColumn; -use tablepro_core::{QueryResult, Value}; use super::{RowObject, TabGridContext}; @@ -20,7 +20,7 @@ pub(super) fn row_at(column_view: >k::ColumnView, position: u32) -> Option().ok() } -fn selection_of(column_view: >k::ColumnView) -> Option { +pub(super) fn selection_of(column_view: >k::ColumnView) -> Option { column_view.model()?.downcast::().ok() } @@ -37,25 +37,6 @@ pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) selection.select_item(position, true); } -/// The selection when it holds the clicked row, else the clicked row -/// alone, so rows that moved since the click never stand in for it. -pub(super) fn rows_for_menu( - column_view: >k::ColumnView, - ctx: &TabGridContext, - clicked: &RowObject, -) -> Vec> { - let selected: Vec = selection_of(column_view) - .map(|selection| selected_positions(&selection)) - .unwrap_or_default() - .into_iter() - .filter_map(|position| row_at(column_view, position)) - .collect(); - if !selected.contains(clicked) { - return vec![ctx.effective_cells(clicked)]; - } - selected.iter().map(|row| ctx.effective_cells(row)).collect() -} - /// The paginator's Export button and the menu's Export Results both /// build their payload here, so one label cannot mean two files. pub(crate) fn export_snapshot( diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index f15e6ffae8..3886b53147 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -88,7 +88,6 @@ mod tests { use super::{RowObject, TabGridContext}; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; - use crate::services::tracker_delta::VisualChange; use crate::services::tracker_edit::TrackerEdit; fn context(tracker: &TabTrackerHandle) -> TabGridContext { @@ -100,7 +99,7 @@ mod tests { fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { let delta = tracker.apply(TrackerEdit::Insert { values }); - let Some(VisualChange::InsertDraft { draft_id }) = delta.visual.into_iter().next() else { + let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); }; draft_id diff --git a/linux/crates/app/src/ui/icons.rs b/linux/crates/app/src/ui/icons.rs index 32b9bdfa5f..3665033c59 100644 --- a/linux/crates/app/src/ui/icons.rs +++ b/linux/crates/app/src/ui/icons.rs @@ -14,6 +14,7 @@ pub const GO_LAST: &str = "go-last-symbolic"; pub const GO_NEXT: &str = "go-next-symbolic"; pub const GO_PREVIOUS: &str = "go-previous-symbolic"; pub const LIST_ADD: &str = "list-add-symbolic"; +pub const LIST_REMOVE: &str = "list-remove-symbolic"; pub const MEDIA_PLAYBACK_STOP: &str = "media-playback-stop-symbolic"; pub const NETWORK_SERVER: &str = "network-server-symbolic"; pub const OPEN_MENU: &str = "open-menu-symbolic"; @@ -59,6 +60,7 @@ pub const ALL: &[&str] = &[ GO_NEXT, GO_PREVIOUS, LIST_ADD, + LIST_REMOVE, MEDIA_PLAYBACK_STOP, NETWORK_SERVER, OPEN_MENU, diff --git a/linux/crates/app/tests/tracker_save_postgres.rs b/linux/crates/app/tests/tracker_save_postgres.rs index 4c4582ecfe..d944cf3a30 100644 --- a/linux/crates/app/tests/tracker_save_postgres.rs +++ b/linux/crates/app/tests/tracker_save_postgres.rs @@ -99,9 +99,12 @@ async fn replace_by_unique_key_saves() { let table = TableRef::new(Some("public".to_owned()), "users"); let columns = connection.fetch_columns(&table, within()).await.expect("the columns"); let tracker = TabTrackerHandle::default(); - let _ = tracker.apply(TrackerEdit::Delete { - row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), - original_values: vec![Value::Int(1), Value::Text("a".to_owned())], + let _ = tracker.apply(TrackerEdit::DeleteRows { + persisted: vec![( + RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + vec![Value::Int(1), Value::Text("a".to_owned())].into(), + )], + drafts: Vec::new(), }); let _ = tracker.apply(TrackerEdit::Insert { values: vec![Value::Int(2), Value::Text("a".to_owned())], diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index 523a0c37d0..f82dd3932d 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -75,6 +75,17 @@ + + + + tab.delete-rows + Delete selected rows (Delete) + + + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 27b2ed9ca2..15f2a75b27 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -72,6 +72,7 @@ crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs crates/app/src/ui/browse_tab/mod.rs crates/app/src/ui/browse_tab/page_reply.rs +crates/app/src/ui/browse_tab/row_deletion.rs crates/app/src/ui/browse_tab/tests.rs crates/app/src/ui/connect_dialog/connect_form.rs crates/app/src/ui/connect_dialog/connect_submission.rs @@ -146,6 +147,7 @@ crates/app/src/ui/grid/column_drawing.rs crates/app/src/ui/grid/column_factory.rs crates/app/src/ui/grid/column_width_binding.rs crates/app/src/ui/grid/context_menu.rs +crates/app/src/ui/grid/delete_availability.rs crates/app/src/ui/grid/display_text.rs crates/app/src/ui/grid/edit_snapshot.rs crates/app/src/ui/grid/grid_cell.rs @@ -154,6 +156,7 @@ crates/app/src/ui/grid/grid_msg.rs crates/app/src/ui/grid/grid_row_id.rs crates/app/src/ui/grid/grid_row_index.rs crates/app/src/ui/grid/inline_edit.rs +crates/app/src/ui/grid/menu_rows.rs crates/app/src/ui/grid/mod.rs crates/app/src/ui/grid/row_json_dialog.rs crates/app/src/ui/grid/row_object.rs From c0f2e4f019275d3122125ccca5bc691816676b82 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:15:19 +0700 Subject: [PATCH 002/301] fix(linux-postgres): keep storage and compression across a type change --- linux/CHANGELOG.md | 2 +- .../crates/core/src/ddl/column_attributes.rs | 11 ++- linux/crates/core/src/ddl/column_storage.rs | 23 +++++ linux/crates/core/src/ddl/mod.rs | 2 + .../drivers/postgres/src/catalog/mod.rs | 34 ++++++- .../postgres/src/catalog/pg_column_row.rs | 28 +++++- .../crates/drivers/postgres/src/connection.rs | 16 +++- linux/crates/drivers/postgres/src/ddl.rs | 89 ++++++++++++++++++- .../drivers/postgres/src/metadata/mod.rs | 19 ++-- .../drivers/postgres/tests/integration/ddl.rs | 66 +++++++++++++- 10 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 linux/crates/core/src/ddl/column_storage.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 71337c5a22..653a01433c 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -76,7 +76,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Turning a column's primary key on or off in the Structure tab saved nothing. - Dropping a SQL Server column that has a default failed. - Creating a SQLite table with a foreign key failed. -- Changing a column in the Structure tab lost its collation, SRID, SPARSE, CHECK or other attributes it had. +- Changing a column in the Structure tab lost its collation, SRID, SPARSE, CHECK, storage, compression or other attributes it had. - Saving a MySQL column whose default or generated expression held a quoted string failed. - Save in a Structure tab's close prompt skipped the tab's checks and dropped a default that did not parse. - The Structure tab refused every save on a SQLite table keyed by INTEGER PRIMARY KEY. diff --git a/linux/crates/core/src/ddl/column_attributes.rs b/linux/crates/core/src/ddl/column_attributes.rs index 55f8e67dad..0add42112a 100644 --- a/linux/crates/core/src/ddl/column_attributes.rs +++ b/linux/crates/core/src/ddl/column_attributes.rs @@ -1,4 +1,7 @@ -use super::{CollationName, ColumnKeyRoles, ColumnVisibility, GeneratedColumn, IdentityStyle, UnmodeledAttribute}; +use super::{ + CollationName, ColumnCompression, ColumnKeyRoles, ColumnStorage, ColumnVisibility, GeneratedColumn, IdentityStyle, + UnmodeledAttribute, +}; use crate::column::SqlExpression; /// What a column definition says beyond its name, type, nullability and @@ -32,6 +35,12 @@ pub struct ColumnAttributes { /// drops it unless the definition restates it; every other engine /// keeps a CHECK on the table, where a redefinition leaves it alone. pub check: Option, + /// PostgreSQL `SET STORAGE`, set only when it differs from the + /// type's own. + pub storage: Option, + /// PostgreSQL `SET COMPRESSION`, set only when the column names a + /// method rather than following the server's default. + pub compression: Option, pub identity: IdentityStyle, /// The SQL Server constraint that holds the default. A type change /// has to drop it by name first and add it back afterwards. diff --git a/linux/crates/core/src/ddl/column_storage.rs b/linux/crates/core/src/ddl/column_storage.rs new file mode 100644 index 0000000000..9b2cb9805e --- /dev/null +++ b/linux/crates/core/src/ddl/column_storage.rs @@ -0,0 +1,23 @@ +/// How PostgreSQL keeps a column's long values: inline or out of line, +/// compressed or not, as `SET STORAGE` names it. +/// +/// A type change resets it to the new type's own, so a column that had +/// one set keeps it only when the change restates it. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnStorage { + Plain, + Main, + External, + Extended, +} + +/// The method PostgreSQL compresses a column's values with, as +/// `SET COMPRESSION` names it. +/// +/// A type change resets it to `default_toast_compression`, so it is +/// restated the same way as the storage. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ColumnCompression { + Pglz, + Lz4, +} diff --git a/linux/crates/core/src/ddl/mod.rs b/linux/crates/core/src/ddl/mod.rs index 324d67848d..0f6a22c947 100644 --- a/linux/crates/core/src/ddl/mod.rs +++ b/linux/crates/core/src/ddl/mod.rs @@ -11,6 +11,7 @@ mod column_capabilities; mod column_definition; mod column_key_roles; mod column_rename; +mod column_storage; mod column_visibility; mod ddl_capabilities; mod ddl_dialect; @@ -51,6 +52,7 @@ pub use column_capabilities::{ColumnCapabilities, primary_key_permission}; pub use column_definition::ColumnDefinition; pub use column_key_roles::ColumnKeyRoles; pub use column_rename::ColumnRename; +pub use column_storage::{ColumnCompression, ColumnStorage}; pub use column_visibility::ColumnVisibility; pub use ddl_capabilities::DdlCapabilities; pub use ddl_dialect::DdlDialect; diff --git a/linux/crates/drivers/postgres/src/catalog/mod.rs b/linux/crates/drivers/postgres/src/catalog/mod.rs index 02ed7a9aa1..5b644b4306 100644 --- a/linux/crates/drivers/postgres/src/catalog/mod.rs +++ b/linux/crates/drivers/postgres/src/catalog/mod.rs @@ -6,7 +6,7 @@ mod pg_typmod; mod table_definition; mod type_facts; -pub(crate) use pg_column_row::{COLUMNS_SQL, PgColumnRow}; +pub(crate) use pg_column_row::{COLUMNS_SQL, COLUMNS_SQL_BEFORE_14, PgColumnRow}; pub(crate) use table_definition::{INBOUND_FOREIGN_KEYS_SQL, PRIMARY_KEY_NAME_SQL, inbound_foreign_key}; pub(crate) use type_facts::TypeFacts; @@ -14,7 +14,8 @@ use tablepro_core::column::{ CatalogType, ColumnDefault, ColumnInfo, ColumnType, ResultColumn, SqlExpression, SqlTypeExpr, has_dynamic_storage, }; use tablepro_core::ddl::{ - CollationName, ColumnAttributes, ColumnDefinition, GeneratedColumn, GeneratedStorage, IdentityStyle, + CollationName, ColumnAttributes, ColumnCompression, ColumnDefinition, ColumnStorage, GeneratedColumn, + GeneratedStorage, IdentityStyle, UnmodeledAttribute, }; use tablepro_core::edit::parse_literal_text; use tablepro_core::sql_syntax::{LiteralToken, MAX_EXPRESSION_LEN, SqlGrammar, parse_expression}; @@ -92,11 +93,40 @@ pub(crate) fn column_definition(row: &PgColumnRow) -> ColumnDefinition { }) }), identity: identity_style(row), + storage: row.storage.as_deref().and_then(storage), + compression: row.compression.as_deref().and_then(compression), + // A method this build cannot name would be dropped by a type + // change, so the column refuses one instead. + unmodeled: row + .compression + .as_deref() + .filter(|code| compression(code).is_none()) + .map(|_| vec![UnmodeledAttribute::new("COMPRESSION")]) + .unwrap_or_default(), ..ColumnAttributes::default() }; ColumnDefinition::new(column_info(row), attributes) } +/// `pg_attribute.attstorage`, which every release spells the same. +fn storage(code: &str) -> Option { + match code { + "p" => Some(ColumnStorage::Plain), + "m" => Some(ColumnStorage::Main), + "e" => Some(ColumnStorage::External), + "x" => Some(ColumnStorage::Extended), + _ => None, + } +} + +fn compression(code: &str) -> Option { + match code { + "p" => Some(ColumnCompression::Pglz), + "l" => Some(ColumnCompression::Lz4), + _ => None, + } +} + fn identity_style(row: &PgColumnRow) -> IdentityStyle { match row.identity.as_str() { "a" => IdentityStyle::GeneratedAlways, diff --git a/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs b/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs index 13b749566f..aa618dc005 100644 --- a/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs +++ b/linux/crates/drivers/postgres/src/catalog/pg_column_row.rs @@ -9,7 +9,13 @@ use super::type_facts::{TypeFacts, TypeShape}; /// The recursive CTE follows a domain to its base type, keeping the /// first modifier along the way, because a domain column's own /// `atttypmod` is -1 when the modifier was declared on the domain. -pub(crate) const COLUMNS_SQL: &str = "\ +/// +/// The argument is the compression projection, since +/// `attcompression` arrived in PostgreSQL 14. +macro_rules! columns_sql { + ($compression:literal) => { + concat!( + "\ WITH RECURSIVE resolved AS ( SELECT a.attnum, a.atttypid AS type_oid, a.atttypmod AS typmod, 0 AS depth FROM pg_catalog.pg_attribute a @@ -47,7 +53,11 @@ SELECT a.attname::text, a.attgenerated::text, pg_catalog.col_description(a.attrelid, a.attnum::int4)::text, CASE WHEN a.attcollation <> 0 AND a.attcollation <> at.typcollation THEN cn.nspname::text END, - CASE WHEN a.attcollation <> 0 AND a.attcollation <> at.typcollation THEN co.collname::text END + CASE WHEN a.attcollation <> 0 AND a.attcollation <> at.typcollation THEN co.collname::text END, + CASE WHEN a.attstorage <> at.typstorage THEN a.attstorage::text END, + ", + $compression, + " FROM pg_catalog.pg_attribute a JOIN base b ON b.attnum = a.attnum JOIN pg_catalog.pg_type at ON at.oid = a.atttypid @@ -58,7 +68,13 @@ LEFT JOIN pg_catalog.pg_constraint pk ON pk.conrelid = a.attrelid AND pk.contype LEFT JOIN pg_catalog.pg_collation co ON co.oid = a.attcollation LEFT JOIN pg_catalog.pg_namespace cn ON cn.oid = co.collnamespace WHERE a.attrelid = $1::text::regclass AND a.attnum > 0 AND NOT a.attisdropped -ORDER BY a.attnum"; +ORDER BY a.attnum" + ) + }; +} + +pub(crate) const COLUMNS_SQL: &str = columns_sql!("NULLIF(a.attcompression::text, '')"); +pub(crate) const COLUMNS_SQL_BEFORE_14: &str = columns_sql!("NULL::text"); #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct PgColumnRow { @@ -76,6 +92,10 @@ pub(crate) struct PgColumnRow { pub(crate) generated: String, pub(crate) comment: Option, pub(crate) collation: Option<(Option, String)>, + /// `pg_attribute.attstorage` where it differs from the type's. + pub(crate) storage: Option, + /// `pg_attribute.attcompression` where the column names a method. + pub(crate) compression: Option, } impl PgColumnRow { @@ -124,6 +144,8 @@ impl PgColumnRow { generated: row.try_get(15).map_err(field)?, comment: row.try_get(16).map_err(field)?, collation: collation_name.map(|name| (collation_schema, name)), + storage: row.try_get(19).map_err(field)?, + compression: row.try_get(20).map_err(field)?, }) } } diff --git a/linux/crates/drivers/postgres/src/connection.rs b/linux/crates/drivers/postgres/src/connection.rs index 8f1486f338..ac3e4e08c0 100644 --- a/linux/crates/drivers/postgres/src/connection.rs +++ b/linux/crates/drivers/postgres/src/connection.rs @@ -19,6 +19,7 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use tokio_util::task::TaskTracker; use crate::cancel::{PgStop, StopProgress}; +use crate::catalog; use crate::commit_resolution::resolve_op; use crate::dialect::PgDialect; use crate::editor::PgEditorSession; @@ -87,6 +88,13 @@ impl PgConnection { self.dialect.qualify(table) } + fn columns_sql(&self) -> &'static str { + match self.server.at_least(14, 0, 0) { + true => catalog::COLUMNS_SQL, + false => catalog::COLUMNS_SQL_BEFORE_14, + } + } + /// Run one read in its own read-only transaction on a pooled /// connection, owned by a task that outlives the caller. async fn read(&self, options: CallOptions, op: ReadOp) -> Result { @@ -261,9 +269,12 @@ impl Connection for PgConnection { async fn fetch_columns(&self, table: &TableRef, options: CallOptions) -> Result, DriverError> { let relation = self.relation(table); + let columns_sql = self.columns_sql(); self.read( options, - read_op(move |client| Box::pin(async move { metadata::fetch_columns(client, &relation).await })), + read_op(move |client| { + Box::pin(async move { metadata::fetch_columns(client, columns_sql, &relation).await }) + }), ) .await } @@ -296,11 +307,12 @@ impl Connection for PgConnection { options: CallOptions, ) -> Result { let relation = self.relation(table); + let columns_sql = self.columns_sql(); let table = table.clone(); self.read( options, read_op(move |client| { - Box::pin(async move { metadata::fetch_table_definition(client, &table, &relation).await }) + Box::pin(async move { metadata::fetch_table_definition(client, columns_sql, &table, &relation).await }) }), ) .await diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 026925f576..1d588ca345 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -1,9 +1,9 @@ -use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth}; +use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth, NetworkFamily}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ - BuildDdlError, CollationName, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, - DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, Permission, RefusalReason, TableChanges, TableDefinition, - TableDraft, + BuildDdlError, CollationName, ColumnAttributes, ColumnCapabilities, ColumnCompression, ColumnDefinition, + ColumnStorage, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, + Permission, RefusalReason, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -187,6 +187,11 @@ impl DdlDialect for PgDialect { "ALTER COLUMN {name} TYPE {}{collation} USING {name}::{}", resolved.sql, resolved.sql )); + actions.extend(restated_storage( + &name, + &alteration.original.attributes, + &resolved.column_type.kind, + )); } // An identity column is NOT NULL whatever the switch said, and // turning one on sets that below. @@ -395,6 +400,54 @@ fn identity_permission(column: &ColumnDefinition) -> Permission { } } +/// A type change resets SET STORAGE and SET COMPRESSION to the new +/// type's own, so a column that set either has it restated. A +/// fixed-width type keeps its values inline and uncompressed and refuses +/// both, so the settings go with the old type. A type this build cannot +/// place is restated, and the server refuses the change if it cannot +/// take them. +fn restated_storage(name: &str, attributes: &ColumnAttributes, kind: &ColumnKind) -> Vec { + if fixed_width(kind) { + return Vec::new(); + } + let storage = attributes.storage.map(|storage| { + let keyword = match storage { + ColumnStorage::Plain => "PLAIN", + ColumnStorage::Main => "MAIN", + ColumnStorage::External => "EXTERNAL", + ColumnStorage::Extended => "EXTENDED", + }; + format!("ALTER COLUMN {name} SET STORAGE {keyword}") + }); + let compression = attributes.compression.map(|compression| { + let method = match compression { + ColumnCompression::Pglz => "pglz", + ColumnCompression::Lz4 => "lz4", + }; + format!("ALTER COLUMN {name} SET COMPRESSION {method}") + }); + storage.into_iter().chain(compression).collect() +} + +/// Whether PostgreSQL stores the type at a fixed width, which takes +/// only PLAIN storage and no compression. +fn fixed_width(kind: &ColumnKind) -> bool { + matches!( + kind, + ColumnKind::Bool(_) + | ColumnKind::Integer(_) + | ColumnKind::Float(_) + | ColumnKind::Money { .. } + | ColumnKind::Date + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::Interval + | ColumnKind::Uuid + | ColumnKind::Enum { .. } + | ColumnKind::Network(NetworkFamily::MacAddr | NetworkFamily::MacAddr8) + ) +} + /// Whether a type takes a COLLATE clause: text, and arrays of it. fn collatable(kind: &ColumnKind) -> bool { match kind { @@ -628,6 +681,34 @@ mod tests { assert!(!collatable(&dialect().parse_type("uuid").expect("a type").kind)); } + #[test] + fn a_type_change_restates_storage_and_compression_the_new_type_takes() { + let mut original = users(); + for column in &mut original.columns[1..] { + column.attributes.storage = Some(ColumnStorage::External); + column.attributes.compression = Some(ColumnCompression::Lz4); + } + + let plan = alter(&original, |draft| { + draft.columns[1].column_type = DraftType::Typed("varchar(320)".to_owned()); + draft.columns[2].column_type = DraftType::Typed("bigint".to_owned()); + }); + let nullability_only = alter(&original, |draft| draft.columns[1].nullable = false); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"app\".\"users\" ALTER COLUMN \"email\" TYPE varchar(320) USING \ + \"email\"::varchar(320), ALTER COLUMN \"email\" SET STORAGE EXTERNAL, ALTER COLUMN \"email\" SET \ + COMPRESSION lz4, ALTER COLUMN \"status\" TYPE bigint USING \"status\"::bigint" + ] + ); + assert_eq!( + sql(nullability_only), + ["ALTER TABLE \"app\".\"users\" ALTER COLUMN \"email\" SET NOT NULL"] + ); + } + #[test] fn rename_then_alter_uses_new_name() { let plan = alter(&users(), |draft| { diff --git a/linux/crates/drivers/postgres/src/metadata/mod.rs b/linux/crates/drivers/postgres/src/metadata/mod.rs index e8386e44ce..d55b53f0fe 100644 --- a/linux/crates/drivers/postgres/src/metadata/mod.rs +++ b/linux/crates/drivers/postgres/src/metadata/mod.rs @@ -23,8 +23,7 @@ use tablepro_core::meta::TableRef; use tokio_postgres::{Client, Row}; use crate::catalog::{ - COLUMNS_SQL, INBOUND_FOREIGN_KEYS_SQL, PRIMARY_KEY_NAME_SQL, PgColumnRow, column_definition, column_info, - inbound_foreign_key, + INBOUND_FOREIGN_KEYS_SQL, PRIMARY_KEY_NAME_SQL, PgColumnRow, column_definition, column_info, inbound_foreign_key, }; use crate::errors::map_error; @@ -50,14 +49,19 @@ pub(crate) fn decode_error(table: &'static str) -> impl Fn(tokio_postgres::Error /// `relation` is the table's quoted, qualified name, which `::regclass` /// resolves the same way the server resolves it in a query. -pub(crate) async fn fetch_columns(client: &Client, relation: &str) -> Result, DriverError> { - column_rows(client, relation) +/// `columns_sql` is the column query for the server's release. +pub(crate) async fn fetch_columns( + client: &Client, + columns_sql: &str, + relation: &str, +) -> Result, DriverError> { + column_rows(client, columns_sql, relation) .await .map(|rows| rows.iter().map(column_info).collect()) } -async fn column_rows(client: &Client, relation: &str) -> Result, DriverError> { - text_rows(client, COLUMNS_SQL, &[relation]) +async fn column_rows(client: &Client, columns_sql: &str, relation: &str) -> Result, DriverError> { + text_rows(client, columns_sql, &[relation]) .await? .iter() .map(PgColumnRow::from_row) @@ -66,6 +70,7 @@ async fn column_rows(client: &Client, relation: &str) -> Result pub(crate) async fn fetch_table_definition( client: &Client, + columns_sql: &str, table: &TableRef, relation: &str, ) -> Result { @@ -73,7 +78,7 @@ pub(crate) async fn fetch_table_definition( // them run inside the flight's transaction, ahead of its COMMIT. let params = [relation]; let (columns, primary_key, indexes, foreign_keys, inbound_foreign_keys) = futures_util::try_join!( - column_rows(client, relation), + column_rows(client, columns_sql, relation), text_rows(client, PRIMARY_KEY_NAME_SQL, ¶ms), fetch_indexes(client, relation), fetch_foreign_keys(client, relation), diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index 0d8a76e814..163ece8b28 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -2,7 +2,8 @@ //! server. use tablepro_core::ddl::{ - CollationName, DraftColumn, DraftType, IdentityStyle, PrimaryKeyConstraint, TableDefinition, TableDraft, diff_table, + CollationName, ColumnCompression, ColumnStorage, DraftColumn, DraftType, IdentityStyle, PrimaryKeyConstraint, + TableDefinition, TableDraft, diff_table, }; use tablepro_core::meta::TableRef; use tablepro_core::{Connection, WriteKind}; @@ -395,3 +396,66 @@ async fn type_change_keeps_c_collation() { assert_eq!(column("amount").info.column_type.name, "integer"); connection.close().await.expect("a close"); } + +/// ALTER COLUMN ... TYPE resets SET STORAGE and SET COMPRESSION to the +/// new type's own, so the change restates what the column had. An +/// integer takes neither, so its column goes back to the type's. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn type_change_keeps_storage_and_compression() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE kept (id int PRIMARY KEY, body varchar(10), note text, code text)", + "ALTER TABLE kept ALTER COLUMN body SET STORAGE EXTERNAL, ALTER COLUMN body SET COMPRESSION pglz, \ + ALTER COLUMN note SET STORAGE MAIN, ALTER COLUMN code SET STORAGE EXTERNAL", + "INSERT INTO kept VALUES (1, 'a', 'b', '7')", + ], + ) + .await; + let before = read(connection.as_ref()).await; + let column = |definition: &TableDefinition, name: &str| { + let attributes = &definition.column(name).expect("the column").attributes; + (attributes.storage, attributes.compression) + }; + assert_eq!( + column(&before, "body"), + (Some(ColumnStorage::External), Some(ColumnCompression::Pglz)) + ); + assert_eq!(column(&before, "id"), (None, None)); + + let after = change(connection.as_ref(), &kept(), |column| { + let retyped = match column.name.as_str() { + "body" => "varchar(40)", + "note" => "varchar(100)", + "code" => "integer", + _ => return, + }; + column.column_type = DraftType::Typed(retyped.to_owned()); + }) + .await; + + assert_eq!(type_of(&after, "body"), "character varying(40)"); + assert_eq!( + column(&after, "body"), + (Some(ColumnStorage::External), Some(ColumnCompression::Pglz)) + ); + assert_eq!(column(&after, "note"), (Some(ColumnStorage::Main), None)); + assert_eq!(column(&after, "code"), (None, None)); + assert_eq!( + value( + connection.as_ref(), + "SELECT string_agg(attname::text || '=' || attstorage::text || coalesce(nullif(attcompression::text, ''), \ + '-'), ',' ORDER BY attnum) FROM pg_attribute WHERE attrelid = 'kept'::regclass AND attnum > 0" + ) + .await, + "id=p-,body=ep,note=m-,code=p-" + ); + assert_eq!( + value(connection.as_ref(), "SELECT body || note || code FROM kept").await, + "ab7" + ); + connection.close().await.expect("a close"); +} From c4ad884550f86d8e65f8ab9cad9786f391ecf4ee Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:15:35 +0700 Subject: [PATCH 003/301] fix(linux-grid): move or cancel a waiting bulk delete when the key changes --- linux/CHANGELOG.md | 1 + .../src/ui/browse_tab/confirming_delete.rs | 17 +++ linux/crates/app/src/ui/browse_tab/mod.rs | 103 +++++++++++++++-- linux/crates/app/src/ui/browse_tab/tests.rs | 106 ++++++++++++++++++ linux/po/POTFILES.in | 1 + 5 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 linux/crates/app/src/ui/browse_tab/confirming_delete.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 653a01433c..128c7e1a63 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -96,6 +96,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Ctrl+S did nothing in a Structure tab. - Structure tab could not save two columns swapping names, or a column taking the name of one being removed. - Changing the type of a SQL Server column with a default failed. +- A bulk delete confirmed after the table's primary key changed marked rows by their old key. ### Security diff --git a/linux/crates/app/src/ui/browse_tab/confirming_delete.rs b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs new file mode 100644 index 0000000000..1109bb3ac5 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/confirming_delete.rs @@ -0,0 +1,17 @@ +use libadwaita as adw; +use tablepro_core::{ColumnInfo, Value}; + +use crate::services::change_tracker::RowKey; + +/// A bulk delete the user has not answered yet. +/// +/// Its rows are keyed by the primary key of the columns they were read +/// under, so a change to those columns moves them or cancels the delete +/// before it lands. +pub(super) struct ConfirmingDelete { + pub(super) ticket: u64, + pub(super) rows: Vec<(RowKey, Vec)>, + pub(super) drafts: Vec, + pub(super) columns: Vec, + pub(super) dialog: adw::AlertDialog, +} diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 1196cd2a61..7778751a1f 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -7,6 +7,7 @@ mod browse_command; mod browse_request_kind; +mod confirming_delete; mod fetch; mod imp; mod page_reply; @@ -34,6 +35,7 @@ use tablepro_session::{LinkGeneration, Session}; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; +use self::confirming_delete::ConfirmingDelete; pub(crate) use self::page_reply::PageReply; use super::grid::{ CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridRowId, GridRowIndex, GridSink, RowObject, TabGridContext, @@ -41,6 +43,7 @@ use super::grid::{ }; use crate::runtime::{FailedRequests, RequestSequencer}; use crate::services::change_tracker::{RowKey, StatementSource, pk_identity}; +use crate::services::column_relayout::ColumnRelayout; use crate::services::save_batch::SaveTarget; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::{TrackerDelta, VisualChange}; @@ -209,6 +212,8 @@ impl BrowseTab { pending_label: imp.pending_label.get(), grid_sink, suppress_combo_emit: Cell::new(true), + confirming_delete: None, + next_confirmation: 0, }; imp.model.replace(Some(model)); imp.link_watch.replace(Some(WatchForwarder::new( @@ -598,6 +603,8 @@ pub(crate) struct BrowseModel { /// Set until the first page lands, so the page-size dropdown being /// set from a restored state does not count as the user choosing. suppress_combo_emit: Cell, + confirming_delete: Option, + next_confirmation: u64, } #[derive(Debug)] @@ -674,6 +681,11 @@ pub(crate) enum BrowseTabInput { rows: Vec<(RowKey, Vec)>, drafts: Vec, }, + /// The user answered the bulk delete confirmation `ticket` opened. + BulkDeleteAnswered { + ticket: u64, + confirmed: bool, + }, /// The save landed. Edits made while it ran stay pending. SaveCommitted, /// The pending changes stay, so the user can fix the cause and @@ -1369,6 +1381,7 @@ impl BrowseModel { self.drop_grid(); } self.current_columns = columns.clone(); + self.rebase_confirming_delete(fx); let delta = self.tracker.set_target(SaveTarget { table: self.table_ref(), columns: columns.clone(), @@ -1664,6 +1677,28 @@ impl BrowseModel { self.apply_tracker_delta(&delta, fx); self.fetch(BrowseRequestKind::Page, fx); } + BrowseTabInput::BulkDeleteAnswered { ticket, confirmed } => { + // An answer to a confirmation the tab already cancelled + // or replaced. + if self + .confirming_delete + .as_ref() + .is_none_or(|pending| pending.ticket != ticket) + { + return; + } + if !confirmed { + self.confirming_delete = None; + return; + } + self.rebase_confirming_delete(fx); + if let Some(pending) = self.confirming_delete.take() { + fx.input(BrowseTabInput::MarkRowsDeleted { + rows: pending.rows, + drafts: pending.drafts, + }); + } + } BrowseTabInput::MarkRowsDeleted { rows, drafts } => { for (row_key, original_values) in rows { self.apply_edit( @@ -1744,7 +1779,7 @@ impl BrowseModel { /// Mark the selected rows for deletion: persisted rows go to the /// tracker, drafts are dropped. Nothing leaves the tab until Save. - fn delete_selected_rows(&self, fx: &mut Effects) { + fn delete_selected_rows(&mut self, fx: &mut Effects) { let Some(selection) = self.current_selection.as_ref() else { return; }; @@ -1800,18 +1835,70 @@ impl BrowseModel { dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); - let pending = RefCell::new(Some((snapshot, draft_ids))); + // A second Delete while one waits replaces it. + self.cancel_confirming_delete(); + let ticket = self.next_confirmation; + self.next_confirmation += 1; let tab = self.this.clone(); + // Closing the dialog answers it again with its close response, + // so only the first answer counts. + let answered = Cell::new(false); dialog.connect_response(None, move |dlg, response| { - dlg.close(); - if response == "delete" - && let Some((rows, drafts)) = pending.borrow_mut().take() - && let Some(tab) = tab.upgrade() - { - tab.send(BrowseTabInput::MarkRowsDeleted { rows, drafts }); + if answered.replace(true) { + return; + } + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::BulkDeleteAnswered { + ticket, + confirmed: response == "delete", + }); } + dlg.close(); }); dialog.present(Some(&self.inner_stack)); + self.confirming_delete = Some(ConfirmingDelete { + ticket, + rows: snapshot, + drafts: draft_ids, + columns: self.current_columns.clone(), + dialog, + }); + } + + fn cancel_confirming_delete(&mut self) { + if let Some(pending) = self.confirming_delete.take() { + pending.dialog.force_close(); + } + } + + /// Moves a waiting delete's rows onto the columns the tab read + /// since. A key read under another primary key can name another + /// row, so then the delete is cancelled rather than sent with it. + fn rebase_confirming_delete(&mut self, fx: &mut Effects) { + let Some(pending) = self.confirming_delete.as_mut() else { + return; + }; + if pending.columns == self.current_columns { + return; + } + let layout = ColumnRelayout::new(&pending.columns, &self.current_columns); + let moved: Option)>> = pending + .rows + .iter() + .map(|(key, values)| layout.key(key).ok().map(|()| (key.clone(), layout.fetched(values)))) + .collect(); + match moved { + Some(rows) => { + pending.rows = rows; + pending.columns = self.current_columns.clone(); + } + None => { + self.cancel_confirming_delete(); + fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext( + "The table's primary key changed, so no rows were marked for deletion. Select them again.", + ))); + } + } } /// One row as an INSERT for the server the tab is connected to, diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 2857592b22..90713339bc 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1362,3 +1362,109 @@ fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { ); assert_eq!(harness.visible_page().as_deref(), Some("loading")); } + +/// More rows than a delete marks without asking first. +const DOZEN: [&str; 12] = ["a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l"]; + +/// The confirmation a bulk delete is waiting on, by its ticket. +fn confirming(tab: &BrowseTab) -> Option<(u64, libadwaita::AlertDialog)> { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + tab.imp().model.borrow().as_ref().and_then(|model| { + model + .confirming_delete + .as_ref() + .map(|pending| (pending.ticket, pending.dialog.clone())) + }) +} + +/// Every row selected and Delete pressed, which asks before marking. +fn ask_to_delete_every_row(harness: &Harness) -> libadwaita::AlertDialog { + wait_until(WAIT, || harness.shown().len() == DOZEN.len()).expect("the first page"); + harness.tab().send(BrowseTabInput::SelectAllRows); + harness.tab().send(BrowseTabInput::DeleteSelectedRow); + let (_, dialog) = confirming(harness.tab()).expect("a bulk delete asks first"); + assert_eq!(harness.tracker.pending_count(), 0); + dialog +} + +fn answer_delete(dialog: &libadwaita::AlertDialog) { + dialog.emit_by_name::<()>("response", &[&"delete"]); + drain_main_context(); +} + +fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { + harness + .session + .invalidate_table(&tablepro_core::TableRef::unqualified("users")); + harness.tab().send(BrowseTabInput::SchemaChanged); + wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); +} + +#[gtk4::test] +fn a_confirmed_bulk_delete_marks_every_selected_row() { + 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 dialog = ask_to_delete_every_row(&harness); + + answer_delete(&dialog); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + assert!(confirming(harness.tab()).is_none()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_the_primary_key_moves_is_cancelled() { + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(vec![column("id", false), column("name", true)])); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let toasts = toasts(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| { + columns.iter().any(|column| column.name == "name" && column.primary_key) + }); + answer_delete(&dialog); + + assert!(confirming(harness.tab()).is_none(), "the confirmation outlived the key"); + assert_eq!( + harness.tracker.pending_count(), + 0, + "rows were marked by the key they had before" + ); + assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); +} + +#[gtk4::test] +fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_columns() { + let widened = [column("id", true), column("name", false), column("email", false)]; + let mut script = serving_table(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::ok(widened.to_vec())); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + let dialog = ask_to_delete_every_row(&harness); + + change_schema(&harness, |columns| columns.len() == widened.len()); + assert!( + confirming(harness.tab()).is_some(), + "a change that keeps the key cancelled the delete" + ); + answer_delete(&dialog); + + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); + assert_eq!( + harness.tracker.row_state(&key(0)), + crate::services::change_tracker::RowState::PendingDelete + ); +} + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 27b2ed9ca2..d47d46091e 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -68,6 +68,7 @@ crates/app/src/ui/app/workspace_persistence.rs crates/app/src/ui/app/workspace_tabs.rs crates/app/src/ui/browse_tab/browse_command.rs crates/app/src/ui/browse_tab/browse_request_kind.rs +crates/app/src/ui/browse_tab/confirming_delete.rs crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs crates/app/src/ui/browse_tab/mod.rs From 2b0fac61ce2d7eefff024cc6d546c5b09a45d397 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:15:35 +0700 Subject: [PATCH 004/301] fix(linux-grid): log a failed table read by its category, not its text --- linux/crates/app/src/ui/browse_tab/fetch.rs | 25 ++++++++++- linux/crates/app/src/ui/browse_tab/tests.rs | 50 +++++++++++++++++++++ 2 files changed, 73 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index a07bbac228..b6ce8f046b 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -242,7 +242,11 @@ impl BrowseModel { } } Err(error) => { - tracing::warn!(%error, "could not read the table's columns"); + tracing::warn!( + kind = failure_kind(&error), + category = ?error.category(), + "could not read the table's columns" + ); self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); self.retry_if_link_moved(BrowseRequestKind::Columns, fx); } @@ -272,7 +276,11 @@ impl BrowseModel { match value { Ok(count) => self.on_row_count_loaded(count, fx), Err(error) => { - tracing::debug!(%error, "the row count could not be read"); + tracing::debug!( + kind = failure_kind(&error), + category = ?error.category(), + "the row count could not be read" + ); self.retry_if_link_moved(BrowseRequestKind::Count, fx); } } @@ -323,3 +331,16 @@ impl BrowseModel { } } } + +/// What a failed read was, for the log. A server's message can quote +/// the statement it rejected, and with it a literal from the user's +/// filter, so the log names the failure and leaves its text to the UI. +fn failure_kind(error: &OperationError) -> &'static str { + match error { + OperationError::Driver(_) => "driver", + OperationError::Sql(_) | OperationError::Browse(_) | OperationError::Build(_) => "statement", + OperationError::Panicked(_) => "panic", + OperationError::Cancelled => "cancelled", + OperationError::Closed => "closed", + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 90713339bc..58a0f29bc1 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1468,3 +1468,53 @@ fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_co ); } +/// What this thread logs, as text. +#[derive(Clone, Default)] +struct LogLines(Arc>>); + +impl LogLines { + fn text(&self) -> String { + let bytes = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + String::from_utf8_lossy(&bytes).into_owned() + } +} + +impl std::io::Write for LogLines { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0 + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[gtk4::test] +fn a_failed_row_count_logs_its_category_and_not_the_server_message() { + let lines = LogLines::default(); + let subscriber = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::DEBUG) + .with_writer({ + let lines = lines.clone(); + move || lines.clone() + }) + .finish(); + let _logging = tracing::subscriber::set_default(subscriber); + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + script.count_rows.always(FakeReply::err(DriverError::reported( + tablepro_core::ServerDiagnostics::new(None, "You have an error in your SQL syntax near 'hunter2' at line 1"), + ))); + let _harness = Harness::open(script, []); + + wait_until(WAIT, || lines.text().contains("the row count could not be read")).expect("the failure is logged"); + + let text = lines.text(); + assert!(!text.contains("hunter2"), "{text}"); + assert!(text.contains("category=Some(Server)"), "{text}"); +} From 60e9d3b032409e866b572bd0290302d37d187c22 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:15:42 +0700 Subject: [PATCH 005/301] fix(linux-storage): remove the tab file older builds wrote editor SQL into --- linux/CHANGELOG.md | 1 + linux/crates/app/src/storage.rs | 33 ++++++++++- .../app/src/workspace/legacy_tab_file.rs | 55 +++++++++++++++++++ linux/crates/app/src/workspace/mod.rs | 2 + linux/po/POTFILES.in | 1 + 5 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 linux/crates/app/src/workspace/legacy_tab_file.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 128c7e1a63..1b08a73d44 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -104,3 +104,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. - Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. - Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. +- Editor SQL saved by an earlier build stayed on disk in a tab file nothing reads any more. diff --git a/linux/crates/app/src/storage.rs b/linux/crates/app/src/storage.rs index 3c9ec10b98..e01766dce6 100644 --- a/linux/crates/app/src/storage.rs +++ b/linux/crates/app/src/storage.rs @@ -5,7 +5,7 @@ use tablepro_session::runtime::Tasks; use tablepro_storage::{ConnectionStore, StoragePaths}; use crate::persistence::{ColumnWidthStore, FilterSettingsStore}; -use crate::workspace::WorkspaceStore; +use crate::workspace::{WorkspaceStore, remove_legacy_tab_file}; /// Everything the app persists, resolved once at startup and handed to /// the root component. Nothing reaches for a global, so a test can point @@ -28,6 +28,12 @@ impl AppStorage { let column_widths = ColumnWidthStore::load(paths.column_widths_file(), tasks); let filter_settings = FilterSettingsStore::load(paths.filter_settings_file(), tasks); let workspaces = WorkspaceStore::start(paths.workspaces_dir(), tasks); + let state = paths.state.clone(); + drop(tasks.spawn_blocking_task(move || match remove_legacy_tab_file(&state) { + Ok(true) => tracing::info!("removed the tab file an older build left"), + Ok(false) => {} + Err(error) => tracing::warn!(%error, "could not remove the tab file an older build left"), + })); Self { paths, connections, @@ -64,3 +70,28 @@ impl AppStorage { } pub type SharedStorage = Rc; + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::{test_runtime, wait_until}; + + #[gtk4::test] + fn opening_the_storage_removes_the_tab_file_an_older_build_left() { + let root = tempfile::tempdir().expect("a root"); + let paths = StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + std::fs::create_dir_all(&paths.state).expect("the state directory"); + let legacy = paths.state.join("workspace_state.json"); + std::fs::write(&legacy, b"{}").expect("the legacy file"); + let runtime = test_runtime(); + + let _storage = AppStorage::new( + paths, + std::sync::Arc::new(tablepro_storage::SecretStore::new(crate::config::secret_schema())), + &runtime.tasks(), + ); + + wait_until(std::time::Duration::from_secs(10), || !legacy.exists()).expect("the legacy file is removed"); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/workspace/legacy_tab_file.rs b/linux/crates/app/src/workspace/legacy_tab_file.rs new file mode 100644 index 0000000000..5bafc4a8f7 --- /dev/null +++ b/linux/crates/app/src/workspace/legacy_tab_file.rs @@ -0,0 +1,55 @@ +use std::io; +use std::path::Path; + +/// The one tab file builds before per-session workspaces kept in the +/// state directory. Editor tabs wrote their SQL into it, and nothing +/// reads it any more, so it would keep that SQL on disk for good. +const LEGACY_TAB_FILE: &str = "workspace_state.json"; + +/// Removes the tab file an older build left in `state_dir`, and nothing +/// else there. Nothing writes the file any more, so the first start +/// after the upgrade is the one that finds it. +pub(crate) fn remove_legacy_tab_file(state_dir: &Path) -> io::Result { + match std::fs::remove_file(state_dir.join(LEGACY_TAB_FILE)) { + Ok(()) => Ok(true), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(false), + Err(error) => Err(error), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_legacy_tab_file_goes_and_the_rest_of_the_state_directory_stays() { + let state = tempfile::tempdir().expect("a state directory"); + let kept = ["history.db", "workspace_state.json.tmp", "workspaces/abc.json"]; + std::fs::create_dir(state.path().join("workspaces")).expect("the workspaces directory"); + for name in kept { + std::fs::write(state.path().join(name), b"kept").expect("a kept file"); + } + std::fs::write( + state.path().join(LEGACY_TAB_FILE), + br#"{"connections":{"c":{"tabs":[{"kind":"editor","query":"SELECT 1"}]}}}"#, + ) + .expect("the legacy file"); + + let first = remove_legacy_tab_file(state.path()).expect("the removal"); + let second = remove_legacy_tab_file(state.path()).expect("a second start"); + + assert!(first); + assert!(!second, "a start without the file found one"); + assert!(!state.path().join(LEGACY_TAB_FILE).exists()); + for name in kept { + assert!(state.path().join(name).exists(), "{name} was removed"); + } + } + + #[test] + fn a_missing_state_directory_has_nothing_to_remove() { + let root = tempfile::tempdir().expect("a root"); + + assert!(!remove_legacy_tab_file(&root.path().join("never-created")).expect("no error")); + } +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index d61d185f4d..4473698ff7 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -12,6 +12,7 @@ mod dirty_kind; mod dirty_tab; mod draft_writer; mod inhibit_reason; +mod legacy_tab_file; mod open_transaction_dialog; mod pending_state; mod persist_debouncer; @@ -57,6 +58,7 @@ pub use dirty_kind::DirtyKind; pub use dirty_tab::DirtyTab; pub use draft_writer::DraftWriter; pub use inhibit_reason::InhibitReason; +pub(crate) use legacy_tab_file::remove_legacy_tab_file; pub use pending_state::PendingState; pub use persist_debouncer::PersistDebouncer; pub(crate) use ran_statements::RanStatements; diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index d47d46091e..5e91527274 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -193,6 +193,7 @@ crates/app/src/workspace/dirty_kind.rs crates/app/src/workspace/dirty_tab.rs crates/app/src/workspace/draft_writer.rs crates/app/src/workspace/inhibit_reason.rs +crates/app/src/workspace/legacy_tab_file.rs crates/app/src/workspace/mod.rs crates/app/src/workspace/open_transaction_dialog.rs crates/app/src/workspace/pending_state.rs From b2aee4e2a6eae108a2c8e26822e308d27a7b9376 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:18:32 +0700 Subject: [PATCH 006/301] fix(linux-ssh): ask for a jump host's key when its config would not --- linux/CHANGELOG.md | 1 + .../session/src/connect/driver_connector.rs | 1 + .../crates/session/tests/session_postgres.rs | 1 + linux/crates/ssh/src/argv.rs | 39 +- linux/crates/ssh/src/destination.rs | 20 +- linux/crates/ssh/src/first_hop.rs | 234 ------------ linux/crates/ssh/src/hop_config.rs | 179 +++++++++ linux/crates/ssh/src/known_hosts.rs | 4 + linux/crates/ssh/src/lib.rs | 3 +- linux/crates/ssh/src/route.rs | 356 ++++++++++++++++++ linux/crates/ssh/src/services.rs | 6 + linux/crates/ssh/src/session.rs | 79 +++- linux/crates/ssh/src/sweep.rs | 1 + linux/crates/ssh/tests/openssh_session.rs | 59 +++ .../0013-openssh-client-transport.md | 1 + 15 files changed, 730 insertions(+), 254 deletions(-) delete mode 100644 linux/crates/ssh/src/first_hop.rs create mode 100644 linux/crates/ssh/src/hop_config.rs create mode 100644 linux/crates/ssh/src/route.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 1b08a73d44..d6ce5ec292 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -104,4 +104,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL run in the editor, a typed filter or a column default, and the values in them, reached the journal at debug level. - Column types and defaults typed in the Structure tab reached the SQL after a text filter instead of the database's own parser. - Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. +- A jump host whose ssh_config sets StrictHostKeyChecking to accept-new or no was trusted without the host key dialog. - Editor SQL saved by an earlier build stayed on disk in a tab file nothing reads any more. diff --git a/linux/crates/session/src/connect/driver_connector.rs b/linux/crates/session/src/connect/driver_connector.rs index 6203847e5d..939abce8f5 100644 --- a/linux/crates/session/src/connect/driver_connector.rs +++ b/linux/crates/session/src/connect/driver_connector.rs @@ -247,6 +247,7 @@ mod tests { ssh_program: PathBuf::from("/nonexistent/ssh"), ssh_keygen_program: PathBuf::from("/nonexistent/ssh-keygen"), askpass_program: PathBuf::from("/nonexistent/tablepro-askpass"), + config_file: None, }, liveness: LivenessPolicy::DESKTOP, }; diff --git a/linux/crates/session/tests/session_postgres.rs b/linux/crates/session/tests/session_postgres.rs index fa4a33d65b..0a8693e628 100644 --- a/linux/crates/session/tests/session_postgres.rs +++ b/linux/crates/session/tests/session_postgres.rs @@ -101,6 +101,7 @@ fn isolated_ssh() -> IsolatedSsh { ssh_program: wrapper, ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: PathBuf::from("/nonexistent/tablepro-askpass"), + config_file: None, }; IsolatedSsh { dir, services } } diff --git a/linux/crates/ssh/src/argv.rs b/linux/crates/ssh/src/argv.rs index 441791cbfb..9467778565 100644 --- a/linux/crates/ssh/src/argv.rs +++ b/linux/crates/ssh/src/argv.rs @@ -38,13 +38,21 @@ pub(crate) enum ControlOp<'a> { Exit, } +/// `config_file` is the ssh_config the master and every jump host's ssh +/// read in place of OpenSSH's own, which ssh.c hands on to each hop. pub(crate) fn master_args( config: &SshConfig, control: &Path, liveness: &LivenessPolicy, host_keys: Option, + config_file: Option<&Path>, ) -> Vec { - let mut args: Vec = ["-M", "-N", "-T"].into_iter().map(OsString::from).collect(); + let mut args: Vec = Vec::new(); + if let Some(config_file) = config_file { + args.push("-F".into()); + args.push(config_file.as_os_str().to_owned()); + } + args.extend(["-M", "-N", "-T"].into_iter().map(OsString::from)); push_option(&mut args, control_path_option(control)); for option in &FORCED_OPTIONS[..FORCED_OPTIONS.len() - 1] { push_option(&mut args, OsString::from(option)); @@ -174,6 +182,7 @@ mod tests { Path::new("/run/user/1000/app/ssh/0123456789abcdef/89abcdef/control"), &LivenessPolicy::DESKTOP, None, + None, ); assert_eq!( strings(&args), @@ -250,6 +259,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, host_keys, + None, ); let strict: Vec<&str> = strings(&args) .into_iter() @@ -268,6 +278,7 @@ mod tests { control, &LivenessPolicy::DESKTOP, None, + None, )) .join(" "); assert!(!without.contains(" -p ") && !without.contains(" -l ")); @@ -276,6 +287,7 @@ mod tests { control, &LivenessPolicy::DESKTOP, None, + None, ); assert_eq!( strings(&with[with.len() - 6..]), @@ -287,13 +299,33 @@ mod tests { fn jump_hosts_render_one_minus_j() { let mut with_jumps = config(None, None, SshAuth::Agent); with_jumps.jump_hosts = SshDestination::parse_jump_list("ops@jump1:2200,[fd00::1]").unwrap(); - let args = master_args(&with_jumps, Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None); + let args = master_args( + &with_jumps, + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + None, + None, + ); let args = strings(&args); assert_eq!(args.iter().filter(|arg| **arg == "-J").count(), 1); let position = args.iter().position(|arg| *arg == "-J").unwrap(); assert_eq!(args[position + 1], "ops@jump1:2200,[fd00::1]"); } + #[test] + fn a_config_file_comes_first_so_every_hop_gets_it() { + let args = master_args( + &config(None, None, SshAuth::Agent), + Path::new("/tmp/control"), + &LivenessPolicy::DESKTOP, + None, + Some(Path::new("/run/user/1000/m/ssh_config")), + ); + + assert_eq!(strings(&args)[..3], ["-F", "/run/user/1000/m/ssh_config", "-M"]); + assert_eq!(strings(&args).iter().filter(|arg| **arg == "-F").count(), 1); + } + #[test] fn private_key_path_adds_minus_i() { let auth = SshAuth::PrivateKey { @@ -305,6 +337,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None, + None, ); let joined = strings(&args).join(" "); assert!( @@ -321,6 +354,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None, + None, ); assert!(!strings(&args).contains(&"-i")); } @@ -346,6 +380,7 @@ mod tests { Path::new("/tmp/control"), &LivenessPolicy::DESKTOP, None, + None, ); let preferred = strings(&args) .into_iter() diff --git a/linux/crates/ssh/src/destination.rs b/linux/crates/ssh/src/destination.rs index 2dd14dd997..59379abd08 100644 --- a/linux/crates/ssh/src/destination.rs +++ b/linux/crates/ssh/src/destination.rs @@ -95,6 +95,10 @@ impl fmt::Display for SshDestination { /// command that `sh -c` runs, so one of these would run as a command. const SHELL_CHARACTERS: &[char] = &['\'', '`', '"', '$', ';', '&', '<', '>', '|', '(', ')', '{', '}', ',']; +/// What the shell globs in that proxy command, and where OpenSSH cuts a +/// ProxyJump value as a comment. +const GLOB_AND_COMMENT_CHARACTERS: &[char] = &['*', '?', '[', ']', '#']; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Part { Host, @@ -114,6 +118,12 @@ impl Part { fn allows_backslash(self) -> bool { self == Self::User } + + /// A jump host whose own configuration would trust a key without + /// asking is named in a `Host` line, where `!` negates the pattern. + fn allows_bang(self) -> bool { + self == Self::User + } } fn check_part(part: Part, value: &str) -> Result<(), SshFailure> { @@ -127,7 +137,9 @@ fn check_part(part: Part, value: &str) -> Result<(), SshFailure> { if value.chars().any(|c| c.is_whitespace() || c.is_control()) { return Err(invalid(&format!("the {name} contains spaces or control characters"))); } - if let Some(found) = value.chars().find(|c| SHELL_CHARACTERS.contains(c)) { + if let Some(found) = value.chars().find(|c| { + SHELL_CHARACTERS.contains(c) || GLOB_AND_COMMENT_CHARACTERS.contains(c) || (*c == '!' && !part.allows_bang()) + }) { return Err(invalid(&format!("the {name} contains '{found}'"))); } // A trailing backslash would escape whatever ssh appends after it. @@ -169,6 +181,12 @@ mod tests { SshDestination::new("bastion\\x", None, None), SshDestination::new("bastion", None, Some("ops'x".to_owned())), SshDestination::new("bastion", None, Some("ops\\".to_owned())), + SshDestination::new("jump*", None, None), + SshDestination::new("jump?", None, None), + SshDestination::new("!jump", None, None), + SshDestination::new("jump#1", None, None), + SshDestination::new("bastion", None, Some("#ops".to_owned())), + SshDestination::new("bastion", None, Some("ops*".to_owned())), ]; for result in rejected { assert!( diff --git a/linux/crates/ssh/src/first_hop.rs b/linux/crates/ssh/src/first_hop.rs deleted file mode 100644 index f6abd116e9..0000000000 --- a/linux/crates/ssh/src/first_hop.rs +++ /dev/null @@ -1,234 +0,0 @@ -use std::ffi::OsString; -use std::path::Path; -use std::process::Stdio; -use std::time::Duration; - -use tablepro_core::NetworkEndpoint; -use tokio::process::Command; - -use crate::SshDestination; - -/// `ssh -G` does no network I/O, but a `Match exec` line can run a -/// command, so each lookup is still bounded. -const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); -/// A ProxyJump chain whose hops name each other never ends; real chains -/// are a few hops long. -const MAX_HOPS: usize = 8; -const DEFAULT_PORT: u16 = 22; - -#[derive(Debug, PartialEq, Eq)] -enum Resolved { - Direct(NetworkEndpoint), - Jump(SshDestination), - ProxyCommand, -} - -/// The host and port the first TCP connection of a session goes to, -/// from what `ssh -G` printed for the session's destination. -/// -/// An ssh_config alias is only a name until `ssh -G` resolves it, and a -/// jump chain starts at its first hop, so this asks ssh rather than -/// trusting what the user typed. `None` when the route is a -/// ProxyCommand, whose first hop only that command knows. -pub(crate) async fn resolve_first_hop(ssh_program: &Path, destination: Option<&str>) -> Option { - let mut output = destination?.to_owned(); - for _ in 0..MAX_HOPS { - match parse_resolved(&output)? { - Resolved::Direct(endpoint) => return Some(endpoint), - Resolved::ProxyCommand => return None, - Resolved::Jump(first) => output = lookup(ssh_program, &first, &[]).await?, - } - } - tracing::debug!("the ssh jump chain did not end"); - None -} - -pub(crate) async fn lookup( - ssh_program: &Path, - destination: &SshDestination, - jumps: &[SshDestination], -) -> Option { - let mut args: Vec = vec!["-G".into()]; - if !jumps.is_empty() { - let hops: Vec = jumps.iter().map(ToString::to_string).collect(); - args.push("-J".into()); - args.push(hops.join(",").into()); - } - if let Some(port) = destination.port() { - args.push("-p".into()); - args.push(port.to_string().into()); - } - if let Some(user) = destination.user() { - args.push("-l".into()); - args.push(user.into()); - } - args.push("--".into()); - args.push(destination.host().into()); - - let run = Command::new(ssh_program) - .args(&args) - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .kill_on_drop(true) - .output(); - match tokio::time::timeout(LOOKUP_TIMEOUT, run).await { - Ok(Ok(output)) if output.status.success() => Some(String::from_utf8_lossy(&output.stdout).into_owned()), - Ok(Ok(output)) => { - tracing::debug!(status = ?output.status.code(), "ssh -G could not resolve the first hop"); - None - } - Ok(Err(error)) => { - tracing::debug!(%error, "could not run ssh -G"); - None - } - Err(_) => { - tracing::debug!("ssh -G did not finish"); - None - } - } -} - -fn parse_resolved(ssh_g_output: &str) -> Option { - let mut hostname = None; - let mut port = None; - let mut proxy_jump = None; - let mut proxy_command = false; - for line in ssh_g_output.lines() { - let Some((key, value)) = line.split_once(' ') else { - continue; - }; - let value = value.trim(); - match key { - "hostname" => hostname = Some(value), - "port" => port = value.parse::().ok(), - "proxyjump" if value != "none" => proxy_jump = Some(value), - "proxycommand" if value != "none" => proxy_command = true, - _ => {} - } - } - if let Some(chain) = proxy_jump { - let first = chain.split(',').next()?.trim(); - let first = first.strip_prefix("ssh://").unwrap_or(first); - let mut hops = SshDestination::parse_jump_list(first).ok()?; - return hops.pop().map(Resolved::Jump); - } - if proxy_command { - return Some(Resolved::ProxyCommand); - } - NetworkEndpoint::new(hostname?, port.unwrap_or(DEFAULT_PORT)) - .ok() - .map(Resolved::Direct) -} - -#[cfg(test)] -mod tests { - use std::os::unix::fs::PermissionsExt; - use std::path::PathBuf; - - use super::*; - - fn endpoint(host: &str, port: u16) -> NetworkEndpoint { - NetworkEndpoint::new(host, port).unwrap() - } - - #[test] - fn a_direct_destination_resolves_to_its_hostname_and_port() { - let output = "user deploy\nhostname 192.0.2.10\nport 2222\n"; - - assert_eq!( - parse_resolved(output), - Some(Resolved::Direct(endpoint("192.0.2.10", 2222))) - ); - } - - #[test] - fn a_jump_chain_starts_at_its_first_hop() { - let output = "hostname db.internal\nport 22\nproxyjump ops@jump.example.com:2200,second\n"; - - assert_eq!( - parse_resolved(output), - Some(Resolved::Jump( - SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap() - )) - ); - } - - #[test] - fn a_uri_jump_and_an_ipv6_jump_parse() { - let uri = "hostname t\nproxyjump ssh://ops@jump.example.com:2200\n"; - let ipv6 = "hostname t\nproxyjump [::1]:2201\n"; - - assert_eq!( - parse_resolved(uri), - Some(Resolved::Jump( - SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap() - )) - ); - assert_eq!( - parse_resolved(ipv6), - Some(Resolved::Jump(SshDestination::new("::1", Some(2201), None).unwrap())) - ); - } - - #[test] - fn a_proxy_command_has_no_known_first_hop() { - let output = "hostname t\nport 22\nproxycommand nc %h %p\n"; - - assert_eq!(parse_resolved(output), Some(Resolved::ProxyCommand)); - } - - #[test] - fn none_values_are_not_proxies() { - let output = "hostname t\nport 22\nproxyjump none\nproxycommand none\n"; - - assert_eq!(parse_resolved(output), Some(Resolved::Direct(endpoint("t", 22)))); - } - - fn fake_ssh(dir: &Path, script: &str) -> PathBuf { - let program = dir.join("ssh"); - std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap(); - std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); - program - } - - async fn first_hop(program: &Path, host: &str, jumps: &[&str]) -> Option { - let destination = SshDestination::new(host, None, None).unwrap(); - let jumps: Vec = jumps - .iter() - .map(|jump| SshDestination::new(*jump, None, None).unwrap()) - .collect(); - let resolved = lookup(program, &destination, &jumps).await; - resolve_first_hop(program, resolved.as_deref()).await - } - - /// The fake prints a jump for the database host and resolves the - /// alias the jump names, the way an ssh_config would. - #[tokio::test] - async fn an_alias_behind_a_jump_resolves_through_ssh_g() { - let temp = tempfile::tempdir().unwrap(); - let program = fake_ssh( - temp.path(), - r#"for last; do :; done -case "$last" in - db) printf 'hostname 10.0.0.5\nport 5432\nproxyjump bastion\n' ;; - bastion) printf 'hostname 192.0.2.10\nport 2222\n' ;; - *) exit 255 ;; -esac"#, - ); - - let first = first_hop(&program, "db", &["bastion"]).await; - - assert_eq!(first, Some(endpoint("192.0.2.10", 2222))); - } - - #[tokio::test] - async fn a_failing_lookup_and_a_jump_loop_resolve_to_nothing() { - let temp = tempfile::tempdir().unwrap(); - let failing = fake_ssh(temp.path(), "exit 255"); - assert_eq!(first_hop(&failing, "db", &[]).await, None); - - let looping = tempfile::tempdir().unwrap(); - let program = fake_ssh(looping.path(), r"printf 'hostname x\nproxyjump loop\n'"); - assert_eq!(first_hop(&program, "loop", &[]).await, None); - } -} diff --git a/linux/crates/ssh/src/hop_config.rs b/linux/crates/ssh/src/hop_config.rs new file mode 100644 index 0000000000..40e0ae9909 --- /dev/null +++ b/linux/crates/ssh/src/hop_config.rs @@ -0,0 +1,179 @@ +use std::io::Write; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; + +use tablepro_core::SshFailure; + +use crate::host_key_checking::HostKeyChecking; +use crate::route::JumpHop; + +/// What OpenSSH reads when it is given no `-F`, in its order: the user's +/// file, then the system's (`process_config_files` in ssh.c). +const DEFAULT_CONFIG_FILES: [&str; 2] = ["~/.ssh/config", "/etc/ssh/ssh_config"]; +const FILE_NAME: &str = "ssh_config"; + +/// The jump hosts whose own configuration would trust a host key the +/// user never saw, which the destination's `StrictHostKeyChecking=ask` +/// on the master's command line does not reach. +pub(crate) fn hosts_to_pin(hops: &[JumpHop]) -> Vec<&str> { + let mut hosts: Vec<&str> = hops + .iter() + .filter(|hop| matches!(hop.host_keys, Some(HostKeyChecking::AcceptNew | HostKeyChecking::No))) + .map(|hop| hop.destination.host()) + .collect(); + hosts.sort_unstable(); + hosts.dedup(); + hosts +} + +/// An ssh_config that asks before trusting the key of each host in +/// `hosts`, then reads what ssh would have read without it. +/// +/// ssh.c runs a jump host through a second ssh that gets only `-l`, +/// `-p`, `-J` and `-F` from the master's command line, so the master's +/// `-F` is the only way to reach it. The first value ssh_config gives +/// wins, so the pins come before the includes. `base` is the file the +/// master would otherwise be given with `-F`. +pub(crate) fn render(hosts: &[&str], base: Option<&Path>) -> Result { + let mut text = String::new(); + for host in hosts { + text.push_str(&format!("Host {host}\n StrictHostKeyChecking ask\n")); + } + text.push_str("Match all\n"); + match base { + Some(base) => text.push_str(&format!("Include \"{}\"\n", included(base)?)), + None => { + for file in DEFAULT_CONFIG_FILES { + text.push_str(&format!("Include {file}\n")); + } + } + } + Ok(text) +} + +/// Writes the file into the master's private directory. +/// +/// OpenSSH pastes the path into the `sh -c` line it builds for each hop +/// and expands `%` tokens in it first, so a path that is anything other +/// than one plain shell word is refused rather than run. +pub(crate) fn write(master_dir: &Path, contents: &str) -> Result { + let path = master_dir.join(FILE_NAME); + let unsafe_dir = |detail: String| SshFailure::RuntimeDirUnsafe { + path: path.clone(), + detail, + }; + if !plain_shell_word(&path) { + return Err(unsafe_dir( + "a jump host's ssh cannot be given a path with spaces or shell characters".to_owned(), + )); + } + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&path) + .and_then(|mut file| file.write_all(contents.as_bytes())) + .map_err(|error| unsafe_dir(error.to_string()))?; + Ok(path) +} + +fn plain_shell_word(path: &Path) -> bool { + path.as_os_str() + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_alphanumeric() || b"/._-+@:=,".contains(byte)) +} + +/// A path an `Include` line takes in quotes. OpenSSH 9.9 and later +/// expand `%` tokens and `${}` in it and older ones do not, so a path +/// holding either cannot be written to mean the same file on both. +fn included(path: &Path) -> Result<&str, SshFailure> { + let rejected = || SshFailure::ConfigRejected { + detail: format!("{} cannot be included from an ssh_config file", path.display()), + }; + let text = path.to_str().ok_or_else(rejected)?; + if text + .chars() + .any(|c| c.is_control() || matches!(c, '"' | '\\' | '%' | '$')) + { + return Err(rejected()); + } + Ok(text) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + + use super::*; + use crate::SshDestination; + + fn hop(host: &str, host_keys: Option) -> JumpHop { + JumpHop { + destination: SshDestination::new(host, None, None).unwrap(), + host_keys, + } + } + + #[test] + fn only_hosts_whose_configuration_skips_the_dialog_are_pinned() { + let hops = [ + hop("j3", Some(HostKeyChecking::No)), + hop("j2", Some(HostKeyChecking::Yes)), + hop("j1", Some(HostKeyChecking::AcceptNew)), + hop("j0", Some(HostKeyChecking::Ask)), + hop("unknown", None), + hop("j1", Some(HostKeyChecking::AcceptNew)), + ]; + + assert_eq!(hosts_to_pin(&hops), ["j1", "j3"]); + } + + #[test] + fn the_pins_come_before_what_ssh_would_have_read() { + assert_eq!( + render(&["j1", "fd00::1"], None).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nHost fd00::1\n StrictHostKeyChecking ask\nMatch all\n\ + Include ~/.ssh/config\nInclude /etc/ssh/ssh_config\n" + ); + assert_eq!( + render(&["j1"], Some(Path::new("/tmp/test/ssh_config"))).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nMatch all\nInclude \"/tmp/test/ssh_config\"\n" + ); + } + + #[test] + fn a_base_file_that_would_expand_differently_is_refused() { + for base in ["/tmp/%d/config", "/tmp/${HOME}/config", "/tmp/a\"b"] { + assert!( + matches!( + render(&[], Some(Path::new(base))), + Err(SshFailure::ConfigRejected { .. }) + ), + "{base}" + ); + } + } + + #[test] + fn the_file_is_private_and_its_path_one_shell_word() { + let dir = tempfile::tempdir().unwrap(); + + let path = write(dir.path(), "Match all\n").unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), "Match all\n"); + assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); + let spaced = dir.path().join("with space"); + std::fs::create_dir(&spaced).unwrap(); + assert!(matches!( + write(&spaced, "Match all\n"), + Err(SshFailure::RuntimeDirUnsafe { .. }) + )); + assert!(!plain_shell_word(Path::new("/run/user/1000/%d"))); + assert!(!plain_shell_word(Path::new("/run/user/1000/$(id)"))); + assert!(plain_shell_word(Path::new( + "/run/user/1000/app/app.tablepro.TablePro/ssh/0123/89ab/ssh_config" + ))); + } +} diff --git a/linux/crates/ssh/src/known_hosts.rs b/linux/crates/ssh/src/known_hosts.rs index 0871191730..f043d0396d 100644 --- a/linux/crates/ssh/src/known_hosts.rs +++ b/linux/crates/ssh/src/known_hosts.rs @@ -10,6 +10,10 @@ use crate::{SshConfig, SshServices}; pub async fn forget_host_key(services: &SshServices, config: &SshConfig, known_hosts: &Path) -> Result<(), SshFailure> { let mut args: Vec = vec!["-G".into()]; + if let Some(config_file) = &services.config_file { + args.push("-F".into()); + args.push(config_file.as_os_str().to_owned()); + } if let Some(port) = config.destination.port() { args.push("-p".into()); args.push(port.to_string().into()); diff --git a/linux/crates/ssh/src/lib.rs b/linux/crates/ssh/src/lib.rs index deba84c29f..8ab6405ba2 100644 --- a/linux/crates/ssh/src/lib.rs +++ b/linux/crates/ssh/src/lib.rs @@ -4,12 +4,13 @@ mod argv; mod askpass_bridge; mod config; mod destination; -mod first_hop; mod forward; +mod hop_config; mod host_key_checking; mod known_hosts; mod password_owner; mod prompt; +mod route; mod runtime; mod runtime_cell; mod services; diff --git a/linux/crates/ssh/src/route.rs b/linux/crates/ssh/src/route.rs new file mode 100644 index 0000000000..2d0f5393f4 --- /dev/null +++ b/linux/crates/ssh/src/route.rs @@ -0,0 +1,356 @@ +use std::ffi::OsString; +use std::path::Path; +use std::process::Stdio; +use std::time::Duration; + +use tablepro_core::NetworkEndpoint; +use tokio::process::Command; + +use crate::SshDestination; +use crate::host_key_checking::HostKeyChecking; + +/// `ssh -G` does no network I/O, but a `Match exec` line can run a +/// command, so each lookup is still bounded. +const LOOKUP_TIMEOUT: Duration = Duration::from_secs(5); +/// A ProxyJump chain whose hops name each other never ends; real chains +/// are a few hops long. +const MAX_HOPS: usize = 8; +const DEFAULT_PORT: u16 = 22; + +#[derive(Debug, PartialEq, Eq)] +enum Resolved { + Direct(NetworkEndpoint), + /// The ProxyJump chain, first hop first. + Jump(Vec), + ProxyCommand, +} + +/// One jump host the master goes through, with the StrictHostKeyChecking +/// the ssh OpenSSH starts for it reads from the configuration. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct JumpHop { + pub(crate) destination: SshDestination, + pub(crate) host_keys: Option, +} + +/// The jump hosts between this machine and the destination, as far as +/// `ssh -G` could follow them, and where the first TCP connection goes. +#[derive(Debug, Default, PartialEq, Eq)] +pub(crate) struct Route { + pub(crate) hops: Vec, + /// `None` when the route starts at a ProxyCommand, whose first hop + /// only that command knows, or could not be followed. + pub(crate) first_hop: Option, +} + +/// Follow the route from what `ssh -G` printed for the destination. +/// +/// An ssh_config alias is only a name until `ssh -G` resolves it, so +/// this asks ssh rather than trusting what the user typed. ssh.c runs +/// the last hop of a ProxyJump chain through a second ssh, given the +/// earlier hops as its own `-J`, so each hop is looked up the same way, +/// and only the first hop's own ProxyJump from the configuration applies. +pub(crate) async fn trace_route(ssh_program: &Path, config_file: Option<&Path>, destination: Option<&str>) -> Route { + let mut route = Route::default(); + let Some(mut output) = destination.map(str::to_owned) else { + return route; + }; + loop { + let chain = match parse_resolved(&output) { + Some(Resolved::Direct(endpoint)) => { + route.first_hop = Some(endpoint); + return route; + } + Some(Resolved::Jump(chain)) => chain, + Some(Resolved::ProxyCommand) | None => return route, + }; + for index in (0..chain.len()).rev() { + if route.hops.len() == MAX_HOPS { + tracing::debug!("the ssh jump chain did not end"); + return route; + } + let Some(resolved) = lookup(ssh_program, config_file, &chain[index], &chain[..index]).await else { + return route; + }; + route.hops.push(JumpHop { + destination: chain[index].clone(), + host_keys: HostKeyChecking::from_ssh_g(&resolved), + }); + output = resolved; + } + } +} + +pub(crate) async fn lookup( + ssh_program: &Path, + config_file: Option<&Path>, + destination: &SshDestination, + jumps: &[SshDestination], +) -> Option { + let mut args: Vec = vec!["-G".into()]; + if let Some(config_file) = config_file { + args.push("-F".into()); + args.push(config_file.as_os_str().to_owned()); + } + if !jumps.is_empty() { + let hops: Vec = jumps.iter().map(ToString::to_string).collect(); + args.push("-J".into()); + args.push(hops.join(",").into()); + } + if let Some(port) = destination.port() { + args.push("-p".into()); + args.push(port.to_string().into()); + } + if let Some(user) = destination.user() { + args.push("-l".into()); + args.push(user.into()); + } + args.push("--".into()); + args.push(destination.host().into()); + + let run = Command::new(ssh_program) + .args(&args) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .output(); + match tokio::time::timeout(LOOKUP_TIMEOUT, run).await { + Ok(Ok(output)) if output.status.success() => Some(String::from_utf8_lossy(&output.stdout).into_owned()), + Ok(Ok(output)) => { + tracing::debug!(status = ?output.status.code(), "ssh -G could not resolve a host"); + None + } + Ok(Err(error)) => { + tracing::debug!(%error, "could not run ssh -G"); + None + } + Err(_) => { + tracing::debug!("ssh -G did not finish"); + None + } + } +} + +fn parse_resolved(ssh_g_output: &str) -> Option { + let mut hostname = None; + let mut port = None; + let mut proxy_jump = None; + let mut proxy_command = false; + for line in ssh_g_output.lines() { + let Some((key, value)) = line.split_once(' ') else { + continue; + }; + let value = value.trim(); + match key { + "hostname" => hostname = Some(value), + "port" => port = value.parse::().ok(), + "proxyjump" if value != "none" => proxy_jump = Some(value), + "proxycommand" if value != "none" => proxy_command = true, + _ => {} + } + } + if let Some(chain) = proxy_jump { + let hops: Option> = chain + .split(',') + .map(|hop| { + let hop = hop.trim(); + let hop = hop.strip_prefix("ssh://").unwrap_or(hop); + SshDestination::parse_jump_list(hop).ok()?.pop() + }) + .collect(); + return hops.filter(|hops| !hops.is_empty()).map(Resolved::Jump); + } + if proxy_command { + return Some(Resolved::ProxyCommand); + } + NetworkEndpoint::new(hostname?, port.unwrap_or(DEFAULT_PORT)) + .ok() + .map(Resolved::Direct) +} + +#[cfg(test)] +mod tests { + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + + use super::*; + + fn endpoint(host: &str, port: u16) -> NetworkEndpoint { + NetworkEndpoint::new(host, port).unwrap() + } + + fn hop(host: &str) -> SshDestination { + SshDestination::new(host, None, None).unwrap() + } + + #[test] + fn a_direct_destination_resolves_to_its_hostname_and_port() { + let output = "user deploy\nhostname 192.0.2.10\nport 2222\n"; + + assert_eq!( + parse_resolved(output), + Some(Resolved::Direct(endpoint("192.0.2.10", 2222))) + ); + } + + #[test] + fn a_jump_chain_lists_every_hop_first_hop_first() { + let output = "hostname db.internal\nport 22\nproxyjump ops@jump.example.com:2200,second\n"; + + assert_eq!( + parse_resolved(output), + Some(Resolved::Jump(vec![ + SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap(), + hop("second"), + ])) + ); + } + + #[test] + fn a_uri_jump_and_an_ipv6_jump_parse() { + let uri = "hostname t\nproxyjump ssh://ops@jump.example.com:2200,ssh://second\n"; + let ipv6 = "hostname t\nproxyjump [::1]:2201\n"; + + assert_eq!( + parse_resolved(uri), + Some(Resolved::Jump(vec![ + SshDestination::new("jump.example.com", Some(2200), Some("ops".to_owned())).unwrap(), + hop("second"), + ])) + ); + assert_eq!( + parse_resolved(ipv6), + Some(Resolved::Jump(vec![ + SshDestination::new("::1", Some(2201), None).unwrap() + ])) + ); + } + + #[test] + fn a_hop_that_does_not_parse_leaves_the_route_unknown() { + assert_eq!(parse_resolved("hostname t\nproxyjump jump1,,jump2\n"), None); + } + + #[test] + fn a_proxy_command_has_no_known_first_hop() { + let output = "hostname t\nport 22\nproxycommand nc %h %p\n"; + + assert_eq!(parse_resolved(output), Some(Resolved::ProxyCommand)); + } + + #[test] + fn none_values_are_not_proxies() { + let output = "hostname t\nport 22\nproxyjump none\nproxycommand none\n"; + + assert_eq!(parse_resolved(output), Some(Resolved::Direct(endpoint("t", 22)))); + } + + fn fake_ssh(dir: &Path, script: &str) -> PathBuf { + let program = dir.join("ssh"); + std::fs::write(&program, format!("#!/bin/sh\n{script}\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + program + } + + async fn route(program: &Path, host: &str, jumps: &[&str]) -> Route { + let destination = hop(host); + let jumps: Vec = jumps.iter().map(|jump| hop(jump)).collect(); + let resolved = lookup(program, None, &destination, &jumps).await; + trace_route(program, None, resolved.as_deref()).await + } + + /// The fake prints a jump for the database host and resolves the + /// alias the jump names, the way an ssh_config would. + #[tokio::test] + async fn an_alias_behind_a_jump_resolves_through_ssh_g() { + let temp = tempfile::tempdir().unwrap(); + let program = fake_ssh( + temp.path(), + r#"for last; do :; done +case "$last" in + db) printf 'hostname 10.0.0.5\nport 5432\nproxyjump bastion\n' ;; + bastion) printf 'hostname 192.0.2.10\nport 2222\nstricthostkeychecking accept-new\n' ;; + *) exit 255 ;; +esac"#, + ); + + let route = route(&program, "db", &["bastion"]).await; + + assert_eq!(route.first_hop, Some(endpoint("192.0.2.10", 2222))); + assert_eq!( + route.hops, + [JumpHop { + destination: hop("bastion"), + host_keys: Some(HostKeyChecking::AcceptNew), + }] + ); + } + + /// The last hop is looked up with the earlier ones as its `-J`, the + /// way its ssh is started, and the first hop's own ProxyJump from the + /// configuration adds the hop before it. + #[tokio::test] + async fn every_hop_of_a_chain_is_looked_up_as_its_ssh_starts() { + let temp = tempfile::tempdir().unwrap(); + let program = fake_ssh( + temp.path(), + r#"for last; do :; done +case "$*" in + "-G -- db") printf 'hostname 10.0.0.5\nproxyjump j1,j2\n' ;; + "-G -J j1 -- j2") printf 'hostname 10.0.0.2\nproxyjump j1\nstricthostkeychecking false\n' ;; + "-G -- j1") printf 'hostname 10.0.0.1\nproxyjump j0\nstricthostkeychecking true\n' ;; + "-G -- j0") printf 'hostname 192.0.2.1\nport 2200\nstricthostkeychecking ask\n' ;; + *) exit 255 ;; +esac"#, + ); + + let route = route(&program, "db", &[]).await; + + let looked_up: Vec<(String, Option)> = route + .hops + .iter() + .map(|hop| (hop.destination.to_string(), hop.host_keys)) + .collect(); + assert_eq!( + looked_up, + [ + ("j2".to_owned(), Some(HostKeyChecking::No)), + ("j1".to_owned(), Some(HostKeyChecking::Yes)), + ("j0".to_owned(), Some(HostKeyChecking::Ask)), + ] + ); + assert_eq!(route.first_hop, Some(endpoint("192.0.2.1", 2200))); + } + + #[tokio::test] + async fn a_failing_lookup_and_a_jump_loop_resolve_to_nothing() { + let temp = tempfile::tempdir().unwrap(); + let failing = fake_ssh(temp.path(), "exit 255"); + assert_eq!(route(&failing, "db", &[]).await, Route::default()); + + let looping = tempfile::tempdir().unwrap(); + let program = fake_ssh(looping.path(), r"printf 'hostname x\nproxyjump loop\n'"); + let looped = route(&program, "loop", &[]).await; + assert_eq!(looped.first_hop, None); + assert_eq!(looped.hops.len(), MAX_HOPS); + } + + #[tokio::test] + async fn a_config_file_reaches_every_lookup() { + let temp = tempfile::tempdir().unwrap(); + let program = fake_ssh( + temp.path(), + r#"case "$*" in + "-G -F /etc/tablepro-test.conf -- db") printf 'hostname 10.0.0.5\nproxyjump j1\n' ;; + "-G -F /etc/tablepro-test.conf -- j1") printf 'hostname 192.0.2.1\n' ;; + *) exit 255 ;; +esac"#, + ); + let config = Path::new("/etc/tablepro-test.conf"); + + let resolved = lookup(&program, Some(config), &hop("db"), &[]).await; + let route = trace_route(&program, Some(config), resolved.as_deref()).await; + + assert_eq!(route.first_hop, Some(endpoint("192.0.2.1", 22))); + } +} diff --git a/linux/crates/ssh/src/services.rs b/linux/crates/ssh/src/services.rs index c86e09c7b1..b7c2141ec5 100644 --- a/linux/crates/ssh/src/services.rs +++ b/linux/crates/ssh/src/services.rs @@ -15,6 +15,10 @@ pub struct SshServices { pub ssh_program: PathBuf, pub ssh_keygen_program: PathBuf, pub askpass_program: PathBuf, + /// The ssh_config file every ssh this crate starts reads in place of + /// the user's and the system's, as `ssh -F` does. `None` leaves + /// OpenSSH to read its own. + pub config_file: Option, } impl SshServices { @@ -29,6 +33,7 @@ impl SshServices { ssh_program: PathBuf::from("ssh"), ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: locate_askpass(libexecdir, executable_dir.as_deref()), + config_file: None, } } @@ -72,6 +77,7 @@ mod tests { ssh_program: PathBuf::from("ssh"), ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program, + config_file: None, } } diff --git a/linux/crates/ssh/src/session.rs b/linux/crates/ssh/src/session.rs index e9993c00db..4f168b2e7a 100644 --- a/linux/crates/ssh/src/session.rs +++ b/linux/crates/ssh/src/session.rs @@ -18,10 +18,11 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use crate::argv::{ControlOp, master_args}; use crate::askpass_bridge::AskpassBridge; -use crate::first_hop::{lookup, resolve_first_hop}; +use crate::hop_config; use crate::host_key_checking::HostKeyChecking; use crate::known_hosts::spawn_failure; use crate::password_owner::password_owner; +use crate::route::{JumpHop, lookup, trace_route}; use crate::stderr_classify::{ClassifyContext, classify}; use crate::supervisor::{ForwardCancel, Supervisor, TASKS, run_control}; use crate::{SshAuth, SshConfig, SshServices, lock}; @@ -73,14 +74,41 @@ impl SshSession { return Err(error); } }; - let resolved = lookup(&services.ssh_program, &config.destination, &config.jump_hosts).await; + let config_file = services.config_file.as_deref(); + let resolved = lookup( + &services.ssh_program, + config_file, + &config.destination, + &config.jump_hosts, + ) + .await; let password_owner = match config.auth { SshAuth::Password { .. } => resolved.as_deref().and_then(password_owner), SshAuth::Agent | SshAuth::PrivateKey { .. } | SshAuth::KeyboardInteractive => None, }; let host_keys = resolved.as_deref().and_then(HostKeyChecking::from_ssh_g); + let route = tokio::select! { + route = trace_route(&services.ssh_program, config_file, resolved.as_deref()) => route, + () = cancel.cancelled() => { + remove_dir(&master_dir).await; + return Err(SshFailure::InteractionCancelled.into()); + } + }; + let master_config = match pin_jump_hosts(&route.hops, config_file, &master_dir).await { + Ok(written) => written.or_else(|| config_file.map(Path::to_path_buf)), + Err(error) => { + remove_dir(&master_dir).await; + return Err(error); + } + }; let spawned = Command::new(&services.ssh_program) - .args(master_args(config, &control, liveness, host_keys)) + .args(master_args( + config, + &control, + liveness, + host_keys, + master_config.as_deref(), + )) .env("SSH_ASKPASS", &services.askpass_program) .env("SSH_ASKPASS_REQUIRE", "force") .env_remove("SSH_ASKPASS_PROMPT") @@ -110,18 +138,16 @@ impl SshSession { &config.auth, password_owner, ); - let (outcome, first_hop) = tokio::join!( - handshake( - &mut child, - &listener, - &mut bridge, - services, - &control, - liveness, - &cancel, - ), - resolve_first_hop(&services.ssh_program, resolved.as_deref()), - ); + let outcome = handshake( + &mut child, + &listener, + &mut bridge, + services, + &control, + liveness, + &cancel, + ) + .await; drop(listener); if let Err(error) = tokio::fs::remove_file(master_dir.join("askpass")).await { tracing::debug!(%error, "could not remove the askpass socket"); @@ -161,7 +187,7 @@ impl SshSession { master_dir: master_dir.clone(), control: control.clone(), master_pid: child.id(), - first_hop, + first_hop: route.first_hop, closed: closed.clone(), shutdown: shutdown.clone(), exit_status: exit_status.clone(), @@ -227,6 +253,27 @@ impl Drop for SshSession { } } +/// The master's ssh_config when a jump host's own configuration would +/// trust a host key without asking, which the master's `-o` options do +/// not reach. `None` when every hop already asks or refuses. +async fn pin_jump_hosts( + hops: &[JumpHop], + config_file: Option<&Path>, + master_dir: &Path, +) -> Result, TransportError> { + let pinned = hop_config::hosts_to_pin(hops); + if pinned.is_empty() { + return Ok(None); + } + let contents = hop_config::render(&pinned, config_file)?; + let master_dir = master_dir.to_owned(); + tokio::task::spawn_blocking(move || hop_config::write(&master_dir, &contents)) + .await + .map_err(|error| protocol(&format!("the ssh setup task failed: {error}")))? + .map(Some) + .map_err(TransportError::from) +} + pub(crate) struct HandshakeDeadline { remaining: Duration, running_since: Option, diff --git a/linux/crates/ssh/src/sweep.rs b/linux/crates/ssh/src/sweep.rs index 5248baa908..f840c3db8c 100644 --- a/linux/crates/ssh/src/sweep.rs +++ b/linux/crates/ssh/src/sweep.rs @@ -115,6 +115,7 @@ mod tests { ssh_program: PathBuf::from("true"), ssh_keygen_program: PathBuf::from("true"), askpass_program: PathBuf::from("/nonexistent"), + config_file: None, } } diff --git a/linux/crates/ssh/tests/openssh_session.rs b/linux/crates/ssh/tests/openssh_session.rs index d99c71af69..c530d25437 100644 --- a/linux/crates/ssh/tests/openssh_session.rs +++ b/linux/crates/ssh/tests/openssh_session.rs @@ -72,6 +72,7 @@ async fn start() -> TestResult { ssh_program: wrapper, ssh_keygen_program: PathBuf::from("ssh-keygen"), askpass_program: PathBuf::from(env!("CARGO_BIN_EXE_tablepro-askpass")), + config_file: None, }; Ok(Fixture { container, @@ -406,6 +407,64 @@ async fn first_hop_is_the_destination_ssh_resolved() { assert_eq!(first_hop.host(), fixture.host); } +/// OpenSSH runs a jump host through a second ssh that never sees the +/// master's `-o` options, so a configuration that trusts new keys for +/// every host would trust the jump host's without the dialog. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_jump_host_whose_config_skips_the_host_key_check_still_asks() { + let fixture = start().await.unwrap(); + write_config(&fixture.config, &fixture.known_hosts, "accept-new").unwrap(); + let services = SshServices { + ssh_program: PathBuf::from("ssh"), + config_file: Some(fixture.config.clone()), + ..fixture.services.clone() + }; + let config = SshConfig { + // sshd reaches itself on its own port, so one container is both + // the jump host and the destination behind it. + destination: SshDestination::new("127.0.0.1", Some(2222), Some("deploy".to_owned())).unwrap(), + jump_hosts: vec![SshDestination::new(&fixture.host, Some(fixture.port), Some("deploy".to_owned())).unwrap()], + auth: SshAuth::Agent, + }; + let prompter = Arc::new(FakePrompter::new(vec![ + accept(), + answer("s3cret"), + accept(), + answer("s3cret"), + ])); + + let session = SshSession::connect( + &config, + &services, + &LivenessPolicy::DESKTOP, + CredentialInteraction::Attended(prompter.clone()), + CancellationToken::new(), + ) + .await; + + let purposes: Vec = prompter.requests().into_iter().map(|request| request.purpose).collect(); + let jump = format!("]:{}", fixture.port); + assert!( + matches!( + purposes.as_slice(), + [ + PromptPurpose::SshHostKeyConfirmation { host, .. }, + PromptPurpose::SshPassword { .. }, + PromptPurpose::SshHostKeyConfirmation { .. }, + PromptPurpose::SshPassword { .. }, + ] if host.ends_with(&jump) + ), + "{purposes:?}" + ); + let session = session.unwrap(); + assert_eq!( + session.first_hop_endpoint(), + Some(&NetworkEndpoint::new(&fixture.host, fixture.port).unwrap()) + ); + session.shutdown(Duration::from_secs(5)).await; +} + #[tokio::test] #[ignore = "requires docker"] async fn no_loopback_listener_while_tunnelled() { diff --git a/linux/docs/decisions/0013-openssh-client-transport.md b/linux/docs/decisions/0013-openssh-client-transport.md index 494d4bfb9f..29a1b7af10 100644 --- a/linux/docs/decisions/0013-openssh-client-transport.md +++ b/linux/docs/decisions/0013-openssh-client-transport.md @@ -22,6 +22,7 @@ People reach their databases through bastions configured in `~/.ssh/config`: `Pr - openssh-client 8.4 or later is a runtime dependency; the GNOME 50 Flatpak runtime ships it. - The helper is installed to libexecdir next to the app. +- A jump host runs in a second ssh that gets only `-l`, `-p`, `-J` and `-F` from the master (ssh.c turns ProxyJump into a ProxyCommand), never its `-o` options. When a hop's configuration sets `StrictHostKeyChecking accept-new` or `no`, the master gets a generated `-F` file that sets `ask` for that host and then includes `~/.ssh/config` and `/etc/ssh/ssh_config`, so the hop asks through the same dialog. Every other connection reads the configuration as before. - Failures are classified from OpenSSH's stderr, pinned by captured fixtures. A change to those strings in a later OpenSSH release shows up as a failing fixture test. - russh, ssh-key and internal-russh-forked-ssh-key leave the dependency graph when the app moves to this transport. From 400f8d5cdab83e496c12c00b8fe1f820da69351c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:27:04 +0700 Subject: [PATCH 007/301] fix(linux): drop late input to a closed component instead of panicking relm4's ComponentSender::input and FactorySender::input expect on a closed channel, so a read or save that answered after its window closed aborted the whole process. Both are now banned in favour of deliver. --- linux/crates/app/clippy.toml | 6 ++ linux/crates/app/src/runtime/deliver_input.rs | 51 +++++++++++++++++ linux/crates/app/src/runtime/mod.rs | 2 + linux/crates/app/src/ui/app/close_flow.rs | 7 ++- linux/crates/app/src/ui/app/connection.rs | 53 +++++++++--------- linux/crates/app/src/ui/app/import.rs | 7 ++- linux/crates/app/src/ui/app/mod.rs | 21 +++---- linux/crates/app/src/ui/app/status_pages.rs | 5 +- linux/crates/app/src/ui/app/structure.rs | 17 +++--- linux/crates/app/src/ui/app/workspace_tabs.rs | 5 +- linux/crates/app/src/ui/connection_row.rs | 14 +++-- linux/crates/app/src/ui/history_dialog.rs | 56 ++++++++++--------- linux/crates/app/src/ui/import_dialog.rs | 12 ++-- .../crates/app/src/ui/saved_queries_dialog.rs | 18 +++--- linux/crates/app/src/ui/sidebar_row.rs | 14 +++-- linux/crates/app/src/ui/welcome_view.rs | 5 +- linux/po/POTFILES.in | 1 + 17 files changed, 186 insertions(+), 108 deletions(-) create mode 100644 linux/crates/app/src/runtime/deliver_input.rs diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml index 84a393bc06..f3596aa3af 100644 --- a/linux/crates/app/clippy.toml +++ b/linux/crates/app/clippy.toml @@ -35,4 +35,10 @@ disallowed-methods = [ # under it skips the shutdown that flushes pending writes. { path = "gtk4::glib::MainContext::block_on", reason = "await in glib::spawn_future_local instead of blocking the main loop" }, { path = "gio::prelude::ApplicationExt::quit", reason = "close the window so shutdown runs; run() shuts the runtime down after" }, + + # A window can close while work it started is still on its way back. + # These panic on a component that has shut down; deliver drops the + # message instead. + { path = "relm4::ComponentSender::input", reason = "use crate::runtime::DeliverInput::deliver" }, + { path = "relm4::FactorySender::input", reason = "use crate::runtime::DeliverInput::deliver" }, ] diff --git a/linux/crates/app/src/runtime/deliver_input.rs b/linux/crates/app/src/runtime/deliver_input.rs new file mode 100644 index 0000000000..d17649315e --- /dev/null +++ b/linux/crates/app/src/runtime/deliver_input.rs @@ -0,0 +1,51 @@ +/// Send a component its input without panicking once it has shut down. +/// +/// relm4's `ComponentSender::input` and `FactorySender::input` call +/// `expect` on a closed channel, and a window can close while a read or +/// a save it started is still on its way back, which would take every +/// other window down with it. The message is dropped instead. +pub trait DeliverInput { + fn deliver(&self, message: M); +} + +impl DeliverInput for relm4::Sender { + fn deliver(&self, message: M) { + if self.send(message).is_err() { + tracing::debug!(target: "component", "message dropped after component shutdown"); + } + } +} + +impl DeliverInput for relm4::ComponentSender { + fn deliver(&self, message: C::Input) { + self.input_sender().deliver(message); + } +} + +impl DeliverInput for relm4::FactorySender { + fn deliver(&self, message: C::Input) { + self.input_sender().deliver(message); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_message_to_a_live_receiver_arrives() { + let (sender, receiver) = relm4::channel::(); + + sender.deliver(7); + + assert_eq!(receiver.recv_sync(), Some(7)); + } + + #[test] + fn a_message_after_shutdown_is_dropped_without_panicking() { + let (sender, receiver) = relm4::channel::(); + drop(receiver); + + sender.deliver(7); + } +} diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index 5763e2b4ea..60887bc330 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -1,5 +1,6 @@ mod app_runtime; mod deliver; +mod deliver_input; mod failed_requests; mod gio_process_hold; mod request_sequencer; @@ -9,6 +10,7 @@ mod stamped; pub use app_runtime::AppRuntime; pub use deliver::spawn_and_deliver; +pub use deliver_input::DeliverInput; pub use failed_requests::FailedRequests; pub use gio_process_hold::{GioProcessHold, hold_default_application}; pub use request_sequencer::RequestSequencer; diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs index 9d152d6341..ad998c67c3 100644 --- a/linux/crates/app/src/ui/app/close_flow.rs +++ b/linux/crates/app/src/ui/app/close_flow.rs @@ -10,6 +10,7 @@ use super::workspace_persistence::{WorkspacePersistence, WorkspaceTabs}; use super::{App, AppMsg, WorkspaceTab, connection}; use tablepro_session::runtime::ProcessHold; +use crate::runtime::DeliverInput; use crate::services::active_session::ActiveSession; use crate::services::session_context::SessionContext; use crate::workspace::{CloseDecision, InhibitReason}; @@ -118,7 +119,7 @@ impl App { } self.close_guards .ask_close_tab(self.window.upcast_ref(), &self.workspace_handle, id, move |decision| { - sender.input(AppMsg::TabCloseDecided { + sender.deliver(AppMsg::TabCloseDecided { id, approved: decision == CloseDecision::Approved, }); @@ -148,13 +149,13 @@ impl App { pub(super) fn close_workspace_then(&self, proceed: AppMsg, sender: ComponentSender) { self.close_guards.begin_teardown(); if self.close_guards.may_tear_down(&self.workspace_handle) { - sender.input(proceed); + sender.deliver(proceed); return; } self.close_guards .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { if decision == CloseDecision::Approved { - sender.input(proceed); + sender.deliver(proceed); } }); } diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index cdfbd702c1..ebb9faf245 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -27,6 +27,7 @@ use crate::workspace::CloseDecision; use super::connect_flow::{self, SubmitError}; use super::{App, AppMsg, qualified_label}; +use crate::runtime::DeliverInput; /// What a failed connect can be retried from: the connection as it was /// submitted, and the setting that would fix it. @@ -136,10 +137,10 @@ impl App { }); let submitted = sender.clone(); dialog.connect_submitted(move |_, submission| { - submitted.input(AppMsg::ConnectSubmitted(Box::new(submission))); + submitted.deliver(AppMsg::ConnectSubmitted(Box::new(submission))); }); let closed = sender; - dialog.connect_closed(move |_| closed.input(AppMsg::DialogClosed)); + dialog.connect_closed(move |_| closed.deliver(AppMsg::DialogClosed)); dialog.present(Some(&self.window)); if let Some(field) = focus { dialog.reveal_field(field); @@ -242,7 +243,7 @@ impl App { ), Err(failure) => failed(crate::ui::error_text::task_failure_message(&failure), retry), }; - sender.input(message); + sender.deliver(message); }); } @@ -279,7 +280,7 @@ impl App { if !self.close_guards.may_tear_down(&self.workspace_handle) { self.close_guards .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { - sender.input(match decision { + sender.deliver(match decision { CloseDecision::Approved => AppMsg::SessionReady(Box::new(ready)), CloseDecision::Cancelled => AppMsg::SessionDeclined(Box::new(ready)), }); @@ -320,12 +321,12 @@ impl App { _health: WatchForwarder::new( session.health(), move |health| { - health_sender.input(AppMsg::HealthChanged(FromSession::new(health_origin.clone(), health))); + health_sender.deliver(AppMsg::HealthChanged(FromSession::new(health_origin.clone(), health))); }, spawn_local, ), _catalog_watch: catalog_watch(&session, move |update| { - catalog_sender.input(AppMsg::CatalogChanged(update)); + catalog_sender.deliver(AppMsg::CatalogChanged(update)); }), }); for warning in warnings { @@ -367,7 +368,7 @@ impl App { let retry = std::cell::RefCell::new(Some(retry)); dialog.connect_response(Some("edit"), move |_, _| { if let Some(retry) = retry.borrow_mut().take() { - sender.input(AppMsg::EditConnection(retry)); + sender.deliver(AppMsg::EditConnection(retry)); } }); } @@ -431,11 +432,11 @@ impl App { Err(error) => tracing::warn!(%error, "the last-opened task failed"), Ok(Ok(())) => {} } - sender_for_touch.input(AppMsg::ReloadConnections); + sender_for_touch.deliver(AppMsg::ReloadConnections); }); return; } - sender.input(AppMsg::ReloadConnections); + sender.deliver(AppMsg::ReloadConnections); } /// The key this window's session is claimed under, which its saved @@ -490,10 +491,10 @@ impl App { let reading = self.tasks.spawn_blocking_task(move || store.load_blocking()); glib::spawn_future_local(async move { match reading.await { - Ok(Ok(connections)) => sender.input(AppMsg::ConnectionsLoaded(connections.to_vec())), + Ok(Ok(connections)) => sender.deliver(AppMsg::ConnectionsLoaded(connections.to_vec())), Ok(Err(error)) => { tracing::warn!(%error, "the saved connections could not be read"); - sender.input(AppMsg::ConnectionListUnavailable); + sender.deliver(AppMsg::ConnectionListUnavailable); } Err(failure) => tracing::warn!(%failure, "the connections read task failed"), } @@ -601,7 +602,7 @@ impl App { .await; if let Ok(Err(error)) = saved { tracing::warn!(%error, "could not save the duplicated connection"); - sender_clone.input(AppMsg::ShowToast(crate::i18n::gettext( + sender_clone.deliver(AppMsg::ShowToast(crate::i18n::gettext( "The connection could not be duplicated.", ))); return; @@ -610,8 +611,8 @@ impl App { // user types one, so a keyring that refuses is // reported rather than undoing the copy. let carried = copy_secrets(secrets.as_ref(), source.id, copy_id, &name).await; - sender_clone.input(AppMsg::ReloadConnections); - sender_clone.input(AppMsg::ShowToast(if carried { + sender_clone.deliver(AppMsg::ReloadConnections); + sender_clone.deliver(AppMsg::ShowToast(if carried { crate::i18n::gettext_f("Duplicated as {name}", &[("name", &name)]) } else { crate::i18n::gettext_f( @@ -670,9 +671,9 @@ impl App { .await; if let Ok(Err(error)) = written { tracing::warn!(%error, "could not save the connection"); - sender_clone.input(AppMsg::ShowToast(failure)); + sender_clone.deliver(AppMsg::ShowToast(failure)); } - sender_clone.input(AppMsg::ReloadConnections); + sender_clone.deliver(AppMsg::ReloadConnections); })); } @@ -759,8 +760,8 @@ impl App { crate::i18n::gettext("The connections could not be imported.") } }; - sender_clone.input(AppMsg::ReloadConnections); - sender_clone.input(AppMsg::ShowToast(message)); + sender_clone.deliver(AppMsg::ReloadConnections); + sender_clone.deliver(AppMsg::ShowToast(message)); })); } @@ -806,7 +807,7 @@ impl App { connect_flow::connect_saved(&saved, &drivers, &credentials, &*connector, interaction, cancel).await }; crate::runtime::spawn_and_deliver(&self.tasks, connecting, move |outcome| match outcome { - Ok(Ok((pending, refused))) => sender.input(AppMsg::SessionReady(Box::new(ReadySession { + Ok(Ok((pending, refused))) => sender.deliver(AppMsg::SessionReady(Box::new(ReadySession { key, pending, credentials: source, @@ -816,12 +817,12 @@ impl App { .collect(), generation: Some(generation), }))), - Ok(Err(ConnectError::Cancelled)) => sender.input(AppMsg::ConnectFailed { + Ok(Err(ConnectError::Cancelled)) => sender.deliver(AppMsg::ConnectFailed { generation, message: None, edit: None, }), - Ok(Err(error)) => sender.input(AppMsg::ConnectFailed { + Ok(Err(error)) => sender.deliver(AppMsg::ConnectFailed { generation, message: Some(crate::ui::error_text::connect_error_message(&error)), edit: Some(Box::new(ConnectRetry { @@ -829,7 +830,7 @@ impl App { ..retry })), }), - Err(failure) => sender.input(AppMsg::ConnectFailed { + Err(failure) => sender.deliver(AppMsg::ConnectFailed { generation, message: Some(crate::ui::error_text::task_failure_message(&failure)), edit: Some(Box::new(retry)), @@ -995,7 +996,7 @@ fn execute_delete_connection( // Keep the entry: an entry with no secrets can be // fixed, a secret with no entry is orphaned. tracing::warn!(%error, "could not delete the connection secrets; keeping the entry"); - sender_clone.input(AppMsg::SecretDeleteFailed(id)); + sender_clone.deliver(AppMsg::SecretDeleteFailed(id)); return; } let removed = tasks.spawn_blocking_task(move || connections.remove_blocking(id)).await; @@ -1011,7 +1012,7 @@ fn execute_delete_connection( { tracing::warn!(%error, "could not remove the connection's saved queries"); } - sender_clone.input(AppMsg::ReloadConnections); + sender_clone.deliver(AppMsg::ReloadConnections); })); } @@ -1040,7 +1041,7 @@ impl App { toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); let retry_sender = sender; toast.connect_button_clicked(move |_| { - retry_sender.input(AppMsg::DeleteConnection(id)); + retry_sender.deliver(AppMsg::DeleteConnection(id)); }); self.toast_overlay.add_toast(toast); } @@ -1078,7 +1079,7 @@ impl App { toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( &moved, ))); - sender.input(AppMsg::ReloadConnections); + sender.deliver(AppMsg::ReloadConnections); return; } Ok(Err(error)) => error.to_string(), diff --git a/linux/crates/app/src/ui/app/import.rs b/linux/crates/app/src/ui/app/import.rs index bfd7c1d009..7d90a8b8db 100644 --- a/linux/crates/app/src/ui/app/import.rs +++ b/linux/crates/app/src/ui/app/import.rs @@ -15,6 +15,7 @@ use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::import_dialog::{CsvImport, MAX_CSV_FILE_BYTES}; use super::{App, AppMsg}; +use crate::runtime::DeliverInput; use crate::workspace::RanStatements; /// How many of an import's statements go into the log line that @@ -152,8 +153,8 @@ impl App { RanStatements::starting(history, connection, sql) .finished(touched, tablepro_storage::query_history::Outcome::Success) .await; - sender.input(AppMsg::RefreshPage); - sender.input(AppMsg::ShowToast(crate::i18n::ngettext_f( + sender.deliver(AppMsg::RefreshPage); + sender.deliver(AppMsg::ShowToast(crate::i18n::ngettext_f( "Imported {n} row.", "Imported {n} rows.", u32::try_from(count).unwrap_or(u32::MAX), @@ -162,7 +163,7 @@ impl App { } Err(error) => { let message = crate::ui::error_text::operation_error_message(&error); - sender.input(AppMsg::ShowAlert { + sender.deliver(AppMsg::ShowAlert { title: crate::i18n::gettext("Couldn't import"), body: message, }); diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 1564c8fad1..4ef75573df 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -29,6 +29,7 @@ use super::editor::SqlEditor; use super::history_dialog::HistoryDialog; use super::sidebar_row::{SidebarRow, SidebarRowOutput}; use super::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; +use crate::runtime::DeliverInput; use crate::services::change_tracker_registry::ChangeTrackerRegistry; use crate::services::from_session::FromSession; use crate::services::session_context::SessionContext; @@ -529,7 +530,7 @@ impl SimpleComponent for App { pack_start = >k::Button { set_icon_name: crate::ui::icons::LIST_ADD, set_tooltip_text: Some(crate::i18n::gettext("New connection").as_str()), - connect_clicked => AppMsg::OpenConnect, + connect_clicked[sender] => move |_| sender.deliver(AppMsg::OpenConnect), }, #[name = "saved_connections_button"] @@ -758,7 +759,7 @@ impl SimpleComponent for App { let name = row.widget_name().to_string(); let idx = row.index() as usize; let schema = schemas_for_activate.borrow().get(idx).cloned().unwrap_or(None); - activate_sender.input(AppMsg::SelectTable { + activate_sender.deliver(AppMsg::SelectTable { schema, name, open_mode: OpenMode::SwitchOrAppend, @@ -855,7 +856,7 @@ impl SimpleComponent for App { let schema_for_button = current.clone(); let header_box = sidebar_section_header::sidebar_section_header(current.as_deref(), can_create_table, move || { - sender_for_button.input(AppMsg::NewTableTab { + sender_for_button.deliver(AppMsg::NewTableTab { schema: schema_for_button.clone(), }); }); @@ -1008,7 +1009,7 @@ impl SimpleComponent for App { CLOSED_TABS_CAPACITY, ))), }; - sender.input(AppMsg::ReloadConnections); + sender.deliver(AppMsg::ReloadConnections); model.show_welcome_page(sender.clone()); widgets @@ -1023,12 +1024,12 @@ impl SimpleComponent for App { let banner_sender = sender.clone(); widgets.reconnect_banner.connect_button_clicked(move |_| { - banner_sender.input(AppMsg::RetryConnection); + banner_sender.deliver(AppMsg::RetryConnection); }); let list_banner_sender = sender.clone(); widgets.connection_list_banner.connect_button_clicked(move |_| { - list_banner_sender.input(AppMsg::ResetConnectionList); + list_banner_sender.deliver(AppMsg::ResetConnectionList); }); ComponentParts { model, widgets } @@ -1306,7 +1307,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend ($name:expr, $msg:expr) => {{ let s = sender.clone(); gio::ActionEntry::builder($name) - .activate(move |_, _, _| s.input($msg)) + .activate(move |_, _, _| s.deliver($msg)) .build() }}; } @@ -1335,7 +1336,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend ]); let disconnect = gio::SimpleAction::new("disconnect", None); let sender_for_disconnect = sender.clone(); - disconnect.connect_activate(move |_, _| sender_for_disconnect.input(AppMsg::Disconnect)); + disconnect.connect_activate(move |_, _| sender_for_disconnect.deliver(AppMsg::Disconnect)); disconnect.set_enabled(false); group.add_action(&disconnect); @@ -1343,7 +1344,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend // and Ctrl+H stay insensitive until `HistoryService` reports Ready. let show_history = gio::SimpleAction::new("show-history", None); let sender_for_history = sender.clone(); - show_history.connect_activate(move |_, _| sender_for_history.input(AppMsg::ShowHistory)); + show_history.connect_activate(move |_, _| sender_for_history.deliver(AppMsg::ShowHistory)); show_history.set_enabled(false); group.add_action(&show_history); @@ -1351,7 +1352,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend // exactly when it is. let show_saved_queries = gio::SimpleAction::new("show-saved-queries", None); let sender_for_saved = sender.clone(); - show_saved_queries.connect_activate(move |_, _| sender_for_saved.input(AppMsg::ShowSavedQueries)); + show_saved_queries.connect_activate(move |_, _| sender_for_saved.deliver(AppMsg::ShowSavedQueries)); show_saved_queries.set_enabled(false); group.add_action(&show_saved_queries); diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/app/status_pages.rs index 8cf48615f2..5e084b761a 100644 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ b/linux/crates/app/src/ui/app/status_pages.rs @@ -5,6 +5,7 @@ use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, HistoryDialogO use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; use super::{App, AppMsg}; +use crate::runtime::DeliverInput; impl App { pub(super) fn show_welcome_page(&self, _sender: ComponentSender) { @@ -32,7 +33,7 @@ impl App { // take the whole connect timeout, so it can be stopped here. toast.set_button_label(Some(&crate::i18n::gettext("Cancel"))); let cancel = sender.clone(); - toast.connect_button_clicked(move |_| cancel.input(AppMsg::CancelConnect)); + toast.connect_button_clicked(move |_| cancel.deliver(AppMsg::CancelConnect)); self.toast_overlay.add_toast(toast.clone()); self.connect_progress_toast = Some(toast); } @@ -169,7 +170,7 @@ impl App { crate::i18n::gettext("Could not save that query.") } }; - sender_for_result.input(AppMsg::ShowToast(message)); + sender_for_result.deliver(AppMsg::ShowToast(message)); }) .drop_on_shutdown() }); diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/app/structure.rs index 29dc5d06e9..341295fe6b 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/app/structure.rs @@ -15,6 +15,7 @@ use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; use tokio_util::sync::CancellationToken; +use crate::runtime::DeliverInput; use crate::services::from_session::FromSession; use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; @@ -96,7 +97,7 @@ impl App { dialog.connect_response(None, move |dlg, response| { dlg.close(); if response == "drop" { - sender_for_resp.input(AppMsg::DropTableConfirmed { + sender_for_resp.deliver(AppMsg::DropTableConfirmed { schema: schema_for_resp.clone(), table: table_for_resp.clone(), }); @@ -142,8 +143,8 @@ impl App { }; crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { match settled(result).and_then(|(_, written)| written) { - Ok(_) => sender.input(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), - Err(error) => sender.input(AppMsg::ShowAlert { + Ok(_) => sender.deliver(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), + Err(error) => sender.deliver(AppMsg::ShowAlert { title: crate::i18n::gettext("Drop failed"), body: error_text::operation_error_message(&error), }), @@ -153,7 +154,7 @@ impl App { pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef, sender: ComponentSender) { self.close_tabs_for_table(table.schema.as_deref(), &table.name); - sender.input(AppMsg::SchemaChanged { + sender.deliver(AppMsg::SchemaChanged { schema: table.schema, table: Some(table.name), }); @@ -233,14 +234,14 @@ impl App { None, sender.clone(), ); - sender.input(AppMsg::SchemaChanged { + sender.deliver(AppMsg::SchemaChanged { schema, table: Some(name), }); } SaveKind::UpdateInPlace(schema, table) => { self.send_to_structure_tab(tab_id, StructureTabInput::SaveCompleted { new_table_name }); - sender.input(AppMsg::SchemaChanged { + sender.deliver(AppMsg::SchemaChanged { schema, table: Some(table), }); @@ -264,7 +265,7 @@ impl App { let message = match settled(result) { Ok((definition, dialect)) => match create_table_sql(&definition, &*dialect) { Ok(Some(sql)) => { - sender.input(AppMsg::ShowCreateTableLoaded(FromSession::new(origin, sql))); + sender.deliver(AppMsg::ShowCreateTableLoaded(FromSession::new(origin, sql))); return; } Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), @@ -278,7 +279,7 @@ impl App { &[("error", &error_text::operation_error_message(&error))], ), }; - sender.input(AppMsg::ShowToast(message)); + sender.deliver(AppMsg::ShowToast(message)); }); } diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 3ec367f6e5..56517eb6cc 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -24,6 +24,7 @@ use super::{ App, AppMsg, CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceTab, read_workspace_tab_id, write_workspace_tab_id, }; +use crate::runtime::DeliverInput; impl App { /// Builds the unified AdwTabOverview tree once per connection. @@ -90,7 +91,7 @@ impl App { }; let reading = tasks.spawn_blocking_task(move || RestoredDrafts::read_blocking(&store, &scope, referenced)); let drafts = reading.await.unwrap_or_default(); - sender.input(AppMsg::WorkspaceLoaded(FromSession::new( + sender.deliver(AppMsg::WorkspaceLoaded(FromSession::new( origin, LoadedWorkspace { key, load, drafts }, ))); @@ -200,7 +201,7 @@ impl App { let resetting = self.storage.workspaces().reset(&key); glib::spawn_future_local(async move { let outcome = resetting.await; - sender.input(AppMsg::WorkspaceLayoutReset(FromSession::new(origin, outcome))); + sender.deliver(AppMsg::WorkspaceLayoutReset(FromSession::new(origin, outcome))); }); } diff --git a/linux/crates/app/src/ui/connection_row.rs b/linux/crates/app/src/ui/connection_row.rs index b8f4cff36a..76419457f4 100644 --- a/linux/crates/app/src/ui/connection_row.rs +++ b/linux/crates/app/src/ui/connection_row.rs @@ -7,6 +7,8 @@ use uuid::Uuid; use tablepro_core::AuthMode; use tablepro_storage::{ConnectionColor, SavedConnection}; +use crate::runtime::DeliverInput; + /// What a row needs to build itself: the connection, plus the groups /// the rest of the list is filed under so its menu can offer them. #[derive(Debug, Clone)] @@ -68,7 +70,7 @@ impl FactoryComponent for ConnectionRow { set_title: &self.saved.name, set_subtitle: &subtitle_for(&self.saved), set_activatable: true, - connect_activated => ConnectionRowMsg::Open, + connect_activated[sender] => move |_| sender.deliver(ConnectionRowMsg::Open), // The colour the user put on this connection, as a dot // ahead of the name. Absent rather than grey when there is @@ -118,11 +120,11 @@ impl FactoryComponent for ConnectionRow { let actions = gio::SimpleActionGroup::new(); let duplicate_sender = sender.clone(); let duplicate = gio::ActionEntry::builder("duplicate") - .activate(move |_, _, _| duplicate_sender.input(ConnectionRowMsg::RequestDuplicate)) + .activate(move |_, _, _| duplicate_sender.deliver(ConnectionRowMsg::RequestDuplicate)) .build(); let remove_sender = sender.clone(); let remove = gio::ActionEntry::builder("remove") - .activate(move |_, _, _| remove_sender.input(ConnectionRowMsg::RequestDelete)) + .activate(move |_, _, _| remove_sender.deliver(ConnectionRowMsg::RequestDelete)) .build(); actions.add_action_entries([duplicate, remove]); @@ -139,7 +141,7 @@ impl FactoryComponent for ConnectionRow { return; }; action.set_state(&id.to_variant()); - color_sender.input(ConnectionRowMsg::SetColor(ConnectionColor::from_id(&id))); + color_sender.deliver(ConnectionRowMsg::SetColor(ConnectionColor::from_id(&id))); }); actions.add_action(&color); @@ -156,7 +158,7 @@ impl FactoryComponent for ConnectionRow { return; }; action.set_state(&name.to_variant()); - group_sender.input(ConnectionRowMsg::SetGroup(match name.trim().is_empty() { + group_sender.deliver(ConnectionRowMsg::SetGroup(match name.trim().is_empty() { true => None, false => Some(name), })); @@ -164,7 +166,7 @@ impl FactoryComponent for ConnectionRow { actions.add_action(&group); let new_group_sender = sender.clone(); let new_group = gio::ActionEntry::builder("new-group") - .activate(move |_, _, _| new_group_sender.input(ConnectionRowMsg::NewGroupRequested)) + .activate(move |_, _, _| new_group_sender.deliver(ConnectionRowMsg::NewGroupRequested)) .build(); actions.add_action_entries([new_group]); root.insert_action_group("connection", Some(&actions)); diff --git a/linux/crates/app/src/ui/history_dialog.rs b/linux/crates/app/src/ui/history_dialog.rs index bf9f8ebfe1..c83a8cf77d 100644 --- a/linux/crates/app/src/ui/history_dialog.rs +++ b/linux/crates/app/src/ui/history_dialog.rs @@ -12,6 +12,8 @@ use tablepro_storage::query_history::{Entry, SearchFilter}; use uuid::Uuid; +use crate::runtime::DeliverInput; + pub struct HistoryDialog { history: Option, tasks: Tasks, @@ -189,7 +191,7 @@ impl Component for HistoryDialog { for combo in [&filter_connection, &filter_status, &filter_window] { let s = sender.clone(); - combo.connect_selected_notify(move |_| s.input(HistoryDialogInput::FiltersChanged)); + combo.connect_selected_notify(move |_| s.deliver(HistoryDialogInput::FiltersChanged)); } header.pack_start(&filter_button); @@ -200,7 +202,7 @@ impl Component for HistoryDialog { .build(); let s_select = sender.clone(); select_button.connect_toggled(move |btn| { - s_select.input(HistoryDialogInput::ToggleSelectMode(btn.is_active())); + s_select.deliver(HistoryDialogInput::ToggleSelectMode(btn.is_active())); }); header.pack_start(&select_button); @@ -227,23 +229,23 @@ impl Component for HistoryDialog { let action_group = gio::SimpleActionGroup::new(); let export_sql_sender = sender.clone(); let export_sql = gio::ActionEntry::builder("export-sql") - .activate(move |_, _, _| export_sql_sender.input(HistoryDialogInput::ExportSelectedSql)) + .activate(move |_, _, _| export_sql_sender.deliver(HistoryDialogInput::ExportSelectedSql)) .build(); let export_csv_sender = sender.clone(); let export_csv = gio::ActionEntry::builder("export-csv") - .activate(move |_, _, _| export_csv_sender.input(HistoryDialogInput::ExportSelectedCsv)) + .activate(move |_, _, _| export_csv_sender.deliver(HistoryDialogInput::ExportSelectedCsv)) .build(); let delete_sender = sender.clone(); let delete = gio::ActionEntry::builder("delete-selected") - .activate(move |_, _, _| delete_sender.input(HistoryDialogInput::DeleteSelected)) + .activate(move |_, _, _| delete_sender.deliver(HistoryDialogInput::DeleteSelected)) .build(); let clear_sender = sender.clone(); let clear_all = gio::ActionEntry::builder("clear-all") - .activate(move |_, _, _| clear_sender.input(HistoryDialogInput::ClearAllRequested)) + .activate(move |_, _, _| clear_sender.deliver(HistoryDialogInput::ClearAllRequested)) .build(); let storage_sender = sender.clone(); let show_storage = gio::ActionEntry::builder("show-storage") - .activate(move |_, _, _| storage_sender.input(HistoryDialogInput::OpenStorageLocation)) + .activate(move |_, _, _| storage_sender.deliver(HistoryDialogInput::OpenStorageLocation)) .build(); action_group.add_action_entries([export_sql, export_csv, delete, clear_all, show_storage]); @@ -252,7 +254,7 @@ impl Component for HistoryDialog { .hexpand(true) .build(); let s = sender.clone(); - search.connect_search_changed(move |_| s.input(HistoryDialogInput::SearchChanged)); + search.connect_search_changed(move |_| s.deliver(HistoryDialogInput::SearchChanged)); let search_bar = gtk::SearchBar::builder() .child(&search) @@ -351,7 +353,7 @@ impl Component for HistoryDialog { let delete_button = gtk::Button::builder().label(crate::i18n::gettext("Delete")).build(); delete_button.add_css_class("destructive-action"); let s_del_bar = sender.clone(); - delete_button.connect_clicked(move |_| s_del_bar.input(HistoryDialogInput::DeleteSelected)); + delete_button.connect_clicked(move |_| s_del_bar.deliver(HistoryDialogInput::DeleteSelected)); let selection_bar_inner = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) @@ -403,7 +405,7 @@ impl Component for HistoryDialog { }; model.load_connections(&sender); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); ComponentParts { model, widgets: () } } @@ -447,7 +449,7 @@ impl Component for HistoryDialog { tracing::warn!(error = %e, "history set_pinned failed"); } }); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogInput::Delete(id) => { @@ -460,7 +462,7 @@ impl Component for HistoryDialog { tracing::warn!(error = %e, "history delete failed"); } }); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogInput::ToggleSelected(id, on) => { @@ -519,7 +521,7 @@ impl Component for HistoryDialog { dialog.connect_response(None, move |d, response| { d.close(); if response == "delete" { - s.input(HistoryDialogInput::DeleteSelectedConfirmed); + s.deliver(HistoryDialogInput::DeleteSelectedConfirmed); } }); dialog.present(Some(&self.root)); @@ -538,7 +540,7 @@ impl Component for HistoryDialog { tracing::warn!(error = %e, "history delete_many failed"); } }); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogInput::ExportSelectedSql => self.export_selected("sql", sender), @@ -560,7 +562,7 @@ impl Component for HistoryDialog { dialog.connect_response(None, move |d, response| { d.close(); if response == "clear" { - s.input(HistoryDialogInput::ClearAllConfirmed); + s.deliver(HistoryDialogInput::ClearAllConfirmed); } }); dialog.present(Some(&self.root)); @@ -617,7 +619,7 @@ impl Component for HistoryDialog { HistoryDialogCmd::Loaded(entries) => self.render_entries(entries, &sender), HistoryDialogCmd::Cleared => { self.selected_ids.clear(); - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); } HistoryDialogCmd::ExportReady(content, suggested_name) => self.save_export(content, suggested_name), HistoryDialogCmd::ExportFailed(msg) => { @@ -643,7 +645,7 @@ impl HistoryDialog { &self.tasks, async move { history.known_connections().await }, move |listed| match listed { - Ok(Ok(connections)) => sender.input(HistoryDialogInput::ConnectionsLoaded(connections)), + Ok(Ok(connections)) => sender.deliver(HistoryDialogInput::ConnectionsLoaded(connections)), Ok(Err(error)) => tracing::warn!(%error, "could not list the connections in the history"), Err(failure) => tracing::warn!(%failure, "the history connections task failed"), }, @@ -655,7 +657,7 @@ impl HistoryDialog { fn run_search(&self, sender: ComponentSender) { let filter = self.build_filter(); let Some(history) = self.history.clone() else { - sender.input(HistoryDialogInput::Refresh); + sender.deliver(HistoryDialogInput::Refresh); return; }; sender.command(move |out, shutdown| { @@ -685,7 +687,7 @@ impl HistoryDialog { let slot = self.filter_debounce.clone(); let id = gtk::glib::timeout_add_local_once(std::time::Duration::from_millis(150), move || { slot.borrow_mut().take(); - s.input(HistoryDialogInput::Refresh); + s.deliver(HistoryDialogInput::Refresh); }); *self.filter_debounce.borrow_mut() = Some(id); } @@ -828,7 +830,7 @@ impl HistoryDialog { let id_for_check = entry.id; let s_check = sender.clone(); select_check.connect_toggled(move |btn| { - s_check.input(HistoryDialogInput::ToggleSelected(id_for_check, btn.is_active())); + s_check.deliver(HistoryDialogInput::ToggleSelected(id_for_check, btn.is_active())); }); row.add_prefix(&select_check); @@ -847,7 +849,7 @@ impl HistoryDialog { } let id = entry.id; let s = sender.clone(); - pin_btn.connect_clicked(move |_| s.input(HistoryDialogInput::TogglePin(id))); + pin_btn.connect_clicked(move |_| s.deliver(HistoryDialogInput::TogglePin(id))); row.add_suffix(&pin_btn); let delete_btn = gtk::Button::builder() @@ -858,11 +860,11 @@ impl HistoryDialog { delete_btn.add_css_class("flat"); delete_btn.add_css_class("destructive-action"); let s_del = sender.clone(); - delete_btn.connect_clicked(move |_| s_del.input(HistoryDialogInput::Delete(id))); + delete_btn.connect_clicked(move |_| s_del.deliver(HistoryDialogInput::Delete(id))); row.add_suffix(&delete_btn); let s_act = sender.clone(); - row.connect_activated(move |_| s_act.input(HistoryDialogInput::Activate(id))); + row.connect_activated(move |_| s_act.deliver(HistoryDialogInput::Activate(id))); let menu = gio::Menu::new(); let nav_section = gio::Menu::new(); @@ -894,15 +896,15 @@ impl HistoryDialog { let row_actions = gio::SimpleActionGroup::new(); let s_open = sender.clone(); let act_open = gio::ActionEntry::builder("open") - .activate(move |_, _, _| s_open.input(HistoryDialogInput::Activate(id))) + .activate(move |_, _, _| s_open.deliver(HistoryDialogInput::Activate(id))) .build(); let s_replace = sender.clone(); let act_replace = gio::ActionEntry::builder("replace") - .activate(move |_, _, _| s_replace.input(HistoryDialogInput::ReplaceCurrent(id))) + .activate(move |_, _, _| s_replace.deliver(HistoryDialogInput::ReplaceCurrent(id))) .build(); let s_pin = sender.clone(); let act_pin = gio::ActionEntry::builder("pin") - .activate(move |_, _, _| s_pin.input(HistoryDialogInput::TogglePin(id))) + .activate(move |_, _, _| s_pin.deliver(HistoryDialogInput::TogglePin(id))) .build(); let query_for_copy = entry.query.clone(); let row_for_copy = row.clone(); @@ -913,7 +915,7 @@ impl HistoryDialog { .build(); let s_del2 = sender.clone(); let act_del = gio::ActionEntry::builder("delete") - .activate(move |_, _, _| s_del2.input(HistoryDialogInput::Delete(id))) + .activate(move |_, _, _| s_del2.deliver(HistoryDialogInput::Delete(id))) .build(); row_actions.add_action_entries([act_open, act_replace, act_pin, act_copy, act_del]); row.insert_action_group("history-row", Some(&row_actions)); diff --git a/linux/crates/app/src/ui/import_dialog.rs b/linux/crates/app/src/ui/import_dialog.rs index 191d1907b4..b40e012f5c 100644 --- a/linux/crates/app/src/ui/import_dialog.rs +++ b/linux/crates/app/src/ui/import_dialog.rs @@ -9,6 +9,8 @@ use tablepro_core::ColumnInfo; use tablepro_core::export::CsvDelimiter; use tablepro_core::import::{CsvImportOptions, CsvSheet, MAX_PREVIEW_ROWS, read_csv, suggest_mapping}; +use crate::runtime::DeliverInput; + /// The most a CSV may be before the import refuses it. /// /// Every row becomes an INSERT in one transaction, so the file's size @@ -107,7 +109,7 @@ impl Component for ImportCsvDialog { let import_button = gtk::Button::builder().label(crate::i18n::gettext("Import")).build(); import_button.add_css_class("suggested-action"); let sender_for_import = sender.clone(); - import_button.connect_clicked(move |_| sender_for_import.input(ImportCsvDialogInput::Import)); + import_button.connect_clicked(move |_| sender_for_import.deliver(ImportCsvDialogInput::Import)); header.pack_end(&import_button); toolbar.add_top_bar(&header); @@ -158,11 +160,11 @@ impl Component for ImportCsvDialog { root.set_child(Some(&toolbar)); let sender_for_header = sender.clone(); - header_row.connect_active_notify(move |_| sender_for_header.input(ImportCsvDialogInput::Reread)); + header_row.connect_active_notify(move |_| sender_for_header.deliver(ImportCsvDialogInput::Reread)); let sender_for_delimiter = sender.clone(); - delimiter_row.connect_selected_notify(move |_| sender_for_delimiter.input(ImportCsvDialogInput::Reread)); + delimiter_row.connect_selected_notify(move |_| sender_for_delimiter.deliver(ImportCsvDialogInput::Reread)); let sender_for_null = sender.clone(); - null_row.connect_changed(move |_| sender_for_null.input(ImportCsvDialogInput::MappingChanged)); + null_row.connect_changed(move |_| sender_for_null.deliver(ImportCsvDialogInput::MappingChanged)); let mut model = ImportCsvDialog { root: root.clone(), @@ -269,7 +271,7 @@ impl ImportCsvDialog { .build(); row.set_selected(field.map_or(0, |index| index as u32 + 1)); let sender = sender.clone(); - row.connect_selected_notify(move |_| sender.input(ImportCsvDialogInput::MappingChanged)); + row.connect_selected_notify(move |_| sender.deliver(ImportCsvDialogInput::MappingChanged)); self.mapping_group.add(&row); self.mapping_rows.borrow_mut().push(row); } diff --git a/linux/crates/app/src/ui/saved_queries_dialog.rs b/linux/crates/app/src/ui/saved_queries_dialog.rs index 7cf2058f7a..d66d75ac84 100644 --- a/linux/crates/app/src/ui/saved_queries_dialog.rs +++ b/linux/crates/app/src/ui/saved_queries_dialog.rs @@ -7,6 +7,8 @@ use tablepro_storage::SavedQueries; use tablepro_storage::saved_queries::SavedQuery; use uuid::Uuid; +use crate::runtime::DeliverInput; + /// The user's named queries, grouped by the connection each was saved /// against. /// @@ -94,7 +96,7 @@ impl Component for SavedQueriesDialog { search_bar.append(&search); toolbar.add_top_bar(&search_bar); let sender_for_search = sender.clone(); - search.connect_search_changed(move |_| sender_for_search.input(SavedQueriesDialogInput::SearchChanged)); + search.connect_search_changed(move |_| sender_for_search.deliver(SavedQueriesDialogInput::SearchChanged)); let groups = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) @@ -136,7 +138,7 @@ impl Component for SavedQueriesDialog { status_page, queries: Vec::new(), }; - sender.input(SavedQueriesDialogInput::Refresh); + sender.deliver(SavedQueriesDialogInput::Refresh); ComponentParts { model, widgets: () } } @@ -202,10 +204,10 @@ impl Component for SavedQueriesDialog { self.queries = queries; self.render(&sender); } - SavedQueriesDialogCmd::Changed => sender.input(SavedQueriesDialogInput::Refresh), + SavedQueriesDialogCmd::Changed => sender.deliver(SavedQueriesDialogInput::Refresh), SavedQueriesDialogCmd::Failed(message) => { let _ = sender.output(SavedQueriesDialogOutput::ShowToast(message)); - sender.input(SavedQueriesDialogInput::Refresh); + sender.deliver(SavedQueriesDialogInput::Refresh); } } } @@ -290,7 +292,7 @@ impl SavedQueriesDialog { let id = saved.id; let sender_for_activate = sender.clone(); - row.connect_activated(move |_| sender_for_activate.input(SavedQueriesDialogInput::Activate(id))); + row.connect_activated(move |_| sender_for_activate.deliver(SavedQueriesDialogInput::Activate(id))); let menu = gio::Menu::new(); let open_section = gio::Menu::new(); @@ -311,7 +313,7 @@ impl SavedQueriesDialog { let actions = gio::SimpleActionGroup::new(); let entry = |name: &str, message: fn(i64) -> SavedQueriesDialogInput, sender: ComponentSender| { gio::ActionEntry::::builder(name) - .activate(move |_, _, _| sender.input(message(id))) + .activate(move |_, _, _| sender.deliver(message(id))) .build() }; actions.add_action_entries([ @@ -361,7 +363,7 @@ impl SavedQueriesDialog { if name.trim().is_empty() { return; } - sender.input(SavedQueriesDialogInput::Rename(id, name)); + sender.deliver(SavedQueriesDialogInput::Rename(id, name)); }); dialog.present(Some(&self.root)); } @@ -386,7 +388,7 @@ impl SavedQueriesDialog { dialog.connect_response(None, move |dialog, response| { dialog.close(); if response == "delete" { - sender.input(SavedQueriesDialogInput::Delete(id)); + sender.deliver(SavedQueriesDialogInput::Delete(id)); } }); dialog.present(Some(&self.root)); diff --git a/linux/crates/app/src/ui/sidebar_row.rs b/linux/crates/app/src/ui/sidebar_row.rs index 15a76fc235..07ea56e934 100644 --- a/linux/crates/app/src/ui/sidebar_row.rs +++ b/linux/crates/app/src/ui/sidebar_row.rs @@ -7,6 +7,8 @@ use relm4::gtk::prelude::*; use tablepro_core::meta::ObjectInfo; +use crate::runtime::DeliverInput; + #[derive(Debug)] pub struct SidebarRow { pub info: ObjectInfo, @@ -151,7 +153,7 @@ impl FactoryComponent for SidebarRow { let state = gesture.current_event_state(); if state.contains(gdk::ModifierType::CONTROL_MASK) { gesture.set_state(gtk::EventSequenceState::Claimed); - sender_for_ctrl.input(SidebarRowMsg::OpenInNewTab); + sender_for_ctrl.deliver(SidebarRowMsg::OpenInNewTab); } }); root.add_controller(click_gesture); @@ -216,23 +218,23 @@ impl FactoryComponent for SidebarRow { let group = gtk::gio::SimpleActionGroup::new(); let sender_open = sender.clone(); let open_action = gtk::gio::ActionEntry::builder("open-in-new-tab") - .activate(move |_, _, _| sender_open.input(SidebarRowMsg::OpenInNewTab)) + .activate(move |_, _, _| sender_open.deliver(SidebarRowMsg::OpenInNewTab)) .build(); let sender_edit = sender.clone(); let edit_action = gtk::gio::ActionEntry::builder("edit-structure") - .activate(move |_, _, _| sender_edit.input(SidebarRowMsg::EditStructure)) + .activate(move |_, _, _| sender_edit.deliver(SidebarRowMsg::EditStructure)) .build(); let sender_show = sender.clone(); let show_create_action = gtk::gio::ActionEntry::builder("show-create-table") - .activate(move |_, _, _| sender_show.input(SidebarRowMsg::ShowCreateTable)) + .activate(move |_, _, _| sender_show.deliver(SidebarRowMsg::ShowCreateTable)) .build(); let sender_import = sender.clone(); let import_action = gtk::gio::ActionEntry::builder("import-csv") - .activate(move |_, _, _| sender_import.input(SidebarRowMsg::ImportCsv)) + .activate(move |_, _, _| sender_import.deliver(SidebarRowMsg::ImportCsv)) .build(); let sender_drop = sender.clone(); let drop_action = gtk::gio::ActionEntry::builder("drop-table") - .activate(move |_, _, _| sender_drop.input(SidebarRowMsg::DropTable)) + .activate(move |_, _, _| sender_drop.deliver(SidebarRowMsg::DropTable)) .build(); group.add_action_entries([open_action, edit_action, show_create_action, import_action, drop_action]); root.insert_action_group("sidebar-row", Some(&group)); diff --git a/linux/crates/app/src/ui/welcome_view.rs b/linux/crates/app/src/ui/welcome_view.rs index a514641f68..b82c672606 100644 --- a/linux/crates/app/src/ui/welcome_view.rs +++ b/linux/crates/app/src/ui/welcome_view.rs @@ -8,6 +8,7 @@ use uuid::Uuid; use super::connection_list; use super::connection_row::{ConnectionRow, ConnectionRowInit, ConnectionRowOutput}; +use crate::runtime::DeliverInput; pub struct WelcomeView { connections: Vec, @@ -87,7 +88,7 @@ impl SimpleComponent for WelcomeView { empty_btn.add_css_class("suggested-action"); empty_btn.add_css_class("pill"); let s_empty = sender.clone(); - empty_btn.connect_clicked(move |_| s_empty.input(WelcomeViewInput::OpenConnect)); + empty_btn.connect_clicked(move |_| s_empty.deliver(WelcomeViewInput::OpenConnect)); empty_page.set_child(Some(&empty_btn)); root.add_named(&empty_page, Some("empty")); @@ -126,7 +127,7 @@ impl SimpleComponent for WelcomeView { .build(); header_btn.add_css_class("flat"); let s_header = sender.clone(); - header_btn.connect_clicked(move |_| s_header.input(WelcomeViewInput::OpenConnect)); + header_btn.connect_clicked(move |_| s_header.deliver(WelcomeViewInput::OpenConnect)); group.set_header_suffix(Some(&header_btn)); group.add(factory.widget()); outer.append(&group); diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 27b2ed9ca2..ca5b4bb877 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -11,6 +11,7 @@ crates/app/src/persistence/mod.rs crates/app/src/persistence/state_file.rs crates/app/src/runtime/app_runtime.rs crates/app/src/runtime/deliver.rs +crates/app/src/runtime/deliver_input.rs crates/app/src/runtime/failed_requests.rs crates/app/src/runtime/gio_process_hold.rs crates/app/src/runtime/mod.rs From a2deb1bb7847e6806eab19f3b1c358e87acf7367 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:27:14 +0700 Subject: [PATCH 008/301] feat(linux-grid): set a cell to NULL with Ctrl+Backspace Ctrl+Shift+N becomes the application's New Window, and GTK runs application accelerators before a focused grid sees the key. --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/browse_tab/imp.rs | 9 ++++----- linux/crates/app/src/ui/browse_tab/mod.rs | 2 +- linux/data/resources/shortcuts-dialog.ui | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 71337c5a22..f800e3429d 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. - Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. +- Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. ### Fixed diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index 2921376c7d..f86569b019 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -117,11 +117,10 @@ fn install_bindings(klass: &mut ::Class) { ctrl, send(|| BrowseTabInput::InsertRow, glib::Propagation::Stop), ); - // No GNOME precedent for "set NULL"; Ctrl+Backspace is delete-word - // in every text field, so the null shortcut is Ctrl+Shift+N. - klass.add_binding(gdk::Key::n, ctrl | gdk::ModifierType::SHIFT_MASK, |tab| { - tab.set_focused_cell_null() - }); + // Ctrl+Shift+N is the application's New Window. An open cell editor + // takes Ctrl+Backspace as delete-word first, so this sees it only on + // a focused cell. + klass.add_binding(gdk::Key::BackSpace, ctrl, |tab| tab.set_focused_cell_null()); // A cell editor's text field consumes Ctrl+C and Ctrl+V first, so // these only see the grid's own copy and paste. klass.add_binding( diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 1196cd2a61..71c54e4a8d 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -444,7 +444,7 @@ impl BrowseTab { }); } - /// Set the focused cell to NULL, for Ctrl+Shift+N. A locked cell + /// Set the focused cell to NULL, for Ctrl+Backspace. A locked cell /// lets the key go on. fn set_focused_cell_null(&self) -> glib::Propagation { // `RootExt::focus` would return the window itself. diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index 63b2a08c89..e9d46bc5c5 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -116,7 +116,7 @@ Set focused cell to NULL - <Primary><Shift>n + <Primary>BackSpace From 8e39de18aa40af531d6ff2c80302d31b44cebbf7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:27:29 +0700 Subject: [PATCH 009/301] fix(linux-grid): edit cells from lossless edit text with exact editors Drafts hold CellInput, so a cell left to the server is not a NULL, and InsertDraft seeds Default where the server fills. The grid parses each commit against its column and sends a TrackerEdit; untouched text is no edit. --- linux/CHANGELOG.md | 7 + .../crates/app/src/services/change_tracker.rs | 172 ++++-- .../app/src/services/column_relayout.rs | 37 +- .../app/src/services/tab_tracker_handle.rs | 31 +- linux/crates/app/src/services/tracker_edit.rs | 9 +- linux/crates/app/src/services/undo_op.rs | 12 +- linux/crates/app/src/ui/browse_tab/mod.rs | 147 ++--- linux/crates/app/src/ui/browse_tab/tests.rs | 543 ++++++++++++------ linux/crates/app/src/ui/error_text/edit.rs | 162 ++++++ linux/crates/app/src/ui/error_text/mod.rs | 2 + .../crates/app/src/ui/grid/calendar_editor.rs | 166 ++++++ linux/crates/app/src/ui/grid/cell_edit.rs | 158 +++++ linux/crates/app/src/ui/grid/cell_editor.rs | 210 ------- .../app/src/ui/grid/cell_editor_kind.rs | 325 +++++++++-- linux/crates/app/src/ui/grid/cell_popovers.rs | 303 ---------- linux/crates/app/src/ui/grid/cell_preset.rs | 91 +++ .../crates/app/src/ui/grid/column_drawing.rs | 122 ++-- .../crates/app/src/ui/grid/column_factory.rs | 362 ++++++------ linux/crates/app/src/ui/grid/context_menu.rs | 16 +- linux/crates/app/src/ui/grid/display_text.rs | 59 +- linux/crates/app/src/ui/grid/edit_commit.rs | 281 +++++++++ linux/crates/app/src/ui/grid/edit_snapshot.rs | 7 +- linux/crates/app/src/ui/grid/grid_cell.rs | 138 +++-- linux/crates/app/src/ui/grid/grid_menus.rs | 87 ++- linux/crates/app/src/ui/grid/grid_msg.rs | 11 +- linux/crates/app/src/ui/grid/inline_edit.rs | 222 ------- linux/crates/app/src/ui/grid/inline_editor.rs | 146 +++++ linux/crates/app/src/ui/grid/mod.rs | 15 +- .../app/src/ui/grid/multiline_editor.rs | 107 ++++ .../app/src/ui/grid/tab_grid_context.rs | 49 +- linux/crates/app/src/ui/grid/toggle_cell.rs | 163 ++++++ .../app/src/workspace/workspace_handle.rs | 8 +- .../crates/app/tests/tracker_save_postgres.rs | 8 +- linux/data/resources/style.css | 13 +- linux/po/POTFILES.in | 10 +- 35 files changed, 2599 insertions(+), 1600 deletions(-) create mode 100644 linux/crates/app/src/ui/error_text/edit.rs create mode 100644 linux/crates/app/src/ui/grid/calendar_editor.rs create mode 100644 linux/crates/app/src/ui/grid/cell_edit.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_editor.rs delete mode 100644 linux/crates/app/src/ui/grid/cell_popovers.rs create mode 100644 linux/crates/app/src/ui/grid/edit_commit.rs delete mode 100644 linux/crates/app/src/ui/grid/inline_edit.rs create mode 100644 linux/crates/app/src/ui/grid/inline_editor.rs create mode 100644 linux/crates/app/src/ui/grid/multiline_editor.rs create mode 100644 linux/crates/app/src/ui/grid/toggle_cell.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 13c3d95b56..95407e90f7 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. - Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. - Delete button in the table grid, with the reason in its tooltip when the selected rows can't be deleted. +- Set to DEFAULT in a new row's cell menu, for columns the server fills. ### Changed @@ -29,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. - Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. - Delete, Duplicate and Copy as in a grid cell's menu act on every selected row when the clicked row is selected. +- Grid cells edit in place with the keyboard each type needs; dates open a calendar, and JSON, XML and long text a larger editor. +- Set Value in a grid cell's menu is Set to Empty, Set to NULL and Set to DEFAULT, each shown only where the column takes it. ### Fixed @@ -50,6 +53,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - ClickHouse save with an unknown outcome could read as applied because of another table's mutation. - A new row removed with Delete stayed in the grid while the save left it out. - Undoing a delete of many rows took one Ctrl+Z per row and could not bring back more than 50. +- Editing a number in the grid could round it: integers past 2^53 and floats past six decimal places. +- Setting a new row's cell to NULL saved the column's default instead. +- Setting a NOT NULL cell to NULL was accepted and failed at save. +- The Menu key on a read-only grid cell opened a text selection menu instead of the cell's menu. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. - Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. - Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index 56dc07d2e5..ba50dd7c1e 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -55,13 +55,12 @@ impl RowKey { /// the one the row was read with. pub type KeyValue = tablepro_core::value::ValueIdentity; -/// A draft row collected by the tracker. `values` is the full column -/// vector (length = `columns.len()` at the time of creation). Empty -/// cells start as `Value::Null`. +/// A draft row collected by the tracker, one cell per column. A cell +/// left to the server is `CellInput::Default`, which is not a NULL. #[derive(Debug, Clone, PartialEq)] pub struct DraftRow { pub draft_id: u64, - pub values: Vec, + pub cells: Vec, /// The insert or the latest edit to one of its cells. pub touched: Touch, } @@ -168,8 +167,8 @@ impl TabChangeTracker { original, value, } => self.track_cell_edit(row_key, col, original, value), - TrackerEdit::DraftCell { draft_id, col, value } => self.track_draft_cell_edit(draft_id, col, value), - TrackerEdit::Insert { values } => self.track_insert(values), + TrackerEdit::DraftCell { draft_id, col, input } => self.track_draft_cell_edit(draft_id, col, input), + TrackerEdit::InsertDraft { cells } => self.track_insert(cells), } } @@ -186,7 +185,7 @@ impl TabChangeTracker { persisted.iter().any(|(row_key, _)| self.row_in_flight(row_key)) || drafts.iter().any(|draft_id| self.draft_in_flight(*draft_id)) } - TrackerEdit::Insert { .. } => false, + TrackerEdit::InsertDraft { .. } => false, } } @@ -250,10 +249,10 @@ impl TabChangeTracker { self.put_cell_edit(&row_key, col, after) } - fn track_insert(&mut self, values: Vec) -> TrackerDelta { + fn track_insert(&mut self, cells: Vec) -> TrackerDelta { let draft = DraftRow { draft_id: self.next_draft_id, - values, + cells, touched: self.touch(), }; self.next_draft_id += 1; @@ -313,7 +312,7 @@ impl TabChangeTracker { true } - fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, value: Value) -> TrackerDelta { + fn track_draft_cell_edit(&mut self, draft_id: u64, col: usize, input: CellInput) -> TrackerDelta { let Some((before, before_touch)) = self.draft_cell(draft_id, col) else { return self.delta(Vec::new()); }; @@ -322,16 +321,16 @@ impl TabChangeTracker { draft_id, col, before, - after: value.clone(), + after: input.clone(), before_touch, after_touch, }); - self.put_draft_cell(draft_id, col, value, after_touch) + self.put_draft_cell(draft_id, col, input, after_touch) } - fn draft_cell(&self, draft_id: u64, col: usize) -> Option<(Value, Touch)> { + fn draft_cell(&self, draft_id: u64, col: usize) -> Option<(CellInput, Touch)> { let draft = self.drafts.iter().find(|d| d.draft_id == draft_id)?; - Some((draft.values.get(col)?.clone(), draft.touched)) + Some((draft.cells.get(col)?.clone(), draft.touched)) } fn is_being_written(&self, cell: &(RowKey, usize)) -> bool { @@ -387,14 +386,14 @@ impl TabChangeTracker { } } - fn put_draft_cell(&mut self, draft_id: u64, col: usize, value: Value, touched: Touch) -> TrackerDelta { + fn put_draft_cell(&mut self, draft_id: u64, col: usize, input: CellInput, touched: Touch) -> TrackerDelta { let Some(draft) = self.drafts.iter_mut().find(|d| d.draft_id == draft_id) else { return self.delta(Vec::new()); }; - let Some(cell) = draft.values.get_mut(col) else { + let Some(cell) = draft.cells.get_mut(col) else { return self.delta(Vec::new()); }; - *cell = value; + *cell = input; draft.touched = touched; self.delta(vec![RowKey::Draft(draft_id)]) } @@ -486,7 +485,7 @@ impl TabChangeTracker { pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { CellOverlay { - value: self.current_cell_value(row_key, col, original).clone(), + value: self.current_cell_value(row_key, col, original), state: self.cell_state(row_key, col), error_flash: self.error_row.as_ref() == Some(row_key), } @@ -541,27 +540,42 @@ impl TabChangeTracker { } /// A draft's cells live only here, so for a draft `original` is - /// what an out-of-range column reads as. - fn current_cell_value<'a>(&'a self, row_key: &RowKey, col: usize, original: &'a Value) -> &'a Value { + /// what an out-of-range column reads as, and a cell left to the + /// server reads as NULL. + fn current_cell_value(&self, row_key: &RowKey, col: usize, original: &Value) -> Value { if let RowKey::Draft(draft_id) = row_key { - return self - .drafts - .iter() - .find(|draft| draft.draft_id == *draft_id) - .and_then(|draft| draft.values.get(col)) - .unwrap_or(original); + return match self.draft_input(*draft_id, col) { + Some(CellInput::Value(value)) => value, + Some(CellInput::Default) => Value::Null, + None => original.clone(), + }; } match self.updates.get(&(row_key.clone(), col)) { - Some(edit) => &edit.pending, - None => original, + Some(edit) => edit.pending.clone(), + None => original.clone(), } } - pub fn draft_values(&self, draft_id: u64) -> Option<&[Value]> { + /// A draft's values, with a cell left to the server as NULL. + pub fn draft_values(&self, draft_id: u64) -> Option> { self.drafts .iter() .find(|draft| draft.draft_id == draft_id) - .map(|draft| draft.values.as_slice()) + .map(|draft| { + draft + .cells + .iter() + .map(|input| input.value().cloned().unwrap_or(Value::Null)) + .collect() + }) + } + + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .and_then(|draft| draft.cells.get(col)) + .cloned() } /// A draft's cells as its INSERT carries them. @@ -693,9 +707,9 @@ impl TabChangeTracker { rows.push(row_key); } for mut draft in std::mem::take(&mut self.drafts) { - match layout.row(&draft.values) { - Ok(values) => { - draft.values = values; + match layout.draft(&draft.cells) { + Ok(cells) => { + draft.cells = cells; self.drafts.push(draft); } Err(misfit) => { @@ -946,25 +960,29 @@ fn key_values(key: &[KeyValue]) -> Vec { key.iter().map(|part| part.value().clone()).collect() } +/// A column the server always fills is left to it whatever the draft +/// holds: SQL Server IDENTITY and PostgreSQL GENERATED ALWAYS refuse a +/// value. fn insert_cells(columns: &[ColumnInfo], draft: &DraftRow) -> Vec { columns .iter() - .zip(&draft.values) - .map(|(column, value)| draft_cell(column, value)) + .zip(&draft.cells) + .map( + |(column, input)| match column.is_auto_increment || column.is_generated { + true => CellInput::Default, + false => input.clone(), + }, + ) .collect() } -/// A NULL the user left in a column with a default means "use the -/// default" (CURRENT_TIMESTAMP, gen_random_uuid()), not an explicit -/// NULL over it. -fn draft_cell(column: &ColumnInfo, value: &Value) -> CellInput { - if column.is_auto_increment || column.is_generated { - return CellInput::Default; +/// What a new row holds in `column` until the user types: the server's +/// value where it has one, else NULL. +pub fn new_draft_cell(column: &ColumnInfo) -> CellInput { + match column.is_auto_increment || column.is_generated || !column.default.is_none() { + true => CellInput::Default, + false => CellInput::Value(Value::Null), } - if matches!(value, Value::Null) && !column.default.is_none() { - return CellInput::Default; - } - CellInput::Value(value.clone()) } #[cfg(test)] @@ -1032,7 +1050,11 @@ mod tests { } fn insert(t: &mut TabChangeTracker, values: Vec) -> u64 { - match t.track_insert(values).inserted_drafts.as_slice() { + match t + .track_insert(values.into_iter().map(CellInput::Value).collect()) + .inserted_drafts + .as_slice() + { [draft_id] => *draft_id, other => panic!("an insert adds one draft row: {other:?}"), } @@ -1130,7 +1152,7 @@ mod tests { let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); - t.track_insert(vec![Value::Null]); + t.track_insert(vec![Value::Null].into_iter().map(CellInput::Value).collect()); delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); t.discard(); assert_eq!(t.pending_count(), 0); @@ -1266,21 +1288,21 @@ mod tests { let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("b"), + input: CellInput::Value(text("b")), }); let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("c"), + input: CellInput::Value(text("c")), }); let undone = t.undo(); assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); - assert_eq!(t.drafts()[0].values[1], text("b")); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(text("b"))); let redone = t.redo().map(|delta| delta.changed); assert_eq!(redone, Some(vec![RowKey::Draft(draft_id)])); - assert_eq!(t.drafts()[0].values[1], text("c")); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(text("c"))); } #[test] @@ -1336,7 +1358,7 @@ mod tests { let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("a"), + input: CellInput::Value(text("a")), }); assert_eq!( @@ -1354,7 +1376,7 @@ mod tests { let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: text("a"), + input: CellInput::Value(text("a")), }); let edited = vec!["delete [Int(1)]".to_owned(), format!("insert {draft_id}")]; assert_eq!(script(&t, &columns), edited); @@ -1402,7 +1424,12 @@ mod tests { _ => t.track_cell_edit(rk(&[Value::Int(id)]), 1, text("a"), text("b")), }; } - let _ = t.track_insert(vec![Value::Null, text("draft")]); + let _ = t.track_insert( + vec![Value::Null, text("draft")] + .into_iter() + .map(CellInput::Value) + .collect(), + ); }; let mut first = TabChangeTracker::default(); let mut second = TabChangeTracker::default(); @@ -1441,7 +1468,12 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; let doomed = rk(&[Value::Int(9)]); - t.track_insert(vec![Value::Null, Value::Text("draft".into())]); + t.track_insert( + vec![Value::Null, Value::Text("draft".into())] + .into_iter() + .map(CellInput::Value) + .collect(), + ); delete( &mut t, doomed.clone(), @@ -1491,7 +1523,12 @@ mod tests { tablepro_core::column::SqlExpression::from_catalog_text("now()"), ); let columns = vec![pk_col("id"), data_col("name"), stamped]; - t.track_insert(vec![Value::Null, Value::Text("draft".into()), Value::Null]); + let mut cells: Vec = columns.iter().map(new_draft_cell).collect(); + cells[1] = CellInput::Value(Value::Text("draft".into())); + let draft_id = match t.track_insert(cells).inserted_drafts.as_slice() { + [draft_id] => *draft_id, + other => panic!("an insert adds one draft row: {other:?}"), + }; let tracked = t.change_set(&columns).unwrap(); @@ -1505,6 +1542,17 @@ mod tests { ] }] ); + + // NULL set on purpose is not the default. + let _ = t.apply(TrackerEdit::DraftCell { + draft_id, + col: 2, + input: CellInput::Value(Value::Null), + }); + assert_eq!( + t.draft_cells(draft_id, &columns).map(|cells| cells[2].clone()), + Some(CellInput::Value(Value::Null)) + ); } #[test] @@ -1544,7 +1592,7 @@ mod tests { let mut t = TabChangeTracker::default(); let draft_id = insert(&mut t, vec![Value::Null, Value::Text("hi".into())]); // Edit the draft so undo stack has a CellEdit on top of Insert. - t.track_draft_cell_edit(draft_id, 1, Value::Text("edited".into())); + t.track_draft_cell_edit(draft_id, 1, CellInput::Value(Value::Text("edited".into()))); assert_eq!(t.drafts().len(), 1); assert!(t.can_undo()); @@ -1658,14 +1706,14 @@ mod tests { let edited = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: Value::Text("b".into()), + input: CellInput::Value(Value::Text("b".into())), }); let undone = t.undo(); assert_eq!(edited.changed, vec![RowKey::Draft(draft_id)]); assert!(edited.inserted_drafts.is_empty() && edited.removed_drafts.is_empty()); assert_eq!(undone.map(|delta| delta.changed), Some(vec![RowKey::Draft(draft_id)])); - assert_eq!(t.drafts()[0].values[1], Value::Text("a".into())); + assert_eq!(t.drafts()[0].cells[1], CellInput::Value(Value::Text("a".into()))); } #[test] @@ -1675,7 +1723,7 @@ mod tests { let _ = t.apply(TrackerEdit::DraftCell { draft_id, col: 1, - value: Value::Text("b".into()), + input: CellInput::Value(Value::Text("b".into())), }); let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1, &Value::Null); @@ -1696,7 +1744,7 @@ mod tests { let delta = t.apply(TrackerEdit::DraftCell { draft_id, col: 4, - value: Value::Int(1), + input: CellInput::Value(Value::Int(1)), }); assert_eq!( @@ -1897,7 +1945,7 @@ mod tests { assert_eq!( t.draft_values(draft_id), - Some(&[Value::Null, text("Ada"), Value::Null][..]) + Some(vec![Value::Null, text("Ada"), Value::Null]) ); } diff --git a/linux/crates/app/src/services/column_relayout.rs b/linux/crates/app/src/services/column_relayout.rs index 27dcc9777a..daa4c34870 100644 --- a/linux/crates/app/src/services/column_relayout.rs +++ b/linux/crates/app/src/services/column_relayout.rs @@ -1,10 +1,10 @@ use tablepro_core::column::ColumnType; -use tablepro_core::edit::parse_literal_text; +use tablepro_core::edit::{CellInput, parse_literal_text}; use tablepro_core::export::value_text; use tablepro_core::value::same_value; use tablepro_core::{ColumnInfo, Value}; -use super::change_tracker::RowKey; +use super::change_tracker::{RowKey, new_draft_cell}; /// How the columns a tab's pending changes were made against line up /// with the columns it read since. Columns are matched by name: once a @@ -87,23 +87,38 @@ impl<'a> ColumnRelayout<'a> { } } - /// A new row's values under the new columns. A value the user left + /// Where a draft cell's input in old column `col` belongs now. A + /// cell left to the server fits anywhere, as NULL does. + pub(crate) fn input_cell(&self, col: usize, input: &CellInput) -> Result { + self.cell(col, input.value().unwrap_or(&Value::Null)) + } + + /// A new row's cells under the new columns. A value the user left /// in a column that is gone, or that no longer takes it, has no - /// place; an empty cell there is dropped. - pub(crate) fn row(&self, values: &[Value]) -> Result, Misfit> { + /// place; an empty cell there is dropped. A new column starts as a + /// new row's would. + pub(crate) fn draft(&self, cells: &[CellInput]) -> Result, Misfit> { let mut lost = Vec::new(); - for (col, value) in values.iter().enumerate() { - if matches!(value, Value::Null) { + for (col, input) in cells.iter().enumerate() { + let Some(value) = input.value().filter(|value| !matches!(value, Value::Null)) else { continue; - } + }; if let Err(Misfit::Columns(columns)) = self.cell(col, value) { lost.extend(columns); } } - match lost.is_empty() { - true => Ok(self.fetched(values)), - false => Err(Misfit::Columns(lost)), + if !lost.is_empty() { + return Err(Misfit::Columns(lost)); } + Ok(self + .sources + .iter() + .zip(self.new) + .map(|(source, column)| match source.and_then(|col| cells.get(col)) { + Some(input) => input.clone(), + None => new_draft_cell(column), + }) + .collect()) } /// A row as read under the old columns, under the new ones. A new diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 010105db52..4bc325425c 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -56,7 +56,11 @@ impl TabTrackerHandle { } pub fn draft_values(&self, draft_id: u64) -> Option> { - self.0.borrow().draft_values(draft_id).map(<[Value]>::to_vec) + self.0.borrow().draft_values(draft_id) + } + + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { + self.0.borrow().draft_input(draft_id, col) } pub fn draft_cells(&self, draft_id: u64, columns: &[ColumnInfo]) -> Option> { @@ -191,8 +195,11 @@ mod tests { #[test] fn committed_clears_only_snapshot_edits() { let tracker = edited(); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(9), Value::Int(9)], + let _ = tracker.apply(TrackerEdit::InsertDraft { + cells: vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), }); let batch = tracker.begin_save().expect("a save").expect("changes to save"); assert_eq!(batch.changes.changes.changes.len(), 2); @@ -214,8 +221,11 @@ mod tests { #[test] fn an_insert_or_delete_in_flight_is_not_changed_until_it_settles() { let tracker = edited(); - let inserted = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(9), Value::Int(9)], + let inserted = tracker.apply(TrackerEdit::InsertDraft { + cells: vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), }); let draft_id = match inserted.inserted_drafts.as_slice() { [draft_id] => *draft_id, @@ -226,7 +236,7 @@ mod tests { let typed = TrackerEdit::DraftCell { draft_id, col: 1, - value: Value::Int(10), + input: CellInput::Value(Value::Int(10)), }; assert!(tracker.refuses(&typed)); let held = tracker.apply(typed); @@ -243,7 +253,7 @@ mod tests { ); assert!(held.inserted_drafts.is_empty() && held.removed_drafts.is_empty()); - assert_eq!(tracker.drafts()[0].values[1], Value::Int(9)); + assert_eq!(tracker.drafts()[0].cells[1], CellInput::Value(Value::Int(9))); let _ = tracker.settle_save(&rolled_back(), batch.snapshot); assert!(tracker.undo().is_some()); } @@ -258,8 +268,11 @@ mod tests { original: Value::Text("a".into()), value: Value::Text("b".into()), }); - let inserted = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Null, Value::Text("c".into())], + let inserted = tracker.apply(TrackerEdit::InsertDraft { + cells: vec![Value::Null, Value::Text("c".into())] + .into_iter() + .map(CellInput::Value) + .collect(), }); assert_eq!(edited.changed, vec![row(1)]); diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs index edd172bc17..c7960773fd 100644 --- a/linux/crates/app/src/services/tracker_edit.rs +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use tablepro_core::Value; +use tablepro_core::edit::CellInput; use super::change_tracker::RowKey; @@ -16,10 +17,10 @@ pub enum TrackerEdit { DraftCell { draft_id: u64, col: usize, - value: Value, + input: CellInput, }, - Insert { - values: Vec, + InsertDraft { + cells: Vec, }, /// One Delete: the saved rows are marked, each with the row as it /// was read, and the drafts are dropped. @@ -40,7 +41,7 @@ impl TrackerEdit { .map(|(row_key, _)| row_key.clone()) .chain(drafts.iter().map(|draft_id| RowKey::Draft(*draft_id))) .collect(), - Self::Insert { .. } => Vec::new(), + Self::InsertDraft { .. } => Vec::new(), } } } diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs index d7db5ca814..e35575fc4c 100644 --- a/linux/crates/app/src/services/undo_op.rs +++ b/linux/crates/app/src/services/undo_op.rs @@ -1,4 +1,4 @@ -use tablepro_core::Value; +use tablepro_core::edit::CellInput; use super::change_tracker::{CellEdit, DeleteMark, DraftRow, RowKey}; use super::column_relayout::ColumnRelayout; @@ -21,8 +21,8 @@ pub enum UndoOp { DraftCell { draft_id: u64, col: usize, - before: Value, - after: Value, + before: CellInput, + after: CellInput, before_touch: Touch, after_touch: Touch, }, @@ -79,10 +79,10 @@ impl UndoOp { before_touch, after_touch, } => { - layout.cell(*col, before).ok()?; + layout.input_cell(*col, before).ok()?; Some(UndoOp::DraftCell { draft_id: *draft_id, - col: layout.cell(*col, after).ok()?, + col: layout.input_cell(*col, after).ok()?, before: before.clone(), after: after.clone(), before_touch: *before_touch, @@ -91,7 +91,7 @@ impl UndoOp { } UndoOp::Insert { draft } => Some(UndoOp::Insert { draft: DraftRow { - values: layout.row(&draft.values).ok()?, + cells: layout.draft(&draft.cells).ok()?, ..draft.clone() }, }), diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 02a3b5cf62..998d2089be 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -26,7 +26,7 @@ use gtk4::{gdk, gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::dialect::SqlDialect; -use tablepro_core::edit::{CellInput, EditContext, EditParseError}; +use tablepro_core::edit::{CellInput, EditParseError, parse_edit_text}; use tablepro_core::export::RenderInsertError; use tablepro_core::meta::{RowIdentity, TableRef}; use tablepro_core::{ColumnInfo, QueryResult, Value}; @@ -39,10 +39,10 @@ pub(crate) use self::page_reply::PageReply; use self::row_deletion::{RowDeletion, plan_row_deletion}; use super::grid::{ CellPreset, ColumnWidthBinding, DeleteAvailability, GridCell, GridMsg, GridRowId, GridRowIndex, GridSink, - RowObject, TabGridContext, build_column_view, cell_editability, delete_availability, + RowObject, TabGridContext, build_column_view, cell_editability, cell_presets, delete_availability, edit_context, }; use crate::runtime::{FailedRequests, RequestSequencer}; -use crate::services::change_tracker::{RowKey, StatementSource, pk_identity}; +use crate::services::change_tracker::{RowKey, StatementSource, new_draft_cell, pk_identity}; use crate::services::save_batch::SaveTarget; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; @@ -503,7 +503,7 @@ impl From for BrowseTabInput { fn from(msg: GridMsg) -> Self { match msg { GridMsg::SortChanged(col_idx, ascending) => Self::SortChanged { col_idx, ascending }, - GridMsg::CellEdited { row, column, input } => Self::GridCellEdited { row, column, input }, + GridMsg::Edit { row, column, edit } => Self::GridEdit { row, column, edit }, GridMsg::CopyToClipboard(text) => Self::GridCopyToClipboard(text), GridMsg::ShowToast(text) => Self::GridShowToast(text), GridMsg::CopyRowsAsInsert { rows } => Self::GridCopyRowsAsInsert(rows), @@ -669,15 +669,15 @@ pub(crate) enum BrowseTabInput { FocusInsertedDraft, /// Delete over these rows, from the key, the button or the menu. DeleteRows(Vec), - GridCellEdited { + /// An edit the grid parsed for the column, which the tab checks + /// against the cell's lock before the tracker takes it. + GridEdit { row: RowObject, column: usize, - input: String, + edit: TrackerEdit, }, - /// Cell context-menu "Set Value". The grid names the preset; the - /// tab resolves it against the column's declared type, because - /// only the tab holds the column metadata that says whether an - /// empty string is a value the column can hold. + /// A preset from a cell's menu or Ctrl+Shift+N. The tab resolves it + /// against the column, so both paths reach the same value. GridSetCellValue { row: RowObject, column: usize, @@ -822,25 +822,22 @@ impl BrowseModel { )) } - /// Resolve a "Set Value" preset against the column it lands in. - /// The grid offers Empty only on free-text columns, so the - /// fallback here is for the keyboard and action-activation paths: - /// an empty string means NULL on a column that takes one, and is - /// refused on a column that does not, exactly as typing an empty - /// value into the cell would be. - fn resolve_cell_preset(&self, preset: CellPreset, col_index: usize, context: EditContext) -> Result { + /// Resolve a preset against the column it lands in. The menu offers + /// only the ones the cell can take; the keyboard's Set NULL reaches + /// here for any cell, and a column that refuses the value says why, + /// as committing an empty cell would. + fn resolve_cell_preset(&self, preset: CellPreset, row: &RowObject, col: usize) -> Result { + let Some(column) = self.current_columns.get(col) else { + return Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)); + }; + let presets = cell_presets(column, row.id()); match preset { - CellPreset::Null => Ok(Value::Null), - CellPreset::Empty => parse_input_for_column("", self.current_columns.get(col_index), context), - } - } - - /// Whether an edit lands in a draft row, which is the one place an - /// empty cell can mean the server's default. - fn edit_context_for(row: &RowObject) -> EditContext { - match row.id() { - GridRowId::Draft(_) => EditContext::InsertDraft, - GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => EditContext::UpdateRow, + CellPreset::Empty => parse_edit_text("", column, edit_context(row.id())) + .map_err(|error| crate::ui::error_text::edit_parse_message(&error)), + CellPreset::Null if presets.null => Ok(CellInput::Value(Value::Null)), + CellPreset::Null => Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)), + CellPreset::Default if presets.default => Ok(CellInput::Default), + CellPreset::Default => Err(crate::i18n::gettext("This cell can't take the column's default")), } } @@ -927,21 +924,21 @@ impl BrowseModel { self.apply_tracker_delta(&delta, fx); } - /// A typed or preset value for one cell, against the row the edit - /// was made on, wherever it sits now. - fn edit_cell(&mut self, row: &RowObject, col: usize, value: Value, fx: &mut Effects) { - match row.id() { - GridRowId::Draft(draft_id) => { + /// A preset value for one cell, against the row the edit was made + /// on, wherever it sits now. + fn edit_cell(&mut self, row: &RowObject, col: usize, input: CellInput, fx: &mut Effects) { + match (row.id(), input) { + (GridRowId::Draft(draft_id), input) => { self.apply_edit( TrackerEdit::DraftCell { draft_id: *draft_id, col, - value, + input, }, fx, ); } - GridRowId::Persisted(row_key) => { + (GridRowId::Persisted(row_key), CellInput::Value(value)) => { let Some(original) = row.cell(col).cloned() else { return; }; @@ -955,7 +952,7 @@ impl BrowseModel { fx, ); } - GridRowId::Result | GridRowId::Unkeyed => {} + (GridRowId::Persisted(_), CellInput::Default) | (GridRowId::Result | GridRowId::Unkeyed, _) => {} } } @@ -1082,7 +1079,7 @@ impl BrowseModel { return; }; if let Some(cell) = GridCell::from_focus(&focused) { - super::grid::enter_edit_mode(&cell); + super::grid::begin_edit(&cell); } }); } @@ -1581,19 +1578,19 @@ impl BrowseModel { let source_cells = ctx.effective_cells(row); // The duplicate is a new row, so values the database // owns (key, identity, generated) are left to it. - let values: Vec = self + let cells: Vec = self .current_columns .iter() .enumerate() .map(|(i, col)| { if col.primary_key || col.is_auto_increment || col.is_generated { - Value::Null + new_draft_cell(col) } else { - source_cells.get(i).cloned().unwrap_or(Value::Null) + CellInput::Value(source_cells.get(i).cloned().unwrap_or(Value::Null)) } }) .collect(); - self.apply_edit(TrackerEdit::Insert { values }, fx); + self.apply_edit(TrackerEdit::InsertDraft { cells }, fx); } self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); @@ -1602,51 +1599,32 @@ impl BrowseModel { if self.current_columns.is_empty() { return; } - let values: Vec = self.current_columns.iter().map(|_| Value::Null).collect(); - self.apply_edit(TrackerEdit::Insert { values }, fx); + let cells = self.current_columns.iter().map(new_draft_cell).collect(); + self.apply_edit(TrackerEdit::InsertDraft { cells }, fx); self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } BrowseTabInput::DeleteRows(rows) => self.delete_rows(&rows, fx), - BrowseTabInput::GridCellEdited { row, column, input } => { + BrowseTabInput::GridEdit { row, column, edit } => { // The cell shows what was typed until its row is drawn again. if self.refuses_edit(&row, column) { row.bump_revision(); return; } - // A column that holds line breaks keeps them; everywhere - // else a pasted newline would reach the SQL layer. - let keeps_line_breaks = self - .current_columns - .get(column) - .is_some_and(|c| c.column_type.kind.accepts_line_breaks()); - let normalized = match keeps_line_breaks { - true => input, - false => normalize_single_line_input(&input), - }; - let col = self.current_columns.get(column); - let new = match parse_input_for_column(&normalized, col, Self::edit_context_for(&row)) { - Ok(v) => v, - Err(message) => { - fx.output(BrowseTabOutput::ShowToast(message)); - row.bump_revision(); - return; - } - }; - self.edit_cell(&row, column, new, fx); + self.apply_edit(edit, fx); } BrowseTabInput::GridSetCellValue { row, column, preset } => { if self.refuses_edit(&row, column) { return; } - let value = match self.resolve_cell_preset(preset, column, Self::edit_context_for(&row)) { - Ok(value) => value, + let input = match self.resolve_cell_preset(preset, &row, column) { + Ok(input) => input, Err(message) => { fx.output(BrowseTabOutput::ShowToast(message)); return; } }; - self.edit_cell(&row, column, value, fx); + self.edit_cell(&row, column, input, fx); } BrowseTabInput::GridShowToast(message) => { fx.output(BrowseTabOutput::ShowToast(message)); @@ -1950,19 +1928,6 @@ fn clear_box(b: >k::Box) { } } -/// Collapse newlines / carriage returns to spaces, then squash any -/// resulting consecutive whitespace runs to a single space. Applied -/// at cell-edit commit time for non-JSON columns so a multi-line -/// clipboard paste into a single-line cell never reaches the SQL -/// layer with embedded `\n`. -fn normalize_single_line_input(text: &str) -> String { - let replaced: String = text - .chars() - .map(|c| if matches!(c, '\n' | '\r') { ' ' } else { c }) - .collect(); - replaced.split_whitespace().collect::>().join(" ") -} - /// A fetched row's key from its primary key cells, in the table's /// column order. `None` when the table has no primary key or the row is /// too short to hold it. @@ -1991,28 +1956,6 @@ fn update_selection_chrome(label: >k::Label, n: u32) { label.set_visible(true); } -/// The value the user typed, as the column's own type. -/// -/// Parsing against the typed column rather than a type-name string is -/// what makes an out-of-range number a message beside the cell instead -/// of a failed transaction. What an empty cell means is core's rule: -/// the empty string where the column holds one, the server's default -/// in a draft row, NULL where the column takes it, and a refusal -/// otherwise. -fn parse_input_for_column(text: &str, col: Option<&ColumnInfo>, context: EditContext) -> Result { - let Some(col) = col else { - return Ok(Value::Text(text.to_string())); - }; - match tablepro_core::edit::parse_edit_text(text, col, context) { - Ok(CellInput::Value(value)) => Ok(value), - // A draft holds NULL for a cell left to the server; the save - // leaves that column out of the INSERT. - Ok(CellInput::Default) => Ok(Value::Null), - Err(EditParseError::Required) => Err(crate::i18n::gettext("Field is required")), - Err(error) => Err(error.to_string()), - } -} - /// Format a positive integer with thousands separators (1000 → 1,000). /// Used for page-size dropdown labels so they read naturally instead /// of the abbreviated "1 K" / "5 K" form. diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 8d09ec66bf..7561355538 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -3,7 +3,7 @@ use std::time::Duration; use gtk4::prelude::*; use tablepro_core::column::{ColumnDefault, ColumnType}; -use tablepro_core::edit::EditContext; +use tablepro_core::edit::CellInput; use tablepro_core::filter::{FilterOp, FilterRule}; use tablepro_core::meta::RowIdentity; use tablepro_core::{ColumnInfo, DriverError, FilterSet, LossPhase, QueryResult, Value}; @@ -12,10 +12,7 @@ use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; use super::row_deletion::RowDeletion; -use super::{ - BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands, parse_input_for_column, - persisted_key, -}; +use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands, persisted_key}; use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; @@ -82,141 +79,6 @@ fn format_thousands_handles_edges() { assert_eq!(format_thousands(999), "999"); } -#[test] -fn clearing_a_text_cell_stores_the_empty_string() { - let cleared = - parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).expect("an empty string"); - - assert_eq!(cleared, Value::Text(String::new())); -} - -#[test] -fn clearing_a_column_with_no_empty_value_yields_null() { - let cleared = parse_input_for_column("", Some(&col("timestamp", true)), EditContext::UpdateRow).expect("a null"); - - assert!(matches!(cleared, Value::Null)); -} - -#[test] -fn empty_on_not_null_with_default_is_left_to_the_server_only_in_a_draft() { - let column = col_with_default("timestamp", "now()"); - - let draft = parse_input_for_column("", Some(&column), EditContext::InsertDraft).unwrap(); - assert!(matches!(draft, Value::Null), "a draft leaves it to the default"); - - let existing = parse_input_for_column("", Some(&column), EditContext::UpdateRow); - assert!(existing.is_err(), "an UPDATE cannot ask for the default: {existing:?}"); -} - -#[test] -fn empty_on_a_required_column_with_no_empty_value_is_rejected() { - let r = parse_input_for_column("", Some(&col("integer", false)), EditContext::UpdateRow); - - assert!(r.is_err()); - assert!(r.as_ref().is_err_and(|message| message.contains("required")), "{r:?}"); -} - -#[test] -fn parses_int_decimal_float_bool() { - assert!(matches!( - parse_input_for_column("42", Some(&col("integer", false)), EditContext::UpdateRow).unwrap(), - Value::Int(42) - )); - assert!(matches!( - parse_input_for_column("3.14", Some(&col("real", false)), EditContext::UpdateRow).unwrap(), - Value::Float32(_) - )); - assert!(matches!( - parse_input_for_column("99.99", Some(&col("decimal(10,2)", false)), EditContext::UpdateRow).unwrap(), - Value::Decimal(_) - )); - assert!(matches!( - parse_input_for_column("yes", Some(&col("boolean", false)), EditContext::UpdateRow).unwrap(), - Value::Bool(true) - )); - // MySQL spells boolean as tinyint(1), so it parses as the integer it - // is and the grid renders 0 and 1. - assert!(matches!( - parse_input_for_column("0", Some(&col("tinyint(1)", false)), EditContext::UpdateRow).unwrap(), - Value::Int(0) - )); -} - -#[test] -fn parses_uuid_json_date_time_datetime_timestamptz() { - let uuid = parse_input_for_column( - "550e8400-e29b-41d4-a716-446655440000", - Some(&col("uuid", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(uuid, Value::Uuid(_))); - - let json = parse_input_for_column(r#"{"a":1}"#, Some(&col("jsonb", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(json, Value::Json(_))); - - let date = parse_input_for_column("2024-01-15", Some(&col("date", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(date, Value::Date(_))); - - let time = parse_input_for_column("14:30:00", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(time, Value::Time(_))); - let time_short = parse_input_for_column("14:30", Some(&col("time", false)), EditContext::UpdateRow).unwrap(); - assert!(matches!(time_short, Value::Time(_))); - - let datetime = parse_input_for_column( - "2024-01-15 14:30:00", - Some(&col("timestamp", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(datetime, Value::Timestamp(_))); - let datetime_t = parse_input_for_column( - "2024-01-15T14:30:00", - Some(&col("datetime", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(datetime_t, Value::Timestamp(_))); -} - -#[test] -fn rejects_invalid_type_specific_input() { - assert!(parse_input_for_column("not-a-number", Some(&col("integer", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("not-a-uuid", Some(&col("uuid", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("{not json", Some(&col("jsonb", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("2024/01/15", Some(&col("date", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("13:00:99", Some(&col("time", false)), EditContext::UpdateRow).is_err()); - assert!( - parse_input_for_column("128", Some(&col("tinyint", false)), EditContext::UpdateRow).is_err(), - "a number past the column's range was accepted" - ); - assert!(parse_input_for_column("not-a-date", Some(&col("timestamp", false)), EditContext::UpdateRow).is_err()); - assert!(parse_input_for_column("maybe", Some(&col("boolean", false)), EditContext::UpdateRow).is_err()); -} - -#[test] -fn unknown_type_falls_through_to_text() { - let r = parse_input_for_column( - "anything goes here", - Some(&col("varchar(255)", false)), - EditContext::UpdateRow, - ) - .unwrap(); - assert!(matches!(r, Value::Text(_))); -} - -#[test] -fn null_sentinel_typed_literally_is_text() { - // Only an actually-empty input becomes NULL. The grid clears the - // visual sentinel before an edit, so `` here was typed on - // purpose. - let r = parse_input_for_column("", Some(&col("text", true)), EditContext::UpdateRow).unwrap(); - match r { - Value::Text(s) => assert_eq!(s, ""), - other => panic!("expected Text(\"\") got {other:?}"), - } -} - #[test] fn a_fetched_row_is_keyed_by_its_primary_key_cells() { let cells = vec![Value::Int(42), Value::Text("alice".into())]; @@ -914,23 +776,17 @@ fn duplicate_row_copies_pending_edits_of_persisted_row() { wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); let ada = harness.grid_row(0); - harness.tab().send(BrowseTabInput::GridCellEdited { - row: ada.clone(), - column: 1, - input: "Ada Lovelace".to_owned(), - }); + harness.tab().send(typed(&ada, 1, "Ada Lovelace")); harness.tab().send(BrowseTabInput::DuplicateRows(vec![ada])); let drafts = harness.tracker.drafts(); assert_eq!(drafts.len(), 1, "{drafts:?}"); - let texts: Vec> = drafts[0] - .values - .iter() - .map(tablepro_core::export::value_to_text) - .collect(); assert_eq!( - texts, - vec![None, Some("Ada Lovelace".to_owned())], + drafts[0].cells, + vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("Ada Lovelace".to_owned())) + ], "the duplicate left out the row's pending edit" ); } @@ -979,11 +835,35 @@ fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { } fn cell_text(cell: &GridCell) -> String { - cell.editor() - .map(|editor| editor.text().to_string()) + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) .unwrap_or_default() } +/// What the grid sends once `text` is typed into `column` of a saved +/// row and committed. +fn typed(row: &RowObject, column: usize, text: &str) -> BrowseTabInput { + BrowseTabInput::GridEdit { + row: row.clone(), + column, + edit: TrackerEdit::Cell { + row_key: row.key().cloned().expect("a saved row"), + col: column, + original: row.cell(column).cloned().unwrap_or(Value::Null), + value: Value::Text(text.to_owned()), + }, + } +} + +/// Open the cell's one-line editor and type `text` over its value. +fn type_into(cell: &GridCell, text: &str) -> gtk4::Text { + crate::ui::grid::begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open its one-line editor"); + editor.set_text(text); + editor +} + fn has_class(cell: &GridCell, class: &str) -> bool { cell.content().is_some_and(|content| content.has_css_class(class)) } @@ -1005,11 +885,9 @@ fn a_commit_is_tracked_at_once_and_leaves_its_cell_in_the_window() { wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); - crate::ui::grid::enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - editor.entry().set_text("Ada Lovelace"); + let editor = type_into(&cell, "Ada Lovelace"); - editor.stop_editing(true); + editor.emit_activate(); assert!( cell.root().is_some(), @@ -1089,11 +967,9 @@ fn rejected_edit_restores_cell_display() { let toasts = toasts(harness.tab()); let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); - crate::ui::grid::enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - editor.entry().set_text("thirty-six"); + let editor = type_into(&cell, "thirty-six"); - editor.stop_editing(true); + editor.emit_activate(); wait_until(WAIT, || cell_text(&cell) == "36").expect("the cell kept the text that was refused"); assert_eq!(toasts.borrow().len(), 1, "{:?}", toasts.borrow()); @@ -1110,12 +986,17 @@ fn refused_edit_restores_cell_display() { // A saved row's key, which the tab refuses to change in place. let cell = grid_cell_of(harness.tab(), 0, 0); let row = cell.row().expect("a bound row"); - cell.editor().expect("a text cell").set_text("7"); + cell.display().expect("a text cell").set_text(Some("7")); - harness.tab().send(BrowseTabInput::GridCellEdited { - row, + harness.tab().send(BrowseTabInput::GridEdit { + row: row.clone(), column: 0, - input: "7".to_owned(), + edit: TrackerEdit::Cell { + row_key: key(0), + col: 0, + original: Value::Int(0), + value: Value::Int(7), + }, }); assert_eq!(cell_text(&cell), "0", "the cell kept the edit the tab refused"); @@ -1131,11 +1012,11 @@ fn undo_under_an_open_editor_leaves_nothing_pending() { let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); harness.apply_delta(&harness.tracker.apply(rename(0, "Ada", "Ada Lovelace"))); - crate::ui::grid::enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); + crate::ui::grid::begin_edit(&cell); + assert!(cell.is_editing()); harness.tab().send(BrowseTabInput::Undo); - editor.stop_editing(false); + cell.cancel_edit(); assert_eq!( harness.tracker.pending_count(), @@ -1205,10 +1086,14 @@ fn copy_row_as_insert_on_a_draft_leaves_what_the_server_fills_out() { harness.tab().send(BrowseTabInput::InsertRow); let draft = harness.grid_row(0); assert!(draft.draft_id().is_some(), "the new row goes on top"); - harness.tab().send(BrowseTabInput::GridCellEdited { + harness.tab().send(BrowseTabInput::GridEdit { row: draft.clone(), column: 1, - input: "x".to_owned(), + edit: TrackerEdit::DraftCell { + draft_id: draft.draft_id().expect("a draft"), + col: 1, + input: CellInput::Value(Value::Text("x".into())), + }, }); harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(vec![draft])); @@ -1281,11 +1166,7 @@ fn saved_cells(tracker: &TabTrackerHandle) -> Vec<(String, Option)> { } fn edit_first_row(harness: &Harness, column: usize, input: &str) { - harness.tab().send(BrowseTabInput::GridCellEdited { - row: harness.grid_row(0), - column, - input: input.to_owned(), - }); + harness.tab().send(typed(&harness.grid_row(0), column, input)); assert_eq!(harness.tracker.pending_count(), 1); } @@ -1358,11 +1239,7 @@ fn a_grid_built_for_columns_the_table_lost_takes_no_edits() { harness.tab().send(BrowseTabInput::SchemaChanged); wait_until(WAIT, || harness.tab().columns() == people(&["name", "email"])).expect("the new columns"); // Column 2 was name in the grid the event came from, and is email now. - harness.tab().send(BrowseTabInput::GridCellEdited { - row, - column: 2, - input: "Grace".to_owned(), - }); + harness.tab().send(typed(&row, 2, "Grace")); assert_eq!( harness.tracker.pending_count(), @@ -1617,3 +1494,307 @@ fn delete_action_disabled_for_unkeyed_selection() { delete_rows(harness.tab()); assert_eq!(harness.tracker.pending_count(), 0); } + +fn named_col(name: &str, data_type: &str, nullable: bool) -> ColumnInfo { + let mut column = col(data_type, nullable); + column.name = name.to_owned(); + column +} + +/// A table of an `id` key and `column`, with one row, id 0, holding +/// `value`, drawn in a window. +fn one_cell(column: ColumnInfo, value: Value) -> (Harness, Shown) { + let columns = vec![self::column("id", true), column]; + let mut script = ConnectionScript::default(); + script.fetch_columns.always(FakeReply::ok(columns.clone())); + script.row_identity.always(FakeReply::ok(RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + })); + script.count_rows.always(FakeReply::ok(1)); + script.probe.always(FakeReply::ok(())); + script.query_bound.always(FakeReply::ok(QueryResult::new( + columns.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), value]], + ))); + let harness = Harness::open(script, []); + wait_until(WAIT, || { + harness.tab().snapshot().is_some_and(|page| page.rows.len() == 1) + }) + .expect("the first page"); + let shown = Shown::new(harness.tab()); + (harness, shown) +} + +/// The value the tracker holds for row 0's second cell. +fn pending_value(harness: &Harness, fetched: &Value) -> Value { + harness.tracker.cell_overlay(&key(0), 1, fetched).value +} + +/// Open the cell's one-line editor and paste `text` over its value +/// through the clipboard, as Ctrl+V does. +fn paste_into(cell: &GridCell, text: &str) -> gtk4::Text { + crate::ui::grid::begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open its one-line editor"); + let opened = editor.text(); + editor.clipboard().set_text(text); + editor.emit_paste_clipboard(); + wait_until(WAIT, || editor.text() != opened).expect("the paste never landed"); + editor +} + +fn calendar_of(cell: &GridCell) -> Option { + crate::test_support::first_descendant_of_type::(cell) +} + +#[gtk4::test] +fn unchanged_40kb_multiline_text_records_no_edit() { + let long = "lorem ipsum ".repeat(3_500); + let (harness, _shown) = one_cell(named_col("note", "text", true), Value::Text(long.clone())); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + assert!(cell.inline_text().is_none(), "40 KB of text opened in a one-line field"); + let view = + crate::test_support::first_descendant_of_type::(&cell).expect("the multi-line editor"); + let buffer = view.buffer(); + assert_eq!(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true), long); + crate::test_support::descendants(&cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") + .emit_clicked(); + assert_eq!( + harness.tracker.pending_count(), + 0, + "saving the text as it was recorded an edit" + ); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn bigint_edit_is_exact() { + let fetched = Value::Int(1_234_567_890_123_456_789); + let (harness, _shown) = one_cell(named_col("n", "bigint", false), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = type_into(&cell, "1234567890123456790"); + assert_eq!( + editor.input_purpose(), + gtk4::InputPurpose::Number, + "a signed integer field offers a number keypad" + ); + editor.emit_activate(); + + assert_eq!(pending_value(&harness, &fetched), Value::Int(1_234_567_890_123_456_790)); +} + +#[gtk4::test] +#[expect( + clippy::approx_constant, + reason = "a double with every digit it can hold, which is the point, not pi" +)] +fn pressing_enter_on_float_keeps_full_precision() { + let fetched = Value::Float64(3.141_592_653_589_79); + let (harness, _shown) = one_cell(named_col("x", "double precision", false), fetched); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), "3.14159265358979"); + editor.emit_activate(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "Enter rounded the value it opened on" + ); +} + +#[gtk4::test] +fn null_int_opens_empty_with_null_placeholder() { + let (harness, _shown) = one_cell(named_col("n", "integer", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), "", "NULL opened as the text of its sentinel"); + assert_eq!(editor.placeholder_text().as_deref(), Some("NULL")); +} + +#[gtk4::test] +fn clearing_not_null_int_without_default_shows_required_toast() { + let fetched = Value::Int(5); + let (harness, _shown) = one_cell(named_col("n", "integer", false), fetched.clone()); + let toasts = toasts(harness.tab()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = type_into(&cell, ""); + assert_eq!(editor.placeholder_text().as_deref(), Some("Required")); + editor.emit_activate(); + + assert_eq!(*toasts.borrow(), ["This column needs a value".to_owned()]); + assert_eq!(harness.tracker.pending_count(), 0); + assert_eq!(cell_text(&cell), "5"); +} + +#[gtk4::test] +fn draft_not_null_with_default_placeholder_is_default() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let (harness, _shown) = one_cell(level, Value::Int(3)); + harness.tab().send(BrowseTabInput::InsertRow); + let mut cell = None; + wait_until(WAIT, || { + cell = crate::test_support::descendants(harness.tab()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|cell| cell.column() == 1 && cell.row().is_some_and(|row| row.draft_id().is_some())); + cell.is_some() + }) + .expect("the new row's cell is drawn"); + let cell = cell.expect("a cell"); + cell.cancel_edit(); + assert_eq!(cell_text(&cell), "DEFAULT", "a cell left to the server read as NULL"); + + crate::ui::grid::begin_edit(&cell); + + let editor = cell.inline_text().expect("a one-line field"); + assert_eq!(editor.text(), ""); + assert_eq!(editor.placeholder_text().as_deref(), Some("DEFAULT")); +} + +#[gtk4::test] +fn pasted_newlines_in_text_cell_are_kept() { + let fetched = Value::Text("a".into()); + let (harness, _shown) = one_cell(named_col("note", "text", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = paste_into(&cell, "first line\nsecond line"); + editor.emit_activate(); + + assert_eq!( + pending_value(&harness, &fetched), + Value::Text("first line\nsecond line".into()) + ); +} + +#[gtk4::test] +fn a_line_break_pasted_into_a_number_keeps_the_first_line() { + let fetched = Value::Int(1); + let (harness, _shown) = one_cell(named_col("n", "integer", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let editor = paste_into(&cell, "42\n43"); + assert_eq!(editor.text(), "42"); + editor.emit_activate(); + + assert_eq!(pending_value(&harness, &fetched), Value::Int(42)); +} + +#[gtk4::test] +fn tinyint1_value_2_renders_inconsistent_with_exact_tooltip() { + let mut flag = named_col("flag", "tinyint(1)", false); + flag.column_type.kind = tablepro_core::column::ColumnKind::Bool(tablepro_core::column::BoolStorage::Integer); + let (harness, _shown) = one_cell(flag, Value::Int(2)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + let check = cell.check_button().expect("a check button"); + + assert!(check.is_inconsistent(), "2 showed as true or false"); + assert_eq!(check.tooltip_text().as_deref(), Some("2")); + check.set_active(true); + assert_eq!(pending_value(&harness, &Value::Int(2)), Value::Int(1)); +} + +fn a_date(year: i32, month: u32, day: u32) -> Value { + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + )) +} + +#[gtk4::test] +fn calendar_preselects_date_value() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + let calendar = calendar_of(&cell).expect("a date opened its calendar"); + assert_eq!(calendar.date().ymd(), (2024, 2, 29)); +} + +#[gtk4::test] +fn picking_a_day_commits_it() { + let fetched = a_date(2024, 2, 29); + let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::begin_edit(&cell); + let calendar = calendar_of(&cell).expect("the calendar"); + + let picked = gtk4::glib::DateTime::from_utc(2024, 3, 1, 0, 0, 0.0).expect("a day"); + calendar.set_date(&picked); + + assert_eq!(pending_value(&harness, &fetched), a_date(2024, 3, 1)); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn opening_calendar_on_existing_date_records_no_edit() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); + let cell = grid_cell_of(harness.tab(), 0, 1); + crate::ui::grid::begin_edit(&cell); + let popover = crate::test_support::first_descendant_of_type::(&cell).expect("the calendar popover"); + + popover.popdown(); + wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "opening the calendar recorded its own day" + ); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn out_of_range_date_opens_inline_editor() { + let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(12_000, 1, 1)); + let cell = grid_cell_of(harness.tab(), 0, 1); + + crate::ui::grid::begin_edit(&cell); + + assert!( + calendar_of(&cell).is_none(), + "the calendar opened on a year it cannot show" + ); + let editor = cell.inline_text().expect("a one-line field"); + assert!(editor.text().contains("12000"), "{}", editor.text()); +} + +#[gtk4::test] +fn read_only_cells_use_inscription_not_selectable_label() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 1).expect("the first page"); + let _shown = Shown::new(harness.tab()); + // A saved row's key, which the row is named by and cannot change. + let cell = grid_cell_of(harness.tab(), 0, 0); + + assert!(cell.lock().is_some()); + assert!( + cell.display().is_some(), + "a locked cell does not draw in an Inscription" + ); + assert!( + crate::test_support::descendants(&cell) + .iter() + .all(|widget| !widget.is::()), + "a locked cell carries a selectable label" + ); + crate::ui::grid::begin_edit(&cell); + assert!(!cell.is_editing(), "a locked cell opened for editing"); +} diff --git a/linux/crates/app/src/ui/error_text/edit.rs b/linux/crates/app/src/ui/error_text/edit.rs new file mode 100644 index 0000000000..542cebbc40 --- /dev/null +++ b/linux/crates/app/src/ui/error_text/edit.rs @@ -0,0 +1,162 @@ +use tablepro_core::column::IntegerKind; +use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + +use crate::i18n::{gettext, gettext_f, ngettext_f}; + +/// What to tell the user about text a cell would not take. Every +/// variant has its own branch, so a new one fails to compile here +/// rather than reaching a widget as its log text. +pub fn edit_parse_message(error: &EditParseError) -> String { + match error { + EditParseError::Required => gettext("This column needs a value"), + EditParseError::InvalidInteger => gettext("Enter a whole number"), + EditParseError::IntegerOutOfRange { kind } => { + let (min, max) = integer_range(*kind); + gettext_f( + "Enter a whole number from {min} to {max}", + &[("min", &min), ("max", &max)], + ) + } + EditParseError::InvalidFloat => gettext("Enter a number"), + EditParseError::InvalidDecimal(_) => gettext("Enter a decimal number, such as 12.50"), + EditParseError::PrecisionExceeded { precision } => ngettext_f( + "This column keeps at most {n} digit", + "This column keeps at most {n} digits", + *precision, + &[("n", &precision.to_string())], + ), + EditParseError::ScaleExceeded { scale } => ngettext_f( + "This column keeps at most {n} decimal place", + "This column keeps at most {n} decimal places", + *scale, + &[("n", &scale.to_string())], + ), + EditParseError::InvalidBoolean => gettext("Enter true or false"), + EditParseError::InvalidUuid => gettext("Enter a UUID"), + EditParseError::InvalidJson(detail) => gettext_f("Enter valid JSON: {detail}", &[("detail", detail)]), + EditParseError::InvalidDate => gettext("Enter a date as YYYY-MM-DD"), + EditParseError::InvalidTime => gettext("Enter a time as HH:MM:SS"), + EditParseError::TimeOutOfRange => gettext("This time is outside the range the column can hold"), + EditParseError::InvalidTimestamp => gettext("Enter a date and time as YYYY-MM-DD HH:MM:SS"), + EditParseError::OffsetRequired => gettext("Add a time zone offset, such as +02:00"), + EditParseError::InvalidInterval => gettext("Enter an interval, such as 1 day 02:00:00"), + EditParseError::InvalidBits => gettext("Enter bits as 0 and 1"), + EditParseError::BitLengthMismatch { expected } => ngettext_f( + "Enter exactly {n} bit", + "Enter exactly {n} bits", + *expected, + &[("n", &expected.to_string())], + ), + EditParseError::UnknownEnumLabel => gettext("Enter one of the values this column allows"), + EditParseError::NotEditable(reason) => not_editable_message(*reason), + } +} + +fn not_editable_message(reason: ReadOnlyReason) -> String { + match reason { + ReadOnlyReason::Bytes => gettext("Binary values can't be edited here"), + ReadOnlyReason::Spatial => gettext("Spatial values can't be edited here"), + ReadOnlyReason::Undecodable => gettext("This value couldn't be read, so it can't be edited"), + ReadOnlyReason::Composite => gettext("Arrays and composite values can't be edited here"), + ReadOnlyReason::Variant => gettext("Values whose type changes by row can't be edited here"), + } +} + +/// The smallest and largest value of an integer column, written out. +fn integer_range(kind: IntegerKind) -> (String, String) { + let bits = kind.width.bits(); + match (kind.signed, bits) { + (true, 256) => ("-2^255".to_owned(), "2^255 - 1".to_owned()), + (false, 256) => ("0".to_owned(), "2^256 - 1".to_owned()), + (true, 128) => (i128::MIN.to_string(), i128::MAX.to_string()), + (false, 128) => ("0".to_owned(), u128::MAX.to_string()), + (true, bits) => { + let half = 1i128 << (bits - 1); + ((-half).to_string(), (half - 1).to_string()) + } + (false, bits) => ("0".to_owned(), ((1u128 << bits) - 1).to_string()), + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use tablepro_core::column::{IntegerKind, IntegerWidth}; + use tablepro_core::edit::{EditParseError, ReadOnlyReason}; + use tablepro_core::value::DecimalParseError; + + use super::{edit_parse_message, integer_range}; + + #[test] + fn every_edit_parse_error_variant_has_a_distinct_message() { + let kind = IntegerKind { + width: IntegerWidth::W16, + signed: true, + }; + let errors = [ + EditParseError::Required, + EditParseError::InvalidInteger, + EditParseError::IntegerOutOfRange { kind }, + EditParseError::InvalidFloat, + EditParseError::InvalidDecimal(DecimalParseError::InvalidSyntax), + EditParseError::PrecisionExceeded { precision: 5 }, + EditParseError::ScaleExceeded { scale: 2 }, + EditParseError::InvalidBoolean, + EditParseError::InvalidUuid, + EditParseError::InvalidJson("expected value at line 1 column 1".to_owned()), + EditParseError::InvalidDate, + EditParseError::InvalidTime, + EditParseError::TimeOutOfRange, + EditParseError::InvalidTimestamp, + EditParseError::OffsetRequired, + EditParseError::InvalidInterval, + EditParseError::InvalidBits, + EditParseError::BitLengthMismatch { expected: 8 }, + EditParseError::UnknownEnumLabel, + EditParseError::NotEditable(ReadOnlyReason::Bytes), + ]; + + let messages: Vec = errors.iter().map(edit_parse_message).collect(); + + assert!(messages.iter().all(|message| !message.is_empty()), "{messages:?}"); + let distinct: HashSet<&String> = messages.iter().collect(); + assert_eq!(distinct.len(), messages.len(), "{messages:?}"); + assert_eq!(messages[2], "Enter a whole number from -32768 to 32767"); + } + + #[test] + fn every_read_only_reason_says_its_own_thing() { + let reasons = [ + ReadOnlyReason::Bytes, + ReadOnlyReason::Spatial, + ReadOnlyReason::Undecodable, + ReadOnlyReason::Composite, + ReadOnlyReason::Variant, + ]; + + let messages: HashSet = reasons + .iter() + .map(|reason| edit_parse_message(&EditParseError::NotEditable(*reason))) + .collect(); + + assert_eq!(messages.len(), reasons.len()); + } + + #[test] + fn integer_ranges_follow_width_and_sign() { + let range = |width, signed| integer_range(IntegerKind { width, signed }); + + assert_eq!(range(IntegerWidth::W8, false), ("0".to_owned(), "255".to_owned())); + assert_eq!( + range(IntegerWidth::W24, true), + ("-8388608".to_owned(), "8388607".to_owned()) + ); + assert_eq!( + range(IntegerWidth::W64, true), + (i64::MIN.to_string(), i64::MAX.to_string()) + ); + assert_eq!(range(IntegerWidth::W128, false).1, u128::MAX.to_string()); + assert_eq!(range(IntegerWidth::W256, true).0, "-2^255"); + } +} diff --git a/linux/crates/app/src/ui/error_text/mod.rs b/linux/crates/app/src/ui/error_text/mod.rs index 4906757306..1df76bf2c6 100644 --- a/linux/crates/app/src/ui/error_text/mod.rs +++ b/linux/crates/app/src/ui/error_text/mod.rs @@ -7,6 +7,7 @@ mod ddl; mod driver; +mod edit; mod operation; mod script; mod secret; @@ -18,6 +19,7 @@ mod tls_remedy; pub use ddl::{build_error_text, refusal_text, save_blocker_text}; pub use driver::{config_message, driver_message}; +pub use edit::edit_parse_message; pub use operation::{connect_error_message, operation_error_message, task_failure_message}; pub use script::script_diagnostic_message; pub use tls_remedy::{TlsRemedy, connect_remedy}; diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs new file mode 100644 index 0000000000..126dd17f46 --- /dev/null +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -0,0 +1,166 @@ +use chrono::Datelike; +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::edit::CellInput; +use tablepro_core::value::Temporal; +use tablepro_core::{ColumnInfo, Value}; + +use super::edit_commit::{commit_edit, send_commit}; +use super::inline_editor::{InlineEditor, InlineOptions}; +use super::{EditSnapshot, GridCell}; + +/// The day a calendar opens on. +#[derive(Debug, Clone)] +enum CalendarStart { + On(glib::DateTime), + /// A NULL the column may keep: the calendar shows today and commits + /// nothing until a day is picked. + Today, +} + +/// A date column's editor: a calendar for a day it can show, and the +/// one-line field for anything else, such as an infinity. +pub struct CalendarEditor; + +impl CalendarEditor { + /// `text` is the cell's edit text, for the one-line field. + pub(super) fn open( + cell: &GridCell, + snapshot: EditSnapshot, + column: &ColumnInfo, + text: String, + inline: InlineOptions, + ) { + let Some(start) = calendar_start(&snapshot.original, column) else { + InlineEditor::begin(cell, snapshot, &text, inline); + return; + }; + cell.cancel_edit(); + let calendar = gtk::Calendar::new(); + // Before the handler: set_date emits day-selected when the day + // changes, and that is not a pick. + if let CalendarStart::On(date) = &start { + calendar.set_date(date); + } + let type_value = gtk::Button::with_label(&crate::i18n::gettext("Type a Value")); + type_value.add_css_class("flat"); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(6) + .build(); + content.append(&calendar); + content.append(&type_value); + let popover = gtk::Popover::builder().child(&content).build(); + + let weak_cell = cell.downgrade(); + calendar.connect_day_selected(move |calendar| { + let Some(cell) = weak_cell.upgrade() else { + return; + }; + let (year, month, day) = calendar.date().ymd(); + pick(&cell, &format!("{year:04}-{month:02}-{day:02}")); + }); + let weak_cell = cell.downgrade(); + type_value.connect_clicked(move |_| { + let Some(cell) = weak_cell.upgrade() else { + return; + }; + let Some(snapshot) = cell.take_edit() else { + return; + }; + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + InlineEditor::begin(&cell, snapshot, &text, inline.clone()); + }); + + cell.begin_edit(snapshot); + cell.present_popover(&popover); + calendar.grab_focus(); + } +} + +/// The popover closes before the tab hears of the day, as every editor's +/// does. +fn pick(cell: &GridCell, day: &str) { + let Some(snapshot) = cell.take_edit() else { + return; + }; + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + let Some(drawing) = cell.drawing() else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, day); + send_commit(cell, snapshot, result); +} + +/// `None` for a value the calendar cannot show: an infinity, a year +/// outside 1 to 9999, the server's default or a NULL the column refuses. +fn calendar_start(input: &CellInput, column: &ColumnInfo) -> Option { + match input { + CellInput::Value(Value::Date(Temporal::Finite(date))) if (1..=9999).contains(&date.year()) => { + let month = i32::try_from(date.month()).ok()?; + let day = i32::try_from(date.day()).ok()?; + glib::DateTime::from_utc(date.year(), month, day, 0, 0, 0.0) + .ok() + .map(CalendarStart::On) + } + CellInput::Value(Value::Null) if column.nullable => Some(CalendarStart::Today), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use chrono::NaiveDate; + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType}; + use tablepro_core::edit::CellInput; + use tablepro_core::value::Temporal; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CalendarStart, calendar_start}; + + fn date_column(nullable: bool) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("date"); + column_type.kind = ColumnKind::Date; + ColumnInfo { + name: "day".to_owned(), + column_type, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn date(year: i32, month: u32, day: u32) -> CellInput { + CellInput::Value(Value::Date(Temporal::Finite( + NaiveDate::from_ymd_opt(year, month, day).expect("a date"), + ))) + } + + #[test] + fn the_calendar_opens_only_on_a_day_it_can_show() { + let nullable = date_column(true); + + let Some(CalendarStart::On(start)) = calendar_start(&date(2026, 9, 15), &nullable) else { + panic!("a date did not open the calendar on its day"); + }; + assert_eq!(start.ymd(), (2026, 9, 15)); + assert!(matches!( + calendar_start(&CellInput::Value(Value::Null), &nullable), + Some(CalendarStart::Today) + )); + assert!(calendar_start(&CellInput::Value(Value::Null), &date_column(false)).is_none()); + assert!(calendar_start(&CellInput::Default, &nullable).is_none()); + assert!(calendar_start(&date(12_000, 1, 1), &nullable).is_none()); + assert!(calendar_start(&CellInput::Value(Value::Date(Temporal::Infinity)), &nullable).is_none()); + } +} diff --git a/linux/crates/app/src/ui/grid/cell_edit.rs b/linux/crates/app/src/ui/grid/cell_edit.rs new file mode 100644 index 0000000000..87a353f370 --- /dev/null +++ b/linux/crates/app/src/ui/grid/cell_edit.rs @@ -0,0 +1,158 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use tablepro_core::edit::{CellInput, EditParseError, EditText, edit_text}; + +use super::calendar_editor::CalendarEditor; +use super::cell_editor_kind::CellEditorKind; +use super::edit_commit::empty_placeholder; +use super::inline_editor::{InlineEditor, InlineOptions, finish_inline}; +use super::multiline_editor::MultilineEditor; +use super::toggle_cell::ToggleCell; +use super::{EditSnapshot, GridCell, GridMsg}; +use crate::ui::error_text::edit_parse_message; + +/// Open the editor the cell's column and value call for. It starts from +/// the whole value with any pending edit, never from the text the cell +/// draws, which is cut short or stands in for the value. +pub(crate) fn begin_edit(cell: &GridCell) { + let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let editability = cell.lock().map_or(Ok(()), Err); + let kind = CellEditorKind::for_cell(column, editability); + match kind { + CellEditorKind::ReadOnly(_) => return, + CellEditorKind::Toggle(_) => { + ToggleCell::toggle(cell); + return; + } + CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar => {} + } + let original = drawing.tab_ctx.edit_input(&row, cell.column()); + let text = match &original { + CellInput::Value(value) => edit_text(value, &column.column_type), + CellInput::Default => EditText::Editable(String::new()), + }; + let editable = match &text { + EditText::Editable(editable) => editable.clone(), + EditText::ReadOnly(reason) => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(edit_parse_message(&EditParseError::NotEditable( + *reason, + )))); + } + return; + } + }; + let inline = |accepts_line_breaks, purpose| InlineOptions { + accepts_line_breaks, + purpose, + placeholder: empty_placeholder(column, row.id()), + }; + let snapshot = EditSnapshot { + row: row.clone(), + column: cell.column(), + original, + }; + match kind.for_text(&text) { + CellEditorKind::Inline { + accepts_line_breaks, + purpose, + } => InlineEditor::begin(cell, snapshot, &editable, inline(accepts_line_breaks, purpose)), + CellEditorKind::Multiline { language } => MultilineEditor::open(cell, snapshot, &editable, language), + CellEditorKind::Calendar => CalendarEditor::open( + cell, + snapshot, + column, + editable, + inline(false, gtk::InputPurpose::FreeForm), + ), + CellEditorKind::Toggle(_) | CellEditorKind::ReadOnly(_) => {} + } +} + +/// `child_focus` on the window is what a real Tab press does. +fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { + let Some(root) = widget.root() else { return }; + let Ok(window) = root.dynamic_cast::() else { + return; + }; + window.child_focus(direction); +} + +pub(super) fn install_edit_triggers(cell: &GridCell) { + // Editing takes a double-click, since a single click in a grid + // selects the row. Capture phase, because the column view's row + // selection takes the press in the bubble phase. + // + // The handlers read the cell from their controller: the cell owns + // both controllers, so capturing it would be a cycle. + let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); + gesture.set_propagation_phase(gtk::PropagationPhase::Capture); + gesture.connect_pressed(move |gesture, n_press, _, _| { + if n_press != 2 { + return; + } + let Some(cell) = gesture.widget().and_downcast::() else { + return; + }; + if cell.lock().is_some() || cell.is_editing() { + return; + } + gesture.set_state(gtk::EventSequenceState::Claimed); + begin_edit(&cell); + }); + cell.add_controller(gesture); + + // While editing, Return and Escape belong to the open editor. + let controller = gtk::EventControllerKey::new(); + controller.connect_key_pressed(move |controller, keyval, _, modifiers| { + let Some(cell) = controller.widget().and_downcast::() else { + return glib::Propagation::Proceed; + }; + let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); + let direction = match keyval { + gtk::gdk::Key::Tab if !shift => Some(gtk::DirectionType::TabForward), + gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => Some(gtk::DirectionType::TabBackward), + _ => None, + }; + if cell.inline_text().is_some() { + let Some(direction) = direction else { + return glib::Propagation::Proceed; + }; + finish_inline(&cell, true); + move_focus(&cell, direction); + return glib::Propagation::Stop; + } + if cell.is_editing() { + return glib::Propagation::Proceed; + } + match keyval { + gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { + begin_edit(&cell); + glib::Propagation::Stop + } + // The column view moves between rows only. + gtk::gdk::Key::Right => { + move_focus(&cell, gtk::DirectionType::TabForward); + glib::Propagation::Stop + } + gtk::gdk::Key::Left => { + move_focus(&cell, gtk::DirectionType::TabBackward); + glib::Propagation::Stop + } + // Left to GTK, Tab leaves the grid for the paginator. + _ => match direction { + Some(direction) => { + move_focus(&cell, direction); + glib::Propagation::Stop + } + None => glib::Propagation::Proceed, + }, + } + }); + cell.add_controller(controller); +} diff --git a/linux/crates/app/src/ui/grid/cell_editor.rs b/linux/crates/app/src/ui/grid/cell_editor.rs deleted file mode 100644 index 50a3b95b6a..0000000000 --- a/linux/crates/app/src/ui/grid/cell_editor.rs +++ /dev/null @@ -1,210 +0,0 @@ -//! The display page is a plain label with no pointer controllers while -//! the cell takes edits, so a single click reaches the column view's row -//! selection. The edit page shows only after `start_editing`. - -use gtk4::prelude::*; -use gtk4::subclass::prelude::*; -use gtk4::{glib, pango}; - -mod imp { - use super::*; - - pub struct CellEditor { - pub stack: gtk4::Stack, - pub label: gtk4::Label, - pub entry: gtk4::Text, - } - - impl Default for CellEditor { - fn default() -> Self { - Self { - stack: gtk4::Stack::new(), - label: gtk4::Label::new(None), - entry: gtk4::Text::new(), - } - } - } - - #[glib::object_subclass] - impl ObjectSubclass for CellEditor { - const NAME: &'static str = "TableProCellEditor"; - type Type = super::CellEditor; - type ParentType = gtk4::Widget; - - fn class_init(klass: &mut Self::Class) { - klass.set_layout_manager_type::(); - } - } - - impl ObjectImpl for CellEditor { - fn constructed(&self) { - self.parent_constructed(); - // App styles select this class, not the type's node name. - self.obj().add_css_class("tp-cell-editor"); - let stack = &self.stack; - stack.set_transition_type(gtk4::StackTransitionType::None); - stack.set_hhomogeneous(true); - stack.set_vhomogeneous(true); - let label = &self.label; - label.set_xalign(0.0); - label.set_hexpand(true); - label.set_ellipsize(pango::EllipsizeMode::End); - let entry = &self.entry; - entry.set_hexpand(true); - stack.add_named(label, Some("display")); - stack.add_named(entry, Some("edit")); - stack.set_visible_child_name("display"); - stack.set_parent(&*self.obj()); - - let weak_for_activate = self.obj().downgrade(); - entry.connect_activate(move |_| { - if let Some(this) = weak_for_activate.upgrade() { - this.stop_editing(true); - } - }); - - // Capture phase, so Escape cancels before the GtkText can - // take it to clear its own buffer. - let key_ctrl = gtk4::EventControllerKey::new(); - key_ctrl.set_propagation_phase(gtk4::PropagationPhase::Capture); - let weak_for_key = self.obj().downgrade(); - key_ctrl.connect_key_pressed(move |_, keyval, _, _| { - if keyval == gtk4::gdk::Key::Escape - && let Some(this) = weak_for_key.upgrade() - && this.is_editing() - { - this.stop_editing(false); - return glib::Propagation::Stop; - } - glib::Propagation::Proceed - }); - entry.add_controller(key_ctrl); - - // GtkText flushes an IME preedit on focus-out before this - // runs, so the commit copies the composed text. - let focus_ctrl = gtk4::EventControllerFocus::new(); - let weak_for_focus = self.obj().downgrade(); - focus_ctrl.connect_leave(move |_| { - if let Some(this) = weak_for_focus.upgrade() - && this.is_editing() - { - this.stop_editing(true); - } - }); - entry.add_controller(focus_ctrl); - } - - fn dispose(&self) { - // GTK warns about a finalized widget that still has children. - self.stack.unparent(); - } - } - - impl WidgetImpl for CellEditor {} -} - -glib::wrapper! { - pub struct CellEditor(ObjectSubclass) - @extends gtk4::Widget, - @implements gtk4::Accessible, gtk4::Buildable, gtk4::ConstraintTarget; -} - -impl Default for CellEditor { - fn default() -> Self { - Self::new() - } -} - -impl CellEditor { - pub fn new() -> Self { - glib::Object::new() - } - - pub fn text(&self) -> glib::GString { - if self.is_editing() { - self.entry_widget().text() - } else { - self.label_widget().text() - } - } - - pub fn set_text(&self, s: &str) { - self.label_widget().set_text(s); - self.entry_widget().set_text(s); - } - - pub fn set_selectable(&self, selectable: bool) { - let label = self.label_widget(); - if label.is_selectable() != selectable { - label.set_selectable(selectable); - } - } - - /// The entry is left alone: a row marked for deletion is not edited. - pub fn set_strikethrough(&self, on: bool) { - let label = self.label_widget(); - if on { - let attrs = pango::AttrList::new(); - attrs.insert(pango::AttrInt::new_strikethrough(true)); - label.set_attributes(Some(&attrs)); - } else { - label.set_attributes(None); - } - } - - pub fn is_editing(&self) -> bool { - self.stack_widget() - .visible_child_name() - .map(|n| n == "edit") - .unwrap_or(false) - } - - /// `text` is the whole value, never what the label drew: that has - /// `` for a NULL. The label takes it too, so Escape puts back - /// what the edit started from and sends nothing. - pub fn start_editing(&self, text: &str) { - let stack = self.stack_widget(); - let entry = self.entry_widget(); - self.set_text(text); - stack.set_visible_child_name("edit"); - entry.grab_focus(); - // The first keystroke replaces the value, as in a Files rename. - entry.select_region(0, -1); - } - - pub fn stop_editing(&self, commit: bool) { - if commit { - let new_text = self.entry_widget().text(); - self.label_widget().set_text(&new_text); - } else { - self.entry_widget().set_text(&self.label_widget().text()); - } - self.stack_widget().set_visible_child_name("display"); - } - - pub fn connect_editing_notify(&self, callback: F) -> glib::SignalHandlerId { - let stack = self.stack_widget(); - let weak = self.downgrade(); - stack.connect_visible_child_name_notify(move |_| { - if let Some(this) = weak.upgrade() { - callback(&this); - } - }) - } - - pub fn entry(&self) -> gtk4::Text { - self.entry_widget() - } - - fn stack_widget(&self) -> gtk4::Stack { - self.imp().stack.clone() - } - - fn label_widget(&self) -> gtk4::Label { - self.imp().label.clone() - } - - fn entry_widget(&self) -> gtk4::Text { - self.imp().entry.clone() - } -} diff --git a/linux/crates/app/src/ui/grid/cell_editor_kind.rs b/linux/crates/app/src/ui/grid/cell_editor_kind.rs index 04c4e70c16..0966a9ac35 100644 --- a/linux/crates/app/src/ui/grid/cell_editor_kind.rs +++ b/linux/crates/app/src/ui/grid/cell_editor_kind.rs @@ -1,71 +1,276 @@ -/// A bool column never gets here: it is a check button, not an editor. +use gtk4 as gtk; + +use tablepro_core::ColumnInfo; +use tablepro_core::column::{BoolStorage, ColumnKind, IntegerKind}; +use tablepro_core::edit::{EditText, ReadOnlyReason, read_only_reason}; + +use super::CellLock; + +/// Past this many bytes a value opens in the multi-line editor: a +/// one-line entry lays all of it out at once and shows almost none. +pub const INLINE_EDIT_MAX_BYTES: usize = 4096; + +/// The editor a cell opens with. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum CellEditorKind { - Text, - Date, - Int, - Float, - Json, +pub enum CellEditorKind { + Toggle(BoolStorage), + Inline { + accepts_line_breaks: bool, + purpose: gtk::InputPurpose, + }, + Multiline { + language: Option<&'static str>, + }, + Calendar, + ReadOnly(CellLock), } -pub(super) fn classify_editor_kind(data_type: &str) -> CellEditorKind { - // JSON before the numbers: a contrived type name can match both. - if is_date_type(data_type) { - CellEditorKind::Date - } else if is_json_type(data_type) { - CellEditorKind::Json - } else if is_int_type(data_type) { - CellEditorKind::Int - } else if is_float_type(data_type) { - CellEditorKind::Float - } else { - CellEditorKind::Text +impl CellEditorKind { + pub fn for_cell(column: &ColumnInfo, editability: Result<(), CellLock>) -> Self { + if let Err(lock) = editability { + return Self::ReadOnly(lock); + } + let kind = &column.column_type.kind; + let inline = |purpose| Self::Inline { + accepts_line_breaks: kind.accepts_line_breaks(), + purpose, + }; + match kind { + ColumnKind::Bool(storage) => Self::Toggle(*storage), + ColumnKind::Json { .. } => Self::Multiline { language: Some("json") }, + ColumnKind::Xml => Self::Multiline { language: Some("xml") }, + ColumnKind::Date => Self::Calendar, + ColumnKind::Integer(IntegerKind { signed: false, .. }) => inline(gtk::InputPurpose::Digits), + ColumnKind::Integer(_) | ColumnKind::Float(_) | ColumnKind::Decimal { .. } | ColumnKind::Money { .. } => { + inline(gtk::InputPurpose::Number) + } + ColumnKind::Text { .. } + | ColumnKind::Enum { .. } + | ColumnKind::Uuid + | ColumnKind::Network(_) + | ColumnKind::Interval + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::BitString { .. } + | ColumnKind::Other => inline(gtk::InputPurpose::FreeForm), + ColumnKind::Bytes { .. } + | ColumnKind::Spatial + | ColumnKind::Array { .. } + | ColumnKind::Variant + | ColumnKind::Composite => Self::ReadOnly(CellLock::Value( + read_only_reason(kind).unwrap_or(ReadOnlyReason::Composite), + )), + } } -} -pub(super) fn is_bool_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!(dt.as_str(), "bool" | "boolean" | "bit" | "tinyint(1)") + /// The editor for this value: none for a value with no text form, + /// and the multi-line one for text a one-line entry would mangle. + pub fn for_text(self, text: &EditText) -> Self { + match (self, text) { + (_, EditText::ReadOnly(reason)) => Self::ReadOnly(CellLock::Value(*reason)), + (Self::Inline { .. }, EditText::Editable(text)) + if text.contains(['\n', '\r']) || text.len() > INLINE_EDIT_MAX_BYTES => + { + Self::Multiline { language: None } + } + (kind, EditText::Editable(_)) => kind, + } + } } -/// A calendar picks a day and nothing else, so a type with a time in it -/// stays on the text editor. -fn is_date_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - dt == "date" || (dt.starts_with("date") && !dt.contains("datetime") && !dt.contains("time")) -} +#[cfg(test)] +mod tests { + use gtk4 as gtk; + use tablepro_core::ColumnInfo; + use tablepro_core::column::{ + BoolStorage, ColumnDefault, ColumnKind, ColumnType, FloatWidth, IntegerKind, IntegerWidth, NetworkFamily, + TemporalZone, TextLength, TimeRange, + }; + use tablepro_core::edit::{EditText, ReadOnlyReason}; -fn is_int_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!( - dt.as_str(), - "int" - | "int2" - | "int4" - | "int8" - | "integer" - | "smallint" - | "bigint" - | "tinyint" - | "mediumint" - | "serial" - | "bigserial" - | "smallserial" - ) || dt.starts_with("int(") - || dt.starts_with("integer(") - || dt.starts_with("smallint(") - || dt.starts_with("bigint(") - || dt.starts_with("mediumint(") -} + use super::{CellEditorKind, INLINE_EDIT_MAX_BYTES}; + use crate::ui::grid::CellLock; -/// Decimal, numeric and money are left out because `SqlDecimal` keeps -/// every digit and `GtkSpinButton` holds an f64. -fn is_float_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - matches!(dt.as_str(), "float" | "double" | "real" | "double precision") || dt.starts_with("float(") -} + fn column(kind: ColumnKind) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("t"); + column_type.kind = kind; + ColumnInfo { + name: "c".to_owned(), + column_type, + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn integer(signed: bool) -> ColumnKind { + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed, + }) + } + + #[test] + fn editor_kind_for_cell_matrix() { + let free = |accepts_line_breaks| CellEditorKind::Inline { + accepts_line_breaks, + purpose: gtk::InputPurpose::FreeForm, + }; + let number = CellEditorKind::Inline { + accepts_line_breaks: false, + purpose: gtk::InputPurpose::Number, + }; + let cases = [ + ( + ColumnKind::Bool(BoolStorage::Native), + CellEditorKind::Toggle(BoolStorage::Native), + ), + ( + ColumnKind::Bool(BoolStorage::Integer), + CellEditorKind::Toggle(BoolStorage::Integer), + ), + ( + integer(false), + CellEditorKind::Inline { + accepts_line_breaks: false, + purpose: gtk::InputPurpose::Digits, + }, + ), + (integer(true), number), + (ColumnKind::Float(FloatWidth::F64), number), + ( + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + number, + ), + (ColumnKind::Money { scale: Some(2) }, number), + ( + ColumnKind::Text { + length: TextLength::Unbounded, + }, + free(true), + ), + ( + ColumnKind::Enum { + labels: vec!["a".to_owned()], + multiple: false, + }, + free(false), + ), + (ColumnKind::Uuid, free(false)), + (ColumnKind::Network(NetworkFamily::Inet), free(false)), + (ColumnKind::Interval, free(false)), + ( + ColumnKind::Time { + fractional_digits: None, + with_offset: false, + range: TimeRange::TimeOfDay, + }, + free(false), + ), + ( + ColumnKind::Timestamp { + fractional_digits: Some(6), + zone: TemporalZone::Offset, + }, + free(false), + ), + ( + ColumnKind::BitString { + length: Some(8), + varying: false, + }, + free(false), + ), + (ColumnKind::Other, free(true)), + ( + ColumnKind::Json { binary: true }, + CellEditorKind::Multiline { language: Some("json") }, + ), + (ColumnKind::Xml, CellEditorKind::Multiline { language: Some("xml") }), + (ColumnKind::Date, CellEditorKind::Calendar), + ( + ColumnKind::Bytes { + length: TextLength::Unbounded, + }, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes)), + ), + ( + ColumnKind::Spatial, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Spatial)), + ), + ( + ColumnKind::Array { + element: Box::new(integer(true)), + }, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), + ), + ( + ColumnKind::Variant, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Variant)), + ), + ( + ColumnKind::Composite, + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Composite)), + ), + ]; + for (kind, expected) in cases { + assert_eq!( + CellEditorKind::for_cell(&column(kind.clone()), Ok(())), + expected, + "{kind:?}" + ); + } -fn is_json_type(data_type: &str) -> bool { - let dt = data_type.to_ascii_lowercase(); - dt.contains("json") + let text = column(ColumnKind::Text { + length: TextLength::Unbounded, + }); + for lock in [ + CellLock::Value(ReadOnlyReason::Undecodable), + CellLock::KeyColumn, + CellLock::Generated, + CellLock::AutoIncrement, + CellLock::UnkeyedRow, + CellLock::ResultRow, + CellLock::ReadOnlySession, + ] { + assert_eq!( + CellEditorKind::for_cell(&text, Err(lock)), + CellEditorKind::ReadOnly(lock) + ); + } + } + + #[test] + fn inline_escalates_to_multiline_for_line_breaks_and_4096_bytes() { + let inline = CellEditorKind::Inline { + accepts_line_breaks: true, + purpose: gtk::InputPurpose::FreeForm, + }; + let multiline = CellEditorKind::Multiline { language: None }; + let editable = |text: String| EditText::Editable(text); + + assert_eq!(inline.for_text(&editable("one line".to_owned())), inline); + assert_eq!(inline.for_text(&editable("two\nlines".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("carriage\rreturn".to_owned())), multiline); + assert_eq!(inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES))), inline); + assert_eq!( + inline.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + multiline + ); + assert_eq!( + CellEditorKind::Calendar.for_text(&editable("x".repeat(INLINE_EDIT_MAX_BYTES + 1))), + CellEditorKind::Calendar, + "only the one-line editor gives way" + ); + assert_eq!( + inline.for_text(&EditText::ReadOnly(ReadOnlyReason::Bytes)), + CellEditorKind::ReadOnly(CellLock::Value(ReadOnlyReason::Bytes)) + ); + } } diff --git a/linux/crates/app/src/ui/grid/cell_popovers.rs b/linux/crates/app/src/ui/grid/cell_popovers.rs deleted file mode 100644 index e843aceca1..0000000000 --- a/linux/crates/app/src/ui/grid/cell_popovers.rs +++ /dev/null @@ -1,303 +0,0 @@ -use chrono::Datelike; -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; -use sourceview5::prelude::*; - -use super::display_text::truncate_for_display; -use super::{GridCell, GridMsg, GridSink}; - -fn current_text(cell: &GridCell) -> String { - cell.edit_text().unwrap_or_default() -} - -/// Parent the popover to the cell and keep it there until it closes. -fn present(cell: &GridCell, popover: >k::Popover) { - popover.set_parent(cell); - cell.set_popover(popover); - // The cell holds the popover in its slot and as its child, so the - // popover holds the cell weakly and itself not at all. - let cell_for_close = cell.downgrade(); - popover.connect_closed(move |popover| { - popover.unparent(); - if let Some(cell) = cell_for_close.upgrade() { - cell.release_popover(popover); - } - }); - popover.popup(); -} - -/// The popover is closed before the tab hears of the value, so an edit -/// the tab redraws the row for never meets an open popover. -fn commit(cell: &GridCell, popover: >k::Popover, input: String, sink: &GridSink) { - // Drawn now rather than after the tracker's round trip. - if let Some(editor) = cell.editor() { - editor.set_text(&truncate_for_display(&input)); - } - let row = cell.row(); - popover.popdown(); - if let Some(row) = row { - sink(GridMsg::CellEdited { - row, - column: cell.column(), - input, - }); - } -} - -pub(super) fn show_calendar_popover(cell: &GridCell, sink: &GridSink) { - let calendar = gtk::Calendar::new(); - if let Ok(parsed) = chrono::NaiveDate::parse_from_str(¤t_text(cell), "%Y-%m-%d") - && let Ok(dt) = glib::DateTime::from_local(parsed.year(), parsed.month() as i32, parsed.day() as i32, 0, 0, 0.0) - { - calendar.set_date(&dt); - } - - let popover = gtk::Popover::builder().child(&calendar).build(); - let cell_for_cal = cell.downgrade(); - let popover_for_cal = popover.downgrade(); - let sink = sink.clone(); - calendar.connect_day_selected(move |c| { - let (Some(cell), Some(popover)) = (cell_for_cal.upgrade(), popover_for_cal.upgrade()) else { - return; - }; - let dt = c.date(); - let formatted = format!("{:04}-{:02}-{:02}", dt.year(), dt.month(), dt.day_of_month()); - commit(&cell, &popover, formatted, &sink); - }); - present(cell, &popover); -} - -pub(super) fn show_spin_button_popover(cell: &GridCell, sink: &GridSink, is_float: bool) { - let current_text = current_text(cell); - let (initial, lower, upper, step, digits) = if is_float { - let val = current_text.parse::().unwrap_or(0.0); - (val, f64::MIN, f64::MAX, 0.1_f64, 6_u32) - } else { - let val = current_text.parse::().unwrap_or(0) as f64; - (val, i64::MIN as f64, i64::MAX as f64, 1.0_f64, 0_u32) - }; - let adjustment = gtk::Adjustment::new(initial, lower, upper, step, step * 10.0, 0.0); - let spin = gtk::SpinButton::new(Some(&adjustment), step, digits); - spin.set_numeric(true); - spin.set_width_chars(20); - - let popover = gtk::Popover::builder().child(&spin).build(); - let cell_for_commit = cell.downgrade(); - let popover_for_commit = popover.downgrade(); - let sink = sink.clone(); - spin.connect_activate(move |s| { - let (Some(cell), Some(popover)) = (cell_for_commit.upgrade(), popover_for_commit.upgrade()) else { - return; - }; - // f64's Display claims no more precision than the value has. - let formatted = if is_float { - format!("{}", s.value()) - } else { - format!("{}", s.value() as i64) - }; - commit(&cell, &popover, formatted, &sink); - }); - present(cell, &popover); - spin.grab_focus(); -} - -pub(super) fn show_json_popover(cell: &GridCell, sink: &GridSink) { - let buffer = sourceview5::Buffer::new(None); - if let Some(lang) = sourceview5::LanguageManager::default().language("json") { - buffer.set_language(Some(&lang)); - } - buffer.set_text(¤t_text(cell)); - - let view = sourceview5::View::with_buffer(&buffer); - view.set_show_line_numbers(true); - view.set_monospace(true); - view.set_auto_indent(true); - view.set_tab_width(2); - view.set_indent_width(2); - show_buffer_popover(cell, sink, &view); -} - -/// For a text the one-line editor cannot lay out without stalling. -pub(super) fn show_long_text_popover(cell: &GridCell, sink: &GridSink, text: &str) { - let buffer = sourceview5::Buffer::new(None); - buffer.set_text(text); - let view = sourceview5::View::with_buffer(&buffer); - view.set_wrap_mode(gtk::WrapMode::WordChar); - show_buffer_popover(cell, sink, &view); -} - -/// Saves through a button, since Return inserts a newline in a -/// multi-line buffer. -fn show_buffer_popover(cell: &GridCell, sink: &GridSink, view: &sourceview5::View) { - let buffer = view.buffer(); - - let scrolled = gtk::ScrolledWindow::builder() - .child(view) - .min_content_width(420) - .min_content_height(280) - .has_frame(true) - .build(); - - let save_button = gtk::Button::with_label(&crate::i18n::gettext("Save")); - save_button.add_css_class("suggested-action"); - - let cancel_button = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); - - let button_box = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(8) - .halign(gtk::Align::End) - .build(); - button_box.append(&cancel_button); - button_box.append(&save_button); - - let container = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(8) - .margin_top(8) - .margin_bottom(8) - .margin_start(8) - .margin_end(8) - .build(); - container.append(&scrolled); - container.append(&button_box); - - let popover = gtk::Popover::builder().child(&container).build(); - - let popover_for_cancel = popover.downgrade(); - cancel_button.connect_clicked(move |_| { - if let Some(popover) = popover_for_cancel.upgrade() { - popover.popdown(); - } - }); - - let cell_for_commit = cell.downgrade(); - let popover_for_commit = popover.downgrade(); - let sink = sink.clone(); - let buffer_for_commit = buffer.clone(); - save_button.connect_clicked(move |_| { - let (Some(cell), Some(popover)) = (cell_for_commit.upgrade(), popover_for_commit.upgrade()) else { - return; - }; - let start = buffer_for_commit.start_iter(); - let end = buffer_for_commit.end_iter(); - let text = buffer_for_commit.text(&start, &end, true).to_string(); - // The tab parses the text for its column and refuses, with a - // toast, a value the column cannot take. - commit(&cell, &popover, text, &sink); - }); - - present(cell, &popover); - view.grab_focus(); -} - -#[cfg(test)] -mod tests { - use gtk4::prelude::*; - use gtk4::{self as gtk, glib}; - - use super::{show_calendar_popover, show_json_popover, show_spin_button_popover}; - use crate::ui::grid::{CellEditor, GridCell, GridSink}; - - #[gtk4::test] - fn calendar_selects_cell_date() { - let calendar = gtk::Calendar::new(); - let date = glib::DateTime::from_local(2026, 9, 15, 0, 0, 0.0).unwrap(); - - calendar.set_date(&date); - - assert_eq!(calendar.date().year(), 2026); - assert_eq!(calendar.date().month(), 9); - assert_eq!(calendar.date().day_of_month(), 15); - } - - #[derive(Clone, Copy, Debug)] - enum PopoverEditor { - Date, - Int, - Json, - } - - /// Commit through the editor's own control, which also closes it. - fn commit_popover(editor: PopoverEditor, popover: >k::Popover) { - use crate::test_support::first_descendant_of_type; - match editor { - PopoverEditor::Date => first_descendant_of_type::(popover) - .expect("the calendar") - .emit_by_name::<()>("day-selected", &[]), - PopoverEditor::Int => { - let spin = first_descendant_of_type::(popover).expect("the spin button"); - spin.set_value(42.0); - spin.emit_by_name::<()>("activate", &[]); - } - PopoverEditor::Json => { - first_descendant_of_type::(popover) - .expect("the JSON view") - .buffer() - .set_text("[1]"); - crate::test_support::descendants(popover) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - .emit_clicked(); - } - } - } - - /// Another test's window can take the focus and close an autohide - /// popover before it opens, so a dismissed one is shown again. - fn open_popover(editor: PopoverEditor, cell: &GridCell, sink: &GridSink) -> Option { - for _ in 0..5 { - match editor { - PopoverEditor::Date => show_calendar_popover(cell, sink), - PopoverEditor::Int => show_spin_button_popover(cell, sink, false), - PopoverEditor::Json => show_json_popover(cell, sink), - } - let popover = crate::test_support::first_descendant_of_type::(cell)?; - let settled = crate::test_support::wait_until(std::time::Duration::from_secs(5), || { - popover.is_mapped() || !popover.is_visible() - }); - if settled.is_ok() && popover.is_mapped() { - return Some(popover); - } - if popover.parent().is_some() { - popover.unparent(); - } - cell.take_popover(); - } - None - } - - #[gtk4::test] - fn a_closed_popover_editor_frees_its_cell() { - use crate::test_support::wait_until; - let timeout = std::time::Duration::from_secs(5); - let window = gtk::Window::new(); - window.present(); - for editor in [PopoverEditor::Date, PopoverEditor::Int, PopoverEditor::Json] { - let sink: GridSink = std::rc::Rc::new(|_| {}); - let cell = GridCell::new(); - let text = CellEditor::new(); - text.set_text("x"); - cell.set_content(&text); - window.set_child(Some(&cell)); - wait_until(timeout, || cell.is_mapped()).expect("the cell is drawn"); - let popover = open_popover(editor, &cell, &sink).expect("the editor popover opens"); - - commit_popover(editor, &popover); - wait_until(timeout, || popover.parent().is_none()).expect("the popover closes"); - assert_ne!(text.text(), "x", "{editor:?} did not commit"); - assert!(!cell.is_editing(), "{editor:?} left its popover in the cell"); - // Only the cell is checked: GTK itself keeps a closed autohide - // popover alive once a child of it has taken focus. - let freed = cell.downgrade(); - window.set_child(None::<>k::Widget>); - drop((cell, text, popover)); - crate::test_support::drain_main_context(); - - assert!(freed.upgrade().is_none(), "{editor:?} kept its cell alive"); - } - window.destroy(); - } -} diff --git a/linux/crates/app/src/ui/grid/cell_preset.rs b/linux/crates/app/src/ui/grid/cell_preset.rs index 319403172f..2091544a53 100644 --- a/linux/crates/app/src/ui/grid/cell_preset.rs +++ b/linux/crates/app/src/ui/grid/cell_preset.rs @@ -1,5 +1,96 @@ +use tablepro_core::ColumnInfo; + +use super::GridRowId; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CellPreset { Empty, Null, + /// Leave the column out of the new row's INSERT. + Default, +} + +/// Which presets a cell's menu offers. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CellPresets { + pub empty: bool, + pub null: bool, + pub default: bool, +} + +/// Only an INSERT can leave a column out, so Default is for new rows +/// alone, and only where the server has something to fill in. +pub fn cell_presets(column: &ColumnInfo, row: &GridRowId) -> CellPresets { + let server_fills = !column.default.is_none() || column.is_auto_increment || column.is_generated; + CellPresets { + empty: column.column_type.kind.accepts_empty_string(), + null: column.nullable, + default: matches!(row, GridRowId::Draft(_)) && server_fills, + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnKind, ColumnType, IntegerKind, IntegerWidth, TextLength}; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CellPresets, cell_presets}; + use crate::services::change_tracker::RowKey; + use crate::ui::grid::GridRowId; + + fn column(kind: ColumnKind, nullable: bool, default: ColumnDefault) -> ColumnInfo { + let mut column_type = ColumnType::untyped_text("t"); + column_type.kind = kind; + ColumnInfo { + name: "c".to_owned(), + column_type, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default, + comment: None, + } + } + + #[test] + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where a default expression comes from" + )] + fn presets_enabled_per_kind_nullability_and_draft() { + let text = ColumnKind::Text { + length: TextLength::Unbounded, + }; + let integer = ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W32, + signed: true, + }); + let now = ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text("now()")); + let saved = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft = GridRowId::Draft(1); + let presets = |empty, null, default| CellPresets { empty, null, default }; + + assert_eq!( + cell_presets(&column(text.clone(), true, ColumnDefault::None), &saved), + presets(true, true, false) + ); + assert_eq!( + cell_presets(&column(text, false, ColumnDefault::None), &draft), + presets(true, false, false), + "a column with nothing to fill offered Default" + ); + assert_eq!( + cell_presets(&column(integer.clone(), false, now.clone()), &draft), + presets(false, false, true) + ); + assert_eq!( + cell_presets(&column(integer.clone(), true, now), &saved), + presets(false, true, false), + "a saved row offered Default" + ); + let mut serial = column(integer, false, ColumnDefault::None); + serial.is_auto_increment = true; + assert_eq!(cell_presets(&serial, &draft), presets(false, false, true)); + } } diff --git a/linux/crates/app/src/ui/grid/column_drawing.rs b/linux/crates/app/src/ui/grid/column_drawing.rs index d2227eeaae..8e5b0ac749 100644 --- a/linux/crates/app/src/ui/grid/column_drawing.rs +++ b/linux/crates/app/src/ui/grid/column_drawing.rs @@ -1,10 +1,13 @@ use gtk4 as gtk; use gtk4::prelude::*; +use tablepro_core::column::{BoolStorage, ColumnKind}; +use tablepro_core::edit::CellInput; use tablepro_core::{ColumnInfo, Value}; use super::display_text::{auto_filled_sentinel, editable_null_sentinel, value_to_display_text}; -use super::{CellEditor, CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; +use super::toggle_cell::{ToggleCell, toggle_state}; +use super::{CellLock, GridCell, GridRowId, RowObject, TabGridContext, cell_editability}; const PENDING_CSS_CLASSES: &[&str] = &[ "tp-cell-modified", @@ -13,7 +16,7 @@ const PENDING_CSS_CLASSES: &[&str] = &[ "tp-row-leftmost-error-flash", ]; -/// Shorter text fits a typical 200 px column, so its ellipsized label +/// Shorter text fits a typical 200 px column, so its ellipsized display /// needs no tooltip. const TOOLTIP_MIN_CHARS: usize = 40; @@ -24,7 +27,7 @@ pub struct ColumnDrawing { /// `None` for a column the grid knows only from the result set. pub catalog: Option, pub tab_ctx: TabGridContext, - /// The server fills these in, so a draft row shows a placeholder + /// The server fills these in, so a new row shows a placeholder /// rather than an empty cell the user should type in. pub auto_filled: bool, } @@ -38,67 +41,48 @@ impl ColumnDrawing { } pub fn draw(&self, cell: &GridCell, row: &RowObject) { - let (value, pending_classes) = self.tab_ctx.bind_state(row, cell.column()); - draw_cell(cell, &value, &pending_classes, cell.lock().is_none(), self.auto_filled); - } -} - -/// `editable` is whether this row's cell takes an edit: such a cell -/// shows NULL as ``, so it reads differently from an empty -/// string, and a locked one dims a plain NULL. -fn draw_cell(grid_cell: &GridCell, value: &Value, pending_classes: &[&'static str], editable: bool, auto_filled: bool) { - let is_null = matches!(value, Value::Null); - let text = if is_null && auto_filled { - auto_filled_sentinel() - } else if is_null && editable { - editable_null_sentinel() - } else { - value_to_display_text(value) - }; - let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); - let Some(content) = grid_cell.content() else { - return; - }; - if let Some(editor) = content.downcast_ref::() { + let (input, pending_classes) = self.tab_ctx.bind_state(row, cell.column()); // An edit opened on a value the row no longer has would, once it - // ended, send that value back or leave it on screen. An edit on - // the same value keeps what was typed. - if grid_cell.record_drawn_text(&text) && (editor.is_editing() || grid_cell.is_editing()) { - grid_cell.cancel_edit(); + // ended, send that value back. An edit on the same value keeps + // what was typed. + if cell.edit_original().is_some_and(|original| original != input) { + cell.cancel_edit(); } - if !editor.is_editing() { - editor.set_text(&text); + let Some(content) = cell.content() else { + return; + }; + apply_pending_classes(content, &pending_classes); + let pending_delete = pending_classes.contains(&"tp-row-pending-delete"); + if let Some(check) = content.downcast_ref::() { + let storage = match self.catalog.as_ref().map(|column| &column.column_type.kind) { + Some(ColumnKind::Bool(storage)) => *storage, + _ => BoolStorage::Native, + }; + ToggleCell::draw(cell, check, &toggle_state(&input, storage)); + check.set_sensitive(cell.lock().is_none()); + // A check box has no strikethrough, so a pending delete dims it. + check.set_opacity(if pending_delete { 0.5 } else { 1.0 }); + } else if let Some(display) = content.downcast_ref::() { + self.draw_text(display, &input, cell.lock().is_none(), pending_delete); } - // A locked cell's text is there to be read and copied, as a - // read-only column's is. - editor.set_selectable(!editable); - apply_cell_tooltip(content, &text, is_null); - set_class(content, "dim-label", is_null && !editable); - set_class(content, "tp-null-sentinel", is_null && (editable || auto_filled)); - apply_pending_classes(content, pending_classes); - editor.set_strikethrough(is_pending_delete); - } else if let Some(checkbox) = content.downcast_ref::() { - // Suppressed, so drawing the value does not echo back as an edit. - grid_cell.suppress_toggle(true); - // A value that is not a bool shows as inconsistent rather than as - // a confidently wrong check box. - let (active, inconsistent) = match value { - Value::Bool(flag) => (*flag, false), - _ => (false, true), + } + + /// An editable cell shows NULL as ``, so it reads differently + /// from an empty string, and a locked one dims a plain NULL. + fn draw_text(&self, display: >k::Inscription, input: &CellInput, editable: bool, pending_delete: bool) { + let (text, placeholder) = match input { + CellInput::Default if self.auto_filled => (auto_filled_sentinel(), true), + CellInput::Default => ("DEFAULT".to_owned(), true), + CellInput::Value(Value::Null) if editable => (editable_null_sentinel(), true), + CellInput::Value(value) => (value_to_display_text(value), false), }; - checkbox.set_inconsistent(inconsistent); - checkbox.set_active(active); - grid_cell.suppress_toggle(false); - checkbox.set_sensitive(editable); - apply_pending_classes(content, pending_classes); - // A check box has no strikethrough, so a pending delete dims it. - checkbox.set_opacity(if is_pending_delete { 0.5 } else { 1.0 }); - } else if let Some(label) = content.downcast_ref::() { - label.set_text(&text); - apply_cell_tooltip(content, &text, is_null); - set_class(content, "dim-label", is_null); - apply_pending_classes(content, pending_classes); - set_label_strikethrough(label, is_pending_delete); + let is_null = matches!(input, CellInput::Value(Value::Null)); + display.set_text(Some(&text)); + let widget = display.upcast_ref::(); + apply_cell_tooltip(widget, &text, placeholder || is_null); + set_class(widget, "dim-label", is_null && !editable); + set_class(widget, "tp-null-sentinel", placeholder); + display.set_attributes(pending_delete.then(strikethrough).as_ref()); } } @@ -116,8 +100,8 @@ fn apply_pending_classes(widget: >k::Widget, pending_classes: &[&'static str]) } } -fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { - if is_null || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { +fn apply_cell_tooltip(widget: >k::Widget, text: &str, stands_in: bool) { + if stands_in || text.chars().take(TOOLTIP_MIN_CHARS + 1).count() <= TOOLTIP_MIN_CHARS { widget.set_tooltip_text(None); } else { widget.set_tooltip_text(Some(text)); @@ -125,13 +109,9 @@ fn apply_cell_tooltip(widget: >k::Widget, text: &str, is_null: bool) { } /// A Pango attribute, because CSS `text-decoration` does not cascade -/// reliably into a cell's label. -fn set_label_strikethrough(label: >k::Label, on: bool) { - if on { - let attrs = gtk::pango::AttrList::new(); - attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); - label.set_attributes(Some(&attrs)); - } else { - label.set_attributes(None); - } +/// reliably into a cell's text. +fn strikethrough() -> gtk::pango::AttrList { + let attrs = gtk::pango::AttrList::new(); + attrs.insert(gtk::pango::AttrInt::new_strikethrough(true)); + attrs } diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index a95d8abf9a..8d8d698d0b 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -3,16 +3,16 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{self as gtk, glib}; -use tablepro_core::column::ResultColumn; +use tablepro_core::column::{ColumnKind, ResultColumn}; use tablepro_core::{ColumnInfo, QueryResult}; +use super::cell_edit::install_edit_triggers; use super::cell_editability::column_takes_edits; -use super::cell_editor_kind::{CellEditorKind, classify_editor_kind, is_bool_type}; use super::column_drawing::ColumnDrawing; -use super::context_menu::{CellMenuTarget, attach_cell_gesture}; +use super::context_menu::attach_cell_gesture; use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; -use super::inline_edit::{install_edit_commit_handler, install_edit_triggers}; -use super::{CellEditor, GridCell, GridMsg, GridSink, RowObject, TabGridContext}; +use super::toggle_cell::ToggleCell; +use super::{GridCell, GridMsg, GridSink, RowObject, TabGridContext}; const WIDE_TABLE_THRESHOLD: usize = 8; /// About 14 characters at the default font size. @@ -116,13 +116,14 @@ pub fn build_column_view( (column_view, selection) } -/// How a column's cells are built. A column no row can edit gets the -/// plain label a read-only grid has. +/// How a column's cells are built. Editors are made when an edit +/// opens, so only the display is chosen here. #[derive(Clone, Copy)] enum CellKind { + /// Drawn and never edited, so it takes no edit triggers. ReadOnly, Toggle, - Text(CellEditorKind), + Text, } struct ColumnInit<'a> { @@ -154,10 +155,9 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { let kind = match catalog.as_ref() { Some(column) if tab_ctx.editable && column_takes_edits(column, &tab_ctx.identity, false) => { - if is_bool_type(&info.column_type.name) { - CellKind::Toggle - } else { - CellKind::Text(classify_editor_kind(&info.column_type.name)) + match column.column_type.kind { + ColumnKind::Bool(_) => CellKind::Toggle, + _ => CellKind::Text, } } _ => CellKind::ReadOnly, @@ -170,10 +170,6 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { tab_ctx: tab_ctx.clone(), auto_filled, }); - let target = CellMenuTarget { - text_editable: matches!(kind, CellKind::Text(_)), - accepts_empty: matches!(kind, CellKind::Text(_)) && info.column_type.kind.accepts_empty_string(), - }; factory.connect_setup(move |_, item| { let Some(cell) = item.downcast_ref::() else { @@ -182,12 +178,16 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { let grid_cell = GridCell::new(); grid_cell.attach(cell, idx); grid_cell.set_drawing(drawing.clone()); + grid_cell.set_sink(sink.clone()); match kind { - CellKind::ReadOnly => setup_readonly_cell(&grid_cell), - CellKind::Toggle => setup_toggle_cell(&grid_cell, sink.clone()), - CellKind::Text(editor_kind) => setup_editable_cell(&grid_cell, sink.clone(), editor_kind), + CellKind::ReadOnly => grid_cell.set_content(&text_display()), + CellKind::Toggle => ToggleCell::setup(&grid_cell), + CellKind::Text => { + grid_cell.set_content(&text_display()); + install_edit_triggers(&grid_cell); + } } - attach_cell_gesture(&grid_cell, &column_view, target, &grid_menus); + attach_cell_gesture(&grid_cell, &column_view, &grid_menus); cell.set_child(Some(&grid_cell)); }); @@ -240,56 +240,17 @@ fn build_column(init: ColumnInit<'_>) -> gtk::ColumnViewColumn { column } -fn setup_editable_cell(grid_cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { - let editor = CellEditor::new(); - editor.set_hexpand(true); - editor.set_margin_start(8); - editor.set_margin_end(8); - grid_cell.set_content(&editor); - grid_cell.set_sink(sink.clone()); - install_edit_commit_handler(grid_cell, sink.clone()); - install_edit_triggers(grid_cell, sink, editor_kind); -} - -fn setup_toggle_cell(grid_cell: &GridCell, sink: GridSink) { - let checkbox = gtk::CheckButton::builder() - .halign(gtk::Align::Start) - .valign(gtk::Align::Center) - .margin_start(8) - .margin_end(8) - .build(); - grid_cell.set_content(&checkbox); - // The cell owns the check button, so the handler finds the cell - // through its parent rather than holding it. - checkbox.connect_toggled(move |checkbox| { - let Some(grid_cell) = checkbox.parent().and_downcast::() else { - return; - }; - if grid_cell.toggle_suppressed() || grid_cell.lock().is_some() { - return; - } - let Some(row) = grid_cell.row() else { - return; - }; - let input = if checkbox.is_active() { "true" } else { "false" }; - sink(GridMsg::CellEdited { - row, - column: grid_cell.column(), - input: input.to_owned(), - }); - }); -} - -fn setup_readonly_cell(grid_cell: &GridCell) { - let label = gtk::Label::builder() - .xalign(0.0) - .hexpand(true) - .selectable(true) - .ellipsize(gtk::pango::EllipsizeMode::End) - .margin_start(8) - .margin_end(8) - .build(); - grid_cell.set_content(&label); +/// Every cell but a check box draws its value in an Inscription, which +/// lays out only what fits and offers no text selection of its own. +fn text_display() -> gtk::Inscription { + let display = gtk::Inscription::new(None); + display.set_xalign(0.0); + display.set_hexpand(true); + display.set_text_overflow(gtk::InscriptionOverflow::EllipsizeEnd); + display.set_margin_start(8); + display.set_margin_end(8); + display.add_css_class("tp-cell-text"); + display } #[cfg(test)] @@ -301,6 +262,7 @@ mod tests { use gtk4::prelude::*; use gtk4::{self as gtk, gio, glib}; + use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; use tablepro_core::{ColumnInfo, QueryResult, Value}; @@ -309,12 +271,11 @@ mod tests { use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; use crate::services::tracker_edit::TrackerEdit; - use crate::test_support::{descendants, drain_main_context, wait_until}; + use crate::test_support::{descendants, drain_main_context, first_descendant_of_type, wait_until}; use crate::ui::grid::cell_editability::column_takes_edits; use crate::ui::grid::display_text::editable_null_sentinel; use crate::ui::grid::{ - CellEditor, CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridSink, RowObject, TabGridContext, - enter_edit_mode, + CellPreset, ColumnWidthBinding, GridCell, GridMsg, GridSink, RowObject, TabGridContext, begin_edit, }; const WAIT: Duration = Duration::from_secs(5); @@ -449,8 +410,8 @@ mod tests { } fn insert_draft(&self) { - let delta = self.tracker.apply(TrackerEdit::Insert { - values: vec![Value::Null, Value::Null], + let delta = self.tracker.apply(TrackerEdit::InsertDraft { + cells: vec![CellInput::Value(Value::Null), CellInput::Value(Value::Null)], }); let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); @@ -500,24 +461,43 @@ mod tests { ) } - fn type_into(cell: &GridCell, text: &str) -> CellEditor { - enter_edit_mode(cell); - let editor = cell.editor().expect("a text cell"); - assert!(editor.is_editing(), "the cell did not open for editing"); - editor.entry().set_text(text); + /// Open the cell's one-line editor and type `text` over its value. + fn type_into(cell: &GridCell, text: &str) -> gtk::Text { + begin_edit(cell); + let editor = cell.inline_text().expect("the cell did not open for editing"); + editor.set_text(text); editor } - fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, String)> { + fn display_text(cell: &GridCell) -> String { + cell.display() + .and_then(|display| display.text()) + .map(|text| text.to_string()) + .unwrap_or_default() + } + + /// Each edit the grid sent, as the row, the column and the value. + fn edited_rows(messages: &[GridMsg]) -> Vec<(Option, usize, CellInput)> { messages .iter() .filter_map(|msg| match msg { - GridMsg::CellEdited { row, column, input } => Some((row.key().cloned(), *column, input.clone())), + GridMsg::Edit { row, column, edit } => { + let input = match edit { + TrackerEdit::Cell { value, .. } => CellInput::Value(value.clone()), + TrackerEdit::DraftCell { input, .. } => input.clone(), + other => panic!("a cell commit made {other:?}"), + }; + Some((row.key().cloned(), *column, input)) + } _ => None, }) .collect() } + fn text(value: &str) -> CellInput { + CellInput::Value(Value::Text(value.to_owned())) + } + /// Without focusable cells, which come with the grid's keyboard /// model, a grid cell takes the focus through its open editor. #[gtk4::test] @@ -536,11 +516,11 @@ mod tests { .expect("the focused editor resolves to its grid cell"); let cell = focused.expect("a focused cell"); assert_eq!(cell.row().and_then(|row| row.key().cloned()), Some(key(2))); - editor.stop_editing(true); + editor.emit_activate(); assert_eq!( edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, "renamed".to_owned())] + vec![(Some(key(2)), 1, text("renamed"))] ); } @@ -556,7 +536,7 @@ mod tests { drain_main_context(); grid.view .activate_action("cell.set-null", None) - .expect("the menu's Set NULL"); + .expect("the menu's Set to NULL"); let set: Vec<(Option, usize, CellPreset)> = grid .take_messages() @@ -576,11 +556,11 @@ mod tests { let editor = type_into(&cell, "renamed"); grid.insert_draft(); - editor.stop_editing(true); + editor.emit_activate(); assert_eq!( edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, "renamed".to_owned())] + vec![(Some(key(2)), 1, text("renamed"))] ); } @@ -594,17 +574,13 @@ mod tests { ], ); grid.insert_draft(); - let toggle = grid - .cell_of(2, 1) - .content() - .and_then(|content| content.downcast_ref::().cloned()) - .expect("a check button"); + let toggle = grid.cell_of(2, 1).check_button().expect("a check button"); toggle.set_active(true); assert_eq!( edited_rows(&grid.take_messages()), - vec![(Some(key(2)), 1, "true".to_owned())] + vec![(Some(key(2)), 1, CellInput::Value(Value::Bool(true)))] ); } @@ -625,33 +601,26 @@ mod tests { assert!(grid.cell_of(1, 1).lock().is_none()); assert!(draft_key_cell.lock().is_none(), "a draft could not set its own key"); - enter_edit_mode(&grid.cell_of(1, 0)); - assert!(!grid.cell_of(1, 0).editor().expect("a text cell").is_editing()); - } - - fn label_of(cell: &GridCell) -> gtk::Label { - descendants(cell) - .into_iter() - .find_map(|widget| widget.downcast::().ok()) - .expect("a cell draws its value in a label") + begin_edit(&grid.cell_of(1, 0)); + assert!(!grid.cell_of(1, 0).is_editing()); + begin_edit(&draft_key_cell); + assert!(draft_key_cell.inline_text().is_some()); } #[gtk4::test] - fn a_locked_key_cell_keeps_its_text_selectable() { + fn locked_cells_draw_in_an_inscription_with_no_label() { let grid = two_text_rows(); - grid.insert_draft(); - let draft_key_cell = grid - .cells() - .into_iter() - .find(|cell| cell.column() == 0 && cell.row().is_some_and(|row| row.draft_id().is_some())) - .expect("the draft's key cell"); + let locked = grid.cell_of(1, 0); assert!( - label_of(&grid.cell_of(1, 0)).is_selectable(), - "a saved row's key cannot be selected" + locked.display().is_some(), + "a locked cell does not draw in an Inscription" + ); + assert!( + descendants(&locked).iter().all(|widget| !widget.is::()), + "a locked cell carries a label, whose own menu takes Shift+F10" ); - assert!(!label_of(&draft_key_cell).is_selectable()); - assert!(!label_of(&grid.cell_of(1, 1)).is_selectable()); + assert_eq!(display_text(&locked), "1"); } #[gtk4::test] @@ -672,7 +641,7 @@ mod tests { .bump_revision(); assert_eq!(delta.pending_count, 1); - assert_eq!(cell.editor().expect("a text cell").text(), "new"); + assert_eq!(display_text(&cell), "new"); assert!( cell.content() .is_some_and(|content| content.has_css_class("tp-cell-modified")) @@ -687,12 +656,12 @@ mod tests { let row = cell.row().expect("a bound row"); row.bump_revision(); - editor.stop_editing(false); - assert_eq!(editor.text(), "B", "Escape did not go back to the row's value"); - type_into(&cell, "typed"); - row.bump_revision(); - assert_eq!(editor.entry().text(), "typed", "the redraw overwrote what was typed"); + assert_eq!(editor.text(), "typed", "the redraw overwrote what was typed"); + cell.cancel_edit(); + assert_eq!(display_text(&cell), "B", "Escape did not go back to the row's value"); + assert!(cell.inline_text().is_none()); + assert_eq!(edited_rows(&grid.take_messages()), vec![]); } #[gtk4::test] @@ -723,7 +692,7 @@ mod tests { /// Row 1's name edited from `from` to `to`, its editor opened on /// `to`, then the edit undone under the open editor. - fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell, CellEditor) { + fn undo_under_open_editor(from: Value, to: Value) -> (Grid, GridCell) { let grid = name_rows(from.clone()); let cell = grid.cell_of(1, 1); grid.redraw(&grid.tracker.apply(TrackerEdit::Cell { @@ -732,62 +701,66 @@ mod tests { original: from, value: to, })); - enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - assert!(editor.is_editing(), "the cell did not open for editing"); + begin_edit(&cell); + assert!(cell.inline_text().is_some(), "the cell did not open for editing"); grid.redraw(&grid.tracker.undo().expect("an edit to undo")); - (grid, cell, editor) + (grid, cell) } #[gtk4::test] fn escape_after_an_undo_under_the_editor_sends_nothing() { - let (grid, cell, editor) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + let (grid, cell) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); assert!( - !editor.is_editing(), + !cell.is_editing(), "the editor stayed open on a value the row no longer has" ); - editor.stop_editing(false); + cell.cancel_edit(); cell.unbind(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(editor.text(), "A"); + assert_eq!(display_text(&cell), "A"); assert_eq!(grid.tracker.pending_count(), 0); } #[gtk4::test] fn escape_after_undoing_an_edit_of_null_sends_nothing() { - let (grid, _cell, editor) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); + let (grid, cell) = undo_under_open_editor(Value::Null, Value::Text("hello".into())); - editor.stop_editing(false); + cell.cancel_edit(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(editor.text().as_str(), editable_null_sentinel()); + assert_eq!(display_text(&cell), editable_null_sentinel()); } #[gtk4::test] - fn enter_after_an_undo_under_the_editor_shows_the_undone_value() { - let (grid, _cell, editor) = undo_under_open_editor(Value::Text("A".into()), Value::Text("X".into())); + fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + let grid = name_rows(Value::Null); + let cell = grid.cell_of(1, 1); + begin_edit(&cell); - editor.stop_editing(true); + cell.row().expect("a bound row").bump_revision(); + assert!(cell.is_editing(), "a redraw of the same value closed the editor"); + cell.cancel_edit(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); - assert_eq!(editor.text(), "A", "the cell shows an edit the tracker no longer has"); } #[gtk4::test] - fn escape_on_a_null_cell_drawn_again_under_its_editor_sends_nothing() { + fn a_null_text_cell_left_as_it_opened_stays_null() { let grid = name_rows(Value::Null); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); - let editor = cell.editor().expect("a text cell"); - cell.row().expect("a bound row").bump_revision(); - assert!(editor.is_editing(), "a redraw of the same value closed the editor"); - editor.stop_editing(false); + let editor = type_into(&cell, ""); + assert_eq!( + editor.placeholder_text().unwrap_or_default(), + "", + "a text column keeps the empty string, so empty means nothing more" + ); + editor.emit_activate(); assert_eq!(edited_rows(&grid.take_messages()), vec![]); } @@ -796,16 +769,21 @@ mod tests { format!("tpyo{}", "x".repeat(60_000)) } - /// The text of whichever editor the cell opened: its entry, or the - /// popover it opened instead. - fn opened_text(cell: &GridCell) -> Option { - let editor = cell.editor()?; - if editor.is_editing() { - return Some(editor.entry().text().to_string()); - } - let view = crate::test_support::first_descendant_of_type::(cell)?; + fn source_view(cell: &GridCell) -> Option { + first_descendant_of_type::(cell) + } + + fn buffer_text(view: &sourceview5::View) -> String { let buffer = view.buffer(); - Some(buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string()) + buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string() + } + + fn save_button(cell: &GridCell) -> gtk::Button { + descendants(cell) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Save button") } #[gtk4::test] @@ -813,9 +791,10 @@ mod tests { let grid = name_rows(Value::Text(long_text())); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + begin_edit(&cell); - let opened = opened_text(&cell).expect("an editor opened"); + assert!(cell.inline_text().is_none(), "a long text opened in a one-line field"); + let opened = buffer_text(&source_view(&cell).expect("the multi-line editor")); assert_eq!(opened.len(), long_text().len(), "the editor opened on the cut text"); assert_eq!(opened, long_text()); } @@ -824,23 +803,19 @@ mod tests { fn a_long_text_saved_from_its_editor_sends_the_whole_value() { let grid = name_rows(Value::Text(long_text())); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + begin_edit(&cell); let fixed = long_text().replacen("tpyo", "typo", 1); - let view = crate::test_support::first_descendant_of_type::(&cell).expect("a text popover"); + let view = source_view(&cell).expect("the multi-line editor"); view.buffer().set_text(&fixed); - descendants(&cell) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) - .expect("the Save button") - .emit_clicked(); + save_button(&cell).emit_clicked(); - assert_eq!(edited_rows(&grid.take_messages()), vec![(Some(key(1)), 1, fixed)]); - assert!( - cell.editor().is_some_and(|editor| editor.text().len() < 20_000), - "the cell drew the whole value" + assert_eq!( + edited_rows(&grid.take_messages()), + vec![(Some(key(1)), 1, text(&fixed))] ); + assert!(!cell.is_editing()); + assert!(display_text(&cell).len() < 20_000, "the cell drew the whole value"); } #[gtk4::test] @@ -848,9 +823,14 @@ mod tests { let grid = name_rows(Value::Text(editable_null_sentinel())); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + let opened = cell_editor_text(&cell); + + assert_eq!(opened, Some(editable_null_sentinel())); + } - assert_eq!(opened_text(&cell), Some(editable_null_sentinel())); + fn cell_editor_text(cell: &GridCell) -> Option { + begin_edit(cell); + cell.inline_text().map(|editor| editor.text().to_string()) } #[gtk4::test] @@ -858,9 +838,9 @@ mod tests { let grid = name_rows(Value::Bytes(vec![1, 2, 3])); let cell = grid.cell_of(1, 1); - enter_edit_mode(&cell); + begin_edit(&cell); - assert_eq!(opened_text(&cell), None, "the editor opened on the size placeholder"); + assert!(!cell.is_editing(), "the editor opened on the size placeholder"); assert!( grid.take_messages() .iter() @@ -869,6 +849,45 @@ mod tests { ); } + /// A closed editor popover must leave nothing holding its cell. + #[gtk4::test] + fn a_closed_popover_editor_frees_its_cell() { + let grid = Grid::open( + vec![ + named("id", "integer", true), + named("doc", "jsonb", false), + named("day", "date", false), + ], + vec![vec![ + Value::Int(1), + Value::Json(tablepro_core::value::JsonText::parse("[1]".to_owned()).expect("json")), + Value::Date(tablepro_core::value::Temporal::Finite( + chrono::NaiveDate::from_ymd_opt(2026, 9, 15).expect("a date"), + )), + ]], + ); + for column in [1, 2] { + let cell = grid.cell_of(1, column); + begin_edit(&cell); + let popover = first_descendant_of_type::(&cell).expect("the editor popover"); + assert!(cell.is_editing()); + + popover.popdown(); + wait_until(WAIT, || popover.parent().is_none()).expect("the popover closes"); + + assert!(!cell.is_editing(), "column {column} left its popover in the cell"); + } + assert_eq!(edited_rows(&grid.take_messages()), vec![]); + let freed: Vec> = grid.cells().iter().map(ObjectExt::downgrade).collect(); + + drop(grid); + + assert!( + freed.iter().all(|cell| cell.upgrade().is_none()), + "an editor popover kept its cell alive" + ); + } + fn width_binding(tasks: &tablepro_session::runtime::Tasks, root: &tempfile::TempDir) -> ColumnWidthBinding { ColumnWidthBinding { store: crate::persistence::ColumnWidthStore::load(root.path().join("column-widths.json"), tasks), @@ -959,12 +978,15 @@ mod tests { .collect() }; wait_until(WAIT, || cells().len() == 3).expect("the key, text and bool cells are drawn"); - // A right-click is what fills the menu's target, so it has to be - // in the picture too. + // A right-click is what fills the menu's target, and an open + // editor is another hold on a cell, so both are in the picture. for cell in cells() { let gesture = menu_gesture(cell.upcast_ref()).expect("a cell answers a right-click"); gesture.emit_by_name::<()>("pressed", &[&1i32, &1.0f64, &1.0f64]); } + if let Some(name) = cells().into_iter().find(|cell| cell.column() == 1) { + begin_edit(&name); + } let freed: Vec> = cells().iter().map(ObjectExt::downgrade).collect(); window.destroy(); diff --git a/linux/crates/app/src/ui/grid/context_menu.rs b/linux/crates/app/src/ui/grid/context_menu.rs index 4bc3be8d21..ed11cc7810 100644 --- a/linux/crates/app/src/ui/grid/context_menu.rs +++ b/linux/crates/app/src/ui/grid/context_menu.rs @@ -24,19 +24,7 @@ impl CellContext { } } -/// What a column's cells offer in the menu while their row is unlocked. -#[derive(Clone, Copy)] -pub(super) struct CellMenuTarget { - pub(super) text_editable: bool, - pub(super) accepts_empty: bool, -} - -pub(super) fn attach_cell_gesture( - cell: &GridCell, - column_view: &glib::WeakRef, - target: CellMenuTarget, - menus: &GridMenus, -) { +pub(super) fn attach_cell_gesture(cell: &GridCell, column_view: &glib::WeakRef, menus: &GridMenus) { // The cell's lock follows the row bound to it, so the menu is picked // when it opens. let prepare = { @@ -46,7 +34,7 @@ pub(super) fn attach_cell_gesture( if let Some(position) = cell.position() { select_row_for_menu(column_view, position); } - menus.actions.arm(&target, &menu_rows_of(column_view, cell)); + menus.actions.arm(cell, &menu_rows_of(column_view, cell)); match cell.lock() { None => menus.editable_popover.clone(), Some(_) => menus.readonly_popover.clone(), diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs index e6883cd517..ea2306e7d4 100644 --- a/linux/crates/app/src/ui/grid/display_text.rs +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -41,24 +41,7 @@ pub(super) fn value_to_display_text(value: &Value) -> String { } } -/// The whole value as an editor starts from it, which committed -/// unchanged writes the value back. Never the display text, which is cut -/// short or stands in for a value. `None` for a value with no text form. -pub(super) fn value_to_edit_text(value: &Value) -> Option { - match value { - Value::Null => Some(String::new()), - Value::Bytes(_) | Value::Undecodable(_) => None, - other => tablepro_core::export::value_to_text(other), - } -} - -/// Whether the cell shows only the start of `s`. Such a text is too long -/// for the one-line editor, which lays out all of it. -pub(super) fn is_cut_for_display(s: &str) -> bool { - s.len() >= DISPLAY_TEXT_BYTES_THRESHOLD && s.chars().nth(DISPLAY_TEXT_MAX_CHARS).is_some() -} - -pub(super) fn truncate_for_display(s: &str) -> String { +fn truncate_for_display(s: &str) -> String { if s.len() < DISPLAY_TEXT_BYTES_THRESHOLD { return s.to_string(); } @@ -82,7 +65,7 @@ pub(super) fn truncate_for_display(s: &str) -> String { mod tests { use tablepro_core::Value; - use super::{is_cut_for_display, truncate_for_display, value_to_display_text, value_to_edit_text}; + use super::{truncate_for_display, value_to_display_text}; #[test] fn display_text_primitive_variants() { @@ -154,44 +137,6 @@ mod tests { assert_ne!(text, "NULL", "an unreadable value looked like a missing one"); } - #[test] - fn edit_text_distinguishes_null_from_text_null() { - assert_eq!(value_to_edit_text(&Value::Null).as_deref(), Some("")); - assert_eq!(value_to_edit_text(&Value::Text("NULL".into())).as_deref(), Some("NULL")); - assert_eq!(value_to_edit_text(&Value::Int(0)).as_deref(), Some("0")); - } - - #[test] - fn edit_text_keeps_extended_variants_visible() { - assert_eq!( - value_to_edit_text(&date_value(2026, 4, 26)).as_deref(), - Some("2026-04-26") - ); - - let id = uuid::Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); - assert_eq!( - value_to_edit_text(&Value::Uuid(id)).as_deref(), - Some("550e8400-e29b-41d4-a716-446655440000") - ); - } - - #[test] - fn edit_text_is_the_whole_value_however_long() { - let huge = "x".repeat(1_000_000); - - assert_eq!(value_to_edit_text(&Value::Text(huge.clone())), Some(huge.clone())); - assert!(is_cut_for_display(&huge)); - assert!(!is_cut_for_display(&"x".repeat(39_999))); - // 40,000 bytes, but only 10,000 characters, which the cell shows - // whole. - assert!(!is_cut_for_display(&"🦀".repeat(10_000))); - } - - #[test] - fn a_value_shown_by_a_stand_in_has_no_edit_text() { - assert_eq!(value_to_edit_text(&Value::Bytes(vec![0u8; 16])), None); - } - #[test] fn truncate_short_text_passes_through() { let s = "hello world"; diff --git a/linux/crates/app/src/ui/grid/edit_commit.rs b/linux/crates/app/src/ui/grid/edit_commit.rs new file mode 100644 index 0000000000..4a0afefd5d --- /dev/null +++ b/linux/crates/app/src/ui/grid/edit_commit.rs @@ -0,0 +1,281 @@ +use tablepro_core::edit::{ + CellInput, EditContext, EditText, EmptyInputMeaning, edit_text, empty_input_meaning, is_unchanged, parse_edit_text, +}; +use tablepro_core::{ColumnInfo, Value}; + +use super::{EditSnapshot, GridCell, GridMsg, GridRowId}; +use crate::services::tracker_edit::TrackerEdit; +use crate::ui::error_text::edit_parse_message; + +/// What committing an editor's text comes to. +#[derive(Debug, Clone, PartialEq)] +pub enum CommitResult { + Edit(TrackerEdit), + Unchanged, + /// The text is not a value of the column, with what to tell the user. + Rejected(String), +} + +/// Only a new row can leave a column to the server. +pub fn edit_context(row: &GridRowId) -> EditContext { + match row { + GridRowId::Draft(_) => EditContext::InsertDraft, + GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => EditContext::UpdateRow, + } +} + +/// The text an editor ends with, as an edit of the row it began on. +/// Every editor commits through here, and none reads the cell's display. +/// +/// Text left as the editor opened it is no edit: NULL and the empty +/// string open as the same empty field, and committing it untouched must +/// not turn one into the other. +pub fn commit_edit(column: &ColumnInfo, snapshot: &EditSnapshot, input: &str) -> CommitResult { + let opened_with = match &snapshot.original { + CellInput::Value(value) => edit_text(value, &column.column_type), + CellInput::Default => EditText::Editable(String::new()), + }; + if matches!(&opened_with, EditText::Editable(text) if text == input) { + return CommitResult::Unchanged; + } + match parse_edit_text(input, column, edit_context(snapshot.row.id())) { + Ok(parsed) => commit_input(column, snapshot, parsed), + Err(error) => { + tracing::debug!(%error, column = %column.name, "refused an edit"); + CommitResult::Rejected(edit_parse_message(&error)) + } + } +} + +/// `input` as an edit of the row the snapshot names, or no edit when it +/// leaves the cell as it was. +pub fn commit_input(column: &ColumnInfo, snapshot: &EditSnapshot, input: CellInput) -> CommitResult { + let unchanged = match (&snapshot.original, &input) { + (CellInput::Value(original), CellInput::Value(value)) => is_unchanged(original, value, &column.column_type), + (CellInput::Default, CellInput::Default) => true, + _ => false, + }; + if unchanged { + return CommitResult::Unchanged; + } + match (snapshot.row.id(), input) { + (GridRowId::Draft(draft_id), input) => CommitResult::Edit(TrackerEdit::DraftCell { + draft_id: *draft_id, + col: snapshot.column, + input, + }), + (GridRowId::Persisted(row_key), CellInput::Value(value)) => CommitResult::Edit(TrackerEdit::Cell { + row_key: row_key.clone(), + col: snapshot.column, + original: snapshot.row.cell(snapshot.column).cloned().unwrap_or(Value::Null), + value, + }), + (GridRowId::Persisted(_), CellInput::Default) => { + tracing::error!("a saved row's cell was given the server's default"); + CommitResult::Unchanged + } + (GridRowId::Result | GridRowId::Unkeyed, _) => CommitResult::Unchanged, + } +} + +/// Hand the outcome to the tab. A refused value is said in a toast and +/// the cell draws its value again. +pub(super) fn send_commit(cell: &GridCell, snapshot: EditSnapshot, result: CommitResult) { + let Some(sink) = cell.sink() else { + return; + }; + match result { + CommitResult::Edit(edit) => sink(GridMsg::Edit { + row: snapshot.row, + column: snapshot.column, + edit, + }), + CommitResult::Unchanged => {} + CommitResult::Rejected(message) => { + sink(GridMsg::ShowToast(message)); + cell.render(); + } + } +} + +/// What an empty editor commits, said where the text would be. The +/// keywords are SQL and stay untranslated. +pub fn empty_placeholder(column: &ColumnInfo, row: &GridRowId) -> Option { + match empty_input_meaning(column, edit_context(row)) { + EmptyInputMeaning::EmptyText => None, + EmptyInputMeaning::Default => Some("DEFAULT".to_owned()), + EmptyInputMeaning::Null => Some("NULL".to_owned()), + EmptyInputMeaning::Required => Some(crate::i18n::gettext("Required")), + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::edit::CellInput; + use tablepro_core::{ColumnInfo, Value}; + + use super::{CommitResult, commit_edit, empty_placeholder}; + use crate::services::change_tracker::RowKey; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::{EditSnapshot, GridRowId, RowObject}; + + #[expect( + clippy::disallowed_methods, + reason = "a test fixture stands in for the catalogue, which is where this text comes from in production" + )] + fn column(data_type: &str, nullable: bool, default: Option<&str>) -> ColumnInfo { + let kind = tablepro_core::column::classify_type_name(data_type); + ColumnInfo { + name: "c".to_owned(), + column_type: ColumnType { + kind, + ..ColumnType::untyped_text(data_type) + }, + nullable, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: match default { + Some(text) => ColumnDefault::Expression(tablepro_core::column::SqlExpression::from_catalog_text(text)), + None => ColumnDefault::None, + }, + comment: None, + } + } + + fn saved(value: Value) -> EditSnapshot { + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + EditSnapshot { + row: RowObject::persisted(Some(key), vec![Value::Int(1), value.clone()].into()), + column: 1, + original: CellInput::Value(value), + } + } + + fn draft(original: CellInput) -> EditSnapshot { + EditSnapshot { + row: RowObject::draft(3), + column: 1, + original, + } + } + + fn value_of(result: CommitResult) -> Option { + match result { + CommitResult::Edit(TrackerEdit::Cell { value, .. }) => Some(CellInput::Value(value)), + CommitResult::Edit(TrackerEdit::DraftCell { input, .. }) => Some(input), + _ => None, + } + } + + #[test] + fn text_left_as_it_opened_is_no_edit() { + let text = column("text", true, None); + let stamped = column("timestamp", false, Some("now()")); + + assert_eq!( + commit_edit(&text, &saved(Value::Null), ""), + CommitResult::Unchanged, + "an untouched NULL became the empty string" + ); + assert_eq!( + commit_edit(&stamped, &draft(CellInput::Default), ""), + CommitResult::Unchanged + ); + assert_eq!( + value_of(commit_edit(&text, &saved(Value::Null), "a")), + Some(CellInput::Value(Value::Text("a".into()))) + ); + } + + #[test] + fn clearing_a_cell_means_what_the_column_makes_of_empty() { + let text = column("text", true, None); + let stamp = column("timestamp", true, None); + let stamped = column("timestamp", false, Some("now()")); + + assert_eq!( + value_of(commit_edit(&text, &saved(Value::Text("a".into())), "")), + Some(CellInput::Value(Value::Text(String::new()))) + ); + assert_eq!( + value_of(commit_edit(&stamp, &saved(Value::Int(1)), "")), + Some(CellInput::Value(Value::Null)) + ); + assert_eq!( + value_of(commit_edit(&stamped, &draft(CellInput::Value(Value::Int(1))), "")), + Some(CellInput::Default), + "a new row leaves an empty cell to the server" + ); + assert!( + matches!( + commit_edit(&stamped, &saved(Value::Int(1)), ""), + CommitResult::Rejected(_) + ), + "an UPDATE cannot ask for the default" + ); + } + + #[test] + fn a_commit_names_the_row_and_the_value_it_was_read_with() { + let text = column("text", true, None); + let snapshot = saved(Value::Text("old".into())); + + let result = commit_edit(&text, &snapshot, "new"); + + assert_eq!( + result, + CommitResult::Edit(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }) + ); + assert_eq!(commit_edit(&text, &snapshot, "old"), CommitResult::Unchanged); + } + + #[test] + fn a_value_the_column_cannot_hold_is_refused_with_its_reason() { + let integer = column("integer", false, None); + + let CommitResult::Rejected(message) = commit_edit(&integer, &saved(Value::Int(1)), "thirty") else { + panic!("text in an integer column was accepted"); + }; + + assert_eq!(message, "Enter a whole number"); + } + + #[test] + fn a_cell_that_has_no_row_to_write_to_records_nothing() { + let text = column("text", true, None); + let snapshot = EditSnapshot { + row: RowObject::result(vec![Value::Text("a".into())].into()), + column: 0, + original: CellInput::Value(Value::Text("a".into())), + }; + + assert_eq!(commit_edit(&text, &snapshot, "b"), CommitResult::Unchanged); + } + + #[test] + fn the_placeholder_says_what_empty_commits() { + let saved_row = GridRowId::Persisted(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")); + let draft_row = GridRowId::Draft(1); + + assert_eq!(empty_placeholder(&column("text", true, None), &saved_row), None); + assert_eq!( + empty_placeholder(&column("integer", true, None), &saved_row).as_deref(), + Some("NULL") + ); + assert_eq!( + empty_placeholder(&column("integer", false, Some("0")), &draft_row).as_deref(), + Some("DEFAULT") + ); + assert_eq!( + empty_placeholder(&column("integer", false, None), &saved_row).as_deref(), + Some("Required") + ); + } +} diff --git a/linux/crates/app/src/ui/grid/edit_snapshot.rs b/linux/crates/app/src/ui/grid/edit_snapshot.rs index 85fcb26b51..85473a3110 100644 --- a/linux/crates/app/src/ui/grid/edit_snapshot.rs +++ b/linux/crates/app/src/ui/grid/edit_snapshot.rs @@ -1,10 +1,13 @@ +use tablepro_core::edit::CellInput; + use super::RowObject; /// An edit as it stood when it began. The commit goes to `row`, not to -/// whatever row the cell shows by then. +/// whatever row the cell shows by then, and is measured against +/// `original`, the cell's value with any pending edit. #[derive(Debug, Clone)] pub struct EditSnapshot { pub row: RowObject, pub column: usize, - pub original_text: String, + pub original: CellInput, } diff --git a/linux/crates/app/src/ui/grid/grid_cell.rs b/linux/crates/app/src/ui/grid/grid_cell.rs index bbc7433be9..d32c931b7c 100644 --- a/linux/crates/app/src/ui/grid/grid_cell.rs +++ b/linux/crates/app/src/ui/grid/grid_cell.rs @@ -4,11 +4,12 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, glib}; +use tablepro_core::edit::CellInput; use super::cell_binding::CellBinding; use super::column_drawing::ColumnDrawing; -use super::display_text::value_to_edit_text; -use super::{CellEditor, CellLock, EditSnapshot, GridSink, RowObject}; +use super::inline_editor::InlineEditor; +use super::{CellLock, EditSnapshot, GridSink, RowObject}; mod imp { use super::*; @@ -17,7 +18,11 @@ mod imp { pub struct GridCell { pub list_item: glib::WeakRef, pub column: Cell, + /// What the cell draws its value with: an Inscription, or a check + /// button for a boolean column. pub content: OnceCell, + /// The one-line editor, laid over the hidden display while open. + pub inline: RefCell>, /// An open editor popover. Unbind pops it down, or the recycled /// cell is finalized with a GtkPopover child still in it. pub popover: RefCell>, @@ -25,18 +30,11 @@ mod imp { /// Set while the bind draws a check button, which would otherwise /// echo back as an edit of the value it just drew. pub suppress_toggle: Cell, - /// Set while an IME composition is in flight: focus moving to the - /// IME popover ends the edit, and committing then would send the - /// text from before the composition. - pub preedit_active: Cell, pub lock: Cell>, pub drawing: OnceCell>, - /// Where an editor that opens in a popover sends its value. + /// Where the cell's editors send their edits. pub sink: OnceCell, pub binding: RefCell>, - /// The label cannot say what was drawn once an edit has cleared - /// a NULL sentinel from it. - pub drawn_text: RefCell, #[cfg(test)] pub renders: Cell, } @@ -56,6 +54,10 @@ mod imp { impl ObjectImpl for GridCell { fn dispose(&self) { self.binding.take(); + self.edit.take(); + if let Some(editor) = self.inline.take() { + editor.widget().unparent(); + } if let Some(popover) = self.popover.take() { popover.unparent(); } @@ -104,10 +106,24 @@ impl GridCell { self.imp().content.get() } - pub fn editor(&self) -> Option { + /// The text display every cell but a check box draws with. + pub fn display(&self) -> Option { self.content()?.clone().downcast().ok() } + pub fn check_button(&self) -> Option { + self.content()?.clone().downcast().ok() + } + + /// The one-line editor's text field while it is open. + pub fn inline_text(&self) -> Option { + self.imp() + .inline + .borrow() + .as_ref() + .map(|editor| editor.widget().clone()) + } + pub fn row(&self) -> Option { self.imp().list_item.upgrade()?.item().and_downcast() } @@ -122,7 +138,8 @@ impl GridCell { } pub fn is_editing(&self) -> bool { - self.imp().edit.borrow().is_some() || self.imp().popover.borrow().is_some() + let imp = self.imp(); + imp.edit.borrow().is_some() || imp.popover.borrow().is_some() || imp.inline.borrow().is_some() } /// The cell that holds the keyboard focus. A focused column view @@ -143,6 +160,10 @@ impl GridCell { let _ = self.imp().drawing.set(drawing); } + pub(super) fn drawing(&self) -> Option> { + self.imp().drawing.get().cloned() + } + pub(super) fn set_sink(&self, sink: GridSink) { let _ = self.imp().sink.set(sink); } @@ -151,20 +172,6 @@ impl GridCell { self.imp().sink.get().cloned() } - /// The whole value the cell draws, pending edit included, as an - /// editor starts from it. `None` with no row bound or no text form. - pub(super) fn edit_text(&self) -> Option { - let row = self - .imp() - .binding - .borrow() - .as_ref() - .map(|binding| binding.row().clone())?; - let drawing = self.imp().drawing.get()?; - let (value, _) = drawing.tab_ctx.bind_state(&row, self.column()); - value_to_edit_text(&value) - } - pub(super) fn bind(&self, row: &RowObject) { let lock = match self.imp().drawing.get() { Some(drawing) => drawing.lock(row.id()), @@ -182,29 +189,17 @@ impl GridCell { self.cancel_edit(); } - /// The snapshot goes first, so the editor closing sends nothing. - pub(super) fn cancel_edit(&self) { + /// The snapshot goes first, so an editor closing sends nothing. + pub(crate) fn cancel_edit(&self) { self.take_edit(); - if let Some(editor) = self.editor() - && editor.is_editing() - { - editor.stop_editing(false); + if let Some(editor) = self.take_inline() { + editor.close(self); } if let Some(popover) = self.take_popover() { popover.popdown(); } } - /// `true` when `text` differs from what the cell drew before it. - pub(super) fn record_drawn_text(&self, text: &str) -> bool { - let mut drawn = self.imp().drawn_text.borrow_mut(); - if drawn.as_str() == text { - return false; - } - text.clone_into(&mut drawn); - true - } - pub(super) fn render(&self) { #[cfg(test)] self.imp().renders.set(self.imp().renders.get() + 1); @@ -214,7 +209,7 @@ impl GridCell { .borrow() .as_ref() .map(|binding| binding.row().clone()); - let (Some(row), Some(drawing)) = (row, self.imp().drawing.get().cloned()) else { + let (Some(row), Some(drawing)) = (row, self.drawing()) else { return; }; drawing.draw(self, &row); @@ -233,10 +228,43 @@ impl GridCell { self.imp().edit.take() } - pub(super) fn set_popover(&self, popover: >k::Popover) { + /// The value the open edit started from. + pub(super) fn edit_original(&self) -> Option { + self.imp() + .edit + .borrow() + .as_ref() + .map(|snapshot| snapshot.original.clone()) + } + + pub(super) fn set_inline(&self, editor: InlineEditor) { + self.imp().inline.replace(Some(editor)); + } + + pub(super) fn take_inline(&self) -> Option { + self.imp().inline.take() + } + + /// Parent `popover` to the cell and keep it there until it closes. + /// A popover the user closes ends its edit; one the editor took back + /// first has already dealt with it. + pub(super) fn present_popover(&self, popover: >k::Popover) { + popover.set_parent(self); if let Some(previous) = self.imp().popover.replace(Some(popover.clone())) { previous.popdown(); } + // The cell holds the popover in its slot and as its child, so the + // popover holds the cell weakly and itself not at all. + let cell = self.downgrade(); + popover.connect_closed(move |popover| { + popover.unparent(); + if let Some(cell) = cell.upgrade() + && cell.release_popover(popover) + { + cell.take_edit(); + } + }); + popover.popup(); } pub(super) fn take_popover(&self) -> Option { @@ -244,12 +272,14 @@ impl GridCell { } /// Forget `popover` if it is still this cell's, leaving a newer one - /// in place. - pub(super) fn release_popover(&self, popover: >k::Popover) { + /// in place. `true` when it was. + fn release_popover(&self, popover: >k::Popover) -> bool { let mut slot = self.imp().popover.borrow_mut(); if slot.as_ref() == Some(popover) { slot.take(); + return true; } + false } pub(super) fn toggle_suppressed(&self) -> bool { @@ -259,14 +289,6 @@ impl GridCell { pub(super) fn suppress_toggle(&self, suppressed: bool) { self.imp().suppress_toggle.set(suppressed); } - - pub(super) fn preedit_active(&self) -> bool { - self.imp().preedit_active.get() - } - - pub(super) fn set_preedit_active(&self, active: bool) { - self.imp().preedit_active.set(active); - } } #[cfg(test)] @@ -275,22 +297,22 @@ mod tests { use gtk4::prelude::*; use super::GridCell; - use crate::ui::grid::CellEditor; #[gtk4::test] fn from_focus_finds_the_cell_below_a_focused_cell_widget_or_above_its_editor() { let cell_widget = gtk::Box::new(gtk::Orientation::Horizontal, 0); let cell = GridCell::new(); - let editor = CellEditor::new(); - cell.set_content(&editor); + let editor = gtk::Text::new(); + editor.set_parent(&cell); cell_widget.append(&cell); let below = GridCell::from_focus(cell_widget.upcast_ref()); - let above = GridCell::from_focus(editor.entry().upcast_ref()); + let above = GridCell::from_focus(editor.upcast_ref()); assert_eq!(below.as_ref(), Some(&cell)); assert_eq!(above.as_ref(), Some(&cell)); assert!(GridCell::from_focus(gtk::Label::new(None).upcast_ref()).is_none()); + editor.unparent(); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index ef3bcd6655..d6dc45c6d9 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -5,12 +5,16 @@ use gtk4::{self as gtk, gio}; use tablepro_core::column::ResultColumn; -use super::context_menu::{CellContext, CellMenuTarget}; -use super::inline_edit::enter_edit_mode; +use super::cell_edit::begin_edit; +use super::cell_editor_kind::CellEditorKind; +use super::cell_preset::cell_presets; +use super::context_menu::CellContext; use super::menu_rows::menu_rows_of; use super::row_json_dialog::show_row_json_dialog; use super::selection::export_snapshot; -use super::{CellPreset, DeleteAvailability, GridMsg, GridSink, RowObject, TabGridContext, delete_availability}; +use super::{ + CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, TabGridContext, delete_availability, +}; /// Enabled per right-clicked cell. `None` on a read-only grid, which /// registers none of them. @@ -20,20 +24,40 @@ pub(super) struct CellActions { /// dead entry. edit: Option, set_empty: Option, + set_null: Option, + set_default: Option, /// Shown insensitive while disabled, since a menu item cannot carry /// a tooltip to say why. delete_row: Option, } impl CellActions { - /// `rows` is what the menu acts on, read when it opens. - pub(super) fn arm(&self, target: &CellMenuTarget, rows: &[RowObject]) { - if let Some(edit) = &self.edit { - edit.set_enabled(target.text_editable); - } - if let Some(set_empty) = &self.set_empty { - set_empty.set_enabled(target.accepts_empty); - } + /// Set for `cell` and `rows`, what the menu acts on, as it opens. + pub(super) fn arm(&self, cell: &GridCell, rows: &[RowObject]) { + let column = cell.drawing().and_then(|drawing| drawing.catalog.clone()); + let row = cell.row(); + let editor = column + .as_ref() + .map(|column| CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err))); + let presets = column + .as_ref() + .zip(row.as_ref()) + .map(|(column, row)| cell_presets(column, row.id())); + let enable = |action: &Option, enabled: bool| { + if let Some(action) = action { + action.set_enabled(enabled); + } + }; + enable( + &self.edit, + matches!( + editor, + Some(CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar) + ), + ); + enable(&self.set_empty, presets.is_some_and(|presets| presets.empty)); + enable(&self.set_null, presets.is_some_and(|presets| presets.null)); + enable(&self.set_default, presets.is_some_and(|presets| presets.default)); if let Some(delete_row) = &self.delete_row { let availability = delete_availability(rows.iter().map(RowObject::id), false); delete_row.set_enabled(availability == DeleteAvailability::Available); @@ -76,11 +100,23 @@ impl MenuShape { fn build_cell_menu(shape: MenuShape) -> gio::Menu { let menu = gio::Menu::new(); + let edit_section = gio::Menu::new(); + let hidden_while_disabled = |label: String, action: &str| { + let item = gio::MenuItem::new(Some(&label), Some(action)); + item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + edit_section.append_item(&item); + }; if shape.edit_cell { - let edit_section = gio::Menu::new(); - let edit_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Edit cell")), Some("cell.edit")); - edit_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - edit_section.append_item(&edit_item); + hidden_while_disabled(crate::i18n::gettext("Edit cell"), "cell.edit"); + } + if shape.set_value { + // Each is offered only where the column can hold it, so a menu + // never offers a value the save would refuse. + hidden_while_disabled(crate::i18n::gettext("Set to Empty"), "cell.set-empty"); + hidden_while_disabled(crate::i18n::gettext("Set to NULL"), "cell.set-null"); + hidden_while_disabled(crate::i18n::gettext("Set to DEFAULT"), "cell.set-default"); + } + if shape.edit_cell || shape.set_value { menu.append_section(None, &edit_section); } @@ -123,17 +159,6 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { menu.append_section(None, &json_section); let action_section = gio::Menu::new(); - if shape.set_value { - let set_value = gio::Menu::new(); - // Only a free-text column can hold an empty string: elsewhere - // "Empty" would either be rejected by the server or mean NULL, - // which the item below already says plainly. - let empty_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Empty")), Some("cell.set-empty")); - empty_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - set_value.append_item(&empty_item); - set_value.append(Some("NULL"), Some("cell.set-null")); - action_section.append_submenu(Some(&crate::i18n::gettext("Set Value")), &set_value); - } action_section.append(Some(&crate::i18n::gettext("Export Results…")), Some("cell.export")); if shape.row_ops { action_section.append(Some(&crate::i18n::gettext("Insert row")), Some("cell.insert-row")); @@ -226,7 +251,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { }}; } - let edit_action = cell_action!("edit", |cell, _row| enter_edit_mode(cell)); + let edit_action = cell_action!("edit", |cell, _row| begin_edit(cell)); // Plain Copy reads the cell's value, never the widget's text: the // widget carries the display form, which is the `` sentinel, // the `(auto)` placeholder, `` for a blob and a value cut @@ -346,6 +371,11 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { column: cell.column(), preset: CellPreset::Null, }); + let set_default_action = send_action!("set-default", |cell, row| GridMsg::SetCellValue { + row, + column: cell.column(), + preset: CellPreset::Default, + }); let export_action = { let s = sink.clone(); let view = view.clone(); @@ -394,6 +424,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { edit_action, set_empty_action, set_null_action, + set_default_action, copy_row_insert_action, insert_row_action, delete_row_action, @@ -406,6 +437,8 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let actions = CellActions { edit: editable.then(|| simple_action(&group, "edit")).flatten(), set_empty: editable.then(|| simple_action(&group, "set-empty")).flatten(), + set_null: editable.then(|| simple_action(&group, "set-null")).flatten(), + set_default: editable.then(|| simple_action(&group, "set-default")).flatten(), delete_row: editable.then(|| simple_action(&group, "delete-row")).flatten(), }; diff --git a/linux/crates/app/src/ui/grid/grid_msg.rs b/linux/crates/app/src/ui/grid/grid_msg.rs index 17b11b910d..75d08a9b95 100644 --- a/linux/crates/app/src/ui/grid/grid_msg.rs +++ b/linux/crates/app/src/ui/grid/grid_msg.rs @@ -3,6 +3,7 @@ use std::rc::Rc; use tablepro_core::QueryResult; use super::{CellPreset, RowObject}; +use crate::services::tracker_edit::TrackerEdit; /// Where a grid sends its events. The owner holds the grid, so the /// closure holds the owner weakly. @@ -13,19 +14,19 @@ pub enum GridMsg { /// The sorter's state after the click, not a toggle hint: a click on /// another column fires both sort notifies, and a hint would flip twice. SortChanged(usize, bool), - CellEdited { + /// An edit of `column` in `row`, already parsed for the column. + Edit { row: RowObject, column: usize, - input: String, + edit: TrackerEdit, }, CopyToClipboard(String), ShowToast(String), CopyRowsAsInsert { rows: Vec, }, - /// An intent, not a `Value`: the grid does not know the column's - /// declared type, and `Text("")` in an `int` or a `date` column is an - /// UPDATE the server rejects. + /// An intent the tab resolves against the column, so the keyboard + /// path and the menu reach the same value. SetCellValue { row: RowObject, column: usize, diff --git a/linux/crates/app/src/ui/grid/inline_edit.rs b/linux/crates/app/src/ui/grid/inline_edit.rs deleted file mode 100644 index a28c397187..0000000000 --- a/linux/crates/app/src/ui/grid/inline_edit.rs +++ /dev/null @@ -1,222 +0,0 @@ -use std::rc::Rc; - -use gtk4::prelude::*; -use gtk4::{self as gtk, glib}; - -use super::cell_editor_kind::CellEditorKind; -use super::cell_popovers::{ - show_calendar_popover, show_json_popover, show_long_text_popover, show_spin_button_popover, -}; -use super::display_text::is_cut_for_display; -use super::{EditSnapshot, GridCell, GridMsg, GridSink}; - -/// Opens on the whole value, never on the text the cell draws: that -/// is cut short for a long value, and saving it would cut the value. -/// A value too long for the one-line editor opens in a popover. -pub(crate) fn enter_edit_mode(cell: &GridCell) { - if cell.lock().is_some() { - return; - } - let Some(editor) = cell.editor() else { - return; - }; - let Some(text) = editable_text(cell) else { - return; - }; - if !is_cut_for_display(&text) { - editor.start_editing(&text); - return; - } - if let Some(sink) = cell.sink() { - show_long_text_popover(cell, &sink, &text); - } -} - -/// The cell's whole value, or `None` after telling the user why it -/// cannot be typed over. -fn editable_text(cell: &GridCell) -> Option { - let text = cell.edit_text(); - if text.is_none() - && let Some(sink) = cell.sink() - { - sink(GridMsg::ShowToast(crate::i18n::gettext( - "This value has no text form to edit.", - ))); - } - text -} - -/// `child_focus` on the window is what a real Tab press does. -fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { - let Some(root) = widget.root() else { return }; - let Ok(window) = root.dynamic_cast::() else { - return; - }; - window.child_focus(direction); -} - -pub(super) fn install_edit_triggers(cell: &GridCell, sink: GridSink, editor_kind: CellEditorKind) { - let open: Rc = match editor_kind { - CellEditorKind::Text => Rc::new(enter_edit_mode), - CellEditorKind::Date => Rc::new(move |cell| show_calendar_popover(cell, &sink)), - CellEditorKind::Int => Rc::new(move |cell| show_spin_button_popover(cell, &sink, false)), - CellEditorKind::Float => Rc::new(move |cell| show_spin_button_popover(cell, &sink, true)), - CellEditorKind::Json => Rc::new(move |cell| show_json_popover(cell, &sink)), - }; - let trigger = move |cell: &GridCell| { - if cell.lock().is_some() { - return; - } - // The text editor checks for itself, since the menu opens it too. - if editor_kind != CellEditorKind::Text && editable_text(cell).is_none() { - return; - } - open(cell); - }; - let trigger = Rc::new(trigger); - - // Editing takes a double-click, since a single click in a grid - // selects the row. Capture phase, because the column view's row - // selection takes the press in the bubble phase. - // - // The handlers read the cell from their controller: the cell owns - // both controllers, so capturing it would be a cycle. - let gesture = gtk::GestureClick::builder().button(gtk::gdk::BUTTON_PRIMARY).build(); - gesture.set_propagation_phase(gtk::PropagationPhase::Capture); - let trigger_for_press = trigger.clone(); - gesture.connect_pressed(move |gesture, n_press, _, _| { - if n_press != 2 { - return; - } - let Some(cell) = gesture.widget().and_downcast::() else { - return; - }; - // Left to a locked cell's label, which selects a word with it. - if cell.lock().is_some() { - return; - } - gesture.set_state(gtk::EventSequenceState::Claimed); - trigger_for_press(&cell); - }); - cell.add_controller(gesture); - - // While editing, Return and Escape belong to the CellEditor's entry. - let controller = gtk::EventControllerKey::new(); - controller.connect_key_pressed(move |controller, keyval, _, modifiers| { - let Some(cell) = controller.widget().and_downcast::() else { - return glib::Propagation::Proceed; - }; - let Some(editor) = cell.editor() else { - return glib::Propagation::Proceed; - }; - let editing = editor.is_editing(); - let shift = modifiers.contains(gtk::gdk::ModifierType::SHIFT_MASK); - - if !editing { - match keyval { - gtk::gdk::Key::F2 | gtk::gdk::Key::Return | gtk::gdk::Key::KP_Enter => { - trigger(&cell); - return glib::Propagation::Stop; - } - // Left to GTK, Tab leaves the grid for the paginator. - gtk::gdk::Key::Tab if !shift => { - move_focus(&cell, gtk::DirectionType::TabForward); - return glib::Propagation::Stop; - } - gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - move_focus(&cell, gtk::DirectionType::TabBackward); - return glib::Propagation::Stop; - } - // The column view moves between rows only. - gtk::gdk::Key::Right => { - move_focus(&cell, gtk::DirectionType::TabForward); - return glib::Propagation::Stop; - } - gtk::gdk::Key::Left => { - move_focus(&cell, gtk::DirectionType::TabBackward); - return glib::Propagation::Stop; - } - _ => {} - } - return glib::Propagation::Proceed; - } - - match keyval { - gtk::gdk::Key::Tab if !shift => { - editor.stop_editing(true); - move_focus(&cell, gtk::DirectionType::TabForward); - glib::Propagation::Stop - } - gtk::gdk::Key::Tab | gtk::gdk::Key::ISO_Left_Tab if shift => { - editor.stop_editing(true); - move_focus(&cell, gtk::DirectionType::TabBackward); - glib::Propagation::Stop - } - _ => glib::Propagation::Proceed, - } - }); - cell.add_controller(controller); -} - -/// The row is taken when the edit begins, so a commit after rows moved -/// still lands on it. Focus moving to an IME popover ends the edit -/// mid-composition, so the commit waits until the preedit clears. -pub(super) fn install_edit_commit_handler(cell: &GridCell, sink: GridSink) { - let Some(editor) = cell.editor() else { - return; - }; - // The editor is the cell's own child, so its handlers hold the cell - // weakly. - let cell_for_preedit = cell.downgrade(); - let sink_for_preedit = sink.clone(); - editor.entry().connect_preedit_changed(move |_, preedit| { - let Some(cell) = cell_for_preedit.upgrade() else { - return; - }; - let active = !preedit.is_empty(); - cell.set_preedit_active(active); - // The edit ended during the composition, so this is its commit. - if !active && !cell.editor().is_some_and(|editor| editor.is_editing()) { - commit_cell_edit(&cell, &sink_for_preedit); - } - }); - - let cell_for_edit = cell.downgrade(); - editor.connect_editing_notify(move |editor| { - let Some(cell) = cell_for_edit.upgrade() else { - return; - }; - if editor.is_editing() { - if let Some(row) = cell.row() { - cell.begin_edit(EditSnapshot { - row, - column: cell.column(), - original_text: editor.text().to_string(), - }); - } - return; - } - if cell.preedit_active() { - return; - } - commit_cell_edit(&cell, &sink); - }); -} - -fn commit_cell_edit(cell: &GridCell, sink: &GridSink) { - let Some(snapshot) = cell.take_edit() else { - return; - }; - let Some(editor) = cell.editor() else { - return; - }; - let input = editor.text().to_string(); - if input == snapshot.original_text { - return; - } - sink(GridMsg::CellEdited { - row: snapshot.row, - column: snapshot.column, - input, - }); -} diff --git a/linux/crates/app/src/ui/grid/inline_editor.rs b/linux/crates/app/src/ui/grid/inline_editor.rs new file mode 100644 index 0000000000..f5ce4e601f --- /dev/null +++ b/linux/crates/app/src/ui/grid/inline_editor.rs @@ -0,0 +1,146 @@ +use std::cell::Cell; +use std::rc::Rc; + +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; + +use super::edit_commit::{commit_edit, send_commit}; +use super::{EditSnapshot, GridCell}; + +/// How the one-line editor treats what is typed into it. +#[derive(Debug, Clone, PartialEq)] +pub struct InlineOptions { + /// Off for a column a line break means nothing in: a pasted break + /// keeps the first line, as a one-line field does elsewhere. + pub accepts_line_breaks: bool, + pub purpose: gtk::InputPurpose, + /// What an empty commit means, shown while the field is empty. + pub placeholder: Option, +} + +/// A `gtk::Text` laid over the cell's display while an edit is open, and +/// removed when it commits or is cancelled. +#[derive(Debug)] +pub struct InlineEditor { + text: gtk::Text, +} + +impl InlineEditor { + pub(super) fn begin(cell: &GridCell, snapshot: EditSnapshot, initial: &str, options: InlineOptions) { + cell.cancel_edit(); + let text = gtk::Text::new(); + text.set_hexpand(true); + text.set_margin_start(8); + text.set_margin_end(8); + text.add_css_class("tp-cell-text"); + text.set_truncate_multiline(!options.accepts_line_breaks); + text.set_input_purpose(options.purpose); + text.set_placeholder_text(options.placeholder.as_deref()); + text.set_text(initial); + connect_commit(cell, &text); + if let Some(display) = cell.content() { + display.set_visible(false); + } + text.set_parent(cell); + cell.begin_edit(snapshot); + cell.set_inline(Self { text: text.clone() }); + text.grab_focus(); + // The first keystroke replaces the value, as in a Files rename. + text.select_region(0, -1); + } + + pub(super) fn widget(&self) -> >k::Text { + &self.text + } + + /// Take the field out of the cell and show the display again, with + /// the text the field held. + pub(super) fn close(self, cell: &GridCell) -> String { + let typed = self.text.text().to_string(); + self.text.unparent(); + if let Some(display) = cell.content() { + display.set_visible(true); + } + typed + } +} + +/// End the cell's one-line edit. The field leaves before the commit is +/// sent, so the redraw the commit causes meets no open editor. +pub(super) fn finish_inline(cell: &GridCell, commit: bool) { + let Some(editor) = cell.take_inline() else { + return; + }; + let snapshot = cell.take_edit(); + let typed = editor.close(cell); + if !commit { + return; + } + let (Some(snapshot), Some(drawing)) = (snapshot, cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &typed); + send_commit(cell, snapshot, result); +} + +/// The field is the cell's child, so its handlers hold the cell weakly. +fn connect_commit(cell: &GridCell, text: >k::Text) { + let weak = cell.downgrade(); + text.connect_activate(move |_| { + if let Some(cell) = weak.upgrade() { + finish_inline(&cell, true); + } + }); + + // Capture phase, so Escape cancels before the GtkText can take it to + // clear its own buffer. + let keys = gtk::EventControllerKey::new(); + keys.set_propagation_phase(gtk::PropagationPhase::Capture); + let weak = cell.downgrade(); + keys.connect_key_pressed(move |_, keyval, _, _| { + if keyval != gtk::gdk::Key::Escape { + return glib::Propagation::Proceed; + } + let Some(cell) = weak.upgrade() else { + return glib::Propagation::Proceed; + }; + finish_inline(&cell, false); + glib::Propagation::Stop + }); + text.add_controller(keys); + + // Focus moving to an IME popover ends the edit mid-composition, and + // committing then would send the text from before it, so the commit + // waits for the preedit to clear. + let composing = Rc::new(Cell::new(false)); + let commit_when_composed = Rc::new(Cell::new(false)); + let weak = cell.downgrade(); + let preedit_composing = composing.clone(); + let preedit_commit = commit_when_composed.clone(); + text.connect_preedit_changed(move |_, preedit| { + preedit_composing.set(!preedit.is_empty()); + if preedit.is_empty() + && preedit_commit.replace(false) + && let Some(cell) = weak.upgrade() + { + finish_inline(&cell, true); + } + }); + + let focus = gtk::EventControllerFocus::new(); + let weak = cell.downgrade(); + focus.connect_leave(move |_| { + let Some(cell) = weak.upgrade() else { + return; + }; + if composing.get() { + commit_when_composed.set(true); + return; + } + finish_inline(&cell, true); + }); + text.add_controller(focus); +} diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 63c6a1eb4e..879388a93c 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -1,9 +1,9 @@ +mod calendar_editor; mod cell_binding; +mod cell_edit; mod cell_editability; -mod cell_editor; mod cell_editor_kind; mod cell_lock; -mod cell_popovers; mod cell_preset; mod column_drawing; mod column_factory; @@ -11,32 +11,35 @@ mod column_width_binding; mod context_menu; mod delete_availability; mod display_text; +mod edit_commit; mod edit_snapshot; mod grid_cell; mod grid_menus; mod grid_msg; mod grid_row_id; mod grid_row_index; -mod inline_edit; +mod inline_editor; mod menu_rows; +mod multiline_editor; mod row_json_dialog; mod row_object; mod selection; mod tab_grid_context; +mod toggle_cell; +pub(crate) use self::cell_edit::begin_edit; pub(crate) use self::cell_editability::cell_editability; -pub(crate) use self::cell_editor::CellEditor; pub(crate) use self::cell_lock::CellLock; -pub(crate) use self::cell_preset::CellPreset; +pub(crate) use self::cell_preset::{CellPreset, cell_presets}; pub(crate) use self::column_factory::build_column_view; pub(crate) use self::column_width_binding::ColumnWidthBinding; pub(crate) use self::delete_availability::{DeleteAvailability, delete_availability}; +pub(crate) use self::edit_commit::edit_context; pub(crate) use self::edit_snapshot::EditSnapshot; pub(crate) use self::grid_cell::GridCell; pub(crate) use self::grid_msg::{GridMsg, GridSink}; pub(crate) use self::grid_row_id::GridRowId; pub(crate) use self::grid_row_index::GridRowIndex; -pub(crate) use self::inline_edit::enter_edit_mode; pub(crate) use self::row_object::RowObject; pub(crate) use self::selection::{export_snapshot, selected_positions}; pub(crate) use self::tab_grid_context::TabGridContext; diff --git a/linux/crates/app/src/ui/grid/multiline_editor.rs b/linux/crates/app/src/ui/grid/multiline_editor.rs new file mode 100644 index 0000000000..621c36682e --- /dev/null +++ b/linux/crates/app/src/ui/grid/multiline_editor.rs @@ -0,0 +1,107 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk, glib}; +use sourceview5::prelude::*; + +use super::edit_commit::{commit_edit, send_commit}; +use super::{EditSnapshot, GridCell}; + +/// A popover with a source view, for text that spans lines or is too +/// long for a one-line field. Return adds a line, so Save is a button +/// and Ctrl+Return. +pub struct MultilineEditor; + +impl MultilineEditor { + pub(super) fn open(cell: &GridCell, snapshot: EditSnapshot, text: &str, language: Option<&'static str>) { + cell.cancel_edit(); + let buffer = sourceview5::Buffer::new(None); + if let Some(language) = language.and_then(|id| sourceview5::LanguageManager::default().language(id)) { + buffer.set_language(Some(&language)); + } + buffer.set_text(text); + let view = sourceview5::View::with_buffer(&buffer); + view.set_monospace(true); + view.set_wrap_mode(gtk::WrapMode::WordChar); + view.set_auto_indent(true); + crate::ui::style_scheme_resolver::apply(&view, "Adwaita"); + + let scrolled = gtk::ScrolledWindow::builder() + .child(&view) + .min_content_width(420) + .min_content_height(280) + .has_frame(true) + .build(); + let save = gtk::Button::with_label(&crate::i18n::gettext("Save")); + save.add_css_class("suggested-action"); + let cancel = gtk::Button::with_label(&crate::i18n::gettext("Cancel")); + let buttons = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(8) + .halign(gtk::Align::End) + .build(); + buttons.append(&cancel); + buttons.append(&save); + let content = gtk::Box::builder() + .orientation(gtk::Orientation::Vertical) + .spacing(8) + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + content.append(&scrolled); + content.append(&buttons); + let popover = gtk::Popover::builder().child(&content).build(); + + let weak_popover = popover.downgrade(); + cancel.connect_clicked(move |_| { + if let Some(popover) = weak_popover.upgrade() { + popover.popdown(); + } + }); + let weak_cell = cell.downgrade(); + save.connect_clicked(move |_| { + if let Some(cell) = weak_cell.upgrade() { + save_text(&cell, &buffer); + } + }); + let save_shortcut = gtk::ShortcutController::new(); + let weak_save = save.downgrade(); + save_shortcut.add_shortcut(gtk::Shortcut::new( + Some(gtk::KeyvalTrigger::new( + gtk::gdk::Key::Return, + gtk::gdk::ModifierType::CONTROL_MASK, + )), + Some(gtk::CallbackAction::new(move |_, _| { + if let Some(save) = weak_save.upgrade() { + save.emit_clicked(); + } + glib::Propagation::Stop + })), + )); + view.add_controller(save_shortcut); + + cell.begin_edit(snapshot); + cell.present_popover(&popover); + view.grab_focus(); + } +} + +/// The popover closes before the tab hears of the value, so an edit the +/// tab redraws the row for never meets an open popover. +fn save_text(cell: &GridCell, buffer: &sourceview5::Buffer) { + let Some(snapshot) = cell.take_edit() else { + return; + }; + let text = buffer.text(&buffer.start_iter(), &buffer.end_iter(), true).to_string(); + if let Some(popover) = cell.take_popover() { + popover.popdown(); + } + let Some(drawing) = cell.drawing() else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let result = commit_edit(column, &snapshot, &text); + send_commit(cell, snapshot, result); +} diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index 3886b53147..799f8c6e04 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -1,4 +1,5 @@ use tablepro_core::Value; +use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; use super::{ColumnWidthBinding, GridRowId, RowObject}; @@ -56,14 +57,26 @@ impl TabGridContext { .collect() } - pub(super) fn bind_state(&self, row: &RowObject, col: usize) -> (Value, Vec<&'static str>) { + /// What an editor opens on: a draft's cell as the tracker holds it, + /// a saved row's pending value, else the value as it was read. + pub(crate) fn edit_input(&self, row: &RowObject, col: usize) -> CellInput { + self.bind_state(row, col).0 + } + + pub(super) fn bind_state(&self, row: &RowObject, col: usize) -> (CellInput, Vec<&'static str>) { let raw = row.cell(col).cloned().unwrap_or(Value::Null); let Some((tracker, key)) = self.tracked_key(row) else { - return (raw, Vec::new()); + return (CellInput::Value(raw), Vec::new()); }; let overlay = tracker.cell_overlay(&key, col, &raw); let classes = pending_classes(tracker.row_state(&key), &overlay, col); - (overlay.value, classes) + let input = match row.id() { + GridRowId::Draft(draft_id) => tracker + .draft_input(*draft_id, col) + .unwrap_or(CellInput::Value(overlay.value)), + GridRowId::Result | GridRowId::Unkeyed | GridRowId::Persisted(_) => CellInput::Value(overlay.value), + }; + (input, classes) } } @@ -84,6 +97,7 @@ fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Ve #[cfg(test)] mod tests { use tablepro_core::Value; + use tablepro_core::edit::CellInput; use super::{RowObject, TabGridContext}; use crate::services::change_tracker::RowKey; @@ -98,7 +112,9 @@ mod tests { } fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { - let delta = tracker.apply(TrackerEdit::Insert { values }); + let delta = tracker.apply(TrackerEdit::InsertDraft { + cells: values.into_iter().map(CellInput::Value).collect(), + }); let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); }; @@ -113,7 +129,11 @@ mod tests { let (value, classes) = context(&tracker).bind_state(&RowObject::draft(draft_id), 0); - assert_eq!(value, Value::Int(4), "a draft's cells are read from the tracker"); + assert_eq!( + value, + CellInput::Value(Value::Int(4)), + "a draft's cells are read from the tracker" + ); assert_eq!(classes, vec!["tp-row-pending-insert", "tp-row-leftmost-error-flash"]); } @@ -123,7 +143,7 @@ mod tests { let (value, classes) = TabGridContext::default().bind_state(&row, 0); - assert_eq!(value, Value::Int(1)); + assert_eq!(value, CellInput::Value(Value::Int(1))); assert!(classes.is_empty()); } @@ -149,4 +169,21 @@ mod tests { vec![Value::Null, Value::Text("draft".into())] ); } + + #[test] + fn a_draft_cell_left_to_the_server_opens_as_the_default() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::InsertDraft { + cells: vec![CellInput::Default, CellInput::Value(Value::Null)], + }); + let draft = RowObject::draft(delta.inserted_drafts[0]); + + assert_eq!(context(&tracker).edit_input(&draft, 0), CellInput::Default); + assert_eq!(context(&tracker).edit_input(&draft, 1), CellInput::Value(Value::Null)); + assert_eq!( + context(&tracker).effective_cells(&draft), + vec![Value::Null, Value::Null], + "a copy has no value to give a default" + ); + } } diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs new file mode 100644 index 0000000000..cc895344bc --- /dev/null +++ b/linux/crates/app/src/ui/grid/toggle_cell.rs @@ -0,0 +1,163 @@ +use gtk4::prelude::*; +use gtk4::{self as gtk}; +use tablepro_core::Value; +use tablepro_core::column::{BoolStorage, ColumnKind}; +use tablepro_core::edit::CellInput; + +use super::display_text::value_to_display_text; +use super::edit_commit::{commit_input, send_commit}; +use super::{EditSnapshot, GridCell}; + +/// A boolean cell: a check button that commits as it is toggled. +pub struct ToggleCell; + +/// How a check button shows a cell's value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ToggleState { + pub active: bool, + /// A value that is neither true nor false shows as neither, with the + /// value itself in the tooltip, rather than as a confidently wrong + /// box. + pub inconsistent: bool, + pub tooltip: Option, +} + +impl ToggleCell { + pub(super) fn setup(cell: &GridCell) { + let check = gtk::CheckButton::builder() + .halign(gtk::Align::Start) + .valign(gtk::Align::Center) + .margin_start(8) + .margin_end(8) + .build(); + cell.set_content(&check); + // The cell owns the check button, so the handler finds the cell + // through its parent rather than holding it. + check.connect_toggled(|check| { + let Some(cell) = check.parent().and_downcast::() else { + return; + }; + if !cell.toggle_suppressed() && cell.lock().is_none() { + commit_toggle(&cell, check.is_active()); + } + }); + } + + /// Flip the cell from the keyboard, as a click would. + pub(super) fn toggle(cell: &GridCell) { + if let Some(check) = cell.check_button() { + check.set_active(!check.is_active()); + } + } + + pub(super) fn draw(cell: &GridCell, check: >k::CheckButton, state: &ToggleState) { + // Suppressed, so drawing the value does not echo back as an edit. + cell.suppress_toggle(true); + check.set_inconsistent(state.inconsistent); + check.set_active(state.active); + cell.suppress_toggle(false); + check.set_tooltip_text(state.tooltip.as_deref()); + } +} + +pub fn toggle_state(input: &CellInput, storage: BoolStorage) -> ToggleState { + let neither = |tooltip: String| ToggleState { + active: false, + inconsistent: true, + tooltip: Some(tooltip), + }; + let shown = |active| ToggleState { + active, + inconsistent: false, + tooltip: None, + }; + match (input, storage) { + (CellInput::Default, _) => neither("DEFAULT".to_owned()), + (CellInput::Value(Value::Null), _) => neither("NULL".to_owned()), + (CellInput::Value(Value::Bool(flag)), _) => shown(*flag), + (CellInput::Value(Value::Int(0)), BoolStorage::Integer) => shown(false), + (CellInput::Value(Value::Int(1)), BoolStorage::Integer) => shown(true), + (CellInput::Value(other), _) => neither(value_to_display_text(other)), + } +} + +/// `tinyint(1)` keeps any integer, so a toggle writes 0 or 1 as one. +pub fn toggled_value(storage: BoolStorage, active: bool) -> Value { + match storage { + BoolStorage::Native => Value::Bool(active), + BoolStorage::Integer => Value::Int(i64::from(active)), + } +} + +fn commit_toggle(cell: &GridCell, active: bool) { + let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { + return; + }; + let Some(column) = drawing.catalog.as_ref() else { + return; + }; + let ColumnKind::Bool(storage) = column.column_type.kind else { + return; + }; + let snapshot = EditSnapshot { + original: drawing.tab_ctx.edit_input(&row, cell.column()), + row, + column: cell.column(), + }; + let result = commit_input(column, &snapshot, CellInput::Value(toggled_value(storage, active))); + send_commit(cell, snapshot, result); +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::column::BoolStorage; + use tablepro_core::edit::CellInput; + + use super::{ToggleState, toggle_state, toggled_value}; + + fn state(active: bool, inconsistent: bool, tooltip: Option<&str>) -> ToggleState { + ToggleState { + active, + inconsistent, + tooltip: tooltip.map(str::to_owned), + } + } + + #[test] + fn a_toggle_shows_only_true_and_false_as_checked_or_not() { + let value = |value| CellInput::Value(value); + + assert_eq!( + toggle_state(&value(Value::Bool(true)), BoolStorage::Native), + state(true, false, None) + ); + assert_eq!( + toggle_state(&value(Value::Int(1)), BoolStorage::Integer), + state(true, false, None) + ); + assert_eq!( + toggle_state(&value(Value::Int(0)), BoolStorage::Integer), + state(false, false, None) + ); + assert_eq!( + toggle_state(&value(Value::Int(2)), BoolStorage::Integer), + state(false, true, Some("2")) + ); + assert_eq!( + toggle_state(&value(Value::Null), BoolStorage::Native), + state(false, true, Some("NULL")) + ); + assert_eq!( + toggle_state(&CellInput::Default, BoolStorage::Integer), + state(false, true, Some("DEFAULT")) + ); + } + + #[test] + fn a_toggle_writes_the_columns_own_form() { + assert_eq!(toggled_value(BoolStorage::Native, true), Value::Bool(true)); + assert_eq!(toggled_value(BoolStorage::Integer, true), Value::Int(1)); + assert_eq!(toggled_value(BoolStorage::Integer, false), Value::Int(0)); + } +} diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index ec6ca36ac0..8964a96662 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -448,6 +448,7 @@ fn failing_source(plan: &SavePlan, changes: &TrackedChanges, error: &DriverError #[cfg(test)] mod tests { + use tablepro_core::edit::CellInput; use tablepro_core::meta::TableRef; use tablepro_core::{CommitToken, LossPhase, Value}; use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; @@ -591,8 +592,11 @@ mod tests { let columns = vec![integer("id", true), integer("n", false)]; let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); let tracker = TabTrackerHandle::default(); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(10), Value::Int(1)], + let _ = tracker.apply(TrackerEdit::InsertDraft { + cells: vec![Value::Int(10), Value::Int(1)] + .into_iter() + .map(CellInput::Value) + .collect(), }); let _ = tracker.apply(TrackerEdit::Cell { row_key: edited.clone(), diff --git a/linux/crates/app/tests/tracker_save_postgres.rs b/linux/crates/app/tests/tracker_save_postgres.rs index d944cf3a30..ad5bf62422 100644 --- a/linux/crates/app/tests/tracker_save_postgres.rs +++ b/linux/crates/app/tests/tracker_save_postgres.rs @@ -14,6 +14,7 @@ use secrecy::SecretString; use tablepro_app::change_tracker::{RowKey, pk_identity}; use tablepro_app::tab_tracker_handle::TabTrackerHandle; use tablepro_app::tracker_edit::TrackerEdit; +use tablepro_core::edit::CellInput; use tablepro_core::meta::TableRef; use tablepro_core::save::plan_save; use tablepro_core::statement::{GuardedStatement, Statement, StatementBuilder}; @@ -106,8 +107,11 @@ async fn replace_by_unique_key_saves() { )], drafts: Vec::new(), }); - let _ = tracker.apply(TrackerEdit::Insert { - values: vec![Value::Int(2), Value::Text("a".to_owned())], + let _ = tracker.apply(TrackerEdit::InsertDraft { + cells: vec![Value::Int(2), Value::Text("a".to_owned())] + .into_iter() + .map(CellInput::Value) + .collect(), }); let changes = tracker.change_set(&columns).expect("a change set"); diff --git a/linux/data/resources/style.css b/linux/data/resources/style.css index 37704ef1e3..d8075fc5c5 100644 --- a/linux/data/resources/style.css +++ b/linux/data/resources/style.css @@ -26,16 +26,16 @@ textview.sql-editor, textview.sql-editor text { /* Italic only: the dim-label Adwaita class added alongside already supplies the opacity. */ -label.tp-null-sentinel { +.tp-null-sentinel { font-style: italic; } /* GtkColumnView's default cell focus is a 1px outline that vanishes against the selected-row highlight. A 2px inset accent ring is the - spreadsheet-standard focused-cell signal. The selectors stay explicit - so the ring never stacks on a GtkCheckButton, which paints its own. */ -columnview > listview > row > cell:focus-within > tp-grid-cell > label, -columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor { + spreadsheet-standard focused-cell signal. The selector names the text + display and its editor, so the ring never stacks on a GtkCheckButton, + which paints its own. */ +columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-text { box-shadow: inset 0 0 0 2px var(--accent-color); border-radius: 2px; } @@ -71,8 +71,7 @@ columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor color: var(--error-fg-color); } - columnview > listview > row > cell:focus-within > tp-grid-cell > label, - columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-editor { + columnview > listview > row > cell:focus-within > tp-grid-cell > .tp-cell-text { box-shadow: inset 0 0 0 3px var(--accent-color); } } diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 15f2a75b27..e0d941a53b 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -125,6 +125,7 @@ crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor_font_provider.rs crates/app/src/ui/error_text/ddl.rs crates/app/src/ui/error_text/driver.rs +crates/app/src/ui/error_text/edit.rs crates/app/src/ui/error_text/mod.rs crates/app/src/ui/error_text/operation.rs crates/app/src/ui/error_text/script.rs @@ -136,12 +137,12 @@ crates/app/src/ui/error_text/tls_remedy.rs crates/app/src/ui/export_dialog.rs crates/app/src/ui/file_read.rs crates/app/src/ui/filter_strip.rs +crates/app/src/ui/grid/calendar_editor.rs crates/app/src/ui/grid/cell_binding.rs +crates/app/src/ui/grid/cell_edit.rs crates/app/src/ui/grid/cell_editability.rs -crates/app/src/ui/grid/cell_editor.rs crates/app/src/ui/grid/cell_editor_kind.rs crates/app/src/ui/grid/cell_lock.rs -crates/app/src/ui/grid/cell_popovers.rs crates/app/src/ui/grid/cell_preset.rs crates/app/src/ui/grid/column_drawing.rs crates/app/src/ui/grid/column_factory.rs @@ -149,19 +150,22 @@ crates/app/src/ui/grid/column_width_binding.rs crates/app/src/ui/grid/context_menu.rs crates/app/src/ui/grid/delete_availability.rs crates/app/src/ui/grid/display_text.rs +crates/app/src/ui/grid/edit_commit.rs crates/app/src/ui/grid/edit_snapshot.rs crates/app/src/ui/grid/grid_cell.rs crates/app/src/ui/grid/grid_menus.rs crates/app/src/ui/grid/grid_msg.rs crates/app/src/ui/grid/grid_row_id.rs crates/app/src/ui/grid/grid_row_index.rs -crates/app/src/ui/grid/inline_edit.rs +crates/app/src/ui/grid/inline_editor.rs crates/app/src/ui/grid/menu_rows.rs crates/app/src/ui/grid/mod.rs +crates/app/src/ui/grid/multiline_editor.rs crates/app/src/ui/grid/row_json_dialog.rs crates/app/src/ui/grid/row_object.rs crates/app/src/ui/grid/selection.rs crates/app/src/ui/grid/tab_grid_context.rs +crates/app/src/ui/grid/toggle_cell.rs crates/app/src/ui/handler_guard.rs crates/app/src/ui/history_dialog.rs crates/app/src/ui/icons.rs From 80dee6ef531181488c4088e157c6353bc2f8e0f2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:27:54 +0700 Subject: [PATCH 010/301] feat(linux): run as TableProApplication with New Window and app quit An adw::Application subclass replaces RelmApp and the flock gate: a second launch hands over to the running instance on D-Bus, and --new-window opens a window there. Quit asks each window through its close prompts and never calls g_application_quit, so the process ends only once every close and save hold has dropped. --- linux/ARCHITECTURE.md | 3 +- linux/CHANGELOG.md | 3 + linux/crates/app/Cargo.toml | 12 + linux/crates/app/clippy.toml | 11 +- .../crates/app/src/application/app_actions.rs | 171 ++++++ .../app/src/application/app_services.rs | 176 +++++++ .../app/src/application/app_window_host.rs | 34 ++ linux/crates/app/src/application/imp.rs | 111 ++++ linux/crates/app/src/application/mod.rs | 124 +++++ .../app/src/application/quit_sequence.rs | 54 ++ linux/crates/app/src/application/tests.rs | 308 +++++++++++ .../crates/app/src/application/window_host.rs | 13 + .../app/src/application/window_launch.rs | 8 + .../app/src/application/window_registry.rs | 70 +++ linux/crates/app/src/lib.rs | 162 +----- linux/crates/app/src/main.rs | 16 +- .../app/src/runtime/gio_process_hold.rs | 51 +- linux/crates/app/src/runtime/mod.rs | 2 +- linux/crates/app/src/services/mod.rs | 1 - .../app/src/services/single_instance.rs | 126 ----- linux/crates/app/src/ui/app/connection.rs | 17 +- linux/crates/app/src/ui/app/mod.rs | 154 +++--- linux/crates/app/src/ui/app/status_pages.rs | 22 +- linux/crates/app/src/ui/mod.rs | 9 +- .../crates/app/src/workspace/close_guards.rs | 121 ++++- .../crates/app/src/workspace/close_reason.rs | 11 + linux/crates/app/src/workspace/mod.rs | 4 + .../app/src/workspace/window_inhibitor.rs | 20 + .../crates/app/tests/application_lifecycle.rs | 497 ++++++++++++++++++ .../app/tests/application_uniqueness.rs | 293 +++++++++++ linux/crates/app/tests/startup.rs | 6 +- linux/crates/storage/src/paths.rs | 4 - .../test-support/src/fake_connection.rs | 11 + .../data/app.tablepro.TablePro.desktop.in.in | 5 + linux/data/resources/shortcuts-dialog.ui | 10 +- linux/docs/storage.md | 1 - linux/po/POTFILES.in | 12 +- 37 files changed, 2264 insertions(+), 389 deletions(-) create mode 100644 linux/crates/app/src/application/app_actions.rs create mode 100644 linux/crates/app/src/application/app_services.rs create mode 100644 linux/crates/app/src/application/app_window_host.rs create mode 100644 linux/crates/app/src/application/imp.rs create mode 100644 linux/crates/app/src/application/mod.rs create mode 100644 linux/crates/app/src/application/quit_sequence.rs create mode 100644 linux/crates/app/src/application/tests.rs create mode 100644 linux/crates/app/src/application/window_host.rs create mode 100644 linux/crates/app/src/application/window_launch.rs create mode 100644 linux/crates/app/src/application/window_registry.rs delete mode 100644 linux/crates/app/src/services/single_instance.rs create mode 100644 linux/crates/app/src/workspace/close_reason.rs create mode 100644 linux/crates/app/tests/application_lifecycle.rs create mode 100644 linux/crates/app/tests/application_uniqueness.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index c9677a9dfe..7e77cec489 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -85,7 +85,7 @@ Bridging uses Relm4's built-in primitives instead of hand-rolled channels: - `relm4::spawn(async move { ... })`: a fire-and-forget tokio task with no component lifetime tie. Used for storage writes (`touch_last_opened`, `query_history::record`, column-width persistence) and other side-effect work. - `sender.input(AppMsg::...)` from inside an async block routes back into the component's `update` on the GTK thread. Combined with `sender.command`, this is how a "fetch-then-render" round trip lands its result on the right widget. -`main.rs` builds a tiny `tokio::runtime::Builder::new_multi_thread().worker_threads(1)` runtime exclusively to `block_on` the history-DB init / prune at startup, then `shutdown_timeout`s it before `RelmApp::run` takes over. The query-history sqlx pool is stored in a `OnceLock` and reused from Relm4's runtime afterward. +`run()` builds the app's own tokio runtime (`AppRuntime`) and hands its `Tasks` to `TableProApplication`, an `adw::Application` subclass that owns the windows, the app actions and D-Bus uniqueness. Its startup opens the query history on that runtime. Quit asks each window through its close prompts and never calls `g_application_quit`, so the process ends when the last window and the last close or save hold are gone, and the runtime shuts down after that. ## UI architecture: Relm4 @@ -241,7 +241,6 @@ The Structure tab is **definition + draft**, not a per-change log. The `TableDef | Application preferences | GSettings | schema `app.tablepro.TablePro` | | Window size / maximized | GSettings | schema `app.tablepro.TablePro` | | Per-table column widths | JSON, durable write | `$XDG_CONFIG_HOME/tablepro/column_widths.json` | -| Single-instance lock | `flock` on an empty file | `$XDG_RUNTIME_DIR/app//tablepro.lock` | `WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index f800e3429d..39910a499c 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. - Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. - Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. +- New Window (Ctrl+Shift+N, or `tablepro --new-window`) opens another window in the running TablePro. ### Changed @@ -28,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. - Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. - Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. +- Quit asks each window about its unsaved changes in turn, and waits for open connections to close. ### Fixed @@ -39,6 +41,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. - Saving a SQL Server row with a char or varchar key locked the whole table until the save ended. - Stop could not end a SQLite statement waiting for another connection's lock. +- Launching TablePro while it was running did nothing instead of showing the open window. - A SQLite save that failed in a way SQLite rolls back itself closed the connection and its temporary tables. - A SQLite script ending in an unclosed comment showed later results under the first statement's columns. - SQLite text containing a NUL character exported as SQL that SQLite cannot read. diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index 06e852c9cd..852f2f7dbb 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -28,6 +28,18 @@ name = "logging" path = "tests/logging.rs" harness = false +# Both start the application, which GTK allows only on the process's +# main thread, so they run without the test harness. +[[test]] +name = "application_uniqueness" +path = "tests/application_uniqueness.rs" +harness = false + +[[test]] +name = "application_lifecycle" +path = "tests/application_lifecycle.rs" +harness = false + [features] default = ["kerberos"] kerberos = ["tablepro-driver-mssql/kerberos"] diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml index f3596aa3af..be994fc95e 100644 --- a/linux/crates/app/clippy.toml +++ b/linux/crates/app/clippy.toml @@ -34,11 +34,20 @@ disallowed-methods = [ # Blocking the GTK main loop freezes every window, and quitting from # under it skips the shutdown that flushes pending writes. { path = "gtk4::glib::MainContext::block_on", reason = "await in glib::spawn_future_local instead of blocking the main loop" }, - { path = "gio::prelude::ApplicationExt::quit", reason = "close the window so shutdown runs; run() shuts the runtime down after" }, + { path = "gio::prelude::ApplicationExt::quit", reason = "use TableProApplication::request_quit, which closes each window through its prompts" }, # A window can close while work it started is still on its way back. # These panic on a component that has shut down; deliver drops the # message instead. { path = "relm4::ComponentSender::input", reason = "use crate::runtime::DeliverInput::deliver" }, { path = "relm4::FactorySender::input", reason = "use crate::runtime::DeliverInput::deliver" }, + + # TableProApplication owns the windows and the app actions. Relm4's + # own application builds a second one that knows neither. + { path = "relm4::RelmApp::run", reason = "open windows with TableProApplication::open_window" }, + { path = "relm4::main_application", reason = "take the application from the window or its WindowHost" }, + { path = "relm4::main_adw_application", reason = "take the application from the window or its WindowHost" }, + { path = "relm4::actions::RelmActionGroup::register_for_main_application", reason = "add actions with add_action_entries on the window or the application" }, + { path = "relm4::ComponentBuilder::transient_for", reason = "relm4 applies it only from RelmApp; present dialogs on their parent window" }, + { path = "relm4::ComponentBuilder::transient_for_native", reason = "relm4 applies it only from RelmApp; present dialogs on their parent window" }, ] diff --git a/linux/crates/app/src/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs new file mode 100644 index 0000000000..8be2924807 --- /dev/null +++ b/linux/crates/app/src/application/app_actions.rs @@ -0,0 +1,171 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use super::{TableProApplication, WindowLaunch}; +use crate::ui::PreferencesDialog; + +/// The application-wide shortcuts. No window or tab binds these keys: +/// GTK runs application accelerators before a focused widget sees the +/// key. AdwApplication adds `app.shortcuts` on `question` from +/// `shortcuts-dialog.ui`. +pub const APP_ACCELS: &[(&str, &[&str])] = &[ + ("app.new-window", &["n"]), + ("app.quit", &["q"]), + ("app.preferences", &["comma"]), +]; + +pub fn install_app_actions(app: &TableProApplication) { + let new_window = gio::ActionEntry::builder("new-window") + .activate(|app: &TableProApplication, _, _| { + app.open_window(WindowLaunch::Picker); + }) + .build(); + let quit = gio::ActionEntry::builder("quit") + .activate(|app: &TableProApplication, _, _| app.request_quit()) + .build(); + let preferences = gio::ActionEntry::builder("preferences") + .activate(|app: &TableProApplication, _, _| present_preferences(app)) + .build(); + let about = gio::ActionEntry::builder("about") + .activate(|app: &TableProApplication, _, _| about_dialog().present(app.active_window().as_ref())) + .build(); + app.add_action_entries([new_window, quit, preferences, about]); + for (action, accels) in APP_ACCELS { + app.set_accels_for_action(action, accels); + } +} + +fn present_preferences(app: &TableProApplication) { + let Some(services) = app.services() else { + return; + }; + PreferencesDialog::new( + &services.settings, + &services.storage, + services.history().store(), + &services.tasks, + ) + .present(app.active_window().as_ref()); +} + +fn about_dialog() -> adw::AboutDialog { + let dialog = adw::AboutDialog::builder() + .application_name(crate::i18n::gettext("TablePro")) + .application_icon(crate::config::APP_ID) + .developer_name(crate::i18n::gettext("TablePro Authors")) + .version(crate::config::VERSION) + .website("https://github.com/TableProApp/TablePro") + .issue_url("https://github.com/TableProApp/TablePro/issues") + .support_url("https://github.com/TableProApp/TablePro/discussions") + .copyright(crate::i18n::gettext("© 2025–2026 TablePro Authors")) + .license_type(gtk::License::Agpl30) + .comments(crate::i18n::gettext( + "A native Linux database client built with GTK4 + libadwaita.", + )) + .build(); + dialog.set_developers(&["TablePro Authors https://github.com/TableProApp/TablePro"]); + dialog.set_translator_credits(&crate::i18n::gettext("translator-credits")); + dialog +} + +#[cfg(test)] +mod tests { + use std::rc::Rc; + use std::time::Duration; + + use gtk::gdk; + use tablepro_session::runtime::CountingHold; + + use super::*; + use crate::test_support::MemorySettings; + use crate::ui::{BrowseTab, SqlEditor, SqlEditorInit, install_window_shortcuts}; + + fn parse(accel: &str) -> (gdk::Key, gdk::ModifierType) { + let (key, modifiers) = gtk::accelerator_parse(accel).expect("an accelerator GTK can parse"); + (key.to_lower(), modifiers) + } + + fn keyval_triggers(trigger: >k::ShortcutTrigger, into: &mut Vec<(gdk::Key, gdk::ModifierType)>) { + if let Some(keyval) = trigger.downcast_ref::() { + into.push((keyval.keyval().to_lower(), keyval.modifiers())); + } else if let Some(pair) = trigger.downcast_ref::() { + keyval_triggers(&pair.first(), into); + keyval_triggers(&pair.second(), into); + } + } + + /// Every key a shortcut controller on `widget` or below it binds. + fn bound_keys(widget: >k::Widget) -> Vec<(gdk::Key, gdk::ModifierType)> { + let mut keys = Vec::new(); + let mut widgets = crate::test_support::descendants(widget); + widgets.push(widget.clone()); + for widget in widgets { + let controllers = widget.observe_controllers(); + for index in 0..controllers.n_items() { + let Some(controller) = controllers.item(index).and_downcast::() else { + continue; + }; + for position in 0..controller.n_items() { + if let Some(trigger) = controller + .item(position) + .and_downcast::() + .and_then(|shortcut| shortcut.trigger()) + { + keyval_triggers(&trigger, &mut keys); + } + } + } + } + keys + } + + #[gtk4::test] + fn app_accels_do_not_collide_with_window_accels() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let window = adw::ApplicationWindow::builder().build(); + install_window_shortcuts(&window); + let browse: BrowseTab = glib::Object::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: crate::services::active_session::ActiveSession::new(), + hold: Rc::new(CountingHold::new()), + close_timeout: Duration::from_secs(1), + }); + let mut bound = bound_keys(window.upcast_ref()); + bound.extend(bound_keys(browse.upcast_ref())); + bound.extend(bound_keys(editor.upcast_ref())); + assert!( + bound.contains(&parse("BackSpace")), + "the grid's Set NULL key was not found, so the check reads nothing" + ); + + let reserved = APP_ACCELS + .iter() + .flat_map(|(_, accels)| accels.iter()) + .chain(std::iter::once(&"question")); + for accel in reserved { + assert!( + !bound.contains(&parse(accel)), + "{accel} is bound in a window or a tab as well as by the application" + ); + } + drop(editor); + runtime.shutdown(); + } + + #[test] + fn app_accels_name_app_actions() { + for (action, accels) in APP_ACCELS { + assert!(action.starts_with("app."), "{action}"); + assert!(!accels.is_empty(), "{action} has no accelerator"); + } + } +} diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs new file mode 100644 index 0000000000..550a46a937 --- /dev/null +++ b/linux/crates/app/src/application/app_services.rs @@ -0,0 +1,176 @@ +use std::cell::OnceCell; +use std::path::Path; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4::glib::prelude::IsA; +use tablepro_core::{ConnectEnvironment, DriverRegistry, LivenessPolicy}; +use tablepro_session::operation::{DeadlinePolicy, DefaultDeadlines}; +use tablepro_session::runtime::{ProcessHold, Tasks}; +use tablepro_session::{ConnectServices, Connector, DriverConnector, SessionRegistry}; +use tablepro_ssh::SshServices; +use tablepro_storage::{AppSettings, SecretStore, StoragePaths}; + +use crate::config; +use crate::runtime::GioProcessHold; +use crate::services::active_session::ActiveSession; +use crate::services::history_service::{HistoryService, publish_retention}; +use crate::services::session_context::SessionContext; +use crate::storage::{AppStorage, SharedStorage}; +use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; + +/// What every window of the application shares. GTK-thread only. +pub struct AppServices { + pub drivers: Arc, + pub connector: Arc, + pub deadlines: Arc, + pub liveness: LivenessPolicy, + pub sessions: Rc, + pub settings: Rc, + pub storage: SharedStorage, + pub tasks: Tasks, + /// Bound to the application once it exists, which is after this. + hold: Rc, + history: OnceCell, + ssh: Option, +} + +/// What `AppServices::new` builds from, for a caller that brings its own +/// connector and storage. +pub struct AppServicesParts { + pub drivers: DriverRegistry, + pub connector: Arc, + pub deadlines: Arc, + pub liveness: LivenessPolicy, + pub settings: Rc, + pub storage: AppStorage, + pub tasks: Tasks, +} + +impl AppServices { + pub fn new(parts: AppServicesParts) -> Self { + let AppServicesParts { + drivers, + connector, + deadlines, + liveness, + settings, + storage, + tasks, + } = parts; + Self { + drivers: Arc::new(drivers), + connector, + deadlines, + liveness, + sessions: SessionRegistry::new(), + settings, + storage: Rc::new(storage), + tasks, + hold: Rc::new(GioProcessHold::default()), + history: OnceCell::new(), + ssh: None, + } + } + + /// A second launch builds this too before it hands its command line + /// to the running instance, so what only the primary may do waits + /// for `start`. + pub fn production(drivers: DriverRegistry, settings: Rc, paths: StoragePaths, tasks: Tasks) -> Self { + let secrets = Arc::new(SecretStore::new(config::secret_schema())); + let storage = AppStorage::new(paths, secrets, &tasks); + let ssh = SshServices::system(storage.paths().runtime.clone(), Path::new(config::LIBEXECDIR)); + let liveness = LivenessPolicy::DESKTOP; + let deadlines: Arc = Arc::new(DefaultDeadlines::new(liveness)); + let connector = DriverConnector::new( + ConnectServices { + environment: ConnectEnvironment::new(config::VERSION), + ssh: ssh.clone(), + liveness, + }, + deadlines.clone(), + ); + let mut services = Self::new(AppServicesParts { + drivers, + connector: Arc::new(connector), + deadlines, + liveness, + settings, + storage, + tasks, + }); + services.ssh = Some(ssh); + services + } + + /// The work only the primary instance does, from its startup. + /// + /// A missing askpass helper is logged rather than fatal: only SSH + /// needs it, and the connect that tries says so in its own error. The + /// sweep ends masters a crashed instance left behind, which would + /// otherwise keep their tunnels open until the next reboot. + pub(crate) fn start(&self) { + drop(self.history()); + let Some(ssh) = self.ssh.clone() else { + return; + }; + if let Err(error) = ssh.verify_askpass_helper() { + tracing::warn!(%error, "SSH connections will fail until the askpass helper is installed"); + } + drop(self.tasks.spawn_task(async move { + let ended = tablepro_ssh::sweep_stale_masters(&ssh).await; + if ended > 0 { + tracing::info!(ended, "ended SSH masters a previous instance left running"); + } + })); + } + + /// The database migrates and prunes on open, which is too slow to + /// hold up the first window. The menu items that need it stay + /// disabled until the service reports Ready. + pub(crate) fn history(&self) -> HistoryService { + self.history + .get_or_init(|| { + let retention = publish_retention(&self.settings); + HistoryService::start(self.storage.paths().clone(), retention, &self.tasks) + }) + .clone() + } + + pub(crate) fn hold(&self) -> Rc { + self.hold.clone() + } + + pub(crate) fn bind_application(&self, application: &impl IsA) { + self.hold.bind(application); + } + + /// One window's view of the shared services: its own session slot, + /// and its own credential prompts, which it shows over itself. + pub(crate) fn window_context(&self) -> (Rc, PromptQueue) { + let (prompter, prompts) = GtkCredentialPrompter::new(); + let context = SessionContext { + drivers: self.drivers.clone(), + connector: self.connector.clone(), + deadlines: self.deadlines.clone(), + prompter: Arc::new(prompter), + secrets: self.storage.secrets().clone(), + sessions: self.sessions.clone(), + hold: self.hold(), + tasks: self.tasks.clone(), + liveness: self.liveness, + active: ActiveSession::new(), + }; + (Rc::new(context), prompts) + } +} + +impl std::fmt::Debug for AppServices { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AppServices") + .field("drivers", &self.drivers.len()) + .field("sessions", &self.sessions) + .field("history", &self.history.get().is_some()) + .finish_non_exhaustive() + } +} diff --git a/linux/crates/app/src/application/app_window_host.rs b/linux/crates/app/src/application/app_window_host.rs new file mode 100644 index 0000000000..0d11383211 --- /dev/null +++ b/linux/crates/app/src/application/app_window_host.rs @@ -0,0 +1,34 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; + +use super::{TableProApplication, WindowHost, WindowLaunch}; + +/// The application keeps every window's controller, so a window that +/// held it strongly would keep it alive past its own shutdown. +#[derive(Debug)] +pub struct AppWindowHost { + app: glib::WeakRef, +} + +impl AppWindowHost { + pub fn new(app: &TableProApplication) -> Self { + Self { app: app.downgrade() } + } +} + +impl WindowHost for AppWindowHost { + fn present_window(&self, window_id: u32) -> bool { + self.app.upgrade().is_some_and(|app| app.present_window(window_id)) + } + + fn open_window(&self, launch: WindowLaunch) { + if let Some(app) = self.app.upgrade() { + app.open_window(launch); + } + } + + fn application(&self) -> Option { + self.app.upgrade().map(|app| app.upcast()) + } +} diff --git a/linux/crates/app/src/application/imp.rs b/linux/crates/app/src/application/imp.rs new file mode 100644 index 0000000000..85ba4f279d --- /dev/null +++ b/linux/crates/app/src/application/imp.rs @@ -0,0 +1,111 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::ops::ControlFlow; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::{AppServices, WindowLaunch, WindowRegistry, install_app_actions}; +use crate::workspace::CloseDecision; + +pub(super) const NEW_WINDOW_OPTION: &str = "new-window"; + +#[derive(Default)] +pub struct TableProApplication { + pub(super) services: OnceCell>, + pub(super) windows: WindowRegistry, + /// The quit in progress, if any. + pub(super) quit: RefCell>>, + /// The window the quit is waiting on. + pub(super) quit_asking: Cell>, +} + +#[glib::object_subclass] +impl ObjectSubclass for TableProApplication { + const NAME: &'static str = "TpApplication"; + type Type = super::TableProApplication; + type ParentType = adw::Application; +} + +impl ObjectImpl for TableProApplication { + fn constructed(&self) { + self.parent_constructed(); + // GApplication parses the command line before it emits + // handle-local-options, so an option added there is unknown. + self.obj().add_main_option( + NEW_WINDOW_OPTION, + glib::Char::from(b'n'), + glib::OptionFlags::NONE, + glib::OptionArg::None, + &crate::i18n::gettext("Open a new window"), + None, + ); + } +} + +impl ApplicationImpl for TableProApplication { + fn startup(&self) { + self.parent_startup(); + let app = self.obj(); + install_app_actions(&app); + if let Some(services) = self.services.get() { + services.start(); + } + app.connect_query_end(|app| { + app.present_inhibiting_window(); + }); + } + + fn activate(&self) { + self.parent_activate(); + let app = self.obj(); + if let Some(window) = app.active_window() { + window.present(); + return; + } + app.open_window(WindowLaunch::Picker); + } + + /// `--new-window` on a second launch opens the window in the running + /// instance and exits. On the first launch it falls through to + /// activate, which opens the one window either way. + fn handle_local_options(&self, options: &glib::VariantDict) -> ControlFlow { + if !options.contains(NEW_WINDOW_OPTION) { + return ControlFlow::Continue(()); + } + let app = self.obj(); + if let Err(error) = app.register(gio::Cancellable::NONE) { + tracing::error!(%error, "could not register with the session bus"); + return ControlFlow::Break(glib::ExitCode::FAILURE); + } + if app.is_remote() { + app.activate_action("new-window", None); + return ControlFlow::Break(glib::ExitCode::SUCCESS); + } + ControlFlow::Continue(()) + } + + fn shutdown(&self) { + self.windows.clear(); + drop(self.quit.take()); + self.parent_shutdown(); + } +} + +impl GtkApplicationImpl for TableProApplication { + fn window_removed(&self, window: >k::Window) { + let id = window + .downcast_ref::() + .map(|window| window.id()); + self.parent_window_removed(window); + if let Some(id) = id { + drop(self.windows.remove(id)); + } + } +} + +impl AdwApplicationImpl for TableProApplication {} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs new file mode 100644 index 0000000000..91899aa063 --- /dev/null +++ b/linux/crates/app/src/application/mod.rs @@ -0,0 +1,124 @@ +//! The GApplication: one process per profile, the actions every window +//! shares, and the windows themselves. + +mod app_actions; +mod app_services; +mod app_window_host; +mod imp; +mod quit_sequence; +mod window_host; +mod window_launch; +mod window_registry; + +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use relm4::Component; + +pub use app_actions::{APP_ACCELS, install_app_actions}; +pub use app_services::{AppServices, AppServicesParts}; +pub use app_window_host::AppWindowHost; +pub use quit_sequence::QuitSequence; +pub use window_host::WindowHost; +pub use window_launch::WindowLaunch; +pub(crate) use window_registry::WindowRegistry; + +use crate::config; +use crate::ui::{App, AppInit}; + +glib::wrapper! { + pub struct TableProApplication(ObjectSubclass) + @extends adw::Application, gtk::Application, gio::Application, + @implements gio::ActionGroup, gio::ActionMap; +} + +impl TableProApplication { + pub fn new(services: AppServices) -> Self { + let app: Self = glib::Object::builder() + .property("application-id", config::APP_ID) + .property("resource-base-path", config::RESOURCE_BASE_PATH) + .build(); + services.bind_application(&app); + // Only `new` sets it, and it has just built the object. + let _ = app.imp().services.set(Rc::new(services)); + app + } + + /// `None` only for an application built without `new`. + pub fn services(&self) -> Option> { + self.imp().services.get().cloned() + } + + /// Open a window on `launch` and show it. `None` when the + /// application has not started yet, since GTK takes windows only + /// after startup. + pub fn open_window(&self, launch: WindowLaunch) -> Option { + let Some(services) = self.services() else { + tracing::warn!("no window opened: the application has no services"); + return None; + }; + let builder = App::builder(); + let window = builder.root.clone(); + // The window gets its id when it is added, and the component + // claims its session under that id from init on. + self.add_window(&window); + if window.application().is_none() { + tracing::warn!("no window opened: the application has not started"); + window.destroy(); + return None; + } + let host: Rc = Rc::new(AppWindowHost::new(self)); + let controller = builder.launch(AppInit { services, host, launch }).detach(); + self.imp().windows.insert(window.id(), controller); + window.present(); + Some(window) + } + + /// False when no window has that id. + pub fn present_window(&self, window_id: u32) -> bool { + let Some(window) = self.window_by_id(window_id) else { + return false; + }; + window.present(); + true + } + + /// Ask every window to close, in turn. While one is asking, another + /// request brings that window forward instead of starting over. + pub fn request_quit(&self) { + let imp = self.imp(); + if imp.quit.borrow().is_some() { + if let Some(asking) = imp.quit_asking.get() { + self.present_window(asking); + } + return; + } + let app = self.clone(); + let running = glib::spawn_future_local(async move { + let decision = QuitSequence::run(app.clone()).await; + app.imp().quit.take(); + decision + }); + imp.quit.replace(Some(running)); + } + + /// Bring forward the first window that holds the session back, so + /// the user sees what a logout would lose. Returns its id. + pub(crate) fn present_inhibiting_window(&self) -> Option { + let imp = self.imp(); + let window = self + .windows() + .into_iter() + .filter_map(|window| window.downcast::().ok()) + .find(|window| imp.windows.holds_inhibit(window.id()))?; + window.present(); + Some(window.id()) + } +} + +#[cfg(test)] +mod tests; diff --git a/linux/crates/app/src/application/quit_sequence.rs b/linux/crates/app/src/application/quit_sequence.rs new file mode 100644 index 0000000000..a1d4142743 --- /dev/null +++ b/linux/crates/app/src/application/quit_sequence.rs @@ -0,0 +1,54 @@ +use futures::channel::oneshot; +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::TableProApplication; +use crate::ui::AppMsg; +use crate::workspace::{CloseDecision, CloseReason}; + +/// Asks each window in turn whether it may close, the way closing it by +/// hand would. +/// +/// It never calls `g_application_quit`, which ignores the hold count: +/// the process ends once the last window is gone and the last save, +/// close and teardown hold has dropped. +pub struct QuitSequence; + +impl QuitSequence { + pub async fn run(app: TableProApplication) -> CloseDecision { + let mut asked: Vec = Vec::new(); + let decision = loop { + let Some(window) = app.windows().into_iter().find(|window| !asked.contains(window)) else { + break CloseDecision::Approved; + }; + asked.push(window.clone()); + if ask(&app, &window).await == CloseDecision::Cancelled { + break CloseDecision::Cancelled; + } + }; + app.imp().quit_asking.set(None); + decision + } +} + +async fn ask(app: &TableProApplication, window: >k::Window) -> CloseDecision { + let sender = window + .downcast_ref::() + .and_then(|window| app.imp().windows.sender(window.id())); + let Some(sender) = sender else { + // Not one of the session windows, so it has nothing to ask. + window.close(); + return CloseDecision::Approved; + }; + if let Some(window) = window.downcast_ref::() { + app.imp().quit_asking.set(Some(window.id())); + } + window.present(); + let (reply, answer) = oneshot::channel(); + if sender.send(AppMsg::CloseRequested(CloseReason::Quit(reply))).is_err() { + return CloseDecision::Approved; + } + // A window torn down before it answered has nothing left to keep. + answer.await.unwrap_or(CloseDecision::Approved) +} diff --git a/linux/crates/app/src/application/tests.rs b/linux/crates/app/src/application/tests.rs new file mode 100644 index 0000000000..1a84d9d567 --- /dev/null +++ b/linux/crates/app/src/application/tests.rs @@ -0,0 +1,308 @@ +use std::cell::Cell; +use std::ops::ControlFlow; +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use libadwaita as adw; +use tablepro_core::{DriverRegistry, LivenessPolicy}; +use tablepro_session::fakes::FakeConnector; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::StoragePaths; +use tablepro_test_support::FakeSecretVault; + +use super::*; +use crate::storage::AppStorage; +use crate::test_support::{MemorySettings, finish_on_main, wait_until}; +use crate::workspace::{CloseDecision, InhibitReason, InhibitTarget, WindowInhibitor}; + +const WAIT: Duration = Duration::from_secs(10); + +/// What fake services need kept alive for as long as the test runs. +struct Backing { + runtime: Option, + _settings: MemorySettings, + _dir: tempfile::TempDir, +} + +impl Drop for Backing { + fn drop(&mut self) { + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn fake_services() -> (AppServices, Backing) { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + let liveness = LivenessPolicy::DESKTOP; + let services = AppServices::new(AppServicesParts { + drivers: DriverRegistry::new(), + connector: Arc::new(FakeConnector::new([])), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings: settings.get().clone(), + storage, + tasks: runtime.tasks(), + }); + let backing = Backing { + runtime: Some(runtime), + _settings: settings, + _dir: dir, + }; + (services, backing) +} + +/// An application over fakes, under an id of its own so it never meets +/// another test's application, or a TablePro the developer has open, on +/// the session bus. +struct TestApp { + app: TableProApplication, + _backing: Backing, +} + +impl TestApp { + fn new(name: &str) -> Self { + let (services, backing) = fake_services(); + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!("{}.Test.{name}", crate::config::SCHEMA_ID))); + Self { app, _backing: backing } + } + + fn started(name: &str) -> Self { + let test = Self::new(name); + test.app + .register(gio::Cancellable::NONE) + .expect("register the application"); + test + } + + fn window(&self) -> adw::ApplicationWindow { + self.app.open_window(WindowLaunch::Picker).expect("a window") + } +} + +impl Drop for TestApp { + fn drop(&mut self) { + for window in self.app.windows() { + window.destroy(); + } + crate::test_support::drain_main_context(); + } +} + +/// Grants every inhibition, as a session manager would. +struct GrantingSession; + +impl InhibitTarget for GrantingSession { + fn inhibit(&self, _flags: gtk4::ApplicationInhibitFlags, _reason: &str) -> u32 { + 1 + } + + fn uninhibit(&self, _cookie: u32) {} +} + +#[gtk4::test] +fn new_builds_on_the_configured_id_and_resources() { + let (services, _backing) = fake_services(); + + let app = TableProApplication::new(services); + + assert_eq!(app.application_id().as_deref(), Some(crate::config::APP_ID)); + assert_eq!( + app.resource_base_path().as_deref(), + Some(crate::config::RESOURCE_BASE_PATH) + ); + assert!(app.services().is_some()); + assert!(glib::Object::new::().services().is_none()); +} + +#[gtk4::test] +fn new_window_option_registered_before_parse() { + let test = TestApp::new("Parse"); + let seen = Rc::new(Cell::new(false)); + let sink = seen.clone(); + test.app.connect_handle_local_options(move |_, options| { + sink.set(options.contains("new-window")); + ControlFlow::Break(glib::ExitCode::SUCCESS) + }); + + let code = test.app.run_with_args(&["tablepro", "--new-window"]); + + assert_eq!(code, glib::ExitCode::SUCCESS, "GApplication refused --new-window"); + assert!(seen.get(), "handle-local-options did not see --new-window"); +} + +#[gtk4::test] +fn handle_local_options_new_window_on_primary_continues() { + let test = TestApp::new("Primary"); + let options = glib::VariantDict::new(None); + options.insert_value("new-window", &true.to_variant()); + + let flow = test.app.imp().handle_local_options(&options); + + assert!(matches!(flow, ControlFlow::Continue(())), "{flow:?}"); + assert!(test.app.is_registered() && !test.app.is_remote()); +} + +#[gtk4::test] +fn handle_local_options_without_new_window_does_not_register() { + let test = TestApp::new("Plain"); + + let flow = test.app.imp().handle_local_options(&glib::VariantDict::new(None)); + + assert!(matches!(flow, ControlFlow::Continue(())), "{flow:?}"); + assert!(!test.app.is_registered()); +} + +#[gtk4::test] +fn startup_installs_the_app_actions_and_their_accels() { + let test = TestApp::started("Actions"); + + for action in ["new-window", "quit", "preferences", "about"] { + assert!(test.app.lookup_action(action).is_some(), "app.{action} is missing"); + } + for (action, _) in APP_ACCELS { + assert!(!test.app.accels_for_action(action).is_empty(), "{action} has no accel"); + } +} + +#[gtk4::test] +fn activate_opens_one_picker_and_then_presents_it() { + let test = TestApp::started("Activate"); + + test.app.activate(); + test.app.activate(); + + assert_eq!(test.app.windows().len(), 1); + assert_eq!(test.app.imp().windows.len(), 1); +} + +#[gtk4::test] +fn new_window_action_opens_another_window() { + let test = TestApp::started("NewWindow"); + test.window(); + + test.app.activate_action("new-window", None); + + assert_eq!(test.app.windows().len(), 2); + assert_eq!(test.app.imp().windows.len(), 2); +} + +#[gtk4::test] +fn a_window_before_startup_is_refused() { + let test = TestApp::new("Early"); + + assert!(test.app.open_window(WindowLaunch::Picker).is_none()); + assert_eq!(test.app.imp().windows.len(), 0); +} + +#[gtk4::test] +fn a_removed_window_takes_its_controller_with_it() { + let test = TestApp::started("Removed"); + let window = test.window(); + assert_eq!(test.app.imp().windows.len(), 1); + + window.destroy(); + + assert_eq!(test.app.imp().windows.len(), 0); +} + +#[gtk4::test] +fn present_window_finds_the_window_by_id() { + let test = TestApp::started("Present"); + let window = test.window(); + window.set_visible(false); + + assert!(test.app.present_window(window.id())); + assert!(window.is_visible()); + assert!(!test.app.present_window(window.id() + 100)); +} + +#[gtk4::test] +fn query_end_presents_inhibiting_window() { + let test = TestApp::started("QueryEnd"); + let idle = test.window(); + let busy = test.window(); + for window in [&idle, &busy] { + window.set_visible(false); + } + test.app + .imp() + .windows + .with_model(busy.id(), |model| { + let mut inhibitor = WindowInhibitor::new(GrantingSession); + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + model.set_inhibitor(inhibitor); + }) + .expect("the busy window's component"); + + test.app.emit_by_name::<()>("query-end", &[]); + + assert!(busy.is_visible(), "the window holding logout back stayed hidden"); + assert!(!idle.is_visible(), "a window with nothing to lose was brought forward"); + assert_eq!(test.app.present_inhibiting_window(), Some(busy.id())); +} + +#[gtk4::test] +fn quit_closes_every_window_through_its_close_flow() { + let test = TestApp::started("Quit"); + let first = test.window(); + let second = test.window(); + let requests = Rc::new(Cell::new(0)); + for window in [&first, &second] { + let requests = requests.clone(); + window.connect_close_request(move |_| { + requests.set(requests.get() + 1); + glib::Propagation::Proceed + }); + } + + let decision = finish_on_main(WAIT, QuitSequence::run(test.app.clone())).expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + assert_eq!(requests.get(), 2, "a window closed without a close request"); + assert!(!first.is_visible() && !second.is_visible()); + assert!(test.app.windows().is_empty()); + assert_eq!(test.app.imp().windows.len(), 0); + assert_eq!(test.app.imp().quit_asking.get(), None); +} + +#[gtk4::test] +fn request_quit_runs_once_and_clears_when_done() { + let test = TestApp::started("RequestQuit"); + test.window(); + + test.app.request_quit(); + assert!(test.app.imp().quit.borrow().is_some()); + wait_until(WAIT, || test.app.imp().quit.borrow().is_none()).expect("the quit to finish"); + + assert!(test.app.windows().is_empty()); +} + +#[gtk4::test] +fn a_second_quit_request_brings_the_asking_window_forward() { + let test = TestApp::started("QuitAgain"); + let asking = test.window(); + asking.set_visible(false); + let pending = glib::spawn_future_local(std::future::pending::()); + test.app.imp().quit.replace(Some(pending)); + test.app.imp().quit_asking.set(Some(asking.id())); + + test.app.request_quit(); + + assert!(asking.is_visible(), "the window the quit waits on stayed hidden"); + assert_eq!(test.app.windows().len(), 1, "a second quit started over"); + if let Some(pending) = test.app.imp().quit.take() { + pending.abort(); + } +} diff --git a/linux/crates/app/src/application/window_host.rs b/linux/crates/app/src/application/window_host.rs new file mode 100644 index 0000000000..e4a0876b66 --- /dev/null +++ b/linux/crates/app/src/application/window_host.rs @@ -0,0 +1,13 @@ +use gtk4 as gtk; + +use super::WindowLaunch; + +/// What a window may ask of the application that hosts it, so a window +/// reaches the others without holding the application. +pub trait WindowHost { + /// False when no window has that id any more. + fn present_window(&self, window_id: u32) -> bool; + fn open_window(&self, launch: WindowLaunch); + /// `None` once the application is gone. + fn application(&self) -> Option; +} diff --git a/linux/crates/app/src/application/window_launch.rs b/linux/crates/app/src/application/window_launch.rs new file mode 100644 index 0000000000..e31ff2bf9b --- /dev/null +++ b/linux/crates/app/src/application/window_launch.rs @@ -0,0 +1,8 @@ +use tablepro_storage::SavedConnection; + +/// What a new window opens on. +#[derive(Debug, Clone)] +pub enum WindowLaunch { + Picker, + Connect(Box), +} diff --git a/linux/crates/app/src/application/window_registry.rs b/linux/crates/app/src/application/window_registry.rs new file mode 100644 index 0000000000..8c43cf7a23 --- /dev/null +++ b/linux/crates/app/src/application/window_registry.rs @@ -0,0 +1,70 @@ +use std::cell::RefCell; +use std::collections::HashMap; + +use relm4::{ComponentController, Controller}; + +use crate::ui::{App, AppMsg}; + +/// The controller of each window the application opened, by window id. +/// GTK-thread only. +#[derive(Default)] +pub(crate) struct WindowRegistry { + controllers: RefCell>>, +} + +impl WindowRegistry { + pub(crate) fn insert(&self, id: u32, controller: Controller) { + self.controllers.borrow_mut().insert(id, controller); + } + + /// The caller drops the controller after the map is released, since + /// dropping it stops the window's component. + pub(crate) fn remove(&self, id: u32) -> Option> { + self.controllers.borrow_mut().remove(&id) + } + + pub(crate) fn sender(&self, id: u32) -> Option> { + self.controllers + .borrow() + .get(&id) + .map(|controller| controller.sender().clone()) + } + + /// Whether the window holds logout back. + /// + /// The state is read outside any update: the components run their + /// updates without returning to the main loop, which is the only + /// place this is called from. + pub(crate) fn holds_inhibit(&self, id: u32) -> bool { + self.controllers + .borrow() + .get(&id) + .is_some_and(|controller| controller.model().is_inhibiting()) + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.controllers.borrow().len() + } + + #[cfg(test)] + pub(crate) fn with_model(&self, id: u32, change: impl FnOnce(&mut App) -> R) -> Option { + let controllers = self.controllers.borrow(); + let controller = controllers.get(&id)?; + let mut parts = controller.state().get_mut(); + Some(change(&mut parts.model)) + } + + pub(crate) fn clear(&self) { + let controllers: Vec> = self.controllers.borrow_mut().drain().map(|(_, c)| c).collect(); + drop(controllers); + } +} + +impl std::fmt::Debug for WindowRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("WindowRegistry") + .field("windows", &self.controllers.borrow().keys().collect::>()) + .finish() + } +} diff --git a/linux/crates/app/src/lib.rs b/linux/crates/app/src/lib.rs index 19ecb0945d..36aadccb26 100644 --- a/linux/crates/app/src/lib.rs +++ b/linux/crates/app/src/lib.rs @@ -1,17 +1,12 @@ -use std::path::Path; use std::rc::Rc; use std::sync::Arc; -use libadwaita as adw; -use relm4::RelmApp; use thiserror::Error; -use tablepro_core::{ConnectEnvironment, DriverRegistry, LivenessPolicy}; -use tablepro_session::operation::{DeadlinePolicy, DefaultDeadlines}; -use tablepro_session::runtime::Tasks; -use tablepro_session::{ConnectServices, DriverConnector, SessionRegistry}; -use tablepro_ssh::SshServices; +use tablepro_core::DriverRegistry; +use tablepro_storage::{AppSettings, StoragePaths}; +pub mod application; pub mod config; pub mod i18n; pub mod logging; @@ -26,148 +21,46 @@ mod test_support; mod ui; mod workspace; +use application::{AppServices, TableProApplication}; + #[derive(Debug, Error)] -enum StartupError { +pub enum StartupError { + #[error("could not start logging: {0}")] + Logging(#[from] logging::LoggingError), #[error("could not start the background runtime: {0}")] Runtime(#[source] std::io::Error), #[error("could not register the embedded resources: {0}")] Resources(#[source] glib::Error), #[error("could not open the settings schema: {0}")] Settings(#[from] tablepro_storage::SettingsError), - #[error("could not start logging: {0}")] - Logging(#[from] logging::LoggingError), } -pub fn run() -> glib::ExitCode { +/// Start the application and return its exit code once the last window +/// and the last background close are gone. +pub fn run() -> Result { // SAFETY: nothing above spawns a thread; the tokio runtime and GTK start later. let translations = unsafe { i18n::init() }; - - if let Err(error) = logging::init(config::profile()) { - // Nothing is journalling yet, so this is the one place that has - // to reach the session log by another route. - glib::g_critical!("tablepro", "{error}"); - return glib::ExitCode::FAILURE; - } + logging::init(config::profile())?; if let Err(error) = translations { tracing::warn!(%error, "translations unavailable; falling back to the source strings"); } - match start() { - Ok(()) => glib::ExitCode::SUCCESS, - Err(error) => { - tracing::error!(%error, "TablePro could not start"); - glib::ExitCode::FAILURE - } - } -} - -fn start() -> Result<(), StartupError> { - // Single-instance gate: belt-and-suspenders flock on top of - // gtk::Application's DBus-based uniqueness, since the latter - // silently lets two processes through when DBus is unavailable. - // A second instance would write the same state files beside this - // one. Hold the lock through the entire `start`. - let paths = tablepro_storage::StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); - - let _instance_lock = match services::single_instance::acquire(&paths) { - Ok(lock) => Some(lock), - Err(services::single_instance::LockError::AlreadyRunning) => { - tracing::info!("another TablePro instance is running; exiting"); - return Ok(()); - } - Err(e) => { - // No XDG runtime, cache or HOME: proceed without the - // lock. gtk::Application's uniqueness still applies. - tracing::warn!(error = %e, "single-instance lock unavailable; relying on DBus uniqueness"); - None - } - }; - - let settings = std::rc::Rc::new(tablepro_storage::AppSettings::open(config::SCHEMA_ID)?); let runtime = runtime::AppRuntime::build().map_err(StartupError::Runtime)?; - let tasks = runtime.tasks(); - // The database migrates and prunes on open, which is too slow to - // hold up the first window. The menu item that needs it stays - // disabled until the service reports Ready. - let retention = services::history_service::publish_retention(&settings); - let history = services::history_service::HistoryService::start(paths.clone(), retention, &tasks); - let storage = std::rc::Rc::new(storage::AppStorage::new( - paths, - std::sync::Arc::new(tablepro_storage::SecretStore::new(config::secret_schema())), - &tasks, - )); - - let drivers = Arc::new(build_registry()); - tracing::info!(drivers = drivers.len(), "starting tablepro"); - gio::resources_register_include!("tablepro.gresource").map_err(StartupError::Resources)?; + let settings = Rc::new(AppSettings::open(config::SCHEMA_ID)?); + let paths = StoragePaths::from_glib(config::storage_dir_name(), config::APP_ID); + let drivers = build_registry(); + tracing::info!(drivers = drivers.len(), "starting tablepro"); - let application = adw::Application::builder() - .application_id(config::APP_ID) - .resource_base_path(config::RESOURCE_BASE_PATH) - .build(); - let ssh = ssh_services(&storage.paths().runtime, &tasks); - let liveness = LivenessPolicy::DESKTOP; - let deadlines: Arc = Arc::new(DefaultDeadlines::new(liveness)); - let connector = DriverConnector::new( - ConnectServices { - environment: ConnectEnvironment::new(config::VERSION), - ssh, - liveness, - }, - deadlines.clone(), - ); - let (prompter, prompts) = ui::credential_prompt::GtkCredentialPrompter::new(); - let context = Rc::new(services::session_context::SessionContext { - drivers, - connector: Arc::new(connector), - deadlines, - prompter: Arc::new(prompter), - secrets: storage.secrets().clone(), - sessions: SessionRegistry::new(), - hold: Rc::new(runtime::GioProcessHold::new(&application)), - tasks: tasks.clone(), - liveness, - active: services::active_session::ActiveSession::new(), - }); - - let app = RelmApp::from_app(application); - app.run::(ui::AppInit { - context, - prompts, - settings, - storage, - tasks, - history, - }); + let application = TableProApplication::new(AppServices::production(drivers, settings, paths, runtime.tasks())); + let code = gio::prelude::ApplicationExtManual::run(&application); + drop(application); // The main loop has returned, so nothing is producing work any // more. Give what is in flight a moment to finish rather than // dropping it mid-write. runtime.shutdown(); - Ok(()) -} - -/// The system OpenSSH client, with its control sockets under the app's -/// runtime directory. -/// -/// A missing askpass helper is logged rather than fatal: only SSH needs -/// it, and the connect that tries says so in its own error. The sweep -/// ends masters a crashed instance left behind, which would otherwise -/// keep their tunnels open until the next reboot. -fn ssh_services(runtime_dir: &Path, tasks: &Tasks) -> SshServices { - let services = SshServices::system(runtime_dir.to_path_buf(), Path::new(config::LIBEXECDIR)); - if let Err(error) = services.verify_askpass_helper() { - tracing::warn!(%error, "SSH connections will fail until the askpass helper is installed"); - } - let sweeping = services.clone(); - drop(tasks.spawn_task(async move { - let ended = tablepro_ssh::sweep_stale_masters(&sweeping).await; - if ended > 0 { - tracing::info!(ended, "ended SSH masters a previous instance left running"); - } - })); - services + Ok(code) } fn build_registry() -> DriverRegistry { @@ -190,11 +83,10 @@ pub(crate) fn register_test_resources() { #[cfg(test)] mod tests { - use gio::prelude::ApplicationExt; - - use super::register_test_resources; use glib::prelude::ObjectExt; + use libadwaita as adw; + use super::register_test_resources; use super::*; #[test] @@ -222,14 +114,4 @@ mod tests { assert_eq!(dialog.type_().name(), "AdwShortcutsDialog"); } - - #[gtk4::test] - fn application_uses_explicit_resource_base_path() { - let app = adw::Application::builder() - .application_id(config::APP_ID) - .resource_base_path(config::RESOURCE_BASE_PATH) - .build(); - - assert_eq!(app.resource_base_path().as_deref(), Some(config::RESOURCE_BASE_PATH)); - } } diff --git a/linux/crates/app/src/main.rs b/linux/crates/app/src/main.rs index bcd9e1d404..9e7afed97e 100644 --- a/linux/crates/app/src/main.rs +++ b/linux/crates/app/src/main.rs @@ -1,3 +1,17 @@ +use tablepro_app::StartupError; + fn main() -> glib::ExitCode { - tablepro_app::run() + match tablepro_app::run() { + Ok(code) => code, + Err(StartupError::Logging(error)) => { + // Nothing is journalling yet, so this is the one place that + // has to reach the session log by another route. + glib::g_critical!("tablepro", "{error}"); + glib::ExitCode::FAILURE + } + Err(error) => { + tracing::error!(%error, "TablePro could not start"); + glib::ExitCode::FAILURE + } + } } diff --git a/linux/crates/app/src/runtime/gio_process_hold.rs b/linux/crates/app/src/runtime/gio_process_hold.rs index d9a0ce38b5..fc60a0c6cf 100644 --- a/linux/crates/app/src/runtime/gio_process_hold.rs +++ b/linux/crates/app/src/runtime/gio_process_hold.rs @@ -1,39 +1,40 @@ +use glib::prelude::*; use tablepro_session::runtime::{HoldToken, ProcessHold}; /// Keeps the GApplication running while a token is alive, so closing /// the last window does not end the process with a connection still /// closing behind it. -#[derive(Debug, Clone)] +/// +/// It holds the application weakly: the application owns the services +/// this lives in, and a token taken once the application is gone has +/// nothing left to keep alive. +#[derive(Debug, Clone, Default)] pub struct GioProcessHold { - application: gio::Application, + application: glib::WeakRef, } impl GioProcessHold { - pub fn new(application: &impl glib::prelude::IsA) -> Self { - Self { - application: application.as_ref().clone(), - } + pub fn new(application: &impl IsA) -> Self { + let hold = Self::default(); + hold.bind(application); + hold } - /// The hold for whichever application is running, or none in a test - /// that never started one. - pub fn for_default_application() -> Option { - gio::Application::default().map(|application| Self { application }) + /// Point the hold at `application`. Tokens taken before this hold + /// nothing. + pub fn bind(&self, application: &impl IsA) { + self.application.set(Some(application.upcast_ref())); } } impl ProcessHold for GioProcessHold { fn acquire(&self) -> HoldToken { - HoldToken::new(gio::prelude::ApplicationExtManual::hold(&self.application)) + self.application.upgrade().map_or_else(HoldToken::inert, |application| { + HoldToken::new(gio::prelude::ApplicationExtManual::hold(&application)) + }) } } -/// A hold on the running application when there is one, and an inert -/// token otherwise. GTK-thread only, like the application itself. -pub fn hold_default_application() -> HoldToken { - GioProcessHold::for_default_application().map_or_else(HoldToken::inert, |hold| hold.acquire()) -} - #[cfg(test)] mod tests { use super::*; @@ -48,4 +49,20 @@ mod tests { let again = hold.acquire(); drop(again); } + + #[gtk4::test] + fn a_hold_without_an_application_gives_inert_tokens() { + let unbound = GioProcessHold::default(); + drop(unbound.acquire()); + + let application = gio::Application::new(None, gio::ApplicationFlags::NON_UNIQUE); + let hold = GioProcessHold::new(&application); + drop(application); + + assert!( + hold.application.upgrade().is_none(), + "the hold kept the application alive" + ); + drop(hold.acquire()); + } } diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index 60887bc330..7ac5e97b19 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -12,7 +12,7 @@ pub use app_runtime::AppRuntime; pub use deliver::spawn_and_deliver; pub use deliver_input::DeliverInput; pub use failed_requests::FailedRequests; -pub use gio_process_hold::{GioProcessHold, hold_default_application}; +pub use gio_process_hold::GioProcessHold; pub use request_sequencer::RequestSequencer; pub use request_stamp::RequestStamp; pub use request_ticket::RequestTicket; diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 757bb5afca..7e5cbb07fc 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -11,7 +11,6 @@ pub mod save_batch; pub mod secret_labels; pub mod secret_save_report; pub mod session_context; -pub mod single_instance; pub mod structure_tracker; pub mod structure_tracker_handle; pub mod structure_tracker_registry; diff --git a/linux/crates/app/src/services/single_instance.rs b/linux/crates/app/src/services/single_instance.rs deleted file mode 100644 index 00835a2d30..0000000000 --- a/linux/crates/app/src/services/single_instance.rs +++ /dev/null @@ -1,126 +0,0 @@ -//! Process-level single-instance gate. -//! -//! `gtk::Application::register()` already handles single-instance through -//! the D-Bus session bus on a healthy GNOME session: a second launch -//! sends `activate` to the primary and exits. That mechanism breaks when -//! D-Bus is unavailable (sandboxed, headless, minimal session) and -//! silently lets two processes through. Two TablePro processes racing on -//! the workspace state corrupt each other's tabs. -//! -//! This adds a belt-and-suspenders exclusive lock on -//! `StoragePaths::instance_lock`, under the per-application runtime -//! directory. It is held for the lifetime of the returned `Lock`. The -//! kernel releases it when the process exits, so a crash does not leak -//! it. - -use std::fs::TryLockError; -use std::fs::{File, OpenOptions}; -use std::os::unix::fs::OpenOptionsExt; -use std::path::PathBuf; - -use tablepro_storage::StoragePaths; - -#[derive(Debug)] -pub struct Lock { - // Held only for its drop side effect: closing the fd, which the - // kernel turns into a lock release. - _file: File, -} - -#[derive(Debug)] -pub enum LockError { - AlreadyRunning, - Io { path: PathBuf, source: std::io::Error }, -} - -impl std::fmt::Display for LockError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - LockError::AlreadyRunning => write!(f, "another TablePro instance is already running"), - LockError::Io { path, source } => { - write!(f, "single-instance lock {}: {source}", path.display()) - } - } - } -} - -impl std::error::Error for LockError {} - -/// Take the process-wide single-instance lock. `Err(AlreadyRunning)` -/// means another process holds it. The guard must outlive every code -/// path that writes user state. -pub fn acquire(paths: &StoragePaths) -> Result { - let path = paths.instance_lock(); - tablepro_storage::fs::ensure_private_dir(&paths.runtime).map_err(|error| LockError::Io { - path: paths.runtime.clone(), - source: std::io::Error::other(error), - })?; - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .truncate(false) - .mode(0o600) - .open(&path) - .map_err(|source| LockError::Io { - path: path.clone(), - source, - })?; - match file.try_lock() { - Ok(()) => Ok(Lock { _file: file }), - Err(TryLockError::WouldBlock) => Err(LockError::AlreadyRunning), - Err(TryLockError::Error(source)) => Err(LockError::Io { path, source }), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn paths(root: &tempfile::TempDir) -> StoragePaths { - StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro") - } - - #[test] - fn instance_lock_lives_under_runtime_dir() { - let root = tempfile::tempdir().expect("tempdir"); - let paths = paths(&root); - - let _lock = acquire(&paths).expect("acquire"); - - assert_eq!(paths.instance_lock(), paths.runtime.join("tablepro.lock")); - assert!(paths.instance_lock().exists()); - } - - #[test] - fn a_second_acquire_reports_already_running() { - let root = tempfile::tempdir().expect("tempdir"); - let paths = paths(&root); - let _held = acquire(&paths).expect("first acquire"); - - let second = acquire(&paths); - - assert!(matches!(second, Err(LockError::AlreadyRunning)), "{second:?}"); - } - - #[test] - fn the_lock_releases_when_the_guard_drops() { - let root = tempfile::tempdir().expect("tempdir"); - let paths = paths(&root); - - drop(acquire(&paths).expect("first acquire")); - - assert!(acquire(&paths).is_ok()); - } - - #[test] - fn two_profiles_do_not_block_each_other() { - let root = tempfile::tempdir().expect("tempdir"); - let installed = StoragePaths::under(root.path(), "tablepro", "app.tablepro.TablePro"); - let devel = StoragePaths::under(root.path(), "tablepro-devel", "app.tablepro.TablePro.Devel"); - - let _held = acquire(&installed).expect("installed"); - - assert!(acquire(&devel).is_ok()); - } -} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs index ebb9faf245..a682412233 100644 --- a/linux/crates/app/src/ui/app/connection.rs +++ b/linux/crates/app/src/ui/app/connection.rs @@ -298,11 +298,13 @@ impl App { } = ready; let claim = match self.context.sessions.claim(key.clone(), self.window.id()) { Ok(claim) => claim, - Err(ClaimError::AlreadyOpen { .. }) => { + Err(ClaimError::AlreadyOpen { window_id }) => { self.discard_pending(pending); - self.show_toast(&crate::i18n::gettext( - "This connection is already open in another window.", - )); + if !self.host.present_window(window_id) { + self.show_toast(&crate::i18n::gettext( + "This connection is already open in another window.", + )); + } return; } }; @@ -772,6 +774,13 @@ impl App { self.show_toast(&crate::i18n::gettext("This connection is already open.")); return; } + // One window per connection: the one that has it comes forward. + if let Some(owner) = self.context.sessions.window_for(&key) + && owner != self.window.id() + && self.host.present_window(owner) + { + return; + } if self.context.active.is_connected() { self.close_workspace_then(AppMsg::OpenSavedConfirmed(saved), sender); return; diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 4ef75573df..a6f1ce5af2 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -37,20 +37,17 @@ use crate::services::structure_tracker_registry::StructureTrackerRegistry; pub(crate) use connection::{ConnectRetry, ReadySession}; -/// What `run()` hands the root component: the session services and the -/// settings it opened before GTK started. +/// What the application hands each window it opens. pub struct AppInit { - pub context: std::rc::Rc, - /// Credential prompts waiting for a window to show them in. - pub prompts: crate::ui::credential_prompt::PromptQueue, - pub settings: std::rc::Rc, - pub storage: crate::storage::SharedStorage, - pub tasks: tablepro_session::runtime::Tasks, - pub history: crate::services::history_service::HistoryService, + pub services: std::rc::Rc, + pub host: std::rc::Rc, + pub launch: crate::application::WindowLaunch, } pub struct App { context: std::rc::Rc, + /// Reaches the application's other windows. + host: std::rc::Rc, /// The adopted session's claim and watchers. Dropping it releases /// the registry key and stops the banner and sidebar updates. session_links: Option, @@ -374,8 +371,8 @@ pub enum AppMsg { approved: bool, }, RefreshPage, - ShowAbout, - ShowPreferences, + /// The application asks the window to close. + CloseRequested(crate::workspace::CloseReason), ExportResults { result: QueryResult, name: String, @@ -496,6 +493,28 @@ impl App { pub(super) fn dirty_tabs(&self) -> Vec { dirty_tabs(&self.change_trackers, &self.structure_trackers) } + + /// Whether the window holds logout back now. + pub(crate) fn is_inhibiting(&self) -> bool { + self.inhibitor.is_held() + } + + /// A test session has no session manager to grant an inhibition. + #[cfg(test)] + pub(crate) fn set_inhibitor(&mut self, inhibitor: crate::workspace::WindowInhibitor) { + self.inhibitor = inhibitor; + } + + fn on_close_requested(&self, reason: crate::workspace::CloseReason) { + match reason { + crate::workspace::CloseReason::Quit(reply) => { + self.close_guards.request_window_close(&self.window, move |decision| { + // A quit that went away no longer needs the answer. + let _ = reply.send(decision); + }); + } + } + } } #[relm4::component(pub)] @@ -650,14 +669,12 @@ impl SimpleComponent for App { } fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let AppInit { - context, - prompts, - settings, - storage, - tasks, - history, - } = init; + let AppInit { services, host, launch } = init; + let (context, prompts) = services.window_context(); + let settings = services.settings.clone(); + let storage = services.storage.clone(); + let tasks = services.tasks.clone(); + let history = services.history(); let drafts = crate::workspace::DraftWriter::new( tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), tasks.clone(), @@ -933,7 +950,7 @@ impl SimpleComponent for App { workspace_outer_stack.add_named(&workspace_empty_page, Some("empty")); workspace_outer_stack.set_visible_child_name("empty"); - let actions = install_window_actions(&widgets.window, sender.clone()); + let actions = install_window_actions(&widgets.window, sender.input_sender()); let history_gate = spawn_history_gate( vec![actions.show_history.clone(), actions.show_saved_queries.clone()], history.availability(), @@ -956,6 +973,7 @@ impl SimpleComponent for App { let prompt_presenter = crate::ui::credential_prompt::CredentialPromptPresenter::start(prompts, &root); let model = App { context, + host, session_links: None, prompt_presenter, connect_attempts, @@ -1011,6 +1029,10 @@ impl SimpleComponent for App { }; sender.deliver(AppMsg::ReloadConnections); model.show_welcome_page(sender.clone()); + match launch { + crate::application::WindowLaunch::Picker => {} + crate::application::WindowLaunch::Connect(saved) => sender.deliver(AppMsg::OpenSaved(*saved)), + } widgets .new_connection_button @@ -1192,14 +1214,7 @@ impl SimpleComponent for App { } } AppMsg::RefreshPage => self.on_refresh_active_tab(), - AppMsg::ShowAbout => self.on_show_about(), - AppMsg::ShowPreferences => super::preferences_dialog::PreferencesDialog::new( - &self.settings, - &self.storage, - self.history.store(), - &self.tasks, - ) - .present(Some(&self.window)), + AppMsg::CloseRequested(reason) => self.on_close_requested(reason), AppMsg::ExportResults { result, name, target } => { let dialect = self.context.active.get().and_then(|session| session.current_dialect()); super::export_dialog::present( @@ -1232,6 +1247,9 @@ fn qualified_label(schema: Option<&str>, table: &str) -> String { fn primary_menu_model() -> gio::Menu { let menu = gio::Menu::new(); + let window_section = gio::Menu::new(); + window_section.append(Some(&crate::i18n::gettext("New Window")), Some("app.new-window")); + menu.append_section(None, &window_section); let connection_section = gio::Menu::new(); let disconnect_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Disconnect")), Some("win.disconnect")); disconnect_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); @@ -1255,12 +1273,12 @@ fn primary_menu_model() -> gio::Menu { ); menu.append_section(None, &connections_section); let prefs_section = gio::Menu::new(); - prefs_section.append(Some(&crate::i18n::gettext("Preferences")), Some("win.preferences")); + prefs_section.append(Some(&crate::i18n::gettext("Preferences")), Some("app.preferences")); menu.append_section(None, &prefs_section); let app_section = gio::Menu::new(); app_section.append(Some(&crate::i18n::gettext("Keyboard Shortcuts")), Some("app.shortcuts")); - app_section.append(Some(&crate::i18n::gettext("About TablePro")), Some("win.about")); - app_section.append(Some(&crate::i18n::gettext("Quit")), Some("win.quit")); + app_section.append(Some(&crate::i18n::gettext("About TablePro")), Some("app.about")); + app_section.append(Some(&crate::i18n::gettext("Quit")), Some("app.quit")); menu.append_section(None, &app_section); menu } @@ -1298,31 +1316,22 @@ struct WindowActions { show_saved_queries: gio::SimpleAction, } -fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSender) -> WindowActions { - let group = gio::SimpleActionGroup::new(); - - // Twelve identical action wrappers were inlined here before; the macro - // keeps the tuple-list intent obvious and removes 36 lines of boilerplate. +/// The window's actions go on the window's own action map, which GTK +/// already exports as `win`: a second group inserted under that name +/// would hide it from the application's accelerators and from D-Bus. +fn install_window_actions(window: &adw::ApplicationWindow, sender: &relm4::Sender) -> WindowActions { macro_rules! input_action { ($name:expr, $msg:expr) => {{ let s = sender.clone(); - gio::ActionEntry::builder($name) + gio::ActionEntry::::builder($name) .activate(move |_, _, _| s.deliver($msg)) .build() }}; } - let window_for_quit = window.clone(); - let quit = gio::ActionEntry::builder("quit") - .activate(move |_, _, _| window_for_quit.close()) - .build(); - - group.add_action_entries([ - input_action!("about", AppMsg::ShowAbout), - quit, + window.add_action_entries([ input_action!("open-editor", AppMsg::NewEditorTab), input_action!("close-current", AppMsg::CloseActiveWorkspaceTab), - input_action!("preferences", AppMsg::ShowPreferences), input_action!("refresh-page", AppMsg::RefreshPage), input_action!("save-changes", AppMsg::SaveActiveTab), input_action!("undo-change", AppMsg::UndoActiveBrowseTab), @@ -1338,7 +1347,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend let sender_for_disconnect = sender.clone(); disconnect.connect_activate(move |_, _| sender_for_disconnect.deliver(AppMsg::Disconnect)); disconnect.set_enabled(false); - group.add_action(&disconnect); + window.add_action(&disconnect); // The history database opens on a background task, so the menu item // and Ctrl+H stay insensitive until `HistoryService` reports Ready. @@ -1346,7 +1355,7 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend let sender_for_history = sender.clone(); show_history.connect_activate(move |_, _| sender_for_history.deliver(AppMsg::ShowHistory)); show_history.set_enabled(false); - group.add_action(&show_history); + window.add_action(&show_history); // Saved queries live in the same database, so they are reachable // exactly when it is. @@ -1354,10 +1363,8 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend let sender_for_saved = sender.clone(); show_saved_queries.connect_activate(move |_, _| sender_for_saved.deliver(AppMsg::ShowSavedQueries)); show_saved_queries.set_enabled(false); - group.add_action(&show_saved_queries); + window.add_action(&show_saved_queries); - window.insert_action_group("win", Some(&group)); - tracing::info!(enabled = disconnect.is_enabled(), "registered win.disconnect"); WindowActions { disconnect, show_history, @@ -1365,13 +1372,14 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend } } -fn install_window_shortcuts(window: &adw::ApplicationWindow) { +/// Quit, New Window and Preferences are application accelerators, which +/// GTK runs before these, so none of their keys is bound here. +pub(crate) fn install_window_shortcuts(window: &adw::ApplicationWindow) { use gtk::gdk::{Key, ModifierType}; let primary = ModifierType::CONTROL_MASK; let primary_shift = ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK; let controller = gtk::ShortcutController::new(); controller.set_scope(gtk::ShortcutScope::Global); - controller.add_shortcut(make_shortcut(Key::q, primary, "win.quit")); controller.add_shortcut(make_shortcut(Key::w, primary, "win.close-current")); controller.add_shortcut(make_shortcut(Key::e, primary, "win.open-editor")); // Ctrl+T mirrors Ctrl+E for the browser/IDE muscle memory ("new @@ -1380,7 +1388,6 @@ fn install_window_shortcuts(window: &adw::ApplicationWindow) { controller.add_shortcut(make_shortcut(Key::t, primary, "win.open-editor")); controller.add_shortcut(make_shortcut(Key::F5, ModifierType::empty(), "win.refresh-page")); controller.add_shortcut(make_shortcut(Key::f, primary, "win.open-filter")); - controller.add_shortcut(make_shortcut(Key::comma, primary, "win.preferences")); controller.add_shortcut(make_shortcut(Key::h, primary, "win.show-history")); controller.add_shortcut(make_shortcut(Key::s, primary, "win.save-changes")); controller.add_shortcut(make_shortcut(Key::z, primary, "win.undo-change")); @@ -1422,20 +1429,43 @@ mod tests { } #[gtk4::test] - fn window_actions_exclude_shortcuts() { - let app = adw::Application::builder() - .application_id(crate::config::APP_ID) - .build(); - let window = adw::ApplicationWindow::new(&app); + fn window_shortcuts_leave_the_application_keys_alone() { + let window = adw::ApplicationWindow::builder().build(); install_window_shortcuts(&window); let names = shortcut_action_names(&window); - assert!(names.iter().any(|name| name == "win.quit"), "{names:?}"); + assert!(names.iter().any(|name| name == "win.close-current"), "{names:?}"); + for owned_by_the_application in ["win.quit", "win.preferences", "win.shortcuts"] { + assert!( + !names.iter().any(|name| name == owned_by_the_application), + "{owned_by_the_application} is an application action now: {names:?}" + ); + } + } + + #[gtk4::test] + fn window_actions_live_on_the_windows_own_map() { + let window = adw::ApplicationWindow::builder().build(); + let (sender, receiver) = relm4::channel::(); + + let actions = install_window_actions(&window, &sender); + + assert!(window.lookup_action("open-editor").is_some()); assert!( - !names.iter().any(|name| name == "win.shortcuts"), - "AdwApplication owns app.shortcuts now: {names:?}" + window + .lookup_action("disconnect") + .is_some_and(|action| !action.is_enabled()) ); + for moved in ["quit", "about", "preferences"] { + assert!( + window.lookup_action(moved).is_none(), + "win.{moved} is still on the window" + ); + } + WidgetExt::activate_action(&window, "win.open-editor", None).expect("win resolves to the window's own map"); + assert!(matches!(receiver.recv_sync(), Some(AppMsg::NewEditorTab))); + drop(actions); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/app/status_pages.rs index 5e084b761a..f251be392a 100644 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ b/linux/crates/app/src/ui/app/status_pages.rs @@ -1,5 +1,5 @@ use relm4::adw::prelude::*; -use relm4::{Component, ComponentController, ComponentSender, adw, gtk}; +use relm4::{Component, ComponentController, ComponentSender, adw}; use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, HistoryDialogOutput}; use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; @@ -175,26 +175,6 @@ impl App { .drop_on_shutdown() }); } - - pub(super) fn on_show_about(&self) { - let dialog = adw::AboutDialog::builder() - .application_name(crate::i18n::gettext("TablePro")) - .application_icon(crate::config::APP_ID) - .developer_name(crate::i18n::gettext("TablePro Authors")) - .version(crate::config::VERSION) - .website("https://github.com/TableProApp/TablePro") - .issue_url("https://github.com/TableProApp/TablePro/issues") - .support_url("https://github.com/TableProApp/TablePro/discussions") - .copyright(crate::i18n::gettext("© 2025–2026 TablePro Authors")) - .license_type(gtk::License::Agpl30) - .comments(crate::i18n::gettext( - "A native Linux database client built with GTK4 + libadwaita.", - )) - .build(); - dialog.set_developers(&["TablePro Authors https://github.com/TableProApp/TablePro"]); - dialog.set_translator_credits(&crate::i18n::gettext("translator-credits")); - dialog.present(Some(&self.window)); - } } /// A name for a query the user has not named, taken from the first diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 93d077658f..7a897f051d 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -28,4 +28,11 @@ mod style_scheme_resolver; mod welcome_view; mod window_geometry; -pub use app::{App, AppInit}; +#[cfg(test)] +pub(crate) use app::install_window_shortcuts; +pub use app::{App, AppInit, AppMsg}; +#[cfg(test)] +pub(crate) use browse_tab::BrowseTab; +#[cfg(test)] +pub(crate) use editor::{SqlEditor, SqlEditorInit}; +pub(crate) use preferences_dialog::PreferencesDialog; diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs index 678290567e..f6ee7a2efd 100644 --- a/linux/crates/app/src/workspace/close_guards.rs +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -9,6 +9,9 @@ use uuid::Uuid; use super::{CloseDecision, CloseGuard, CloseScope, CloseStep, WorkspaceHandle}; +/// Hears how the window's close ended. +type CloseWaiter = Box; + /// Where the window's own close stands. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum WindowClose { @@ -25,6 +28,11 @@ enum WindowClose { pub(crate) struct CloseGuards { turn: futures::lock::Mutex<()>, window: Cell, + /// Close requests that reached the window's handler, so a caller can + /// tell when a dialog over the window took one instead. + requests: Cell, + /// Callers waiting to hear how the window's close ended. + waiting: RefCell>, /// Open transactions the user agreed to lose in the current /// disconnect or switch. A switch asks again once its connection is /// ready, about what changed while it connected, but not about these. @@ -36,6 +44,8 @@ impl CloseGuards { Rc::new(Self { turn: futures::lock::Mutex::new(()), window: Cell::new(WindowClose::Open), + requests: Cell::new(0), + waiting: RefCell::new(Vec::new()), teardown_acknowledged: RefCell::new(HashSet::new()), }) } @@ -62,14 +72,43 @@ impl CloseGuards { ) { let guards = self.clone(); window.connect_close_request(move |window| { + guards.requests.set(guards.requests.get() + 1); if !guards.window_may_close(window.upcast_ref(), &handle) { return glib::Propagation::Stop; } on_close(window); + guards.settle(CloseDecision::Approved); glib::Propagation::Proceed }); } + /// Close the window through its prompts and tell `decided` how it + /// ended. A prompt already on screen answers for this request too. + pub(crate) fn request_window_close( + &self, + window: &impl IsA, + decided: impl FnOnce(CloseDecision) + 'static, + ) { + self.waiting.borrow_mut().push(Box::new(decided)); + if self.window.get() == WindowClose::Asking { + return; + } + let before = self.requests.get(); + window.close(); + if self.requests.get() == before { + // libadwaita takes a close request over one of its dialogs as + // dismissing the dialog, and the window stays open. + self.settle(CloseDecision::Cancelled); + } + } + + fn settle(&self, decision: CloseDecision) { + let waiting = self.waiting.take(); + for decided in waiting { + decided(decision); + } + } + fn window_may_close(self: &Rc, window: >k::Window, handle: &WorkspaceHandle) -> bool { match self.window.get() { WindowClose::Asking => return false, @@ -90,7 +129,10 @@ impl CloseGuards { guards.window.set(WindowClose::Approved); window.close(); } - CloseDecision::Cancelled => guards.window.set(WindowClose::Open), + CloseDecision::Cancelled => { + guards.window.set(WindowClose::Open); + guards.settle(CloseDecision::Cancelled); + } } }); false @@ -268,6 +310,83 @@ mod tests { wait_until(WAIT, || closes.get() == 1).expect("the approved close to go through"); } + #[gtk4::test] + fn a_requested_close_of_a_clean_window_is_approved_at_once() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + let (decision, sink) = decision_sink(); + + guards.request_window_close(&window, sink); + + assert_eq!(decision.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_requested_close_hears_the_prompts_answer() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + + let (kept, sink) = decision_sink(); + guards.request_window_close(&window, sink); + let first = answer_next(&window, "Save Changes?", "cancel", None); + wait_until(WAIT, || kept.get().is_some()).expect("a decision"); + assert_eq!(kept.get(), Some(CloseDecision::Cancelled)); + assert!(window.is_visible() && closes.get() == 0); + + let (approved, sink) = decision_sink(); + guards.request_window_close(&window, sink); + answer_next(&window, "Save Changes?", "discard", Some(&first)); + wait_until(WAIT, || approved.get().is_some()).expect("a decision"); + assert_eq!(approved.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + /// A request that arrives while the user's own close is asking is + /// answered by that prompt, rather than dismissing it. + #[gtk4::test] + fn a_request_during_the_prompt_waits_for_its_answer() { + let workspace = Workspace::open(ConnectionScript::default()); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + window.close(); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the prompt"); + let shown = visible_alert(&window); + + let (decision, sink) = decision_sink(); + guards.request_window_close(&window, sink); + drain_main_context(); + assert_eq!(visible_alert(&window), shown, "the request dismissed the prompt"); + assert_eq!(decision.get(), None); + answer_next(&window, "Save Changes?", "discard", None); + + wait_until(WAIT, || decision.get().is_some()).expect("a decision"); + assert_eq!(decision.get(), Some(CloseDecision::Approved)); + assert_eq!(closes.get(), 1); + } + + #[gtk4::test] + fn a_close_a_dialog_takes_is_reported_cancelled() { + let workspace = Workspace::open(ConnectionScript::default()); + workspace.handle.discard(&[workspace.tab], false); + let guards = CloseGuards::new(); + let (window, closes) = guarded_window(&guards, &workspace); + let other = adw::AlertDialog::new(Some("Elsewhere"), None); + other.add_response("close", "Close"); + other.present(Some(&window)); + wait_until(WAIT, || visible_alert(&window).is_some()).expect("the dialog"); + + let (decision, sink) = decision_sink(); + guards.request_window_close(&window, sink); + + assert_eq!(decision.get(), Some(CloseDecision::Cancelled)); + assert_eq!(closes.get(), 0); + assert!(window.is_visible()); + } + #[gtk4::test] fn a_teardown_proceeds_only_when_approved() { let workspace = Workspace::open(ConnectionScript::default()); diff --git a/linux/crates/app/src/workspace/close_reason.rs b/linux/crates/app/src/workspace/close_reason.rs new file mode 100644 index 0000000000..77803a5d4d --- /dev/null +++ b/linux/crates/app/src/workspace/close_reason.rs @@ -0,0 +1,11 @@ +use futures::channel::oneshot; + +use super::CloseDecision; + +/// Why a window is asked to close, and who hears how it ended. +#[derive(Debug)] +pub enum CloseReason { + /// The application is quitting. A dropped sender means the window + /// went without deciding. + Quit(oneshot::Sender), +} diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index d61d185f4d..229939def2 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -5,6 +5,7 @@ mod close_decision; mod close_guard; mod close_guards; +mod close_reason; mod close_scope; mod close_step; mod dialog_response; @@ -51,6 +52,7 @@ mod write_job; pub use close_decision::CloseDecision; pub(crate) use close_guard::CloseGuard; pub(crate) use close_guards::CloseGuards; +pub use close_reason::CloseReason; pub use close_scope::CloseScope; pub use close_step::{CloseStep, next_close_step}; pub use dirty_kind::DirtyKind; @@ -76,6 +78,8 @@ pub use tab_directory::TabDirectory; pub use tab_entry::TabEntry; pub use tab_record::TabRecord; pub use unknown_cause::UnknownCause; +#[cfg(test)] +pub use window_inhibitor::InhibitTarget; pub use window_inhibitor::{ApplicationInhibit, WindowInhibitor}; pub use workspace_event::WorkspaceEvent; pub use workspace_file_name::WorkspaceFileName; diff --git a/linux/crates/app/src/workspace/window_inhibitor.rs b/linux/crates/app/src/workspace/window_inhibitor.rs index f4ccbd47f9..11b6999f72 100644 --- a/linux/crates/app/src/workspace/window_inhibitor.rs +++ b/linux/crates/app/src/workspace/window_inhibitor.rs @@ -47,6 +47,12 @@ impl WindowInhibitor { } } + /// Whether the session is being held back now. A session without an + /// inhibit service never holds it. + pub fn is_held(&self) -> bool { + self.cookie != 0 + } + fn release(&mut self) { if self.cookie != 0 { self.target.uninhibit(std::mem::take(&mut self.cookie)); @@ -199,12 +205,26 @@ mod tests { let mut inhibitor = WindowInhibitor::new(session.clone()); inhibitor.update(Some(InhibitReason::SavingChanges)); + assert!(!inhibitor.is_held(), "a refused inhibit counted as held"); inhibitor.update(None); drop(inhibitor); assert!(session.held.borrow().is_empty()); } + #[gtk4::test] + fn is_held_follows_the_cookie() { + let session = Session::default(); + let mut inhibitor = WindowInhibitor::new(session.clone()); + assert!(!inhibitor.is_held()); + + inhibitor.update(Some(InhibitReason::UnsavedChanges)); + assert!(inhibitor.is_held()); + + inhibitor.update(None); + assert!(!inhibitor.is_held()); + } + #[gtk4::test] fn a_window_without_an_application_inhibits_nothing() { let window = gtk::Window::new(); diff --git a/linux/crates/app/tests/application_lifecycle.rs b/linux/crates/app/tests/application_lifecycle.rs new file mode 100644 index 0000000000..51a072bfe4 --- /dev/null +++ b/linux/crates/app/tests/application_lifecycle.rs @@ -0,0 +1,497 @@ +//! Quitting a running TablePro, in process, over fake connections. +//! +//! Each case runs in a child process of its own: GTK allows one +//! application per process on the main thread, and the application id +//! can be registered on the bus only once at a time. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::cell::Cell; +use std::path::Path; +use std::process::{Command, ExitCode, Stdio}; +use std::rc::Rc; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_app::application::{AppServices, AppServicesParts, TableProApplication, WindowLaunch}; +use tablepro_app::runtime::AppRuntime; +use tablepro_app::storage::AppStorage; +use tablepro_core::{AuthMode, DriverRegistry, EditorSession, LivenessPolicy, TlsConfig, TransactionState}; +use tablepro_session::fakes::{FakeConnector, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_storage::{AppSettings, SavedConnection, StoragePaths}; +use tablepro_test_support::{ + CloseBehaviour, ConnectionScript, FakeConnection, FakeDriver, FakeEditorSession, FakeReply, FakeRun, + FakeSecretVault, Replies, +}; + +const CASE: &str = "TABLEPRO_LIFECYCLE_CASE"; +const CASES: &[&str] = &[ + "quit_waits_for_300ms_close_within_timeout_budget", + "hanging_close_abandoned_at_close_timeout_and_run_returns", + "cancelled_close_guard_keeps_app_running", + "editor_session_close_keeps_app_alive_until_resolved", +]; +/// A case that has not finished by now never will. +const CASE_LIMIT: Duration = Duration::from_secs(90); +const STEP_LIMIT: Duration = Duration::from_secs(20); + +fn main() -> ExitCode { + if let Ok(case) = std::env::var(CASE) { + return child(&case); + } + parent() +} + +/// A private session bus, so a TablePro the developer has open never +/// answers for the application under test. +fn parent() -> ExitCode { + let display = Display::current(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let mut failed = false; + for case in CASES { + match run_case(case, &display) { + Ok(()) => eprintln!("{case}: ok"), + Err(message) => { + eprintln!("{case} failed: {message}"); + failed = true; + } + } + } + bus.down(); + if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS } +} + +/// `g_test_dbus_up` unsets `DISPLAY` along with the session bus, so the +/// display is read before and handed to each case. +struct Display(Vec<(&'static str, std::ffi::OsString)>); + +impl Display { + fn current() -> Self { + Self( + ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(), + ) + } +} + +fn run_case(case: &str, display: &Display) -> Result<(), String> { + let exe = std::env::current_exe().map_err(|error| format!("could not find the test binary: {error}"))?; + // A file rather than a pipe: nobody reads a pipe while the case runs, + // and a full one would stop it mid-write. + let log = tempfile::NamedTempFile::new().map_err(|error| format!("no log file: {error}"))?; + let stderr = log + .reopen() + .map_err(|error| format!("could not open the log file: {error}"))?; + let mut child = Command::new(exe) + .envs(display.0.iter().cloned()) + .env(CASE, case) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env_remove("LANGUAGE") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr) + .spawn() + .map_err(|error| format!("could not start the case: {error}"))?; + let deadline = Instant::now() + CASE_LIMIT + Duration::from_secs(10); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(50)), + Ok(None) => { + let _ = child.kill(); + break; + } + Err(error) => return Err(format!("could not wait for the case: {error}")), + } + } + let status = child + .wait() + .map_err(|error| format!("could not wait for the case: {error}"))?; + if status.success() { + return Ok(()); + } + let stderr = std::fs::read_to_string(log.path()).unwrap_or_default(); + Err(format!("exited with {status}:\n{stderr}")) +} + +/// What a case scripts the fake connection and editor session to do. +struct Plan { + connection_close: CloseBehaviour, + close_timeout: Duration, + editor: Option, +} + +struct EditorPlan { + leaves: TransactionState, + close: CloseBehaviour, +} + +fn child(case: &str) -> ExitCode { + std::thread::spawn(|| { + std::thread::sleep(CASE_LIMIT); + eprintln!("the case did not finish within {CASE_LIMIT:?}"); + std::process::exit(2); + }); + match run_child(case) { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run_child(case: &str) -> Result<(), String> { + let plan = match case { + "quit_waits_for_300ms_close_within_timeout_budget" => Plan { + connection_close: CloseBehaviour::Delay(Duration::from_millis(300)), + close_timeout: Duration::from_secs(2), + editor: None, + }, + "hanging_close_abandoned_at_close_timeout_and_run_returns" => Plan { + connection_close: CloseBehaviour::Hang, + close_timeout: Duration::from_millis(200), + editor: None, + }, + "cancelled_close_guard_keeps_app_running" => Plan { + connection_close: CloseBehaviour::Immediate, + close_timeout: Duration::from_secs(2), + editor: Some(EditorPlan { + leaves: TransactionState::InTransaction, + close: CloseBehaviour::Immediate, + }), + }, + "editor_session_close_keeps_app_alive_until_resolved" => Plan { + connection_close: CloseBehaviour::Immediate, + close_timeout: Duration::from_secs(2), + editor: Some(EditorPlan { + leaves: TransactionState::Idle, + close: CloseBehaviour::Delay(Duration::from_millis(100)), + }), + }, + other => return Err(format!("no case named {other}")), + }; + let world = World::build(&plan)?; + let outcome = world.run(case); + world.runtime.shutdown(); + outcome +} + +/// A TablePro over fakes, with what the case reads after it exits. +struct World { + runtime: AppRuntime, + app: TableProApplication, + connector: FakeConnector, + editor: Option>, + saved: SavedConnection, + _dir: tempfile::TempDir, +} + +impl World { + fn build(plan: &Plan) -> Result { + gio::resources_register_include!("tablepro.gresource") + .map_err(|error| format!("could not register the resources: {error}"))?; + let dir = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let runtime = AppRuntime::build().map_err(|error| format!("no runtime: {error}"))?; + let settings = Rc::new(settings(dir.path())?); + let paths = StoragePaths::under(dir.path(), "tablepro", tablepro_app::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + + let mut script = ConnectionScript { + close: plan.connection_close, + ..ConnectionScript::default() + }; + script.probe.always(FakeReply::ok(())); + script.list_schemas.always(FakeReply::ok(Vec::new())); + script.list_objects.always(FakeReply::ok(Vec::new())); + let editor = plan.editor.as_ref().map(|editor| { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(editor.leaves)); + Arc::new(FakeEditorSession::with_close(runs, editor.close)) + }); + if let Some(editor) = &editor { + let session: Arc = editor.clone(); + script.open_editor_session.always(FakeReply::ok(session)); + } + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + script.clone(), + ))); + let connector = FakeConnector::new([]).always(FakeEstablish::Connect(script)); + let liveness = LivenessPolicy { + close_timeout: plan.close_timeout, + ..LivenessPolicy::DESKTOP + }; + let services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(connector.clone()), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings, + storage, + tasks: runtime.tasks(), + }); + let saved = saved_connection(&dir.path().join("fake.db")); + Ok(Self { + app: TableProApplication::new(services), + runtime, + connector, + editor, + saved, + _dir: dir, + }) + } + + fn run(&self, case: &str) -> Result<(), String> { + let steps = Steps::default(); + let saved = self.saved.clone(); + let (connector, editor, recorded) = (self.connector.clone(), self.editor.clone(), steps.clone()); + let case_name = case.to_owned(); + self.app.connect_startup(move |app| { + let Some(window) = app.open_window(WindowLaunch::Connect(Box::new(saved.clone()))) else { + fail("the application opened no window"); + }; + let (app, connector, editor, recorded, case) = ( + app.clone(), + connector.clone(), + editor.clone(), + recorded.clone(), + case_name.clone(), + ); + glib::spawn_future_local(async move { + if let Err(message) = script(&case, &app, &window, &connector, editor.as_deref(), &recorded).await { + fail(&message); + } + }); + }); + + let code = self.app.run_with_args(&["tablepro"]); + let returned = Instant::now(); + if code != glib::ExitCode::SUCCESS { + return Err(format!("run() returned {code:?}")); + } + let quit_at = steps.quit_at.get().ok_or("the case never asked to quit")?; + let took = returned.duration_since(quit_at); + let connection = self + .connector + .connections() + .into_iter() + .next() + .ok_or("no connection was made")?; + check(case, &steps, &connection, self.editor.as_deref(), took) + } +} + +fn check( + case: &str, + steps: &Steps, + connection: &FakeConnection, + editor: Option<&FakeEditorSession>, + took: Duration, +) -> Result<(), String> { + match case { + "quit_waits_for_300ms_close_within_timeout_budget" => { + expect(connection.close_finished(), "run() returned before the close finished")?; + expect( + took >= Duration::from_millis(300), + &format!("run() returned after {took:?}"), + )?; + expect(took < Duration::from_secs(2), &format!("the quit took {took:?}")) + } + "hanging_close_abandoned_at_close_timeout_and_run_returns" => { + expect(connection.is_closed(), "the close never started")?; + expect(!connection.close_finished(), "a hanging close finished")?; + expect( + took >= Duration::from_millis(200), + &format!("run() returned after {took:?}"), + )?; + expect(took < Duration::from_secs(5), &format!("the quit took {took:?}")) + } + "cancelled_close_guard_keeps_app_running" => { + expect(steps.kept_running.get(), "the cancelled quit closed the window")?; + let editor = editor.ok_or("no editor session")?; + expect(editor.is_closed(), "the editor session was left open")?; + expect(connection.close_finished(), "the connection was left open") + } + "editor_session_close_keeps_app_alive_until_resolved" => { + let editor = editor.ok_or("no editor session")?; + expect(editor.is_closed(), "run() returned before the editor session closed") + } + other => Err(format!("no checks for {other}")), + } +} + +/// What the script running inside the application saw. +#[derive(Clone, Default)] +struct Steps { + quit_at: Rc>>, + kept_running: Rc>, +} + +async fn script( + case: &str, + app: &TableProApplication, + window: &adw::ApplicationWindow, + connector: &FakeConnector, + editor: Option<&FakeEditorSession>, + steps: &Steps, +) -> Result<(), String> { + let services = app.services().ok_or("the application has no services")?; + until("the session", || services.sessions.live_count() == 1).await?; + if let Some(editor) = editor { + run_in_a_new_editor_tab(window, editor).await?; + } + steps.quit_at.set(Some(Instant::now())); + app.activate_action("quit", None); + if case != "cancelled_close_guard_keeps_app_running" { + return Ok(()); + } + + answer(window, "Close With Open Transactions?", "cancel").await?; + glib::timeout_future(Duration::from_millis(500)).await; + let editor = editor.ok_or("no editor session")?; + let connection = connector.connections().into_iter().next().ok_or("no connection")?; + if app.windows().len() != 1 || editor.is_closed() || connection.is_closed() { + return Err("the quit went on after the close was cancelled".to_owned()); + } + steps.kept_running.set(true); + + steps.quit_at.set(Some(Instant::now())); + app.activate_action("quit", None); + answer(window, "Close With Open Transactions?", "close").await +} + +/// Open an editor tab through its window action and run what it holds, +/// which opens the tab's own editor session. +async fn run_in_a_new_editor_tab(window: &adw::ApplicationWindow, editor: &FakeEditorSession) -> Result<(), String> { + WidgetExt::activate_action(window, "win.open-editor", None).map_err(|error| format!("win.open-editor: {error}"))?; + let mut run = None; + until("the editor's Run button", || { + run = descendants(window.upcast_ref()) + .into_iter() + .filter(|widget| widget.type_().name() == "TableProSqlEditor") + .flat_map(|editor| descendants(&editor)) + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some("Run")); + run.is_some() + }) + .await?; + let run = run.ok_or("no Run button")?; + run.emit_clicked(); + until("the run to reach the editor session", || !editor.texts().is_empty()).await?; + until("the run to end", || run.is_sensitive()).await +} + +/// Wait for the alert headed `heading` and click its `response`. +async fn answer(window: &adw::ApplicationWindow, heading: &str, response: &str) -> Result<(), String> { + let mut shown = None; + until(heading, || { + shown = window + .visible_dialog() + .and_downcast::() + .filter(|alert| alert.heading().as_deref() == Some(heading)); + shown.is_some() + }) + .await?; + let alert = shown.ok_or("no alert")?; + let label = alert.response_label(response); + let button = descendants(alert.upcast_ref()) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + .ok_or_else(|| format!("“{heading}” has no {response} button"))?; + button.emit_clicked(); + Ok(()) +} + +async fn until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + STEP_LIMIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + glib::timeout_future(Duration::from_millis(10)).await; + } + Ok(()) +} + +fn descendants(root: >k4::Widget) -> Vec { + let mut found = Vec::new(); + let mut pending: Vec = root.first_child().into_iter().collect(); + while let Some(widget) = pending.pop() { + pending.extend(widget.next_sibling()); + pending.extend(widget.first_child()); + found.push(widget); + } + found +} + +fn expect(holds: bool, otherwise: &str) -> Result<(), String> { + if holds { Ok(()) } else { Err(otherwise.to_owned()) } +} + +/// A step that cannot go on leaves the application holding windows and +/// connections, so the case ends here rather than waiting on them. +fn fail(message: &str) -> ! { + eprintln!("{message}"); + std::process::exit(1); +} + +fn saved_connection(database: &Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +/// The schema compiled into the case's own directory behind a memory +/// backend, so the case never reads or writes the developer's settings. +fn settings(dir: &Path) -> Result { + let schemas = dir.join("schemas"); + std::fs::create_dir_all(&schemas).map_err(|error| format!("could not create the schema directory: {error}"))?; + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, schemas.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(&schemas) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if !output.status.success() { + return Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + AppSettings::with_backend( + tablepro_app::config::SCHEMA_ID, + &schemas, + &gio::memory_settings_backend_new(), + ) + .map_err(|error| format!("could not open the settings: {error}")) +} diff --git a/linux/crates/app/tests/application_uniqueness.rs b/linux/crates/app/tests/application_uniqueness.rs new file mode 100644 index 0000000000..5d729b7af5 --- /dev/null +++ b/linux/crates/app/tests/application_uniqueness.rs @@ -0,0 +1,293 @@ +//! One TablePro per session: a second launch hands its command line to +//! the running one over D-Bus and exits. + +#![expect( + clippy::print_stderr, + reason = "a harness-free test binary reports its own failures on stderr" +)] + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitCode, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use gio::prelude::*; + +const APP_ID: &str = tablepro_app::config::APP_ID; +/// Long enough for a cold start under a loaded CI runner. +const WAIT: Duration = Duration::from_secs(30); +const CALL_TIMEOUT_MS: i32 = 5_000; + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(message) => { + eprintln!("{message}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), String> { + let mut sandbox = Sandbox::new()?; + // g_test_dbus_up unsets DISPLAY along with the session bus. + sandbox.display = ["DISPLAY", "WAYLAND_DISPLAY"] + .into_iter() + .filter_map(|name| Some((name, std::env::var_os(name)?))) + .collect(); + let bus = gio::TestDBus::new(gio::TestDBusFlags::NONE); + bus.up(); + let outcome = bus + .bus_address() + .ok_or_else(|| "the test bus has no address".to_owned()) + .and_then(|address| launches(&sandbox, &address)); + bus.down(); + outcome +} + +fn launches(sandbox: &Sandbox, address: &str) -> Result<(), String> { + let connection = gio::DBusConnection::for_address_sync( + address, + gio::DBusConnectionFlags::AUTHENTICATION_CLIENT | gio::DBusConnectionFlags::MESSAGE_BUS_CONNECTION, + None, + gio::Cancellable::NONE, + ) + .map_err(|error| format!("could not connect to the test bus: {error}"))?; + // A file rather than a pipe: nobody reads a pipe while the primary + // runs, and a full one would stop it mid-write. + let log_path = sandbox.root().join("primary.log"); + let log = std::fs::File::create(&log_path).map_err(|error| format!("could not create the log: {error}"))?; + let mut primary = sandbox + .tablepro(address, &[]) + .stdout(Stdio::null()) + .stderr(log) + .spawn() + .map_err(|error| format!("could not start the primary: {error}"))?; + + let outcome = with_primary(sandbox, address, &connection); + if outcome.is_ok() { + quit(&connection)?; + let status = wait_for_exit(&mut primary)?; + if !status.success() { + return Err(format!("the primary exited with {status} after app.quit")); + } + return Ok(()); + } + let _ = primary.kill(); + let _ = primary.wait(); + let log = std::fs::read_to_string(&log_path).unwrap_or_default(); + outcome.map_err(|message| format!("{message}\nprimary stderr:\n{log}")) +} + +fn with_primary(sandbox: &Sandbox, address: &str, connection: &gio::DBusConnection) -> Result<(), String> { + wait_until("the primary to own its name", || name_has_owner(connection))?; + wait_until("the primary's first window", || window_count(connection) == 1)?; + + let plain = launch(sandbox, address, &[])?; + if !plain.success() { + return Err(format!("a second plain launch exited with {plain}")); + } + let new_window = launch(sandbox, address, &["--new-window"])?; + if !new_window.success() { + return Err(format!("a launch with --new-window exited with {new_window}")); + } + + wait_until("the second window", || window_count(connection) >= 2)?; + // The plain launch's activation reached the primary first, so a + // window it opened by mistake would be here by now. + std::thread::sleep(Duration::from_millis(500)); + let windows = window_count(connection); + if windows != 2 { + return Err(format!("{windows} windows after one plain launch and one --new-window")); + } + Ok(()) +} + +/// Ask the primary to quit the way the Quit menu item does. +fn quit(connection: &gio::DBusConnection) -> Result<(), String> { + let no_parameter: Vec = Vec::new(); + let platform_data: HashMap = HashMap::new(); + let arguments = ("quit", no_parameter, platform_data).to_variant(); + connection + .call_sync( + Some(APP_ID), + &object_path(), + "org.gtk.Actions", + "Activate", + Some(&arguments), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .map(drop) + .map_err(|error| format!("app.quit over D-Bus failed: {error}")) +} + +fn name_has_owner(connection: &gio::DBusConnection) -> bool { + connection + .call_sync( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "NameHasOwner", + Some(&(APP_ID,).to_variant()), + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(bool,)>()) + .is_some_and(|(owned,)| owned) +} + +/// GtkApplication exports each window's actions at +/// `/window/`, so the children of that node are the +/// open windows. +fn window_count(connection: &gio::DBusConnection) -> usize { + connection + .call_sync( + Some(APP_ID), + &format!("{}/window", object_path()), + "org.freedesktop.DBus.Introspectable", + "Introspect", + None, + None, + gio::DBusCallFlags::NONE, + CALL_TIMEOUT_MS, + gio::Cancellable::NONE, + ) + .ok() + .and_then(|reply| reply.get::<(String,)>()) + .map_or(0, |(xml,)| xml.matches(" Result { + let mut child = sandbox + .tablepro(address, arguments) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("could not launch tablepro {arguments:?}: {error}"))?; + wait_for_exit(&mut child) +} + +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(20)), + Ok(None) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("tablepro did not exit within {WAIT:?}")); + } + Err(error) => return Err(format!("could not wait for tablepro: {error}")), + } + } +} + +fn wait_until(what: &str, mut ready: impl FnMut() -> bool) -> Result<(), String> { + let deadline = Instant::now() + WAIT; + while !ready() { + if Instant::now() >= deadline { + return Err(format!("timed out waiting for {what}")); + } + std::thread::sleep(Duration::from_millis(50)); + } + Ok(()) +} + +/// Directories of the test's own, so every launch reads and writes +/// nothing of the developer's. +struct Sandbox { + root: tempfile::TempDir, + schemas: PathBuf, + config: PathBuf, + data: PathBuf, + state: PathBuf, + cache: PathBuf, + runtime: PathBuf, + display: Vec<(&'static str, std::ffi::OsString)>, +} + +impl Sandbox { + fn new() -> Result { + let root = tempfile::tempdir().map_err(|error| format!("no temporary directory: {error}"))?; + let dir = |name: &str| -> Result { + let path = root.path().join(name); + std::fs::create_dir_all(&path).map_err(|error| format!("could not create {}: {error}", path.display()))?; + Ok(path) + }; + let sandbox = Self { + schemas: dir("schemas")?, + config: dir("config")?, + data: dir("data")?, + state: dir("state")?, + cache: dir("cache")?, + runtime: dir("runtime")?, + display: Vec::new(), + root, + }; + private(&sandbox.runtime)?; + compile_schema(&sandbox.schemas)?; + Ok(sandbox) + } + + fn root(&self) -> &Path { + self.root.path() + } + + fn tablepro(&self, address: &str, arguments: &[&str]) -> Command { + let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); + command + .args(arguments) + .envs(self.display.iter().cloned()) + .env("DBUS_SESSION_BUS_ADDRESS", address) + .env("LC_ALL", "C.UTF-8") + .env("GSETTINGS_BACKEND", "memory") + .env("GSETTINGS_SCHEMA_DIR", &self.schemas) + .env("XDG_CONFIG_HOME", &self.config) + .env("XDG_DATA_HOME", &self.data) + .env("XDG_STATE_HOME", &self.state) + .env("XDG_CACHE_HOME", &self.cache) + .env("XDG_RUNTIME_DIR", &self.runtime) + .env_remove("LANGUAGE") + .stdin(Stdio::null()); + command + } +} + +/// GLib and the XDG base directory specification expect the runtime +/// directory to be private to the user. +fn private(path: &Path) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("could not make {} private: {error}", path.display())) +} + +fn compile_schema(dir: &Path) -> Result<(), String> { + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../data") + .join("app.tablepro.TablePro.gschema.xml"); + std::fs::copy(&source, dir.join("app.tablepro.TablePro.gschema.xml")) + .map_err(|error| format!("could not stage the schema: {error}"))?; + let output = Command::new("glib-compile-schemas") + .arg(dir) + .output() + .map_err(|error| format!("could not run glib-compile-schemas: {error}"))?; + if output.status.success() { + return Ok(()); + } + Err(format!( + "glib-compile-schemas failed: {}", + String::from_utf8_lossy(&output.stderr) + )) +} diff --git a/linux/crates/app/tests/startup.rs b/linux/crates/app/tests/startup.rs index ee3b17b447..917dadead6 100644 --- a/linux/crates/app/tests/startup.rs +++ b/linux/crates/app/tests/startup.rs @@ -76,9 +76,9 @@ fn compiled_schema_dir() -> Result { /// schema is installed would be answered by their machine instead of /// by the code. /// -/// The single-instance lock lives under `XDG_RUNTIME_DIR`, so a TablePro -/// the developer has open would otherwise hold it, and the binary under -/// test would exit at once without printing or reporting anything. +/// `XDG_RUNTIME_DIR` is the test's own, so nothing the binary leaves +/// there, such as SSH control sockets, lands beside a TablePro the +/// developer has open. fn tablepro(sandbox: &Sandbox<'_>) -> Command { let mut command = Command::new(env!("CARGO_BIN_EXE_tablepro")); command diff --git a/linux/crates/storage/src/paths.rs b/linux/crates/storage/src/paths.rs index baa243ce75..09ab19737f 100644 --- a/linux/crates/storage/src/paths.rs +++ b/linux/crates/storage/src/paths.rs @@ -61,10 +61,6 @@ impl StoragePaths { pub fn drafts_dir(&self) -> PathBuf { self.data.join("drafts") } - - pub fn instance_lock(&self) -> PathBuf { - self.runtime.join("tablepro.lock") - } } #[cfg(test)] diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index 241e611e50..7bed407872 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -29,6 +29,7 @@ pub struct FakeConnection { read_only: bool, close_behaviour: CloseBehaviour, close_started: AtomicBool, + close_finished: AtomicBool, link: LinkWatch, closed: CancellationToken, counters: CallCounters, @@ -44,6 +45,7 @@ impl FakeConnection { read_only: script.read_only, close_behaviour: script.close, close_started: AtomicBool::new(false), + close_finished: AtomicBool::new(false), script: Mutex::new(script), link: LinkWatch::new(), closed: CancellationToken::new(), @@ -66,6 +68,12 @@ impl FakeConnection { self.closed.is_cancelled() } + /// Set once the first close has run to its end, delay included. A + /// close that hangs, or whose caller gave up on it, never sets it. + pub fn close_finished(&self) -> bool { + self.close_finished.load(Ordering::SeqCst) + } + /// The cancel token every call to `method` was handed, oldest first, /// so a test can tell its own stop reached the call. pub fn observed_cancels(&self, method: &str) -> Vec { @@ -395,6 +403,7 @@ impl Connection for FakeConnection { self.closed.cancel(); if first { self.close_behaviour.run().await; + self.close_finished.store(true, Ordering::SeqCst); } Ok(()) } @@ -480,6 +489,7 @@ mod tests { }); let outcome = tokio::time::timeout(Duration::from_secs(3600), hanging.close()).await; assert!(outcome.is_err(), "a hanging close returned"); + assert!(hanging.is_closed() && !hanging.close_finished()); let delayed = FakeConnection::new(ConnectionScript { close: CloseBehaviour::Delay(Duration::from_secs(5)), @@ -488,6 +498,7 @@ mod tests { let started = Instant::now(); delayed.close().await.unwrap(); assert!(started.elapsed() >= Duration::from_secs(5)); + assert!(delayed.close_finished()); let again = Instant::now(); delayed.close().await.unwrap(); assert_eq!(again.elapsed(), Duration::ZERO, "a second close waited again"); diff --git a/linux/data/app.tablepro.TablePro.desktop.in.in b/linux/data/app.tablepro.TablePro.desktop.in.in index d2e403678e..51cbc24f69 100644 --- a/linux/data/app.tablepro.TablePro.desktop.in.in +++ b/linux/data/app.tablepro.TablePro.desktop.in.in @@ -11,3 +11,8 @@ Type=Application Categories=Development;Database; DBusActivatable=true StartupNotify=true +Actions=new-window; + +[Desktop Action new-window] +Name=New Window +Exec=tablepro --new-window diff --git a/linux/data/resources/shortcuts-dialog.ui b/linux/data/resources/shortcuts-dialog.ui index e9d46bc5c5..1a4993756b 100644 --- a/linux/data/resources/shortcuts-dialog.ui +++ b/linux/data/resources/shortcuts-dialog.ui @@ -18,10 +18,16 @@ F5 + + + New window + app.new-window + + Open Preferences - <Primary>comma + app.preferences @@ -57,7 +63,7 @@ Quit - <Primary>q + app.quit diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 846454d1b6..90f3592a56 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -86,7 +86,6 @@ base directories and their fallbacks. | `history_database()` | `$XDG_STATE_HOME//history.db` | | `workspaces_dir()` | `$XDG_STATE_HOME//workspaces` | | `drafts_dir()` | `$XDG_DATA_HOME//drafts` | -| `instance_lock()` | `$XDG_RUNTIME_DIR/app//tablepro.lock` | `` is `tablepro` for an installed build and `tablepro-devel` for a development one, so `cargo run` never writes over an installed build's diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index ca5b4bb877..deef1f3b88 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -1,3 +1,13 @@ +crates/app/src/application/app_actions.rs +crates/app/src/application/app_services.rs +crates/app/src/application/app_window_host.rs +crates/app/src/application/imp.rs +crates/app/src/application/mod.rs +crates/app/src/application/quit_sequence.rs +crates/app/src/application/tests.rs +crates/app/src/application/window_host.rs +crates/app/src/application/window_launch.rs +crates/app/src/application/window_registry.rs crates/app/src/config.rs crates/app/src/i18n.rs crates/app/src/lib.rs @@ -33,7 +43,6 @@ crates/app/src/services/save_batch.rs crates/app/src/services/secret_labels.rs crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs -crates/app/src/services/single_instance.rs crates/app/src/services/structure_tracker.rs crates/app/src/services/structure_tracker_handle.rs crates/app/src/services/structure_tracker_registry.rs @@ -186,6 +195,7 @@ crates/app/src/ui/window_geometry.rs crates/app/src/workspace/close_decision.rs crates/app/src/workspace/close_guard.rs crates/app/src/workspace/close_guards.rs +crates/app/src/workspace/close_reason.rs crates/app/src/workspace/close_scope.rs crates/app/src/workspace/close_step.rs crates/app/src/workspace/dialog_response.rs From 35be64a4daeb5e610cb4ba346619b5f20493376c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:36:05 +0700 Subject: [PATCH 011/301] feat(linux-sqlite): rebuild tables for changes ALTER TABLE cannot make --- linux/CHANGELOG.md | 2 + linux/crates/drivers/sqlite/src/catalog.rs | 6 +- .../drivers/sqlite/src/catalog/dependents.rs | 2 +- .../sqlite/src/catalog/table_definition.rs | 63 +- linux/crates/drivers/sqlite/src/ddl.rs | 514 ++------ .../src/ddl/column_constraint_segment.rs | 32 + .../sqlite/src/ddl/create_table_source.rs | 1105 +++++++++++++++++ .../sqlite/src/ddl/native_change_rules.rs | 164 +++ .../drivers/sqlite/src/ddl/rebuild_plan.rs | 892 +++++++++++++ .../drivers/sqlite/src/ddl/source_element.rs | 72 ++ .../sqlite/src/ddl/source_read_error.rs | 64 + linux/crates/drivers/sqlite/src/ddl/tests.rs | 984 +++++++++++++++ linux/crates/drivers/sqlite/src/metadata.rs | 39 +- .../drivers/sqlite/tests/integration/ddl.rs | 654 +++++++++- .../0010-ddl-plans-and-table-definitions.md | 5 +- linux/docs/drivers/sqlite.md | 16 + 16 files changed, 4169 insertions(+), 445 deletions(-) create mode 100644 linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/create_table_source.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/source_element.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/source_read_error.rs create mode 100644 linux/crates/drivers/sqlite/src/ddl/tests.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 71337c5a22..12c84819a5 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Save Changes dialog on close that lists every tab with unsaved changes and saves only the ones left checked. - Logout waits while there are unsaved changes, and logout and suspend wait while a save is running. - Auto-increment on and off for PostgreSQL columns in the Structure tab, numbering on from the largest existing value. +- SQLite column type, nullability, default and primary key changes, and foreign keys added or removed, in the Structure tab. ### Changed @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Show CREATE TABLE gives the server's own statement on MySQL, ClickHouse and SQLite. - Structure tab sets a column NOT NULL when it joins the primary key, and gives its nullability back when it leaves. - Auto increment in the Structure tab no longer needs the primary key on PostgreSQL and SQL Server. +- SQLite foreign keys show the name their CREATE TABLE gives them. ### Fixed diff --git a/linux/crates/drivers/sqlite/src/catalog.rs b/linux/crates/drivers/sqlite/src/catalog.rs index 7976b24aec..90ed97c8fb 100644 --- a/linux/crates/drivers/sqlite/src/catalog.rs +++ b/linux/crates/drivers/sqlite/src/catalog.rs @@ -17,10 +17,10 @@ use tablepro_core::column::{ use tablepro_core::sql_syntax::{LiteralToken, MAX_EXPRESSION_LEN, SqlGrammar, parse_expression}; use tablepro_core::value::{SqlDecimal, Value}; -pub(crate) use dependents::{SchemaRow, select_dependents}; +pub(crate) use dependents::{SchemaRow, select_dependents, words}; pub use runtime_version::{SQLITE_MIN_VERSION, SqliteVersion, check_runtime_version}; pub(crate) use sqlite_column_row::{SqliteColumnRow, TableFacts}; -pub(crate) use table_definition::{InboundKeyRow, foreign_key_name, generated_expressions, inbound_foreign_keys}; +pub(crate) use table_definition::{InboundKeyRow, foreign_key_names, generated_expressions, inbound_foreign_keys}; /// The schema unqualified names resolve to. pub(crate) const MAIN_SCHEMA: &str = "main"; @@ -71,7 +71,7 @@ pub(crate) fn dependents_sql(schema_sql: &str) -> String { pub(crate) fn inbound_keys_sql(schema_sql: &str) -> String { format!( - "SELECT m.name AS child, f.id AS id, f.\"from\" AS \"from\", f.\"to\" AS \"to\" \ + "SELECT m.name AS child, m.sql AS child_sql, f.id AS id, f.\"from\" AS \"from\", f.\"to\" AS \"to\" \ FROM {schema_sql}.sqlite_schema m, pragma_foreign_key_list(m.name, ?2) f \ WHERE m.type = 'table' AND f.\"table\" = ?1 COLLATE NOCASE ORDER BY m.name, f.id, f.seq" ) diff --git a/linux/crates/drivers/sqlite/src/catalog/dependents.rs b/linux/crates/drivers/sqlite/src/catalog/dependents.rs index e4abfb940c..12660818c7 100644 --- a/linux/crates/drivers/sqlite/src/catalog/dependents.rs +++ b/linux/crates/drivers/sqlite/src/catalog/dependents.rs @@ -66,7 +66,7 @@ fn keeps(row: &SchemaRow, mentions: &HashSet, table: &str, names: &[Stri /// Every identifier the SQL names, lowercased and unquoted. A name /// inside a string literal or a comment is not a reference. -fn words(sql: &str) -> HashSet { +pub(crate) fn words(sql: &str) -> HashSet { let plan = ScriptPlan::build( sql, SqlGrammar::Sqlite, diff --git a/linux/crates/drivers/sqlite/src/catalog/table_definition.rs b/linux/crates/drivers/sqlite/src/catalog/table_definition.rs index f7fe5b4490..7563507edd 100644 --- a/linux/crates/drivers/sqlite/src/catalog/table_definition.rs +++ b/linux/crates/drivers/sqlite/src/catalog/table_definition.rs @@ -7,11 +7,15 @@ use tablepro_core::column::SqlExpression; use tablepro_core::ddl::InboundForeignKey; use tablepro_core::meta::TableRef; +use crate::ddl::declared_foreign_key_names; + /// One row of the inbound foreign key query: a key column of another /// table that points at this one. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct InboundKeyRow { pub child: String, + /// The child's CREATE TABLE, where the key's declared name is. + pub child_sql: Option, pub id: i64, pub from: String, /// `None` when the key names no columns, which means the parent's @@ -29,10 +33,21 @@ pub(crate) fn inbound_foreign_keys( let mut keys: Vec = Vec::new(); let mut positions: HashMap<(String, i64), usize> = HashMap::new(); let mut unnamed_targets: Vec = Vec::new(); + let mut names: HashMap> = HashMap::new(); for row in rows { + let child_names = names.entry(row.child.clone()).or_insert_with(|| { + let declared = row.child_sql.as_deref().and_then(declared_foreign_key_names); + let count = declared.as_ref().map_or(0, Vec::len); + foreign_key_names(&row.child, declared, count) + }); + let name = usize::try_from(row.id) + .ok() + .and_then(|id| child_names.get(id)) + .cloned() + .unwrap_or_else(|| foreign_key_name(&row.child, row.id)); let position = *positions.entry((row.child.clone(), row.id)).or_insert_with(|| { keys.push(InboundForeignKey { - name: foreign_key_name(&row.child, row.id), + name, table: TableRef::new(schema.map(str::to_owned), row.child.clone()), columns: Vec::new(), referenced_columns: Vec::new(), @@ -57,13 +72,32 @@ pub(crate) fn inbound_foreign_keys( keys } -/// SQLite does not keep a foreign key's constraint name anywhere the -/// pragmas reach, so the name is made from the child table and the -/// key's position, which stays the same while the schema does. +/// The name of a key declared without one, made from the child table +/// and the key's position, which stays the same while the schema does. pub(crate) fn foreign_key_name(table: &str, id: i64) -> String { format!("fk_{table}_{id}") } +/// A name for each of a table's `count` foreign keys, by pragma id. The +/// pragmas do not report the name a CONSTRAINT clause gave a key, so it +/// comes from the table's CREATE TABLE, `declared`. A key without one, +/// or with a name an earlier key took, gets a made-up one. +pub(crate) fn foreign_key_names(table: &str, declared: Option>>, count: usize) -> Vec { + let declared = declared.filter(|declared| declared.len() == count).unwrap_or_default(); + let mut names: Vec = Vec::with_capacity(count); + for id in 0..count { + let made_up = || foreign_key_name(table, i64::try_from(id).unwrap_or(i64::MAX)); + let name = declared + .get(id) + .cloned() + .flatten() + .filter(|name| !names.iter().any(|taken| taken.eq_ignore_ascii_case(name))) + .unwrap_or_else(made_up); + names.push(name); + } + names +} + /// The expressions of the generated columns, read from the table's own /// CREATE TABLE. The pragmas say a column is generated but not how. /// The text is the parser's rendering of the expression: the table's @@ -102,6 +136,7 @@ mod tests { fn key_row(child: &str, id: i64, from: &str, to: Option<&str>) -> InboundKeyRow { InboundKeyRow { child: child.to_owned(), + child_sql: None, id, from: from.to_owned(), to: to.map(str::to_owned), @@ -126,6 +161,26 @@ mod tests { assert_eq!(keys[1].referenced_columns, ["a", "b"]); } + #[test] + fn a_declared_constraint_name_is_the_key_name() { + let child_sql = "CREATE TABLE lines (a INTEGER CONSTRAINT to_orders REFERENCES orders, b INTEGER REFERENCES \ + orders, CONSTRAINT to_orders FOREIGN KEY (a) REFERENCES orders)"; + let rows = [(0, "a"), (1, "b"), (2, "a")].map(|(id, from)| InboundKeyRow { + child_sql: Some(child_sql.to_owned()), + ..key_row("lines", id, from, None) + }); + + let keys = inbound_foreign_keys(None, rows.to_vec(), &["id".to_owned()]); + + let names: Vec<&str> = keys.iter().map(|key| key.name.as_str()).collect(); + assert_eq!(names, ["to_orders", "fk_lines_1", "fk_lines_2"]); + assert_eq!( + foreign_key_names("t", Some(vec![Some("x".to_owned())]), 2), + ["fk_t_0", "fk_t_1"], + "a list that does not match the catalogue is not used" + ); + } + #[test] fn generated_expressions_come_from_the_create_statement() { let expressions = generated_expressions( diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs index 23393692b3..5466950d9b 100644 --- a/linux/crates/drivers/sqlite/src/ddl.rs +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -1,36 +1,50 @@ +//! How SQLite plans structure changes: its own ALTER TABLE where that +//! can make the change, and a rebuild of the table where it cannot. + +mod column_constraint_segment; +mod create_table_source; +mod native_change_rules; +mod rebuild_plan; +mod source_element; +mod source_read_error; + use tablepro_core::ForeignKeyInfo; use tablepro_core::column::ColumnDefault; -use tablepro_core::ddl::ddl_sql::{column_list, default_sql, foreign_key_sql, index_keys_sql, parenthesized}; +use tablepro_core::ddl::ddl_sql::{column_list, default_sql, index_keys_sql, parenthesized}; use tablepro_core::ddl::{ - BuildDdlError, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, SchemaObjectKind, - TableChanges, TableDefinition, TableDraft, + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, + GeneratedStorage, Permission, RefusalReason, SchemaObjectKind, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, TableRef}; +use self::create_table_source::CreateTableSource; +pub(crate) use self::create_table_source::declared_foreign_key_names; use crate::dialect::SqliteDialect; -/// What SQLite's ALTER TABLE does natively. Everything else needs the -/// table rebuilt, which these plans do not do yet. +/// A change ALTER TABLE cannot make rebuilds the table, so every change +/// is offered. The rebuild needs the table's stored CREATE TABLE, and +/// `column_capabilities` refuses the changes only a rebuild makes when +/// that text cannot be read. static CAPABILITIES: DdlCapabilities = DdlCapabilities { create_table: true, rename_table: true, add_column: true, drop_column: true, rename_column: true, - alter_column_type: false, - alter_column_nullability: false, - alter_column_default: false, - alter_primary_key: false, - alter_auto_increment: false, + alter_column_type: true, + alter_column_nullability: true, + alter_column_default: true, + alter_primary_key: true, + alter_auto_increment: true, // AUTOINCREMENT belongs to the INTEGER PRIMARY KEY. auto_increment_outside_primary_key: false, create_index: true, drop_index: true, expression_index_keys: true, partial_indexes: true, - add_foreign_key: false, - drop_foreign_key: false, + add_foreign_key: true, + drop_foreign_key: true, column_comments: false, drop_table_cascade: false, }; @@ -39,8 +53,6 @@ const TYPE_SUGGESTIONS: &[&str] = &[ "INTEGER", "TEXT", "REAL", "BLOB", "NUMERIC", "BOOLEAN", "DATETIME", "DATE", ]; -/// SQLite runs a schema change inside the write's transaction, one -/// native ALTER TABLE per change. impl DdlDialect for SqliteDialect { fn capabilities(&self) -> &DdlCapabilities { &CAPABILITIES @@ -50,6 +62,24 @@ impl DdlDialect for SqliteDialect { self } + /// The rebuild writes a new type or nullability beside a generated + /// column's expression rather than restating the column, so neither + /// loses the expression. + fn column_capabilities(&self, table: &TableDefinition, column: &ColumnDefinition) -> ColumnCapabilities { + let mut permissions = ColumnCapabilities::standard(&CAPABILITIES, table, column); + let readable = match CreateTableSource::of(table) { + Ok(_) => Permission::Allowed, + Err(_) => Permission::Refused(RefusalReason::UnreadableDefinition), + }; + let rebuild = || readable.clone(); + permissions.alter_type = rebuild(); + permissions.alter_nullability = rebuild(); + permissions.alter_default = permissions.alter_default.and(rebuild); + permissions.alter_primary_key = permissions.alter_primary_key.and(rebuild); + permissions.alter_auto_increment = permissions.alter_auto_increment.and(rebuild); + permissions + } + fn type_suggestions(&self) -> &'static [&'static str] { TYPE_SUGGESTIONS } @@ -58,8 +88,8 @@ impl DdlDialect for SqliteDialect { "TEXT" } - /// Foreign keys are declared inside CREATE TABLE, the only place - /// SQLite takes them. + /// Foreign keys are declared inside CREATE TABLE, and the + /// connection's `foreign_keys` setting enforces them. fn plan_create_table(&self, draft: &TableDraft) -> Result { draft.check_create(self)?; let table = TableRef::new(draft.table.schema.clone(), draft.table.name.trim()); @@ -82,16 +112,12 @@ impl DdlDialect for SqliteDialect { let key: Vec = key.into_iter().map(str::to_owned).collect(); definitions.push(format!("PRIMARY KEY ({})", column_list(self, &key))); } - // A foreign key names its parent bare: SQLite looks for it in the - // child's own schema and refuses a qualified name. - let local = TableRef::unqualified(table.name.clone()); - definitions.extend(draft.foreign_keys.iter().map(|key| { - let key = ForeignKeyInfo { - ref_schema: None, - ..key.clone() - }; - foreign_key_sql(self, &local, &key) - })); + definitions.extend( + draft + .foreign_keys + .iter() + .map(|key| foreign_key_constraint(self, key, &key.columns)), + ); let mut statements = vec![format!( "CREATE TABLE {} (\n {}\n)", self.qualify(&table), @@ -107,8 +133,49 @@ impl DdlDialect for SqliteDialect { Ok(DdlPlan::from_sql(statements)) } + /// Native statements when ALTER TABLE makes every change, otherwise + /// a rebuild that also runs the native ones. fn plan_alter_table(&self, original: &TableDefinition, changes: &TableChanges) -> Result { changes.check(self, original)?; + let source = CreateTableSource::of(original); + let Some(change) = native_change_rules::first_rebuild_change(original, source.as_ref().ok(), changes) else { + return self.native_plan(original, changes); + }; + let source = source.map_err(|error| { + tracing::debug!(%error, table = %original.table.name, "the stored CREATE TABLE could not be read"); + BuildDdlError::Refused { + column: change.column, + operation: change.operation, + reason: RefusalReason::UnreadableDefinition, + } + })?; + rebuild_plan::plan(self, original, &source, changes) + } + + /// The CREATE statement SQLite stored, then the stored statements of + /// the table's own indexes and triggers. Indexes SQLite made for a + /// constraint have no statement and come back with the table. + fn create_table_script(&self, definition: &TableDefinition) -> Result { + let Some(source) = &definition.source else { + return self.plan_create_table(&TableDraft::from_definition(definition)); + }; + let own = |table: &str| table.eq_ignore_ascii_case(&definition.table.name); + let dependents = source + .dependents + .iter() + .filter(|object| matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger)) + .filter(|object| own(&object.table)) + .map(|object| object.sql.clone()); + Ok(DdlPlan::from_sql( + std::iter::once(source.create_statement.clone()).chain(dependents), + )) + } +} + +impl SqliteDialect { + /// One native ALTER TABLE per change, inside the write's + /// transaction. + fn native_plan(&self, original: &TableDefinition, changes: &TableChanges) -> Result { let mut statements = Vec::new(); // SQLite keeps a renamed table in its schema, so the new name is // bare. @@ -143,12 +210,6 @@ impl DdlDialect for SqliteDialect { )); } for column in &changes.added_columns { - // The rows already there would have nothing to hold. - if !column.nullable && column.default.is_none() { - return Err(BuildDdlError::Unsupported( - DdlOperation::AddRequiredColumnWithoutDefault, - )); - } statements.push(format!( "ALTER TABLE {qualified} ADD COLUMN {}", self.column_definition(column, false)? @@ -163,27 +224,6 @@ impl DdlDialect for SqliteDialect { DdlPlan::for_changes(changes, statements) } - /// The CREATE statement SQLite stored, then the stored statements of - /// the table's own indexes and triggers. Indexes SQLite made for a - /// constraint have no statement and come back with the table. - fn create_table_script(&self, definition: &TableDefinition) -> Result { - let Some(source) = &definition.source else { - return self.plan_create_table(&TableDraft::from_definition(definition)); - }; - let own = |table: &str| table.eq_ignore_ascii_case(&definition.table.name); - let dependents = source - .dependents - .iter() - .filter(|object| matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger)) - .filter(|object| own(&object.table)) - .map(|object| object.sql.clone()); - Ok(DdlPlan::from_sql( - std::iter::once(source.create_statement.clone()).chain(dependents), - )) - } -} - -impl SqliteDialect { fn column_definition(&self, column: &DraftColumn, inline_key: bool) -> Result { let resolved = column.column_type.resolve(self, &column.name)?; let mut parts = vec![self.quote_identifier(&column.name), resolved.sql.clone()]; @@ -230,6 +270,28 @@ impl SqliteDialect { } } +/// A foreign key as a table constraint over `columns`. The parent is +/// named bare, since SQLite looks for it in the child's own schema and +/// refuses a qualified name, and a key with no parent columns points at +/// the parent's primary key. +fn foreign_key_constraint(dialect: &SqliteDialect, key: &ForeignKeyInfo, columns: &[String]) -> String { + let mut sql = format!( + "CONSTRAINT {} FOREIGN KEY ({}) REFERENCES {}", + dialect.quote_identifier(&key.name), + column_list(dialect, columns), + dialect.quote_identifier(&key.ref_table) + ); + if !key.ref_columns.is_empty() { + sql.push_str(&format!(" ({})", column_list(dialect, &key.ref_columns))); + } + for (clause, action) in [("ON DELETE", key.on_delete), ("ON UPDATE", key.on_update)] { + if let Some(action) = action.filter(|action| !action.is_default()) { + sql.push_str(&format!(" {clause} {}", action.as_sql())); + } + } + sql +} + /// An expression default goes in parentheses, apart from the three /// CURRENT_ keywords SQLite takes bare. fn default_clause(default: &ColumnDefault, sql: &str) -> String { @@ -243,348 +305,4 @@ fn default_clause(default: &ColumnDefault, sql: &str) -> String { } #[cfg(test)] -mod tests { - use tablepro_core::column::{ColumnInfo, SqlExpression}; - use tablepro_core::ddl::ReferentialAction; - use tablepro_core::ddl::{ - ColumnAttributes, ColumnDefinition, DraftType, InboundForeignKey, Permission, RefusalReason, - SchemaObjectSource, TableSource, diff_table, - }; - use tablepro_core::meta::IndexKey; - use tablepro_core::value::Value; - - use super::*; - use crate::catalog::SqliteVersion; - - fn dialect() -> SqliteDialect { - SqliteDialect::new(SqliteVersion::new(3, 46, 0)) - } - - fn typed(name: &str, declaration: &str) -> DraftColumn { - DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) - } - - fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { - ColumnDefinition::new( - ColumnInfo { - name: name.to_owned(), - column_type: dialect().parse_type(declaration).expect("a SQLite type"), - nullable: !primary_key, - primary_key, - is_auto_increment: false, - is_generated: false, - default: tablepro_core::column::ColumnDefault::None, - comment: None, - }, - ColumnAttributes::default(), - ) - } - - fn users() -> TableDefinition { - TableDefinition { - table: TableRef::unqualified("users"), - columns: vec![column("id", "INTEGER", true), column("email", "TEXT", false)], - primary_key: Some(tablepro_core::ddl::PrimaryKeyConstraint { - name: None, - columns: vec!["id".to_owned()], - }), - indexes: Vec::new(), - foreign_keys: Vec::new(), - inbound_foreign_keys: Vec::new(), - source: None, - } - } - - fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { - let mut draft = TableDraft::from_definition(original); - edit(&mut draft); - dialect().plan_alter_table(original, &diff_table(original, &draft)) - } - - fn sql(plan: Result) -> Vec { - plan.expect("the change plans") - .sql() - .into_iter() - .map(str::to_owned) - .collect() - } - - #[test] - fn plan_create_table_simple() { - let mut draft = TableDraft::new(TableRef::unqualified("notes")); - let mut id = typed("id", "INTEGER"); - id.primary_key = true; - id.nullable = false; - id.auto_increment = true; - let mut body = typed("body", "TEXT"); - body.nullable = false; - body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); - let mut created = typed("created_at", "TEXT"); - created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); - let mut token = typed("token", "TEXT"); - token.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("lower(hex(randomblob(4)))")); - draft.columns = vec![id, body, created, token]; - - assert_eq!( - sql(dialect().plan_create_table(&draft)), - [ - "CREATE TABLE \"notes\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n \"body\" TEXT NOT \ - NULL DEFAULT 'it''s',\n \"created_at\" TEXT DEFAULT CURRENT_TIMESTAMP,\n \"token\" TEXT DEFAULT \ - (lower(hex(randomblob(4))))\n)" - ] - ); - } - - #[test] - fn plan_create_table_composite_key_index_and_foreign_key() { - let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); - let mut order = typed("order_id", "INTEGER"); - order.primary_key = true; - order.nullable = false; - let mut line = typed("line", "INTEGER"); - line.primary_key = true; - line.nullable = false; - draft.columns = vec![order, line, typed("sku", "TEXT")]; - draft.indexes = vec![IndexInfo { - name: "by_sku".to_owned(), - keys: vec![IndexKey::Column { - name: "sku".to_owned(), - descending: false, - }], - unique: true, - primary: false, - predicate: Some("sku IS NOT NULL".to_owned()), - }]; - draft.foreign_keys = vec![ForeignKeyInfo { - name: "lines_order_fk".to_owned(), - columns: vec!["order_id".to_owned()], - ref_schema: None, - ref_table: "orders".to_owned(), - ref_columns: vec!["id".to_owned()], - on_delete: Some(ReferentialAction::Cascade), - on_update: None, - }]; - - assert_eq!( - sql(dialect().plan_create_table(&draft)), - [ - "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER NOT NULL,\n \"line\" INTEGER NOT \ - NULL,\n \"sku\" TEXT,\n PRIMARY KEY (\"order_id\", \"line\"),\n CONSTRAINT \"lines_order_fk\" \ - FOREIGN KEY (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE CASCADE\n)", - "CREATE UNIQUE INDEX \"main\".\"by_sku\" ON \"lines\" (\"sku\") WHERE sku IS NOT NULL", - ] - ); - } - - #[test] - fn plan_alter_table_takes_the_native_changes_and_refuses_the_rest() { - let native = alter(&users(), |draft| { - draft.table.name = "members".to_owned(); - draft.columns[1].name = "mail".to_owned(); - draft.columns.push(typed("nickname", "TEXT")); - }); - let nullability = alter(&users(), |draft| draft.columns[1].nullable = false); - let typed_change = alter(&users(), |draft| { - draft.columns[1].column_type = DraftType::Typed("BLOB".to_owned()); - }); - let default = alter(&users(), |draft| { - draft.columns[1].default = ColumnDefault::Literal(Value::Text("x".to_owned())); - }); - - assert_eq!( - sql(native), - [ - "ALTER TABLE \"users\" RENAME TO \"members\"", - "ALTER TABLE \"members\" RENAME COLUMN \"email\" TO \"mail\"", - "ALTER TABLE \"members\" ADD COLUMN \"nickname\" TEXT", - ] - ); - for refused in [nullability, typed_change, default] { - assert!( - matches!( - refused, - Err(BuildDdlError::Refused { - reason: RefusalReason::EngineUnsupported, - .. - }) - ), - "{refused:?}" - ); - } - } - - #[test] - fn a_swap_steps_aside_through_a_temporary_name() { - let mut table = users(); - table.columns.push(column("name", "TEXT", false)); - - let plan = alter(&table, |draft| { - draft.columns[1].name = "Name".to_owned(); - draft.columns[2].name = "email".to_owned(); - }); - - assert_eq!( - sql(plan), - [ - "ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", - "ALTER TABLE \"users\" RENAME COLUMN \"name\" TO \"email\"", - "ALTER TABLE \"users\" RENAME COLUMN \"__tablepro_rename_1\" TO \"Name\"", - ] - ); - } - - #[test] - fn a_required_column_without_a_default_cannot_be_added() { - let plan = alter(&users(), |draft| { - let mut required = typed("nickname", "TEXT"); - required.nullable = false; - draft.columns.push(required); - }); - - assert_eq!( - plan, - Err(BuildDdlError::Unsupported( - DdlOperation::AddRequiredColumnWithoutDefault - )) - ); - } - - #[test] - fn an_indexed_column_drop_drops_the_index_first() { - let mut table = users(); - table.indexes = vec![IndexInfo { - name: "by_email".to_owned(), - keys: vec![IndexKey::Column { - name: "email".to_owned(), - descending: false, - }], - unique: false, - primary: false, - predicate: None, - }]; - - let plan = alter(&table, |draft| { - draft.indexes.clear(); - draft.columns.retain(|column| column.name != "email"); - }); - - assert_eq!( - sql(plan), - ["DROP INDEX \"by_email\"", "ALTER TABLE \"users\" DROP COLUMN \"email\""] - ); - } - - #[test] - fn primary_key_referenced_by_foreign_key_is_refused() { - let mut table = users(); - // SQLite reports a reference to the primary key with a NULL - // target, which the catalogue resolves to the key's columns. - table.inbound_foreign_keys = vec![InboundForeignKey { - name: "fk_orders_0".to_owned(), - table: TableRef::unqualified("orders"), - columns: vec!["user_id".to_owned()], - referenced_columns: vec!["id".to_owned()], - }]; - - let permissions = dialect().column_capabilities(&table, &table.columns[1]); - let id = dialect().column_capabilities(&table, &table.columns[0]); - - assert_eq!( - permissions.alter_primary_key, - Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) - ); - assert_eq!( - id.drop, - Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) - ); - } - - #[test] - fn drop_of_referenced_column_is_refused() { - let mut table = users(); - table.inbound_foreign_keys = vec![InboundForeignKey { - name: "fk_orders_0".to_owned(), - table: TableRef::unqualified("orders"), - columns: vec!["user_email".to_owned()], - referenced_columns: vec!["email".to_owned()], - }]; - - let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); - - assert!( - matches!( - plan, - Err(BuildDdlError::Refused { - operation: DdlOperation::DropColumn, - reason: RefusalReason::ReferencedByForeignKeys(_), - .. - }) - ), - "{plan:?}" - ); - } - - #[test] - fn the_script_replays_the_stored_table_and_its_own_dependents() { - let mut table = users(); - let object = |kind, name: &str, owner: &str, sql: &str| SchemaObjectSource { - kind, - name: name.to_owned(), - table: owner.to_owned(), - sql: sql.to_owned(), - }; - table.source = Some(TableSource { - create_statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)".to_owned(), - dependents: vec![ - object( - SchemaObjectKind::Index, - "by_email", - "Users", - "CREATE INDEX by_email ON users (email)", - ), - object( - SchemaObjectKind::Index, - "by_total", - "orders", - "CREATE INDEX by_total ON orders (total)", - ), - object( - SchemaObjectKind::Trigger, - "stamp", - "users", - "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", - ), - object(SchemaObjectKind::View, "v", "v", "CREATE VIEW v AS SELECT * FROM users"), - ], - }); - - assert_eq!( - sql(dialect().create_table_script(&table)), - [ - "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)", - "CREATE INDEX by_email ON users (email)", - "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", - ] - ); - } - - #[test] - fn foreign_keys_and_comments_wait_for_the_rebuild() { - let dialect = dialect(); - let capabilities = DdlDialect::capabilities(&dialect); - - assert!(!capabilities.add_foreign_key && !capabilities.drop_foreign_key); - assert!(!capabilities.column_comments); - assert!(capabilities.partial_indexes && capabilities.expression_index_keys); - } - - #[test] - fn every_type_suggestion_parses() { - let dialect = dialect(); - - for suggestion in dialect.type_suggestions() { - assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); - } - assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); - } -} +mod tests; diff --git a/linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs b/linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs new file mode 100644 index 0000000000..bf9fde76a6 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/column_constraint_segment.rs @@ -0,0 +1,32 @@ +use std::ops::Range; + +use super::source_element::ForeignKeyTarget; + +/// One column constraint as the CREATE TABLE text spells it, with the +/// bytes it takes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ColumnConstraintSegment { + pub range: Range, + pub kind: ColumnConstraintKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ColumnConstraintKind { + /// `CONSTRAINT name`, which SQLite reads as its own entry naming the + /// constraint after it. + Name(String), + PrimaryKey { + autoincrement: bool, + }, + NotNull, + Null, + Unique, + Check, + Default, + Collate, + References(ForeignKeyTarget), + Generated, + /// `[NOT] DEFERRABLE ...` standing on its own, which SQLite applies + /// to the foreign key before it. + Deferrable, +} diff --git a/linux/crates/drivers/sqlite/src/ddl/create_table_source.rs b/linux/crates/drivers/sqlite/src/ddl/create_table_source.rs new file mode 100644 index 0000000000..f80ffc0b8c --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/create_table_source.rs @@ -0,0 +1,1105 @@ +//! The CREATE TABLE text SQLite stored for a table, read into the +//! pieces a rebuild splices: each column with its constraints, the +//! table constraints, and the options after the list. +//! +//! The reader follows the grammar of lang_createtable.html over the +//! script lexer's tokens, the lexer the editor splits scripts with and +//! that is tested against `sqlite3_complete`. A quoted name, a string +//! or a comment is one token there, and every token carries its bytes, +//! so what a rebuild does not change goes into the new table as it was +//! written. + +use std::collections::HashSet; +use std::ops::Range; + +use tablepro_core::ddl::TableDefinition; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; + +use super::column_constraint_segment::{ColumnConstraintKind, ColumnConstraintSegment}; +use super::source_element::{ + ElementKind, ForeignKeyTarget, SourceColumn, SourceElement, SourceForeignKey, TableConstraint, TableConstraintKind, +}; +use super::source_read_error::SourceReadError; + +/// One token that is not whitespace or a comment. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Token { + pub kind: ScriptTokenKind, + pub range: Range, +} + +/// The tokens of `sql` without whitespace and comments. An unclosed +/// string, name or comment makes the text unreadable. +pub(crate) fn tokens(sql: &str) -> Result, SourceReadError> { + let plan = ScriptPlan::build( + sql, + SqlGrammar::Sqlite, + LexicalSettings::default_for(SqlGrammar::Sqlite), + ); + let mut tokens: Vec = Vec::new(); + for token in plan.tokens(0..sql.len()) { + match token.kind { + ScriptTokenKind::Unterminated => return Err(SourceReadError::Unterminated), + kind if kind.is_trivia() => {} + // The lexer reads `X'00'` as a word and a string; SQLite reads + // one blob literal. + ScriptTokenKind::StringLiteral + if tokens.last().is_some_and(|last| { + last.kind == ScriptTokenKind::Word + && last.range.end == token.range.start + && sql + .get(last.range.clone()) + .is_some_and(|word| word.eq_ignore_ascii_case("x")) + }) => + { + if let Some(last) = tokens.last_mut() { + last.kind = ScriptTokenKind::StringLiteral; + last.range.end = token.range.end; + } + } + kind => tokens.push(Token { + kind, + range: token.range, + }), + } + } + Ok(tokens) +} + +/// A table's CREATE TABLE statement, read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct CreateTableSource<'a> { + sql: &'a str, + tokens: Vec, + /// The table's name, quotes removed. + pub table: String, + /// The byte of the `(` that opens the list. + pub open: usize, + /// The byte of the `)` that closes it. + pub close: usize, + pub elements: Vec, + /// Everything after the closing `)`, such as ` WITHOUT ROWID`. + pub options: Range, + pub without_rowid: bool, +} + +impl<'a> CreateTableSource<'a> { + pub(crate) fn read(sql: &'a str) -> Result { + let tokens = tokens(sql)?; + let mut reader = Reader { + sql, + tokens: &tokens, + position: 0, + }; + let (table, open) = reader.head()?; + let elements = reader.elements()?; + let close = reader.close()?; + let (options, without_rowid) = reader.options()?; + Ok(Self { + sql, + tokens, + table, + open, + close, + elements, + options, + without_rowid, + }) + } + + /// The statement stored for `definition`, read and checked against + /// what the catalogue says the table has. Columns pair by position, + /// which `pragma_table_xinfo` keeps in declaration order. + pub(crate) fn of(definition: &'a TableDefinition) -> Result { + let sql = definition + .source + .as_ref() + .map(|source| source.create_statement.as_str()) + .ok_or(SourceReadError::Missing)?; + let source = Self::read(sql)?; + if !source.table.eq_ignore_ascii_case(&definition.table.name) { + return Err(SourceReadError::TableName { + expected: definition.table.name.clone(), + found: source.table, + }); + } + let columns: Vec<&SourceColumn> = source.columns().map(|(_, column)| column).collect(); + if columns.len() != definition.columns.len() { + return Err(SourceReadError::ColumnCount { + expected: definition.columns.len(), + found: columns.len(), + }); + } + for (position, (column, defined)) in columns.iter().zip(&definition.columns).enumerate() { + if !column.name.eq_ignore_ascii_case(defined.name()) { + return Err(SourceReadError::ColumnName { + position, + expected: defined.name().to_owned(), + found: column.name.clone(), + }); + } + } + let keys = source.foreign_keys().len(); + if keys != definition.foreign_keys.len() { + return Err(SourceReadError::ForeignKeyCount { + expected: definition.foreign_keys.len(), + found: keys, + }); + } + Ok(source) + } + + pub(crate) fn sql(&self) -> &'a str { + self.sql + } + + pub(crate) fn text(&self, range: Range) -> &'a str { + self.sql.get(range).unwrap_or_default() + } + + /// The column elements with their index among all elements. + pub(crate) fn columns(&self) -> impl Iterator { + self.elements + .iter() + .enumerate() + .filter_map(|(index, element)| match &element.kind { + ElementKind::Column(column) => Some((index, column)), + ElementKind::Constraint(_) => None, + }) + } + + /// The table constraints with their index among all elements. + pub(crate) fn constraints(&self) -> impl Iterator { + self.elements + .iter() + .enumerate() + .filter_map(|(index, element)| match &element.kind { + ElementKind::Constraint(constraint) => Some((index, constraint)), + ElementKind::Column(_) => None, + }) + } + + /// Every foreign key in the order the text declares them. SQLite + /// numbers them from the last: `pragma_foreign_key_list` id 0 is + /// the last one here. + pub(crate) fn foreign_keys(&self) -> Vec> { + let mut keys = Vec::new(); + for (index, element) in self.elements.iter().enumerate() { + match &element.kind { + ElementKind::Column(column) => { + for (position, segment) in column.segments.iter().enumerate() { + if let ColumnConstraintKind::References(target) = &segment.kind { + let name = position + .checked_sub(1) + .and_then(|before| column.segments.get(before)) + .and_then(|before| match &before.kind { + ColumnConstraintKind::Name(name) => Some(name.as_str()), + _ => None, + }); + keys.push(SourceForeignKey { + name, + element: index, + segment: Some(position), + columns: std::slice::from_ref(&column.name), + target, + }); + } + } + } + ElementKind::Constraint(TableConstraint { + name, + kind: TableConstraintKind::ForeignKey { columns, target }, + }) => keys.push(SourceForeignKey { + name: name.as_deref(), + element: index, + segment: None, + columns, + target, + }), + ElementKind::Constraint(_) => {} + } + } + keys + } + + /// The column declared with AUTOINCREMENT, on its own PRIMARY KEY or + /// in a table PRIMARY KEY of that one column. + pub(crate) fn autoincrement_column(&self) -> Option<&str> { + let inline = self.columns().find_map(|(_, column)| { + column + .segments + .iter() + .any(|segment| matches!(segment.kind, ColumnConstraintKind::PrimaryKey { autoincrement: true })) + .then_some(column.name.as_str()) + }); + inline.or_else(|| { + self.constraints().find_map(|(_, constraint)| match &constraint.kind { + TableConstraintKind::PrimaryKey { + columns, + autoincrement: true, + } => match columns.as_slice() { + [only] => Some(only.as_str()), + _ => None, + }, + _ => None, + }) + }) + } + + /// The names `range` mentions, lowercased and unquoted. A name inside + /// a string literal is not a mention. + pub(crate) fn mentions(&self, range: Range) -> HashSet { + self.tokens + .iter() + .filter(|token| token.range.start >= range.start && token.range.end <= range.end) + .filter_map(|token| match token.kind { + ScriptTokenKind::Word => Some(self.text(token.range.clone()).to_lowercase()), + ScriptTokenKind::QuotedIdentifier => Some(unquote(self.text(token.range.clone())).to_lowercase()), + _ => None, + }) + .collect() + } +} + +/// The names a CREATE TABLE gives its foreign keys, in the order +/// `pragma_foreign_key_list` numbers them, which is from the last one +/// declared. `None` for a key declared without a name, and no list when +/// the text does not read. +pub(crate) fn declared_foreign_key_names(sql: &str) -> Option>> { + let source = CreateTableSource::read(sql).ok()?; + let mut names: Vec> = source + .foreign_keys() + .iter() + .map(|key| key.name.map(str::to_owned)) + .collect(); + names.reverse(); + Some(names) +} + +/// `"a""b"`, `` `a``b` ``, `[a b]` and `'a'` all name what SQLite +/// would read. A bare word is the name as written. +pub(crate) fn unquote(text: &str) -> String { + let mut characters = text.chars(); + match (characters.next(), characters.next_back()) { + (Some('['), Some(']')) => characters.as_str().to_owned(), + (Some(open @ ('"' | '`' | '\'')), Some(close)) if open == close => { + let doubled = format!("{open}{open}"); + characters.as_str().replace(&doubled, &open.to_string()) + } + _ => text.to_owned(), + } +} + +/// Words that start a column constraint, where a column's type ends. +const COLUMN_CONSTRAINT_STARTS: &[&str] = &[ + "CONSTRAINT", + "PRIMARY", + "NOT", + "NULL", + "UNIQUE", + "CHECK", + "DEFAULT", + "COLLATE", + "REFERENCES", + "AS", +]; + +const TABLE_CONSTRAINT_STARTS: &[&str] = &["CONSTRAINT", "PRIMARY", "UNIQUE", "CHECK", "FOREIGN"]; + +struct Reader<'s, 't> { + sql: &'s str, + tokens: &'t [Token], + position: usize, +} + +impl Reader<'_, '_> { + fn peek(&self) -> Option<&Token> { + self.tokens.get(self.position) + } + + fn text_of(&self, token: &Token) -> &str { + self.sql.get(token.range.clone()).unwrap_or_default() + } + + fn is_word(&self, ahead: usize, word: &str) -> bool { + self.tokens + .get(self.position + ahead) + .is_some_and(|token| token.kind == ScriptTokenKind::Word && self.text_of(token).eq_ignore_ascii_case(word)) + } + + fn at(&self, word: &str) -> bool { + self.is_word(0, word) + } + + fn at_any(&self, words: &[&str]) -> bool { + words.iter().any(|word| self.at(word)) + } + + fn at_kind(&self, kind: ScriptTokenKind) -> bool { + self.peek().is_some_and(|token| token.kind == kind) + } + + fn at_punct(&self, punct: &str) -> bool { + self.peek() + .is_some_and(|token| token.kind == ScriptTokenKind::Punct && self.text_of(token) == punct) + } + + fn eat(&mut self, word: &str) -> bool { + let found = self.at(word); + if found { + self.position += 1; + } + found + } + + fn eat_any(&mut self, words: &[&str]) -> bool { + words.iter().any(|word| self.eat(word)) + } + + fn expect(&mut self, word: &str) -> Result<(), SourceReadError> { + match self.eat(word) { + true => Ok(()), + false => Err(self.unexpected()), + } + } + + fn unexpected(&self) -> SourceReadError { + match self.peek() { + Some(token) => SourceReadError::Unexpected { + offset: token.range.start, + found: self.text_of(token).to_owned(), + }, + None => SourceReadError::Unterminated, + } + } + + /// The end of the last token read. + fn last_end(&self) -> usize { + self.position + .checked_sub(1) + .and_then(|last| self.tokens.get(last)) + .map_or(0, |token| token.range.end) + } + + /// A name: a word, a quoted name, or a string, which SQLite also + /// takes as a name. + fn name(&mut self) -> Result<(String, Range), SourceReadError> { + let Some(token) = self.peek().cloned() else { + return Err(SourceReadError::Unterminated); + }; + match token.kind { + ScriptTokenKind::Word | ScriptTokenKind::QuotedIdentifier | ScriptTokenKind::StringLiteral => { + self.position += 1; + Ok((unquote(self.text_of(&token)), token.range)) + } + _ => Err(self.unexpected()), + } + } + + /// A parenthesised group, up to the `)` that closes it. + fn group(&mut self) -> Result, SourceReadError> { + let Some(open) = self + .peek() + .cloned() + .filter(|token| token.kind == ScriptTokenKind::OpenParen) + else { + return Err(self.unexpected()); + }; + let mut depth = 0usize; + while let Some(token) = self.peek().cloned() { + self.position += 1; + match token.kind { + ScriptTokenKind::OpenParen => depth += 1, + ScriptTokenKind::CloseParen => { + depth -= 1; + if depth == 0 { + return Ok(open.range.start..token.range.end); + } + } + _ => {} + } + } + Err(SourceReadError::Unterminated) + } + + /// `( name [COLLATE c] [ASC|DESC], ... )`, the names only. + fn name_list(&mut self) -> Result, SourceReadError> { + if !self.at_kind(ScriptTokenKind::OpenParen) { + return Err(self.unexpected()); + } + self.position += 1; + let mut names = Vec::new(); + loop { + names.push(self.name()?.0); + loop { + let Some(token) = self.peek() else { + return Err(SourceReadError::Unterminated); + }; + match token.kind { + ScriptTokenKind::CloseParen => { + self.position += 1; + return Ok(names); + } + ScriptTokenKind::OpenParen => { + self.group()?; + } + _ if self.at_punct(",") => { + self.position += 1; + break; + } + _ => self.position += 1, + } + } + } + } + + /// `CREATE [TEMP] TABLE [IF NOT EXISTS] [schema.]name (`, giving the + /// name and the byte of the `(`. + fn head(&mut self) -> Result<(String, usize), SourceReadError> { + if !self.eat("CREATE") { + return Err(SourceReadError::NotCreateTable); + } + self.eat_any(&["TEMP", "TEMPORARY"]); + if !self.eat("TABLE") { + return Err(SourceReadError::NotCreateTable); + } + if self.eat("IF") { + self.expect("NOT")?; + self.expect("EXISTS")?; + } + let (mut table, _) = self.name()?; + if self.at_punct(".") { + self.position += 1; + table = self.name()?.0; + } + match self.peek() { + Some(token) if token.kind == ScriptTokenKind::OpenParen => { + let open = token.range.start; + self.position += 1; + Ok((table, open)) + } + _ => Err(SourceReadError::NotCreateTable), + } + } + + /// The columns, then the table constraints, which SQLite lets follow + /// one another without a comma. + fn elements(&mut self) -> Result, SourceReadError> { + let mut elements = Vec::new(); + let mut constraints = false; + loop { + let start = self.peek().ok_or(SourceReadError::Unterminated)?.range.start; + let kind = if self.at_any(TABLE_CONSTRAINT_STARTS) { + constraints = true; + ElementKind::Constraint(self.table_constraint()?) + } else if constraints { + return Err(self.unexpected()); + } else { + ElementKind::Column(self.column()?) + }; + elements.push(SourceElement { + range: start..self.last_end(), + kind, + }); + if self.at_punct(",") { + self.position += 1; + } else if self.at_kind(ScriptTokenKind::CloseParen) { + return Ok(elements); + } else if !(constraints && self.at_any(TABLE_CONSTRAINT_STARTS)) { + return Err(self.unexpected()); + } + } + } + + fn close(&mut self) -> Result { + match self.peek() { + Some(token) if token.kind == ScriptTokenKind::CloseParen => { + let close = token.range.start; + self.position += 1; + Ok(close) + } + _ => Err(self.unexpected()), + } + } + + /// `WITHOUT ROWID` and `STRICT`, comma separated, up to the end or a + /// closing semicolon. + fn options(&mut self) -> Result<(Range, bool), SourceReadError> { + let start = self.last_end(); + let mut end = start; + let mut without_rowid = false; + while let Some(token) = self.peek().cloned() { + match token.kind { + ScriptTokenKind::Semicolon => { + self.position += 1; + return match self.peek() { + Some(_) => Err(self.unexpected()), + None => Ok((start..end, without_rowid)), + }; + } + ScriptTokenKind::Word => { + without_rowid |= self.at("WITHOUT") && self.is_word(1, "ROWID"); + } + _ if self.at_punct(",") => {} + _ => return Err(self.unexpected()), + } + end = token.range.end; + self.position += 1; + } + Ok((start..end, without_rowid)) + } + + fn column(&mut self) -> Result { + let (name, name_range) = self.name()?; + let type_range = self.type_name()?; + let segments = self.column_constraints()?; + Ok(SourceColumn { + name, + name_range, + type_range, + segments, + }) + } + + fn at_column_constraint(&self) -> bool { + self.at_any(COLUMN_CONSTRAINT_STARTS) || (self.at("GENERATED") && self.is_word(1, "ALWAYS")) + } + + /// The type: names up to the first constraint, then at most one + /// parenthesised size. + fn type_name(&mut self) -> Result>, SourceReadError> { + let first = self.position; + while let Some(token) = self.peek() { + match token.kind { + ScriptTokenKind::Word if self.at_column_constraint() => break, + ScriptTokenKind::Word | ScriptTokenKind::QuotedIdentifier | ScriptTokenKind::StringLiteral => { + self.position += 1; + } + ScriptTokenKind::OpenParen if self.position > first => { + self.group()?; + break; + } + _ => break, + } + } + Ok(self + .tokens + .get(first) + .filter(|_| self.position > first) + .map(|token| token.range.start..self.last_end())) + } + + fn column_constraints(&mut self) -> Result, SourceReadError> { + let mut segments = Vec::new(); + loop { + let Some(token) = self.peek() else { + return Err(SourceReadError::Unterminated); + }; + if token.kind == ScriptTokenKind::CloseParen || self.at_punct(",") { + return Ok(segments); + } + let start = token.range.start; + let kind = self.column_constraint()?; + segments.push(ColumnConstraintSegment { + range: start..self.last_end(), + kind, + }); + } + } + + fn column_constraint(&mut self) -> Result { + if self.eat("CONSTRAINT") { + return Ok(ColumnConstraintKind::Name(self.name()?.0)); + } + if self.eat("PRIMARY") { + self.expect("KEY")?; + self.eat_any(&["ASC", "DESC"]); + self.conflict()?; + let autoincrement = self.eat("AUTOINCREMENT"); + return Ok(ColumnConstraintKind::PrimaryKey { autoincrement }); + } + // NOT NULL and NOT DEFERRABLE share their first word. + if self.at("NOT") && self.is_word(1, "NULL") { + self.position += 2; + self.conflict()?; + return Ok(ColumnConstraintKind::NotNull); + } + if self.at("DEFERRABLE") || (self.at("NOT") && self.is_word(1, "DEFERRABLE")) { + self.deferrable()?; + return Ok(ColumnConstraintKind::Deferrable); + } + if self.eat("NULL") { + self.conflict()?; + return Ok(ColumnConstraintKind::Null); + } + if self.eat("UNIQUE") { + self.conflict()?; + return Ok(ColumnConstraintKind::Unique); + } + if self.eat("CHECK") { + self.group()?; + return Ok(ColumnConstraintKind::Check); + } + if self.eat("DEFAULT") { + self.default_value()?; + return Ok(ColumnConstraintKind::Default); + } + if self.eat("COLLATE") { + self.name()?; + return Ok(ColumnConstraintKind::Collate); + } + if self.eat("REFERENCES") { + return Ok(ColumnConstraintKind::References(self.references()?)); + } + if self.eat("GENERATED") { + self.expect("ALWAYS")?; + self.expect("AS")?; + self.generated()?; + return Ok(ColumnConstraintKind::Generated); + } + if self.eat("AS") { + self.generated()?; + return Ok(ColumnConstraintKind::Generated); + } + Err(self.unexpected()) + } + + /// `ON CONFLICT resolution`, where one is allowed. + fn conflict(&mut self) -> Result<(), SourceReadError> { + if self.at("ON") && self.is_word(1, "CONFLICT") { + self.position += 2; + if !self.eat_any(&["ROLLBACK", "ABORT", "FAIL", "IGNORE", "REPLACE"]) { + return Err(self.unexpected()); + } + } + Ok(()) + } + + /// One item: a parenthesised expression, a signed number, a literal + /// or a name. `DEFAULT NULL NOT NULL` is a NULL default on a NOT NULL + /// column. + fn default_value(&mut self) -> Result<(), SourceReadError> { + if self.at_kind(ScriptTokenKind::OpenParen) { + self.group()?; + return Ok(()); + } + if self.at_punct("+") || self.at_punct("-") { + self.position += 1; + } + match self.peek().map(|token| token.kind) { + Some( + ScriptTokenKind::Number + | ScriptTokenKind::StringLiteral + | ScriptTokenKind::Word + | ScriptTokenKind::QuotedIdentifier, + ) => { + self.position += 1; + Ok(()) + } + _ => Err(self.unexpected()), + } + } + + /// The parenthesised expression and the storage after it. SQLite's + /// grammar takes any name there and checks it later. + fn generated(&mut self) -> Result<(), SourceReadError> { + self.group()?; + self.eat_any(&["STORED", "VIRTUAL"]); + Ok(()) + } + + /// What follows REFERENCES: the parent, its columns, the actions, + /// MATCH, and whether the check is deferred. + fn references(&mut self) -> Result { + let (table, _) = self.name()?; + let columns = match self.at_kind(ScriptTokenKind::OpenParen) { + true => self.name_list()?, + false => Vec::new(), + }; + loop { + if self.at("ON") && !self.is_word(1, "CONFLICT") { + self.position += 1; + if !self.eat_any(&["DELETE", "UPDATE", "INSERT"]) { + return Err(self.unexpected()); + } + self.action()?; + } else if self.eat("MATCH") { + self.name()?; + } else { + break; + } + } + if self.at("DEFERRABLE") || (self.at("NOT") && self.is_word(1, "DEFERRABLE")) { + self.deferrable()?; + } + Ok(ForeignKeyTarget { table, columns }) + } + + fn action(&mut self) -> Result<(), SourceReadError> { + let known = if self.eat("SET") { + self.eat_any(&["NULL", "DEFAULT"]) + } else if self.eat("NO") { + self.eat("ACTION") + } else { + self.eat_any(&["CASCADE", "RESTRICT"]) + }; + match known { + true => Ok(()), + false => Err(self.unexpected()), + } + } + + fn deferrable(&mut self) -> Result<(), SourceReadError> { + self.eat("NOT"); + self.expect("DEFERRABLE")?; + if self.eat("INITIALLY") && !self.eat_any(&["DEFERRED", "IMMEDIATE"]) { + return Err(self.unexpected()); + } + Ok(()) + } + + fn table_constraint(&mut self) -> Result { + let name = match self.eat("CONSTRAINT") { + true => Some(self.name()?.0), + false => None, + }; + let kind = if self.eat("PRIMARY") { + self.expect("KEY")?; + let start = self.position; + let columns = self.name_list()?; + let autoincrement = self + .tokens + .get(start..self.position) + .unwrap_or_default() + .iter() + .any(|token| { + token.kind == ScriptTokenKind::Word && self.text_of(token).eq_ignore_ascii_case("AUTOINCREMENT") + }); + self.conflict()?; + TableConstraintKind::PrimaryKey { columns, autoincrement } + } else if self.eat("UNIQUE") { + let columns = self.name_list()?; + self.conflict()?; + TableConstraintKind::Unique { columns } + } else if self.eat("CHECK") { + self.group()?; + self.conflict()?; + TableConstraintKind::Check + } else if self.eat("FOREIGN") { + self.expect("KEY")?; + let columns = self.name_list()?; + self.expect("REFERENCES")?; + let target = self.references()?; + TableConstraintKind::ForeignKey { columns, target } + } else if name.is_some() { + TableConstraintKind::NameOnly + } else { + return Err(self.unexpected()); + }; + Ok(TableConstraint { name, kind }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn read(sql: &str) -> CreateTableSource<'_> { + CreateTableSource::read(sql).unwrap_or_else(|error| panic!("{sql}: {error}")) + } + + fn column<'s>(source: &'s CreateTableSource<'_>, name: &str) -> &'s SourceColumn { + source + .columns() + .map(|(_, column)| column) + .find(|column| column.name == name) + .unwrap_or_else(|| panic!("no column {name}")) + } + + fn kinds(column: &SourceColumn) -> Vec<&ColumnConstraintKind> { + column.segments.iter().map(|segment| &segment.kind).collect() + } + + fn segment_texts<'s>(source: &CreateTableSource<'s>, column: &SourceColumn) -> Vec<&'s str> { + column + .segments + .iter() + .map(|segment| source.text(segment.range.clone())) + .collect() + } + + fn type_text<'s>(source: &CreateTableSource<'s>, column: &SourceColumn) -> Option<&'s str> { + column.type_range.clone().map(|range| source.text(range)) + } + + #[test] + fn identifiers_in_every_quote_style_read_as_their_names() { + let source = read("CREATE TABLE \"we\"\"ird\" (\"a\"\"b\" TEXT, `c``d` INT, [e f] REAL, 'g' BLOB, plain)"); + + assert_eq!(source.table, "we\"ird"); + let names: Vec<&str> = source.columns().map(|(_, column)| column.name.as_str()).collect(); + assert_eq!(names, ["a\"b", "c`d", "e f", "g", "plain"]); + assert_eq!(type_text(&source, column(&source, "e f")), Some("REAL")); + assert_eq!(column(&source, "plain").type_range, None); + } + + #[test] + fn a_multibyte_identifier_keeps_its_byte_ranges() { + let sql = "CREATE TABLE café (naïve TEXT NOT NULL, \"日本\" VARCHAR(10) DEFAULT 'é')"; + let source = read(sql); + + let naive = column(&source, "naïve"); + assert_eq!(source.text(naive.name_range.clone()), "naïve"); + assert_eq!(segment_texts(&source, naive), ["NOT NULL"]); + let japanese = column(&source, "日本"); + assert_eq!(type_text(&source, japanese), Some("VARCHAR(10)")); + assert_eq!(segment_texts(&source, japanese), ["DEFAULT 'é'"]); + assert_eq!( + source.text(source.elements[1].range.clone()), + "\"日本\" VARCHAR(10) DEFAULT 'é'" + ); + } + + #[test] + fn default_null_is_one_item_before_not_null() { + let source = + read("CREATE TABLE t (a TEXT DEFAULT NULL NOT NULL, b INT DEFAULT -1 NULL, c DEFAULT x'0A' UNIQUE)"); + + assert_eq!( + segment_texts(&source, column(&source, "a")), + ["DEFAULT NULL", "NOT NULL"] + ); + assert_eq!( + kinds(column(&source, "b")), + [&ColumnConstraintKind::Default, &ColumnConstraintKind::Null] + ); + assert_eq!( + segment_texts(&source, column(&source, "c")), + ["DEFAULT x'0A'", "UNIQUE"] + ); + assert_eq!(column(&source, "c").type_range, None); + } + + #[test] + fn not_deferrable_is_part_of_the_foreign_key_and_not_null_is_not() { + let source = read( + "CREATE TABLE t (a INT REFERENCES p (id) ON DELETE SET NULL MATCH SIMPLE NOT DEFERRABLE INITIALLY \ + IMMEDIATE NOT NULL, b INT REFERENCES q DEFERRABLE INITIALLY DEFERRED)", + ); + + let a = column(&source, "a"); + assert_eq!( + segment_texts(&source, a), + [ + "REFERENCES p (id) ON DELETE SET NULL MATCH SIMPLE NOT DEFERRABLE INITIALLY IMMEDIATE", + "NOT NULL" + ] + ); + assert_eq!( + kinds(a)[0], + &ColumnConstraintKind::References(ForeignKeyTarget { + table: "p".to_owned(), + columns: vec!["id".to_owned()], + }) + ); + assert_eq!( + kinds(column(&source, "b")), + [&ColumnConstraintKind::References(ForeignKeyTarget { + table: "q".to_owned(), + columns: Vec::new(), + })] + ); + } + + #[test] + fn conflict_clauses_stay_with_their_constraint() { + let source = read( + "CREATE TABLE t (id INTEGER PRIMARY KEY DESC ON CONFLICT REPLACE AUTOINCREMENT, a TEXT NOT NULL ON \ + CONFLICT IGNORE UNIQUE ON CONFLICT FAIL, CONSTRAINT k UNIQUE (a) ON CONFLICT ABORT)", + ); + + assert_eq!( + kinds(column(&source, "id")), + [&ColumnConstraintKind::PrimaryKey { autoincrement: true }] + ); + assert_eq!( + segment_texts(&source, column(&source, "a")), + ["NOT NULL ON CONFLICT IGNORE", "UNIQUE ON CONFLICT FAIL"] + ); + assert_eq!(source.autoincrement_column(), Some("id")); + let constraints: Vec<&TableConstraint> = source.constraints().map(|(_, constraint)| constraint).collect(); + assert_eq!( + constraints, + [&TableConstraint { + name: Some("k".to_owned()), + kind: TableConstraintKind::Unique { + columns: vec!["a".to_owned()] + }, + }] + ); + } + + #[test] + fn inline_and_table_foreign_keys_come_in_declaration_order() { + let source = read( + "CREATE TABLE c (a INTEGER CONSTRAINT to_p REFERENCES p, b TEXT, CONSTRAINT second FOREIGN KEY (b) \ + REFERENCES q (k) ON DELETE CASCADE ON UPDATE NO ACTION MATCH FULL DEFERRABLE, FOREIGN KEY (a, b) \ + REFERENCES z)", + ); + + let keys = source.foreign_keys(); + let summary: Vec = keys + .iter() + .map(|key| { + format!( + "{:?} {} -> {} at {} {:?}", + key.name, + key.columns.join(","), + key.target.table, + key.element, + key.segment + ) + }) + .collect(); + assert_eq!( + summary, + [ + "Some(\"to_p\") a -> p at 0 Some(1)", + "Some(\"second\") b -> q at 2 None", + "None a,b -> z at 3 None", + ] + ); + assert_eq!( + declared_foreign_key_names(source.sql()), + Some(vec![None, Some("second".to_owned()), Some("to_p".to_owned())]) + ); + assert_eq!( + kinds(column(&source, "a")), + [ + &ColumnConstraintKind::Name("to_p".to_owned()), + &ColumnConstraintKind::References(ForeignKeyTarget { + table: "p".to_owned(), + columns: Vec::new() + }) + ] + ); + } + + #[test] + fn nested_check_and_stored_generated_column_read_whole() { + let source = read( + "CREATE TABLE t (a INT CHECK (a > (SELECT 0) AND a <> ')'), b INT GENERATED ALWAYS AS (a * (2 + 1)) \ + STORED NOT NULL, c AS (a || 'x') VIRTUAL, CHECK (b > a))", + ); + + assert_eq!( + segment_texts(&source, column(&source, "a")), + ["CHECK (a > (SELECT 0) AND a <> ')')"] + ); + assert_eq!( + segment_texts(&source, column(&source, "b")), + ["GENERATED ALWAYS AS (a * (2 + 1)) STORED", "NOT NULL"] + ); + assert_eq!(segment_texts(&source, column(&source, "c")), ["AS (a || 'x') VIRTUAL"]); + let (index, constraint) = source.constraints().next().unwrap(); + assert_eq!(constraint.kind, TableConstraintKind::Check); + assert_eq!(source.text(source.elements[index].range.clone()), "CHECK (b > a)"); + } + + #[test] + fn comments_stay_outside_the_elements_they_follow() { + let sql = + "CREATE TABLE t ( -- the key\n id INTEGER /* inline */ PRIMARY KEY, -- trailing\n name TEXT -- last\n)"; + let source = read(sql); + + let texts: Vec<&str> = source + .elements + .iter() + .map(|element| source.text(element.range.clone())) + .collect(); + assert_eq!(texts, ["id INTEGER /* inline */ PRIMARY KEY", "name TEXT"]); + assert_eq!(source.text(source.close..source.close + 1), ")"); + assert!(!source.mentions(source.elements[0].range.clone()).contains("inline")); + } + + #[test] + fn without_rowid_and_strict_options_are_kept() { + let source = read("CREATE TABLE t (k TEXT PRIMARY KEY, v ANY) WITHOUT ROWID, STRICT"); + let plain = read("CREATE TABLE t (k TEXT PRIMARY KEY) STRICT;"); + + assert!(source.without_rowid); + assert_eq!(source.text(source.options.clone()), " WITHOUT ROWID, STRICT"); + assert!(!plain.without_rowid); + assert_eq!(plain.text(plain.options.clone()), " STRICT"); + } + + #[test] + fn table_constraints_may_follow_one_another_without_commas() { + let source = read( + "CREATE TABLE t2(a INTEGER, b, UNIQUE(a) CHECK(a > 0) PRIMARY KEY (a COLLATE nocase DESC, b AUTOINCREMENT))", + ); + + let kinds: Vec<&TableConstraintKind> = source.constraints().map(|(_, constraint)| &constraint.kind).collect(); + assert_eq!( + kinds, + [ + &TableConstraintKind::Unique { + columns: vec!["a".to_owned()] + }, + &TableConstraintKind::Check, + &TableConstraintKind::PrimaryKey { + columns: vec!["a".to_owned(), "b".to_owned()], + autoincrement: true + }, + ] + ); + assert_eq!(source.autoincrement_column(), None); + } + + #[test] + fn a_type_of_several_words_ends_at_the_first_constraint() { + let source = + read("CREATE TABLE t (a UNSIGNED BIG INT NOT NULL, b DOUBLE PRECISION, c DECIMAL(10, 5) COLLATE BINARY)"); + + assert_eq!(type_text(&source, column(&source, "a")), Some("UNSIGNED BIG INT")); + assert_eq!(type_text(&source, column(&source, "b")), Some("DOUBLE PRECISION")); + assert_eq!(type_text(&source, column(&source, "c")), Some("DECIMAL(10, 5)")); + assert_eq!(segment_texts(&source, column(&source, "c")), ["COLLATE BINARY"]); + } + + #[test] + fn a_column_added_by_alter_table_reads_before_the_table_constraints() { + let source = read("CREATE TABLE \"p\" (\n \"id\" INTEGER NOT NULL, \"note\" TEXT,\n PRIMARY KEY (\"id\")\n)"); + + let names: Vec<&str> = source.columns().map(|(_, column)| column.name.as_str()).collect(); + assert_eq!(names, ["id", "note"]); + assert_eq!(source.constraints().count(), 1); + } + + #[test] + fn what_is_not_a_readable_create_table_is_refused() { + let cases = [ + "CREATE VIRTUAL TABLE f USING fts5(body)", + "CREATE TABLE t AS SELECT 1", + "CREATE TABLE t (a TEXT", + "CREATE TABLE t (a TEXT DEFAULT 'open)", + "CREATE TABLE t (PRIMARY KEY (a), a TEXT)", + "CREATE TABLE t (a TEXT) WITHOUT ROWID; SELECT 1", + "CREATE INDEX i ON t (a)", + ]; + + for sql in cases { + assert!(CreateTableSource::read(sql).is_err(), "{sql}"); + } + } + + #[test] + fn mentions_skip_strings_and_unquote_names() { + let source = read("CREATE TABLE t (a INT CHECK (\"B\" > 0 AND a <> 'c'))"); + + let mentions = source.mentions(source.elements[0].range.clone()); + assert!(mentions.contains("b") && mentions.contains("a")); + assert!(!mentions.contains("c")); + } +} diff --git a/linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs b/linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs new file mode 100644 index 0000000000..e65bd12201 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/native_change_rules.rs @@ -0,0 +1,164 @@ +//! Which changes SQLite's own ALTER TABLE makes, per the rules of +//! lang_altertable.html. Anything else rebuilds the table. + +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::{ColumnDefinition, DdlOperation, DraftColumn, TableChanges, TableDefinition}; +use tablepro_core::value::Value; + +use super::column_constraint_segment::ColumnConstraintKind; +use super::create_table_source::CreateTableSource; +use super::source_element::TableConstraintKind; + +/// The first change that needs a rebuild, named as the error that +/// refuses it would name it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct RebuildChange { + pub column: String, + pub operation: DdlOperation, +} + +impl RebuildChange { + fn new(column: &str, operation: DdlOperation) -> Self { + Self { + column: column.to_owned(), + operation, + } + } +} + +/// `None` when every change has a native statement: a table or column +/// rename, an index created or dropped, a column added or dropped where +/// ALTER TABLE allows it. +pub(super) fn first_rebuild_change( + original: &TableDefinition, + source: Option<&CreateTableSource<'_>>, + changes: &TableChanges, +) -> Option { + for alteration in &changes.altered_columns { + let changed = alteration.changed; + let operation = if changed.column_type { + DdlOperation::AlterColumnType + } else if changed.nullability { + DdlOperation::AlterColumnNullability + } else if changed.default { + DdlOperation::AlterColumnDefault + } else if changed.primary_key { + DdlOperation::AlterPrimaryKey + } else if changed.auto_increment { + DdlOperation::AlterAutoIncrement + } else { + continue; + }; + return Some(RebuildChange::new(alteration.original.name(), operation)); + } + if let Some(change) = &changes.primary_key { + let column = change + .to + .first() + .or_else(|| change.from.as_ref().and_then(|key| key.columns.first())) + .map_or("", String::as_str); + return Some(RebuildChange::new(column, DdlOperation::AlterPrimaryKey)); + } + if let Some(key) = changes.dropped_foreign_keys.first() { + let column = key.columns.first().map_or("", String::as_str); + return Some(RebuildChange::new(column, DdlOperation::DropForeignKey)); + } + if let Some(key) = changes.added_foreign_keys.first() { + let column = key.columns.first().map_or("", String::as_str); + return Some(RebuildChange::new(column, DdlOperation::AddForeignKey)); + } + if let Some(column) = changes.added_columns.iter().find(|column| !adds_natively(column)) { + return Some(RebuildChange::new(&column.name, DdlOperation::AddColumn)); + } + changes + .dropped_columns + .iter() + .find(|column| !drops_natively(original, source, column)) + .map(|column| RebuildChange::new(column.name(), DdlOperation::DropColumn)) +} + +/// ADD COLUMN takes no key, no default but a constant one, and NOT NULL +/// only with a default the rows already there can take. +fn adds_natively(column: &DraftColumn) -> bool { + let constant = !matches!(column.default, ColumnDefault::Expression(_)); + let has_value = !matches!( + column.default, + ColumnDefault::None | ColumnDefault::Literal(Value::Null) + ); + !column.primary_key && constant && (column.nullable || has_value) +} + +/// DROP COLUMN refuses a key or UNIQUE column, which a rebuild can drop +/// with its constraint. Every other use, by an index, a CHECK, a +/// generated column, a trigger or a view, SQLite checks against the +/// schema itself and refuses by name, which no rebuild could improve on +/// without breaking that use. +fn drops_natively( + original: &TableDefinition, + source: Option<&CreateTableSource<'_>>, + column: &ColumnDefinition, +) -> bool { + if column.info.primary_key + || original + .foreign_keys + .iter() + .any(|key| names(&key.columns, column.name())) + { + return false; + } + let Some(source) = source else { + return true; + }; + let own_unique = source + .columns() + .find(|(_, source_column)| source_column.name.eq_ignore_ascii_case(column.name())) + .is_some_and(|(_, source_column)| { + source_column.segments.iter().any(|segment| { + matches!( + segment.kind, + ColumnConstraintKind::Unique | ColumnConstraintKind::PrimaryKey { .. } + ) + }) + }); + let unique_alone = source.constraints().any(|(_, constraint)| match &constraint.kind { + TableConstraintKind::Unique { columns } => { + matches!(columns.as_slice(), [only] if only.eq_ignore_ascii_case(column.name())) + } + _ => false, + }); + !own_unique && !unique_alone +} + +pub(super) fn names(columns: &[String], name: &str) -> bool { + columns.iter().any(|column| column.eq_ignore_ascii_case(name)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::SqlExpression; + use tablepro_core::ddl::DraftType; + + use super::*; + + fn column(nullable: bool, default: ColumnDefault) -> DraftColumn { + let mut column = DraftColumn::new("c", DraftType::Typed("TEXT".to_owned())); + column.nullable = nullable; + column.default = default; + column + } + + #[test] + fn add_column_is_native_only_where_alter_table_allows_it() { + let literal = ColumnDefault::Literal(Value::Text("x".to_owned())); + let expression = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + + assert!(adds_natively(&column(true, ColumnDefault::None))); + assert!(adds_natively(&column(false, literal.clone()))); + assert!(!adds_natively(&column(false, ColumnDefault::None))); + assert!(!adds_natively(&column(false, ColumnDefault::Literal(Value::Null)))); + assert!(!adds_natively(&column(true, expression))); + let mut keyed = column(false, literal); + keyed.primary_key = true; + assert!(!adds_natively(&keyed)); + } +} diff --git a/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs b/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs new file mode 100644 index 0000000000..a786d42422 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs @@ -0,0 +1,892 @@ +//! A table rebuilt for the changes SQLite's ALTER TABLE cannot make, by +//! the procedure of lang_altertable.html section 7: a new table in the +//! revised shape, the rows copied into it, the old table dropped and the +//! new one renamed into its place, then its indexes, triggers and views +//! made again. The write protocol runs the plan in one transaction with +//! foreign keys off, and checks them before it commits. +//! +//! The new table is the stored CREATE TABLE with only the edited parts +//! replaced, so a CHECK, a COLLATE, a generated expression or a comment +//! the app does not model stays as it was written. Kept columns keep +//! their names through the copy, and renames run natively at the end, +//! where SQLite carries them into every index, trigger and view. + +use std::collections::{HashMap, HashSet}; +use std::ops::Range; + +use tablepro_core::ddl::ddl_sql::{column_list, default_sql}; +use tablepro_core::ddl::{ + BuildDdlError, ColumnAlteration, ColumnDefinition, ColumnRename, DdlOperation, DdlPlan, DraftColumn, RefusalReason, + SchemaObjectKind, SchemaObjectSource, TableChanges, TableDefinition, order_renames, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; +use tablepro_core::{ForeignKeyCheck, ForeignKeyMode}; + +use super::column_constraint_segment::ColumnConstraintKind; +use super::create_table_source::{CreateTableSource, tokens}; +use super::native_change_rules::names; +use super::source_element::{SourceColumn, SourceElement, TableConstraint, TableConstraintKind}; +use super::{default_clause, foreign_key_constraint}; +use crate::catalog::{MAIN_SCHEMA, words}; +use crate::dialect::SqliteDialect; + +/// The new table is built under this prefix and renamed over the old +/// one once the rows are in it. +const REBUILD_PREFIX: &str = "_tablepro_rebuild_"; + +/// An added column whose name a kept column holds until its own rename +/// runs is created under this prefix and renamed last. +const ADDED_PREFIX: &str = "__tablepro_added_"; + +pub(super) fn plan( + dialect: &SqliteDialect, + original: &TableDefinition, + source: &CreateTableSource<'_>, + changes: &TableChanges, +) -> Result { + let naming = Naming::new(original, changes); + let mut rebuild = Rebuild { + dialect, + original, + source, + changes, + dependents: original + .source + .as_ref() + .map_or(&[][..], |source| source.dependents.as_slice()), + naming, + key: Key::Kept, + dropped_keys: HashSet::new(), + }; + rebuild.key = rebuild.written_key()?; + rebuild.dropped_keys = rebuild.find_dropped_keys()?; + rebuild.check_dropped_columns()?; + rebuild.plan() +} + +/// The primary key of the new table. +#[derive(Debug, Clone, PartialEq, Eq)] +enum Key { + /// As the stored text declares it. + Kept, + /// Written again: the columns under the names they are created with, + /// and the one column that takes AUTOINCREMENT on its own definition. + Written { + columns: Vec, + autoincrement: Option, + }, +} + +/// Where each column of the finished table is while the rebuild runs. +struct Naming { + /// The name a column is created under in the new table, by its + /// final name folded. + created: HashMap, + /// The added columns, each under the name it is created with. + added: Vec, + /// The renames left for after the rebuild: the edit's own, and the + /// added columns stepping out of a temporary name. + renames: Vec, + /// The columns of the new table as it is created. + occupied: Vec, +} + +impl Naming { + fn new(original: &TableDefinition, changes: &TableChanges) -> Self { + let mut created = HashMap::new(); + let mut occupied = Vec::new(); + for column in original + .columns + .iter() + .filter(|column| !dropped(changes, column.name())) + { + created.insert(fold(&final_name(changes, column.name())), column.name().to_owned()); + occupied.push(column.name().to_owned()); + } + let mut taken: HashSet = occupied.iter().map(|name| fold(name)).collect(); + let mut reserved: HashSet = taken.clone(); + reserved.extend(created.keys().cloned()); + reserved.extend(changes.added_columns.iter().map(|column| fold(&column.name))); + let mut renames = changes.renamed_columns.clone(); + let mut added = Vec::new(); + let mut next = 1usize; + for column in &changes.added_columns { + let mut name = column.name.clone(); + if taken.contains(&fold(&name)) { + let temporary = loop { + let candidate = format!("{ADDED_PREFIX}{next}"); + next += 1; + if !reserved.contains(&fold(&candidate)) { + break candidate; + } + }; + renames.push(ColumnRename { + from: temporary.clone(), + to: column.name.clone(), + }); + name = temporary; + } + taken.insert(fold(&name)); + reserved.insert(fold(&name)); + created.insert(fold(&column.name), name.clone()); + occupied.push(name.clone()); + added.push(DraftColumn { name, ..column.clone() }); + } + Self { + created, + added, + renames, + occupied, + } + } + + fn created<'n>(&'n self, final_name: &'n str) -> &'n str { + self.created.get(&fold(final_name)).map_or(final_name, String::as_str) + } + + fn ordered_renames(&self) -> Vec { + order_renames( + &self.renames, + self.occupied.iter().map(String::as_str), + std::iter::empty(), + ) + } +} + +struct Rebuild<'a> { + dialect: &'a SqliteDialect, + original: &'a TableDefinition, + source: &'a CreateTableSource<'a>, + changes: &'a TableChanges, + dependents: &'a [SchemaObjectSource], + naming: Naming, + key: Key, + /// The foreign keys the edit drops, by their element and, for an + /// inline one, their segment. + dropped_keys: HashSet<(usize, Option)>, +} + +impl Rebuild<'_> { + fn plan(&self) -> Result { + let table = &self.original.table; + let schema = table.schema.clone(); + let temporary = TableRef::new(schema.clone(), format!("{REBUILD_PREFIX}{}", table.name)); + let qualified = self.dialect.qualify(table); + let create = self.create_statement(&temporary)?; + let copy = self.copy_statement(&temporary)?; + + let mut statements = Vec::new(); + // Dropped first, as the native plan does: SQLite refuses to drop an + // index it made for a constraint, and says so here rather than the + // rebuild quietly keeping it. + for index in &self.changes.dropped_indexes { + let index = TableRef::new(schema.clone(), index.name.clone()); + statements.push(format!("DROP INDEX {}", self.dialect.qualify(&index))); + } + // The rename below checks every view and trigger in the schema, + // and fails on one that names a table that is not there. + let elsewhere: Vec<&SchemaObjectSource> = self.dependents.iter().filter(|object| !self.owned(object)).collect(); + for kind in [SchemaObjectKind::Trigger, SchemaObjectKind::View] { + for object in elsewhere.iter().rev().filter(|object| object.kind == kind) { + let keyword = match kind { + SchemaObjectKind::Trigger => "TRIGGER", + _ => "VIEW", + }; + let name = TableRef::new(schema.clone(), object.name.clone()); + statements.push(format!("DROP {keyword} {}", self.dialect.qualify(&name))); + } + } + statements.push(create); + statements.push(copy); + if self.carries_sequence() { + let sequence = self.dialect.qualify(&TableRef::new(schema.clone(), "sqlite_sequence")); + let new_name = text_literal(&temporary.name); + statements.push(format!("DELETE FROM {sequence} WHERE name = {new_name}")); + statements.push(format!( + "INSERT INTO {sequence} (name, seq) SELECT {new_name}, seq FROM {sequence} WHERE name = {} COLLATE \ + NOCASE", + text_literal(&table.name) + )); + } + statements.push(format!("DROP TABLE {qualified}")); + statements.push(format!( + "ALTER TABLE {} RENAME TO {}", + self.dialect.qualify(&temporary), + self.dialect.quote_identifier(&table.name) + )); + for object in self.recreated() { + let sql = replayable(self.dialect, &object.sql, schema.as_deref()) + .ok_or_else(|| unreadable(&object.name, DdlOperation::AlterTable))?; + statements.push(sql); + } + for rename in self.naming.ordered_renames() { + statements.push(format!( + "ALTER TABLE {qualified} RENAME COLUMN {} TO {}", + self.dialect.quote_identifier(&rename.from), + self.dialect.quote_identifier(&rename.to) + )); + } + if let Some(name) = &self.changes.renamed_table { + statements.push(format!( + "ALTER TABLE {qualified} RENAME TO {}", + self.dialect.quote_identifier(name) + )); + } + let target = self.changes.target(table); + statements.extend( + self.changes + .added_indexes + .iter() + .map(|index| self.dialect.create_index(&target, index)), + ); + + let mut plan = DdlPlan::from_sql(statements); + plan.foreign_keys = ForeignKeyMode::SuspendedThenVerified { + checks: self.checks(&target), + }; + Ok(plan) + } + + /// The rebuilt table under both its names, and every table whose + /// foreign keys point at it, since their rows now point into a new + /// table. + fn checks(&self, target: &TableRef) -> Vec { + let table = &self.original.table; + let mut checks = vec![ForeignKeyCheck::new(Some(table.clone()), target.clone())]; + for key in &self.original.inbound_foreign_keys { + let child = &key.table; + let known = same_table(child, table) + || checks + .iter() + .any(|check| check.before.as_ref().is_some_and(|before| same_table(before, child))); + if !known { + checks.push(ForeignKeyCheck::same_table(child.clone())); + } + } + checks + } + + /// An index or trigger on the table goes with it when it is dropped. + fn owned(&self, object: &SchemaObjectSource) -> bool { + matches!(object.kind, SchemaObjectKind::Index | SchemaObjectKind::Trigger) + && object.table.eq_ignore_ascii_case(&self.original.table.name) + } + + /// The dependents made again after the rename, in the order the + /// schema first made them. + fn recreated(&self) -> impl Iterator { + self.dependents.iter().filter(|object| { + !(object.kind == SchemaObjectKind::Index + && self + .changes + .dropped_indexes + .iter() + .any(|index| index.name.eq_ignore_ascii_case(&object.name))) + }) + } + + fn carries_sequence(&self) -> bool { + let before = self.source.autoincrement_column().is_some(); + let after = match &self.key { + Key::Kept => before, + Key::Written { autoincrement, .. } => autoincrement.is_some(), + }; + before && after + } + + fn alteration(&self, original: &str) -> Option<&ColumnAlteration> { + self.changes + .altered_columns + .iter() + .find(|alteration| alteration.original.name() == original) + } + + fn altered_to(&self, final_name: &str) -> Option<&ColumnAlteration> { + self.changes + .altered_columns + .iter() + .find(|alteration| alteration.draft.name.eq_ignore_ascii_case(final_name)) + } + + fn added(&self, final_name: &str) -> Option<&DraftColumn> { + self.changes + .added_columns + .iter() + .find(|column| column.name.eq_ignore_ascii_case(final_name)) + } + + /// The kept column that ends up as `final_name`. + fn kept(&self, final_name: &str) -> Option<&ColumnDefinition> { + let created = self.naming.created(final_name); + self.original + .columns + .iter() + .filter(|column| !dropped(self.changes, column.name())) + .find(|column| column.name() == created) + } + + /// The primary key the finished table has, by final names. + fn final_key(&self) -> Vec { + if let Some(change) = &self.changes.primary_key { + return change.to.clone(); + } + let key: Vec = match &self.original.primary_key { + Some(key) => key.columns.clone(), + None => self + .original + .columns + .iter() + .filter(|column| column.info.primary_key) + .map(|column| column.name().to_owned()) + .collect(), + }; + key.iter().map(|name| final_name(self.changes, name)).collect() + } + + fn declared_type(&self, final_name: &str) -> Result { + if let Some(alteration) = self.altered_to(final_name) { + let column_type = &alteration.draft.column_type; + return Ok(column_type.resolve(self.dialect, alteration.original.name())?.sql); + } + if let Some(column) = self.added(final_name) { + return Ok(column.column_type.resolve(self.dialect, &column.name)?.sql); + } + Ok(self + .kept(final_name) + .and_then(|column| column.info.column_type.catalog.as_ref()) + .map(|catalog| catalog.declaration.as_sql().to_owned()) + .unwrap_or_default()) + } + + /// Whether the key column takes AUTOINCREMENT. One the edit left + /// alone keeps what it was declared with. + fn numbers_itself(&self, final_name: &str) -> bool { + if let Some(alteration) = self.altered_to(final_name) { + return alteration.draft.auto_increment; + } + if let Some(column) = self.added(final_name) { + return column.auto_increment; + } + let declared = self.source.autoincrement_column(); + self.kept(final_name) + .is_some_and(|column| declared.is_some_and(|name| name.eq_ignore_ascii_case(column.name()))) + } + + fn written_key(&self) -> Result { + let key = self.final_key(); + let numbering_toggled = self + .changes + .altered_columns + .iter() + .any(|alteration| alteration.changed.auto_increment && names(&key, &alteration.draft.name)); + if self.changes.primary_key.is_none() && !numbering_toggled { + return Ok(Key::Kept); + } + let autoincrement = match key.as_slice() { + [only] if self.numbers_itself(only) => { + if !self.declared_type(only)?.trim().eq_ignore_ascii_case("INTEGER") { + return Err(BuildDdlError::Refused { + column: only.clone(), + operation: DdlOperation::AlterAutoIncrement, + reason: RefusalReason::NotAnInteger, + }); + } + Some(self.naming.created(only).to_owned()) + } + _ => None, + }; + Ok(Key::Written { + columns: key.iter().map(|name| self.naming.created(name).to_owned()).collect(), + autoincrement, + }) + } + + /// The foreign keys the edit drops, found in the text. The catalogue + /// numbers them from the last one declared, so the n-th key it lists + /// is the n-th from the end here. + fn find_dropped_keys(&self) -> Result)>, BuildDdlError> { + let declared = self.source.foreign_keys(); + let listed = &self.original.foreign_keys; + let mut taken = vec![false; listed.len()]; + let mut dropped = HashSet::new(); + for key in &self.changes.dropped_foreign_keys { + let column = key.columns.first().map_or("", String::as_str); + let refuse = || unreadable(column, DdlOperation::DropForeignKey); + let id = listed + .iter() + .enumerate() + .position(|(id, listed)| listed == key && !taken.get(id).copied().unwrap_or(true)) + .ok_or_else(refuse)?; + if let Some(slot) = taken.get_mut(id) { + *slot = true; + } + let found = listed + .len() + .checked_sub(id + 1) + .and_then(|position| declared.get(position)) + .ok_or_else(refuse)?; + let matches = found.columns.len() == key.columns.len() + && found + .columns + .iter() + .zip(&key.columns) + .all(|(found, listed)| found.eq_ignore_ascii_case(listed)) + && found.target.table.eq_ignore_ascii_case(&key.ref_table); + if !matches { + return Err(refuse()); + } + dropped.insert((found.element, found.segment)); + } + Ok(dropped) + } + + /// Whether a table constraint is left out of the new table. + fn constraint_removed(&self, index: usize, constraint: &TableConstraint) -> bool { + match &constraint.kind { + TableConstraintKind::PrimaryKey { .. } => matches!(self.key, Key::Written { .. }), + TableConstraintKind::ForeignKey { .. } => self.dropped_keys.contains(&(index, None)), + // A UNIQUE of the dropped column alone goes with it. + TableConstraintKind::Unique { columns } => { + matches!(columns.as_slice(), [only] if dropped(self.changes, only)) + } + TableConstraintKind::Check | TableConstraintKind::NameOnly => false, + } + } + + /// Refuse to drop a column something the rebuild keeps still uses. + /// SQLite would refuse the new table, or worse, make the trigger or + /// view again and let it fail on its next use. + fn check_dropped_columns(&self) -> Result<(), BuildDdlError> { + for column in &self.changes.dropped_columns { + let name = column.name(); + // Mentions are lowercased whole, which can only find more. + let folded = name.to_lowercase(); + let mut users: Vec = self + .original + .foreign_keys + .iter() + .filter(|key| names(&key.columns, name) && !self.changes.dropped_foreign_keys.contains(key)) + .map(|key| key.name.clone()) + .collect(); + for (index, constraint) in self.source.constraints() { + let range = self.element(index).range.clone(); + let uses = !matches!(constraint.kind, TableConstraintKind::ForeignKey { .. }) + && !self.constraint_removed(index, constraint) + && self.source.mentions(range.clone()).contains(&folded); + if uses { + users.push( + constraint + .name + .clone() + .unwrap_or_else(|| self.source.text(range).to_owned()), + ); + } + } + for (_, other) in self.source.columns() { + if other.name.eq_ignore_ascii_case(name) || dropped(self.changes, &other.name) { + continue; + } + let uses = other.segments.iter().any(|segment| { + matches!( + segment.kind, + ColumnConstraintKind::Check | ColumnConstraintKind::Generated + ) && self.source.mentions(segment.range.clone()).contains(&folded) + }); + if uses { + users.push(other.name.clone()); + } + } + users.extend( + self.recreated() + .filter(|object| words(&object.sql).contains(&folded)) + .map(|object| object.name.clone()), + ); + if !users.is_empty() { + return Err(BuildDdlError::Refused { + column: name.to_owned(), + operation: DdlOperation::DropColumn, + reason: RefusalReason::ColumnInUse(users), + }); + } + } + Ok(()) + } + + fn element(&self, index: usize) -> &SourceElement { + &self.source.elements[index] + } + + /// The stored CREATE TABLE under the temporary name, with the edited + /// parts written again: kept columns where they were, added columns + /// after them, then the table constraints, the new key and the added + /// foreign keys. + fn create_statement(&self, temporary: &TableRef) -> Result { + let sql = self.source.sql(); + let mut pieces: Vec<(Option, String)> = Vec::new(); + for ((index, column), original) in self.source.columns().zip(&self.original.columns) { + if !dropped(self.changes, original.name()) { + pieces.push((Some(index), self.column_text(index, column, original)?)); + } + } + for column in &self.naming.added { + let inline = matches!(&self.key, Key::Written { autoincrement: Some(name), .. } if *name == column.name); + pieces.push((None, self.dialect.column_definition(column, inline)?)); + } + for (index, constraint) in self.source.constraints() { + if !self.constraint_removed(index, constraint) { + pieces.push(( + Some(index), + self.source.text(self.element(index).range.clone()).to_owned(), + )); + } + } + if let Key::Written { + columns, + autoincrement: None, + } = &self.key + && !columns.is_empty() + { + pieces.push((None, format!("PRIMARY KEY ({})", column_list(self.dialect, columns)))); + } + for key in &self.changes.added_foreign_keys { + let columns: Vec = key + .columns + .iter() + .map(|column| self.naming.created(column).to_owned()) + .collect(); + pieces.push((None, foreign_key_constraint(self.dialect, key, &columns))); + } + + let list = self.source.text(self.source.open + 1..self.source.close); + let separator = if list.contains('\n') { ",\n " } else { ", " }; + let first = self + .source + .elements + .first() + .map_or(self.source.close, |element| element.range.start); + let last = self + .source + .elements + .last() + .map_or(self.source.close, |element| element.range.end); + let mut body = self.source.text(self.source.open + 1..first).to_owned(); + let mut previous: Option> = None; + for (index, text) in &pieces { + match (previous, index) { + (None, _) => {} + // Elements that stood side by side keep what was between + // them, comments included. + (Some(Some(before)), Some(index)) if *index == before + 1 => { + body.push_str( + self.source + .text(self.element(before).range.end..self.element(*index).range.start), + ); + } + _ => body.push_str(separator), + } + body.push_str(text); + previous = Some(*index); + } + body.push_str(self.source.text(last..self.source.close)); + Ok(format!( + "CREATE TABLE {} ({body}){}", + self.dialect.qualify(temporary), + sql.get(self.source.options.clone()).unwrap_or_default() + )) + } + + /// A kept column's definition with the edited parts replaced and the + /// rest as written: a COLLATE, CHECK, GENERATED or REFERENCES the + /// edit did not touch stays. + fn column_text( + &self, + index: usize, + column: &SourceColumn, + original: &ColumnDefinition, + ) -> Result { + let element = self.element(index); + let mut splice = Splice::new(element.range.clone()); + let mut removed = vec![false; column.segments.len()]; + let mut appended: Vec = Vec::new(); + let remove = |removed: &mut Vec, pick: &dyn Fn(&ColumnConstraintKind) -> bool| { + for (slot, segment) in removed.iter_mut().zip(&column.segments) { + *slot |= pick(&segment.kind); + } + }; + + if let Key::Written { autoincrement, .. } = &self.key { + remove(&mut removed, &|kind| { + matches!(kind, ColumnConstraintKind::PrimaryKey { .. }) + }); + if autoincrement.as_deref() == Some(original.name()) { + appended.push("PRIMARY KEY AUTOINCREMENT".to_owned()); + } + } + if let Some(alteration) = self.alteration(original.name()) { + let name = original.name(); + let draft = &alteration.draft; + if alteration.changed.column_type { + let resolved = draft.column_type.resolve(self.dialect, name)?; + match &column.type_range { + Some(range) => splice.replace(range.clone(), resolved.sql), + None => splice.insert(column.name_range.end, format!(" {}", resolved.sql)), + } + } + if alteration.changed.nullability { + remove(&mut removed, &|kind| { + matches!(kind, ColumnConstraintKind::NotNull | ColumnConstraintKind::Null) + }); + if !draft.nullable { + appended.push("NOT NULL".to_owned()); + } + } + if alteration.changed.default { + remove(&mut removed, &|kind| matches!(kind, ColumnConstraintKind::Default)); + let column_type = draft.column_type.column_type(self.dialect, name)?; + if let Some(default) = default_sql(self.dialect, name, &draft.default, &column_type)? { + appended.push(format!("DEFAULT {}", default_clause(&draft.default, &default))); + } + } + } + for (position, slot) in removed.iter_mut().enumerate() { + *slot |= self.dropped_keys.contains(&(index, Some(position))); + } + // A CONSTRAINT name goes with the constraint it names. + for position in 1..removed.len() { + let named = column + .segments + .get(position - 1) + .is_some_and(|segment| matches!(segment.kind, ColumnConstraintKind::Name(_))); + if removed[position] && named { + removed[position - 1] = true; + } + } + for (segment, removed) in column.segments.iter().zip(&removed) { + if *removed { + splice.remove(self.with_space_before(segment.range.clone(), element.range.start)); + } + } + for text in appended { + splice.insert(element.range.end, format!(" {text}")); + } + Ok(splice.render(self.source.sql())) + } + + /// `range` with the whitespace before it, so a removed constraint + /// leaves no gap. + fn with_space_before(&self, range: Range, floor: usize) -> Range { + let bytes = self.source.sql().as_bytes(); + let mut start = range.start; + while start > floor && bytes.get(start - 1).is_some_and(u8::is_ascii_whitespace) { + start -= 1; + } + start..range.end + } + + /// The rows into the new table. Kept columns have their old names + /// there, and a generated column is computed again. The rowid comes + /// along too, so whatever names a row by it still finds the row, + /// unless a copied INTEGER PRIMARY KEY column carries it already. + fn copy_statement(&self, temporary: &TableRef) -> Result { + let mut columns: Vec = self.rowid_name().into_iter().map(str::to_owned).collect(); + columns.extend( + self.original + .columns + .iter() + .filter(|column| !dropped(self.changes, column.name())) + .filter(|column| !column.info.is_generated && column.attributes.generated.is_none()) + .map(|column| self.dialect.quote_identifier(column.name())), + ); + if columns.is_empty() { + return Err(BuildDdlError::Unsupported(DdlOperation::AlterTable)); + } + let list = columns.join(", "); + Ok(format!( + "INSERT INTO {} ({list}) SELECT {list} FROM {}", + self.dialect.qualify(temporary), + self.dialect.qualify(&self.original.table) + )) + } + + /// A name for the rowid that no column of either table hides. + fn rowid_name(&self) -> Option<&'static str> { + if self.source.without_rowid { + return None; + } + let alias_copied = match self.final_key().as_slice() { + [only] => { + self.kept(only).is_some() + && self + .declared_type(only) + .is_ok_and(|declared| declared.trim().eq_ignore_ascii_case("INTEGER")) + } + _ => false, + }; + if alias_copied { + return None; + } + let taken: HashSet = self + .original + .columns + .iter() + .map(|column| fold(column.name())) + .chain(self.naming.occupied.iter().map(|name| fold(name))) + .collect(); + ["rowid", "oid", "_rowid_"] + .into_iter() + .find(|name| !taken.contains(*name)) + } +} + +/// Edits to one stretch of the source, applied in order. +struct Splice { + range: Range, + edits: Vec<(Range, String)>, +} + +impl Splice { + fn new(range: Range) -> Self { + Self { + range, + edits: Vec::new(), + } + } + + fn replace(&mut self, range: Range, text: String) { + self.edits.push((range, text)); + } + + fn insert(&mut self, at: usize, text: String) { + self.edits.push((at..at, text)); + } + + fn remove(&mut self, range: Range) { + self.edits.push((range, String::new())); + } + + fn render(mut self, sql: &str) -> String { + self.edits.sort_by_key(|(range, _)| (range.start, range.end)); + let mut text = String::new(); + let mut cursor = self.range.start; + for (range, replacement) in self.edits { + if range.start >= cursor { + text.push_str(sql.get(cursor..range.start).unwrap_or_default()); + } + text.push_str(&replacement); + cursor = cursor.max(range.end); + } + text.push_str(sql.get(cursor..self.range.end).unwrap_or_default()); + text + } +} + +/// The stored CREATE of an index, trigger or view, ready to run again: +/// one statement, creating its object in `schema`. `None` when the text +/// is not one such statement. +/// +/// SQLite compiles only the first statement of a schema entry when it +/// loads the schema, so text edited through `writable_schema` can carry +/// more that it never ran, and replaying that would run them. It stores +/// the CREATE without its schema, and run as stored it would land in +/// `main`. +fn replayable(dialect: &SqliteDialect, sql: &str, schema: Option<&str>) -> Option { + let plan = ScriptPlan::build( + sql, + SqlGrammar::Sqlite, + LexicalSettings::default_for(SqlGrammar::Sqlite), + ); + if plan.statements().len() != 1 { + return None; + } + let Some(schema) = schema.filter(|schema| !schema.eq_ignore_ascii_case(MAIN_SCHEMA)) else { + return Some(sql.to_owned()); + }; + let tokens = tokens(sql).ok()?; + let word = |index: usize, word: &str| { + tokens.get(index).is_some_and(|token| { + token.kind == ScriptTokenKind::Word + && sql + .get(token.range.clone()) + .is_some_and(|text| text.eq_ignore_ascii_case(word)) + }) + }; + if !word(0, "CREATE") { + return None; + } + let mut index = 1; + if word(index, "TEMP") || word(index, "TEMPORARY") { + index += 1; + } + if word(index, "UNIQUE") { + index += 1; + } + if !(word(index, "INDEX") || word(index, "TRIGGER") || word(index, "VIEW")) { + return None; + } + index += 1; + if word(index, "IF") { + index += 3; + } + let name = tokens.get(index)?; + let qualified = tokens + .get(index + 1) + .is_some_and(|token| token.kind == ScriptTokenKind::Punct && sql.get(token.range.clone()) == Some(".")); + if qualified { + return Some(sql.to_owned()); + } + Some(format!( + "{}{}.{}", + sql.get(..name.range.start)?, + dialect.quote_identifier(schema), + sql.get(name.range.start..)? + )) +} + +fn unreadable(column: &str, operation: DdlOperation) -> BuildDdlError { + BuildDdlError::Refused { + column: column.to_owned(), + operation, + reason: RefusalReason::UnreadableDefinition, + } +} + +fn dropped(changes: &TableChanges, name: &str) -> bool { + changes + .dropped_columns + .iter() + .any(|column| column.name().eq_ignore_ascii_case(name)) +} + +/// The name a column has once the edit is done. +fn final_name(changes: &TableChanges, original: &str) -> String { + changes + .renamed_columns + .iter() + .find(|rename| rename.from == original) + .map_or(original, |rename| rename.to.as_str()) + .to_owned() +} + +fn same_table(left: &TableRef, right: &TableRef) -> bool { + let schema = |table: &TableRef| { + table + .schema + .as_deref() + .filter(|schema| !schema.is_empty()) + .unwrap_or(MAIN_SCHEMA) + .to_lowercase() + }; + left.name.eq_ignore_ascii_case(&right.name) && schema(left) == schema(right) +} + +/// SQLite compares names without regard to ASCII case, and only ASCII. +fn fold(name: &str) -> String { + name.to_ascii_lowercase() +} + +fn text_literal(text: &str) -> String { + format!("'{}'", text.replace('\'', "''")) +} diff --git a/linux/crates/drivers/sqlite/src/ddl/source_element.rs b/linux/crates/drivers/sqlite/src/ddl/source_element.rs new file mode 100644 index 0000000000..8073b1b3c4 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/source_element.rs @@ -0,0 +1,72 @@ +use std::ops::Range; + +use super::column_constraint_segment::ColumnConstraintSegment; + +/// One entry of the CREATE TABLE list: a column or a table constraint, +/// from its first token to its last. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceElement { + pub range: Range, + pub kind: ElementKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ElementKind { + Column(SourceColumn), + Constraint(TableConstraint), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SourceColumn { + /// As SQLite reads it, quotes removed. + pub name: String, + pub name_range: Range, + /// `None` for a column declared without a type. + pub type_range: Option>, + pub segments: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct TableConstraint { + pub name: Option, + pub kind: TableConstraintKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum TableConstraintKind { + PrimaryKey { + columns: Vec, + autoincrement: bool, + }, + Unique { + columns: Vec, + }, + Check, + ForeignKey { + columns: Vec, + target: ForeignKeyTarget, + }, + /// `CONSTRAINT name` with nothing after it, which SQLite accepts. + NameOnly, +} + +/// What a foreign key points at. No columns means the parent's primary +/// key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ForeignKeyTarget { + pub table: String, + pub columns: Vec, +} + +/// A foreign key the text declares, inline on a column or as a table +/// constraint, in the order SQLite numbers them from the end. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct SourceForeignKey<'s> { + /// The name its CONSTRAINT clause gives it. + pub name: Option<&'s str>, + pub element: usize, + /// The REFERENCES segment of an inline key. + pub segment: Option, + pub columns: &'s [String], + pub target: &'s ForeignKeyTarget, +} diff --git a/linux/crates/drivers/sqlite/src/ddl/source_read_error.rs b/linux/crates/drivers/sqlite/src/ddl/source_read_error.rs new file mode 100644 index 0000000000..a669718012 --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/source_read_error.rs @@ -0,0 +1,64 @@ +use std::fmt; + +/// Why the CREATE TABLE text SQLite stored could not be read as the +/// table the catalogue describes. A rebuild splices that text, so any +/// doubt about it refuses the changes only a rebuild makes. It never +/// leaves the crate: the plan turns it into `UnreadableDefinition`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum SourceReadError { + Missing, + /// A virtual table, or a statement that is not CREATE TABLE. + NotCreateTable, + Unexpected { + offset: usize, + found: String, + }, + Unterminated, + TableName { + expected: String, + found: String, + }, + ColumnCount { + expected: usize, + found: usize, + }, + ColumnName { + position: usize, + expected: String, + found: String, + }, + ForeignKeyCount { + expected: usize, + found: usize, + }, +} + +impl fmt::Display for SourceReadError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Missing => f.write_str("the catalogue gave no CREATE statement"), + Self::NotCreateTable => f.write_str("not a CREATE TABLE statement with a column list"), + Self::Unexpected { offset, found } => write!(f, "unexpected {found:?} at byte {offset}"), + Self::Unterminated => f.write_str("the statement ends before its column list closes"), + Self::TableName { expected, found } => write!(f, "the statement creates {found}, not {expected}"), + Self::ColumnCount { expected, found } => write!( + f, + "the statement declares {found} columns where the catalogue has {expected}" + ), + Self::ColumnName { + position, + expected, + found, + } => write!( + f, + "column {position} is {found} in the statement and {expected} in the catalogue" + ), + Self::ForeignKeyCount { expected, found } => write!( + f, + "the statement declares {found} foreign keys where the catalogue has {expected}" + ), + } + } +} + +impl std::error::Error for SourceReadError {} diff --git a/linux/crates/drivers/sqlite/src/ddl/tests.rs b/linux/crates/drivers/sqlite/src/ddl/tests.rs new file mode 100644 index 0000000000..2ff0696fcd --- /dev/null +++ b/linux/crates/drivers/sqlite/src/ddl/tests.rs @@ -0,0 +1,984 @@ +use tablepro_core::column::{ColumnInfo, SqlExpression}; +use tablepro_core::ddl::{ + ColumnAttributes, ColumnDefinition, DdlOperation, DraftType, GeneratedColumn, IdentityStyle, InboundForeignKey, + Permission, PrimaryKeyConstraint, ReferentialAction, RefusalReason, SchemaObjectSource, TableSource, diff_table, +}; +use tablepro_core::meta::IndexKey; +use tablepro_core::value::Value; +use tablepro_core::{ForeignKeyCheck, ForeignKeyMode}; + +use super::*; +use crate::catalog::SqliteVersion; + +fn dialect() -> SqliteDialect { + SqliteDialect::new(SqliteVersion::new(3, 46, 0)) +} + +fn typed(name: &str, declaration: &str) -> DraftColumn { + DraftColumn::new(name, DraftType::Typed(declaration.to_owned())) +} + +fn column(name: &str, declaration: &str, primary_key: bool) -> ColumnDefinition { + ColumnDefinition::new( + ColumnInfo { + name: name.to_owned(), + column_type: dialect().parse_type(declaration).expect("a SQLite type"), + nullable: !primary_key, + primary_key, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ) +} + +/// `INTEGER PRIMARY KEY`, the rowid itself, as the catalogue reads it. +fn rowid_alias(name: &str) -> ColumnDefinition { + let mut column = column(name, "INTEGER", true); + column.info.nullable = true; + column.info.is_auto_increment = true; + column.attributes.identity = IdentityStyle::SqliteRowidAlias; + column +} + +fn not_null(mut column: ColumnDefinition) -> ColumnDefinition { + column.info.nullable = false; + column +} + +fn table(name: &str, columns: Vec, create: &str) -> TableDefinition { + let key: Vec = columns + .iter() + .filter(|column| column.info.primary_key) + .map(|column| column.name().to_owned()) + .collect(); + TableDefinition { + table: TableRef::unqualified(name), + columns, + primary_key: (!key.is_empty()).then_some(PrimaryKeyConstraint { + name: None, + columns: key, + }), + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: Some(TableSource { + create_statement: create.to_owned(), + dependents: Vec::new(), + }), + } +} + +fn object(kind: SchemaObjectKind, name: &str, owner: &str, sql: &str) -> SchemaObjectSource { + SchemaObjectSource { + kind, + name: name.to_owned(), + table: owner.to_owned(), + sql: sql.to_owned(), + } +} + +fn index(name: &str, column: &str) -> IndexInfo { + IndexInfo { + name: name.to_owned(), + keys: vec![IndexKey::Column { + name: column.to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + } +} + +fn foreign_key(name: &str, column: &str, parent: &str, parent_column: &str) -> ForeignKeyInfo { + ForeignKeyInfo { + name: name.to_owned(), + columns: vec![column.to_owned()], + ref_schema: None, + ref_table: parent.to_owned(), + ref_columns: vec![parent_column.to_owned()], + on_delete: Some(ReferentialAction::NoAction), + on_update: Some(ReferentialAction::NoAction), + } +} + +fn inbound(name: &str, child: &str, column: &str, parent_column: &str) -> InboundForeignKey { + InboundForeignKey { + name: name.to_owned(), + table: TableRef::unqualified(child), + columns: vec![column.to_owned()], + referenced_columns: vec![parent_column.to_owned()], + } +} + +fn users() -> TableDefinition { + TableDefinition { + source: None, + ..table( + "users", + vec![column("id", "INTEGER", true), column("email", "TEXT", false)], + "", + ) + } +} + +/// A table with each kind of dependent: its own index and trigger, a +/// view on it, a trigger on another table that writes to it, and a +/// table whose foreign key points at it. +fn orders() -> TableDefinition { + let mut total = column("total", "REAL", false); + total.info.default = ColumnDefault::Literal(Value::Int(0)); + let mut orders = table( + "orders", + vec![ + rowid_alias("id"), + not_null(column("customer", "INTEGER", false)), + total, + column("note", "TEXT", false), + ], + "CREATE TABLE orders (\n id INTEGER PRIMARY KEY,\n customer INTEGER NOT NULL REFERENCES customers (id),\n \ + total REAL DEFAULT 0 CHECK (total >= 0),\n note TEXT COLLATE NOCASE\n)", + ); + orders.indexes = vec![index("orders_by_customer", "customer")]; + orders.foreign_keys = vec![foreign_key("fk_orders_0", "customer", "customers", "id")]; + orders.inbound_foreign_keys = vec![inbound("fk_lines_0", "lines", "order_id", "id")]; + if let Some(source) = orders.source.as_mut() { + source.dependents = vec![ + object( + SchemaObjectKind::Index, + "orders_by_customer", + "orders", + "CREATE INDEX orders_by_customer ON orders (customer)", + ), + object( + SchemaObjectKind::Trigger, + "orders_stamp", + "orders", + "CREATE TRIGGER orders_stamp AFTER INSERT ON orders BEGIN UPDATE orders SET note = 'new' WHERE id = \ + new.id; END", + ), + object( + SchemaObjectKind::View, + "big_orders", + "big_orders", + "CREATE VIEW big_orders AS SELECT id, total FROM orders WHERE total > 100", + ), + object( + SchemaObjectKind::Trigger, + "customers_cleanup", + "customers", + "CREATE TRIGGER customers_cleanup AFTER DELETE ON customers BEGIN DELETE FROM orders WHERE customer = \ + old.id; END", + ), + ]; + } + orders +} + +fn alter(original: &TableDefinition, edit: impl FnOnce(&mut TableDraft)) -> Result { + let mut draft = TableDraft::from_definition(original); + edit(&mut draft); + dialect().plan_alter_table(original, &diff_table(original, &draft)) +} + +fn sql(plan: Result) -> Vec { + plan.expect("the change plans") + .sql() + .into_iter() + .map(str::to_owned) + .collect() +} + +fn checks(plan: &Result) -> Vec { + match plan.as_ref().map(|plan| &plan.foreign_keys) { + Ok(ForeignKeyMode::SuspendedThenVerified { checks }) => checks.clone(), + other => panic!("not a rebuild: {other:?}"), + } +} + +fn retype(draft: &mut TableDraft, name: &str, declaration: &str) { + if let Some(column) = draft.columns.iter_mut().find(|column| column.name == name) { + column.column_type = DraftType::Typed(declaration.to_owned()); + } +} + +fn refusal(plan: &Result) -> Option<(&str, DdlOperation, &RefusalReason)> { + match plan { + Err(BuildDdlError::Refused { + column, + operation, + reason, + }) => Some((column.as_str(), *operation, reason)), + _ => None, + } +} + +#[test] +fn plan_create_table_simple() { + let mut draft = TableDraft::new(TableRef::unqualified("notes")); + let mut id = typed("id", "INTEGER"); + id.primary_key = true; + id.nullable = false; + id.auto_increment = true; + let mut body = typed("body", "TEXT"); + body.nullable = false; + body.default = ColumnDefault::Literal(Value::Text("it's".to_owned())); + let mut created = typed("created_at", "TEXT"); + created.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("CURRENT_TIMESTAMP")); + let mut token = typed("token", "TEXT"); + token.default = ColumnDefault::Expression(SqlExpression::from_catalog_text("lower(hex(randomblob(4)))")); + draft.columns = vec![id, body, created, token]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"notes\" (\n \"id\" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,\n \"body\" TEXT NOT \ + NULL DEFAULT 'it''s',\n \"created_at\" TEXT DEFAULT CURRENT_TIMESTAMP,\n \"token\" TEXT DEFAULT \ + (lower(hex(randomblob(4))))\n)" + ] + ); +} + +#[test] +fn plan_create_table_composite_key_index_and_foreign_key() { + let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); + let mut order = typed("order_id", "INTEGER"); + order.primary_key = true; + order.nullable = false; + let mut line = typed("line", "INTEGER"); + line.primary_key = true; + line.nullable = false; + draft.columns = vec![order, line, typed("sku", "TEXT")]; + draft.indexes = vec![IndexInfo { + name: "by_sku".to_owned(), + keys: vec![IndexKey::Column { + name: "sku".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: Some("sku IS NOT NULL".to_owned()), + }]; + draft.foreign_keys = vec![ForeignKeyInfo { + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + ..foreign_key("lines_order_fk", "order_id", "orders", "id") + }]; + + assert_eq!( + sql(dialect().plan_create_table(&draft)), + [ + "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER NOT NULL,\n \"line\" INTEGER NOT NULL,\n \ + \"sku\" TEXT,\n PRIMARY KEY (\"order_id\", \"line\"),\n CONSTRAINT \"lines_order_fk\" FOREIGN KEY \ + (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE CASCADE\n)", + "CREATE UNIQUE INDEX \"main\".\"by_sku\" ON \"lines\" (\"sku\") WHERE sku IS NOT NULL", + ] + ); +} + +#[test] +fn create_table_inlines_foreign_keys_without_pragma() { + let mut draft = TableDraft::new(TableRef::new(Some("main".to_owned()), "lines")); + draft.columns = vec![typed("order_id", "INTEGER")]; + draft.foreign_keys = vec![ForeignKeyInfo { + ref_schema: Some("main".to_owned()), + on_delete: Some(ReferentialAction::SetNull), + ..foreign_key("lines_order", "order_id", "orders", "id") + }]; + + let plan = dialect().plan_create_table(&draft).expect("the table plans"); + + assert_eq!( + plan.sql(), + [ + "CREATE TABLE \"main\".\"lines\" (\n \"order_id\" INTEGER,\n CONSTRAINT \"lines_order\" FOREIGN KEY \ + (\"order_id\") REFERENCES \"orders\" (\"id\") ON DELETE SET NULL\n)" + ] + ); + assert!(plan.foreign_keys.is_enforced()); + assert!(plan.sql().iter().all(|statement| !statement.contains("PRAGMA"))); +} + +#[test] +fn native_rename_and_add_column_skip_rebuild() { + let notes = table( + "notes", + vec![column("title", "TEXT", false), column("body", "TEXT", false)], + "CREATE TABLE notes (title TEXT, body TEXT)", + ); + + let plan = alter(¬es, |draft| { + draft.table.name = "memos".to_owned(); + draft.columns[1].name = "content".to_owned(); + let mut tag = typed("tag", "TEXT"); + tag.nullable = false; + tag.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + draft.columns.push(tag); + }); + + assert!(plan.as_ref().is_ok_and(|plan| plan.foreign_keys.is_enforced())); + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"notes\" RENAME TO \"memos\"", + "ALTER TABLE \"memos\" RENAME COLUMN \"body\" TO \"content\"", + "ALTER TABLE \"memos\" ADD COLUMN \"tag\" TEXT NOT NULL DEFAULT 'none'", + ] + ); +} + +#[test] +fn type_change_builds_rebuild_with_rowid_copy() { + let notes = table( + "notes", + vec![column("title", "TEXT", false), column("body", "TEXT", false)], + "CREATE TABLE notes (title TEXT, body TEXT)", + ); + + let plan = alter(¬es, |draft| retype(draft, "body", "BLOB")); + + assert_eq!( + checks(&plan), + [ForeignKeyCheck::same_table(TableRef::unqualified("notes"))] + ); + assert_eq!( + sql(plan), + [ + "CREATE TABLE \"_tablepro_rebuild_notes\" (title TEXT, body BLOB)", + "INSERT INTO \"_tablepro_rebuild_notes\" (rowid, \"title\", \"body\") SELECT rowid, \"title\", \"body\" \ + FROM \"notes\"", + "DROP TABLE \"notes\"", + "ALTER TABLE \"_tablepro_rebuild_notes\" RENAME TO \"notes\"", + ] + ); +} + +#[test] +fn plan_alter_table_rebuilds_for_nullability_type_and_default() { + let notes = table( + "notes", + vec![column("title", "TEXT", false), not_null(column("body", "TEXT", false))], + "CREATE TABLE notes (title TEXT DEFAULT 'x' NOT NULL, body TEXT NOT NULL ON CONFLICT REPLACE)", + ); + let mut notes = notes; + notes.columns[0].info.default = ColumnDefault::Literal(Value::Text("x".to_owned())); + notes.columns[0].info.nullable = false; + + let plan = alter(¬es, |draft| { + draft.columns[0].default = ColumnDefault::Expression(SqlExpression::from_catalog_text("upper('y')")); + draft.columns[0].nullable = true; + draft.columns[1].nullable = true; + retype(draft, "body", "VARCHAR(20)"); + }); + + assert_eq!( + sql(plan)[0], + "CREATE TABLE \"_tablepro_rebuild_notes\" (title TEXT DEFAULT (upper('y')), body VARCHAR(20))" + ); +} + +#[test] +fn rebuild_preserves_dependents_and_checks_children() { + let orders = orders(); + + let plan = alter(&orders, |draft| retype(draft, "total", "NUMERIC")); + + assert_eq!( + checks(&plan), + [ + ForeignKeyCheck::same_table(TableRef::unqualified("orders")), + ForeignKeyCheck::same_table(TableRef::unqualified("lines")), + ] + ); + assert_eq!( + sql(plan), + [ + "DROP TRIGGER \"customers_cleanup\"", + "DROP VIEW \"big_orders\"", + "CREATE TABLE \"_tablepro_rebuild_orders\" (\n id INTEGER PRIMARY KEY,\n customer INTEGER NOT NULL \ + REFERENCES customers (id),\n total NUMERIC DEFAULT 0 CHECK (total >= 0),\n note TEXT COLLATE \ + NOCASE\n)", + "INSERT INTO \"_tablepro_rebuild_orders\" (\"id\", \"customer\", \"total\", \"note\") SELECT \"id\", \ + \"customer\", \"total\", \"note\" FROM \"orders\"", + "DROP TABLE \"orders\"", + "ALTER TABLE \"_tablepro_rebuild_orders\" RENAME TO \"orders\"", + "CREATE INDEX orders_by_customer ON orders (customer)", + "CREATE TRIGGER orders_stamp AFTER INSERT ON orders BEGIN UPDATE orders SET note = 'new' WHERE id = new.id; \ + END", + "CREATE VIEW big_orders AS SELECT id, total FROM orders WHERE total > 100", + "CREATE TRIGGER customers_cleanup AFTER DELETE ON customers BEGIN DELETE FROM orders WHERE customer = \ + old.id; END", + ] + ); +} + +#[test] +fn rebuild_checks_cover_table_and_inbound_children() { + let mut orders = orders(); + orders.inbound_foreign_keys = vec![ + inbound("fk_lines_0", "lines", "order_id", "id"), + inbound("fk_lines_1", "Lines", "first_order_id", "id"), + inbound("fk_refunds_0", "refunds", "order_id", "id"), + inbound("fk_orders_1", "orders", "parent_id", "id"), + ]; + + let plan = alter(&orders, |draft| { + draft.table.name = "purchases".to_owned(); + retype(draft, "total", "NUMERIC"); + }); + + assert_eq!( + checks(&plan), + [ + ForeignKeyCheck::new( + Some(TableRef::unqualified("orders")), + TableRef::unqualified("purchases") + ), + ForeignKeyCheck::same_table(TableRef::unqualified("lines")), + ForeignKeyCheck::same_table(TableRef::unqualified("refunds")), + ] + ); +} + +#[test] +fn rebuild_keeps_check_collate_generated_verbatim() { + let mut generated = column("c", "INT", false); + generated.info.is_generated = true; + generated.attributes.generated = Some(GeneratedColumn { + expression: SqlExpression::from_catalog_text("a * 2"), + storage: GeneratedStorage::Stored, + }); + let table = table( + "m", + vec![ + column("a", "INT", false), + not_null(column("b", "TEXT", false)), + generated, + ], + "CREATE TABLE m (a INT CHECK (a > 0) COLLATE BINARY, b TEXT COLLATE NOCASE NOT NULL, c INT GENERATED ALWAYS \ + AS (a * 2) STORED, CHECK (b <> ''))", + ); + let permissions = dialect().column_capabilities(&table, &table.columns[2]); + + let plan = alter(&table, |draft| { + retype(draft, "a", "BIGINT"); + draft.columns[1].nullable = true; + retype(draft, "c", "REAL"); + }); + + assert!(permissions.alter_type.is_allowed() && permissions.alter_nullability.is_allowed()); + assert_eq!( + permissions.alter_default, + Permission::Refused(RefusalReason::GeneratedColumn) + ); + assert_eq!( + sql(plan)[..2], + [ + "CREATE TABLE \"_tablepro_rebuild_m\" (a BIGINT CHECK (a > 0) COLLATE BINARY, b TEXT COLLATE NOCASE, c \ + REAL GENERATED ALWAYS AS (a * 2) STORED, CHECK (b <> ''))", + "INSERT INTO \"_tablepro_rebuild_m\" (rowid, \"a\", \"b\") SELECT rowid, \"a\", \"b\" FROM \"m\"", + ] + ); +} + +#[test] +fn rebuild_orders_views_before_renames() { + let orders = orders(); + + let statements = sql(alter(&orders, |draft| { + draft.table.name = "purchases".to_owned(); + draft.columns[3].name = "remark".to_owned(); + retype(draft, "total", "NUMERIC"); + draft.indexes.push(index("purchases_by_remark", "remark")); + })); + + let position = |prefix: &str| { + statements + .iter() + .position(|statement| statement.starts_with(prefix)) + .unwrap_or_else(|| panic!("no {prefix} in {statements:#?}")) + }; + assert!(position("CREATE VIEW big_orders") < position("ALTER TABLE \"orders\" RENAME COLUMN")); + assert_eq!( + statements[position("ALTER TABLE \"orders\" RENAME COLUMN")..], + [ + "ALTER TABLE \"orders\" RENAME COLUMN \"note\" TO \"remark\"", + "ALTER TABLE \"orders\" RENAME TO \"purchases\"", + "CREATE INDEX \"purchases_by_remark\" ON \"purchases\" (\"remark\")", + ] + ); +} + +#[test] +fn rebuild_renames_use_temporary_names_on_collision() { + let people = table( + "people", + vec![ + rowid_alias("id"), + column("email", "TEXT", false), + column("name", "TEXT", false), + ], + "CREATE TABLE people (id INTEGER PRIMARY KEY, email TEXT, name TEXT)", + ); + + let added = sql(alter(&people, |draft| { + draft.columns[1].name = "mail".to_owned(); + retype(draft, "mail", "BLOB"); + draft.columns.push(typed("email", "TEXT")); + })); + let swapped = sql(alter(&people, |draft| { + draft.columns[1].name = "name".to_owned(); + draft.columns[2].name = "email".to_owned(); + retype(draft, "name", "BLOB"); + })); + + assert_eq!( + added[0], + "CREATE TABLE \"_tablepro_rebuild_people\" (id INTEGER PRIMARY KEY, email BLOB, name TEXT, \ + \"__tablepro_added_1\" TEXT)" + ); + assert_eq!( + added[4..], + [ + "ALTER TABLE \"people\" RENAME COLUMN \"email\" TO \"mail\"", + "ALTER TABLE \"people\" RENAME COLUMN \"__tablepro_added_1\" TO \"email\"", + ] + ); + assert_eq!( + swapped[4..], + [ + "ALTER TABLE \"people\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", + "ALTER TABLE \"people\" RENAME COLUMN \"name\" TO \"email\"", + "ALTER TABLE \"people\" RENAME COLUMN \"__tablepro_rename_1\" TO \"name\"", + ] + ); +} + +#[test] +fn dropping_a_foreign_key_removes_it_from_the_text() { + let mut lines = table( + "lines", + vec![ + not_null(column("order_id", "INTEGER", false)), + column("sku", "TEXT", false), + ], + "CREATE TABLE lines (order_id INTEGER CONSTRAINT to_order REFERENCES orders (id) ON DELETE CASCADE NOT NULL, \ + sku TEXT, FOREIGN KEY (sku) REFERENCES products (sku))", + ); + // The catalogue numbers keys from the last one declared. + lines.foreign_keys = vec![ + foreign_key("fk_lines_0", "sku", "products", "sku"), + ForeignKeyInfo { + on_delete: Some(ReferentialAction::Cascade), + ..foreign_key("fk_lines_1", "order_id", "orders", "id") + }, + ]; + + let inline = sql(alter(&lines, |draft| { + draft.foreign_keys.remove(1); + })); + let constraint = sql(alter(&lines, |draft| { + draft.foreign_keys.remove(0); + })); + + assert_eq!( + inline[0], + "CREATE TABLE \"_tablepro_rebuild_lines\" (order_id INTEGER NOT NULL, sku TEXT, FOREIGN KEY (sku) REFERENCES \ + products (sku))" + ); + assert_eq!( + constraint[0], + "CREATE TABLE \"_tablepro_rebuild_lines\" (order_id INTEGER CONSTRAINT to_order REFERENCES orders (id) ON \ + DELETE CASCADE NOT NULL, sku TEXT)" + ); +} + +#[test] +fn adding_a_foreign_key_appends_a_table_constraint() { + let lines = table( + "lines", + vec![column("order_id", "INTEGER", false), column("sku", "TEXT", false)], + "CREATE TABLE lines (\n order_id INTEGER,\n sku TEXT\n)", + ); + + let plan = alter(&lines, |draft| { + draft.foreign_keys.push(ForeignKeyInfo { + on_update: Some(ReferentialAction::Cascade), + ..foreign_key("lines_order", "order_id", "orders", "id") + }); + }); + + assert_eq!( + sql(plan)[0], + "CREATE TABLE \"_tablepro_rebuild_lines\" (\n order_id INTEGER,\n sku TEXT,\n CONSTRAINT \"lines_order\" \ + FOREIGN KEY (\"order_id\") REFERENCES \"orders\" (\"id\") ON UPDATE CASCADE\n)" + ); +} + +#[test] +fn primary_key_change_writes_the_key_again() { + let tags = table( + "tags", + vec![column("id", "INTEGER", false), not_null(column("name", "TEXT", false))], + "CREATE TABLE tags (id INTEGER, name TEXT NOT NULL)", + ); + + let numbered = sql(alter(&tags, |draft| { + draft.columns[0].primary_key = true; + draft.columns[0].nullable = false; + draft.columns[0].auto_increment = true; + })); + let composite = sql(alter(&tags, |draft| { + for column in &mut draft.columns { + column.primary_key = true; + column.nullable = false; + } + })); + let text_key = alter(&tags, |draft| { + draft.columns[1].primary_key = true; + draft.columns[1].auto_increment = true; + }); + + assert_eq!( + numbered[..2], + [ + "CREATE TABLE \"_tablepro_rebuild_tags\" (id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, name TEXT NOT \ + NULL)", + "INSERT INTO \"_tablepro_rebuild_tags\" (\"id\", \"name\") SELECT \"id\", \"name\" FROM \"tags\"", + ] + ); + assert_eq!( + composite[0], + "CREATE TABLE \"_tablepro_rebuild_tags\" (id INTEGER NOT NULL, name TEXT NOT NULL, PRIMARY KEY (\"id\", \ + \"name\"))" + ); + assert_eq!( + refusal(&text_key), + Some(("name", DdlOperation::AlterAutoIncrement, &RefusalReason::NotAnInteger)) + ); +} + +#[test] +fn autoincrement_carries_the_sequence_through_the_rebuild() { + let mut id = rowid_alias("id"); + id.info.nullable = true; + let counters = table( + "counters", + vec![id, column("n", "INT", false)], + "CREATE TABLE counters (id INTEGER PRIMARY KEY AUTOINCREMENT, n INT)", + ); + + let statements = sql(alter(&counters, |draft| retype(draft, "n", "TEXT"))); + + assert_eq!( + statements[2..4], + [ + "DELETE FROM \"sqlite_sequence\" WHERE name = '_tablepro_rebuild_counters'", + "INSERT INTO \"sqlite_sequence\" (name, seq) SELECT '_tablepro_rebuild_counters', seq FROM \ + \"sqlite_sequence\" WHERE name = 'counters' COLLATE NOCASE", + ] + ); +} + +#[test] +fn dropping_a_column_a_view_uses_is_refused_in_a_rebuild() { + let orders = orders(); + + let plan = alter(&orders, |draft| { + draft.columns.retain(|column| column.name != "total"); + retype(draft, "note", "BLOB"); + }); + + assert_eq!( + refusal(&plan), + Some(( + "total", + DdlOperation::DropColumn, + &RefusalReason::ColumnInUse(vec!["big_orders".to_owned()]) + )) + ); +} + +#[test] +fn a_unique_column_drops_with_its_constraint() { + let codes = table( + "codes", + vec![ + column("id", "INTEGER", false), + column("code", "TEXT", false), + column("label", "TEXT", false), + ], + "CREATE TABLE codes (id INTEGER, code TEXT UNIQUE, label TEXT, UNIQUE (label))", + ); + + let own = sql(alter(&codes, |draft| { + draft.columns.retain(|column| column.name != "code") + })); + let alone = sql(alter(&codes, |draft| { + draft.columns.retain(|column| column.name != "label") + })); + let plain = sql(alter(&codes, |draft| { + draft.columns.retain(|column| column.name != "id") + })); + + assert_eq!( + own[0], + "CREATE TABLE \"_tablepro_rebuild_codes\" (id INTEGER, label TEXT, UNIQUE (label))" + ); + assert_eq!( + alone[0], + "CREATE TABLE \"_tablepro_rebuild_codes\" (id INTEGER, code TEXT UNIQUE)" + ); + assert_eq!(plain, ["ALTER TABLE \"codes\" DROP COLUMN \"id\""]); +} + +#[test] +fn a_required_column_without_a_default_rebuilds() { + let notes = table( + "notes", + vec![column("title", "TEXT", false)], + "CREATE TABLE notes (title TEXT)", + ); + let add = |draft: &mut TableDraft| { + let mut required = typed("nickname", "TEXT"); + required.nullable = false; + draft.columns.push(required); + }; + + let rebuilt = sql(alter(¬es, add)); + let unread = alter(&users(), add); + + assert_eq!( + rebuilt[0], + "CREATE TABLE \"_tablepro_rebuild_notes\" (title TEXT, \"nickname\" TEXT NOT NULL)" + ); + assert_eq!( + refusal(&unread), + Some(( + "nickname", + DdlOperation::AddColumn, + &RefusalReason::UnreadableDefinition + )) + ); +} + +#[test] +fn unreadable_source_refuses_rebuild_only_operations() { + let virtual_table = table( + "docs", + vec![column("body", "TEXT", false)], + "CREATE VIRTUAL TABLE docs USING fts5(body)", + ); + let unreadable = Permission::Refused(RefusalReason::UnreadableDefinition); + + for table in [users(), virtual_table] { + let permissions = dialect().column_capabilities(&table, &table.columns[table.columns.len() - 1]); + assert_eq!(permissions.alter_type, unreadable, "{}", table.table.name); + assert_eq!(permissions.alter_nullability, unreadable); + assert_eq!(permissions.alter_default, unreadable); + assert_eq!(permissions.alter_primary_key, unreadable); + assert!(permissions.rename.is_allowed() && permissions.drop.is_allowed()); + } + let plan = alter(&users(), |draft| retype(draft, "email", "BLOB")); + assert_eq!( + refusal(&plan), + Some(( + "email", + DdlOperation::AlterColumnType, + &RefusalReason::UnreadableDefinition + )) + ); + let renamed = alter(&users(), |draft| draft.columns[1].name = "mail".to_owned()); + assert_eq!( + sql(renamed), + ["ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"mail\""] + ); +} + +#[test] +fn a_dependent_carrying_a_second_statement_is_not_replayed() { + let mut orders = orders(); + if let Some(view) = orders + .source + .as_mut() + .and_then(|source| source.dependents.iter_mut().find(|object| object.name == "big_orders")) + { + view.sql = "CREATE VIEW big_orders AS SELECT id FROM orders; DELETE FROM orders".to_owned(); + } + + let plan = alter(&orders, |draft| retype(draft, "total", "NUMERIC")); + + assert_eq!( + refusal(&plan), + Some(( + "big_orders", + DdlOperation::AlterTable, + &RefusalReason::UnreadableDefinition + )) + ); +} + +#[test] +fn an_attached_schema_rebuilds_in_place() { + let mut orders = orders(); + orders.table = TableRef::new(Some("aux".to_owned()), "orders"); + orders.inbound_foreign_keys.clear(); + + let statements = sql(alter(&orders, |draft| retype(draft, "total", "NUMERIC"))); + + assert_eq!(statements[0], "DROP TRIGGER \"aux\".\"customers_cleanup\""); + assert_eq!( + statements[2].split(" (").next(), + Some("CREATE TABLE \"aux\".\"_tablepro_rebuild_orders\"") + ); + assert_eq!( + statements[4..7], + [ + "DROP TABLE \"aux\".\"orders\"", + "ALTER TABLE \"aux\".\"_tablepro_rebuild_orders\" RENAME TO \"orders\"", + "CREATE INDEX \"aux\".orders_by_customer ON orders (customer)", + ] + ); + assert_eq!( + statements[8], + "CREATE VIEW \"aux\".big_orders AS SELECT id, total FROM orders WHERE total > 100" + ); +} + +#[test] +fn a_swap_steps_aside_through_a_temporary_name() { + let mut table = users(); + table.columns.push(column("name", "TEXT", false)); + + let plan = alter(&table, |draft| { + draft.columns[1].name = "Name".to_owned(); + draft.columns[2].name = "email".to_owned(); + }); + + assert_eq!( + sql(plan), + [ + "ALTER TABLE \"users\" RENAME COLUMN \"email\" TO \"__tablepro_rename_1\"", + "ALTER TABLE \"users\" RENAME COLUMN \"name\" TO \"email\"", + "ALTER TABLE \"users\" RENAME COLUMN \"__tablepro_rename_1\" TO \"Name\"", + ] + ); +} + +#[test] +fn an_indexed_column_drop_drops_the_index_first() { + let mut table = users(); + table.indexes = vec![index("by_email", "email")]; + + let plan = alter(&table, |draft| { + draft.indexes.clear(); + draft.columns.retain(|column| column.name != "email"); + }); + + assert_eq!( + sql(plan), + ["DROP INDEX \"by_email\"", "ALTER TABLE \"users\" DROP COLUMN \"email\""] + ); +} + +#[test] +fn primary_key_referenced_by_foreign_key_is_refused() { + let mut table = users(); + // SQLite reports a reference to the primary key with a NULL + // target, which the catalogue resolves to the key's columns. + table.inbound_foreign_keys = vec![inbound("fk_orders_0", "orders", "user_id", "id")]; + + let permissions = dialect().column_capabilities(&table, &table.columns[1]); + let id = dialect().column_capabilities(&table, &table.columns[0]); + + assert_eq!( + permissions.alter_primary_key, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) + ); + assert_eq!( + id.drop, + Permission::Refused(RefusalReason::ReferencedByForeignKeys(vec!["fk_orders_0".to_owned()])) + ); +} + +#[test] +fn drop_of_referenced_column_is_refused() { + let mut table = users(); + table.inbound_foreign_keys = vec![inbound("fk_orders_0", "orders", "user_email", "email")]; + + let plan = alter(&table, |draft| draft.columns.retain(|column| column.name != "email")); + + assert!( + matches!( + plan, + Err(BuildDdlError::Refused { + operation: DdlOperation::DropColumn, + reason: RefusalReason::ReferencedByForeignKeys(_), + .. + }) + ), + "{plan:?}" + ); +} + +#[test] +fn the_script_replays_the_stored_table_and_its_own_dependents() { + let mut table = users(); + table.source = Some(TableSource { + create_statement: "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)".to_owned(), + dependents: vec![ + object( + SchemaObjectKind::Index, + "by_email", + "Users", + "CREATE INDEX by_email ON users (email)", + ), + object( + SchemaObjectKind::Index, + "by_total", + "orders", + "CREATE INDEX by_total ON orders (total)", + ), + object( + SchemaObjectKind::Trigger, + "stamp", + "users", + "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", + ), + object(SchemaObjectKind::View, "v", "v", "CREATE VIEW v AS SELECT * FROM users"), + ], + }); + + assert_eq!( + sql(dialect().create_table_script(&table)), + [ + "CREATE TABLE users (id INTEGER PRIMARY KEY, email TEXT)", + "CREATE INDEX by_email ON users (email)", + "CREATE TRIGGER stamp AFTER INSERT ON users BEGIN SELECT 1; END", + ] + ); +} + +#[test] +fn foreign_keys_rebuild_and_comments_stay_refused() { + let dialect = dialect(); + let capabilities = DdlDialect::capabilities(&dialect); + + assert!(capabilities.add_foreign_key && capabilities.drop_foreign_key); + assert!(capabilities.alter_column_type && capabilities.alter_primary_key); + assert!(!capabilities.column_comments); + assert!(capabilities.partial_indexes && capabilities.expression_index_keys); +} + +#[test] +fn every_type_suggestion_parses() { + let dialect = dialect(); + + for suggestion in dialect.type_suggestions() { + assert!(dialect.parse_type(suggestion).is_ok(), "{suggestion}"); + } + assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); +} diff --git a/linux/crates/drivers/sqlite/src/metadata.rs b/linux/crates/drivers/sqlite/src/metadata.rs index 87569d2bfc..8e952c327a 100644 --- a/linux/crates/drivers/sqlite/src/metadata.rs +++ b/linux/crates/drivers/sqlite/src/metadata.rs @@ -19,9 +19,10 @@ use tablepro_core::meta::{ use tablepro_core::{DriverError, ForeignKeyInfo, ServerCode, ServerDiagnostics}; use crate::catalog::{ - self, InboundKeyRow, MAIN_SCHEMA, SchemaRow, SqliteColumnRow, TableFacts, foreign_key_name, generated_expressions, + self, InboundKeyRow, MAIN_SCHEMA, SchemaRow, SqliteColumnRow, TableFacts, foreign_key_names, generated_expressions, inbound_foreign_keys, select_dependents, }; +use crate::ddl::declared_foreign_key_names; use crate::errors::{SQLITE_ERROR, map_error}; use crate::index_sql::read_index_sql; @@ -126,7 +127,7 @@ pub(crate) async fn fetch_foreign_keys( Entry::Occupied(entry) => entry.into_mut(), Entry::Vacant(entry) => entry.insert(( ForeignKeyInfo { - name: foreign_key_name(&table.name, id), + name: String::new(), columns: Vec::new(), ref_schema: table.schema.clone(), ref_table: text(&row, "table")?, @@ -143,8 +144,17 @@ pub(crate) async fn fetch_foreign_keys( None => *to_primary = true, } } + let declared = match keys.is_empty() { + true => None, + false => create_statement(connection, table) + .await? + .as_deref() + .and_then(declared_foreign_key_names), + }; + let mut names = foreign_key_names(&table.name, declared, keys.len()).into_iter(); let mut resolved = Vec::with_capacity(keys.len()); for (mut key, to_primary) in keys.into_values() { + key.name = names.next().unwrap_or_default(); // A key that names no parent columns points at the parent's // primary key. if to_primary { @@ -239,15 +249,7 @@ pub(crate) async fn fetch_table_definition( ) -> Result { let shape = table_shape(connection, table).await?; let schema_sql = quote(schema_of(table)); - let create_statement: Option = sqlx::query(AssertSqlSafe(catalog::object_sql(&schema_sql))) - .bind(table.name.as_str()) - .fetch_optional(&mut *connection) - .await - .map_err(map_error)? - .map(|row| row.try_get::, _>("sql")) - .transpose() - .map_err(map_error)? - .flatten(); + let create_statement = create_statement(connection, table).await?; let expressions = match ( &create_statement, @@ -285,6 +287,7 @@ pub(crate) async fn fetch_table_definition( .map(|row| { Ok(InboundKeyRow { child: text(row, "child")?, + child_sql: row.try_get("child_sql").map_err(map_error)?, id: row.try_get("id").map_err(map_error)?, from: text(row, "from")?, to: row.try_get("to").map_err(map_error)?, @@ -312,6 +315,20 @@ pub(crate) async fn fetch_table_definition( }) } +/// The CREATE statement SQLite stored for the table. +async fn create_statement(connection: &mut SqlxConnection, table: &TableRef) -> Result, DriverError> { + let schema_sql = quote(schema_of(table)); + Ok(sqlx::query(AssertSqlSafe(catalog::object_sql(&schema_sql))) + .bind(table.name.as_str()) + .fetch_optional(&mut *connection) + .await + .map_err(map_error)? + .map(|row| row.try_get::, _>("sql")) + .transpose() + .map_err(map_error)? + .flatten()) +} + async fn dependents( connection: &mut SqlxConnection, schema_sql: &str, diff --git a/linux/crates/drivers/sqlite/tests/integration/ddl.rs b/linux/crates/drivers/sqlite/tests/integration/ddl.rs index e77af128d5..0c6df43e72 100644 --- a/linux/crates/drivers/sqlite/tests/integration/ddl.rs +++ b/linux/crates/drivers/sqlite/tests/integration/ddl.rs @@ -1,26 +1,42 @@ //! Structure changes the SQLite dialect plans, run against a database -//! file. +//! file: native ALTER TABLE where SQLite has one, and the table rebuild +//! where it does not. -use tablepro_core::ddl::{DraftColumn, SchemaObjectKind, TableDefinition, TableDraft, diff_table}; -use tablepro_core::meta::TableRef; -use tablepro_core::{Connection, Value}; +use tablepro_core::column::ColumnDefault; +use tablepro_core::ddl::{ + BuildDdlError, DdlOperation, DraftColumn, DraftType, ReferentialAction, RefusalReason, SchemaObjectKind, + TableDefinition, TableDraft, diff_table, +}; +use tablepro_core::meta::{IndexKey, TableRef}; +use tablepro_core::statement::GuardedStatement; +use tablepro_core::{ + Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, ServerCode, Value, WriteBatch, WriteKind, WriteReport, +}; -use crate::support::{TestFile, apply, call, query}; +use crate::support::{TestFile, apply, call, query, statement}; fn kept() -> TableRef { TableRef::unqualified("kept") } -async fn read(connection: &dyn Connection) -> TableDefinition { - connection.fetch_table_definition(&kept(), call()).await.unwrap() +fn named(name: &str) -> TableRef { + TableRef::unqualified(name) } -/// Plan the edit against the table as read, run it as the structure tab -/// saves, and read the table again. -async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { - let before = read(connection).await; +async fn read(connection: &dyn Connection, table: &TableRef) -> TableDefinition { + connection.fetch_table_definition(table, call()).await.unwrap() +} + +/// Plan the edit against the table as read and run it as the structure +/// tab saves. +async fn save( + connection: &dyn Connection, + table: &TableRef, + edit: impl FnOnce(&mut TableDraft), +) -> Result { + let before = read(connection, table).await; let mut draft = TableDraft::from_definition(&before); - draft.columns.iter_mut().for_each(edit); + edit(&mut draft); let dialect = connection.dialect(); let plan = dialect .ddl() @@ -28,10 +44,28 @@ async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> .plan_alter_table(&before, &diff_table(&before, &draft)) .unwrap(); connection - .write_batch(&plan.into_write_batch(kept()), call()) + .write_batch(&plan.into_write_batch(table.clone()), call()) + .await +} + +/// Edit each column of `kept`, save, and read the table again. +async fn change(connection: &dyn Connection, edit: impl Fn(&mut DraftColumn)) -> TableDefinition { + save(connection, &kept(), |draft| draft.columns.iter_mut().for_each(edit)) .await .unwrap(); - read(connection).await + read(connection, &kept()).await +} + +fn column<'d>(draft: &'d mut TableDraft, name: &str) -> &'d mut DraftColumn { + draft + .columns + .iter_mut() + .find(|column| column.name == name) + .unwrap_or_else(|| panic!("no column {name}")) +} + +fn retype(draft: &mut TableDraft, name: &str, declaration: &str) { + column(draft, name).column_type = DraftType::Typed(declaration.to_owned()); } /// The stored statement with its quotes taken out: SQLite writes a @@ -45,6 +79,58 @@ fn dependent(definition: &TableDefinition, kind: SchemaObjectKind) -> String { .unwrap_or_default() } +fn dependent_names(definition: &TableDefinition) -> Vec { + definition + .source + .as_ref() + .map(|source| source.dependents.iter().map(|object| object.name.clone()).collect()) + .unwrap_or_default() +} + +fn create_statement(definition: &TableDefinition) -> String { + definition + .source + .as_ref() + .map(|source| source.create_statement.clone()) + .unwrap_or_default() +} + +fn names(definition: &TableDefinition) -> Vec { + definition + .columns + .iter() + .map(|column| column.name().to_owned()) + .collect() +} + +fn text(value: &str) -> Value { + Value::Text(value.to_owned()) +} + +/// Rows written with foreign keys off, which SQLite never checks. +async fn unchecked(connection: &dyn Connection, statements: &[&str]) { + let batch = WriteBatch::new( + WriteKind::SchemaChanges, + named("unchecked"), + statements + .iter() + .map(|sql| GuardedStatement::unguarded(statement(sql))) + .collect(), + ForeignKeyMode::SuspendedThenVerified { checks: Vec::new() }, + ); + connection.write_batch(&batch, call()).await.unwrap(); +} + +async fn rebuild_tables_left(connection: &dyn Connection) -> Value { + query( + connection, + "SELECT count(*) FROM sqlite_schema WHERE name LIKE '!_tablepro!_%' ESCAPE '!'", + ) + .await + .rows[0][0] + .clone() +} + /// SQLite rewrites the indexes and triggers that name a column as it /// renames it, and a swap goes through a temporary name. #[tokio::test] @@ -86,13 +172,6 @@ async fn rename_column_updates_index_and_trigger() { .await .unwrap(); - let names = |definition: &TableDefinition| { - definition - .columns - .iter() - .map(|column| column.name().to_owned()) - .collect::>() - }; assert_eq!(names(&renamed), ["id", "mail", "name"]); assert!(dependent(&renamed, SchemaObjectKind::Index).contains("(mail)")); assert!(dependent(&renamed, SchemaObjectKind::Trigger).contains("new.mail")); @@ -100,14 +179,537 @@ async fn rename_column_updates_index_and_trigger() { assert!(dependent(&swapped, SchemaObjectKind::Index).contains("(name)")); assert!(dependent(&swapped, SchemaObjectKind::Trigger).contains("new.name")); let audit = query(connection.as_ref(), "SELECT note FROM audit ORDER BY rowid").await; + assert_eq!(audit.rows, [vec![text("a@x")], vec![text("b@x")]]); + let kept = query(connection.as_ref(), "SELECT name, mail FROM kept WHERE id = 1").await; + assert_eq!(kept.rows, [vec![text("a@x"), text("Ann")]]); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn create_table_with_foreign_key_succeeds() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "INSERT INTO parents VALUES (1)", + ], + ) + .await + .unwrap(); + let mut draft = TableDraft::new(named("children")); + let mut id = DraftColumn::new("id", DraftType::Typed("INTEGER".to_owned())); + id.primary_key = true; + id.nullable = false; + let parent = DraftColumn::new("parent_id", DraftType::Typed("INTEGER".to_owned())); + draft.columns = vec![id, parent]; + draft.foreign_keys = vec![ForeignKeyInfo { + name: "children_parent".to_owned(), + columns: vec!["parent_id".to_owned()], + ref_schema: None, + ref_table: "parents".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: Some(ReferentialAction::Cascade), + on_update: None, + }]; + let plan = connection.dialect().ddl().unwrap().plan_create_table(&draft).unwrap(); + + connection + .write_batch(&plan.into_write_batch(named("children")), call()) + .await + .unwrap(); + + let children = read(connection.as_ref(), &named("children")).await; + assert_eq!(children.foreign_keys.len(), 1); + let key = &children.foreign_keys[0]; assert_eq!( - audit.rows, - [vec![Value::Text("a@x".to_owned())], vec![Value::Text("b@x".to_owned())]] + (key.columns.as_slice(), key.ref_table.as_str(), key.on_delete), + ( + &["parent_id".to_owned()][..], + "parents", + Some(ReferentialAction::Cascade) + ) ); - let kept = query(connection.as_ref(), "SELECT name, mail FROM kept WHERE id = 1").await; + apply(connection.as_ref(), &["INSERT INTO children VALUES (1, 1)"]) + .await + .unwrap(); + let orphan = apply(connection.as_ref(), &["INSERT INTO children VALUES (2, 99)"]).await; + assert!(orphan.is_err(), "{orphan:?}"); + apply(connection.as_ref(), &["DELETE FROM parents WHERE id = 1"]) + .await + .unwrap(); + let left = query(connection.as_ref(), "SELECT count(*) FROM children").await; + assert_eq!(left.rows[0][0], Value::Int(0)); + connection.close().await.unwrap(); +} + +/// A rebuild keeps every row under its rowid, the indexes and the +/// constraints written into the table, its own trigger, a view on it, a +/// trigger elsewhere that writes to it, and the keys that point at it. +#[tokio::test] +async fn rebuild_preserves_data_rowids_and_dependents() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT)", + "CREATE TABLE orders (\n code TEXT NOT NULL UNIQUE, -- the order's code\n customer INTEGER REFERENCES \ + customers (id),\n total REAL DEFAULT 0 CHECK (total >= 0),\n note TEXT COLLATE NOCASE\n)", + "CREATE TABLE lines (order_code TEXT REFERENCES orders (code), qty INTEGER)", + "CREATE TABLE audit (note TEXT)", + "CREATE INDEX orders_by_customer ON orders (customer)", + "CREATE TRIGGER orders_audit AFTER INSERT ON orders BEGIN INSERT INTO audit VALUES ('order ' || \ + new.code); END", + "CREATE VIEW big_orders AS SELECT code, total FROM orders WHERE total > 10", + "CREATE TRIGGER customers_cleanup AFTER DELETE ON customers BEGIN DELETE FROM orders WHERE customer = \ + old.id; END", + "INSERT INTO customers VALUES (1, 'Ann'), (2, 'Bo')", + "INSERT INTO orders (rowid, code, customer, total, note) VALUES (5, 'a', 1, 20, 'X'), (9, 'b', 2, 5, \ + 'y'), (12, 'c', 1, 50, NULL)", + "INSERT INTO lines VALUES ('a', 1), ('c', 2)", + ], + ) + .await + .unwrap(); + let rows_sql = "SELECT rowid, code, customer, total, note FROM orders ORDER BY rowid"; + let before_rows = query(connection.as_ref(), rows_sql).await.rows; + let before = read(connection.as_ref(), &named("orders")).await; + + save(connection.as_ref(), &named("orders"), |draft| { + retype(draft, "total", "DOUBLE PRECISION"); + column(draft, "note").default = ColumnDefault::Literal(text("none")); + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &named("orders")).await; + assert_eq!(query(connection.as_ref(), rows_sql).await.rows, before_rows); + assert_eq!(dependent_names(&after), dependent_names(&before)); + assert_eq!(after.indexes, before.indexes); + assert_eq!(after.foreign_keys, before.foreign_keys); + assert_eq!(after.inbound_foreign_keys, before.inbound_foreign_keys); + let create = create_statement(&after); + for kept in [ + "-- the order's code", + "CHECK (total >= 0)", + "COLLATE NOCASE", + "total DOUBLE PRECISION", + "DEFAULT 'none'", + ] { + assert!(create.contains(kept), "{kept} is not in {create}"); + } + assert_eq!(rebuild_tables_left(connection.as_ref()).await, Value::Int(0)); + + let view = query(connection.as_ref(), "SELECT code FROM big_orders ORDER BY code").await; + assert_eq!(view.rows, [vec![text("a")], vec![text("c")]]); + apply( + connection.as_ref(), + &["INSERT INTO orders (code, customer) VALUES ('d', 1)"], + ) + .await + .unwrap(); + let audit = query(connection.as_ref(), "SELECT note FROM audit ORDER BY rowid").await; + assert_eq!(audit.rows.last(), Some(&vec![text("order d")])); + assert_eq!(audit.rows.len(), 4, "the copy fired no trigger"); + apply(connection.as_ref(), &["DELETE FROM customers WHERE id = 2"]) + .await + .unwrap(); + let codes = query(connection.as_ref(), "SELECT code FROM orders ORDER BY code").await; + assert_eq!(codes.rows, [vec![text("a")], vec![text("c")], vec![text("d")]]); + let dangling = apply(connection.as_ref(), &["INSERT INTO lines VALUES ('zzz', 1)"]).await; + assert!(dangling.is_err(), "foreign keys are back on: {dangling:?}"); + connection.close().await.unwrap(); +} + +/// Violations written while foreign keys were off, in the rebuilt +/// table's children and in a table the save does not touch, are not +/// the save's. +#[tokio::test] +async fn rebuild_succeeds_with_preexisting_unrelated_violation() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY, label TEXT)", + "CREATE TABLE children (id INTEGER PRIMARY KEY, parent INTEGER REFERENCES parents (id))", + "CREATE TABLE others (id INTEGER PRIMARY KEY, parent INTEGER REFERENCES parents (id))", + "INSERT INTO parents VALUES (1, 'one')", + ], + ) + .await + .unwrap(); + unchecked( + connection.as_ref(), + &[ + "INSERT INTO children VALUES (1, 1), (2, 99)", + "INSERT INTO others VALUES (1, 98)", + ], + ) + .await; + + let parents = save(connection.as_ref(), &named("parents"), |draft| { + retype(draft, "label", "BLOB"); + }) + .await; + let children = save(connection.as_ref(), &named("children"), |draft| { + column(draft, "parent").default = ColumnDefault::Literal(Value::Int(1)); + }) + .await; + + assert!(parents.is_ok(), "{parents:?}"); + assert!(children.is_ok(), "{children:?}"); + let orphans = query(connection.as_ref(), "SELECT count(*) FROM pragma_foreign_key_check").await; + assert_eq!(orphans.rows[0][0], Value::Int(2)); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn not_null_change_with_existing_nulls_rolls_back_schema_unchanged() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, nickname TEXT)", + "CREATE INDEX kept_by_nickname ON kept (nickname)", + "INSERT INTO kept VALUES (1, 'a'), (2, NULL)", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + + let outcome = save(connection.as_ref(), &kept(), |draft| { + column(draft, "nickname").nullable = false; + }) + .await; + + let Err(DriverError::RolledBack { source, .. }) = &outcome else { + panic!("{outcome:?}"); + }; + assert!(source.to_string().contains("NOT NULL"), "{source}"); + let after = read(connection.as_ref(), &kept()).await; + assert_eq!(after, before); + let rows = query(connection.as_ref(), "SELECT id, nickname FROM kept ORDER BY id").await; + assert_eq!( + rows.rows, + [vec![Value::Int(1), text("a")], vec![Value::Int(2), Value::Null]] + ); + assert_eq!(rebuild_tables_left(connection.as_ref()).await, Value::Int(0)); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn adding_violating_foreign_key_rolls_back_with_diagnostics() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "CREATE TABLE children (id INTEGER PRIMARY KEY, parent INTEGER)", + "INSERT INTO parents VALUES (1)", + "INSERT INTO children VALUES (1, 1), (2, 7)", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &named("children")).await; + + let outcome = save(connection.as_ref(), &named("children"), |draft| { + draft.foreign_keys.push(ForeignKeyInfo { + name: "children_parent".to_owned(), + columns: vec!["parent".to_owned()], + ref_schema: None, + ref_table: "parents".to_owned(), + ref_columns: vec!["id".to_owned()], + on_delete: None, + on_update: None, + }); + }) + .await; + + let Err(DriverError::RolledBack { source, .. }) = &outcome else { + panic!("{outcome:?}"); + }; + let DriverError::Server(diagnostics) = source.as_ref() else { + panic!("{source:?}"); + }; + assert_eq!( + diagnostics.code, + Some(ServerCode::Sqlite { + primary: 19, + extended: 787 + }) + ); + assert!( + diagnostics.message.contains("1 rows of children") && diagnostics.message.contains("parents"), + "{}", + diagnostics.message + ); + assert_eq!(read(connection.as_ref(), &named("children")).await, before); + connection.close().await.unwrap(); +} + +/// Two columns trade names while one of them changes type, so the +/// renames after the rebuild step aside through a temporary name. +#[tokio::test] +async fn swap_rename_with_type_change_applies() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, a TEXT, b TEXT)", + "CREATE TABLE audit (note TEXT)", + "CREATE INDEX kept_by_a ON kept (a)", + "CREATE TRIGGER kept_audit AFTER INSERT ON kept BEGIN INSERT INTO audit VALUES (new.b); END", + "INSERT INTO kept VALUES (1, 'first a', 'first b')", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + column(draft, "a").name = "b".to_owned(); + draft.columns[2].name = "a".to_owned(); + draft.columns[1].column_type = DraftType::Typed("BLOB".to_owned()); + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert_eq!(names(&after), ["id", "b", "a"]); + let declared: Vec = after + .columns + .iter() + .map(|column| column.info.column_type.name.clone()) + .collect(); + assert_eq!(declared, ["INTEGER", "BLOB", "TEXT"]); + let rows = query(connection.as_ref(), "SELECT b, a FROM kept").await; + assert_eq!(rows.rows, [vec![text("first a"), text("first b")]]); + assert!(dependent(&after, SchemaObjectKind::Index).contains("(b)")); + assert!(dependent(&after, SchemaObjectKind::Trigger).contains("new.a")); + assert_eq!( + after + .indexes + .iter() + .find(|index| index.name == "kept_by_a") + .map(|index| index.keys.clone()), + Some(vec![IndexKey::Column { + name: "b".to_owned(), + descending: false + }]) + ); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn autoincrement_high_water_mark_survives_rebuild() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)", + "INSERT INTO kept (v) VALUES ('a'), ('b'), ('c')", + "DELETE FROM kept WHERE id = 3", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| retype(draft, "v", "BLOB")) + .await + .unwrap(); + apply(connection.as_ref(), &["INSERT INTO kept (v) VALUES ('d')"]) + .await + .unwrap(); + + let ids = query(connection.as_ref(), "SELECT id FROM kept ORDER BY id").await; + assert_eq!( + ids.rows, + [vec![Value::Int(1)], vec![Value::Int(2)], vec![Value::Int(4)]] + ); + let sequence = query( + connection.as_ref(), + "SELECT name, seq FROM sqlite_sequence ORDER BY name", + ) + .await; + assert_eq!(sequence.rows, [vec![text("kept"), Value::Int(4)]]); + assert!(create_statement(&read(connection.as_ref(), &kept()).await).contains("AUTOINCREMENT")); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn primary_key_change_via_rebuild_applies() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (code TEXT, n INTEGER)", + "INSERT INTO kept VALUES ('x', 1), ('y', 2)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + let code = column(draft, "code"); + code.primary_key = true; + code.nullable = false; + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert_eq!(after.primary_key.map(|key| key.columns), Some(vec!["code".to_owned()])); + let rows = query(connection.as_ref(), "SELECT rowid, code, n FROM kept ORDER BY rowid").await; + assert_eq!( + rows.rows, + [ + vec![Value::Int(1), text("x"), Value::Int(1)], + vec![Value::Int(2), text("y"), Value::Int(2)] + ] + ); + let duplicate = apply(connection.as_ref(), &["INSERT INTO kept VALUES ('x', 3)"]).await; + assert!(duplicate.is_err(), "{duplicate:?}"); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn without_rowid_table_rebuild_applies() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (k TEXT PRIMARY KEY, v INTEGER) WITHOUT ROWID", + "INSERT INTO kept VALUES ('a', 1), ('b', 2)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + retype(draft, "v", "TEXT"); + column(draft, "v").nullable = false; + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &kept()).await; + assert!( + create_statement(&after).ends_with("WITHOUT ROWID"), + "{}", + create_statement(&after) + ); + assert!(!after.columns[1].info.nullable); + let rows = query(connection.as_ref(), "SELECT k, v FROM kept ORDER BY k").await; + assert_eq!(rows.rows, [vec![text("a"), text("1")], vec![text("b"), text("2")]]); + connection.close().await.unwrap(); +} + +/// The catalogue numbers a table's foreign keys from the last one +/// declared, and the rebuild removes the one the user picked. +#[tokio::test] +async fn dropping_one_of_several_foreign_keys_keeps_the_others() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parents (id INTEGER PRIMARY KEY)", + "CREATE TABLE products (sku TEXT PRIMARY KEY)", + "CREATE TABLE lines (parent INTEGER CONSTRAINT to_parent REFERENCES parents (id), sku TEXT, qty \ + INTEGER, FOREIGN KEY (sku) REFERENCES products (sku) ON DELETE CASCADE)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &named("lines"), |draft| { + draft.foreign_keys.retain(|key| key.ref_table != "parents"); + }) + .await + .unwrap(); + + let after = read(connection.as_ref(), &named("lines")).await; + let parents: Vec<&str> = after.foreign_keys.iter().map(|key| key.ref_table.as_str()).collect(); + assert_eq!(parents, ["products"]); + assert!(!create_statement(&after).contains("to_parent")); + apply(connection.as_ref(), &["INSERT INTO lines VALUES (42, NULL, 1)"]) + .await + .unwrap(); + connection.close().await.unwrap(); +} + +/// A rename of the table in the same save as a rebuild runs last, and +/// SQLite carries it into the keys of the tables that point at it. +#[tokio::test] +async fn rename_with_rebuild_moves_the_children_keys() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, label TEXT)", + "CREATE TABLE children (kept_id INTEGER REFERENCES kept (id))", + "INSERT INTO kept VALUES (1, 'one')", + "INSERT INTO children VALUES (1)", + ], + ) + .await + .unwrap(); + + save(connection.as_ref(), &kept(), |draft| { + draft.table.name = "renamed".to_owned(); + retype(draft, "label", "BLOB"); + }) + .await + .unwrap(); + + let children = read(connection.as_ref(), &named("children")).await; + assert_eq!(children.foreign_keys[0].ref_table, "renamed"); + let rows = query(connection.as_ref(), "SELECT id, label FROM renamed").await; + assert_eq!(rows.rows, [vec![Value::Int(1), text("one")]]); + connection.close().await.unwrap(); +} + +#[tokio::test] +async fn dropping_a_column_a_view_needs_is_refused_before_anything_runs() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (id INTEGER PRIMARY KEY, code TEXT UNIQUE, note TEXT)", + "CREATE VIEW codes AS SELECT code FROM kept", + ], + ) + .await + .unwrap(); + let before = read(connection.as_ref(), &kept()).await; + let mut draft = TableDraft::from_definition(&before); + draft.columns.retain(|column| column.name != "code"); + + let plan = connection + .dialect() + .ddl() + .unwrap() + .plan_alter_table(&before, &diff_table(&before, &draft)); + assert_eq!( - kept.rows, - [vec![Value::Text("a@x".to_owned()), Value::Text("Ann".to_owned())]] + plan, + Err(BuildDdlError::Refused { + column: "code".to_owned(), + operation: DdlOperation::DropColumn, + reason: RefusalReason::ColumnInUse(vec!["codes".to_owned()]), + }) ); connection.close().await.unwrap(); } diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index 0d9dfcea7c..e518c83764 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -13,7 +13,7 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` ## Rationale -**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE only on SQLite, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements and the `ForeignKeyMode`, and becomes the `WriteBatch`. +**One change, the engine's order.** The tab edits a `TableDraft`. `diff_table` turns it into `TableChanges`: the table rename, the columns dropped, renamed, altered and added, the primary-key move, and the indexes and foreign keys dropped and added. `plan_alter_table` writes them in the order the engine needs: one combined ALTER TABLE on PostgreSQL, the foreign keys around one ALTER on MySQL, separate statements on SQL Server, with a named default dropped before its column changes and added back after, native ALTER TABLE on SQLite where it has one and a rebuild of the table where it does not, one ALTER per change on ClickHouse. A `DdlPlan` carries the statements and the `ForeignKeyMode`, and becomes the `WriteBatch`. **Refused before Save.** `DdlCapabilities` and `ColumnCapabilities` answer which changes the engine takes, each refusal with a `RefusalReason` the tab shows beside the control it turns off. A primary key another table's foreign key points at cannot move on any engine, and a column such a key references cannot be dropped. Every plan runs the same checks first, so a caller that skips the UI gets `BuildDdlError::Refused` or `Unsupported` rather than SQL the server rejects. @@ -37,7 +37,8 @@ Each engine plans structure changes through its own `DdlDialect`, reached from ` - Engines that rename one column per statement (PostgreSQL, SQL Server, SQLite, ClickHouse) run the renames in an order where each new name is free, and a swap or longer cycle steps one column aside to `__tablepro_rename_` first. MySQL reads every clause of its one ALTER against the table as it was, so its renames need no order. PostgreSQL runs the drops before the renames when a save has both, so a column can take the name of one the same save drops. - Turning numbering on for a PostgreSQL integer column adds a `BY DEFAULT` identity and moves its sequence past the largest key in the same transaction. Turning it off drops the identity, or the default of a serial column, whose sequence stays owned by the column. The identity's sequence is therefore found by its internal dependency on the column, not by `pg_get_serial_sequence`, which can return that serial sequence instead. - A plan for a non-empty change writes at least one statement or refuses it, so a save never reports success for a table it left as it was. -- SQLite table rebuilds are a plan of their own, added to the same dialect later, and their capabilities are off until then. +- SQLite changes a column's type, nullability, default or key, and adds or drops a foreign key, by rebuilding the table (lang_altertable.html section 7) under `ForeignKeyMode::SuspendedThenVerified`. The new table is the stored CREATE TABLE with only the edited parts replaced, so what the definition does not model stays as it was written, and a table whose CREATE TABLE cannot be read refuses those changes as `UnreadableDefinition`. +- SQLite's pragmas drop a foreign key's constraint name, so the definition takes it from the stored CREATE TABLE and makes one up for a key declared without one. ## Alternatives considered diff --git a/linux/docs/drivers/sqlite.md b/linux/docs/drivers/sqlite.md index c7417cb847..9b76004f57 100644 --- a/linux/docs/drivers/sqlite.md +++ b/linux/docs/drivers/sqlite.md @@ -67,6 +67,22 @@ A `COMMIT` never crosses a network, so nothing is left to resolve afterwards (`C The grid names a row by its primary key, then by a unique index whose columns are all `NOT NULL`, then by `rowid`. A `WITHOUT ROWID` table with neither key, or a table with a column named `rowid` that hides the real one, is read-only in the grid. +## Structure changes + +`ALTER TABLE` renames a table or a column, adds a column, and drops one. The DDL dialect uses it where SQLite allows the change: an added column with no key, a constant default, and NOT NULL only with a default; a dropped column that has no key or UNIQUE of its own. Every other change rebuilds the table, following the procedure in [lang_altertable.html](https://www.sqlite.org/lang_altertable.html#otheralter), in one batch under `ForeignKeyMode::SuspendedThenVerified`: + +1. Drop the views, and the triggers on other tables, that name the table, since the rename in step 5 checks every one of them. +2. Create `_tablepro_rebuild_` from the stored CREATE TABLE with only the edited parts replaced: a column's type, NULL, DEFAULT and key, the key itself, and the foreign keys added or dropped. A COLLATE, CHECK, generated expression, conflict clause, comment or `WITHOUT ROWID` the edit did not touch stays as it was written. +3. Copy the rows, with their rowids unless the table has none or an `INTEGER PRIMARY KEY` column carries them. A generated column is computed again. +4. With AUTOINCREMENT on both sides, carry the `sqlite_sequence` mark, so a deleted number is not handed out again. +5. Drop the old table and rename the new one into its place. +6. Make the indexes, triggers and views again from their stored SQL, in the order the schema first made them. +7. Rename columns, then the table, natively, so SQLite carries the new names into every index, trigger, view and foreign key. + +The foreign key check covers the rebuilt table and every table whose keys point at it, and fails the save only for a violation the save introduced. A rebuild refuses to drop a column that a kept index, CHECK, generated column, foreign key, trigger or view still names. A table whose CREATE TABLE the reader cannot follow, such as a virtual table, keeps the native changes and refuses the rest as `UnreadableDefinition`. + +SQLite's pragmas do not report a foreign key's constraint name, so the definition reads it from the CREATE TABLE text, and a key declared without one is named `fk_
_` after its position. + ## Editor sessions - A script is split where SQLite itself would end a statement, so the semicolons inside a trigger body do not end it. Text the splitter cannot finish, such as an unterminated string, goes to SQLite whole, so the error is SQLite's own. The splitter is tested against `sqlite3_complete` (`tests/script_plan_matches_sqlite3_complete.rs`). From ee61e95405d45f2af886e9de463423cbcb538546 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:36:05 +0700 Subject: [PATCH 012/301] test(linux): check DDL conformance changes keep rows and indexes --- .../test-support/src/conformance/cases/ddl.rs | 98 ++++++++++++++++++- .../src/conformance/ddl_suite_macro.rs | 6 ++ linux/docs/testing.md | 1 + 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/linux/crates/test-support/src/conformance/cases/ddl.rs b/linux/crates/test-support/src/conformance/cases/ddl.rs index e0731e385d..1b9155d4d9 100644 --- a/linux/crates/test-support/src/conformance/cases/ddl.rs +++ b/linux/crates/test-support/src/conformance/cases/ddl.rs @@ -11,16 +11,19 @@ use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, IndexKey, ObjectKind, ObjectScope}; use tablepro_core::value::Value; use tablepro_core::{ - Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, TableRef, WriteAtomicity, WriteBatch, WriteKind, + Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, RowLimit, TableRef, WriteAtomicity, WriteBatch, WriteKind, WriteReport, }; -use crate::conformance::support::{CALL_DEADLINE, connect, expect, unexpected, unique, within, writable}; +use crate::conformance::support::{ + CALL_DEADLINE, apply, connect, count, expect, insert_sql, statement, unexpected, unique, within, writable, +}; use crate::conformance::{ConformanceDdlEngine, ConformanceFailure}; const APPLY_CASE: &str = "ddl_plans_apply_and_read_back"; const FAILED_CASE: &str = "failed_ddl_batch_outcome_matches_ddl_atomicity"; const SCRIPT_CASE: &str = "create_table_script_recreates_the_table"; +const ROWS_CASE: &str = "structure_changes_keep_rows_and_indexes"; /// Case 1: each structure change the engine offers applies and reads /// back as drafted, and each one it refuses is refused by the plan @@ -340,6 +343,97 @@ pub async fn create_table_script_recreates_the_table( Ok(()) } +/// Case 4: a table's rows and indexes come through the column changes +/// the engine offers. SQLite makes these by rebuilding the table, which +/// copies every row and makes every index again. +pub async fn structure_changes_keep_rows_and_indexes( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = ROWS_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let ddl = ddl_of(case, dialect.as_ref())?; + let parent = engine.scratch_table(&unique("ddl_rows")); + create( + case, + &engine, + connection.as_ref(), + ddl, + &parent_draft(case, &engine, dialect.as_ref(), &parent)?, + ) + .await?; + let mut table = definition(connection.as_ref(), &parent).await?; + let step = Step { + case, + connection: connection.as_ref(), + ddl, + }; + let index = IndexInfo { + name: format!("{}_label", parent.name), + keys: vec![IndexKey::Column { + name: "label".to_owned(), + descending: false, + }], + unique: false, + primary: false, + predicate: None, + }; + step.apply( + &mut table, + "create index", + table_permission(ddl.capabilities().create_index), + |draft| draft.indexes.push(index), + ) + .await?; + let labels = ["first", "second", "third"]; + let inserts = (1..) + .zip(labels) + .map(|(id, label)| insert_sql(dialect.as_ref(), &parent, id, label)) + .collect(); + apply(connection.as_ref(), &parent, WriteKind::RowChanges, inserts).await?; + let indexes = secondary_indexes(&table); + + let permission = column_permission(ddl, &table, "label", |permissions| permissions.alter_default); + step.apply(&mut table, "change default", permission, |draft| { + edit_column(draft, "label", |column| { + column.default = ColumnDefault::Literal(Value::Text("none".to_owned())); + }) + }) + .await?; + // `created_at` took its default on every row, so none is NULL. + let permission = column_permission(ddl, &table, "created_at", |permissions| permissions.alter_nullability); + step.apply(&mut table, "change nullability", permission, |draft| { + edit_column(draft, "created_at", |column| column.nullable = false) + }) + .await?; + + let rows = count(connection.as_ref(), &parent).await?; + expect(case, rows == 3, || format!("{rows} rows are left of 3"))?; + let sql = format!( + "SELECT {} FROM {} ORDER BY {}", + dialect.quote_identifier("label"), + dialect.qualify(&parent), + dialect.quote_identifier("id") + ); + let read = connection + .query_bound(&statement(&sql), RowLimit::EDITOR_DEFAULT, within(CALL_DEADLINE)) + .await?; + let expected: Vec> = labels + .iter() + .map(|label| vec![Value::Text((*label).to_owned())]) + .collect(); + expect(case, read.rows == expected, || { + format!("the labels read back as {:?}", read.rows) + })?; + let after = secondary_indexes(&table); + expect(case, after == indexes, || { + format!("the indexes were {indexes:?} and are {after:?}") + })?; + drop_table(case, connection.as_ref(), ddl, &parent).await?; + connection.close().await?; + Ok(()) +} + fn ddl_of<'a>(case: &'static str, dialect: &'a dyn SqlDialect) -> Result<&'a dyn DdlDialect, ConformanceFailure> { dialect .ddl() diff --git a/linux/crates/test-support/src/conformance/ddl_suite_macro.rs b/linux/crates/test-support/src/conformance/ddl_suite_macro.rs index 6b48621987..a46c8e8e75 100644 --- a/linux/crates/test-support/src/conformance/ddl_suite_macro.rs +++ b/linux/crates/test-support/src/conformance/ddl_suite_macro.rs @@ -26,5 +26,11 @@ macro_rules! ddl_conformance_suite { create_table_script_recreates_the_table, $crate::conformance::cases::ddl::create_table_script_recreates_the_table::<$engine> } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + structure_changes_keep_rows_and_indexes, + $crate::conformance::cases::ddl::structure_changes_keep_rows_and_indexes::<$engine> + } }; } diff --git a/linux/docs/testing.md b/linux/docs/testing.md index e38c41ccbe..fb002a2513 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -144,6 +144,7 @@ Every case plans through `connection.dialect().ddl()` and runs the plan with `wr - `ddl_plans_apply_and_read_back` creates a table with a quoted literal default and an expression default, then adds, renames and drops a column, changes its nullability and default, adds an index and a foreign key, renames the table, which has to stay listed in its own schema, and drops the tables. After each step `fetch_table_definition` has to match the draft. A step the dialect refuses has to be refused by its plan as well, with a reason, so no step is skipped without a check. Once the foreign key exists, the referenced primary key has to read as held by it. - `failed_ddl_batch_outcome_matches_ddl_atomicity` sends two plans whose second statement the server rejects, and checks the outcome against `ddl_writes`: `RolledBack` and nothing applied, or `PartiallyApplied` and the first plan applied. - `create_table_script_recreates_the_table` drops a table and runs its `create_table_script`, which has to bring back the same column definitions and index keys. +- `structure_changes_keep_rows_and_indexes` fills an indexed table, then changes a default and a nullability. The rows and the index have to come through, which on SQLite means through the table rebuild. The PostgreSQL, MySQL, SQL Server, ClickHouse and SQLite targets run it, and the MySQL target runs it on MariaDB as well. SQLite passes `docker: false`, so its cases run in the unit suite. From 700f007c896a9d7c90db772971b451f178485e5e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:54:42 +0700 Subject: [PATCH 013/301] fix(linux-ssh): copy the system ssh_config into a jump host's pin file --- linux/crates/ssh/src/hop_config.rs | 147 ++++++++++++------ linux/crates/ssh/src/session.rs | 17 +- linux/crates/ssh/tests/openssh_session.rs | 60 ++++++- .../0013-openssh-client-transport.md | 2 +- 4 files changed, 164 insertions(+), 62 deletions(-) diff --git a/linux/crates/ssh/src/hop_config.rs b/linux/crates/ssh/src/hop_config.rs index 40e0ae9909..13a6823c5c 100644 --- a/linux/crates/ssh/src/hop_config.rs +++ b/linux/crates/ssh/src/hop_config.rs @@ -8,9 +8,9 @@ use tablepro_core::SshFailure; use crate::host_key_checking::HostKeyChecking; use crate::route::JumpHop; -/// What OpenSSH reads when it is given no `-F`, in its order: the user's -/// file, then the system's (`process_config_files` in ssh.c). -const DEFAULT_CONFIG_FILES: [&str; 2] = ["~/.ssh/config", "/etc/ssh/ssh_config"]; +/// What OpenSSH reads after the user's file when it is given no `-F` +/// (`process_config_files` in ssh.c). +const SYSTEM_CONFIG: &str = "/etc/ssh/ssh_config"; const FILE_NAME: &str = "ssh_config"; /// The jump hosts whose own configuration would trust a host key the @@ -33,19 +33,36 @@ pub(crate) fn hosts_to_pin(hops: &[JumpHop]) -> Vec<&str> { /// ssh.c runs a jump host through a second ssh that gets only `-l`, /// `-p`, `-J` and `-F` from the master's command line, so the master's /// `-F` is the only way to reach it. The first value ssh_config gives -/// wins, so the pins come before the includes. `base` is the file the -/// master would otherwise be given with `-F`. -pub(crate) fn render(hosts: &[&str], base: Option<&Path>) -> Result { - let mut text = String::new(); +/// wins, so the pins come first. `base` is the file the master would +/// otherwise be given with `-F`. +pub(crate) fn render(hosts: &[impl AsRef], base: Option<&Path>) -> Result, SshFailure> { + render_over(hosts, base, Path::new(SYSTEM_CONFIG)) +} + +/// OpenSSH holds every `Include` to an owner check that it skips for +/// the `-F` file and the system file. In a Flatpak sandbox root's files +/// belong to nobody, so those two are copied in rather than included. +/// The user's file gets the same check either way. +fn render_over(hosts: &[impl AsRef], base: Option<&Path>, system_config: &Path) -> Result, SshFailure> { + let mut text = Vec::new(); for host in hosts { - text.push_str(&format!("Host {host}\n StrictHostKeyChecking ask\n")); + let host = host.as_ref(); + text.extend_from_slice(format!("Host {host}\n StrictHostKeyChecking ask\n").as_bytes()); } - text.push_str("Match all\n"); + text.extend_from_slice(b"Match all\n"); match base { - Some(base) => text.push_str(&format!("Include \"{}\"\n", included(base)?)), + Some(base) => { + let contents = std::fs::read(base).map_err(|error| SshFailure::ConfigRejected { + detail: format!("{}: {error}", base.display()), + })?; + text.extend_from_slice(&contents); + } None => { - for file in DEFAULT_CONFIG_FILES { - text.push_str(&format!("Include {file}\n")); + text.extend_from_slice(b"Include ~/.ssh/config\n"); + // ssh skips a system file it cannot open, so this does too. + match std::fs::read(system_config) { + Ok(contents) => text.extend_from_slice(&contents), + Err(error) => tracing::debug!(%error, "the system ssh_config could not be read"), } } } @@ -57,7 +74,7 @@ pub(crate) fn render(hosts: &[&str], base: Option<&Path>) -> Result Result { +pub(crate) fn write(master_dir: &Path, contents: &[u8]) -> Result { let path = master_dir.join(FILE_NAME); let unsafe_dir = |detail: String| SshFailure::RuntimeDirUnsafe { path: path.clone(), @@ -73,7 +90,7 @@ pub(crate) fn write(master_dir: &Path, contents: &str) -> Result bool { .all(|byte| byte.is_ascii_alphanumeric() || b"/._-+@:=,".contains(byte)) } -/// A path an `Include` line takes in quotes. OpenSSH 9.9 and later -/// expand `%` tokens and `${}` in it and older ones do not, so a path -/// holding either cannot be written to mean the same file on both. -fn included(path: &Path) -> Result<&str, SshFailure> { - let rejected = || SshFailure::ConfigRejected { - detail: format!("{} cannot be included from an ssh_config file", path.display()), - }; - let text = path.to_str().ok_or_else(rejected)?; - if text - .chars() - .any(|c| c.is_control() || matches!(c, '"' | '\\' | '%' | '$')) - { - return Err(rejected()); - } - Ok(text) -} - #[cfg(test)] mod tests { use std::os::unix::fs::PermissionsExt; @@ -130,44 +130,97 @@ mod tests { assert_eq!(hosts_to_pin(&hops), ["j1", "j3"]); } + fn file(dir: &Path, name: &str, contents: &str, mode: u32) -> PathBuf { + let path = dir.join(name); + std::fs::write(&path, contents).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path + } + #[test] fn the_pins_come_before_what_ssh_would_have_read() { + let dir = tempfile::tempdir().unwrap(); + let system = file(dir.path(), "system", "SendEnv LANG\n", 0o644); + let base = file(dir.path(), "base", "Host *\n User ops\n", 0o644); + assert_eq!( - render(&["j1", "fd00::1"], None).unwrap(), + String::from_utf8(render_over(&["j1", "fd00::1"], None, &system).unwrap()).unwrap(), "Host j1\n StrictHostKeyChecking ask\nHost fd00::1\n StrictHostKeyChecking ask\nMatch all\n\ - Include ~/.ssh/config\nInclude /etc/ssh/ssh_config\n" + Include ~/.ssh/config\nSendEnv LANG\n" ); assert_eq!( - render(&["j1"], Some(Path::new("/tmp/test/ssh_config"))).unwrap(), - "Host j1\n StrictHostKeyChecking ask\nMatch all\nInclude \"/tmp/test/ssh_config\"\n" + String::from_utf8(render_over(&["j1"], Some(&base), &system).unwrap()).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nMatch all\nHost *\n User ops\n" + ); + assert_eq!( + String::from_utf8(render_over(&["j1"], None, &dir.path().join("missing")).unwrap()).unwrap(), + "Host j1\n StrictHostKeyChecking ask\nMatch all\nInclude ~/.ssh/config\n" ); } #[test] - fn a_base_file_that_would_expand_differently_is_refused() { - for base in ["/tmp/%d/config", "/tmp/${HOME}/config", "/tmp/a\"b"] { - assert!( - matches!( - render(&[], Some(Path::new(base))), - Err(SshFailure::ConfigRejected { .. }) - ), - "{base}" - ); - } + fn a_base_file_ssh_could_not_open_is_refused() { + let dir = tempfile::tempdir().unwrap(); + + assert!(matches!( + render(&["j1"], Some(&dir.path().join("missing"))), + Err(SshFailure::ConfigRejected { .. }) + )); + } + + /// A system file anyone can write fails the owner check the way + /// root's file does in a Flatpak sandbox, where it belongs to nobody. + /// ssh reads it on its own all the same, so the pinned file has to as + /// well. + #[test] + fn ssh_reads_the_pinned_file_over_a_system_file_it_would_not_include() { + let dir = tempfile::tempdir().unwrap(); + let home = tempfile::tempdir().unwrap(); + let system = file( + dir.path(), + "system", + "Host *\n StrictHostKeyChecking accept-new\n Port 2299\n", + 0o666, + ); + let master_dir = dir.path().join("master"); + std::fs::create_dir(&master_dir).unwrap(); + let pinned = write(&master_dir, &render_over(&["j1"], None, &system).unwrap()).unwrap(); + + let resolve = |host: &str| { + let output = std::process::Command::new("ssh") + .arg("-G") + .arg("-F") + .arg(&pinned) + .arg(host) + .env("HOME", home.path()) + .output() + .unwrap(); + assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr)); + String::from_utf8(output.stdout).unwrap() + }; + + let jump = resolve("j1"); + let other = resolve("other"); + assert!(jump.lines().any(|line| line == "stricthostkeychecking ask"), "{jump}"); + assert!(jump.lines().any(|line| line == "port 2299"), "{jump}"); + assert!( + other.lines().any(|line| line == "stricthostkeychecking accept-new"), + "{other}" + ); } #[test] fn the_file_is_private_and_its_path_one_shell_word() { let dir = tempfile::tempdir().unwrap(); - let path = write(dir.path(), "Match all\n").unwrap(); + let path = write(dir.path(), b"Match all\n").unwrap(); assert_eq!(std::fs::read_to_string(&path).unwrap(), "Match all\n"); assert_eq!(std::fs::metadata(&path).unwrap().permissions().mode() & 0o777, 0o600); let spaced = dir.path().join("with space"); std::fs::create_dir(&spaced).unwrap(); assert!(matches!( - write(&spaced, "Match all\n"), + write(&spaced, b"Match all\n"), Err(SshFailure::RuntimeDirUnsafe { .. }) )); assert!(!plain_shell_word(Path::new("/run/user/1000/%d"))); diff --git a/linux/crates/ssh/src/session.rs b/linux/crates/ssh/src/session.rs index 4f168b2e7a..8f89851f7c 100644 --- a/linux/crates/ssh/src/session.rs +++ b/linux/crates/ssh/src/session.rs @@ -261,17 +261,20 @@ async fn pin_jump_hosts( config_file: Option<&Path>, master_dir: &Path, ) -> Result, TransportError> { - let pinned = hop_config::hosts_to_pin(hops); + let pinned: Vec = hop_config::hosts_to_pin(hops).into_iter().map(str::to_owned).collect(); if pinned.is_empty() { return Ok(None); } - let contents = hop_config::render(&pinned, config_file)?; + let config_file = config_file.map(Path::to_path_buf); let master_dir = master_dir.to_owned(); - tokio::task::spawn_blocking(move || hop_config::write(&master_dir, &contents)) - .await - .map_err(|error| protocol(&format!("the ssh setup task failed: {error}")))? - .map(Some) - .map_err(TransportError::from) + tokio::task::spawn_blocking(move || { + let contents = hop_config::render(&pinned, config_file.as_deref())?; + hop_config::write(&master_dir, &contents) + }) + .await + .map_err(|error| protocol(&format!("the ssh setup task failed: {error}")))? + .map(Some) + .map_err(TransportError::from) } pub(crate) struct HandshakeDeadline { diff --git a/linux/crates/ssh/tests/openssh_session.rs b/linux/crates/ssh/tests/openssh_session.rs index c530d25437..7ecd9ec397 100644 --- a/linux/crates/ssh/tests/openssh_session.rs +++ b/linux/crates/ssh/tests/openssh_session.rs @@ -26,7 +26,7 @@ const SSHD_SCRIPT: &str = "apk add --no-cache openssh >/dev/null \ struct Fixture { container: ContainerAsync, - _temp: tempfile::TempDir, + temp: tempfile::TempDir, host: String, port: u16, config: PathBuf, @@ -76,7 +76,7 @@ async fn start() -> TestResult { }; Ok(Fixture { container, - _temp: temp, + temp, host, port, config, @@ -420,11 +420,56 @@ async fn a_jump_host_whose_config_skips_the_host_key_check_still_asks() { config_file: Some(fixture.config.clone()), ..fixture.services.clone() }; + + jump_host_asks(&fixture, &services).await.unwrap(); +} + +/// The same with no `-F`, as the app runs: the pinned file includes the +/// user's `~/.ssh/config` and copies in the system's. ssh looks the user +/// file up in the password database but an `Include` follows `HOME`, so +/// the wrapper hands the file to the lookups with `-F` and to the +/// `Include` through `HOME`. The pinned file's own `-F` comes last on +/// the master's command line and wins. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_jump_host_whose_user_config_skips_the_host_key_check_still_asks() { + let fixture = start().await.unwrap(); + let home = fixture.temp.path().join("home"); + std::fs::create_dir_all(home.join(".ssh")).unwrap(); + let user_config = home.join(".ssh").join("config"); + write_config(&user_config, &fixture.known_hosts, "accept-new").unwrap(); + // An Include refuses a file others can write. + std::fs::set_permissions(&user_config, std::fs::Permissions::from_mode(0o600)).unwrap(); + let wrapper = fixture.temp.path().join("ssh-as-user"); + std::fs::write( + &wrapper, + format!( + "#!/bin/sh\nHOME='{}' exec ssh -F '{}' \"$@\"\n", + home.display(), + user_config.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&wrapper, std::fs::Permissions::from_mode(0o755)).unwrap(); + let services = SshServices { + ssh_program: wrapper, + config_file: None, + ..fixture.services.clone() + }; + + jump_host_asks(&fixture, &services).await.unwrap(); +} + +async fn jump_host_asks(fixture: &Fixture, services: &SshServices) -> TestResult<()> { let config = SshConfig { // sshd reaches itself on its own port, so one container is both // the jump host and the destination behind it. - destination: SshDestination::new("127.0.0.1", Some(2222), Some("deploy".to_owned())).unwrap(), - jump_hosts: vec![SshDestination::new(&fixture.host, Some(fixture.port), Some("deploy".to_owned())).unwrap()], + destination: SshDestination::new("127.0.0.1", Some(2222), Some("deploy".to_owned()))?, + jump_hosts: vec![SshDestination::new( + &fixture.host, + Some(fixture.port), + Some("deploy".to_owned()), + )?], auth: SshAuth::Agent, }; let prompter = Arc::new(FakePrompter::new(vec![ @@ -436,7 +481,7 @@ async fn a_jump_host_whose_config_skips_the_host_key_check_still_asks() { let session = SshSession::connect( &config, - &services, + services, &LivenessPolicy::DESKTOP, CredentialInteraction::Attended(prompter.clone()), CancellationToken::new(), @@ -457,12 +502,13 @@ async fn a_jump_host_whose_config_skips_the_host_key_check_still_asks() { ), "{purposes:?}" ); - let session = session.unwrap(); + let session = session?; assert_eq!( session.first_hop_endpoint(), - Some(&NetworkEndpoint::new(&fixture.host, fixture.port).unwrap()) + Some(&NetworkEndpoint::new(&fixture.host, fixture.port)?) ); session.shutdown(Duration::from_secs(5)).await; + Ok(()) } #[tokio::test] diff --git a/linux/docs/decisions/0013-openssh-client-transport.md b/linux/docs/decisions/0013-openssh-client-transport.md index 29a1b7af10..325f90fe84 100644 --- a/linux/docs/decisions/0013-openssh-client-transport.md +++ b/linux/docs/decisions/0013-openssh-client-transport.md @@ -22,7 +22,7 @@ People reach their databases through bastions configured in `~/.ssh/config`: `Pr - openssh-client 8.4 or later is a runtime dependency; the GNOME 50 Flatpak runtime ships it. - The helper is installed to libexecdir next to the app. -- A jump host runs in a second ssh that gets only `-l`, `-p`, `-J` and `-F` from the master (ssh.c turns ProxyJump into a ProxyCommand), never its `-o` options. When a hop's configuration sets `StrictHostKeyChecking accept-new` or `no`, the master gets a generated `-F` file that sets `ask` for that host and then includes `~/.ssh/config` and `/etc/ssh/ssh_config`, so the hop asks through the same dialog. Every other connection reads the configuration as before. +- A jump host runs in a second ssh that gets only `-l`, `-p`, `-J` and `-F` from the master (ssh.c turns ProxyJump into a ProxyCommand), never its `-o` options. When a hop's configuration sets `StrictHostKeyChecking accept-new` or `no`, the master gets a generated `-F` file that sets `ask` for that host, includes `~/.ssh/config` and then copies in `/etc/ssh/ssh_config`, so the hop asks through the same dialog. The system file is copied because OpenSSH holds an `Include` to an owner check it skips for that file, and in a Flatpak sandbox root's files belong to nobody. Copied in, a relative `Include` inside it resolves under `~/.ssh` rather than `/etc/ssh`. The `~` of `Include ~/.ssh/config` follows `HOME`, where ssh's own read of the user file follows the password database. A hop whose `ssh -G` lookup fails or takes over 5 seconds is not pinned, and neither is any hop before it. Every other connection reads the configuration as before. - Failures are classified from OpenSSH's stderr, pinned by captured fixtures. A change to those strings in a later OpenSSH release shows up as a failing fixture test. - russh, ssh-key and internal-russh-forked-ssh-key leave the dependency graph when the app moves to this transport. From 53df700317c8c1689a073d6918ce0668e6288b44 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:54:53 +0700 Subject: [PATCH 014/301] fix(linux-postgres): drop storage on a change to any type kept inline --- linux/crates/drivers/postgres/src/ddl.rs | 183 +++++++++++++++--- .../drivers/postgres/tests/integration/ddl.rs | 27 ++- 2 files changed, 173 insertions(+), 37 deletions(-) diff --git a/linux/crates/drivers/postgres/src/ddl.rs b/linux/crates/drivers/postgres/src/ddl.rs index 1d588ca345..4ee9e239a0 100644 --- a/linux/crates/drivers/postgres/src/ddl.rs +++ b/linux/crates/drivers/postgres/src/ddl.rs @@ -3,7 +3,7 @@ use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, fo use tablepro_core::ddl::{ BuildDdlError, CollationName, ColumnAttributes, ColumnCapabilities, ColumnCompression, ColumnDefinition, ColumnStorage, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, IdentityStyle, - Permission, RefusalReason, TableChanges, TableDefinition, TableDraft, + Permission, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -187,11 +187,7 @@ impl DdlDialect for PgDialect { "ALTER COLUMN {name} TYPE {}{collation} USING {name}::{}", resolved.sql, resolved.sql )); - actions.extend(restated_storage( - &name, - &alteration.original.attributes, - &resolved.column_type.kind, - )); + actions.extend(restated_storage(&name, &alteration.original.attributes, &resolved)); } // An identity column is NOT NULL whatever the switch said, and // turning one on sets that below. @@ -401,13 +397,13 @@ fn identity_permission(column: &ColumnDefinition) -> Permission { } /// A type change resets SET STORAGE and SET COMPRESSION to the new -/// type's own, so a column that set either has it restated. A -/// fixed-width type keeps its values inline and uncompressed and refuses -/// both, so the settings go with the old type. A type this build cannot -/// place is restated, and the server refuses the change if it cannot +/// type's own, so a column that set either has it restated. A type that +/// keeps every value inline refuses both, so the settings go with the +/// old type. A type this build cannot place, such as a domain or an +/// enum, is restated, and the server refuses the change if it cannot /// take them. -fn restated_storage(name: &str, attributes: &ColumnAttributes, kind: &ColumnKind) -> Vec { - if fixed_width(kind) { +fn restated_storage(name: &str, attributes: &ColumnAttributes, new_type: &ResolvedType) -> Vec { + if plain_storage(new_type) { return Vec::new(); } let storage = attributes.storage.map(|storage| { @@ -429,23 +425,108 @@ fn restated_storage(name: &str, attributes: &ColumnAttributes, kind: &ColumnKind storage.into_iter().chain(compression).collect() } -/// Whether PostgreSQL stores the type at a fixed width, which takes -/// only PLAIN storage and no compression. -fn fixed_width(kind: &ColumnKind) -> bool { - matches!( - kind, - ColumnKind::Bool(_) - | ColumnKind::Integer(_) - | ColumnKind::Float(_) - | ColumnKind::Money { .. } - | ColumnKind::Date - | ColumnKind::Time { .. } - | ColumnKind::Timestamp { .. } - | ColumnKind::Interval - | ColumnKind::Uuid - | ColumnKind::Enum { .. } - | ColumnKind::Network(NetworkFamily::MacAddr | NetworkFamily::MacAddr8) - ) +/// The built-in types whose `pg_type.typstorage` is PLAIN in +/// PostgreSQL 17, by `typname`. +const PLAIN_STORAGE_TYPES: &[&str] = &[ + "aclitem", + "bool", + "box", + "char", + "cid", + "circle", + "date", + "float4", + "float8", + "gtsvector", + "int2", + "int2vector", + "int4", + "int8", + "interval", + "line", + "lseg", + "macaddr", + "macaddr8", + "money", + "name", + "oid", + "oidvector", + "pg_lsn", + "point", + "regclass", + "regcollation", + "regconfig", + "regdictionary", + "regnamespace", + "regoper", + "regoperator", + "regproc", + "regprocedure", + "regrole", + "regtype", + "tid", + "time", + "timestamp", + "timestamptz", + "timetz", + "tsquery", + "uuid", + "xid", + "xid8", +]; + +/// Whether PostgreSQL keeps every value of the type inline, where SET +/// STORAGE takes only PLAIN and SET COMPRESSION is refused. The kind +/// covers the standard spellings, such as `double precision`, that are +/// not a `typname`. +fn plain_storage(new_type: &ResolvedType) -> bool { + catalog_type_name(&new_type.sql).is_some_and(|name| { + PLAIN_STORAGE_TYPES.contains(&name.as_str()) + || matches!( + new_type.column_type.kind, + ColumnKind::Bool(_) + | ColumnKind::Integer(_) + | ColumnKind::Float(_) + | ColumnKind::Money { .. } + | ColumnKind::Date + | ColumnKind::Time { .. } + | ColumnKind::Timestamp { .. } + | ColumnKind::Interval + | ColumnKind::Uuid + | ColumnKind::Network(NetworkFamily::MacAddr | NetworkFamily::MacAddr8) + ) + }) +} + +/// The `typname` of the built-in type a declaration names: unquoted +/// words fold to lower case and `char` alone means `bpchar`, while a +/// quoted `"char"` is the one-byte type. `None` for an array or a type +/// in a schema other than `pg_catalog`. +fn catalog_type_name(declaration: &str) -> Option { + let text = declaration.trim(); + let lowered = text.to_ascii_lowercase(); + if text.contains('[') || lowered.split_whitespace().next_back() == Some("array") { + return None; + } + let text = if lowered.starts_with("pg_catalog.") { + &text["pg_catalog.".len()..] + } else { + text.strip_prefix("\"pg_catalog\".").unwrap_or(text) + }; + if let Some(quoted) = text.strip_prefix('"') { + let (name, rest) = quoted.split_once('"')?; + return (!rest.starts_with('.')).then(|| name.to_owned()); + } + let word: String = text + .chars() + .take_while(|c| !c.is_whitespace() && *c != '(') + .collect::() + .to_ascii_lowercase(); + match word.as_str() { + "char" | "character" => Some("bpchar".to_owned()), + _ if word.contains(['.', '"']) => None, + _ => Some(word), + } } /// Whether a type takes a COLLATE clause: text, and arrays of it. @@ -709,6 +790,50 @@ mod tests { ); } + #[test] + fn only_a_type_that_keeps_its_values_inline_drops_the_settings() { + let plain = |declaration: &str| { + let resolved = DraftType::Typed(declaration.to_owned()) + .resolve(&dialect(), "c") + .expect("a type"); + plain_storage(&resolved) + }; + + for declaration in [ + "point", + "pg_catalog.lseg", + "line", + "box", + "circle", + "pg_lsn", + "\"char\"", + "name", + "tsquery", + "oid", + "double precision", + "timestamp(3) with time zone", + "\"pg_catalog\".\"int4\"", + ] { + assert!(plain(declaration), "{declaration}"); + } + for declaration in [ + "char(2)", + "character", + "text", + "polygon", + "numeric(10, 2)", + "integer[]", + "geometry", + "public.point", + "\"Point\"", + "mood", + "int4range", + "tsvector", + ] { + assert!(!plain(declaration), "{declaration}"); + } + } + #[test] fn rename_then_alter_uses_new_name() { let plan = alter(&users(), |draft| { diff --git a/linux/crates/drivers/postgres/tests/integration/ddl.rs b/linux/crates/drivers/postgres/tests/integration/ddl.rs index 163ece8b28..db06c3dcc2 100644 --- a/linux/crates/drivers/postgres/tests/integration/ddl.rs +++ b/linux/crates/drivers/postgres/tests/integration/ddl.rs @@ -398,8 +398,9 @@ async fn type_change_keeps_c_collation() { } /// ALTER COLUMN ... TYPE resets SET STORAGE and SET COMPRESSION to the -/// new type's own, so the change restates what the column had. An -/// integer takes neither, so its column goes back to the type's. +/// new type's own, so the change restates what the column had. A type +/// that keeps its values inline, such as integer, point or "char", +/// refuses both, so its column goes back to the type's. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn type_change_keeps_storage_and_compression() { @@ -408,10 +409,12 @@ async fn type_change_keeps_storage_and_compression() { setup( connection.as_ref(), &[ - "CREATE TABLE kept (id int PRIMARY KEY, body varchar(10), note text, code text)", + "CREATE TABLE kept (id int PRIMARY KEY, body varchar(10), note text, code text, spot text, flag text)", "ALTER TABLE kept ALTER COLUMN body SET STORAGE EXTERNAL, ALTER COLUMN body SET COMPRESSION pglz, \ - ALTER COLUMN note SET STORAGE MAIN, ALTER COLUMN code SET STORAGE EXTERNAL", - "INSERT INTO kept VALUES (1, 'a', 'b', '7')", + ALTER COLUMN note SET STORAGE MAIN, ALTER COLUMN code SET STORAGE EXTERNAL, \ + ALTER COLUMN spot SET STORAGE EXTERNAL, ALTER COLUMN flag SET STORAGE EXTERNAL, \ + ALTER COLUMN flag SET COMPRESSION pglz", + "INSERT INTO kept VALUES (1, 'a', 'b', '7', '(1,2)', 'x')", ], ) .await; @@ -431,6 +434,8 @@ async fn type_change_keeps_storage_and_compression() { "body" => "varchar(40)", "note" => "varchar(100)", "code" => "integer", + "spot" => "point", + "flag" => "\"char\"", _ => return, }; column.column_type = DraftType::Typed(retyped.to_owned()); @@ -444,6 +449,8 @@ async fn type_change_keeps_storage_and_compression() { ); assert_eq!(column(&after, "note"), (Some(ColumnStorage::Main), None)); assert_eq!(column(&after, "code"), (None, None)); + assert_eq!(column(&after, "spot"), (None, None)); + assert_eq!(column(&after, "flag"), (None, None)); assert_eq!( value( connection.as_ref(), @@ -451,11 +458,15 @@ async fn type_change_keeps_storage_and_compression() { '-'), ',' ORDER BY attnum) FROM pg_attribute WHERE attrelid = 'kept'::regclass AND attnum > 0" ) .await, - "id=p-,body=ep,note=m-,code=p-" + "id=p-,body=ep,note=m-,code=p-,spot=p-,flag=p-" ); assert_eq!( - value(connection.as_ref(), "SELECT body || note || code FROM kept").await, - "ab7" + value( + connection.as_ref(), + "SELECT body || note || code || spot::text || flag::text FROM kept" + ) + .await, + "ab7(1,2)x" ); connection.close().await.expect("a close"); } From abd5bded9107e6bb0954c5424b1129618c31b5aa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 10:55:02 +0700 Subject: [PATCH 015/301] refactor(linux-grid): let the ticket drop every stale bulk delete answer --- linux/crates/app/src/ui/browse_tab/mod.rs | 9 ++---- linux/crates/app/src/ui/browse_tab/tests.rs | 31 +++++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 7778751a1f..ac9ea75b92 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -1679,7 +1679,8 @@ impl BrowseModel { } BrowseTabInput::BulkDeleteAnswered { ticket, confirmed } => { // An answer to a confirmation the tab already cancelled - // or replaced. + // or replaced, or the close response a dialog sends after + // its answer when it closes. if self .confirming_delete .as_ref() @@ -1840,13 +1841,7 @@ impl BrowseModel { let ticket = self.next_confirmation; self.next_confirmation += 1; let tab = self.this.clone(); - // Closing the dialog answers it again with its close response, - // so only the first answer counts. - let answered = Cell::new(false); dialog.connect_response(None, move |dlg, response| { - if answered.replace(true) { - return; - } if let Some(tab) = tab.upgrade() { tab.send(BrowseTabInput::BulkDeleteAnswered { ticket, diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 58a0f29bc1..dc0c1fa8e0 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1400,6 +1400,8 @@ fn change_schema(harness: &Harness, done: impl Fn(&[ColumnInfo]) -> bool) { wait_until(WAIT, || done(&harness.tab().columns())).expect("the new columns"); } +/// Answering closes the dialog, which answers again with its close +/// response, and the rows stay marked. #[gtk4::test] fn a_confirmed_bulk_delete_marks_every_selected_row() { let mut script = serving_table(); @@ -1518,3 +1520,32 @@ fn a_failed_row_count_logs_its_category_and_not_the_server_message() { assert!(!text.contains("hunter2"), "{text}"); assert!(text.contains("category=Some(Server)"), "{text}"); } + +#[gtk4::test] +fn a_second_delete_replaces_the_waiting_confirmation() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&DOZEN))); + let harness = Harness::open(script, []); + let _shown = Shown::new(harness.tab()); + ask_to_delete_every_row(&harness); + let (first, _) = confirming(harness.tab()).expect("the first confirmation"); + + harness.tab().send(BrowseTabInput::DeleteSelectedRow); + drain_main_context(); + let (second, dialog) = confirming(harness.tab()).expect("the closed confirmation cancelled the new one"); + assert_ne!(first, second); + harness.tab().send(BrowseTabInput::BulkDeleteAnswered { + ticket: first, + confirmed: true, + }); + drain_main_context(); + + assert_eq!( + harness.tracker.pending_count(), + 0, + "an answer to the replaced confirmation marked the rows" + ); + assert_eq!(confirming(harness.tab()).map(|(ticket, _)| ticket), Some(second)); + answer_delete(&dialog); + assert_eq!(harness.tracker.pending_count(), DOZEN.len()); +} From bd27e348d3c834c0ffb0da28f1188e272db3ac61 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:06:34 +0700 Subject: [PATCH 016/301] fix(linux-grid): duplicate a draft as it is, in one undo step A copied draft keeps the cells it leaves to the server instead of writing NULL there. One Ctrl+Z removes every copy, past the 50-step history limit too. --- .../crates/app/src/services/change_tracker.rs | 126 ++++++++++++------ .../app/src/services/tab_tracker_handle.rs | 40 +++--- linux/crates/app/src/services/tracker_edit.rs | 7 +- linux/crates/app/src/services/undo_op.rs | 40 ++++-- linux/crates/app/src/ui/browse_tab/mod.rs | 44 +++--- linux/crates/app/src/ui/browse_tab/tests.rs | 38 ++++++ .../crates/app/src/ui/grid/column_factory.rs | 4 +- .../app/src/ui/grid/tab_grid_context.rs | 17 ++- .../app/src/workspace/workspace_handle.rs | 12 +- .../crates/app/tests/tracker_save_postgres.rs | 12 +- 10 files changed, 231 insertions(+), 109 deletions(-) diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index ba50dd7c1e..8e4b5e926c 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -168,7 +168,7 @@ impl TabChangeTracker { value, } => self.track_cell_edit(row_key, col, original, value), TrackerEdit::DraftCell { draft_id, col, input } => self.track_draft_cell_edit(draft_id, col, input), - TrackerEdit::InsertDraft { cells } => self.track_insert(cells), + TrackerEdit::InsertDrafts { rows } => self.track_insert(rows), } } @@ -185,7 +185,7 @@ impl TabChangeTracker { persisted.iter().any(|(row_key, _)| self.row_in_flight(row_key)) || drafts.iter().any(|draft_id| self.draft_in_flight(*draft_id)) } - TrackerEdit::InsertDraft { .. } => false, + TrackerEdit::InsertDrafts { .. } => false, } } @@ -249,15 +249,24 @@ impl TabChangeTracker { self.put_cell_edit(&row_key, col, after) } - fn track_insert(&mut self, cells: Vec) -> TrackerDelta { - let draft = DraftRow { - draft_id: self.next_draft_id, - cells, - touched: self.touch(), - }; - self.next_draft_id += 1; - self.push_undo(UndoOp::Insert { draft: draft.clone() }); - self.put_draft(draft) + fn track_insert(&mut self, rows: Vec>) -> TrackerDelta { + if rows.is_empty() { + return self.delta(Vec::new()); + } + let drafts: Vec = rows + .into_iter() + .map(|cells| { + let draft_id = self.next_draft_id; + self.next_draft_id += 1; + DraftRow { + draft_id, + cells, + touched: self.touch(), + } + }) + .collect(); + self.push_undo(UndoOp::Insert { drafts: drafts.clone() }); + self.put_drafts(drafts) } /// Marks the saved rows and drops the drafts. The marks are one undo @@ -307,8 +316,8 @@ impl TabChangeTracker { .retain(|(k, _), _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); self.deletes .retain(|k, _| !matches!(k, RowKey::Draft(id) if *id == draft_id)); - self.undo.retain(|op| !op.is_about_draft(draft_id)); - self.redo.retain(|op| !op.is_about_draft(draft_id)); + self.undo.retain_mut(|op| op.forget_draft(draft_id)); + self.redo.retain_mut(|op| op.forget_draft(draft_id)); true } @@ -369,20 +378,21 @@ impl TabChangeTracker { self.delta(steps.iter().map(|step| step.row_key.clone()).collect()) } - fn put_draft(&mut self, draft: DraftRow) -> TrackerDelta { - let draft_id = draft.draft_id; - self.drafts.push(draft); + fn put_drafts(&mut self, drafts: Vec) -> TrackerDelta { + let draft_ids: Vec = drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.extend(drafts); TrackerDelta { - inserted_drafts: vec![draft_id], - ..self.delta(vec![RowKey::Draft(draft_id)]) + inserted_drafts: draft_ids.clone(), + ..self.delta(draft_ids.into_iter().map(RowKey::Draft).collect()) } } - fn remove_draft(&mut self, draft_id: u64) -> TrackerDelta { - self.drafts.retain(|d| d.draft_id != draft_id); + fn remove_drafts(&mut self, drafts: &[DraftRow]) -> TrackerDelta { + let draft_ids: Vec = drafts.iter().map(|draft| draft.draft_id).collect(); + self.drafts.retain(|d| !draft_ids.contains(&d.draft_id)); TrackerDelta { - removed_drafts: vec![draft_id], - ..self.delta(vec![RowKey::Draft(draft_id)]) + removed_drafts: draft_ids.clone(), + ..self.delta(draft_ids.into_iter().map(RowKey::Draft).collect()) } } @@ -416,7 +426,7 @@ impl TabChangeTracker { before_touch, .. } => self.put_draft_cell(*draft_id, *col, before.clone(), *before_touch), - UndoOp::Insert { draft } => self.remove_draft(draft.draft_id), + UndoOp::Insert { drafts } => self.remove_drafts(drafts), UndoOp::Delete { steps } => self.put_delete_marks(steps, true), }; self.redo.push_back(op); @@ -439,7 +449,7 @@ impl TabChangeTracker { after_touch, .. } => self.put_draft_cell(*draft_id, *col, after.clone(), *after_touch), - UndoOp::Insert { draft } => self.put_draft(draft.clone()), + UndoOp::Insert { drafts } => self.put_drafts(drafts.clone()), UndoOp::Delete { steps } => self.put_delete_marks(steps, false), }; self.undo.push_back(op); @@ -570,6 +580,13 @@ impl TabChangeTracker { }) } + pub fn draft_inputs(&self, draft_id: u64) -> Option> { + self.drafts + .iter() + .find(|draft| draft.draft_id == draft_id) + .map(|draft| draft.cells.clone()) + } + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { self.drafts .iter() @@ -1051,7 +1068,7 @@ mod tests { fn insert(t: &mut TabChangeTracker, values: Vec) -> u64 { match t - .track_insert(values.into_iter().map(CellInput::Value).collect()) + .track_insert(vec![values.into_iter().map(CellInput::Value).collect()]) .inserted_drafts .as_slice() { @@ -1152,7 +1169,7 @@ mod tests { let mut t = TabChangeTracker::default(); let key = rk(&[Value::Int(1)]); t.track_cell_edit(key.clone(), 1, Value::Text("a".into()), Value::Text("b".into())); - t.track_insert(vec![Value::Null].into_iter().map(CellInput::Value).collect()); + t.track_insert(vec![vec![CellInput::Value(Value::Null)]]); delete(&mut t, rk(&[Value::Int(2)]), vec![Value::Int(2)]); t.discard(); assert_eq!(t.pending_count(), 0); @@ -1424,12 +1441,10 @@ mod tests { _ => t.track_cell_edit(rk(&[Value::Int(id)]), 1, text("a"), text("b")), }; } - let _ = t.track_insert( - vec![Value::Null, text("draft")] - .into_iter() - .map(CellInput::Value) - .collect(), - ); + let _ = t.track_insert(vec![vec![ + CellInput::Value(Value::Null), + CellInput::Value(text("draft")), + ]]); }; let mut first = TabChangeTracker::default(); let mut second = TabChangeTracker::default(); @@ -1468,12 +1483,10 @@ mod tests { let mut t = TabChangeTracker::default(); let columns = vec![pk_col("id"), data_col("name")]; let doomed = rk(&[Value::Int(9)]); - t.track_insert( - vec![Value::Null, Value::Text("draft".into())] - .into_iter() - .map(CellInput::Value) - .collect(), - ); + t.track_insert(vec![vec![ + CellInput::Value(Value::Null), + CellInput::Value(Value::Text("draft".into())), + ]]); delete( &mut t, doomed.clone(), @@ -1525,7 +1538,7 @@ mod tests { let columns = vec![pk_col("id"), data_col("name"), stamped]; let mut cells: Vec = columns.iter().map(new_draft_cell).collect(); cells[1] = CellInput::Value(Value::Text("draft".into())); - let draft_id = match t.track_insert(cells).inserted_drafts.as_slice() { + let draft_id = match t.track_insert(vec![cells]).inserted_drafts.as_slice() { [draft_id] => *draft_id, other => panic!("an insert adds one draft row: {other:?}"), }; @@ -1775,6 +1788,41 @@ mod tests { ); } + #[test] + fn a_duplicate_of_more_rows_than_the_history_holds_is_one_undo_step() { + let mut t = TabChangeTracker::default(); + let rows: Vec> = (0..UNDO_LIMIT + 10) + .map(|id| vec![CellInput::Value(Value::Int(i64::try_from(id).unwrap()))]) + .collect(); + + let inserted = t.apply(TrackerEdit::InsertDrafts { rows }); + let undone = t.undo().expect("the duplicate to undo"); + + assert_eq!(inserted.inserted_drafts.len(), UNDO_LIMIT + 10); + assert_eq!(undone.removed_drafts, inserted.inserted_drafts); + assert!(t.drafts().is_empty(), "the undo left some copies behind"); + assert!(!t.can_undo()); + let redone = t.redo().expect("the duplicate to redo"); + assert_eq!(redone.inserted_drafts, inserted.inserted_drafts); + } + + #[test] + fn deleting_one_duplicated_draft_leaves_the_others_to_undo() { + let mut t = TabChangeTracker::default(); + let inserted = t.apply(TrackerEdit::InsertDrafts { + rows: (1..=3).map(|id| vec![CellInput::Value(Value::Int(id))]).collect(), + }); + let [first, middle, last] = inserted.inserted_drafts[..] else { + panic!("three drafts: {:?}", inserted.inserted_drafts); + }; + + let _ = drop_drafts(&mut t, vec![middle]); + let undone = t.undo().expect("the rest of the duplicate to undo"); + + assert_eq!(undone.removed_drafts, vec![first, last]); + assert!(t.drafts().is_empty()); + } + #[test] fn redo_of_a_persisted_cell_edit_leaves_the_fetched_row_alone() { let mut t = TabChangeTracker::default(); diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 4bc325425c..73912207f0 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -59,6 +59,10 @@ impl TabTrackerHandle { self.0.borrow().draft_values(draft_id) } + pub fn draft_inputs(&self, draft_id: u64) -> Option> { + self.0.borrow().draft_inputs(draft_id) + } + pub fn draft_input(&self, draft_id: u64, col: usize) -> Option { self.0.borrow().draft_input(draft_id, col) } @@ -195,11 +199,13 @@ mod tests { #[test] fn committed_clears_only_snapshot_edits() { let tracker = edited(); - let _ = tracker.apply(TrackerEdit::InsertDraft { - cells: vec![Value::Int(9), Value::Int(9)] - .into_iter() - .map(CellInput::Value) - .collect(), + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let batch = tracker.begin_save().expect("a save").expect("changes to save"); assert_eq!(batch.changes.changes.changes.len(), 2); @@ -221,11 +227,13 @@ mod tests { #[test] fn an_insert_or_delete_in_flight_is_not_changed_until_it_settles() { let tracker = edited(); - let inserted = tracker.apply(TrackerEdit::InsertDraft { - cells: vec![Value::Int(9), Value::Int(9)] - .into_iter() - .map(CellInput::Value) - .collect(), + let inserted = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(9), Value::Int(9)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let draft_id = match inserted.inserted_drafts.as_slice() { [draft_id] => *draft_id, @@ -268,11 +276,13 @@ mod tests { original: Value::Text("a".into()), value: Value::Text("b".into()), }); - let inserted = tracker.apply(TrackerEdit::InsertDraft { - cells: vec![Value::Null, Value::Text("c".into())] - .into_iter() - .map(CellInput::Value) - .collect(), + let inserted = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Null, Value::Text("c".into())] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); assert_eq!(edited.changed, vec![row(1)]); diff --git a/linux/crates/app/src/services/tracker_edit.rs b/linux/crates/app/src/services/tracker_edit.rs index c7960773fd..14bf499224 100644 --- a/linux/crates/app/src/services/tracker_edit.rs +++ b/linux/crates/app/src/services/tracker_edit.rs @@ -19,9 +19,8 @@ pub enum TrackerEdit { col: usize, input: CellInput, }, - InsertDraft { - cells: Vec, - }, + /// One undo step however many rows it holds. + InsertDrafts { rows: Vec> }, /// One Delete: the saved rows are marked, each with the row as it /// was read, and the drafts are dropped. DeleteRows { @@ -41,7 +40,7 @@ impl TrackerEdit { .map(|(row_key, _)| row_key.clone()) .chain(drafts.iter().map(|draft_id| RowKey::Draft(*draft_id))) .collect(), - Self::InsertDraft { .. } => Vec::new(), + Self::InsertDrafts { .. } => Vec::new(), } } } diff --git a/linux/crates/app/src/services/undo_op.rs b/linux/crates/app/src/services/undo_op.rs index e35575fc4c..757ca26051 100644 --- a/linux/crates/app/src/services/undo_op.rs +++ b/linux/crates/app/src/services/undo_op.rs @@ -26,17 +26,15 @@ pub enum UndoOp { before_touch: Touch, after_touch: Touch, }, + /// One undo removes every draft the insert made. Insert { - draft: DraftRow, + drafts: Vec, }, - /// One Delete over every saved row it marked, so one undo brings - /// them all back. Delete { steps: Vec, }, } -/// One row's delete mark on both sides of a Delete. #[derive(Debug, Clone, PartialEq)] pub struct DeleteStep { pub row_key: RowKey, @@ -89,11 +87,16 @@ impl UndoOp { after_touch: *after_touch, }) } - UndoOp::Insert { draft } => Some(UndoOp::Insert { - draft: DraftRow { - cells: layout.draft(&draft.cells).ok()?, - ..draft.clone() - }, + UndoOp::Insert { drafts } => Some(UndoOp::Insert { + drafts: drafts + .iter() + .map(|draft| { + Some(DraftRow { + cells: layout.draft(&draft.cells).ok()?, + ..draft.clone() + }) + }) + .collect::>()?, }), UndoOp::Delete { steps } => Some(UndoOp::Delete { steps: steps @@ -111,12 +114,21 @@ impl UndoOp { } } - pub fn is_about_draft(&self, draft_id: u64) -> bool { + /// Drops what the step says about a draft that is gone, and returns + /// whether anything is left of the step. + pub fn forget_draft(&mut self, draft_id: u64) -> bool { + let gone = RowKey::Draft(draft_id); match self { - UndoOp::Insert { draft } => draft.draft_id == draft_id, - UndoOp::DraftCell { draft_id: id, .. } => *id == draft_id, - UndoOp::CellEdit { row_key, .. } => *row_key == RowKey::Draft(draft_id), - UndoOp::Delete { steps } => steps.iter().any(|step| step.row_key == RowKey::Draft(draft_id)), + UndoOp::Insert { drafts } => { + drafts.retain(|draft| draft.draft_id != draft_id); + !drafts.is_empty() + } + UndoOp::DraftCell { draft_id: id, .. } => *id != draft_id, + UndoOp::CellEdit { row_key, .. } => *row_key != gone, + UndoOp::Delete { steps } => { + steps.retain(|step| step.row_key != gone); + !steps.is_empty() + } } } } diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 998d2089be..a8c0ce11ba 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -1572,26 +1572,28 @@ impl BrowseModel { return; } let ctx = self.grid_context(); - for row in &rows { - // With its pending edits, since the copy is of the row - // the user is looking at. - let source_cells = ctx.effective_cells(row); - // The duplicate is a new row, so values the database - // owns (key, identity, generated) are left to it. - let cells: Vec = self - .current_columns - .iter() - .enumerate() - .map(|(i, col)| { - if col.primary_key || col.is_auto_increment || col.is_generated { - new_draft_cell(col) - } else { - CellInput::Value(source_cells.get(i).cloned().unwrap_or(Value::Null)) - } - }) - .collect(); - self.apply_edit(TrackerEdit::InsertDraft { cells }, fx); - } + let copies = rows + .iter() + .map(|row| { + // With its pending edits, since the copy is of the + // row the user is looking at. + let source = ctx.effective_inputs(row); + // The duplicate is a new row, so values the database + // owns (key, identity, generated) are left to it. + self.current_columns + .iter() + .enumerate() + .map(|(i, col)| { + if col.primary_key || col.is_auto_increment || col.is_generated { + new_draft_cell(col) + } else { + source.get(i).cloned().unwrap_or(CellInput::Value(Value::Null)) + } + }) + .collect() + }) + .collect(); + self.apply_edit(TrackerEdit::InsertDrafts { rows: copies }, fx); self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } @@ -1600,7 +1602,7 @@ impl BrowseModel { return; } let cells = self.current_columns.iter().map(new_draft_cell).collect(); - self.apply_edit(TrackerEdit::InsertDraft { cells }, fx); + self.apply_edit(TrackerEdit::InsertDrafts { rows: vec![cells] }, fx); self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 7561355538..584aac7799 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -791,6 +791,44 @@ fn duplicate_row_copies_pending_edits_of_persisted_row() { ); } +#[gtk4::test] +fn duplicating_a_draft_leaves_its_defaulted_cells_to_the_server() { + let mut level = col_with_default("integer", "0"); + level.name = "level".to_owned(); + let (harness, _shown) = one_cell(level, Value::Int(3)); + harness.tab().send(BrowseTabInput::InsertRow); + let draft = harness.grid_row(0); + assert!(draft.draft_id().is_some(), "the new row goes on top"); + + harness.tab().send(BrowseTabInput::DuplicateRows(vec![draft])); + + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2, "{drafts:?}"); + assert_eq!( + drafts[1].cells[1], + CellInput::Default, + "the copy wrote NULL where the server fills in" + ); +} + +#[gtk4::test] +fn duplicating_a_selection_is_one_undo_step() { + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(rows(&["Ada", "Grace"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 2).expect("the first page"); + + harness.tab().send(BrowseTabInput::DuplicateRows(vec![ + harness.grid_row(0), + harness.grid_row(1), + ])); + assert_eq!(draft_rows(&harness.store()), 2); + harness.tab().send(BrowseTabInput::Undo); + + assert_eq!(draft_rows(&harness.store()), 0, "one undo left copies behind"); + assert_eq!(harness.tracker.pending_count(), 0); +} + #[gtk4::test] fn copy_row_as_insert_uses_row_below_draft() { let mut script = serving_table(); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 8d8d698d0b..b596e8b4ef 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -410,8 +410,8 @@ mod tests { } fn insert_draft(&self) { - let delta = self.tracker.apply(TrackerEdit::InsertDraft { - cells: vec![CellInput::Value(Value::Null), CellInput::Value(Value::Null)], + let delta = self.tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Value(Value::Null), CellInput::Value(Value::Null)]], }); let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index 799f8c6e04..abcd3efccf 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -57,6 +57,15 @@ impl TabGridContext { .collect() } + /// Like `effective_cells`, except a draft's cell left to the server + /// stays `Default` rather than reading as NULL. + pub(crate) fn effective_inputs(&self, row: &RowObject) -> Vec { + if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { + return tracker.draft_inputs(*draft_id).unwrap_or_default(); + } + self.effective_cells(row).into_iter().map(CellInput::Value).collect() + } + /// What an editor opens on: a draft's cell as the tracker holds it, /// a saved row's pending value, else the value as it was read. pub(crate) fn edit_input(&self, row: &RowObject, col: usize) -> CellInput { @@ -112,8 +121,8 @@ mod tests { } fn insert_draft(tracker: &TabTrackerHandle, values: Vec) -> u64 { - let delta = tracker.apply(TrackerEdit::InsertDraft { - cells: values.into_iter().map(CellInput::Value).collect(), + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![values.into_iter().map(CellInput::Value).collect()], }); let Some(draft_id) = delta.inserted_drafts.into_iter().next() else { panic!("an insert adds a draft row"); @@ -173,8 +182,8 @@ mod tests { #[test] fn a_draft_cell_left_to_the_server_opens_as_the_default() { let tracker = TabTrackerHandle::default(); - let delta = tracker.apply(TrackerEdit::InsertDraft { - cells: vec![CellInput::Default, CellInput::Value(Value::Null)], + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![CellInput::Default, CellInput::Value(Value::Null)]], }); let draft = RowObject::draft(delta.inserted_drafts[0]); diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index 8964a96662..b0ad655e8e 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -592,11 +592,13 @@ mod tests { let columns = vec![integer("id", true), integer("n", false)]; let edited = RowKey::from_pk_values(&[Value::Int(3)]).unwrap(); let tracker = TabTrackerHandle::default(); - let _ = tracker.apply(TrackerEdit::InsertDraft { - cells: vec![Value::Int(10), Value::Int(1)] - .into_iter() - .map(CellInput::Value) - .collect(), + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(10), Value::Int(1)] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let _ = tracker.apply(TrackerEdit::Cell { row_key: edited.clone(), diff --git a/linux/crates/app/tests/tracker_save_postgres.rs b/linux/crates/app/tests/tracker_save_postgres.rs index ad5bf62422..95a6bd270a 100644 --- a/linux/crates/app/tests/tracker_save_postgres.rs +++ b/linux/crates/app/tests/tracker_save_postgres.rs @@ -107,11 +107,13 @@ async fn replace_by_unique_key_saves() { )], drafts: Vec::new(), }); - let _ = tracker.apply(TrackerEdit::InsertDraft { - cells: vec![Value::Int(2), Value::Text("a".to_owned())] - .into_iter() - .map(CellInput::Value) - .collect(), + let _ = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![ + vec![Value::Int(2), Value::Text("a".to_owned())] + .into_iter() + .map(CellInput::Value) + .collect(), + ], }); let changes = tracker.change_set(&columns).expect("a change set"); From 9952a0f1d7d08cad839f12b9e55d210055096a3a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:06:49 +0700 Subject: [PATCH 017/301] fix(linux-grid): commit a calendar pick of the day already selected GtkCalendar emits day-selected only when the date changes, so a click or Space on the selected day, such as today on a NULL cell, picked nothing. --- linux/crates/app/src/ui/browse_tab/tests.rs | 120 ++++++++++++++++++ .../crates/app/src/ui/grid/calendar_editor.rs | 46 ++++++- 2 files changed, 160 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 584aac7799..f4319bd0cd 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1779,6 +1779,126 @@ fn picking_a_day_commits_it() { assert!(!cell.is_editing()); } +/// The middle of the first of the calendar's labels that `matches`, +/// once the popover has laid it out. +fn calendar_label(calendar: >k4::Calendar, matches: impl Fn(>k4::Widget) -> bool) -> (f64, f64) { + let mut found = None; + wait_until(WAIT, || { + found = crate::test_support::descendants(calendar) + .into_iter() + .find(|widget| widget.width() > 0 && matches(widget)); + found.is_some() + }) + .expect("the label is drawn"); + let label = found.expect("a label"); + let middle = gtk4::graphene::Point::new(label.width() as f32 / 2.0, label.height() as f32 / 2.0); + let point = label + .compute_point(calendar, &middle) + .expect("the label is inside the calendar"); + (f64::from(point.x()), f64::from(point.y())) +} + +fn selected_day(calendar: >k4::Calendar) -> (f64, f64) { + calendar_label(calendar, |label| { + label.has_css_class("day-number") && label.state_flags().contains(gtk4::StateFlags::SELECTED) + }) +} + +/// What the calendar hears when a primary click at `(x, y)` ends. +fn release_on(calendar: >k4::Calendar, (x, y): (f64, f64)) { + let controllers = calendar.observe_controllers(); + for gesture in + (0..controllers.n_items()).filter_map(|index| controllers.item(index).and_downcast::()) + { + gesture.emit_by_name::<()>("released", &[&1i32, &x, &y]); + } +} + +fn release_space(calendar: >k4::Calendar) { + use gtk4::glib::translate::IntoGlib; + let controllers = calendar.observe_controllers(); + for keys in (0..controllers.n_items()) + .filter_map(|index| controllers.item(index).and_downcast::()) + { + keys.emit_by_name::<()>( + "key-released", + &[ + >k4::gdk::Key::space.into_glib(), + &0u32, + >k4::gdk::ModifierType::empty(), + ], + ); + } +} + +/// Open the cell's calendar until its popover shows. Test processes +/// share one X display, where another test's window can close an +/// autohide popover before it maps. +fn open_calendar(cell: &GridCell) -> gtk4::Calendar { + for _ in 0..5 { + crate::ui::grid::begin_edit(cell); + if let Some(calendar) = calendar_of(cell) + && wait_until(Duration::from_secs(2), || calendar.is_mapped() && calendar.width() > 0).is_ok() + { + return calendar; + } + cell.cancel_edit(); + drain_main_context(); + } + panic!("the calendar never showed"); +} + +fn shown_day(calendar: >k4::Calendar) -> Value { + let (year, month, day) = calendar.date().ymd(); + a_date(year, u32::try_from(month).unwrap(), u32::try_from(day).unwrap()) +} + +#[gtk4::test] +fn clicking_today_on_a_null_date_commits_it() { + let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + let today = shown_day(&calendar); + + release_on( + &calendar, + calendar_label(&calendar, |label| label.has_css_class("day-name")), + ); + assert!(cell.is_editing(), "a click on a weekday name picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending_value(&harness, &Value::Null), today); + assert!(!cell.is_editing()); +} + +#[gtk4::test] +fn picking_the_same_day_in_another_month_commits_it() { + let fetched = a_date(2024, 3, 15); + let (harness, _shown) = one_cell(named_col("day", "date", true), fetched.clone()); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + + // As the next-month arrow does, this moves the date without a + // day-selected. + calendar.set_date(>k4::glib::DateTime::from_utc(2024, 4, 15, 0, 0, 0.0).expect("a day")); + assert!(cell.is_editing(), "a month change picked a day"); + release_on(&calendar, selected_day(&calendar)); + + assert_eq!(pending_value(&harness, &fetched), a_date(2024, 4, 15)); +} + +#[gtk4::test] +fn space_on_the_selected_day_commits_it() { + let (harness, _shown) = one_cell(named_col("day", "date", true), Value::Null); + let cell = grid_cell_of(harness.tab(), 0, 1); + let calendar = open_calendar(&cell); + let today = shown_day(&calendar); + + release_space(&calendar); + + assert_eq!(pending_value(&harness, &Value::Null), today); +} + #[gtk4::test] fn opening_calendar_on_existing_date_records_no_edit() { let (harness, _shown) = one_cell(named_col("day", "date", true), a_date(2024, 2, 29)); diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs index 126dd17f46..960349250b 100644 --- a/linux/crates/app/src/ui/grid/calendar_editor.rs +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -54,12 +54,44 @@ impl CalendarEditor { let weak_cell = cell.downgrade(); calendar.connect_day_selected(move |calendar| { - let Some(cell) = weak_cell.upgrade() else { + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, calendar); + } + }); + // day-selected fires only when the date changes, so a click or + // Space on the day already selected, today on a NULL or the same + // day after a month change, would pick nothing. + let click = gtk::GestureClick::new(); + let weak_cell = cell.downgrade(); + click.connect_released(move |gesture, _, x, y| { + let Some(calendar) = gesture.widget().and_downcast::() else { + return; + }; + let on_a_day = calendar + .pick(x, y, gtk::PickFlags::DEFAULT) + .is_some_and(|label| label.has_css_class("day-number")); + if !on_a_day { + return; + } + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, &calendar); + } + }); + calendar.add_controller(click); + let keys = gtk::EventControllerKey::new(); + let weak_cell = cell.downgrade(); + keys.connect_key_released(move |controller, keyval, _, _| { + if !matches!(keyval, gtk::gdk::Key::space | gtk::gdk::Key::KP_Space) { + return; + } + let Some(calendar) = controller.widget().and_downcast::() else { return; }; - let (year, month, day) = calendar.date().ymd(); - pick(&cell, &format!("{year:04}-{month:02}-{day:02}")); + if let Some(cell) = weak_cell.upgrade() { + pick(&cell, &calendar); + } }); + calendar.add_controller(keys); let weak_cell = cell.downgrade(); type_value.connect_clicked(move |_| { let Some(cell) = weak_cell.upgrade() else { @@ -81,11 +113,13 @@ impl CalendarEditor { } /// The popover closes before the tab hears of the day, as every editor's -/// does. -fn pick(cell: &GridCell, day: &str) { +/// does. A second pick of the same click finds the edit taken. +fn pick(cell: &GridCell, calendar: >k::Calendar) { let Some(snapshot) = cell.take_edit() else { return; }; + let (year, month, day) = calendar.date().ymd(); + let day = format!("{year:04}-{month:02}-{day:02}"); if let Some(popover) = cell.take_popover() { popover.popdown(); } @@ -95,7 +129,7 @@ fn pick(cell: &GridCell, day: &str) { let Some(column) = drawing.catalog.as_ref() else { return; }; - let result = commit_edit(column, &snapshot, day); + let result = commit_edit(column, &snapshot, &day); send_commit(cell, snapshot, result); } From 5211d461b8539cd7888bc5e1d97bcbf9f5d2007e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:07:07 +0700 Subject: [PATCH 018/301] fix(linux-grid): open no editor on a boolean cell begin_edit flipped the check box, so the auto-edit after Insert Row would write TRUE into a new row once its cells take focus. --- linux/crates/app/src/ui/grid/cell_edit.rs | 13 +++++------- .../crates/app/src/ui/grid/column_factory.rs | 21 +++++++++++++++++++ linux/crates/app/src/ui/grid/toggle_cell.rs | 7 ------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/linux/crates/app/src/ui/grid/cell_edit.rs b/linux/crates/app/src/ui/grid/cell_edit.rs index 87a353f370..53d50b5088 100644 --- a/linux/crates/app/src/ui/grid/cell_edit.rs +++ b/linux/crates/app/src/ui/grid/cell_edit.rs @@ -7,13 +7,15 @@ use super::cell_editor_kind::CellEditorKind; use super::edit_commit::empty_placeholder; use super::inline_editor::{InlineEditor, InlineOptions, finish_inline}; use super::multiline_editor::MultilineEditor; -use super::toggle_cell::ToggleCell; use super::{EditSnapshot, GridCell, GridMsg}; use crate::ui::error_text::edit_parse_message; /// Open the editor the cell's column and value call for. It starts from /// the whole value with any pending edit, never from the text the cell /// draws, which is cut short or stands in for the value. +/// +/// A boolean cell opens nothing: its check button is the editor, so +/// opening it never writes a value. pub(crate) fn begin_edit(cell: &GridCell) { let (Some(row), Some(drawing)) = (cell.row(), cell.drawing()) else { return; @@ -21,14 +23,9 @@ pub(crate) fn begin_edit(cell: &GridCell) { let Some(column) = drawing.catalog.as_ref() else { return; }; - let editability = cell.lock().map_or(Ok(()), Err); - let kind = CellEditorKind::for_cell(column, editability); + let kind = CellEditorKind::for_cell(column, cell.lock().map_or(Ok(()), Err)); match kind { - CellEditorKind::ReadOnly(_) => return, - CellEditorKind::Toggle(_) => { - ToggleCell::toggle(cell); - return; - } + CellEditorKind::ReadOnly(_) | CellEditorKind::Toggle(_) => return, CellEditorKind::Inline { .. } | CellEditorKind::Multiline { .. } | CellEditorKind::Calendar => {} } let original = drawing.tab_ctx.edit_input(&row, cell.column()); diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index b596e8b4ef..495173594c 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -584,6 +584,27 @@ mod tests { ); } + fn flag_rows() -> Grid { + Grid::open( + vec![named("id", "integer", true), named("flag", "boolean", false)], + vec![ + vec![Value::Int(1), Value::Bool(false)], + vec![Value::Int(2), Value::Bool(false)], + ], + ) + } + + #[gtk4::test] + fn opening_a_boolean_cell_writes_nothing() { + let grid = flag_rows(); + let cell = grid.cell_of(1, 1); + + begin_edit(&cell); + + assert!(grid.take_messages().is_empty(), "opening the cell flipped it"); + assert!(!cell.check_button().expect("a check button").is_active()); + } + #[gtk4::test] fn a_saved_rows_key_is_locked_and_a_drafts_is_not() { let grid = two_text_rows(); diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs index cc895344bc..03257605bf 100644 --- a/linux/crates/app/src/ui/grid/toggle_cell.rs +++ b/linux/crates/app/src/ui/grid/toggle_cell.rs @@ -43,13 +43,6 @@ impl ToggleCell { }); } - /// Flip the cell from the keyboard, as a click would. - pub(super) fn toggle(cell: &GridCell) { - if let Some(check) = cell.check_button() { - check.set_active(!check.is_active()); - } - } - pub(super) fn draw(cell: &GridCell, check: >k::CheckButton, state: &ToggleState) { // Suppressed, so drawing the value does not echo back as an edit. cell.suppress_toggle(true); From 514d742b828e113d4bdcb72d2e80306d5cb0401d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:07:11 +0700 Subject: [PATCH 019/301] fix(linux-grid): show text with a NUL character and refuse to edit it GTK takes C strings: the cell drew such a value cut at the NUL, or aborted in a debug build, and an editor would save the cut text. --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/grid/cell_edit.rs | 10 +++++++++ .../crates/app/src/ui/grid/column_factory.rs | 21 +++++++++++++++++++ linux/crates/app/src/ui/grid/display_text.rs | 16 +++++++++++++- 4 files changed, 47 insertions(+), 1 deletion(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 95407e90f7..5f34cf2f1e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Setting a new row's cell to NULL saved the column's default instead. - Setting a NOT NULL cell to NULL was accepted and failed at save. - The Menu key on a read-only grid cell opened a text selection menu instead of the cell's menu. +- Text with a NUL character showed cut short in the grid, and editing it saved the cut value. - An edit, Set Value or row action in the table grid could land on another row once a row above it came or went. - Editing, undoing or deleting a row in the table grid dropped it from a selection of several rows. - Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. diff --git a/linux/crates/app/src/ui/grid/cell_edit.rs b/linux/crates/app/src/ui/grid/cell_edit.rs index 53d50b5088..c556f96f11 100644 --- a/linux/crates/app/src/ui/grid/cell_edit.rs +++ b/linux/crates/app/src/ui/grid/cell_edit.rs @@ -34,6 +34,16 @@ pub(crate) fn begin_edit(cell: &GridCell) { CellInput::Default => EditText::Editable(String::new()), }; let editable = match &text { + // A GTK editor would hold the text only up to the NUL, and + // committing it would cut the value there. + EditText::Editable(editable) if editable.contains('\0') => { + if let Some(sink) = cell.sink() { + sink(GridMsg::ShowToast(crate::i18n::gettext( + "Values with a NUL character can't be edited here", + ))); + } + return; + } EditText::Editable(editable) => editable.clone(), EditText::ReadOnly(reason) => { if let Some(sink) = cell.sink() { diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index 495173594c..c4188b424d 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -584,6 +584,27 @@ mod tests { ); } + #[gtk4::test] + fn a_text_with_a_nul_draws_and_refuses_an_editor() { + let grid = Grid::open( + vec![named("id", "integer", true), named("name", "text", false)], + vec![vec![Value::Int(1), Value::Text("a\0b".into())]], + ); + let cell = grid.cell_of(1, 1); + assert_eq!(display_text(&cell), "a\u{2400}b"); + + begin_edit(&cell); + + assert!( + cell.inline_text().is_none(), + "an editor opened on a value it cannot hold" + ); + assert!( + matches!(grid.take_messages().as_slice(), [GridMsg::ShowToast(_)]), + "the refusal was not explained" + ); + } + fn flag_rows() -> Grid { Grid::open( vec![named("id", "integer", true), named("flag", "boolean", false)], diff --git a/linux/crates/app/src/ui/grid/display_text.rs b/linux/crates/app/src/ui/grid/display_text.rs index ea2306e7d4..7ec29efd5e 100644 --- a/linux/crates/app/src/ui/grid/display_text.rs +++ b/linux/crates/app/src/ui/grid/display_text.rs @@ -35,12 +35,21 @@ pub(super) fn value_to_display_text(value: &Value) -> String { // Everything else renders the way it exports, so what the grid // shows and what a copy produces are the same text. other => match tablepro_core::export::value_to_text(other) { - Some(text) => truncate_for_display(&text), + Some(text) => truncate_for_display(&show_nul(text)), None => readonly_null_sentinel(), }, } } +/// GTK takes C strings, which end at a NUL, so the cell shows U+2400 +/// in its place rather than a value cut short. +fn show_nul(text: String) -> String { + match text.contains('\0') { + true => text.replace('\0', "\u{2400}"), + false => text, + } +} + fn truncate_for_display(s: &str) -> String { if s.len() < DISPLAY_TEXT_BYTES_THRESHOLD { return s.to_string(); @@ -76,6 +85,11 @@ mod tests { assert_eq!(value_to_display_text(&Value::Bytes(vec![0u8; 16])), "<16 bytes>"); } + #[test] + fn a_nul_in_text_shows_as_its_symbol() { + assert_eq!(value_to_display_text(&Value::Text("a\0b".into())), "a\u{2400}b"); + } + fn date_value(year: i32, month: u32, day: u32) -> Value { Value::Date(tablepro_core::value::Temporal::Finite( chrono::NaiveDate::from_ymd_opt(year, month, day).expect("a date"), From 360c521748aeaf0dc8f953db425cb8c1ff4dcedc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:07:23 +0700 Subject: [PATCH 020/301] fix(linux-grid): say in the delete prompt that unsaved rows go now A new row is dropped at once with its undo history, so the prompt no longer tells it to wait for Save. --- linux/crates/app/src/ui/browse_tab/mod.rs | 3 +- .../app/src/ui/browse_tab/row_deletion.rs | 37 ++++++++++++++++++- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index a8c0ce11ba..637b0f2434 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -1790,8 +1790,7 @@ impl BrowseModel { u32::try_from(count).unwrap_or(u32::MAX), &[("n", &count.to_string())], ); - let body = - crate::i18n::gettext("These rows will be marked for deletion. They aren't removed until you click Save."); + let body = deletion.confirmation_body(); let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); dialog.add_response("delete", &crate::i18n::gettext("Delete")); diff --git a/linux/crates/app/src/ui/browse_tab/row_deletion.rs b/linux/crates/app/src/ui/browse_tab/row_deletion.rs index f0f696bd97..bf0f56331a 100644 --- a/linux/crates/app/src/ui/browse_tab/row_deletion.rs +++ b/linux/crates/app/src/ui/browse_tab/row_deletion.rs @@ -5,10 +5,9 @@ use tablepro_core::Value; use crate::services::change_tracker::RowKey; use crate::ui::grid::{GridRowId, RowObject}; -/// What one Delete does to the rows it was given. #[derive(Debug, Clone, Default, PartialEq)] pub struct RowDeletion { - /// Saved rows to mark, each with the row as it was read. + /// Each with the row as it was read. pub persisted: Vec<(RowKey, Arc<[Value]>)>, pub drafts: Vec, /// Rows a DELETE has no key to name by. @@ -23,6 +22,22 @@ impl RowDeletion { pub fn is_empty(&self) -> bool { self.len() == 0 } + + /// A draft is dropped at once with its undo history, so a set that + /// holds one cannot be described as waiting for Save. + pub fn confirmation_body(&self) -> String { + match (self.persisted.is_empty(), self.drafts.is_empty()) { + (_, true) => crate::i18n::gettext( + "These rows will be marked for deletion. They aren't removed until you click Save.", + ), + (true, false) => crate::i18n::gettext( + "These rows were never saved. They're removed now, and Undo won't bring them back.", + ), + (false, false) => crate::i18n::gettext( + "Saved rows will be marked for deletion and aren't removed until you click Save. Rows never saved are removed now, and Undo won't bring them back.", + ), + } + } } pub fn plan_row_deletion(rows: &[RowObject]) -> RowDeletion { @@ -66,4 +81,22 @@ mod tests { assert_eq!(deletion.unkeyed, 2); assert_eq!(deletion.len(), 3); } + + #[test] + fn the_confirmation_says_drafts_go_at_once() { + let saved = RowObject::persisted( + Some(RowKey::from_pk_values(&[Value::Int(1)]).expect("a key")), + vec![Value::Int(1)].into(), + ); + + let only_saved = plan_row_deletion(std::slice::from_ref(&saved)).confirmation_body(); + let only_drafts = plan_row_deletion(&[RowObject::draft(1)]).confirmation_body(); + let mixed = plan_row_deletion(&[saved, RowObject::draft(1)]).confirmation_body(); + + assert!(only_saved.contains("click Save"), "{only_saved}"); + assert!(!only_saved.contains("removed now"), "{only_saved}"); + assert!(only_drafts.contains("removed now"), "{only_drafts}"); + assert!(!only_drafts.contains("click Save"), "{only_drafts}"); + assert!(mixed.contains("click Save") && mixed.contains("removed now"), "{mixed}"); + } } From 4e071c5ca91936d5a24a8c4aa1c315ca1c2467cc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 11:07:27 +0700 Subject: [PATCH 021/301] refactor(linux-grid): drop narrating comments and log a stray preset The Delete button takes its icon from the template. A preset for a column the tab lacks is logged and dropped instead of reading as a missing value. --- linux/crates/app/src/ui/browse_tab/mod.rs | 24 +++++++++++-------- .../crates/app/src/ui/grid/calendar_editor.rs | 4 +--- linux/crates/app/src/ui/grid/cell_preset.rs | 1 - .../app/src/ui/grid/delete_availability.rs | 3 +-- linux/crates/app/src/ui/grid/toggle_cell.rs | 1 - linux/data/resources/ui/browse-tab.ui | 3 +-- 6 files changed, 17 insertions(+), 19 deletions(-) diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 637b0f2434..6ec7cc4fa7 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -108,7 +108,6 @@ impl BrowseTab { ))); imp.inner_stack.set_visible_child_name("loading"); imp.read_only_banner.set_revealed(init.read_only); - imp.delete_button.set_icon_name(crate::ui::icons::LIST_REMOVE); let page_size_labels: Vec = PAGE_SIZE_OPTIONS.iter().map(|n| format_thousands(*n)).collect(); let page_size_strs: Vec<&str> = page_size_labels.iter().map(String::as_str).collect(); @@ -667,10 +666,8 @@ pub(crate) enum BrowseTabInput { /// Queued after an insert, so the focus lands on the column view /// as it is once the insert is drawn. FocusInsertedDraft, - /// Delete over these rows, from the key, the button or the menu. DeleteRows(Vec), - /// An edit the grid parsed for the column, which the tab checks - /// against the cell's lock before the tracker takes it. + /// Checked against the cell's lock before the tracker takes it. GridEdit { row: RowObject, column: usize, @@ -826,19 +823,25 @@ impl BrowseModel { /// only the ones the cell can take; the keyboard's Set NULL reaches /// here for any cell, and a column that refuses the value says why, /// as committing an empty cell would. - fn resolve_cell_preset(&self, preset: CellPreset, row: &RowObject, col: usize) -> Result { + fn resolve_cell_preset( + &self, + preset: CellPreset, + row: &RowObject, + col: usize, + ) -> Option> { let Some(column) = self.current_columns.get(col) else { - return Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)); + tracing::error!(col, "a preset reached a column the tab does not have"); + return None; }; let presets = cell_presets(column, row.id()); - match preset { + Some(match preset { CellPreset::Empty => parse_edit_text("", column, edit_context(row.id())) .map_err(|error| crate::ui::error_text::edit_parse_message(&error)), CellPreset::Null if presets.null => Ok(CellInput::Value(Value::Null)), CellPreset::Null => Err(crate::ui::error_text::edit_parse_message(&EditParseError::Required)), CellPreset::Default if presets.default => Ok(CellInput::Default), CellPreset::Default => Err(crate::i18n::gettext("This cell can't take the column's default")), - } + }) } fn table_label(&self) -> String { @@ -1620,11 +1623,12 @@ impl BrowseModel { return; } let input = match self.resolve_cell_preset(preset, &row, column) { - Ok(input) => input, - Err(message) => { + Some(Ok(input)) => input, + Some(Err(message)) => { fx.output(BrowseTabOutput::ShowToast(message)); return; } + None => return, }; self.edit_cell(&row, column, input, fx); } diff --git a/linux/crates/app/src/ui/grid/calendar_editor.rs b/linux/crates/app/src/ui/grid/calendar_editor.rs index 960349250b..2d1edcaabe 100644 --- a/linux/crates/app/src/ui/grid/calendar_editor.rs +++ b/linux/crates/app/src/ui/grid/calendar_editor.rs @@ -9,12 +9,10 @@ use super::edit_commit::{commit_edit, send_commit}; use super::inline_editor::{InlineEditor, InlineOptions}; use super::{EditSnapshot, GridCell}; -/// The day a calendar opens on. #[derive(Debug, Clone)] enum CalendarStart { On(glib::DateTime), - /// A NULL the column may keep: the calendar shows today and commits - /// nothing until a day is picked. + /// A NULL the column may keep, which stays until a day is picked. Today, } diff --git a/linux/crates/app/src/ui/grid/cell_preset.rs b/linux/crates/app/src/ui/grid/cell_preset.rs index 2091544a53..61c6cab363 100644 --- a/linux/crates/app/src/ui/grid/cell_preset.rs +++ b/linux/crates/app/src/ui/grid/cell_preset.rs @@ -10,7 +10,6 @@ pub enum CellPreset { Default, } -/// Which presets a cell's menu offers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct CellPresets { pub empty: bool, diff --git a/linux/crates/app/src/ui/grid/delete_availability.rs b/linux/crates/app/src/ui/grid/delete_availability.rs index 89d760f7a5..d5c462712c 100644 --- a/linux/crates/app/src/ui/grid/delete_availability.rs +++ b/linux/crates/app/src/ui/grid/delete_availability.rs @@ -15,8 +15,7 @@ pub enum DeleteBlock { } impl DeleteBlock { - /// Why Delete is off, for its button's tooltip. `None` when nothing - /// needs saying: there is just nothing selected. + /// `None` with nothing selected, which needs no explaining. pub fn explanation(self) -> Option { match self { Self::NoRows => None, diff --git a/linux/crates/app/src/ui/grid/toggle_cell.rs b/linux/crates/app/src/ui/grid/toggle_cell.rs index 03257605bf..68482e7ac7 100644 --- a/linux/crates/app/src/ui/grid/toggle_cell.rs +++ b/linux/crates/app/src/ui/grid/toggle_cell.rs @@ -8,7 +8,6 @@ use super::display_text::value_to_display_text; use super::edit_commit::{commit_input, send_commit}; use super::{EditSnapshot, GridCell}; -/// A boolean cell: a check button that commits as it is toggled. pub struct ToggleCell; /// How a check button shows a cell's value. diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index f82dd3932d..731ca095c7 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -76,9 +76,8 @@ - + list-remove-symbolic tab.delete-rows Delete selected rows (Delete) + + + + + + none + 12 + 12 + 6 + 6 + + + + Add Column + list-add-symbolic + + + + @@ -110,6 +142,38 @@ vertical + + + none + 12 + 12 + 6 + 6 + + + + + + + none + 12 + 12 + 6 + 6 + + + + Add Index… + list-add-symbolic + + + + @@ -127,6 +191,38 @@ vertical + + + none + 12 + 12 + 6 + 6 + + + + + + + none + 12 + 12 + 6 + 6 + + + + Add Foreign Key… + list-add-symbolic + + + + diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 4095f0e114..ee4690b4a5 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -189,8 +189,13 @@ crates/app/src/ui/sidebar_row.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs crates/app/src/ui/structure_tab/columns.rs +crates/app/src/ui/structure_tab/context.rs +crates/app/src/ui/structure_tab/draft_column_object.rs +crates/app/src/ui/structure_tab/draft_column_object_imp.rs crates/app/src/ui/structure_tab/fks.rs +crates/app/src/ui/structure_tab/foreign_key_object.rs crates/app/src/ui/structure_tab/imp.rs +crates/app/src/ui/structure_tab/index_object.rs crates/app/src/ui/structure_tab/indexes.rs crates/app/src/ui/structure_tab/mod.rs crates/app/src/ui/structure_tab/structure_command.rs From 271aebbc90494930dcf0607b8339727be6360764 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 12:25:48 +0700 Subject: [PATCH 036/301] feat(linux): open one window per connection session --- linux/ARCHITECTURE.md | 16 +- linux/CHANGELOG.md | 7 + .../app/src/application/app_services.rs | 29 +- linux/crates/app/src/application/imp.rs | 2 +- linux/crates/app/src/application/mod.rs | 16 +- .../app/src/application/quit_sequence.rs | 5 +- linux/crates/app/src/application/tests.rs | 11 +- .../app/src/application/window_launch.rs | 2 + .../app/src/application/window_registry.rs | 58 +- linux/crates/app/src/runtime/deliver_input.rs | 50 +- linux/crates/app/src/runtime/input_queue.rs | 111 ++ linux/crates/app/src/runtime/mod.rs | 4 +- .../app/src/services/file_resolution.rs | 124 ++ linux/crates/app/src/services/from_session.rs | 60 - linux/crates/app/src/services/mod.rs | 2 +- .../app/src/services/session_context.rs | 41 +- linux/crates/app/src/ui/app/close_flow.rs | 282 ---- linux/crates/app/src/ui/app/connection.rs | 1469 ---------------- linux/crates/app/src/ui/app/mod.rs | 1493 ----------------- linux/crates/app/src/ui/editor/mod.rs | 16 +- linux/crates/app/src/ui/mod.rs | 9 +- .../ui/session_window/connect_failed_page.rs | 67 + .../{app => session_window}/connect_flow.rs | 18 +- .../src/ui/session_window/connecting_page.rs | 53 + .../app/src/ui/session_window/connections.rs | 657 ++++++++ linux/crates/app/src/ui/session_window/imp.rs | 78 + linux/crates/app/src/ui/session_window/mod.rs | 864 ++++++++++ .../app/src/ui/session_window/picker_page.rs | 58 + .../session_window/resolve_file_identity.rs | 99 ++ .../app/src/ui/session_window/session_page.rs | 57 + .../src/ui/session_window/session_teardown.rs | 175 ++ .../session_window/session_window_command.rs | 47 + .../ui/session_window/session_window_input.rs | 71 + .../crates/app/src/ui/session_window/tests.rs | 505 ++++++ .../src/ui/session_window/window_actions.rs | 184 ++ .../crates/app/src/ui/workspace/close_flow.rs | 200 +++ .../app/src/ui/workspace/health_banner.rs | 85 + linux/crates/app/src/ui/workspace/imp.rs | 91 + .../app/src/ui/{app => workspace}/import.rs | 50 +- linux/crates/app/src/ui/workspace/mod.rs | 821 +++++++++ .../app/src/ui/{app => workspace}/row_ops.rs | 8 +- .../app/src/ui/{app => workspace}/saves.rs | 26 +- linux/crates/app/src/ui/workspace/sidebar.rs | 358 ++++ .../sidebar_section_header.rs | 0 .../src/ui/{app => workspace}/status_pages.rs | 133 +- .../src/ui/{app => workspace}/structure.rs | 94 +- .../src/ui/{app => workspace}/window_tabs.rs | 6 +- .../app/src/ui/workspace/workspace_actions.rs | 128 ++ .../workspace_persistence.rs | 12 +- .../ui/{app => workspace}/workspace_tabs.rs | 303 ++-- .../crates/app/src/workspace/close_guards.rs | 27 +- linux/data/resources/resources.gresource.xml | 5 + .../data/resources/ui/connect-failed-page.ui | 59 + linux/data/resources/ui/connecting-page.ui | 35 + linux/data/resources/ui/picker-page.ui | 77 + linux/data/resources/ui/session-window.ui | 15 + linux/data/resources/ui/workspace.ui | 216 +++ linux/po/POTFILES.in | 50 +- 58 files changed, 5751 insertions(+), 3788 deletions(-) create mode 100644 linux/crates/app/src/runtime/input_queue.rs create mode 100644 linux/crates/app/src/services/file_resolution.rs delete mode 100644 linux/crates/app/src/services/from_session.rs delete mode 100644 linux/crates/app/src/ui/app/close_flow.rs delete mode 100644 linux/crates/app/src/ui/app/connection.rs delete mode 100644 linux/crates/app/src/ui/app/mod.rs create mode 100644 linux/crates/app/src/ui/session_window/connect_failed_page.rs rename linux/crates/app/src/ui/{app => session_window}/connect_flow.rs (96%) create mode 100644 linux/crates/app/src/ui/session_window/connecting_page.rs create mode 100644 linux/crates/app/src/ui/session_window/connections.rs create mode 100644 linux/crates/app/src/ui/session_window/imp.rs create mode 100644 linux/crates/app/src/ui/session_window/mod.rs create mode 100644 linux/crates/app/src/ui/session_window/picker_page.rs create mode 100644 linux/crates/app/src/ui/session_window/resolve_file_identity.rs create mode 100644 linux/crates/app/src/ui/session_window/session_page.rs create mode 100644 linux/crates/app/src/ui/session_window/session_teardown.rs create mode 100644 linux/crates/app/src/ui/session_window/session_window_command.rs create mode 100644 linux/crates/app/src/ui/session_window/session_window_input.rs create mode 100644 linux/crates/app/src/ui/session_window/tests.rs create mode 100644 linux/crates/app/src/ui/session_window/window_actions.rs create mode 100644 linux/crates/app/src/ui/workspace/close_flow.rs create mode 100644 linux/crates/app/src/ui/workspace/health_banner.rs create mode 100644 linux/crates/app/src/ui/workspace/imp.rs rename linux/crates/app/src/ui/{app => workspace}/import.rs (91%) create mode 100644 linux/crates/app/src/ui/workspace/mod.rs rename linux/crates/app/src/ui/{app => workspace}/row_ops.rs (88%) rename linux/crates/app/src/ui/{app => workspace}/saves.rs (90%) create mode 100644 linux/crates/app/src/ui/workspace/sidebar.rs rename linux/crates/app/src/ui/{app => workspace}/sidebar_section_header.rs (100%) rename linux/crates/app/src/ui/{app => workspace}/status_pages.rs (55%) rename linux/crates/app/src/ui/{app => workspace}/structure.rs (88%) rename linux/crates/app/src/ui/{app => workspace}/window_tabs.rs (96%) create mode 100644 linux/crates/app/src/ui/workspace/workspace_actions.rs rename linux/crates/app/src/ui/{app => workspace}/workspace_persistence.rs (98%) rename linux/crates/app/src/ui/{app => workspace}/workspace_tabs.rs (85%) create mode 100644 linux/data/resources/ui/connect-failed-page.ui create mode 100644 linux/data/resources/ui/connecting-page.ui create mode 100644 linux/data/resources/ui/picker-page.ui create mode 100644 linux/data/resources/ui/session-window.ui create mode 100644 linux/data/resources/ui/workspace.ui diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 7e77cec489..2c3a546e47 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -83,10 +83,12 @@ Bridging uses Relm4's built-in primitives instead of hand-rolled channels: - `sender.command(move |out, shutdown| shutdown.register(async move { ... out.send(...) }).drop_on_shutdown())`: a component-scoped tokio task that cancels when the component drops. The `out` sender feeds `CmdOutput` back into the component's update loop on the GTK thread. Used for every per-tab fetch (browse rows, schema introspection, save transaction). - `relm4::spawn(async move { ... })`: a fire-and-forget tokio task with no component lifetime tie. Used for storage writes (`touch_last_opened`, `query_history::record`, column-width persistence) and other side-effect work. -- `sender.input(AppMsg::...)` from inside an async block routes back into the component's `update` on the GTK thread. Combined with `sender.command`, this is how a "fetch-then-render" round trip lands its result on the right widget. +- A window and its workspace are gtk-rs widgets with an input queue (`runtime::input_queue`). Their actions, signal handlers and background work send it messages, which are handled one at a time on the GTK thread. The queue closes when the widget shuts down, so a reply that lands later is dropped rather than shown. `run()` builds the app's own tokio runtime (`AppRuntime`) and hands its `Tasks` to `TableProApplication`, an `adw::Application` subclass that owns the windows, the app actions and D-Bus uniqueness. Its startup opens the query history on that runtime. Quit asks each window through its close prompts and never calls `g_application_quit`, so the process ends when the last window and the last close or save hold are gone, and the runtime shuts down after that. +Each window is a `SessionWindow` for one connection session. It opens on the picker of saved connections, shows the connect while it runs and the failure when it fails, then the session's `Workspace`. Every connect claims the session's key in the `SessionRegistry` before it starts, so a second window asking for the same database brings the first forward. A workspace never moves to another session: Disconnect writes what it shows, takes its editor sessions, drops the workspace and its claim, and closes the session and those editor sessions under one hold before the window returns to the picker. An approved close runs the same teardown. + ## UI architecture: Relm4 The `app` crate uses [Relm4](https://relm4.org) for component-based UI structure. @@ -197,7 +199,7 @@ The session's `SchemaCatalog` holds what the window knows about the database: th ## Workspace tab system -The active connection drives a single `AdwTabView` hosting heterogeneous tabs. The `App` component owns the strip; tabs are typed via the `WorkspaceTab` enum: +The session drives a single `AdwTabView` hosting heterogeneous tabs. The `Workspace` owns the strip; tabs are typed via the `WorkspaceTab` enum: ```rust pub enum WorkspaceTab { @@ -208,24 +210,24 @@ pub enum WorkspaceTab { } ``` -Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. `App` keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). The UUID is stashed on the `AdwTabPage` via `glib::Quark` qdata so close / right-click actions can recover it. +Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` adopts. The workspace keeps a `HashMap` keyed by the tab's UUID; the canonical display order comes from `tab_view.pages()` (drag-reorderable). The UUID is stashed on the `AdwTabPage` via `glib::Quark` qdata so close / right-click actions can recover it. -App routing is hub-and-spoke: a tab emits signals, and the closures App connects tag them with the tab's UUID and re-emit them as `AppMsg` inputs. App's `update` looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives App one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). +Routing is hub-and-spoke: a tab emits signals, and the closures the workspace connects tag them with the tab's UUID and queue them as `WorkspaceInput`. The workspace looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives the workspace one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). A reply a tab's request brings back after a disconnect reaches a closed queue and is dropped. A browse or structure tab reads its own data through the session. A `RequestSequencer` keeps one request of each kind current: a new page stops the one it replaces, and a reply to a replaced request is dropped. A page and a count wait for the table's columns from the catalog. A read that failed is asked again once the session reconnects, while a tab whose reads all landed keeps its rows. Closing a tab stops its reads, so the server drops the statement. -Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack clears on disconnect because descriptors reference tables in the active connection. +Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack goes with the workspace on disconnect because descriptors reference tables in its session. ## Per-tab pending-change registries -Each window owns two registries holding the in-flight edit state for its tabs, keyed by the same UUID as the workspace slot. App creates them, registers a tracker when it opens a tab, passes the handle into the tab's init, and unregisters it when the tab closes or the connection goes away: +Each workspace owns two registries holding the in-flight edit state for its tabs, keyed by the same UUID as the workspace slot. The workspace creates them, registers a tracker when it opens a tab, passes the handle into the tab's init, and unregisters it when the tab closes or the connection goes away: | Registry | Handle | What it tracks | Materialised by | |---|---|---|---| | `ChangeTrackerRegistry` | `TabTrackerHandle` | Row-level INSERT / UPDATE / DELETE for Table tabs | `TabTrackerHandle::change_set` → `TrackedChanges` | | `StructureTrackerRegistry` | `StructureTrackerHandle` | The table's definition, the user's `TableDraft` and the `TableChanges` between them, for Structure tabs | `PendingStructure::plan` through the connection's `DdlDialect` → `DdlPlan` | -A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `App::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. +A handle is a newtype over `Rc>`. Every method borrows only for its own body, calls no GTK and no callbacks, and returns owned data (`CellOverlay`, `RowState`, `TrackerDelta`), so a grid bind that runs while a tab is applying a change never meets a held borrow. A row edit goes in as a `TrackerEdit` and comes back as a `TrackerDelta`: the rows to redraw, the draft rows the list store gains or loses, and the new pending count. The tab applies it to its `gio::ListStore` after the call returns. A grid row is a `RowObject` whose identity is fixed when it is made: a saved row's key and cells as fetched, or a draft's id, whose cells live only in the tracker. A tracked change bumps the row's `revision` property, and each cell bound to the row listens for it and draws again in place, so the store never swaps a row and its selection and focus stay put. The tab finds the row through a `GridRowIndex` of weak references keyed like the tracker. A grid cell resolves its row through its column view cell when an event fires, so an edit, a menu action or a toggle reaches the row it was made on even after rows above it came or went. Every pending change carries a `Touch` from the tracker's counter. An `UndoOp` holds the pending state on both sides of a step, touch included, so undo after A, B, C shows B, not the fetched value. `change_set` emits one change per row in touch order, and `plan_save` keeps that order: a row deleted and replaced by a draft with the same unique value saves, because its DELETE runs first. `WorkspaceModel::dirty_tabs` reads both registries for the close prompts, the teardown guard and the `•` title prefix. CI fails on a `thread_local!` under `crates/app/src/services`, since a process-wide registry would share one window's unsaved edits with every other window. The Structure tab is **definition + draft**, not a per-change log. The `TableDefinition` one read returned is what the tab plans against; `diff_table` turns the draft into `TableChanges`, and the engine's `DdlDialect` plans them into statements in its own order. Discard copies the definition back over the draft, and is the only restore point. The tracker holds the latest `PendingStructure`, so the close prompt and the save dispatcher read the same change without the tab, and the save plans it again with the dialect of the connection that runs it ([ADR 0010](docs/decisions/0010-ddl-plans-and-table-definitions.md)). diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 29b67f473b..15262bc3d6 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -37,6 +37,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set Value in a grid cell's menu is Set to Empty, Set to NULL and Set to DEFAULT, each shown only where the column takes it. - Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. - Quit asks each window about its unsaved changes in turn, and waits for open connections to close. +- Each window holds one connection: opening one already open brings its window forward, and Disconnect returns to the list. +- Connecting shows in the window with Cancel, and a failed connect says why, with Retry, Edit Connection and Choose Connection. + +### Removed + +- Connection buttons in a connected window's header bar; New Window opens another connection. ### Fixed @@ -44,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format could split `+=`, `b'01'` or `@@version`, or detach a MySQL function from its parenthesis, and change the statement. - Opening a SQL, CSV or connections file from a pipe or a device never finished, or read past its size limit. - Exporting results or query history to a slow disk or a network folder froze the window until the write ended. +- The sidebar's table search button had no name for screen readers. - PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. - MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. - SQL Server money and smallmoney in the SQL editor and query results showed as approximate floats. diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs index 550a46a937..94896f85d0 100644 --- a/linux/crates/app/src/application/app_services.rs +++ b/linux/crates/app/src/application/app_services.rs @@ -15,7 +15,7 @@ use crate::config; use crate::runtime::GioProcessHold; use crate::services::active_session::ActiveSession; use crate::services::history_service::{HistoryService, publish_retention}; -use crate::services::session_context::SessionContext; +use crate::services::session_context::{CredentialSourceFactory, KeyringSources, SessionContext}; use crate::storage::{AppStorage, SharedStorage}; use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; @@ -29,8 +29,12 @@ pub struct AppServices { pub settings: Rc, pub storage: SharedStorage, pub tasks: Tasks, + /// Where each session's secrets come from. + credentials: Rc, /// Bound to the application once it exists, which is after this. - hold: Rc, + application_hold: Rc, + /// What keeps the process alive while a close or a save finishes. + hold: Rc, history: OnceCell, ssh: Option, } @@ -58,6 +62,7 @@ impl AppServices { storage, tasks, } = parts; + let application_hold = Rc::new(GioProcessHold::default()); Self { drivers: Arc::new(drivers), connector, @@ -65,9 +70,11 @@ impl AppServices { liveness, sessions: SessionRegistry::new(), settings, + credentials: Rc::new(KeyringSources::new(storage.secrets().clone())), storage: Rc::new(storage), tasks, - hold: Rc::new(GioProcessHold::default()), + hold: application_hold.clone(), + application_hold, history: OnceCell::new(), ssh: None, } @@ -142,7 +149,20 @@ impl AppServices { } pub(crate) fn bind_application(&self, application: &impl IsA) { - self.hold.bind(application); + self.application_hold.bind(application); + } + + /// A test reads the holds, or stands in for the keyring. + #[cfg(test)] + pub(crate) fn with_hold(mut self, hold: Rc) -> Self { + self.hold = hold; + self + } + + #[cfg(test)] + pub(crate) fn with_credentials(mut self, credentials: Rc) -> Self { + self.credentials = credentials; + self } /// One window's view of the shared services: its own session slot, @@ -159,6 +179,7 @@ impl AppServices { hold: self.hold(), tasks: self.tasks.clone(), liveness: self.liveness, + credentials: self.credentials.clone(), active: ActiveSession::new(), }; (Rc::new(context), prompts) diff --git a/linux/crates/app/src/application/imp.rs b/linux/crates/app/src/application/imp.rs index 85ba4f279d..54bf25723f 100644 --- a/linux/crates/app/src/application/imp.rs +++ b/linux/crates/app/src/application/imp.rs @@ -103,7 +103,7 @@ impl GtkApplicationImpl for TableProApplication { .map(|window| window.id()); self.parent_window_removed(window); if let Some(id) = id { - drop(self.windows.remove(id)); + self.windows.remove(id); } } } diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs index 91899aa063..aff36bb917 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -17,7 +17,6 @@ use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use libadwaita as adw; -use relm4::Component; pub use app_actions::{APP_ACCELS, install_app_actions}; pub use app_services::{AppServices, AppServicesParts}; @@ -28,7 +27,7 @@ pub use window_launch::WindowLaunch; pub(crate) use window_registry::WindowRegistry; use crate::config; -use crate::ui::{App, AppInit}; +use crate::ui::{SessionWindow, SessionWindowInit}; glib::wrapper! { pub struct TableProApplication(ObjectSubclass) @@ -61,10 +60,9 @@ impl TableProApplication { tracing::warn!("no window opened: the application has no services"); return None; }; - let builder = App::builder(); - let window = builder.root.clone(); - // The window gets its id when it is added, and the component - // claims its session under that id from init on. + let window = SessionWindow::new(); + // The window gets its id when it is added, and it claims its + // session under that id from start on. self.add_window(&window); if window.application().is_none() { tracing::warn!("no window opened: the application has not started"); @@ -72,10 +70,10 @@ impl TableProApplication { return None; } let host: Rc = Rc::new(AppWindowHost::new(self)); - let controller = builder.launch(AppInit { services, host, launch }).detach(); - self.imp().windows.insert(window.id(), controller); + self.imp().windows.insert(window.id(), window.clone()); + window.start(SessionWindowInit { services, host, launch }); window.present(); - Some(window) + Some(window.upcast()) } /// False when no window has that id. diff --git a/linux/crates/app/src/application/quit_sequence.rs b/linux/crates/app/src/application/quit_sequence.rs index a1d4142743..366a2533f1 100644 --- a/linux/crates/app/src/application/quit_sequence.rs +++ b/linux/crates/app/src/application/quit_sequence.rs @@ -4,7 +4,8 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use super::TableProApplication; -use crate::ui::AppMsg; +use crate::runtime::{DeliverInput, Delivery}; +use crate::ui::SessionWindowInput; use crate::workspace::{CloseDecision, CloseReason}; /// Asks each window in turn whether it may close, the way closing it by @@ -46,7 +47,7 @@ async fn ask(app: &TableProApplication, window: >k::Window) -> CloseDecision { } window.present(); let (reply, answer) = oneshot::channel(); - if sender.send(AppMsg::CloseRequested(CloseReason::Quit(reply))).is_err() { + if sender.try_deliver(SessionWindowInput::CloseRequested(CloseReason::Quit(reply))) == Delivery::ReceiverGone { return CloseDecision::Approved; } // A window torn down before it answered has nothing left to keep. diff --git a/linux/crates/app/src/application/tests.rs b/linux/crates/app/src/application/tests.rs index 1a84d9d567..ce8b41f988 100644 --- a/linux/crates/app/src/application/tests.rs +++ b/linux/crates/app/src/application/tests.rs @@ -236,15 +236,14 @@ fn query_end_presents_inhibiting_window() { for window in [&idle, &busy] { window.set_visible(false); } + let mut inhibitor = WindowInhibitor::new(GrantingSession); + inhibitor.update(Some(InhibitReason::UnsavedChanges)); test.app .imp() .windows - .with_model(busy.id(), |model| { - let mut inhibitor = WindowInhibitor::new(GrantingSession); - inhibitor.update(Some(InhibitReason::UnsavedChanges)); - model.set_inhibitor(inhibitor); - }) - .expect("the busy window's component"); + .get(busy.id()) + .expect("the busy window") + .set_inhibitor(inhibitor); test.app.emit_by_name::<()>("query-end", &[]); diff --git a/linux/crates/app/src/application/window_launch.rs b/linux/crates/app/src/application/window_launch.rs index e31ff2bf9b..8c3396252e 100644 --- a/linux/crates/app/src/application/window_launch.rs +++ b/linux/crates/app/src/application/window_launch.rs @@ -5,4 +5,6 @@ use tablepro_storage::SavedConnection; pub enum WindowLaunch { Picker, Connect(Box), + /// A database file, from the file manager or the command line. + OpenFile(gio::File), } diff --git a/linux/crates/app/src/application/window_registry.rs b/linux/crates/app/src/application/window_registry.rs index 8c43cf7a23..2d54723ab4 100644 --- a/linux/crates/app/src/application/window_registry.rs +++ b/linux/crates/app/src/application/window_registry.rs @@ -1,70 +1,60 @@ use std::cell::RefCell; use std::collections::HashMap; -use relm4::{ComponentController, Controller}; +use crate::runtime::InputSender; +use crate::ui::{SessionWindow, SessionWindowInput}; -use crate::ui::{App, AppMsg}; - -/// The controller of each window the application opened, by window id. -/// GTK-thread only. +/// Each window the application opened, by window id. GTK-thread only. #[derive(Default)] pub(crate) struct WindowRegistry { - controllers: RefCell>>, + windows: RefCell>, } impl WindowRegistry { - pub(crate) fn insert(&self, id: u32, controller: Controller) { - self.controllers.borrow_mut().insert(id, controller); + pub(crate) fn insert(&self, id: u32, window: SessionWindow) { + self.windows.borrow_mut().insert(id, window); } - /// The caller drops the controller after the map is released, since - /// dropping it stops the window's component. - pub(crate) fn remove(&self, id: u32) -> Option> { - self.controllers.borrow_mut().remove(&id) + /// The window is shut down after the map is released: its teardown + /// may present another window, which reads the map. + pub(crate) fn remove(&self, id: u32) { + let removed = self.windows.borrow_mut().remove(&id); + if let Some(window) = removed { + window.shutdown(); + } } - pub(crate) fn sender(&self, id: u32) -> Option> { - self.controllers - .borrow() - .get(&id) - .map(|controller| controller.sender().clone()) + pub(crate) fn sender(&self, id: u32) -> Option> { + self.windows.borrow().get(&id).and_then(SessionWindow::input) } /// Whether the window holds logout back. - /// - /// The state is read outside any update: the components run their - /// updates without returning to the main loop, which is the only - /// place this is called from. pub(crate) fn holds_inhibit(&self, id: u32) -> bool { - self.controllers - .borrow() - .get(&id) - .is_some_and(|controller| controller.model().is_inhibiting()) + self.windows.borrow().get(&id).is_some_and(SessionWindow::is_inhibiting) } #[cfg(test)] pub(crate) fn len(&self) -> usize { - self.controllers.borrow().len() + self.windows.borrow().len() } #[cfg(test)] - pub(crate) fn with_model(&self, id: u32, change: impl FnOnce(&mut App) -> R) -> Option { - let controllers = self.controllers.borrow(); - let controller = controllers.get(&id)?; - let mut parts = controller.state().get_mut(); - Some(change(&mut parts.model)) + pub(crate) fn get(&self, id: u32) -> Option { + self.windows.borrow().get(&id).cloned() } pub(crate) fn clear(&self) { - let controllers: Vec> = self.controllers.borrow_mut().drain().map(|(_, c)| c).collect(); - drop(controllers); + let windows: Vec = self.windows.borrow_mut().drain().map(|(_, window)| window).collect(); + for window in windows { + window.shutdown(); + } } } impl std::fmt::Debug for WindowRegistry { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("WindowRegistry") - .field("windows", &self.controllers.borrow().keys().collect::>()) + .field("windows", &self.windows.borrow().keys().collect::>()) .finish() } } diff --git a/linux/crates/app/src/runtime/deliver_input.rs b/linux/crates/app/src/runtime/deliver_input.rs index d17649315e..9f18633d3c 100644 --- a/linux/crates/app/src/runtime/deliver_input.rs +++ b/linux/crates/app/src/runtime/deliver_input.rs @@ -1,3 +1,11 @@ +/// Whether a message reached the component it was sent to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Delivery { + Delivered, + /// The component has shut down, so the message was dropped. + ReceiverGone, +} + /// Send a component its input without panicking once it has shut down. /// /// relm4's `ComponentSender::input` and `FactorySender::input` call @@ -5,26 +13,42 @@ /// a save it started is still on its way back, which would take every /// other window down with it. The message is dropped instead. pub trait DeliverInput { - fn deliver(&self, message: M); -} + fn try_deliver(&self, message: M) -> Delivery; -impl DeliverInput for relm4::Sender { fn deliver(&self, message: M) { - if self.send(message).is_err() { + if self.try_deliver(message) == Delivery::ReceiverGone { tracing::debug!(target: "component", "message dropped after component shutdown"); } } } +impl DeliverInput for relm4::Sender { + fn try_deliver(&self, message: M) -> Delivery { + match self.send(message) { + Ok(()) => Delivery::Delivered, + Err(_) => Delivery::ReceiverGone, + } + } +} + impl DeliverInput for relm4::ComponentSender { - fn deliver(&self, message: C::Input) { - self.input_sender().deliver(message); + fn try_deliver(&self, message: C::Input) -> Delivery { + self.input_sender().try_deliver(message) } } impl DeliverInput for relm4::FactorySender { - fn deliver(&self, message: C::Input) { - self.input_sender().deliver(message); + fn try_deliver(&self, message: C::Input) -> Delivery { + self.input_sender().try_deliver(message) + } +} + +impl DeliverInput for tokio::sync::mpsc::UnboundedSender { + fn try_deliver(&self, message: M) -> Delivery { + match self.send(message) { + Ok(()) => Delivery::Delivered, + Err(_) => Delivery::ReceiverGone, + } } } @@ -48,4 +72,14 @@ mod tests { sender.deliver(7); } + + #[test] + fn deliver_to_dropped_receiver_reports_receiver_gone() { + let (sender, receiver) = tokio::sync::mpsc::unbounded_channel::(); + assert_eq!(sender.try_deliver(1), Delivery::Delivered); + + drop(receiver); + + assert_eq!(sender.try_deliver(2), Delivery::ReceiverGone); + } } diff --git a/linux/crates/app/src/runtime/input_queue.rs b/linux/crates/app/src/runtime/input_queue.rs new file mode 100644 index 0000000000..974e9391a7 --- /dev/null +++ b/linux/crates/app/src/runtime/input_queue.rs @@ -0,0 +1,111 @@ +use gtk4::glib; +use gtk4::glib::prelude::*; +use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender}; + +use super::{DeliverInput, Delivery}; + +/// What a widget's actions, signal handlers and background work send it. +/// It is `Send`, so a task on the runtime can answer through it. +pub type InputSender = UnboundedSender; + +pub fn input_queue() -> (InputSender, UnboundedReceiver) { + tokio::sync::mpsc::unbounded_channel() +} + +/// Hand each message `object` receives to `handle`, one at a time and in +/// order, on the GTK thread, until the returned handle is aborted or +/// `object` is gone. +/// +/// Handling waits for the main loop rather than running inside whatever +/// sent the message, so a signal handler or an action never re-enters +/// the widget while it is in the middle of handling another message. +pub fn pump_inputs( + object: &T, + mut receiver: UnboundedReceiver, + handle: impl Fn(&T, M) + 'static, +) -> glib::JoinHandle<()> +where + T: IsA, + M: 'static, +{ + let weak = object.downgrade(); + glib::spawn_future_local(async move { + while let Some(message) = receiver.recv().await { + let Some(object) = weak.upgrade() else { + return; + }; + handle(&object, message); + } + }) +} + +/// A relm4 sender whose messages land in `input`, for a relm4 child that +/// can only forward its output to one. Abort the handle with the widget +/// that owns `input`. +pub fn relm4_bridge(input: InputSender) -> (relm4::Sender, glib::JoinHandle<()>) { + let (sender, receiver) = relm4::channel::(); + let forwarding = glib::spawn_future_local(async move { + while let Some(message) = receiver.recv().await { + if input.try_deliver(message) == Delivery::ReceiverGone { + return; + } + } + }); + (sender, forwarding) +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::rc::Rc; + + use super::*; + use crate::test_support::drain_main_context; + + #[gtk4::test] + fn messages_are_handled_in_order_after_the_sender_returns() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let seen = Rc::new(RefCell::new(Vec::new())); + let sink = seen.clone(); + let pump = pump_inputs(&object, receiver, move |_, message| sink.borrow_mut().push(message)); + + sender.deliver(1); + sender.deliver(2); + assert!(seen.borrow().is_empty(), "handled inside the send"); + drain_main_context(); + + assert_eq!(*seen.borrow(), [1, 2]); + pump.abort(); + } + + #[gtk4::test] + fn an_aborted_pump_leaves_the_sender_with_no_receiver() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let pump = pump_inputs(&object, receiver, |_, _| {}); + + // The handle holds the task's source, and with it the receiver, + // until it is dropped too. + pump.abort(); + drop(pump); + drain_main_context(); + + assert_eq!(sender.try_deliver(1), Delivery::ReceiverGone); + } + + #[gtk4::test] + fn a_bridge_forwards_until_its_target_is_gone() { + let (input, mut receiver) = input_queue::(); + let (bridge, _forwarding) = relm4_bridge(input); + + bridge.deliver(3); + drain_main_context(); + assert_eq!(receiver.try_recv().ok(), Some(3)); + + drop(receiver); + bridge.deliver(4); + drain_main_context(); + assert_eq!(bridge.try_deliver(5), Delivery::ReceiverGone); + } +} diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index 7ac5e97b19..72fb1dd8df 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -3,6 +3,7 @@ mod deliver; mod deliver_input; mod failed_requests; mod gio_process_hold; +mod input_queue; mod request_sequencer; mod request_stamp; mod request_ticket; @@ -10,9 +11,10 @@ mod stamped; pub use app_runtime::AppRuntime; pub use deliver::spawn_and_deliver; -pub use deliver_input::DeliverInput; +pub use deliver_input::{DeliverInput, Delivery}; pub use failed_requests::FailedRequests; pub use gio_process_hold::GioProcessHold; +pub use input_queue::{InputSender, input_queue, pump_inputs, relm4_bridge}; pub use request_sequencer::RequestSequencer; pub use request_stamp::RequestStamp; pub use request_ticket::RequestTicket; diff --git a/linux/crates/app/src/services/file_resolution.rs b/linux/crates/app/src/services/file_resolution.rs new file mode 100644 index 0000000000..4e9611c1b9 --- /dev/null +++ b/linux/crates/app/src/services/file_resolution.rs @@ -0,0 +1,124 @@ +//! A database file handed to TablePro, resolved to the session it +//! opens. Resolving reads the disk, so it runs on the blocking pool. + +use std::path::{Path, PathBuf}; + +use tablepro_session::SessionKey; +use tablepro_session::registry::FileIdentity; +use tablepro_storage::{ConnectionStore, SavedConnection}; +use thiserror::Error; + +/// Why a file could not be opened before any connect was tried. +#[derive(Debug, Error)] +pub enum FileResolveError { + /// A file with no local path, such as one on a remote share GIO + /// reaches without mounting it. + #[error("the file has no local path")] + NotLocal, + #[error("the file could not be found: {0}")] + NotFound(#[source] std::io::Error), +} + +/// What a file turned out to open. +#[derive(Debug)] +pub enum FileTarget { + /// The user saved this file as a connection, so that connection + /// opens, with its name, colour and saved tabs. + Saved(Box), + File(FileIdentity), +} + +/// A file the user opened and what it resolved to. +#[derive(Debug)] +pub struct ResolvedFile { + /// The URI it arrived as, for a Retry to resolve again. + pub uri: String, + pub target: Result, +} + +/// The file's canonical path, so a symlink and its target, or two +/// spellings of one path, open one session. The URI is the canonical +/// path's, which is what the session's key and saved tabs go by. +pub fn resolve_file_identity_blocking(path: Option<&Path>, uri: &str) -> Result { + let path = path.ok_or(FileResolveError::NotLocal)?; + let canonical = std::fs::canonicalize(path).map_err(FileResolveError::NotFound)?; + let uri = glib::filename_to_uri(&canonical, None).map_or_else(|_| uri.to_owned(), |uri| uri.to_string()); + Ok(FileIdentity::new(Some(canonical), uri)) +} + +/// Resolve the file, then find whether a saved connection opens the +/// same one. +pub fn resolve_file_blocking(path: Option, uri: String, connections: &ConnectionStore) -> ResolvedFile { + let target = resolve_file_identity_blocking(path.as_deref(), &uri).map(|identity| { + let saved = match connections.load_blocking() { + Ok(saved) => saved.to_vec(), + Err(error) => { + tracing::warn!(%error, "the saved connections could not be read to match a file against"); + Vec::new() + } + }; + match SessionKey::for_file(&identity, &saved) { + SessionKey::Saved(id) => match saved.into_iter().find(|connection| connection.id == id) { + Some(connection) => FileTarget::Saved(Box::new(connection)), + None => FileTarget::File(identity), + }, + SessionKey::File(_) => FileTarget::File(identity), + } + }); + ResolvedFile { uri, target } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_missing_file_is_not_found() { + let root = tempfile::tempdir().expect("tempdir"); + let missing = root.path().join("gone.db"); + + let resolved = resolve_file_identity_blocking(Some(&missing), "file:///gone.db"); + + assert!(matches!(resolved, Err(FileResolveError::NotFound(_))), "{resolved:?}"); + assert!(matches!( + resolve_file_identity_blocking(None, "sftp://host/app.db"), + Err(FileResolveError::NotLocal) + )); + } + + #[test] + fn a_saved_file_opens_its_saved_connection() { + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + let store = ConnectionStore::new(&tablepro_storage::StoragePaths::under( + root.path(), + "tablepro-test", + "app.tablepro.Test", + )); + let saved = SavedConnection { + id: uuid::Uuid::new_v4(), + name: "local".to_owned(), + driver_id: "sqlite".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: tablepro_core::TlsConfig::disabled(), + read_only: false, + auth_mode: tablepro_core::AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + }; + store.upsert_blocking(saved.clone()).expect("saved"); + + let resolved = resolve_file_blocking(Some(database.clone()), format!("file://{}", database.display()), &store); + + match resolved.target { + Ok(FileTarget::Saved(connection)) => assert_eq!(connection.id, saved.id), + other => panic!("the saved connection was not found: {other:?}"), + } + } +} diff --git a/linux/crates/app/src/services/from_session.rs b/linux/crates/app/src/services/from_session.rs deleted file mode 100644 index 79d775d576..0000000000 --- a/linux/crates/app/src/services/from_session.rs +++ /dev/null @@ -1,60 +0,0 @@ -use std::sync::{Arc, Weak}; - -use tablepro_session::Session; - -use super::active_session::ActiveSession; - -/// A message a session's watcher queued for the window, and the session -/// it came from. -/// -/// The window's queue can hold one behind the switch to another -/// session, and by the time it is handled it describes a session the -/// window has let go of. -#[derive(Debug)] -pub struct FromSession { - origin: Weak, - value: T, -} - -impl FromSession { - pub fn new(origin: Weak, value: T) -> Self { - Self { origin, value } - } - - /// The value, while the session it came from is still the window's. - pub fn if_current(self, active: &ActiveSession) -> Option { - let current = active.get()?; - Weak::ptr_eq(&Arc::downgrade(¤t), &self.origin).then_some(self.value) - } -} - -#[cfg(test)] -mod tests { - use tablepro_test_support::ConnectionScript; - - use super::*; - use crate::test_support::FakeSession; - - #[test] - fn only_the_current_session_is_heard() { - let runtime = crate::test_support::test_runtime(); - let a = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); - let b = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); - let active = ActiveSession::new(); - let from_a = || FromSession::new(Arc::downgrade(&a.session), "a"); - - assert_eq!(from_a().if_current(&active), None, "heard with no session open"); - active.replace(a.session.clone()); - assert_eq!(from_a().if_current(&active), Some("a")); - active.replace(b.session.clone()); - assert_eq!( - from_a().if_current(&active), - None, - "heard after the switch to another session" - ); - assert_eq!( - FromSession::new(Arc::downgrade(&b.session), "b").if_current(&active), - Some("b") - ); - } -} diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index 7e5cbb07fc..a8674c0790 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -3,7 +3,7 @@ pub mod cell_overlay; pub mod change_tracker; pub mod change_tracker_registry; pub mod column_relayout; -pub mod from_session; +pub mod file_resolution; pub mod history_availability; pub mod history_service; pub mod pending_structure; diff --git a/linux/crates/app/src/services/session_context.rs b/linux/crates/app/src/services/session_context.rs index ef67c37437..da5272ce6a 100644 --- a/linux/crates/app/src/services/session_context.rs +++ b/linux/crates/app/src/services/session_context.rs @@ -6,13 +6,14 @@ use tablepro_core::credentials::{CredentialInteraction, CredentialPrompter, Secr use tablepro_core::{DriverRegistry, LivenessPolicy}; use tablepro_session::operation::{DeadlinePolicy, OperationKind}; use tablepro_session::runtime::{ProcessHold, Tasks}; -use tablepro_session::{Connector, CredentialSource, SessionDeps, SessionRegistry}; +use tablepro_session::{Connector, CredentialSource, NoStoredCredentials, SessionDeps, SessionRegistry}; use tablepro_storage::{KeyringCredentials, SavedConnection}; use super::active_session::ActiveSession; /// Everything a window needs to connect, run a session and close it, /// built once in `run()` and shared on the GTK thread. +#[derive(Clone)] pub struct SessionContext { pub drivers: Arc, pub connector: Arc, @@ -23,19 +24,49 @@ pub struct SessionContext { pub hold: Rc, pub tasks: Tasks, pub liveness: LivenessPolicy, + pub credentials: Rc, pub active: ActiveSession, } +/// Where a session's secrets come from at each connect and reconnect. +pub trait CredentialSourceFactory { + /// A saved connection's keyring entries, or for a connection with no + /// saved entry, only what its request carries. + fn for_connection(&self, saved: Option<&SavedConnection>) -> Arc; +} + +/// The keyring, labelling what it remembers in the user's language. +pub struct KeyringSources { + secrets: Arc, +} + +impl KeyringSources { + pub fn new(secrets: Arc) -> Self { + Self { secrets } + } +} + +impl CredentialSourceFactory for KeyringSources { + fn for_connection(&self, saved: Option<&SavedConnection>) -> Arc { + match saved { + Some(saved) => Arc::new( + KeyringCredentials::for_saved(self.secrets.clone(), saved).with_labeler(translated_secret_label), + ), + None => Arc::new(NoStoredCredentials), + } + } +} + impl SessionContext { /// A connect the user is waiting on may ask them for what is missing. pub fn attended(&self) -> CredentialInteraction { CredentialInteraction::Attended(self.prompter.clone()) } - /// A saved connection's secrets, read from the keyring at every - /// connect and labelled in the user's language when remembered. - pub fn keyring_credentials(&self, saved: &SavedConnection) -> KeyringCredentials { - KeyringCredentials::for_saved(self.secrets.clone(), saved).with_labeler(translated_secret_label) + /// What a session opened from `saved` reads its secrets from at + /// every connect, or one opened without a saved entry. + pub fn credentials_for(&self, saved: Option<&SavedConnection>) -> Arc { + self.credentials.for_connection(saved) } /// What a started session reconnects with. A Retry the user presses diff --git a/linux/crates/app/src/ui/app/close_flow.rs b/linux/crates/app/src/ui/app/close_flow.rs deleted file mode 100644 index ad998c67c3..0000000000 --- a/linux/crates/app/src/ui/app/close_flow.rs +++ /dev/null @@ -1,282 +0,0 @@ -use std::cell::Cell; -use std::rc::Rc; - -use relm4::adw::prelude::*; -use relm4::gtk::glib; -use relm4::{ComponentSender, adw}; -use uuid::Uuid; - -use super::workspace_persistence::{WorkspacePersistence, WorkspaceTabs}; -use super::{App, AppMsg, WorkspaceTab, connection}; -use tablepro_session::runtime::ProcessHold; - -use crate::runtime::DeliverInput; -use crate::services::active_session::ActiveSession; -use crate::services::session_context::SessionContext; -use crate::workspace::{CloseDecision, InhibitReason}; - -/// What a window that closes still owes: what it shows written to disk, -/// and its connections closed rather than cut off, in order. -pub(super) struct WindowShutdown { - done: Cell, - parts: WindowShutdownParts, -} - -pub(super) struct WindowShutdownParts { - pub settings: Rc, - pub storage: crate::storage::SharedStorage, - pub drafts: Rc, - pub workspace: Rc, - pub tabs: WorkspaceTabs, - pub context: Rc, - pub connect_attempts: Rc, -} - -impl WindowShutdown { - pub(super) fn new(parts: WindowShutdownParts) -> Rc { - Rc::new(Self { - done: Cell::new(false), - parts, - }) - } - - /// Runs once, from the close request, or from the component's - /// shutdown for a window destroyed without one. `window` is the one - /// still on screen, whose size is kept. - pub(super) fn run(&self, window: Option<&adw::ApplicationWindow>) { - if self.done.replace(true) { - return; - } - let parts = &self.parts; - if let Some(window) = window { - crate::ui::window_geometry::persist(window, &parts.settings); - } - self.flush(); - close_connections( - &parts.tabs, - &parts.context.active, - &*parts.context.hold, - &parts.connect_attempts, - ); - } - - /// The last column drag, filter change, editor text and tab list may - /// still be on their way to disk. - fn flush(&self) { - let parts = &self.parts; - let hold = parts.context.hold.acquire(); - let column_widths = parts.storage.column_widths().flush(); - let filter_settings = parts.storage.filter_settings().flush(); - let editor_drafts = parts.drafts.flush_all(); - let tabs = parts.workspace.detach(); - glib::spawn_future_local(async move { - column_widths.await; - filter_settings.await; - for failure in editor_drafts.await { - tracing::warn!(failure, "an editor draft was not saved"); - } - if let Some(tabs) = tabs - && let Err(error) = tabs.await - { - tracing::debug!(%error, "the open tabs were not saved"); - } - drop(hold); - }); - } -} - -/// The tabs go first: each editor tab closes its own connection, which -/// outlives the window's, and every read on the window's session stops. -/// Only then does the session itself close. Every close holds the -/// process until it ends, so quitting does not cut a connection off -/// mid-close. -fn close_connections( - tabs: &WorkspaceTabs, - active: &ActiveSession, - hold: &dyn ProcessHold, - attempts: &tablepro_session::ConnectAttempts, -) { - for tab in tabs.borrow().values() { - match tab { - WorkspaceTab::Editor(slot) => slot.editor.shutdown(), - WorkspaceTab::Structure(slot) => slot.structure.shutdown(), - WorkspaceTab::Table(slot) => slot.browse.shutdown(), - } - } - connection::close_window_session(active, hold, attempts); -} - -impl App { - /// A tab asked to close. One with nothing to lose closes now; any - /// other waits for the prompts. - pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid, sender: ComponentSender) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; - if self.close_guards.may_close_tab(&self.workspace_handle, id) { - self.finish_close_workspace_tab(id, &tab_view); - return; - } - self.close_guards - .ask_close_tab(self.window.upcast_ref(), &self.workspace_handle, id, move |decision| { - sender.deliver(AppMsg::TabCloseDecided { - id, - approved: decision == CloseDecision::Approved, - }); - }); - } - - pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { - let Some(tab_view) = self.workspace_tab_view.clone() else { - return; - }; - if approved { - self.finish_close_workspace_tab(id, &tab_view); - return; - } - let page = self.workspace_tabs.borrow().get(&id).map(|tab| match tab { - WorkspaceTab::Editor(slot) => slot.page.clone(), - WorkspaceTab::Structure(slot) => slot.page.clone(), - WorkspaceTab::Table(slot) => slot.page.clone(), - }); - if let Some(page) = page { - tab_view.close_page_finish(&page, false); - } - } - - /// Send `proceed` once every tab may close, for a disconnect or a - /// switch to another connection, which take the tabs with them. - pub(super) fn close_workspace_then(&self, proceed: AppMsg, sender: ComponentSender) { - self.close_guards.begin_teardown(); - if self.close_guards.may_tear_down(&self.workspace_handle) { - sender.deliver(proceed); - return; - } - self.close_guards - .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { - if decision == CloseDecision::Approved { - sender.deliver(proceed); - } - }); - } - - /// Logout, and suspend while a save runs, wait for the user while - /// the window holds something they would lose. - pub(super) fn refresh_inhibitor(&mut self) { - let dirty = !self.workspace_handle.dirty_tabs().is_empty(); - let saving = self.workspace_handle.ledger().in_flight() > 0; - self.inhibitor.update(InhibitReason::for_state(dirty, saving)); - } -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::HashMap; - use std::sync::Arc; - use std::time::Duration; - - use tablepro_core::{EditorSession, TransactionState}; - use tablepro_session::ConnectAttempts; - use tablepro_session::runtime::{CountingHold, HoldToken}; - use tablepro_storage::DraftId; - use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; - - use super::*; - use crate::test_support::{FakeSession, MemorySettings, wait_until}; - use crate::ui::app::window_tabs::WindowTabs; - use crate::ui::app::{EditorTabSlot, write_workspace_tab_id}; - use crate::ui::editor::{SqlEditor, SqlEditorInit}; - use crate::workspace::{TabDirectory, TabEntry}; - - const WAIT: Duration = Duration::from_secs(10); - - /// Names each hold it gives out, so a test reads which close began - /// first: every close holds the process from the moment it starts. - struct NamedHold { - name: &'static str, - log: Rc>>, - counting: Rc, - } - - impl ProcessHold for NamedHold { - fn acquire(&self) -> HoldToken { - self.log.borrow_mut().push(self.name); - self.counting.acquire() - } - } - - #[gtk4::test] - fn closing_the_window_names_open_transactions_and_closes_every_connection() { - adw::init().expect("libadwaita initialises under the test backend"); - crate::register_test_resources(); - let runtime = crate::test_support::test_runtime(); - let settings = MemorySettings::new(); - let mut runs = Replies::default(); - runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); - let editor_session = Arc::new(FakeEditorSession::new(runs)); - let mut script = ConnectionScript::default(); - let opened: Arc = editor_session.clone(); - script.open_editor_session.always(FakeReply::ok(opened)); - script.probe.always(FakeReply::ok(())); - let fake = FakeSession::start(script, [], runtime.tasks()); - let active = ActiveSession::new(); - active.replace(fake.session.clone()); - let hold = Rc::new(CountingHold::new()); - let log = Rc::new(RefCell::new(Vec::new())); - let named = |name| NamedHold { - name, - log: log.clone(), - counting: hold.clone(), - }; - let editor = SqlEditor::new(SqlEditorInit { - initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), - settings: settings.get().clone(), - history: None, - tasks: runtime.tasks(), - session: active.clone(), - hold: Rc::new(named("editor tab")), - close_timeout: Duration::from_secs(5), - }); - editor.run(); - wait_until(WAIT, || !editor.is_running()).expect("the run to end"); - let view = adw::TabView::new(); - let page = view.append(&editor); - page.set_title("• Query 1"); - let id = Uuid::new_v4(); - write_workspace_tab_id(&page, id); - let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( - id, - WorkspaceTab::Editor(EditorTabSlot { - editor, - page, - draft: DraftId::new(), - }), - )]))); - let directory = WindowTabs::new(tabs.clone()); - directory.set_view(Some(&view)); - - assert_eq!( - directory.tabs(), - [TabEntry { - id, - title: "Query 1".to_owned(), - open_transaction: true, - }] - ); - - log.borrow_mut().clear(); - close_connections(&tabs, &active, &named("window session"), &ConnectAttempts::new()); - - assert_eq!(*log.borrow(), ["editor tab", "window session"]); - assert!(!active.is_connected()); - wait_until(WAIT, || { - editor_session.close_count() == 1 && fake.connection.is_closed() && hold.live() == 0 - }) - .expect("both connections to close under the hold"); - assert!(fake.session.is_shut_down()); - drop(tabs); - crate::test_support::drain_main_context(); - runtime.shutdown(); - } -} diff --git a/linux/crates/app/src/ui/app/connection.rs b/linux/crates/app/src/ui/app/connection.rs deleted file mode 100644 index a682412233..0000000000 --- a/linux/crates/app/src/ui/app/connection.rs +++ /dev/null @@ -1,1469 +0,0 @@ -use std::sync::Arc; - -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{ComponentController, ComponentSender, adw, gtk}; - -use tablepro_core::meta::{ObjectInfo, ObjectScope}; -use tablepro_session::connect::AttemptGeneration; -use tablepro_session::runtime::{ProcessHold, WatchForwarder}; -use tablepro_session::{ - ClaimError, ConnectAttempts, ConnectError, CredentialSource, MonitorSignal, ObjectListSnapshot, PendingSession, - Session, SessionClaim, SessionHealth, SessionKey, -}; -use tablepro_storage::SavedConnection; -use uuid::Uuid; - -use tablepro_session::RequestTarget; -use tablepro_session::connect::StoreConnectionWrites; - -use crate::services::active_session::ActiveSession; -use crate::services::from_session::FromSession; -use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, ConnectSubmission, FormField}; -use crate::ui::error_text::TlsRemedy; -use crate::ui::file_read::{FileReadError, read_capped_file}; -use crate::ui::ssh_inputs::SshEnvironment; -use crate::workspace::CloseDecision; - -use super::connect_flow::{self, SubmitError}; -use super::{App, AppMsg, qualified_label}; -use crate::runtime::DeliverInput; - -/// What a failed connect can be retried from: the connection as it was -/// submitted, and the setting that would fix it. -#[derive(Debug)] -pub(crate) struct ConnectRetry { - pub prefill: ConnectPrefill, - pub remedy: Option, -} - -/// The password the user typed, so editing after a failure does not -/// make them type it again. It stays in memory. -fn typed_password(target: &RequestTarget) -> Option { - match target { - RequestTarget::Network { - credentials: tablepro_core::Credentials::Password { password, .. }, - .. - } => Some(password.clone()), - _ => None, - } -} - -/// A connect that worked, waiting for the window to adopt it. -pub(crate) struct ReadySession { - pub key: SessionKey, - pub pending: PendingSession, - /// What a reconnect reads its secrets from. - pub credentials: Arc, - /// Secrets the keyring refused to keep, one sentence each. - pub warnings: Vec, - /// The attempt it came from, when the window started it, so a late - /// result loses to a newer attempt. - pub generation: Option, -} - -impl std::fmt::Debug for ReadySession { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ReadySession") - .field("key", &self.key) - .field("pending", &self.pending) - .field("generation", &self.generation) - .finish_non_exhaustive() - } -} - -/// What ties the adopted session to this window. Dropping it releases -/// the registry key and stops the watchers that feed the banner and the -/// sidebar. -pub(crate) struct SessionLinks { - key: SessionKey, - _claim: SessionClaim, - _health: WatchForwarder, - /// Feeds the sidebar. Dropped before the session shuts down, so a - /// list read for a session the window let go of never repaints it. - _catalog_watch: WatchForwarder, -} - -fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { - glib::spawn_future_local(future); -} - -/// Deliver each new object list the session's catalog commits for the -/// sidebar's scope. The list on screen when the watch starts is not -/// delivered again, and a refresh that failed keeps the list it had. -pub(super) fn catalog_watch( - session: &Arc, - mut deliver: impl FnMut(FromSession) + 'static, -) -> WatchForwarder { - let objects = session.subscribe_objects(ObjectScope::DefaultSchema); - let origin = Arc::downgrade(session); - let mut shown = objects.borrow().generation; - WatchForwarder::new( - objects, - move |snapshot: ObjectListSnapshot| { - if snapshot.generation != shown { - shown = snapshot.generation; - deliver(FromSession::new(origin.clone(), snapshot)); - } else if let Some(error) = &snapshot.last_error { - tracing::warn!(%error, "the table list could not be read again"); - } - }, - spawn_local, - ) -} - -impl App { - pub(super) fn on_open_connect(&mut self, sender: ComponentSender) { - self.present_connect_dialog(None, None, sender); - } - - /// Show the connect dialog, filled from `prefill` when a connect - /// from it failed, with the focus on `focus`. - fn present_connect_dialog( - &mut self, - prefill: Option, - focus: Option, - sender: ComponentSender, - ) { - let dialog = ConnectDialog::new(ConnectDialogInit { - drivers: self.context.drivers.clone(), - connector: self.context.connector.clone(), - interaction: self.context.attended(), - tasks: self.tasks.clone(), - saved_connections: Arc::from(self.saved_connections.clone()), - remembered_driver: self.settings.connect_dialog_driver(), - ssh_environment: SshEnvironment::detect(), - prefill, - }); - let submitted = sender.clone(); - dialog.connect_submitted(move |_, submission| { - submitted.deliver(AppMsg::ConnectSubmitted(Box::new(submission))); - }); - let closed = sender; - dialog.connect_closed(move |_| closed.deliver(AppMsg::DialogClosed)); - dialog.present(Some(&self.window)); - if let Some(field) = focus { - dialog.reveal_field(field); - } - self.dialog = Some(dialog); - } - - /// Connect to what the dialog was filled in with, then save it. The - /// entry is only written once the connect works, so a typo never - /// lands in the connection list. - pub(super) fn on_connect_submission(&mut self, submission: ConnectSubmission, sender: ComponentSender) { - if self.context.active.is_connected() { - let proceed = AppMsg::ConnectSubmissionConfirmed(Box::new(submission)); - self.close_workspace_then(proceed, sender); - return; - } - self.connect_submission(submission, sender); - } - - pub(super) fn connect_submission(&mut self, submission: ConnectSubmission, sender: ComponentSender) { - let ConnectSubmission { request, pending_save } = submission; - self.set_loading_page( - &crate::i18n::gettext("Connecting…"), - &crate::i18n::gettext_f("Opening {name}", &[("name", &request.metadata.name)]), - &sender, - ); - let ticket = self.connect_attempts.begin(); - let generation = ticket.generation(); - let cancel = ticket.cancel().clone(); - let retry = ConnectRetry { - prefill: ConnectPrefill { - connection: pending_save.connection.clone(), - password: typed_password(&request.target), - }, - remedy: None, - }; - let connector = self.context.connector.clone(); - let interaction = self.context.attended(); - let secrets = self.context.secrets.clone(); - let writes = StoreConnectionWrites::new(self.storage.connections().clone(), self.tasks.clone()); - let connecting = async move { - connect_flow::connect_then_save( - &*connector, - request, - interaction, - cancel, - &*secrets, - &writes, - pending_save, - ) - .await - }; - let context = self.context.clone(); - let settings = self.settings.clone(); - crate::runtime::spawn_and_deliver(&self.tasks, connecting, move |outcome| { - let failed = |message: String, retry: ConnectRetry| AppMsg::ConnectFailed { - generation, - message: Some(message), - edit: Some(Box::new(retry)), - }; - let message = match outcome { - Ok(Ok((pending, report))) => { - // The dialog opens on this driver next time. - if let Err(error) = settings.set_connect_dialog_driver(&report.saved.driver_id) { - tracing::warn!(%error, "could not remember the connect dialog driver"); - } - AppMsg::SessionReady(Box::new(ReadySession { - key: SessionKey::Saved(report.saved.id), - credentials: Arc::new(context.keyring_credentials(&report.saved)), - pending, - // A keyring refusal must not be silent: the - // connection opened, but the password will be - // asked for next time. - warnings: report - .secret_failures - .iter() - .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) - .collect(), - generation: Some(generation), - })) - } - Ok(Err(SubmitError::Connect(ConnectError::Cancelled))) => AppMsg::ConnectFailed { - generation, - message: None, - edit: None, - }, - Ok(Err(SubmitError::Connect(error))) => failed( - crate::ui::error_text::connect_error_message(&error), - ConnectRetry { - remedy: crate::ui::error_text::connect_remedy(&error), - ..retry - }, - ), - Ok(Err(SubmitError::Save(error))) => failed( - crate::i18n::gettext_f( - "The connection could not be saved: {error}", - &[("error", &error.to_string())], - ), - retry, - ), - Err(failure) => failed(crate::ui::error_text::task_failure_message(&failure), retry), - }; - sender.deliver(message); - }); - } - - pub(super) fn on_cancel_connect(&mut self) { - stop_connecting(&self.connect_attempts); - self.dismiss_loading_page(); - } - - pub(super) fn on_edit_connection(&mut self, retry: ConnectRetry, sender: ComponentSender) { - let focus = retry.remedy.map(crate::ui::connect_dialog::remedy_field); - self.present_connect_dialog(Some(retry.prefill), focus, sender); - } - - /// Adopt a session that connected: claim its key, start it, and - /// build the workspace on its catalog. - pub(super) fn on_session_ready(&mut self, ready: ReadySession, sender: ComponentSender) { - if ready - .generation - .is_some_and(|generation| !self.connect_attempts.accepts(generation)) - { - self.discard_pending(ready.pending); - return; - } - self.dismiss_loading_page(); - if self.session_links.as_ref().is_some_and(|links| links.key == ready.key) { - self.discard_pending(ready.pending); - self.show_toast(&crate::i18n::gettext("This connection is already open.")); - return; - } - // One window shows one database, so the one open now goes first. - // The switch asked before it connected; this asks again about - // anything edited or begun while it connected. - if self.context.active.is_connected() { - if !self.close_guards.may_tear_down(&self.workspace_handle) { - self.close_guards - .ask_tear_down(self.window.upcast_ref(), &self.workspace_handle, move |decision| { - sender.deliver(match decision { - CloseDecision::Approved => AppMsg::SessionReady(Box::new(ready)), - CloseDecision::Cancelled => AppMsg::SessionDeclined(Box::new(ready)), - }); - }); - return; - } - self.release_session(sender.clone()); - } - let ReadySession { - key, - pending, - credentials, - warnings, - generation: _, - } = ready; - let claim = match self.context.sessions.claim(key.clone(), self.window.id()) { - Ok(claim) => claim, - Err(ClaimError::AlreadyOpen { window_id }) => { - self.discard_pending(pending); - if !self.host.present_window(window_id) { - self.show_toast(&crate::i18n::gettext( - "This connection is already open in another window.", - )); - } - return; - } - }; - let driver_id = pending.metadata().driver_id.clone(); - let session = Session::start(pending, self.context.session_deps(credentials)); - claim.attach(&session); - self.context.active.replace(session.clone()); - - let first = session.subscribe_objects(ObjectScope::DefaultSchema).borrow().clone(); - let health_sender = sender.clone(); - let health_origin = Arc::downgrade(&session); - let catalog_sender = sender.clone(); - self.session_links = Some(SessionLinks { - key, - _claim: claim, - _health: WatchForwarder::new( - session.health(), - move |health| { - health_sender.deliver(AppMsg::HealthChanged(FromSession::new(health_origin.clone(), health))); - }, - spawn_local, - ), - _catalog_watch: catalog_watch(&session, move |update| { - catalog_sender.deliver(AppMsg::CatalogChanged(update)); - }), - }); - for warning in warnings { - self.show_toast(&warning); - } - self.dialog = None; - self.on_connected(first.objects.to_vec(), driver_id, sender); - } - - /// A connect failed. A cancelled one returns to the list without an - /// error: the user is the one who stopped it. Otherwise the alert - /// offers to edit what was connected to, starting at the setting - /// that fixes a TLS failure when there is one. - pub(super) fn on_connect_failed( - &mut self, - generation: AttemptGeneration, - message: Option, - edit: Option>, - sender: ComponentSender, - ) { - if !self.connect_attempts.accepts(generation) { - return; - } - self.dismiss_loading_page(); - let Some(message) = message else { - return; - }; - tracing::warn!(error = %message, "connect failed"); - let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Could Not Connect")), Some(&message)); - dialog.add_response("close", &crate::i18n::gettext("Close")); - dialog.set_close_response("close"); - dialog.set_default_response(Some("close")); - if let Some(retry) = edit { - let label = retry - .remedy - .map_or_else(|| crate::i18n::gettext("Edit Connection…"), |remedy| remedy.label()); - dialog.add_response("edit", &label); - dialog.set_default_response(Some("edit")); - let retry = std::cell::RefCell::new(Some(retry)); - dialog.connect_response(Some("edit"), move |_, _| { - if let Some(retry) = retry.borrow_mut().take() { - sender.deliver(AppMsg::EditConnection(retry)); - } - }); - } - dialog.present(Some(&self.window)); - } - - /// Throw away a connection nobody adopted, closing it under the - /// close deadline while the process stays alive for it. - /// The user kept the tabs a switch would have closed. - pub(super) fn on_session_declined(&self, ready: ReadySession) { - self.discard_pending(ready.pending); - } - - fn discard_pending(&self, pending: PendingSession) { - let hold = self.context.hold.acquire(); - let closing = self.context.tasks.spawn_task(pending.discard()); - glib::spawn_future_local(async move { - if let Ok(report) = closing.await { - tracing::debug!(?report, "closed a connection that was not adopted"); - } - drop(hold); - }); - } - - fn on_connected(&mut self, objects: Vec, driver_id: String, sender: ComponentSender) { - self.dismiss_loading_page(); - self.dialog = None; - self.connected = true; - self.current_driver_id = Some(driver_id.clone()); - self.read_only = self.context.active.read_only(); - self.read_only_badge.set_visible(self.read_only); - self.reconnect_banner.set_revealed(false); - self.split_view.set_show_sidebar(true); - self.disconnect_action.set_enabled(true); - self.table_search.set_text(""); - // Build the unified workspace tab tree (Browse + Editor share one - // strip). Empty state shows "Select a table" until the user opens - // a tab via sidebar click or Ctrl+T. - self.ensure_workspace_root(sender.clone()); - self.content_holder.set_content(Some(&self.workspace_outer_stack)); - tracing::info!(driver = %driver_id, table_count = objects.len(), "workspace ready"); - self.show_objects(&objects); - self.refresh_window_title(); - if let Some(key) = self.session_key().cloned() { - self.restore_workspace_tabs(key, sender.clone()); - } - if let Some(connection_id) = self.context.active.metadata().map(|metadata| metadata.id) { - // Stamp `last_opened_at = now()` then reload connections so - // the popover + welcome view re-sort with the fresh - // timestamp. Sequencing matters: ReloadConnections reads - // the JSON; firing it before the touch lands would render - // the previous ordering until the next reload. - let sender_for_touch = sender.clone(); - let connections = self.storage.connections().clone(); - let touching = self - .tasks - .spawn_blocking_task(move || connections.touch_last_opened_blocking(connection_id)); - glib::spawn_future_local(async move { - match touching.await { - Ok(Err(error)) => tracing::warn!(%error, "could not stamp the last-opened time"), - Err(error) => tracing::warn!(%error, "the last-opened task failed"), - Ok(Ok(())) => {} - } - sender_for_touch.deliver(AppMsg::ReloadConnections); - }); - return; - } - sender.deliver(AppMsg::ReloadConnections); - } - - /// The key this window's session is claimed under, which its saved - /// tabs are kept by. - pub(super) fn session_key(&self) -> Option<&SessionKey> { - self.session_links.as_ref().map(|links| &links.key) - } - - pub(super) fn on_disconnect(&mut self, sender: ComponentSender) { - self.close_workspace_then(AppMsg::ForceDisconnect, sender); - } - - pub(super) fn do_disconnect(&mut self, sender: ComponentSender) { - self.release_session(sender.clone()); - self.split_view.set_show_sidebar(false); - self.disconnect_action.set_enabled(false); - self.refresh_window_title(); - self.show_welcome_page(sender); - tracing::info!("disconnected"); - } - - /// Close the workspace and shut the session down. The process is - /// held until the close ends, so quitting right after a disconnect - /// does not cut the connection off mid-close. - fn release_session(&mut self, _sender: ComponentSender) { - // The tabs are saved under the session's key, which goes with - // the session links below. - self.teardown_workspace_tabs(); - // The closed tabs name tables of the connection being released. - self.clear_closed_tabs_stack(); - self.session_links = None; - close_session(&self.context.active, &*self.context.hold); - self.reconnect_banner.set_revealed(false); - self.current_driver_id = None; - self.read_only = false; - self.read_only_badge.set_visible(false); - self.connected = false; - self.table_search.set_text(""); - self.sidebar_schemas.borrow_mut().clear(); - self.sidebar_factory.guard().clear(); - } - - /// The banner's Retry: the only thing that wakes a parked session. - pub(super) fn on_retry_connection(&self) { - if let Some(session) = self.context.active.get() { - session.signal(MonitorSignal::RetryNow); - } - } - - pub(super) fn on_reload_connections(&self, sender: ComponentSender) { - let store = self.storage.connections().clone(); - let reading = self.tasks.spawn_blocking_task(move || store.load_blocking()); - glib::spawn_future_local(async move { - match reading.await { - Ok(Ok(connections)) => sender.deliver(AppMsg::ConnectionsLoaded(connections.to_vec())), - Ok(Err(error)) => { - tracing::warn!(%error, "the saved connections could not be read"); - sender.deliver(AppMsg::ConnectionListUnavailable); - } - Err(failure) => tracing::warn!(%failure, "the connections read task failed"), - } - }); - } - - pub(super) fn on_connections_loaded(&mut self, connections: &[SavedConnection], sender: ComponentSender) { - self.saved_connections = connections.to_vec(); - // The popover shows the same list as the welcome page, so it - // sorts the same way and reads the same group headers. - crate::ui::connection_list::sort(&mut self.saved_connections); - let groups = crate::ui::connection_list::groups(&self.saved_connections); - let mut guard = self.connections_factory.guard(); - guard.clear(); - for saved in &self.saved_connections { - guard.push_back(crate::ui::connection_row::ConnectionRowInit { - saved: saved.clone(), - groups: groups.clone(), - }); - } - drop(guard); - crate::ui::connection_list::install_group_headers( - &self.connections_listbox, - std::rc::Rc::new(self.saved_connections.clone()), - ); - let _ = self - .welcome_view - .sender() - .send(crate::ui::welcome_view::WelcomeViewInput::SetConnections( - self.saved_connections.clone(), - )); - if !self.connected { - self.show_welcome_page(sender); - } - } - - pub(super) fn on_delete_connection(&self, id: Uuid, sender: ComponentSender) { - // The delete takes the keyring secrets with it and has no undo, - // so it asks whatever the preferences say. - let connection_name = self - .saved_connections - .iter() - .find(|s| s.id == id) - .map(|s| s.name.clone()) - .unwrap_or_else(|| crate::i18n::gettext("this connection")); - let title = crate::i18n::gettext_f("Delete {name}?", &[("name", &connection_name)]); - let body = crate::i18n::gettext( - "The saved entry, any stored passwords and the queries saved under this connection \ - will be removed. This cannot be undone.", - ); - let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("delete", &crate::i18n::gettext("Delete")); - dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - - let sender_for_response = sender; - let connections_for_response = self.storage.connections().clone(); - let secrets_for_response = self.storage.secrets().clone(); - let column_widths = self.storage.column_widths().clone(); - let filter_settings = self.storage.filter_settings().clone(); - let tasks_for_response = self.tasks.clone(); - let saved_queries_for_response = self.history.store().map(|history| history.saved_queries()); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); - if response != "delete" { - return; - } - // Nothing can reach these once the entry is gone, so they - // go with it rather than accumulating in the state files. - column_widths.forget_connection(id); - filter_settings.forget_connection(id); - execute_delete_connection( - connections_for_response.clone(), - secrets_for_response.clone(), - tasks_for_response.clone(), - saved_queries_for_response.clone(), - id, - sender_for_response.clone(), - ); - }); - dialog.present(Some(&self.window)); - } - - /// Copy a saved connection so a second database on the same server - /// does not have to be typed out again. - /// - /// The copy takes a new id, so it carries its own secrets rather - /// than sharing the original's: removing either then leaves the - /// other able to connect. - pub(super) fn on_duplicate_connection(&self, source: SavedConnection, sender: ComponentSender) { - self.connections_popover.popdown(); - let copy = tablepro_storage::duplicate(&source, &self.saved_connections); - let name = copy.name.clone(); - let connections = self.storage.connections().clone(); - let secrets = self.storage.secrets().clone(); - let tasks = self.tasks.clone(); - let spawner = self.tasks.clone(); - let sender_clone = sender.clone(); - drop(spawner.spawn_task(async move { - let copy_id = copy.id; - let saved = tasks - .spawn_blocking_task(move || connections.upsert_blocking(copy)) - .await; - if let Ok(Err(error)) = saved { - tracing::warn!(%error, "could not save the duplicated connection"); - sender_clone.deliver(AppMsg::ShowToast(crate::i18n::gettext( - "The connection could not be duplicated.", - ))); - return; - } - // A copy with no password still connects once the - // user types one, so a keyring that refuses is - // reported rather than undoing the copy. - let carried = copy_secrets(secrets.as_ref(), source.id, copy_id, &name).await; - sender_clone.deliver(AppMsg::ReloadConnections); - sender_clone.deliver(AppMsg::ShowToast(if carried { - crate::i18n::gettext_f("Duplicated as {name}", &[("name", &name)]) - } else { - crate::i18n::gettext_f( - "Duplicated as {name}. Its password could not be copied, so enter it again.", - &[("name", &name)], - ) - })); - })); - } - - /// File a connection under a group, or take it out of the one it - /// is in. An empty name means no group. - pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option, sender: ComponentSender) { - let group = group.map(|name| name.trim().to_owned()).filter(|name| !name.is_empty()); - let Some(mut saved) = self.saved_connections.iter().find(|saved| saved.id == id).cloned() else { - return; - }; - if saved.group == group { - return; - } - saved.group = group; - self.write_connection(saved, crate::i18n::gettext("The group could not be saved."), sender); - } - - /// Put a colour on a connection, or take the one it has off. - /// - /// The whole entry is rewritten because that is what the store - /// takes, and it comes from the list the app already holds rather - /// than from a re-read: the row that asked is showing that entry. - pub(super) fn on_set_connection_color( - &self, - id: Uuid, - color: Option, - sender: ComponentSender, - ) { - let Some(mut saved) = self.saved_connections.iter().find(|saved| saved.id == id).cloned() else { - return; - }; - if saved.color == color { - return; - } - saved.color = color; - self.write_connection(saved, crate::i18n::gettext("The colour could not be saved."), sender); - } - - /// Write one connection back and reload the list from disk, so both - /// connection lists redraw from what was actually saved. - fn write_connection(&self, saved: SavedConnection, failure: String, sender: ComponentSender) { - let connections = self.storage.connections().clone(); - let tasks = self.tasks.clone(); - let spawner = self.tasks.clone(); - let sender_clone = sender.clone(); - drop(spawner.spawn_task(async move { - let written = tasks - .spawn_blocking_task(move || connections.upsert_blocking(saved)) - .await; - if let Ok(Err(error)) = written { - tracing::warn!(%error, "could not save the connection"); - sender_clone.deliver(AppMsg::ShowToast(failure)); - } - sender_clone.deliver(AppMsg::ReloadConnections); - })); - } - - /// Write the connection list to a file the user picks. - /// - /// The file carries no passwords, and the dialog's name says what - /// it does carry, because it is going somewhere the keyring does - /// not protect. - pub(super) fn on_export_connections(&self, sender: ComponentSender) { - self.connections_popover.popdown(); - let connections = self.storage.connections().clone(); - let reading = self.tasks.spawn_blocking_task(move || connections.export_blocking()); - let window = self.window.downgrade(); - let input = sender.input_sender().clone(); - glib::spawn_future_local(async move { - let error = match reading.await { - Ok(Ok(bytes)) => { - if let Some(window) = window.upgrade() { - ask_where_to_export(&window, bytes, input); - } - return; - } - Ok(Err(error)) => error.to_string(), - Err(error) => error.to_string(), - }; - tracing::warn!(%error, "could not read the connections to export"); - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext( - "The connections could not be read.", - ))); - }); - } - - /// Ask for an exported file and read it off the GTK thread. - pub(super) fn on_import_connections(&self, sender: ComponentSender) { - self.connections_popover.popdown(); - let dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Import Connections")) - .modal(true) - .default_filter(&json_filter()) - .filters(&json_filters()) - .build(); - let input = sender.input_sender().clone(); - dialog.open(Some(&self.window), gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome else { return }; - let input = input.clone(); - glib::spawn_future_local(async move { - let path = file.path().unwrap_or_default(); - let _ = input.send(match read_connections_file(&file).await { - Ok(bytes) => AppMsg::ConnectionsFileRead { path, bytes }, - Err(message) => AppMsg::ShowToast(message), - }); - }); - }); - } - - pub(super) fn on_connections_file_read( - &self, - path: std::path::PathBuf, - bytes: Vec, - sender: ComponentSender, - ) { - let connections = self.storage.connections().clone(); - let tasks = self.tasks.clone(); - let spawner = self.tasks.clone(); - let sender_clone = sender.clone(); - drop(spawner.spawn_task(async move { - let imported = tasks - .spawn_blocking_task(move || connections.import_blocking(&path, &bytes)) - .await; - let message = match imported { - Ok(Ok(report)) if report.total() == 0 => crate::i18n::gettext("That file has no connections in it."), - Ok(Ok(report)) => crate::i18n::ngettext_f( - "Imported {n} connection.", - "Imported {n} connections.", - report.total() as u32, - &[("n", &report.total().to_string())], - ), - Ok(Err(error)) => { - tracing::warn!(%error, "could not import the connections"); - crate::i18n::gettext("That file is not a TablePro connection export.") - } - Err(error) => { - tracing::warn!(%error, "the import task did not finish"); - crate::i18n::gettext("The connections could not be imported.") - } - }; - sender_clone.deliver(AppMsg::ReloadConnections); - sender_clone.deliver(AppMsg::ShowToast(message)); - })); - } - - pub(super) fn on_open_saved(&mut self, saved: SavedConnection, sender: ComponentSender) { - self.connections_popover.popdown(); - let key = SessionKey::Saved(saved.id); - if self.session_links.as_ref().is_some_and(|links| links.key == key) { - self.show_toast(&crate::i18n::gettext("This connection is already open.")); - return; - } - // One window per connection: the one that has it comes forward. - if let Some(owner) = self.context.sessions.window_for(&key) - && owner != self.window.id() - && self.host.present_window(owner) - { - return; - } - if self.context.active.is_connected() { - self.close_workspace_then(AppMsg::OpenSavedConfirmed(saved), sender); - return; - } - self.connect_saved(saved, sender); - } - - pub(super) fn connect_saved(&mut self, saved: SavedConnection, sender: ComponentSender) { - let key = SessionKey::Saved(saved.id); - self.set_loading_page( - &crate::i18n::gettext("Connecting…"), - &crate::i18n::gettext_f("Opening {name}", &[("name", &saved.name)]), - &sender, - ); - let ticket = self.connect_attempts.begin(); - let generation = ticket.generation(); - let cancel = ticket.cancel().clone(); - let drivers = self.context.drivers.clone(); - let connector = self.context.connector.clone(); - let interaction = self.context.attended(); - let credentials = self.context.keyring_credentials(&saved); - let source: Arc = Arc::new(credentials.clone()); - let retry = ConnectRetry { - prefill: ConnectPrefill { - connection: saved.clone(), - password: None, - }, - remedy: None, - }; - // Reading the keyring, asking for what it lacks and connecting - // all happen off the GTK thread; only the outcome comes back. - let connecting = async move { - connect_flow::connect_saved(&saved, &drivers, &credentials, &*connector, interaction, cancel).await - }; - crate::runtime::spawn_and_deliver(&self.tasks, connecting, move |outcome| match outcome { - Ok(Ok((pending, refused))) => sender.deliver(AppMsg::SessionReady(Box::new(ReadySession { - key, - pending, - credentials: source, - warnings: refused - .iter() - .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) - .collect(), - generation: Some(generation), - }))), - Ok(Err(ConnectError::Cancelled)) => sender.deliver(AppMsg::ConnectFailed { - generation, - message: None, - edit: None, - }), - Ok(Err(error)) => sender.deliver(AppMsg::ConnectFailed { - generation, - message: Some(crate::ui::error_text::connect_error_message(&error)), - edit: Some(Box::new(ConnectRetry { - remedy: crate::ui::error_text::connect_remedy(&error), - ..retry - })), - }), - Err(failure) => sender.deliver(AppMsg::ConnectFailed { - generation, - message: Some(crate::ui::error_text::task_failure_message(&failure)), - edit: Some(Box::new(retry)), - }), - }); - } - - pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { - self.repopulate_sidebar(browsable(objects.to_vec())); - } - - /// Only what the grid can page through goes in the sidebar. - fn repopulate_sidebar(&mut self, objects: Vec) { - { - let mut schemas = self.sidebar_schemas.borrow_mut(); - schemas.clear(); - schemas.extend(objects.iter().map(|object| object.schema.clone())); - } - let mut guard = self.sidebar_factory.guard(); - guard.clear(); - for object in objects { - guard.push_back(object); - } - drop(guard); - self.sidebar_factory.widget().invalidate_headers(); - } - - /// Surfaces session health via `adw::Banner` only when degraded: - /// GNOME apps reserve banners for abnormal, user-actionable states. - pub(super) fn refresh_health_banner(&self, health: &SessionHealth) { - match health_banner_text(health) { - Some(title) => { - self.reconnect_banner.set_title(&title); - self.reconnect_banner.set_revealed(true); - } - None => self.reconnect_banner.set_revealed(false), - } - } - - pub(super) fn refresh_window_title(&self) { - // The table is in the tab title already, so the subtitle leaves - // it out, as GNOME Builder's names only the branch. - let metadata = self.context.active.metadata(); - let connection_name = metadata.as_ref().map(|m| m.name.as_str()); - let active = self.selected_browse_slot_table(); - let table_pair = active.as_ref().map(|(s, t)| (s.as_deref(), t.as_str())); - let (mut os_title, subtitle) = match (connection_name, &self.current_driver_id, table_pair) { - (Some(name), Some(driver), Some((schema, table))) => { - let label = qualified_label(schema, table); - (format!("{label} · {name} — TablePro"), format!("{name} · {driver}")) - } - (Some(name), Some(driver), None) => (format!("{name} — TablePro"), format!("{name} · {driver}")), - (None, Some(driver), _) => (format!("{driver} — TablePro"), driver.clone()), - _ => ("TablePro".to_string(), String::new()), - }; - // GNOME Text Editor convention: prefix the OS-level window - // title with "• " when any open document has unsaved changes, - // so the dirty state is visible from the Activities overview / - // Alt-Tab without needing the tab to be focused. - if !self.dirty_tabs().is_empty() { - os_title = format!("• {os_title}"); - } - self.window.set_title(Some(&os_title)); - self.window_title.set_subtitle(&subtitle); - - // The sidebar's header has no subtitle: the main header already - // names the driver. - match connection_name { - Some(name) => { - self.sidebar_title.set_title(name); - } - None => { - self.sidebar_title.set_title(&crate::i18n::gettext("Tables")); - } - } - } -} - -/// Supersede the connect in flight, not only cancel it: a driver -/// blocked in a socket call can still connect afterwards, and that late -/// session must not be adopted. -fn stop_connecting(attempts: &ConnectAttempts) { - drop(attempts.begin()); - attempts.cancel(); -} - -/// Shut the window's session down and close its connection under the -/// close deadline. The process is held until the close ends, so the -/// server sees the connection closed rather than cut off. -fn close_session(active: &ActiveSession, hold: &dyn ProcessHold) { - let Some(session) = active.take() else { - return; - }; - let hold = hold.acquire(); - let closing = session.begin_shutdown(); - glib::spawn_future_local(async move { - match closing.await { - Ok(report) => tracing::debug!(?report, "the session closed"), - Err(failure) => tracing::warn!(%failure, "the session close task failed"), - } - drop(hold); - }); -} - -/// What a closing window still owes its connection: no connect left to -/// land in it, and the session closed rather than dropped. -/// -/// The window's close request is where this has to start. relm4 calls -/// a detached root's `shutdown` only after the main loop has returned, -/// when no close can run any more and a hold no longer keeps the -/// process alive. -pub(super) fn close_window_session(active: &ActiveSession, hold: &dyn ProcessHold, attempts: &ConnectAttempts) { - stop_connecting(attempts); - close_session(active, hold); -} - -/// Move every secret the original holds onto the copy's own id. -/// -/// Returns whether all of them made it: a keyring that is locked or -/// absent is a reason to tell the user, not to refuse the copy. -async fn copy_secrets( - secrets: &dyn tablepro_core::credentials::SecretVault, - from: Uuid, - to: Uuid, - label: &str, -) -> bool { - use tablepro_core::credentials::{SecretKind, SecretLookup}; - - let mut carried = true; - for kind in SecretKind::ALL { - match secrets.load(from, kind).await { - Ok(SecretLookup::Found(secret)) => { - if let Err(error) = secrets.store(to, kind, &secret, label).await { - tracing::warn!(%error, ?kind, "could not copy a secret onto the duplicate"); - carried = false; - } - } - // Nothing stored for this kind, so nothing to carry. - Ok(SecretLookup::NotStored) => {} - Err(error) => { - tracing::warn!(%error, ?kind, "could not read a secret to duplicate"); - carried = false; - } - } - } - carried -} - -fn execute_delete_connection( - connections: tablepro_storage::ConnectionStore, - secrets: std::sync::Arc, - tasks: tablepro_session::runtime::Tasks, - saved_queries: Option, - id: Uuid, - sender: ComponentSender, -) { - let sender_clone = sender.clone(); - let spawner = tasks.clone(); - drop(spawner.spawn_task(async move { - // Secrets first: an entry with no secrets is recoverable, - // a secret with no entry is orphaned in the keyring. - if let Err(error) = secrets.delete_connection(id).await { - // Keep the entry: an entry with no secrets can be - // fixed, a secret with no entry is orphaned. - tracing::warn!(%error, "could not delete the connection secrets; keeping the entry"); - sender_clone.deliver(AppMsg::SecretDeleteFailed(id)); - return; - } - let removed = tasks.spawn_blocking_task(move || connections.remove_blocking(id)).await; - if let Ok(Err(error)) = removed { - tracing::warn!(%error, "could not remove the saved connection"); - } - // The queries saved here name tables only this database - // has, so they go with it. A failure leaves them listed - // under the connection's last name, which the user can - // still delete one by one. - if let Some(saved_queries) = saved_queries - && let Err(error) = saved_queries.delete_for_connection(id).await - { - tracing::warn!(%error, "could not remove the connection's saved queries"); - } - sender_clone.deliver(AppMsg::ReloadConnections); - })); -} - -impl App { - /// The list could not be read, so the user is told rather than shown - /// an empty welcome view that looks like their connections vanished. - pub(super) fn show_connection_list_banner(&self) { - let Some(content) = - crate::ui::connection_list_banner::banner_content(&self.storage.connections().snapshot().state) - else { - self.connection_list_banner.set_revealed(false); - return; - }; - self.connection_list_banner.set_title(&content.title); - self.connection_list_banner - .set_button_label(Some(&content.button_label)); - self.connection_list_banner.set_revealed(true); - } - - /// The keyring refused, so the entry stays and the user is offered - /// another go rather than being left with orphaned secrets. - pub(super) fn on_secret_delete_failed(&self, id: Uuid, sender: ComponentSender) { - let toast = adw::Toast::new(&crate::i18n::gettext( - "The stored passwords could not be deleted, so the connection was kept.", - )); - toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); - let retry_sender = sender; - toast.connect_button_clicked(move |_| { - retry_sender.deliver(AppMsg::DeleteConnection(id)); - }); - self.toast_overlay.add_toast(toast); - } - - pub(super) fn on_reset_connection_list(&self, sender: ComponentSender) { - let dialog = adw::AlertDialog::new( - Some(&crate::i18n::gettext("Reset Saved Connections?")), - Some(&crate::i18n::gettext( - "The unreadable file will be renamed so it can be recovered. Stored passwords stay in the keyring.", - )), - ); - dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); - dialog.add_response("reset", &crate::i18n::gettext("Reset")); - dialog.set_response_appearance("reset", adw::ResponseAppearance::Destructive); - dialog.set_default_response(Some("cancel")); - dialog.set_close_response("cancel"); - - let store = self.storage.connections().clone(); - let tasks = self.tasks.clone(); - let banner = self.connection_list_banner.clone(); - let toasts = self.toast_overlay.clone(); - dialog.connect_response(None, move |dialog, response| { - dialog.close(); - if response != "reset" { - return; - } - let store = store.clone(); - let resetting = - tasks.spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); - let (banner, toasts, sender) = (banner.clone(), toasts.clone(), sender.clone()); - glib::spawn_future_local(async move { - let error = match resetting.await { - Ok(Ok(moved)) => { - banner.set_revealed(false); - toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( - &moved, - ))); - sender.deliver(AppMsg::ReloadConnections); - return; - } - Ok(Err(error)) => error.to_string(), - Err(error) => error.to_string(), - }; - tracing::warn!(%error, "could not reset the saved connections"); - toasts.add_toast(adw::Toast::new(&crate::i18n::gettext_f( - "Could not reset the saved connections: {error}", - &[("error", &error)], - ))); - }); - }); - dialog.present(Some(&self.window)); - } -} - -fn ask_where_to_export(window: &adw::ApplicationWindow, bytes: Vec, input: relm4::Sender) { - let dialog = gtk::FileDialog::builder() - .title(crate::i18n::gettext("Export Connections")) - .modal(true) - .initial_name("tablepro-connections.json") - .default_filter(&json_filter()) - .filters(&json_filters()) - .build(); - dialog.save(Some(window), gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome else { return }; - glib::spawn_future_local(async move { - let written = file - .replace_contents_future(bytes, None, false, gio::FileCreateFlags::NONE) - .await; - let message = match written { - Ok(_) => crate::i18n::gettext("Connections exported."), - Err((_, error)) => { - tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); - crate::i18n::gettext("The connections could not be written to that file.") - } - }; - let _ = input.send(AppMsg::ShowToast(message)); - }); - }); -} - -/// An export holds names, hosts and options, never secrets, so even -/// thousands of connections come to well under this. -const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; - -/// The chosen export, whole, or what to tell the user instead. -async fn read_connections_file(file: &gio::File) -> Result, String> { - read_capped_file(file, MAX_CONNECTIONS_FILE_BYTES) - .await - .map_err(|error| match error { - FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), - FileReadError::TooLarge => crate::i18n::gettext("That file is too large to be a connection export."), - FileReadError::Io(error) => { - tracing::warn!(%error, "could not read the file to import"); - crate::i18n::gettext("That file could not be read.") - } - }) -} - -/// The filter both file dialogs open with. Built fresh each time -/// because a GtkFileFilter belongs to the dialog it is given to. -fn json_filter() -> gtk::FileFilter { - let filter = gtk::FileFilter::new(); - filter.set_name(Some(&crate::i18n::gettext("JSON files"))); - filter.add_suffix("json"); - filter.add_mime_type("application/json"); - filter -} - -fn json_filters() -> gio::ListStore { - let filters = gio::ListStore::new::(); - filters.append(&json_filter()); - filters -} - -/// Only what the grid can page through: routines and sequences have no -/// rows to show. -fn browsable(objects: Vec) -> Vec { - objects - .into_iter() - .filter(|object| object.kind.is_browsable()) - .collect() -} - -/// The banner's sentence for a session that needs attention, `None` -/// while it is online. -fn health_banner_text(health: &SessionHealth) -> Option { - match health { - SessionHealth::Online => None, - SessionHealth::Reconnecting { attempt } => Some(crate::i18n::gettext_f( - "Connection lost. Reconnecting (attempt {n}).", - &[("n", &attempt.to_string())], - )), - SessionHealth::Offline => Some(crate::i18n::gettext( - "The server cannot be reached. TablePro will keep checking.", - )), - SessionHealth::Failed { cause } => Some(failed_banner_text(*cause)), - } -} - -fn failed_banner_text(cause: tablepro_session::FailureCause) -> String { - use tablepro_session::FailureCause; - match cause { - FailureCause::Authentication => { - crate::i18n::gettext("The server refused the login. Retry to enter the password again.") - } - FailureCause::Tls => crate::i18n::gettext("The secure connection failed. Check the connection's TLS settings."), - FailureCause::Configuration => { - crate::i18n::gettext("The connection settings no longer work. Check them and retry.") - } - FailureCause::HostKeyUnknown => crate::i18n::gettext("The SSH server's host key is not trusted yet."), - FailureCause::HostKeyChanged => crate::i18n::gettext( - "The SSH server's host key changed. Check with its administrator before connecting again.", - ), - FailureCause::HostKeyRevoked => crate::i18n::gettext("The SSH server's host key was revoked."), - } -} - -#[cfg(test)] -mod tests { - use std::cell::RefCell; - use std::collections::VecDeque; - use std::rc::Rc; - use std::time::Duration; - - use tablepro_core::LivenessPolicy; - use tablepro_core::meta::ObjectKind; - use tablepro_session::fakes::{FakeConnector, FakeCredentialSource}; - use tablepro_session::operation::DefaultDeadlines; - use tablepro_session::runtime::CountingHold; - use tablepro_session::{FailureCause, SessionDeps}; - use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; - - use super::*; - use crate::test_support::FakeSession; - - fn read(path: &std::path::Path) -> Result, String> { - let file = gio::File::for_path(path); - crate::test_support::finish_on_main( - Duration::from_secs(10), - async move { read_connections_file(&file).await }, - ) - .expect("the read to end") - } - - #[gtk4::test] - fn an_import_from_a_pipe_is_refused_without_waiting_for_a_writer() { - let root = tempfile::tempdir().expect("tempdir"); - let path = root.path().join("connections.json"); - let made = std::process::Command::new("mkfifo") - .arg(&path) - .status() - .expect("mkfifo"); - assert!(made.success()); - - let refused = read(&path).expect_err("a refusal"); - - assert!(refused.contains("regular files"), "{refused}"); - } - - #[gtk4::test] - fn an_import_past_the_limit_is_refused() { - let root = tempfile::tempdir().expect("tempdir"); - let path = root.path().join("connections.json"); - std::fs::write(&path, vec![b' '; MAX_CONNECTIONS_FILE_BYTES + 1]).expect("write"); - - let refused = read(&path).expect_err("a refusal"); - - assert!(refused.contains("too large"), "{refused}"); - } - - #[gtk4::test] - fn an_export_is_read_whole() { - let root = tempfile::tempdir().expect("tempdir"); - let path = root.path().join("connections.json"); - std::fs::write(&path, br#"{"connections":[]}"#).expect("write"); - - assert_eq!(read(&path).as_deref(), Ok(&br#"{"connections":[]}"#[..])); - } - - #[gtk4::test] - fn a_closing_window_closes_its_session_and_stops_its_connect() { - let runtime = crate::test_support::test_runtime(); - let (pending, connection) = FakeConnector::pending_session( - FakeConnector::request().expect("a request"), - ConnectionScript::default(), - ); - let session = Session::start( - pending, - SessionDeps { - connector: Arc::new(FakeConnector::new([])), - credentials: Arc::new(FakeCredentialSource::new()), - deadlines: Arc::new(DefaultDeadlines::new(LivenessPolicy::DESKTOP)), - tasks: runtime.tasks(), - prompter: None, - }, - ); - let active = ActiveSession::new(); - active.replace(session.clone()); - let hold = CountingHold::new(); - let attempts = ConnectAttempts::new(); - let connecting = attempts.begin(); - - close_window_session(&active, &hold, &attempts); - - assert!(connecting.is_cancelled(), "the connect in flight kept running"); - assert!( - !attempts.accepts(connecting.generation()), - "a late connect would still be adopted" - ); - assert!(!active.is_connected()); - assert_eq!(hold.live(), 1, "nothing kept the process alive for the close"); - crate::test_support::wait_until(Duration::from_secs(5), || hold.live() == 0).expect("the close to end"); - assert!(session.is_shut_down()); - assert!(connection.is_closed(), "the connection was cut off rather than closed"); - } - - fn names(snapshot: &ObjectListSnapshot) -> Vec { - snapshot.objects.iter().map(|object| object.name.clone()).collect() - } - - /// The window's input queue, holding what the catalog watch posted - /// until the window handles it. - type Queue = Rc>>>; - - fn post_to(queue: &Queue) -> impl FnMut(FromSession) + 'static { - let queue = queue.clone(); - move |update| queue.borrow_mut().push_back(update) - } - - /// Handle the queued lists the way the window's `CatalogChanged` does. - fn handle(queue: &Queue, active: &ActiveSession, sidebar: &mut Vec) { - let queued: Vec<_> = queue.borrow_mut().drain(..).collect(); - for update in queued { - if let Some(snapshot) = update.if_current(active) { - *sidebar = names(&snapshot); - } - } - } - - fn refresh_objects( - session: &Arc, - runtime: &crate::runtime::AppRuntime, - ) -> impl Future + use<> { - let session = session.clone(); - let refreshing = runtime - .tasks() - .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }); - async move { - let _ = refreshing.await; - } - } - - #[gtk4::test] - fn late_catalog_commit_after_disconnect_leaves_sidebar_untouched() { - let runtime = crate::test_support::test_runtime(); - let mut first = ConnectionScript::default(); - first.list_objects.always(FakeReply::Late { - after: Duration::from_millis(300), - outcome: Ok(vec![object("from_a", ObjectKind::Table)]), - }); - let a = FakeSession::start(first, [], runtime.tasks()); - let mut second = ConnectionScript::default(); - second - .list_objects - .always(FakeReply::ok(vec![object("from_b", ObjectKind::Table)])); - let b = FakeSession::start(second, [], runtime.tasks()); - let active = ActiveSession::new(); - active.replace(a.session.clone()); - let queue = Queue::default(); - let mut sidebar = Vec::new(); - let watching_a = catalog_watch(&a.session, post_to(&queue)); - let late = refresh_objects(&a.session, &runtime); - let counters = a.connection.counters(); - crate::test_support::wait_until(Duration::from_secs(5), || { - CallCounters::get(&counters.list_objects) == 1 - }) - .expect("A's list was asked for"); - - // Disconnect A and adopt B, in the order the window does. - drop(watching_a); - drop(a.session.begin_shutdown()); - active.replace(b.session.clone()); - let _watching_b = catalog_watch(&b.session, post_to(&queue)); - let refreshed_b = refresh_objects(&b.session, &runtime); - crate::test_support::finish_on_main(Duration::from_secs(5), async move { - refreshed_b.await; - late.await; - }) - .expect("both lists to land"); - crate::test_support::drain_main_context(); - handle(&queue, &active, &mut sidebar); - - assert_eq!( - names(&a.session.subscribe_objects(ObjectScope::DefaultSchema).borrow()), - ["from_a"], - "A's late list never landed, so the test proved nothing" - ); - assert_eq!(sidebar, ["from_b"]); - } - - #[gtk4::test] - fn a_list_queued_before_a_switch_is_not_shown() { - let runtime = crate::test_support::test_runtime(); - let mut first = ConnectionScript::default(); - first - .list_objects - .always(FakeReply::ok(vec![object("from_a", ObjectKind::Table)])); - let a = FakeSession::start(first, [], runtime.tasks()); - let b = FakeSession::start(ConnectionScript::default(), [], runtime.tasks()); - let active = ActiveSession::new(); - active.replace(a.session.clone()); - let queue = Queue::default(); - let mut sidebar = vec!["from_b".to_owned()]; - let watching_a = catalog_watch(&a.session, post_to(&queue)); - crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&a.session, &runtime)) - .expect("A's list to land"); - crate::test_support::wait_until(Duration::from_secs(5), || queue.borrow().len() == 1) - .expect("A's list was posted to the window"); - - // B's SessionReady was ahead of A's list in the queue. - drop(watching_a); - drop(a.session.begin_shutdown()); - active.replace(b.session.clone()); - handle(&queue, &active, &mut sidebar); - - assert_eq!(sidebar, ["from_b"], "A's list replaced B's sidebar"); - } - - fn object(name: &str, kind: ObjectKind) -> ObjectInfo { - ObjectInfo { - schema: Some("public".to_owned()), - name: name.to_owned(), - kind, - } - } - - #[test] - fn the_sidebar_lists_only_what_the_grid_can_page() { - let listed = browsable(vec![ - object("users", ObjectKind::Table), - object("active_users", ObjectKind::View), - object("next_id", ObjectKind::Sequence), - object("refresh", ObjectKind::Function), - ]); - - let names: Vec<&str> = listed.iter().map(|object| object.name.as_str()).collect(); - assert_eq!(names, ["users", "active_users"]); - } - - #[test] - fn the_banner_shows_only_when_the_session_needs_attention() { - assert_eq!(health_banner_text(&SessionHealth::Online), None); - let reconnecting = health_banner_text(&SessionHealth::Reconnecting { attempt: 3 }).expect("a banner"); - assert!(reconnecting.contains('3'), "{reconnecting}"); - assert!(health_banner_text(&SessionHealth::Offline).is_some()); - } - - #[test] - fn each_parked_cause_has_its_own_banner() { - let causes = [ - FailureCause::Authentication, - FailureCause::Tls, - FailureCause::Configuration, - FailureCause::HostKeyUnknown, - FailureCause::HostKeyChanged, - FailureCause::HostKeyRevoked, - ]; - - let texts: std::collections::HashSet = causes - .into_iter() - .map(|cause| health_banner_text(&SessionHealth::Failed { cause }).expect("a banner")) - .collect(); - - assert_eq!(texts.len(), causes.len()); - } -} diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs deleted file mode 100644 index a6f1ce5af2..0000000000 --- a/linux/crates/app/src/ui/app/mod.rs +++ /dev/null @@ -1,1493 +0,0 @@ -mod close_flow; -mod connect_flow; -mod connection; -mod import; -mod row_ops; -mod saves; -mod sidebar_section_header; -mod status_pages; -mod structure; -mod window_tabs; -mod workspace_persistence; -mod workspace_tabs; - -use relm4::adw::prelude::*; -use relm4::factory::FactoryVecDeque; -use relm4::gtk::{gio, glib}; -use relm4::prelude::*; -use relm4::{Controller, adw, gtk}; - -use tablepro_core::QueryResult; -use tablepro_session::{ObjectListSnapshot, SessionHealth}; -use tablepro_storage::SavedConnection; -use uuid::Uuid; - -use super::browse_tab::{BrowseTab, BrowseTabInput}; -use super::connect_dialog::ConnectDialog; -use super::connection_row::{ConnectionRow, ConnectionRowOutput}; -use super::editor::SqlEditor; -use super::history_dialog::HistoryDialog; -use super::sidebar_row::{SidebarRow, SidebarRowOutput}; -use super::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; -use crate::runtime::DeliverInput; -use crate::services::change_tracker_registry::ChangeTrackerRegistry; -use crate::services::from_session::FromSession; -use crate::services::session_context::SessionContext; -use crate::services::structure_tracker_registry::StructureTrackerRegistry; - -pub(crate) use connection::{ConnectRetry, ReadySession}; - -/// What the application hands each window it opens. -pub struct AppInit { - pub services: std::rc::Rc, - pub host: std::rc::Rc, - pub launch: crate::application::WindowLaunch, -} - -pub struct App { - context: std::rc::Rc, - /// Reaches the application's other windows. - host: std::rc::Rc, - /// The adopted session's claim and watchers. Dropping it releases - /// the registry key and stops the banner and sidebar updates. - session_links: Option, - /// Ends the credential prompt loop with the window. - prompt_presenter: glib::JoinHandle<()>, - /// Which connect attempt is current, so a late result is discarded. - /// Shared with the close handler, which stops the one in flight. - connect_attempts: std::rc::Rc, - settings: std::rc::Rc, - storage: crate::storage::SharedStorage, - tasks: tablepro_session::runtime::Tasks, - history: crate::services::history_service::HistoryService, - drafts: std::rc::Rc, - window: adw::ApplicationWindow, - split_view: adw::OverlaySplitView, - window_title: adw::WindowTitle, - sidebar_title: adw::WindowTitle, - disconnect_action: gio::SimpleAction, - /// Watches `HistoryService` and flips `win.show-history`. Aborted in - /// `shutdown` so it does not outlive the window it acts on. - history_gate: glib::JoinHandle<()>, - sidebar_factory: FactoryVecDeque, - sidebar_schemas: std::rc::Rc>>>, - content_holder: adw::ToolbarView, - toast_overlay: adw::ToastOverlay, - /// Persistent "Connecting…" toast handle. Held so we can dismiss it - /// when the connect attempt resolves (success or failure). Native - /// alternative to a fire-and-forget 2 s toast that disappeared - /// before the connection actually completed. - connect_progress_toast: Option, - reconnect_banner: adw::Banner, - connection_list_banner: adw::Banner, - connections_factory: FactoryVecDeque, - /// The popover list's own list box, kept so the group headers can - /// be reinstalled whenever the list changes. - connections_listbox: gtk::ListBox, - connections_popover: gtk::Popover, - row_op_spinner: adw::Spinner, - read_only_badge: gtk::Label, - table_search: gtk::SearchEntry, - /// `"empty"` while no tab is open, `"tabs"` otherwise. - workspace_outer_stack: gtk::Stack, - /// AdwTabOverview wrapping the unified AdwTabBar + AdwTabView. - /// Built lazily on connect; torn down on disconnect. - workspace_root: Option, - workspace_tab_view: Option, - /// Idempotency flag for `ensure_workspace_root`. - workspace_root_added: std::cell::Cell, - /// Per-tab state. Each entry is either a Browse or Editor tab. - /// HashMap for O(1) tab_id lookup; display order is read from - /// `tab_view.pages()` since HashMap is unordered. - workspace_tabs: std::rc::Rc>>, - /// This window's pending row edits, one tracker per Table tab. - change_trackers: std::rc::Rc, - /// This window's pending DDL, one tracker per Structure tab. - structure_trackers: std::rc::Rc, - /// Starts, stops and settles this window's saves, and says what - /// closing any part of it would lose. - workspace_handle: crate::workspace::WorkspaceHandle, - /// The tabs as the close prompts read them. - window_tabs: std::rc::Rc, - /// One close prompt at a time, for a tab, the window, a disconnect - /// or a switch. - close_guards: std::rc::Rc, - /// What closing the window still owes its files and connections. - window_shutdown: std::rc::Rc, - /// Holds logout back while something here would be lost. - inhibitor: crate::workspace::WindowInhibitor, - dialog: Option, - history_dialog: Option>, - saved_queries_dialog: Option>, - import_dialog: Option>, - welcome_view: Controller, - /// Driver id is connection-wide, not per-tab. - current_driver_id: Option, - /// Read-only flag is connection-wide; fanned out to every BrowseTab - /// when toggled. - read_only: bool, - /// Default page size for newly-opened browse tabs (from preferences). - /// Per-tab page size lives on each BrowseTab. - saved_connections: Vec, - connected: bool, - /// Writes the tab list for the session this window shows. Tabs fire - /// `WorkspaceTabsChanged` on every selection, drag-reorder, - /// page-size change and state-changed event, and it writes once - /// after a burst. - workspace: std::rc::Rc, - /// Recently closed tabs for Ctrl+Shift+T. Cleared on disconnect, - /// since the tables they name belong to that connection. Pending - /// edits are not kept: the close has already cleared them. - closed_tabs_stack: std::rc::Rc>>, -} - -/// Snapshot of a tab the user just closed, retained for Ctrl+Shift+T -/// reopen, so reopen routes back through the same `append_*` -/// constructors as a restore. -#[derive(Debug, Clone)] -pub enum ClosedTabDescriptor { - Editor { - query: String, - }, - Table { - schema: Option, - table: String, - offset: u64, - page_size: u64, - sort: Option<(usize, bool)>, - }, - Structure { - schema: Option, - table: String, - }, -} - -pub(super) const CLOSED_TABS_CAPACITY: usize = 10; - -pub struct EditorTabSlot { - pub editor: SqlEditor, - pub page: adw::TabPage, - /// The file this tab's text is written to. The text itself lives in - /// the buffer, not here. - pub draft: tablepro_storage::DraftId, -} - -pub struct StructureTabSlot { - pub id: Uuid, - pub structure: crate::ui::structure_tab::StructureTab, - pub page: adw::TabPage, - pub schema: Option, - /// Empty in `New` mode until SaveCompleted carries the canonical - /// table name back from the driver. Edit mode populates from the - /// sidebar click or restore record. - pub table: String, - pub mode: crate::ui::structure_tab::StructureMode, -} - -/// A tab showing one table's rows. The table's structure opens in a -/// tab of its own: one TabPage per surface, as GNOME Files and Builder -/// do. -pub struct TableTabSlot { - pub id: Uuid, - pub page: adw::TabPage, - pub schema: Option, - pub table: String, - pub browse: BrowseTab, -} - -/// A tab in the unified workspace. -/// -/// - **Table**: one (schema, table) entity in Data (Browse grid) -/// view. Default for every sidebar single-click. -/// - **Editor**: a free-form SQL workspace, orthogonal to any one -/// table. -/// - **Structure**: the DDL editor, for a new table's draft or an -/// existing table. -pub enum WorkspaceTab { - Editor(EditorTabSlot), - Structure(StructureTabSlot), - Table(TableTabSlot), -} - -impl WorkspaceTab { - /// The Browse tab. Only `Table` slots carry one. - pub fn browse_tab(&self) -> Option<&BrowseTab> { - match self { - WorkspaceTab::Table(s) => Some(&s.browse), - _ => None, - } - } - - /// The Structure-side controller. Only `Structure` slots carry - /// one (Table slots no longer fuse the DDL editor in). - pub fn structure_tab(&self) -> Option<&crate::ui::structure_tab::StructureTab> { - match self { - WorkspaceTab::Structure(s) => Some(&s.structure), - _ => None, - } - } - - /// `(schema, table)` when the slot is pinned to one. Editor - /// returns `None`. - pub fn schema_table(&self) -> Option<(Option<&str>, &str)> { - match self { - WorkspaceTab::Structure(s) => Some((s.schema.as_deref(), &s.table)), - WorkspaceTab::Table(s) => Some((s.schema.as_deref(), &s.table)), - WorkspaceTab::Editor(_) => None, - } - } -} - -#[derive(Debug, Clone, Copy)] -pub enum OpenMode { - /// Plain sidebar click: activate the table's tab, or append one. - SwitchOrAppend, - /// Ctrl+click / right-click "Open in new tab": always append a - /// new tab even when the same table is already open. - NewTab, -} - -// The page carries only the tab's id; its kind is in `workspace_tabs`. -fn workspace_tab_id_quark() -> glib::Quark { - static QUARK: std::sync::OnceLock = std::sync::OnceLock::new(); - *QUARK.get_or_init(|| glib::Quark::from_str("tp-workspace-tab-id")) -} - -pub(super) fn write_workspace_tab_id(page: &adw::TabPage, id: Uuid) { - unsafe { - page.set_qdata(workspace_tab_id_quark(), id); - } -} - -pub(super) fn read_workspace_tab_id(page: &adw::TabPage) -> Option { - unsafe { page.qdata::(workspace_tab_id_quark()).map(|p| *p.as_ref()) } -} - -#[derive(Debug)] -pub enum AppMsg { - OpenConnect, - /// A connect worked; adopt its session in this window. - SessionReady(Box), - /// The user kept the tabs the connect would have replaced. - SessionDeclined(Box), - /// The connect dialog's Connect: connect, then save the entry. - ConnectSubmitted(Box), - /// The same, once the user agreed to close the open workspace. - ConnectSubmissionConfirmed(Box), - /// A connect failed. `None` when the user cancelled it, which needs - /// no error. `edit` reopens the dialog on what was submitted. - ConnectFailed { - generation: tablepro_session::connect::AttemptGeneration, - message: Option, - edit: Option>, - }, - /// The Cancel button on the Connecting toast. - CancelConnect, - /// Open the connect dialog on a connection that failed to connect, - /// with the focus on the row that fixes it. - EditConnection(Box), - /// The banner's Retry: ask the monitor to reconnect now. - RetryConnection, - /// The adopted session's health moved. - HealthChanged(FromSession), - /// The session's object list for the sidebar changed: a reconnect - /// read it again, or a schema change refreshed it. - CatalogChanged(FromSession), - DialogClosed, - SelectTable { - schema: Option, - name: String, - open_mode: OpenMode, - }, - ReloadConnections, - ConnectionsLoaded(Vec), - ConnectionListUnavailable, - ResetConnectionList, - SecretDeleteFailed(Uuid), - OpenSaved(SavedConnection), - /// Open a saved connection once the user agreed to close the open - /// workspace. - OpenSavedConfirmed(SavedConnection), - DeleteConnection(Uuid), - DuplicateConnection(SavedConnection), - /// "+ New query" button or Ctrl+T → append a new editor tab. - NewEditorTab, - /// Ctrl+W → close active workspace tab (browse or editor). - CloseActiveWorkspaceTab, - EditorTabRunStateChanged(Uuid, bool), - EditorTabContentChanged(Uuid), - /// The session's saved tabs and their editor text, read off the GTK - /// thread. - WorkspaceLoaded(FromSession), - /// The toast's Reset: move the unreadable saved tabs aside. - ResetWorkspaceLayout, - WorkspaceLayoutReset(FromSession, crate::workspace::WorkspaceSaveError>>), - ShowHistory, - ShowSavedQueries, - /// Keep the active editor tab's SQL under a name the user picks. - SaveActiveQuery, - SaveQueryNamed { - name: String, - query: String, - }, - /// A colour picked from a connection row's menu, or `None` when the - /// user cleared it. - SetConnectionColor(Uuid, Option), - /// A group picked from a connection row's menu, or `None` when the - /// user took it out of one. - SetConnectionGroup(Uuid, Option), - ExportConnections, - ImportConnections, - /// "Import CSV…" from a table's own menu: ask for a file, then - /// open the mapping dialog against that table's columns. - ImportCsvRequested { - schema: Option, - table: String, - }, - /// The file came back with the table's columns beside it, so the - /// mapping dialog has everything it needs. - ImportCsvReady { - schema: Option, - table: String, - columns: Vec, - bytes: Vec, - }, - /// The mapping is settled, so the rows can be written. - ImportCsvRun(Box), - /// The file the user picked, read off the GTK thread. - ConnectionsFileRead { - path: std::path::PathBuf, - bytes: Vec, - }, - OpenHistoryQuery(String), - ReplaceActiveTabQuery(String), - Disconnect, - /// Tear the connection down, once nothing would be lost or the user - /// agreed to lose it. - ForceDisconnect, - /// The close prompts decided about one tab's close. - TabCloseDecided { - id: Uuid, - approved: bool, - }, - RefreshPage, - /// The application asks the window to close. - CloseRequested(crate::workspace::CloseReason), - ExportResults { - result: QueryResult, - name: String, - target: Option, - }, - CopyToClipboard(String), - - // ── Workspace tab routing ──────────────────────────────────────── - /// User clicked the close-X on any workspace tab. - WorkspaceTabClosed(Uuid), - /// Tab right-click "Close Other Tabs" → close every tab except - /// the one whose context menu was used (per-tab close path so - /// each tab still gets the unsaved-changes prompt if dirty). - CloseOtherWorkspaceTabs(Uuid), - /// Tab right-click "Close Tabs to the Right" → close every tab - /// after the targeted one in TabView display order. - CloseWorkspaceTabsToRight(Uuid), - /// The tab order, the selection or a tab's state changed, so the - /// workspace is saved. - WorkspaceTabsChanged, - /// A tab's own Save: write what its tracker holds. - SaveTab(Uuid), - /// A tab's Stop Saving. - StopSaving(Uuid), - /// A save started or settled, or tabs' changes were dropped. - Workspace(crate::workspace::WorkspaceEvent), - /// The number of saves running in this window changed. - SavesInFlightChanged(usize), - /// An editor tab's transaction opened or ended. - EditorTransactionChanged, - /// Ctrl+S, for the active tab. - SaveActiveTab, - /// Ctrl+Z, for the active Table tab. - UndoActiveBrowseTab, - /// Ctrl+Y, for the active Table tab. - RedoActiveBrowseTab, - /// Show a small alert dialog; used by BrowseTab for "select exactly - /// one row" type messages. - ShowAlert { - title: String, - body: String, - }, - ShowToast(String), - /// Tracker for a specific browse tab moved between empty / non-empty. - /// Handler updates that tab's page title to add or remove the - /// "•" dirty marker. Mirrors GNOME Text Editor's leading-bullet - /// convention for unsaved buffers. - BrowseTabDirtyChanged(Uuid, bool), - /// Sidebar right-click → "New Table…" or schema-header "+" button. - /// Always appends a fresh draft Structure tab; never matches an - /// existing tab. - NewTableTab { - schema: Option, - }, - /// Sidebar right-click → "Edit Structure". Switches to an existing - /// Edit-mode Structure tab for `(schema, table)` if one is open; - /// otherwise appends a new Edit-mode Structure tab. - EditStructureTab { - schema: Option, - table: String, - }, - /// Sidebar right-click → "Show CREATE TABLE". The table's - /// definition goes through the dialect's `create_table_script`, and - /// the SQL opens in a fresh editor tab. - ShowCreateTableForExisting { - schema: Option, - table: String, - }, - /// The CREATE statement `ShowCreateTableForExisting` built, for a - /// new editor tab. - ShowCreateTableLoaded(FromSession), - /// Sidebar right-click → "Drop Table…", or in-tab Drop button. - /// App shows the AdwAlertDialog confirmation; on confirm dispatches - /// `DropTableConfirmed`. - DropTablePrompt { - schema: Option, - table: String, - }, - /// The user confirmed the drop. - DropTableConfirmed { - schema: Option, - table: String, - }, - /// The table is gone, so its tabs close without asking. - DropTableSucceeded(FromSession), - /// Tracker for a Structure tab crossed empty / non-empty boundary. - /// Mirrors `BrowseTabDirtyChanged` for the title prefix and the - /// AdwTabPage::set_needs_attention background-tab indicator. - StructureTabDirtyChanged(Uuid, bool), - /// Schema state changed (table created / dropped / altered): the - /// catalog reads the sidebar's list again and drops the table's - /// columns, and the tabs open on the table read it again. - SchemaChanged { - schema: Option, - table: Option, - }, - /// Ctrl+Shift+T → pop the most recent closed-tab descriptor and - /// reopen it. No-op when the stack is empty (e.g. no tabs closed - /// yet, or just reconnected). Editor tabs come back with their - /// buffer; Table tabs come back with their schema/table/mode and - /// last-known pagination, sort, page size. - ReopenClosedTab, - /// Ctrl+F → open the filter strip for the active Browse tab. - /// No-op when the active tab isn't a Browse / Table tab. - ShowFilterDialog, -} - -/// Tabs in either registry with unsaved changes. -fn dirty_tabs(changes: &ChangeTrackerRegistry, structures: &StructureTrackerRegistry) -> Vec { - let mut dirty = changes.dirty(); - dirty.extend(structures.dirty()); - dirty.sort_unstable(); - dirty.dedup(); - dirty -} - -impl App { - pub(super) fn dirty_tabs(&self) -> Vec { - dirty_tabs(&self.change_trackers, &self.structure_trackers) - } - - /// Whether the window holds logout back now. - pub(crate) fn is_inhibiting(&self) -> bool { - self.inhibitor.is_held() - } - - /// A test session has no session manager to grant an inhibition. - #[cfg(test)] - pub(crate) fn set_inhibitor(&mut self, inhibitor: crate::workspace::WindowInhibitor) { - self.inhibitor = inhibitor; - } - - fn on_close_requested(&self, reason: crate::workspace::CloseReason) { - match reason { - crate::workspace::CloseReason::Quit(reply) => { - self.close_guards.request_window_close(&self.window, move |decision| { - // A quit that went away no longer needs the answer. - let _ = reply.send(decision); - }); - } - } - } -} - -#[relm4::component(pub)] -impl SimpleComponent for App { - type Init = AppInit; - type Input = AppMsg; - type Output = (); - - view! { - #[name = "window"] - adw::ApplicationWindow { - set_title: Some("TablePro"), - set_default_width: 1200, - set_default_height: 760, - - adw::ToolbarView { - #[name = "header_bar"] - add_top_bar = &adw::HeaderBar { - #[name = "window_title"] - #[wrap(Some)] - set_title_widget = &adw::WindowTitle { - set_title: "TablePro", - }, - - // Two distinct affordances → two distinct buttons. - // SplitButton would imply they're variants of the - // same action, but "new connection" and "open - // saved" are semantically different (matches GNOME - // Files' "New" + "History" pattern, not the - // SplitButton-as-Save-with-format pattern). - #[name = "new_connection_button"] - pack_start = >k::Button { - set_icon_name: crate::ui::icons::LIST_ADD, - set_tooltip_text: Some(crate::i18n::gettext("New connection").as_str()), - connect_clicked[sender] => move |_| sender.deliver(AppMsg::OpenConnect), - }, - - #[name = "saved_connections_button"] - pack_start = >k::MenuButton { - set_icon_name: crate::ui::icons::DOCUMENT_OPEN, - set_tooltip_text: Some(crate::i18n::gettext("Open saved connection").as_str()), - - #[wrap(Some)] - #[name = "connections_popover"] - set_popover = >k::Popover {}, - }, - - #[name = "read_only_badge"] - pack_end = >k::Label { - set_visible: false, - set_label: &crate::i18n::gettext("Read-only"), - set_margin_end: 6, - add_css_class: "warning", - add_css_class: "caption-heading", - }, - - #[name = "row_op_spinner"] - pack_end = &adw::Spinner { - set_visible: false, - set_margin_end: 6, - set_tooltip_text: Some(crate::i18n::gettext("Saving…").as_str()), - }, - - #[name = "primary_menu_button"] - pack_end = >k::MenuButton { - set_icon_name: crate::ui::icons::OPEN_MENU, - set_tooltip_text: Some(crate::i18n::gettext("Main menu").as_str()), - }, - }, - - #[wrap(Some)] - #[name = "split_view"] - set_content = &adw::OverlaySplitView { - set_min_sidebar_width: 220.0, - set_max_sidebar_width: 280.0, - set_show_sidebar: false, - - // The sidebar has its own header bar for the search - // toggle, as GNOME Files does; the window buttons are - // on the main one. - #[wrap(Some)] - #[name = "sidebar_root"] - set_sidebar = &adw::ToolbarView { - #[name = "sidebar_header"] - add_top_bar = &adw::HeaderBar { - set_show_start_title_buttons: false, - set_show_end_title_buttons: false, - - #[wrap(Some)] - #[name = "sidebar_title"] - set_title_widget = &adw::WindowTitle { - set_title: &crate::i18n::gettext("Tables"), - }, - - #[name = "table_search_toggle"] - pack_end = >k::ToggleButton { - set_icon_name: crate::ui::icons::SYSTEM_SEARCH, - set_tooltip_text: Some(crate::i18n::gettext("Search tables").as_str()), - }, - }, - - #[wrap(Some)] - set_content = >k::Box { - set_orientation: gtk::Orientation::Vertical, - - #[name = "table_search_bar"] - gtk::SearchBar { - set_show_close_button: true, - set_search_mode: false, - - #[wrap(Some)] - #[name = "table_search"] - set_child = >k::SearchEntry { - set_placeholder_text: Some(crate::i18n::gettext("Filter tables…").as_str()), - set_hexpand: true, - }, - }, - - #[name = "sidebar_scroll"] - gtk::ScrolledWindow { - set_hscrollbar_policy: gtk::PolicyType::Never, - set_vexpand: true, - }, - }, - }, - - #[wrap(Some)] - #[name = "toast_overlay"] - set_content = &adw::ToastOverlay { - #[wrap(Some)] - #[name = "content_holder"] - set_child = &adw::ToolbarView { - #[name = "reconnect_banner"] - add_top_bar = &adw::Banner { - set_revealed: false, - set_use_markup: false, - set_button_label: Some(crate::i18n::gettext("Retry").as_str()), - }, - - #[name = "connection_list_banner"] - add_top_bar = &adw::Banner { - set_revealed: false, - set_use_markup: false, - }, - // The welcome view or the workspace goes in - // here once init() has run. - }, - }, - }, - }, - } - } - - fn init(init: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - let AppInit { services, host, launch } = init; - let (context, prompts) = services.window_context(); - let settings = services.settings.clone(); - let storage = services.storage.clone(); - let tasks = services.tasks.clone(); - let history = services.history(); - let drafts = crate::workspace::DraftWriter::new( - tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), - tasks.clone(), - ); - let workspace = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); - let widgets = view_output!(); - - if crate::config::profile() == crate::config::Profile::Development { - widgets.window.add_css_class("devel"); - } - - crate::ui::window_geometry::restore(&widgets.window, &settings); - let change_trackers = std::rc::Rc::new(ChangeTrackerRegistry::default()); - let structure_trackers = std::rc::Rc::new(StructureTrackerRegistry::default()); - let connect_attempts = std::rc::Rc::new(tablepro_session::ConnectAttempts::new()); - let workspace_tabs: workspace_persistence::WorkspaceTabs = - std::rc::Rc::new(std::cell::RefCell::new(std::collections::HashMap::new())); - let window_tabs = std::rc::Rc::new(window_tabs::WindowTabs::new(workspace_tabs.clone())); - let events_sender = sender.input_sender().clone(); - let workspace_handle = crate::workspace::WorkspaceHandle::new(crate::workspace::WorkspaceDeps { - session: context.active.clone(), - tasks: context.tasks.clone(), - hold: context.hold.clone(), - history: Some(history.clone()), - rows: change_trackers.clone(), - structures: structure_trackers.clone(), - directory: window_tabs.clone(), - events: std::rc::Rc::new(move |event| events_sender.emit(AppMsg::Workspace(event))), - }); - let saves_sender = sender.input_sender().clone(); - workspace_handle - .ledger() - .on_change(move |count| saves_sender.emit(AppMsg::SavesInFlightChanged(count))); - let close_guards = crate::workspace::CloseGuards::new(); - let window_shutdown = close_flow::WindowShutdown::new(close_flow::WindowShutdownParts { - settings: settings.clone(), - storage: storage.clone(), - drafts: drafts.clone(), - workspace: workspace.clone(), - tabs: workspace_tabs.clone(), - context: context.clone(), - connect_attempts: connect_attempts.clone(), - }); - let shutdown = window_shutdown.clone(); - close_guards.connect_window(&widgets.window, workspace_handle.clone(), move |window| { - shutdown.run(Some(window)); - }); - let inhibitor = - crate::workspace::WindowInhibitor::new(crate::workspace::ApplicationInhibit::new(&widgets.window)); - - let breakpoint = adw::Breakpoint::new(adw::BreakpointCondition::new_length( - adw::BreakpointConditionLengthType::MaxWidth, - 600.0, - adw::LengthUnit::Sp, - )); - breakpoint.add_setter(&widgets.split_view, "collapsed", Some(&true.into())); - widgets.window.add_breakpoint(breakpoint); - - let sidebar_schemas: std::rc::Rc>>> = - std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); - - let sidebar_factory: FactoryVecDeque = FactoryVecDeque::builder() - .launch( - gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::Single) - .activate_on_single_click(true) - .css_classes(["navigation-sidebar"]) - .build(), - ) - .forward(sender.input_sender(), |out| match out { - // Plain click + Enter activation route through the parent - // ListBox's `row-activated` signal (wired below), which is - // the only signal that fires for both mouse and keyboard. - // The factory only carries the Ctrl+click / right-click - // "open in new tab" path. - SidebarRowOutput::OpenInNewTab { schema, name } => AppMsg::SelectTable { - schema, - name, - open_mode: OpenMode::NewTab, - }, - SidebarRowOutput::EditStructure { schema, name } => AppMsg::EditStructureTab { schema, table: name }, - SidebarRowOutput::ShowCreateTable { schema, name } => { - AppMsg::ShowCreateTableForExisting { schema, table: name } - } - SidebarRowOutput::ImportCsv { schema, name } => AppMsg::ImportCsvRequested { schema, table: name }, - SidebarRowOutput::DropTable { schema, name } => AppMsg::DropTablePrompt { schema, table: name }, - }); - - let sidebar_listbox = sidebar_factory.widget(); - widgets.sidebar_scroll.set_child(Some(sidebar_listbox)); - - // Plain click + Enter on focused row → SwitchOrAppend. This is - // the single source of truth for sidebar activation; per-row - // keybinding signals (gtk::ListBoxRow::activate) only fire on - // Enter and would miss mouse clicks. - let schemas_for_activate = sidebar_schemas.clone(); - let activate_sender = sender.clone(); - sidebar_listbox.connect_row_activated(move |_, row| { - let name = row.widget_name().to_string(); - let idx = row.index() as usize; - let schema = schemas_for_activate.borrow().get(idx).cloned().unwrap_or(None); - activate_sender.deliver(AppMsg::SelectTable { - schema, - name, - open_mode: OpenMode::SwitchOrAppend, - }); - }); - - let search_for_filter = widgets.table_search.clone(); - let schemas_for_filter = sidebar_schemas.clone(); - sidebar_listbox.set_filter_func(move |row| { - let query = search_for_filter.text().to_lowercase(); - if query.is_empty() { - return true; - } - // SidebarRow stashes its table name in widget-name; same - // identifier is read by sync_sidebar_selection. Search - // also matches the row's schema (when present) so a query - // for "auth" surfaces every table in the auth schema, and - // the qualified `schema.table` form so users with - // multi-schema connections can disambiguate by typing the - // dotted name they see in the tab title. - let table_name = row.widget_name().to_lowercase(); - if table_name.contains(&query) { - return true; - } - let schemas = schemas_for_filter.borrow(); - let idx = row.index() as usize; - let Some(schema) = schemas.get(idx).and_then(|s| s.as_deref()) else { - return false; - }; - let schema_lc = schema.to_lowercase(); - schema_lc.contains(&query) || format!("{schema_lc}.{table_name}").contains(&query) - }); - let listbox_for_invalidate = sidebar_listbox.clone(); - widgets.table_search.connect_search_changed(move |_| { - listbox_for_invalidate.invalidate_filter(); - }); - widgets.table_search_bar.connect_entry(&widgets.table_search); - widgets - .table_search_bar - .set_key_capture_widget(Some(&widgets.sidebar_root)); - - // An empty list with no placeholder reads as broken. - let sidebar_placeholder = adw::StatusPage::builder() - .icon_name(crate::ui::icons::VIEW_LIST) - .title(crate::i18n::gettext("No tables")) - .description(crate::i18n::gettext( - "Nothing matches the current search, or this connection has no tables yet.", - )) - .build(); - sidebar_placeholder.add_css_class("compact"); - sidebar_listbox.set_placeholder(Some(&sidebar_placeholder)); - - // Two-way bind the sidebar header's search toggle to the SearchBar. - // Click toggle → SearchBar reveals + entry focuses; press Esc → - // SearchBar hides → toggle deactivates. - widgets - .table_search_toggle - .bind_property("active", &widgets.table_search_bar, "search-mode-enabled") - .bidirectional() - .sync_create() - .build(); - - let schemas_for_header = sidebar_schemas.clone(); - let sender_for_header = sender.clone(); - let context_for_header = context.clone(); - sidebar_listbox.set_header_func(move |row, before| { - let schemas = schemas_for_header.borrow(); - let total_distinct: std::collections::BTreeSet<&str> = - schemas.iter().filter_map(|s| s.as_deref()).collect(); - // A connection with one schema still gets a header, for its - // "New Table" button. - let multi_schema = total_distinct.len() >= 2; - let idx = row.index(); - let current = schemas.get(idx as usize).cloned().flatten(); - let prev_idx = before.map(|b| b.index()); - let prev = prev_idx.and_then(|i| schemas.get(i as usize)).cloned().flatten(); - let needs = match (¤t, &prev) { - (Some(c), Some(p)) => c != p, - (Some(_), None) => true, - (None, None) => before.is_none() && !multi_schema, - (None, Some(_)) => false, - }; - if !needs { - row.set_header(gtk::Widget::NONE); - return; - } - let can_create_table = context_for_header - .active - .get() - .and_then(|session| session.current_dialect()) - .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) - .unwrap_or(false); - let sender_for_button = sender_for_header.clone(); - let schema_for_button = current.clone(); - let header_box = - sidebar_section_header::sidebar_section_header(current.as_deref(), can_create_table, move || { - sender_for_button.deliver(AppMsg::NewTableTab { - schema: schema_for_button.clone(), - }); - }); - row.set_header(Some(&header_box)); - }); - - let connections_listbox = gtk::ListBox::builder() - .selection_mode(gtk::SelectionMode::None) - .css_classes(["boxed-list"]) - .build(); - let connections_factory: FactoryVecDeque = FactoryVecDeque::builder() - .launch(connections_listbox.clone()) - .forward(sender.input_sender(), |out| match out { - ConnectionRowOutput::Open(saved) => AppMsg::OpenSaved(saved), - ConnectionRowOutput::Delete(id) => AppMsg::DeleteConnection(id), - ConnectionRowOutput::Duplicate(saved) => AppMsg::DuplicateConnection(saved), - ConnectionRowOutput::SetColor(id, color) => AppMsg::SetConnectionColor(id, color), - ConnectionRowOutput::SetGroup(id, group) => AppMsg::SetConnectionGroup(id, group), - }); - - // The SplitButton's tooltip already labels the popover, so we drop - // the in-popover "Saved Connections" header that previously sat - // above the list. Explicit width_request prevents AdwSplitButton's - // narrow dropdown trigger from constraining the popover width - // (which produced mid-word hyphenation of connection names). - let popover_content = gtk::Box::builder() - .orientation(gtk::Orientation::Vertical) - .spacing(0) - .margin_top(6) - .margin_bottom(6) - .margin_start(6) - .margin_end(6) - .width_request(320) - .build(); - - let scroll = gtk::ScrolledWindow::builder() - .child(connections_factory.widget()) - .min_content_width(320) - .min_content_height(120) - .max_content_height(400) - .propagate_natural_height(true) - .hscrollbar_policy(gtk::PolicyType::Never) - .build(); - popover_content.append(&scroll); - widgets.connections_popover.set_child(Some(&popover_content)); - - // Workspace outer stack: swaps between an empty StatusPage - // ("Select a table") when no tabs are open and the unified - // AdwTabOverview hosting both Browse and Editor tabs. The - // tab tree itself is built lazily on connect via - // `ensure_workspace_root` in app/workspace_tabs.rs. - let workspace_outer_stack = gtk::Stack::builder() - .transition_type(gtk::StackTransitionType::Crossfade) - .build(); - // CTA button parented inside the empty-state status page so - // the "open editor" affordance is reachable with the mouse: - // without it the only path was the keyboard shortcut and the - // tab-bar "+", and the tab bar is hidden in this empty state. - let workspace_empty_cta = gtk::Button::builder() - .label(crate::i18n::gettext("Open SQL editor")) - .action_name("win.open-editor") - .halign(gtk::Align::Center) - .build(); - workspace_empty_cta.add_css_class("suggested-action"); - workspace_empty_cta.add_css_class("pill"); - let workspace_empty_page = adw::StatusPage::builder() - .icon_name(crate::ui::icons::VIEW_GRID) - .title(crate::i18n::gettext("Select a table")) - .description(crate::i18n::gettext( - "Pick a table from the sidebar, or use the button below (Ctrl+T).", - )) - .child(&workspace_empty_cta) - .build(); - workspace_outer_stack.add_named(&workspace_empty_page, Some("empty")); - workspace_outer_stack.set_visible_child_name("empty"); - - let actions = install_window_actions(&widgets.window, sender.input_sender()); - let history_gate = spawn_history_gate( - vec![actions.show_history.clone(), actions.show_saved_queries.clone()], - history.availability(), - ); - install_window_shortcuts(&widgets.window); - widgets.primary_menu_button.set_menu_model(Some(&primary_menu_model())); - - let welcome_view = - WelcomeView::builder() - .launch(WelcomeViewInit) - .forward(sender.input_sender(), |out| match out { - WelcomeViewOutput::OpenConnect => AppMsg::OpenConnect, - WelcomeViewOutput::OpenSaved(saved) => AppMsg::OpenSaved(saved), - WelcomeViewOutput::Delete(id) => AppMsg::DeleteConnection(id), - WelcomeViewOutput::Duplicate(saved) => AppMsg::DuplicateConnection(saved), - WelcomeViewOutput::SetColor(id, color) => AppMsg::SetConnectionColor(id, color), - WelcomeViewOutput::SetGroup(id, group) => AppMsg::SetConnectionGroup(id, group), - }); - - let prompt_presenter = crate::ui::credential_prompt::CredentialPromptPresenter::start(prompts, &root); - let model = App { - context, - host, - session_links: None, - prompt_presenter, - connect_attempts, - settings: settings.clone(), - storage: storage.clone(), - tasks: tasks.clone(), - history: history.clone(), - drafts: drafts.clone(), - window: root.clone(), - split_view: widgets.split_view.clone(), - window_title: widgets.window_title.clone(), - sidebar_title: widgets.sidebar_title.clone(), - disconnect_action: actions.disconnect, - history_gate, - sidebar_factory, - sidebar_schemas, - content_holder: widgets.content_holder.clone(), - toast_overlay: widgets.toast_overlay.clone(), - connect_progress_toast: None, - reconnect_banner: widgets.reconnect_banner.clone(), - connection_list_banner: widgets.connection_list_banner.clone(), - connections_factory, - connections_listbox, - connections_popover: widgets.connections_popover.clone(), - row_op_spinner: widgets.row_op_spinner.clone(), - read_only_badge: widgets.read_only_badge.clone(), - table_search: widgets.table_search.clone(), - workspace_outer_stack, - workspace_root: None, - workspace_tab_view: None, - workspace_root_added: std::cell::Cell::new(false), - workspace_tabs, - change_trackers, - structure_trackers, - workspace_handle, - window_tabs, - close_guards, - window_shutdown, - inhibitor, - dialog: None, - history_dialog: None, - saved_queries_dialog: None, - import_dialog: None, - welcome_view, - current_driver_id: None, - read_only: false, - saved_connections: Vec::new(), - connected: false, - workspace, - closed_tabs_stack: std::rc::Rc::new(std::cell::RefCell::new(std::collections::VecDeque::with_capacity( - CLOSED_TABS_CAPACITY, - ))), - }; - sender.deliver(AppMsg::ReloadConnections); - model.show_welcome_page(sender.clone()); - match launch { - crate::application::WindowLaunch::Picker => {} - crate::application::WindowLaunch::Connect(saved) => sender.deliver(AppMsg::OpenSaved(*saved)), - } - - widgets - .new_connection_button - .update_property(&[gtk::accessible::Property::Label("New connection")]); - widgets - .saved_connections_button - .update_property(&[gtk::accessible::Property::Label("Open saved connection")]); - widgets - .primary_menu_button - .update_property(&[gtk::accessible::Property::Label("Main menu")]); - - let banner_sender = sender.clone(); - widgets.reconnect_banner.connect_button_clicked(move |_| { - banner_sender.deliver(AppMsg::RetryConnection); - }); - - let list_banner_sender = sender.clone(); - widgets.connection_list_banner.connect_button_clicked(move |_| { - list_banner_sender.deliver(AppMsg::ResetConnectionList); - }); - - ComponentParts { model, widgets } - } - - fn shutdown(&mut self, _widgets: &mut Self::Widgets, _output: relm4::Sender) { - // The gate holds the window's action, so it has to stop before - // the window does. - self.history_gate.abort(); - self.prompt_presenter.abort(); - // The close request already did this; a window destroyed - // without one still closes its tabs' connections, then its own, - // here. The claim on the session's key goes last, so no other - // window adopts a connection still closing. - self.window_shutdown.run(None); - self.session_links = None; - } - - fn update(&mut self, msg: Self::Input, sender: ComponentSender) { - match msg { - AppMsg::OpenConnect => self.on_open_connect(sender), - AppMsg::SessionReady(ready) => self.on_session_ready(*ready, sender), - AppMsg::SessionDeclined(ready) => self.on_session_declined(*ready), - AppMsg::ConnectSubmitted(submission) => self.on_connect_submission(*submission, sender), - AppMsg::ConnectSubmissionConfirmed(submission) => self.connect_submission(*submission, sender), - AppMsg::ConnectFailed { - generation, - message, - edit, - } => self.on_connect_failed(generation, message, edit, sender), - AppMsg::CancelConnect => self.on_cancel_connect(), - AppMsg::EditConnection(retry) => self.on_edit_connection(*retry, sender), - AppMsg::HealthChanged(update) => { - if let Some(health) = update.if_current(&self.context.active) { - self.refresh_health_banner(&health); - } - } - AppMsg::RetryConnection => self.on_retry_connection(), - AppMsg::CatalogChanged(update) => { - if let Some(snapshot) = update.if_current(&self.context.active) { - self.on_catalog_changed(&snapshot); - } - } - AppMsg::Disconnect => self.on_disconnect(sender), - AppMsg::ForceDisconnect => self.do_disconnect(sender), - AppMsg::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), - AppMsg::DialogClosed => self.dialog = None, - AppMsg::SelectTable { - schema, - name, - open_mode, - } => self.dispatch_select_table(schema, name, open_mode, sender), - AppMsg::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), - AppMsg::SaveTab(id) => self.request_save(id), - AppMsg::StopSaving(id) => self.stop_saving(id), - AppMsg::Workspace(event) => self.on_workspace_event(event, sender), - AppMsg::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), - AppMsg::EditorTransactionChanged => self.refresh_inhibitor(), - AppMsg::SaveActiveTab => { - if let Some(id) = self.selected_workspace_tab_id() { - self.request_save(id); - } - } - AppMsg::UndoActiveBrowseTab => { - // Ctrl+Z routes to BrowseTab undo only. Structure - // editing follows the snapshot+diff model: DDL undo - // is a session-level Discard, not a per-keystroke - // history. Inside a Structure-mode Entry, the native - // `gtk::Text` undo handles per-field text revert. - if let Some(id) = self.selected_browse_tab_id() { - self.dispatch_to_tab(id, BrowseTabInput::Undo); - } - } - AppMsg::RedoActiveBrowseTab => { - if let Some(id) = self.selected_browse_tab_id() { - self.dispatch_to_tab(id, BrowseTabInput::Redo); - } - } - AppMsg::WorkspaceTabClosed(id) => self.close_workspace_tab_by_id(id, sender), - AppMsg::CloseOtherWorkspaceTabs(id) => self.close_other_workspace_tabs(id, sender), - AppMsg::CloseWorkspaceTabsToRight(id) => self.close_workspace_tabs_to_right(id, sender), - AppMsg::CloseActiveWorkspaceTab => self.close_active_workspace_tab(sender), - AppMsg::ShowAlert { title, body } => self.show_error_alert(&title, &body), - AppMsg::ShowToast(msg) => self.show_toast(&msg), - AppMsg::BrowseTabDirtyChanged(tab_id, dirty) => { - self.refresh_browse_tab_dirty(tab_id, dirty); - self.refresh_inhibitor(); - } - AppMsg::NewTableTab { schema } => self.on_new_table_tab(schema, sender), - AppMsg::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table, sender), - AppMsg::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table, sender), - AppMsg::ShowCreateTableLoaded(update) => { - if let Some(sql) = update.if_current(&self.context.active) { - self.append_editor_tab(Some(sql), sender); - } - } - AppMsg::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table, sender), - AppMsg::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table, sender), - AppMsg::DropTableSucceeded(update) => { - if let Some(table) = update.if_current(&self.context.active) { - self.on_drop_table_succeeded(table, sender); - } - } - AppMsg::StructureTabDirtyChanged(tab_id, dirty) => { - self.refresh_structure_tab_dirty(tab_id, dirty); - self.refresh_inhibitor(); - } - AppMsg::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), - AppMsg::ReloadConnections => self.on_reload_connections(sender), - AppMsg::ConnectionListUnavailable => self.show_connection_list_banner(), - AppMsg::SecretDeleteFailed(id) => self.on_secret_delete_failed(id, sender), - AppMsg::ResetConnectionList => self.on_reset_connection_list(sender), - AppMsg::ConnectionsLoaded(connections) => { - let conns = connections; - self.on_connections_loaded(&conns, sender); - } - AppMsg::NewEditorTab => self.append_editor_tab(None, sender), - AppMsg::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), - AppMsg::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), - AppMsg::WorkspaceLoaded(update) => { - if let Some(loaded) = update.if_current(&self.context.active) { - self.on_workspace_loaded(loaded, sender); - } - } - AppMsg::ResetWorkspaceLayout => self.on_reset_workspace_layout(sender), - AppMsg::WorkspaceLayoutReset(update) => { - if let Some(outcome) = update.if_current(&self.context.active) { - self.on_workspace_layout_reset(outcome); - } - } - AppMsg::ShowHistory => self.on_show_history(sender), - AppMsg::ShowSavedQueries => self.on_show_saved_queries(sender), - AppMsg::SetConnectionColor(id, color) => self.on_set_connection_color(id, color, sender), - AppMsg::SetConnectionGroup(id, group) => self.on_set_connection_group(id, group, sender), - AppMsg::ExportConnections => self.on_export_connections(sender), - AppMsg::ImportConnections => self.on_import_connections(sender), - AppMsg::ImportCsvRequested { schema, table } => self.on_import_csv_requested(schema, table, sender), - AppMsg::ImportCsvReady { - schema, - table, - columns, - bytes, - } => self.on_import_csv_ready(schema, table, columns, bytes, sender), - AppMsg::ImportCsvRun(import) => self.on_import_csv_run(*import, sender), - AppMsg::ConnectionsFileRead { path, bytes } => self.on_connections_file_read(path, bytes, sender), - AppMsg::SaveActiveQuery => self.on_save_active_query(sender), - AppMsg::SaveQueryNamed { name, query } => self.on_save_query_named(name, query, sender), - AppMsg::OpenHistoryQuery(text) => { - if self.connected { - self.append_editor_tab(Some(text), sender); - } else { - self.show_toast(&crate::i18n::gettext("Connect to a database first to run SQL.")); - } - } - AppMsg::ReplaceActiveTabQuery(text) => { - if self.connected { - self.on_replace_active_tab_query(text, sender); - } else { - self.show_toast(&crate::i18n::gettext("Connect to a database first to run SQL.")); - } - } - AppMsg::RefreshPage => self.on_refresh_active_tab(), - AppMsg::CloseRequested(reason) => self.on_close_requested(reason), - AppMsg::ExportResults { result, name, target } => { - let dialect = self.context.active.get().and_then(|session| session.current_dialect()); - super::export_dialog::present( - &self.window, - &self.toast_overlay, - result, - name, - target, - dialect, - &self.settings, - ) - } - AppMsg::CopyToClipboard(text) => self.on_copy_to_clipboard(text), - AppMsg::DeleteConnection(id) => self.on_delete_connection(id, sender), - AppMsg::DuplicateConnection(saved) => self.on_duplicate_connection(saved, sender), - AppMsg::OpenSaved(saved) => self.on_open_saved(saved, sender), - AppMsg::OpenSavedConfirmed(saved) => self.connect_saved(saved, sender), - AppMsg::ReopenClosedTab => self.on_reopen_closed_tab(sender), - AppMsg::ShowFilterDialog => self.on_show_filter_dialog(), - } - } -} - -fn qualified_label(schema: Option<&str>, table: &str) -> String { - match schema { - Some(s) => format!("{s}.{table}"), - None => table.to_string(), - } -} - -fn primary_menu_model() -> gio::Menu { - let menu = gio::Menu::new(); - let window_section = gio::Menu::new(); - window_section.append(Some(&crate::i18n::gettext("New Window")), Some("app.new-window")); - menu.append_section(None, &window_section); - let connection_section = gio::Menu::new(); - let disconnect_item = gio::MenuItem::new(Some(&crate::i18n::gettext("Disconnect")), Some("win.disconnect")); - disconnect_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - connection_section.append_item(&disconnect_item); - menu.append_section(None, &connection_section); - let history_section = gio::Menu::new(); - history_section.append(Some(&crate::i18n::gettext("Query History")), Some("win.show-history")); - history_section.append( - Some(&crate::i18n::gettext("Saved Queries")), - Some("win.show-saved-queries"), - ); - menu.append_section(None, &history_section); - let connections_section = gio::Menu::new(); - connections_section.append( - Some(&crate::i18n::gettext("Import Connections…")), - Some("win.import-connections"), - ); - connections_section.append( - Some(&crate::i18n::gettext("Export Connections…")), - Some("win.export-connections"), - ); - menu.append_section(None, &connections_section); - let prefs_section = gio::Menu::new(); - prefs_section.append(Some(&crate::i18n::gettext("Preferences")), Some("app.preferences")); - menu.append_section(None, &prefs_section); - let app_section = gio::Menu::new(); - app_section.append(Some(&crate::i18n::gettext("Keyboard Shortcuts")), Some("app.shortcuts")); - app_section.append(Some(&crate::i18n::gettext("About TablePro")), Some("app.about")); - app_section.append(Some(&crate::i18n::gettext("Quit")), Some("app.quit")); - menu.append_section(None, &app_section); - menu -} - -/// Flip the actions that need the query database whenever -/// `HistoryService` changes state. -/// -/// The watch channel is read on the GTK thread, so the actions are only -/// ever touched from the thread that owns them. -fn spawn_history_gate( - actions: Vec, - mut availability: tokio::sync::watch::Receiver, -) -> glib::JoinHandle<()> { - glib::spawn_future_local(async move { - loop { - let state = availability.borrow_and_update().clone(); - for action in &actions { - action.set_enabled(state.is_ready()); - } - if let Some(failure) = state.failure() { - tracing::warn!(error = failure, "query history stays unavailable"); - } - if availability.changed().await.is_err() { - return; - } - } - }) -} - -/// The actions the model keeps a handle on because their enabled -/// state changes after the window is built. -struct WindowActions { - disconnect: gio::SimpleAction, - show_history: gio::SimpleAction, - show_saved_queries: gio::SimpleAction, -} - -/// The window's actions go on the window's own action map, which GTK -/// already exports as `win`: a second group inserted under that name -/// would hide it from the application's accelerators and from D-Bus. -fn install_window_actions(window: &adw::ApplicationWindow, sender: &relm4::Sender) -> WindowActions { - macro_rules! input_action { - ($name:expr, $msg:expr) => {{ - let s = sender.clone(); - gio::ActionEntry::::builder($name) - .activate(move |_, _, _| s.deliver($msg)) - .build() - }}; - } - - window.add_action_entries([ - input_action!("open-editor", AppMsg::NewEditorTab), - input_action!("close-current", AppMsg::CloseActiveWorkspaceTab), - input_action!("refresh-page", AppMsg::RefreshPage), - input_action!("save-changes", AppMsg::SaveActiveTab), - input_action!("undo-change", AppMsg::UndoActiveBrowseTab), - input_action!("redo-change", AppMsg::RedoActiveBrowseTab), - input_action!("reopen-closed-tab", AppMsg::ReopenClosedTab), - input_action!("open-filter", AppMsg::ShowFilterDialog), - input_action!("save-query", AppMsg::SaveActiveQuery), - input_action!("export-connections", AppMsg::ExportConnections), - input_action!("import-connections", AppMsg::ImportConnections), - input_action!("reset-workspace-layout", AppMsg::ResetWorkspaceLayout), - ]); - let disconnect = gio::SimpleAction::new("disconnect", None); - let sender_for_disconnect = sender.clone(); - disconnect.connect_activate(move |_, _| sender_for_disconnect.deliver(AppMsg::Disconnect)); - disconnect.set_enabled(false); - window.add_action(&disconnect); - - // The history database opens on a background task, so the menu item - // and Ctrl+H stay insensitive until `HistoryService` reports Ready. - let show_history = gio::SimpleAction::new("show-history", None); - let sender_for_history = sender.clone(); - show_history.connect_activate(move |_, _| sender_for_history.deliver(AppMsg::ShowHistory)); - show_history.set_enabled(false); - window.add_action(&show_history); - - // Saved queries live in the same database, so they are reachable - // exactly when it is. - let show_saved_queries = gio::SimpleAction::new("show-saved-queries", None); - let sender_for_saved = sender.clone(); - show_saved_queries.connect_activate(move |_, _| sender_for_saved.deliver(AppMsg::ShowSavedQueries)); - show_saved_queries.set_enabled(false); - window.add_action(&show_saved_queries); - - WindowActions { - disconnect, - show_history, - show_saved_queries, - } -} - -/// Quit, New Window and Preferences are application accelerators, which -/// GTK runs before these, so none of their keys is bound here. -pub(crate) fn install_window_shortcuts(window: &adw::ApplicationWindow) { - use gtk::gdk::{Key, ModifierType}; - let primary = ModifierType::CONTROL_MASK; - let primary_shift = ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK; - let controller = gtk::ShortcutController::new(); - controller.set_scope(gtk::ShortcutScope::Global); - controller.add_shortcut(make_shortcut(Key::w, primary, "win.close-current")); - controller.add_shortcut(make_shortcut(Key::e, primary, "win.open-editor")); - // Ctrl+T mirrors Ctrl+E for the browser/IDE muscle memory ("new - // tab"). Both fire `win.open-editor` so the empty workspace state - // can be exited via either shortcut without focus tricks. - controller.add_shortcut(make_shortcut(Key::t, primary, "win.open-editor")); - controller.add_shortcut(make_shortcut(Key::F5, ModifierType::empty(), "win.refresh-page")); - controller.add_shortcut(make_shortcut(Key::f, primary, "win.open-filter")); - controller.add_shortcut(make_shortcut(Key::h, primary, "win.show-history")); - controller.add_shortcut(make_shortcut(Key::s, primary, "win.save-changes")); - controller.add_shortcut(make_shortcut(Key::z, primary, "win.undo-change")); - controller.add_shortcut(make_shortcut(Key::y, primary, "win.redo-change")); - controller.add_shortcut(make_shortcut(Key::z, primary_shift, "win.redo-change")); - controller.add_shortcut(make_shortcut(Key::t, primary_shift, "win.reopen-closed-tab")); - controller.add_shortcut(make_shortcut(Key::s, primary_shift, "win.save-query")); - window.add_controller(controller); -} - -fn make_shortcut(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, action: &str) -> gtk::Shortcut { - gtk::Shortcut::builder() - .trigger(>k::KeyvalTrigger::new(key, modifiers)) - .action(>k::NamedAction::new(action)) - .build() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn shortcut_action_names(window: &adw::ApplicationWindow) -> Vec { - let controllers = window.observe_controllers(); - let mut names = Vec::new(); - for index in 0..controllers.n_items() { - let Some(controller) = controllers.item(index).and_downcast::() else { - continue; - }; - for position in 0..controller.n_items() { - let Some(shortcut) = controller.item(position).and_downcast::() else { - continue; - }; - if let Some(action) = shortcut.action().and_downcast::() { - names.push(action.action_name().to_string()); - } - } - } - names - } - - #[gtk4::test] - fn window_shortcuts_leave_the_application_keys_alone() { - let window = adw::ApplicationWindow::builder().build(); - - install_window_shortcuts(&window); - let names = shortcut_action_names(&window); - - assert!(names.iter().any(|name| name == "win.close-current"), "{names:?}"); - for owned_by_the_application in ["win.quit", "win.preferences", "win.shortcuts"] { - assert!( - !names.iter().any(|name| name == owned_by_the_application), - "{owned_by_the_application} is an application action now: {names:?}" - ); - } - } - - #[gtk4::test] - fn window_actions_live_on_the_windows_own_map() { - let window = adw::ApplicationWindow::builder().build(); - let (sender, receiver) = relm4::channel::(); - - let actions = install_window_actions(&window, &sender); - - assert!(window.lookup_action("open-editor").is_some()); - assert!( - window - .lookup_action("disconnect") - .is_some_and(|action| !action.is_enabled()) - ); - for moved in ["quit", "about", "preferences"] { - assert!( - window.lookup_action(moved).is_none(), - "win.{moved} is still on the window" - ); - } - WidgetExt::activate_action(&window, "win.open-editor", None).expect("win resolves to the window's own map"); - assert!(matches!(receiver.recv_sync(), Some(AppMsg::NewEditorTab))); - drop(actions); - } - - #[gtk4::test] - fn the_history_gate_follows_availability() { - use crate::services::history_availability::HistoryAvailability; - - let action = gio::SimpleAction::new("show-history", None); - action.set_enabled(false); - let (sender, receiver) = tokio::sync::watch::channel(HistoryAvailability::Starting); - let gate = spawn_history_gate(vec![action.clone()], receiver); - - crate::test_support::drain_main_context(); - assert!(!action.is_enabled(), "enabled while the database was still opening"); - - sender.send_replace(HistoryAvailability::Ready); - crate::test_support::drain_main_context(); - assert!(action.is_enabled(), "stayed disabled after the database opened"); - - sender.send_replace(HistoryAvailability::Failed("no disk".to_owned())); - crate::test_support::drain_main_context(); - assert!(!action.is_enabled(), "stayed enabled after the database failed"); - - gate.abort(); - } -} diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 32a0efa885..ccc4b5c3b6 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -145,18 +145,24 @@ impl SqlEditor { } } - /// Close the tab's connection. The process is held until the close - /// ends, so quitting right after closing a tab does not cut it off. - pub fn shutdown(&self) { + /// Stop the running query and hand over the tab's connection, so a + /// teardown closes every tab's at once under one hold. + pub fn take_editor_session(&self) -> Option> { let imp = self.imp(); if imp.shut_down.replace(true) { - return; + return None; } if let Some(running) = imp.running.take() { running.cancel.cancel(); running.drain.abort(); } - let (Some(editor), Some(deps)) = (imp.slot.close(), imp.deps.get()) else { + imp.slot.close() + } + + /// Close the tab's connection. The process is held until the close + /// ends, so quitting right after closing a tab does not cut it off. + pub fn shutdown(&self) { + let (Some(editor), Some(deps)) = (self.take_editor_session(), self.deps()) else { return; }; let hold = deps.hold.acquire(); diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 11e60c8547..dd7f6d3c85 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -1,4 +1,3 @@ -mod app; mod browse_tab; mod connect_dialog; mod connection_list; @@ -19,6 +18,7 @@ pub(crate) mod icons; mod import_dialog; mod preferences_dialog; mod saved_queries_dialog; +mod session_window; mod sidebar_row; mod ssh_inputs; mod ssh_section; @@ -28,12 +28,13 @@ mod style_scheme_grid; mod style_scheme_resolver; mod welcome_view; mod window_geometry; +mod workspace; -#[cfg(test)] -pub(crate) use app::install_window_shortcuts; -pub use app::{App, AppInit, AppMsg}; #[cfg(test)] pub(crate) use browse_tab::BrowseTab; #[cfg(test)] pub(crate) use editor::{SqlEditor, SqlEditorInit}; pub(crate) use preferences_dialog::PreferencesDialog; +#[cfg(test)] +pub(crate) use session_window::install_window_shortcuts; +pub(crate) use session_window::{SessionWindow, SessionWindowInit, SessionWindowInput}; diff --git a/linux/crates/app/src/ui/session_window/connect_failed_page.rs b/linux/crates/app/src/ui/session_window/connect_failed_page.rs new file mode 100644 index 0000000000..21e961ff1d --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -0,0 +1,67 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/connect-failed-page.ui")] + pub struct ConnectFailedPage { + #[template_child] + pub status_page: TemplateChild, + #[template_child] + pub edit_button: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConnectFailedPage { + const NAME: &'static str = "TableProConnectFailedPage"; + type Type = super::ConnectFailedPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ConnectFailedPage {} + impl WidgetImpl for ConnectFailedPage {} + impl BinImpl for ConnectFailedPage {} +} + +glib::wrapper! { + /// A connect that failed: why, and Retry, Edit Connection and + /// Choose Connection as the ways on. + pub struct ConnectFailedPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ConnectFailedPage { + /// `edit` labels the button that opens the connect dialog, and is + /// `None` for a connection the dialog cannot edit, such as a file + /// nobody saved. + pub fn new(message: &str, edit: Option<&str>) -> Self { + let page: Self = glib::Object::new(); + let imp = page.imp(); + imp.status_page.set_description(Some(message)); + if let Some(label) = edit { + imp.edit_button.set_label(label); + imp.edit_button.set_visible(true); + } + page + } + + #[cfg(test)] + pub fn message(&self) -> Option { + self.imp().status_page.description().map(|text| text.to_string()) + } +} diff --git a/linux/crates/app/src/ui/app/connect_flow.rs b/linux/crates/app/src/ui/session_window/connect_flow.rs similarity index 96% rename from linux/crates/app/src/ui/app/connect_flow.rs rename to linux/crates/app/src/ui/session_window/connect_flow.rs index 6701894e9d..af426a187a 100644 --- a/linux/crates/app/src/ui/app/connect_flow.rs +++ b/linux/crates/app/src/ui/session_window/connect_flow.rs @@ -9,7 +9,7 @@ use tablepro_session::connect::ConnectionWrites; use tablepro_session::{ ConnectError, ConnectRequest, Connector, CredentialSource, PendingSave, PendingSession, PersistReport, }; -use tablepro_storage::{CredentialDemand, KeyringCredentials, SavedConnection, StorageError}; +use tablepro_storage::{CredentialDemand, SavedConnection, StorageError}; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -75,7 +75,7 @@ async fn unsave(secrets: &dyn SecretVault, writes: &dyn ConnectionWrites, id: Uu pub(super) async fn connect_saved( saved: &SavedConnection, drivers: &DriverRegistry, - credentials: &KeyringCredentials, + credentials: &dyn CredentialSource, connector: &dyn Connector, interaction: CredentialInteraction, cancel: CancellationToken, @@ -101,6 +101,18 @@ pub(super) async fn connect_saved( Ok((pending, refused)) } +/// Connect to a request that carries every secret it needs, such as a +/// file nobody saved. +pub(super) async fn connect_request( + connector: &dyn Connector, + request: ConnectRequest, + interaction: CredentialInteraction, + cancel: CancellationToken, +) -> Result { + let pending = connector.establish(request, interaction, cancel.clone()).await?; + unless_cancelled(pending, &cancel).await +} + /// A driver blocked in a C call can still connect after the user /// cancelled. That connection is closed here rather than saved or kept. async fn unless_cancelled(pending: PendingSession, cancel: &CancellationToken) -> Result { @@ -126,7 +138,7 @@ mod tests { use tablepro_core::{AuthMode, TlsConfig}; use tablepro_session::connect::StoreConnectionWrites; use tablepro_session::fakes::{FakeConnector, FakeEstablish}; - use tablepro_storage::{ConnectionStore, StoragePaths}; + use tablepro_storage::{ConnectionStore, KeyringCredentials, StoragePaths}; use tablepro_test_support::{ConnectionScript, FakeDriver, FakeSecretVault}; use super::*; diff --git a/linux/crates/app/src/ui/session_window/connecting_page.rs b/linux/crates/app/src/ui/session_window/connecting_page.rs new file mode 100644 index 0000000000..11f690353a --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connecting_page.rs @@ -0,0 +1,53 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/connecting-page.ui")] + pub struct ConnectingPage { + #[template_child] + pub status_page: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for ConnectingPage { + const NAME: &'static str = "TableProConnectingPage"; + type Type = super::ConnectingPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for ConnectingPage {} + impl WidgetImpl for ConnectingPage {} + impl BinImpl for ConnectingPage {} +} + +glib::wrapper! { + /// A connect on its way, which `win.cancel-connect` stops. A remote + /// or tunnelled connect can take the whole connect timeout. + pub struct ConnectingPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl ConnectingPage { + pub fn new(name: &str) -> Self { + let page: Self = glib::Object::new(); + page.imp() + .status_page + .set_title(&crate::i18n::gettext_f("Connecting to {name}", &[("name", name)])); + page + } +} diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs new file mode 100644 index 0000000000..a68bd0e411 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -0,0 +1,657 @@ +//! The saved connections a window's picker lists, and the connect +//! dialog that adds to them. + +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::ComponentController; + +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::{ConnectSource, PendingSave, RequestTarget}; +use tablepro_storage::SavedConnection; +use uuid::Uuid; + +use super::{SessionWindow, SessionWindowInput}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::ui::connect_dialog::{ConnectDialog, ConnectDialogInit, ConnectPrefill, FormField}; +use crate::ui::file_read::{FileReadError, read_capped_file}; +use crate::ui::ssh_inputs::SshEnvironment; + +/// The password the user typed, so editing after a failure does not +/// make them type it again. It stays in memory. +fn typed_password(target: &RequestTarget) -> Option { + match target { + RequestTarget::Network { + credentials: tablepro_core::Credentials::Password { password, .. }, + .. + } => Some(password.clone()), + _ => None, + } +} + +/// What the connect dialog opens on to edit a connection that failed: +/// the connection as it was submitted, with the password typed for it. +pub(super) fn prefill_for(source: &ConnectSource) -> Option { + match source { + ConnectSource::Saved(saved) => Some(ConnectPrefill { + connection: (**saved).clone(), + password: None, + }), + ConnectSource::Intent(intent) => intent.pending_save.as_ref().map(|save| ConnectPrefill { + connection: save.connection.clone(), + password: typed_password(&intent.request.target), + }), + ConnectSource::File(_) => None, + } +} + +impl SessionWindow { + /// Show the connect dialog over this window, filled from `prefill` + /// when a connect from it failed, with the focus on `focus`. + pub(super) fn present_connect_dialog(&self, prefill: Option, focus: Option) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let context = &state.context; + let dialog = ConnectDialog::new(ConnectDialogInit { + drivers: context.drivers.clone(), + connector: context.connector.clone(), + interaction: context.attended(), + tasks: state.services.tasks.clone(), + saved_connections: Arc::from(self.imp().saved_connections.borrow().clone()), + remembered_driver: state.services.settings.connect_dialog_driver(), + ssh_environment: SshEnvironment::detect(), + prefill, + }); + let submitted = input.clone(); + dialog.connect_submitted(move |_, submission| { + submitted.deliver(SessionWindowInput::ConnectFromDialog(Box::new(submission))); + }); + dialog.connect_closed(move |_| input.deliver(SessionWindowInput::DialogClosed)); + dialog.present(Some(self)); + if let Some(field) = focus { + dialog.reveal_field(field); + } + self.imp().dialog.replace(Some(dialog)); + } + + /// Keep the dialog's edits to a connection open in another window, + /// for its next connect. The keyring is written first, so a secret + /// it refuses is reported rather than lost without a word. + pub(super) fn persist_pending_save(&self, save: PendingSave) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let secrets = state.context.secrets.clone(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + drop(state.services.tasks.spawn_task(async move { + match tablepro_session::persist_pending_save(&*secrets, &writes, save).await { + Ok(report) => { + for (_, error) in &report.secret_failures { + input.deliver(SessionWindowInput::ShowToast( + crate::services::secret_save_report::not_saved_message(error), + )); + } + } + Err(error) => { + tracing::warn!(%error, "could not save the edited connection"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext_f( + "The connection could not be saved: {error}", + &[("error", &error.to_string())], + ))); + } + } + input.deliver(SessionWindowInput::ReloadConnections); + })); + } + + /// Stamp the connection's last-opened time, then read the list again + /// so the picker sorts by it. The read waits for the stamp, or it + /// would show the old order until the next one. + pub(super) fn touch_last_opened(&self, id: Uuid) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let touching = state + .services + .tasks + .spawn_blocking_task(move || connections.touch_last_opened_blocking(id)); + glib::spawn_future_local(async move { + match touching.await { + Ok(Err(error)) => tracing::warn!(%error, "could not stamp the last-opened time"), + Err(error) => tracing::warn!(%error, "the last-opened task failed"), + Ok(Ok(())) => {} + } + input.deliver(SessionWindowInput::ReloadConnections); + }); + } + + pub(super) fn on_reload_connections(&self) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let store = state.services.storage.connections().clone(); + let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); + glib::spawn_future_local(async move { + match reading.await { + Ok(Ok(connections)) => input.deliver(SessionWindowInput::ConnectionsLoaded(connections.to_vec())), + Ok(Err(error)) => { + tracing::warn!(%error, "the saved connections could not be read"); + input.deliver(SessionWindowInput::ConnectionListUnavailable); + } + Err(failure) => tracing::warn!(%failure, "the connections read task failed"), + } + }); + } + + pub(super) fn on_connections_loaded(&self, mut connections: Vec) { + crate::ui::connection_list::sort(&mut connections); + self.imp().saved_connections.replace(connections.clone()); + if let Some(welcome) = self.imp().welcome.borrow().as_ref() { + welcome + .sender() + .deliver(crate::ui::welcome_view::WelcomeViewInput::SetConnections(connections)); + } + } + + fn saved_connection(&self, id: Uuid) -> Option { + self.imp() + .saved_connections + .borrow() + .iter() + .find(|saved| saved.id == id) + .cloned() + } + + pub(super) fn on_delete_connection(&self, id: Uuid) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + // The delete takes the keyring secrets with it and has no undo, + // so it asks whatever the preferences say. + let connection_name = self + .saved_connection(id) + .map_or_else(|| crate::i18n::gettext("this connection"), |saved| saved.name); + let title = crate::i18n::gettext_f("Delete {name}?", &[("name", &connection_name)]); + let body = crate::i18n::gettext( + "The saved entry, any stored passwords and the queries saved under this connection \ + will be removed. This cannot be undone.", + ); + let dialog = adw::AlertDialog::new(Some(&title), Some(&body)); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("delete", &crate::i18n::gettext("Delete")); + dialog.set_response_appearance("delete", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let storage = state.services.storage.clone(); + let tasks = state.services.tasks.clone(); + let saved_queries = state.services.history().store().map(|history| history.saved_queries()); + dialog.connect_response(None, move |dialog, response| { + dialog.close(); + if response != "delete" { + return; + } + // Nothing can reach these once the entry is gone, so they + // go with it rather than accumulating in the state files. + storage.column_widths().forget_connection(id); + storage.filter_settings().forget_connection(id); + execute_delete_connection( + storage.connections().clone(), + storage.secrets().clone(), + tasks.clone(), + saved_queries.clone(), + id, + input.clone(), + ); + }); + dialog.present(Some(self)); + } + + /// Copy a saved connection so a second database on the same server + /// does not have to be typed out again. + /// + /// The copy takes a new id, so it carries its own secrets rather + /// than sharing the original's: removing either then leaves the + /// other able to connect. + pub(super) fn on_duplicate_connection(&self, source: SavedConnection) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let copy = tablepro_storage::duplicate(&source, &self.imp().saved_connections.borrow()); + let name = copy.name.clone(); + let connections = state.services.storage.connections().clone(); + let secrets = state.services.storage.secrets().clone(); + let tasks = state.services.tasks.clone(); + drop(state.services.tasks.spawn_task(async move { + let copy_id = copy.id; + let saved = tasks + .spawn_blocking_task(move || connections.upsert_blocking(copy)) + .await; + if let Ok(Err(error)) = saved { + tracing::warn!(%error, "could not save the duplicated connection"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The connection could not be duplicated.", + ))); + return; + } + // A copy with no password still connects once the user types + // one, so a keyring that refuses is reported rather than + // undoing the copy. + let carried = copy_secrets(secrets.as_ref(), source.id, copy_id, &name).await; + input.deliver(SessionWindowInput::ReloadConnections); + input.deliver(SessionWindowInput::ShowToast(if carried { + crate::i18n::gettext_f("Duplicated as {name}", &[("name", &name)]) + } else { + crate::i18n::gettext_f( + "Duplicated as {name}. Its password could not be copied, so enter it again.", + &[("name", &name)], + ) + })); + })); + } + + /// File a connection under a group, or take it out of the one it + /// is in. An empty name means no group. + pub(super) fn on_set_connection_group(&self, id: Uuid, group: Option) { + let group = group.map(|name| name.trim().to_owned()).filter(|name| !name.is_empty()); + let Some(mut saved) = self.saved_connection(id) else { + return; + }; + if saved.group == group { + return; + } + saved.group = group; + self.write_connection(saved, crate::i18n::gettext("The group could not be saved.")); + } + + /// Put a colour on a connection, or take the one it has off. + /// + /// The whole entry is rewritten because that is what the store + /// takes, and it comes from the list the window already holds rather + /// than from a re-read: the row that asked is showing that entry. + pub(super) fn on_set_connection_color(&self, id: Uuid, color: Option) { + let Some(mut saved) = self.saved_connection(id) else { + return; + }; + if saved.color == color { + return; + } + saved.color = color; + self.write_connection(saved, crate::i18n::gettext("The colour could not be saved.")); + } + + /// Write one connection back and reload the list from disk, so the + /// picker redraws from what was actually saved. + fn write_connection(&self, saved: SavedConnection, failure: String) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let tasks = state.services.tasks.clone(); + drop(state.services.tasks.spawn_task(async move { + let written = tasks + .spawn_blocking_task(move || connections.upsert_blocking(saved)) + .await; + if let Ok(Err(error)) = written { + tracing::warn!(%error, "could not save the connection"); + input.deliver(SessionWindowInput::ShowToast(failure)); + } + input.deliver(SessionWindowInput::ReloadConnections); + })); + } + + /// Write the connection list to a file the user picks. + /// + /// The file carries no passwords, and the dialog's name says what + /// it does carry, because it is going somewhere the keyring does + /// not protect. + pub(super) fn on_export_connections(&self) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let reading = state + .services + .tasks + .spawn_blocking_task(move || connections.export_blocking()); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let error = match reading.await { + Ok(Ok(bytes)) => { + if let Some(window) = window.upgrade() { + ask_where_to_export(&window, bytes, input); + } + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not read the connections to export"); + input.deliver(SessionWindowInput::ShowToast(crate::i18n::gettext( + "The connections could not be read.", + ))); + }); + } + + /// Ask for an exported file and read it off the GTK thread. + pub(super) fn on_import_connections(&self) { + let Some(input) = self.input() else { + return; + }; + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Import Connections")) + .modal(true) + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.open(Some(self), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let path = file.path().unwrap_or_default(); + input.deliver(match read_connections_file(&file).await { + Ok(bytes) => SessionWindowInput::ConnectionsFileRead { path, bytes }, + Err(message) => SessionWindowInput::ShowToast(message), + }); + }); + }); + } + + pub(super) fn on_connections_file_read(&self, path: std::path::PathBuf, bytes: Vec) { + let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; + }; + let connections = state.services.storage.connections().clone(); + let tasks = state.services.tasks.clone(); + drop(state.services.tasks.spawn_task(async move { + let imported = tasks + .spawn_blocking_task(move || connections.import_blocking(&path, &bytes)) + .await; + let message = match imported { + Ok(Ok(report)) if report.total() == 0 => crate::i18n::gettext("That file has no connections in it."), + Ok(Ok(report)) => crate::i18n::ngettext_f( + "Imported {n} connection.", + "Imported {n} connections.", + u32::try_from(report.total()).unwrap_or(u32::MAX), + &[("n", &report.total().to_string())], + ), + Ok(Err(error)) => { + tracing::warn!(%error, "could not import the connections"); + crate::i18n::gettext("That file is not a TablePro connection export.") + } + Err(error) => { + tracing::warn!(%error, "the import task did not finish"); + crate::i18n::gettext("The connections could not be imported.") + } + }; + input.deliver(SessionWindowInput::ReloadConnections); + input.deliver(SessionWindowInput::ShowToast(message)); + })); + } + + /// The list could not be read, so the user is told rather than shown + /// an empty picker that looks like their connections vanished. + pub(super) fn show_connection_list_banner(&self) { + let (Some(state), Some(picker)) = (self.state(), self.imp().picker.get()) else { + return; + }; + let banner = picker.banner(); + let Some(content) = + crate::ui::connection_list_banner::banner_content(&state.services.storage.connections().snapshot().state) + else { + banner.set_revealed(false); + return; + }; + banner.set_title(&content.title); + banner.set_button_label(Some(&content.button_label)); + banner.set_revealed(true); + } + + /// The keyring refused, so the entry stays and the user is offered + /// another go rather than being left with orphaned secrets. + pub(super) fn on_secret_delete_failed(&self, id: Uuid) { + let Some(input) = self.input() else { + return; + }; + let toast = adw::Toast::new(&crate::i18n::gettext( + "The stored passwords could not be deleted, so the connection was kept.", + )); + toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); + toast.connect_button_clicked(move |_| input.deliver(SessionWindowInput::DeleteConnection(id))); + self.imp().toast_overlay.add_toast(toast); + } + + pub(super) fn on_reset_connection_list(&self) { + let (Some(state), Some(input), Some(picker)) = (self.state(), self.input(), self.imp().picker.get()) else { + return; + }; + let dialog = adw::AlertDialog::new( + Some(&crate::i18n::gettext("Reset Saved Connections?")), + Some(&crate::i18n::gettext( + "The unreadable file will be renamed so it can be recovered. Stored passwords stay in the keyring.", + )), + ); + dialog.add_response("cancel", &crate::i18n::gettext("Cancel")); + dialog.add_response("reset", &crate::i18n::gettext("Reset")); + dialog.set_response_appearance("reset", adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some("cancel")); + dialog.set_close_response("cancel"); + + let store = state.services.storage.connections().clone(); + let tasks = state.services.tasks.clone(); + let banner = picker.banner(); + let toasts = self.imp().toast_overlay.get(); + dialog.connect_response(None, move |dialog, response| { + dialog.close(); + if response != "reset" { + return; + } + let store = store.clone(); + let resetting = + tasks.spawn_blocking_task(move || store.reset_unreadable_blocking(std::time::SystemTime::now())); + let (banner, toasts, input) = (banner.clone(), toasts.clone(), input.clone()); + glib::spawn_future_local(async move { + let error = match resetting.await { + Ok(Ok(moved)) => { + banner.set_revealed(false); + toasts.add_toast(adw::Toast::new(&crate::ui::connection_list_banner::reset_toast_text( + &moved, + ))); + input.deliver(SessionWindowInput::ReloadConnections); + return; + } + Ok(Err(error)) => error.to_string(), + Err(error) => error.to_string(), + }; + tracing::warn!(%error, "could not reset the saved connections"); + toasts.add_toast(adw::Toast::new(&crate::i18n::gettext_f( + "Could not reset the saved connections: {error}", + &[("error", &error)], + ))); + }); + }); + dialog.present(Some(self)); + } +} + +/// Move every secret the original holds onto the copy's own id. +/// +/// Returns whether all of them made it: a keyring that is locked or +/// absent is a reason to tell the user, not to refuse the copy. +async fn copy_secrets( + secrets: &dyn tablepro_core::credentials::SecretVault, + from: Uuid, + to: Uuid, + label: &str, +) -> bool { + use tablepro_core::credentials::{SecretKind, SecretLookup}; + + let mut carried = true; + for kind in SecretKind::ALL { + match secrets.load(from, kind).await { + Ok(SecretLookup::Found(secret)) => { + if let Err(error) = secrets.store(to, kind, &secret, label).await { + tracing::warn!(%error, ?kind, "could not copy a secret onto the duplicate"); + carried = false; + } + } + // Nothing stored for this kind, so nothing to carry. + Ok(SecretLookup::NotStored) => {} + Err(error) => { + tracing::warn!(%error, ?kind, "could not read a secret to duplicate"); + carried = false; + } + } + } + carried +} + +fn execute_delete_connection( + connections: tablepro_storage::ConnectionStore, + secrets: Arc, + tasks: tablepro_session::runtime::Tasks, + saved_queries: Option, + id: Uuid, + input: InputSender, +) { + let spawner = tasks.clone(); + drop(spawner.spawn_task(async move { + // Secrets first: an entry with no secrets is recoverable, a + // secret with no entry is orphaned in the keyring. + if let Err(error) = secrets.delete_connection(id).await { + tracing::warn!(%error, "could not delete the connection secrets; keeping the entry"); + input.deliver(SessionWindowInput::SecretDeleteFailed(id)); + return; + } + let removed = tasks.spawn_blocking_task(move || connections.remove_blocking(id)).await; + if let Ok(Err(error)) = removed { + tracing::warn!(%error, "could not remove the saved connection"); + } + // The queries saved here name tables only this database has, so + // they go with it. A failure leaves them listed under the + // connection's last name, which the user can still delete one + // by one. + if let Some(saved_queries) = saved_queries + && let Err(error) = saved_queries.delete_for_connection(id).await + { + tracing::warn!(%error, "could not remove the connection's saved queries"); + } + input.deliver(SessionWindowInput::ReloadConnections); + })); +} + +fn ask_where_to_export(window: &SessionWindow, bytes: Vec, input: InputSender) { + let dialog = gtk::FileDialog::builder() + .title(crate::i18n::gettext("Export Connections")) + .modal(true) + .initial_name("tablepro-connections.json") + .default_filter(&json_filter()) + .filters(&json_filters()) + .build(); + dialog.save(Some(window), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + glib::spawn_future_local(async move { + let message = match crate::ui::file_write::replace_file(&file, bytes).await { + Ok(()) => crate::i18n::gettext("Connections exported."), + Err(error) => { + tracing::warn!(%error, uri = %file.uri(), "could not write the exported connections"); + crate::i18n::gettext("The connections could not be written to that file.") + } + }; + input.deliver(SessionWindowInput::ShowToast(message)); + }); + }); +} + +/// An export holds names, hosts and options, never secrets, so even +/// thousands of connections come to well under this. +const MAX_CONNECTIONS_FILE_BYTES: usize = 8 * 1024 * 1024; + +/// The chosen export, whole, or what to tell the user instead. +async fn read_connections_file(file: &gio::File) -> Result, String> { + read_capped_file(file, MAX_CONNECTIONS_FILE_BYTES) + .await + .map_err(|error| match error { + FileReadError::NotRegular => crate::i18n::gettext("Only regular files can be imported."), + FileReadError::TooLarge => crate::i18n::gettext("That file is too large to be a connection export."), + FileReadError::Io(error) => { + tracing::warn!(%error, "could not read the file to import"); + crate::i18n::gettext("That file could not be read.") + } + }) +} + +/// The filter both file dialogs open with. Built fresh each time +/// because a GtkFileFilter belongs to the dialog it is given to. +fn json_filter() -> gtk::FileFilter { + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::i18n::gettext("JSON files"))); + filter.add_suffix("json"); + filter.add_mime_type("application/json"); + filter +} + +fn json_filters() -> gio::ListStore { + let filters = gio::ListStore::new::(); + filters.append(&json_filter()); + filters +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + fn read(path: &std::path::Path) -> Result, String> { + let file = gio::File::for_path(path); + crate::test_support::finish_on_main( + Duration::from_secs(10), + async move { read_connections_file(&file).await }, + ) + .expect("the read to end") + } + + #[gtk4::test] + fn an_import_from_a_pipe_is_refused_without_waiting_for_a_writer() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + let made = std::process::Command::new("mkfifo") + .arg(&path) + .status() + .expect("mkfifo"); + assert!(made.success()); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("regular files"), "{refused}"); + } + + #[gtk4::test] + fn an_import_past_the_limit_is_refused() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, vec![b' '; MAX_CONNECTIONS_FILE_BYTES + 1]).expect("write"); + + let refused = read(&path).expect_err("a refusal"); + + assert!(refused.contains("too large"), "{refused}"); + } + + #[gtk4::test] + fn an_export_is_read_whole() { + let root = tempfile::tempdir().expect("tempdir"); + let path = root.path().join("connections.json"); + std::fs::write(&path, br#"{"connections":[]}"#).expect("write"); + + assert_eq!(read(&path).as_deref(), Ok(&br#"{"connections":[]}"#[..])); + } +} diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs new file mode 100644 index 0000000000..ae508ca3bd --- /dev/null +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -0,0 +1,78 @@ +use std::cell::{Cell, OnceCell, RefCell}; +use std::rc::Rc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; +use relm4::Controller; +use tablepro_session::ConnectAttempts; +use tablepro_storage::SavedConnection; + +use super::picker_page::PickerPage; +use super::session_page::SessionPage; +use super::{SessionWindowInput, WindowState}; +use crate::runtime::InputSender; +use crate::ui::connect_dialog::ConnectDialog; +use crate::ui::welcome_view::WelcomeView; +use crate::workspace::{CloseGuards, WindowInhibitor}; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/session-window.ui")] +pub struct SessionWindow { + #[template_child] + pub toast_overlay: TemplateChild, + + /// What the application handed the window, set once it is added. + pub(super) state: OnceCell, + pub(super) input: OnceCell>, + /// Handles the queued input. Aborted at shutdown, which drops every + /// reply still on its way. + pub(super) pump: RefCell>>, + /// Ends the credential prompt loop with the window. + pub(super) prompt_presenter: RefCell>>, + pub(super) page: RefCell, + /// Which connect attempt is current, so a late result is discarded. + pub(super) attempts: ConnectAttempts, + /// One close prompt at a time, for a tab, the window or a + /// disconnect. + pub(super) close_guards: OnceCell>, + /// Holds logout back while the workspace has something to lose. + pub(super) inhibitor: RefCell>, + pub(super) dirty: Cell, + pub(super) saving: Cell, + pub(super) saved_connections: RefCell>, + pub(super) welcome: RefCell>>, + pub(super) picker: OnceCell, + pub(super) dialog: RefCell>, + pub(super) shut_down: Cell, +} + +#[glib::object_subclass] +impl ObjectSubclass for SessionWindow { + const NAME: &'static str = "TableProSessionWindow"; + type Type = super::SessionWindow; + type ParentType = adw::ApplicationWindow; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for SessionWindow { + /// A window dropped without its close request still closes what it + /// holds. + fn dispose(&self) { + self.obj().shutdown(); + } +} + +impl WidgetImpl for SessionWindow {} +impl WindowImpl for SessionWindow {} +impl ApplicationWindowImpl for SessionWindow {} +impl AdwApplicationWindowImpl for SessionWindow {} diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs new file mode 100644 index 0000000000..ae3b4e4a3e --- /dev/null +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -0,0 +1,864 @@ +//! One window per connection session. It opens on the picker, shows +//! the connect while it runs and the failure when it fails, then the +//! session's workspace. A disconnect tears the workspace down in order +//! and returns to the picker; it never moves to another session. + +mod connect_failed_page; +mod connect_flow; +mod connecting_page; +mod connections; +mod imp; +mod picker_page; +mod resolve_file_identity; +mod session_page; +mod session_teardown; +mod session_window_command; +mod session_window_input; +#[cfg(test)] +pub(crate) mod tests; +mod window_actions; + +use std::rc::Rc; + +use futures::FutureExt; +use futures::future::BoxFuture; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use relm4::prelude::*; + +use tablepro_session::connect::StoreConnectionWrites; +use tablepro_session::registry::FileIdentity; +use tablepro_session::{ + ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, Session, SessionClaim, + SessionKey, +}; +use tablepro_storage::SavedConnection; + +use self::connect_failed_page::ConnectFailedPage; +use self::connecting_page::ConnectingPage; +use self::picker_page::PickerPage; +use self::resolve_file_identity::{FileResolveError, FileTarget, ResolvedFile, resolve_file_blocking}; +use self::session_page::SessionPage; +use self::session_window_command::{AttemptError, ConnectFailure, Connected, SessionWindowCommand}; +pub(crate) use self::session_window_input::SessionWindowInput; +pub(crate) use self::window_actions::install_window_shortcuts; +use crate::application::{AppServices, WindowHost, WindowLaunch}; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::session_context::SessionContext; +use crate::ui::connect_dialog::ConnectSubmission; +use crate::ui::welcome_view::{WelcomeView, WelcomeViewInit, WelcomeViewOutput}; +use crate::ui::workspace::{Workspace, WorkspaceInit, WorkspaceInput}; +use crate::workspace::{ + ApplicationInhibit, CloseDecision, CloseGuards, CloseReason, InhibitReason, WindowInhibitor, WorkspaceHandle, +}; + +glib::wrapper! { + pub struct SessionWindow(ObjectSubclass) + @extends adw::ApplicationWindow, gtk::ApplicationWindow, gtk::Window, gtk::Widget, + @implements gio::ActionGroup, gio::ActionMap, gtk::Accessible, gtk::Buildable, + gtk::ConstraintTarget, gtk::Native, gtk::Root, gtk::ShortcutManager; +} + +/// What the application hands each window it opens. +pub struct SessionWindowInit { + pub services: Rc, + pub host: Rc, + pub launch: WindowLaunch, +} + +/// What a window keeps from its init for as long as it is open. +pub(crate) struct WindowState { + services: Rc, + /// Reaches the application's other windows. + host: Rc, + context: Rc, +} + +impl Default for SessionWindow { + fn default() -> Self { + Self::new() + } +} + +impl SessionWindow { + pub fn new() -> Self { + glib::Object::new() + } + + /// Start the window on `init.launch`. Called once the window is + /// added to the application, which gives it the id its session is + /// claimed under. + pub fn start(&self, init: SessionWindowInit) { + let SessionWindowInit { services, host, launch } = init; + let imp = self.imp(); + let (context, prompts) = services.window_context(); + let (input, receiver) = crate::runtime::input_queue::(); + let _ = imp.input.set(input.clone()); + imp.pump + .replace(Some(crate::runtime::pump_inputs(self, receiver, |window, message| { + window.handle_input(message) + }))); + if crate::config::profile() == crate::config::Profile::Development { + self.add_css_class("devel"); + } + crate::ui::window_geometry::restore(self.upcast_ref(), &services.settings); + window_actions::install(self.upcast_ref(), &input); + install_window_shortcuts(self.upcast_ref()); + imp.prompt_presenter + .replace(Some(crate::ui::credential_prompt::CredentialPromptPresenter::start( + prompts, self, + ))); + + let guards = CloseGuards::new(); + let window = self.downgrade(); + guards.connect_window( + self, + move || window.upgrade().and_then(|window| window.workspace_handle()), + SessionWindow::on_close_approved, + ); + let _ = imp.close_guards.set(guards); + imp.inhibitor + .replace(Some(WindowInhibitor::new(ApplicationInhibit::new(self)))); + + let welcome_input = input.clone(); + let welcome = WelcomeView::builder() + .launch(WelcomeViewInit) + .connect_receiver(move |_, out| { + welcome_input.deliver(match out { + WelcomeViewOutput::OpenConnect => SessionWindowInput::OpenConnectDialog, + WelcomeViewOutput::OpenSaved(saved) => SessionWindowInput::OpenSaved(Box::new(saved)), + WelcomeViewOutput::Delete(id) => SessionWindowInput::DeleteConnection(id), + WelcomeViewOutput::Duplicate(saved) => SessionWindowInput::DuplicateConnection(Box::new(saved)), + WelcomeViewOutput::SetColor(id, color) => SessionWindowInput::SetConnectionColor(id, color), + WelcomeViewOutput::SetGroup(id, group) => SessionWindowInput::SetConnectionGroup(id, group), + }); + }); + let picker = PickerPage::new(welcome.widget()); + let reset_input = input.clone(); + picker + .banner() + .connect_button_clicked(move |_| reset_input.deliver(SessionWindowInput::ResetConnectionList)); + imp.welcome.replace(Some(welcome)); + let _ = imp.picker.set(picker); + let _ = imp.state.set(WindowState { + services, + host, + context, + }); + + self.show_page(SessionPage::Picker); + input.deliver(SessionWindowInput::ReloadConnections); + match launch { + WindowLaunch::Picker => {} + WindowLaunch::Connect(saved) => input.deliver(SessionWindowInput::OpenSaved(saved)), + WindowLaunch::OpenFile(file) => input.deliver(SessionWindowInput::OpenFile(file)), + } + } + + /// What the window's actions, pages and background work send it. + pub(crate) fn input(&self) -> Option> { + self.imp().input.get().cloned() + } + + pub(crate) fn send(&self, message: SessionWindowInput) { + if let Some(input) = self.imp().input.get() { + input.deliver(message); + } + } + + /// Whether the window holds logout back now. + pub(crate) fn is_inhibiting(&self) -> bool { + self.imp() + .inhibitor + .borrow() + .as_ref() + .is_some_and(WindowInhibitor::is_held) + } + + /// A test session has no session manager to grant an inhibition. + #[cfg(test)] + pub(crate) fn set_inhibitor(&self, inhibitor: WindowInhibitor) { + self.imp().inhibitor.replace(Some(inhibitor)); + } + + /// Stop the window's input and close what it still holds: the + /// session it shows, or the connect it runs. Runs once, from the + /// application as the window goes, or from dispose. + pub fn shutdown(&self) { + let imp = self.imp(); + if imp.shut_down.replace(true) { + return; + } + if let Some(pump) = imp.pump.take() { + pump.abort(); + } + if let Some(presenter) = imp.prompt_presenter.take() { + presenter.abort(); + } + self.release_session(); + drop(imp.dialog.take()); + drop(imp.welcome.take()); + drop(imp.inhibitor.take()); + } + + fn state(&self) -> Option<&WindowState> { + self.imp().state.get() + } + + fn close_guards(&self) -> Option<&Rc> { + self.imp().close_guards.get() + } + + fn page_is(&self, matches: impl FnOnce(&SessionPage) -> bool) -> bool { + self.imp().page.try_borrow().is_ok_and(|page| matches(&page)) + } + + /// The page's workspace, while the window shows one. + pub(crate) fn workspace(&self) -> Option { + match &*self.imp().page.try_borrow().ok()? { + SessionPage::Workspace { workspace, .. } => Some(workspace.clone()), + _ => None, + } + } + + fn workspace_handle(&self) -> Option { + self.workspace().and_then(|workspace| workspace.handle()) + } + + fn handle_input(&self, message: SessionWindowInput) { + match message { + SessionWindowInput::OpenSaved(saved) => self.on_open_saved(*saved), + SessionWindowInput::OpenFile(file) => self.on_open_file(file), + SessionWindowInput::FileResolved(resolved) => self.on_file_resolved(*resolved), + SessionWindowInput::OpenConnectDialog => self.present_connect_dialog(None, None), + SessionWindowInput::ConnectFromDialog(submission) => self.on_connect_from_dialog(*submission), + SessionWindowInput::DialogClosed => drop(self.imp().dialog.take()), + SessionWindowInput::CancelConnect => self.on_cancel_connect(), + SessionWindowInput::RetryConnect => self.on_retry_connect(), + SessionWindowInput::EditConnection => self.on_edit_connection(), + SessionWindowInput::Disconnect => self.on_disconnect(), + SessionWindowInput::TeardownApproved => { + if self.tear_down() { + self.show_page(SessionPage::Picker); + tracing::info!("disconnected"); + } + } + SessionWindowInput::Reconnect => { + if let SessionPage::Workspace { session, .. } = &*self.imp().page.borrow() { + session.signal(MonitorSignal::RetryNow); + } + } + SessionWindowInput::ResetWorkspaceLayout => { + if let Some(input) = self.workspace().and_then(|workspace| workspace.input()) { + input.deliver(WorkspaceInput::ResetWorkspaceLayout); + } + } + SessionWindowInput::CloseRequested(CloseReason::Quit(reply)) => { + if let Some(guards) = self.close_guards() { + guards.request_window_close(self, move |decision: CloseDecision| { + // A quit that went away no longer needs the answer. + let _ = reply.send(decision); + }); + } + } + // Only the workspace on screen speaks for the window: one + // torn down has nothing left to lose. + SessionWindowInput::DirtyChanged(dirty) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.imp().dirty.set(dirty); + self.refresh_inhibitor(); + } + } + SessionWindowInput::SavesInFlightChanged(count) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.imp().saving.set(count > 0); + self.refresh_inhibitor(); + } + } + SessionWindowInput::TitleChanged(title) => { + if self.page_is(|page| matches!(page, SessionPage::Workspace { .. })) { + self.set_title(Some(&title)); + } + } + SessionWindowInput::Command(command) => match *command { + SessionWindowCommand::Attempt { generation, result } => self.on_attempt(generation, result), + }, + SessionWindowInput::ReloadConnections => self.on_reload_connections(), + SessionWindowInput::ConnectionsLoaded(connections) => self.on_connections_loaded(connections), + SessionWindowInput::ConnectionListUnavailable => self.show_connection_list_banner(), + SessionWindowInput::ResetConnectionList => self.on_reset_connection_list(), + SessionWindowInput::SecretDeleteFailed(id) => self.on_secret_delete_failed(id), + SessionWindowInput::DeleteConnection(id) => self.on_delete_connection(id), + SessionWindowInput::DuplicateConnection(saved) => self.on_duplicate_connection(*saved), + SessionWindowInput::SetConnectionColor(id, color) => self.on_set_connection_color(id, color), + SessionWindowInput::SetConnectionGroup(id, group) => self.on_set_connection_group(id, group), + SessionWindowInput::ExportConnections => self.on_export_connections(), + SessionWindowInput::ImportConnections => self.on_import_connections(), + SessionWindowInput::ConnectionsFileRead { path, bytes } => self.on_connections_file_read(path, bytes), + SessionWindowInput::ShowToast(message) => self.show_toast(&message), + } + } + + fn show_toast(&self, message: &str) { + self.imp().toast_overlay.add_toast(adw::Toast::new(message)); + } + + /// Move to `page`. The page left behind drops with what it held, + /// which releases a claim. A workspace left behind is torn down + /// first, so its session is closed rather than cut off. + fn show_page(&self, page: SessionPage) { + self.tear_down(); + let imp = self.imp(); + let child: Option = match &page { + SessionPage::Picker => { + self.set_title(Some(&crate::i18n::gettext("TablePro"))); + imp.picker.get().map(|picker| picker.clone().upcast()) + } + SessionPage::Connecting { source, .. } => Some(ConnectingPage::new(&source_name(source)).upcast()), + SessionPage::ConnectFailed { source, failure, .. } => { + Some(ConnectFailedPage::new(&failure.message, edit_label(source, failure).as_deref()).upcast()) + } + SessionPage::Workspace { workspace, .. } => Some(workspace.clone().upcast()), + }; + let editable = match &page { + SessionPage::ConnectFailed { source, .. } => is_editable(source), + _ => false, + }; + window_actions::sync(self.upcast_ref(), &page, editable); + let left = imp.page.replace(page); + imp.toast_overlay.set_child(child.as_ref()); + drop(left); + } + + /// A connect worked or failed. A late result, from an attempt the + /// user cancelled or replaced, is closed rather than adopted. + fn on_attempt( + &self, + generation: tablepro_session::connect::AttemptGeneration, + result: Result, + ) { + let imp = self.imp(); + let current = + imp.attempts.accepts(generation) && self.page_is(|page| matches!(page, SessionPage::Connecting { .. })); + if !current { + if let Ok(connected) = result { + self.discard_pending(connected.pending); + } + return; + } + let SessionPage::Connecting { claim, source } = imp.page.replace(SessionPage::Picker) else { + return; + }; + match result { + Ok(connected) => self.adopt(claim, source, connected), + Err(AttemptError::Cancelled) => { + drop(claim); + self.show_page(SessionPage::Picker); + } + Err(AttemptError::Failed(failure)) => { + tracing::warn!(error = %failure.message, "connect failed"); + self.show_page(SessionPage::ConnectFailed { claim, source, failure }); + } + } + } + + /// Start the session that connected and build its workspace. + fn adopt(&self, claim: SessionClaim, source: ConnectSource, connected: Connected) { + let Connected { + pending, + saved, + warnings, + } = connected; + let (Some(state), Some(guards)) = (self.state(), self.close_guards()) else { + self.discard_pending(pending); + return; + }; + let key = claim.key().clone(); + let credentials = state.context.credentials_for(saved.as_ref()); + let session = Session::start(pending, state.context.session_deps(credentials)); + claim.attach(&session); + if let ConnectSource::Intent(intent) = &source + && let Err(error) = state + .services + .settings + .set_connect_dialog_driver(&intent.request.metadata.driver_id) + { + tracing::warn!(%error, "could not remember the connect dialog driver"); + } + let workspace = Workspace::new(WorkspaceInit { + key: key.clone(), + session: session.clone(), + services: state.services.clone(), + context: state.context.clone(), + window: self.clone().upcast(), + close_guards: guards.clone(), + }); + self.connect_workspace(&workspace); + self.show_page(SessionPage::Workspace { + claim, + session, + workspace: workspace.clone(), + }); + workspace.start(); + drop(self.imp().dialog.take()); + for warning in warnings { + self.show_toast(&warning); + } + if let SessionKey::Saved(id) = key { + self.touch_last_opened(id); + } + } + + fn connect_workspace(&self, workspace: &Workspace) { + let Some(input) = self.input() else { + return; + }; + let dirty = input.clone(); + workspace.connect_dirty_changed(move |_, value| dirty.deliver(SessionWindowInput::DirtyChanged(value))); + let saves = input.clone(); + workspace.connect_saves_in_flight_changed(move |_, count| { + saves.deliver(SessionWindowInput::SavesInFlightChanged(count)); + }); + workspace.connect_title_changed(move |_, title| input.deliver(SessionWindowInput::TitleChanged(title))); + } + + /// Throw away a connection nobody adopted, closing it under the + /// close deadline while the process stays alive for it. + fn discard_pending(&self, pending: PendingSession) { + let Some(state) = self.state() else { + return; + }; + let hold = state.services.hold().acquire(); + let closing = state.services.tasks.spawn_task(pending.discard()); + glib::spawn_future_local(async move { + if let Ok(report) = closing.await { + tracing::debug!(?report, "closed a connection that was not adopted"); + } + drop(hold); + }); + } + + /// Claim the key, then connect, so a second window asking for the + /// same database brings the first forward instead of connecting. + fn claim(&self, key: SessionKey) -> Option { + let state = self.state()?; + match state.context.sessions.claim(key, self.id()) { + Ok(claim) => Some(claim), + Err(ClaimError::AlreadyOpen { window_id }) => { + self.present_owner(window_id); + None + } + } + } + + fn present_owner(&self, window_id: u32) { + let presented = self.state().is_some_and(|state| state.host.present_window(window_id)); + if !presented { + self.show_toast(&crate::i18n::gettext( + "This connection is already open in another window.", + )); + } + } + + fn on_open_saved(&self, saved: SavedConnection) { + let Some(state) = self.state() else { + return; + }; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + // This window has its session; another one opens this. + let key = SessionKey::Saved(saved.id); + if let Some(owner) = state.context.sessions.window_for(&key) + && state.host.present_window(owner) + { + return; + } + state.host.open_window(WindowLaunch::Connect(Box::new(saved))); + return; + } + if let Some(claim) = self.claim(SessionKey::Saved(saved.id)) { + self.begin_connect(claim, ConnectSource::Saved(Box::new(saved))); + } + } + + /// The file is resolved off the GTK thread: canonicalizing its path + /// and matching it against the saved connections read the disk. + fn on_open_file(&self, file: gio::File) { + let Some(state) = self.state() else { + return; + }; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + state.host.open_window(WindowLaunch::OpenFile(file)); + return; + } + let path = file.path(); + let uri = file.uri().to_string(); + let connections = state.services.storage.connections().clone(); + let resolving = state + .services + .tasks + .spawn_blocking_task(move || resolve_file_blocking(path, uri, &connections)); + let input = self.input(); + glib::spawn_future_local(async move { + match resolving.await { + Ok(resolved) => { + if let Some(input) = input { + input.deliver(SessionWindowInput::FileResolved(Box::new(resolved))); + } + } + Err(failure) => tracing::warn!(%failure, "the file could not be resolved"), + } + }); + } + + fn on_file_resolved(&self, resolved: ResolvedFile) { + let ResolvedFile { uri, target } = resolved; + if !self.page_is(|page| matches!(page, SessionPage::Picker)) { + if let Some(state) = self.state() { + state.host.open_window(WindowLaunch::OpenFile(gio::File::for_uri(&uri))); + } + return; + } + match target { + Ok(FileTarget::Saved(saved)) => self.on_open_saved(*saved), + Ok(FileTarget::File(identity)) => { + if let Some(claim) = self.claim(SessionKey::File(identity.uri.clone())) { + self.begin_connect(claim, ConnectSource::File(identity)); + } + } + Err(error) => { + tracing::warn!(%error, %uri, "could not open the file"); + let Some(claim) = self.claim(SessionKey::File(uri.clone())) else { + return; + }; + let failure = ConnectFailure::new(file_failure_message(&uri, &error)); + self.show_page(SessionPage::ConnectFailed { + claim, + source: ConnectSource::File(FileIdentity::new(None, uri)), + failure, + }); + } + } + } + + fn on_connect_from_dialog(&self, submission: ConnectSubmission) { + let ConnectSubmission { request, pending_save } = submission; + match &*self.imp().page.borrow() { + SessionPage::Picker | SessionPage::ConnectFailed { .. } => {} + SessionPage::Connecting { .. } | SessionPage::Workspace { .. } => { + tracing::warn!("a connect from the dialog reached a window with a session"); + return; + } + } + // A failed connect's claim goes first: the dialog may be editing + // that same connection. + self.show_page(SessionPage::Picker); + let key = SessionKey::Saved(pending_save.connection.id); + let Some(state) = self.state() else { + return; + }; + match state.context.sessions.claim(key.clone(), self.id()) { + Ok(claim) => self.begin_connect( + claim, + ConnectSource::Intent(Box::new(ConnectIntent { + key, + request, + pending_save: Some(pending_save), + })), + ), + Err(ClaimError::AlreadyOpen { window_id }) => { + // The edits are kept for the next connect; the session + // open in the other window goes on as it was. + self.persist_pending_save(pending_save); + self.present_owner(window_id); + } + } + } + + fn begin_connect(&self, claim: SessionClaim, source: ConnectSource) { + let Some(state) = self.state() else { + return; + }; + let ticket = self.imp().attempts.begin(); + let generation = ticket.generation(); + let cancel = ticket.cancel().clone(); + let work = connect_work(state, &source, cancel); + self.show_page(SessionPage::Connecting { claim, source }); + let input = self.input(); + crate::runtime::spawn_and_deliver(&state.services.tasks, work, move |outcome| { + let result = outcome.unwrap_or_else(|failure| { + Err(AttemptError::Failed(ConnectFailure::new( + crate::ui::error_text::task_failure_message(&failure), + ))) + }); + if let Some(input) = input { + input.deliver(SessionWindowInput::Command(Box::new(SessionWindowCommand::Attempt { + generation, + result, + }))); + } + }); + } + + /// Stop the connect in progress, or leave a failed one, for the + /// picker. + fn on_cancel_connect(&self) { + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { + stop_connecting(&self.imp().attempts); + } + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. } | SessionPage::ConnectFailed { .. })) { + self.show_page(SessionPage::Picker); + } + } + + fn on_retry_connect(&self) { + let imp = self.imp(); + if !self.page_is(|page| matches!(page, SessionPage::ConnectFailed { .. })) { + return; + } + let SessionPage::ConnectFailed { claim, source, .. } = imp.page.replace(SessionPage::Picker) else { + return; + }; + match source { + // The file is resolved again, since it may have moved. + ConnectSource::File(identity) => { + drop(claim); + self.show_page(SessionPage::Picker); + self.on_open_file(gio::File::for_uri(&identity.uri)); + } + source => self.begin_connect(claim, source), + } + } + + fn on_edit_connection(&self) { + let prefill = match &*self.imp().page.borrow() { + SessionPage::ConnectFailed { source, failure, .. } => connections::prefill_for(source) + .map(|prefill| (prefill, failure.remedy.map(crate::ui::connect_dialog::remedy_field))), + _ => None, + }; + if let Some((prefill, focus)) = prefill { + self.present_connect_dialog(Some(prefill), focus); + } + } + + fn on_disconnect(&self) { + let (Some(handle), Some(guards)) = (self.workspace_handle(), self.close_guards()) else { + return; + }; + guards.begin_teardown(); + if guards.may_tear_down(&handle) { + self.send(SessionWindowInput::TeardownApproved); + return; + } + let input = self.input(); + guards.ask_tear_down(self.upcast_ref(), &handle, move |decision| { + if decision == CloseDecision::Approved + && let Some(input) = input + { + input.deliver(SessionWindowInput::TeardownApproved); + } + }); + } + + /// The close prompts approved the window's close, which goes ahead + /// once this returns: what it holds is written and closed first. + fn on_close_approved(&self) { + if let Some(state) = self.state() { + crate::ui::window_geometry::persist(self.upcast_ref(), &state.services.settings); + } + self.release_session(); + } + + /// Close the session the window shows, or stop the connect it runs. + fn release_session(&self) { + if self.tear_down() { + return; + } + if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { + stop_connecting(&self.imp().attempts); + } + drop(self.imp().page.replace(SessionPage::Picker)); + } + + fn refresh_inhibitor(&self) { + let imp = self.imp(); + let reason = InhibitReason::for_state(imp.dirty.get(), imp.saving.get()); + if let Some(inhibitor) = imp.inhibitor.borrow_mut().as_mut() { + inhibitor.update(reason); + } + } + + #[cfg(test)] + pub(crate) fn page_name(&self) -> &'static str { + self.imp().page.borrow().name() + } +} + +/// Supersede the connect in flight, not only cancel it: a driver +/// blocked in a socket call can still connect afterwards, and that late +/// session must not be adopted. +fn stop_connecting(attempts: &tablepro_session::ConnectAttempts) { + drop(attempts.begin()); + attempts.cancel(); +} + +/// The connect a source makes, off the GTK thread. Reading the keyring, +/// asking for what it lacks and connecting all happen there; only the +/// outcome comes back. +fn connect_work( + state: &WindowState, + source: &ConnectSource, + cancel: tokio_util::sync::CancellationToken, +) -> BoxFuture<'static, Result> { + let context = &state.context; + let connector = context.connector.clone(); + let interaction = context.attended(); + match source { + ConnectSource::Saved(saved) => { + let saved = (**saved).clone(); + let drivers = context.drivers.clone(); + let credentials = context.credentials_for(Some(&saved)); + async move { + let connected = + connect_flow::connect_saved(&saved, &drivers, &*credentials, &*connector, interaction, cancel) + .await; + match connected { + Ok((pending, refused)) => Ok(Connected { + pending, + saved: Some(saved), + warnings: refused + .iter() + .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) + .collect(), + }), + Err(error) => Err(attempt_error(&error)), + } + } + .boxed() + } + ConnectSource::Intent(intent) => { + let request = intent.request.clone(); + let Some(pending_save) = intent.pending_save.clone() else { + return async move { + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(|pending| Connected { + pending, + saved: None, + warnings: Vec::new(), + }) + .map_err(|error| attempt_error(&error)) + } + .boxed(); + }; + let secrets = context.secrets.clone(); + let writes = StoreConnectionWrites::new( + state.services.storage.connections().clone(), + state.services.tasks.clone(), + ); + async move { + let saving = connect_flow::connect_then_save( + &*connector, + request, + interaction, + cancel, + &*secrets, + &writes, + pending_save, + ) + .await; + match saving { + Ok((pending, report)) => Ok(Connected { + pending, + // A keyring refusal must not be silent: the + // connection opened, but the password will be + // asked for next time. + warnings: report + .secret_failures + .iter() + .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) + .collect(), + saved: Some(report.saved), + }), + Err(connect_flow::SubmitError::Connect(error)) => Err(attempt_error(&error)), + Err(connect_flow::SubmitError::Save(error)) => { + Err(AttemptError::Failed(ConnectFailure::new(crate::i18n::gettext_f( + "The connection could not be saved: {error}", + &[("error", &error.to_string())], + )))) + } + } + } + .boxed() + } + ConnectSource::File(identity) => { + let request = resolve_file_identity::file_request(identity, &context.drivers); + async move { + let request = request.map_err(|error| attempt_error(&error))?; + connect_flow::connect_request(&*connector, request, interaction, cancel) + .await + .map(|pending| Connected { + pending, + saved: None, + warnings: Vec::new(), + }) + .map_err(|error| attempt_error(&error)) + } + .boxed() + } + } +} + +fn attempt_error(error: &ConnectError) -> AttemptError { + match error { + ConnectError::Cancelled => AttemptError::Cancelled, + error => AttemptError::Failed(ConnectFailure { + message: crate::ui::error_text::connect_error_message(error), + remedy: crate::ui::error_text::connect_remedy(error), + }), + } +} + +/// What the connecting page names: the connection, or the file. +fn source_name(source: &ConnectSource) -> String { + match source { + ConnectSource::Saved(saved) => saved.name.clone(), + ConnectSource::Intent(intent) => intent.request.metadata.name.clone(), + ConnectSource::File(identity) => file_display_name(&identity.uri), + } +} + +fn file_display_name(uri: &str) -> String { + let file = gio::File::for_uri(uri); + file.basename() + .map_or_else(|| uri.to_owned(), |name| name.to_string_lossy().into_owned()) +} + +/// Only a connection the connect dialog shows can be edited from a +/// failed connect. +fn is_editable(source: &ConnectSource) -> bool { + matches!(source, ConnectSource::Saved(_) | ConnectSource::Intent(_)) +} + +/// The failed page's Edit button: named after the setting that fixes a +/// TLS failure when there is one. +fn edit_label(source: &ConnectSource, failure: &ConnectFailure) -> Option { + is_editable(source).then(|| { + failure + .remedy + .map_or_else(|| crate::i18n::gettext("Edit Connection…"), |remedy| remedy.label()) + }) +} + +fn file_failure_message(uri: &str, error: &FileResolveError) -> String { + let name = gio::File::for_uri(uri).parse_name().to_string(); + match error { + FileResolveError::NotLocal => crate::i18n::gettext_f( + "{file} is not on this computer. Copy it to a local folder to open it.", + &[("file", &name)], + ), + FileResolveError::NotFound(_) => crate::i18n::gettext_f("{file} could not be found.", &[("file", &name)]), + } +} diff --git a/linux/crates/app/src/ui/session_window/picker_page.rs b/linux/crates/app/src/ui/session_window/picker_page.rs new file mode 100644 index 0000000000..e71d85659d --- /dev/null +++ b/linux/crates/app/src/ui/session_window/picker_page.rs @@ -0,0 +1,58 @@ +use gtk4 as gtk; +use gtk4::glib; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +mod imp { + use super::*; + use libadwaita::subclass::prelude::*; + + #[derive(Default, gtk::CompositeTemplate)] + #[template(resource = "/app/tablepro/TablePro/ui/picker-page.ui")] + pub struct PickerPage { + #[template_child] + pub connection_list_banner: TemplateChild, + #[template_child] + pub welcome_slot: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for PickerPage { + const NAME: &'static str = "TableProPickerPage"; + type Type = super::PickerPage; + type ParentType = adw::Bin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } + } + + impl ObjectImpl for PickerPage {} + impl WidgetImpl for PickerPage {} + impl BinImpl for PickerPage {} +} + +glib::wrapper! { + /// The saved connections, with a header bar of their own and the + /// banner that says when the list could not be read. + pub struct PickerPage(ObjectSubclass) + @extends adw::Bin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +impl PickerPage { + pub fn new(welcome: &impl IsA) -> Self { + let page: Self = glib::Object::new(); + page.imp().welcome_slot.set_child(Some(welcome)); + page + } + + pub fn banner(&self) -> adw::Banner { + self.imp().connection_list_banner.get() + } +} diff --git a/linux/crates/app/src/ui/session_window/resolve_file_identity.rs b/linux/crates/app/src/ui/session_window/resolve_file_identity.rs new file mode 100644 index 0000000000..b36da83f82 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/resolve_file_identity.rs @@ -0,0 +1,99 @@ +//! What a database file handed to TablePro connects with, once it is +//! resolved off the GTK thread. + +use std::path::PathBuf; +use std::sync::Arc; + +use tablepro_core::{DatabaseDriver, DriverError, DriverRegistry, FileEndpoint, WorkspaceSettings}; +use tablepro_session::registry::FileIdentity; +use tablepro_session::{ConnectError, ConnectRequest, ConnectionMetadata, RequestTarget}; + +pub(crate) use crate::services::file_resolution::{FileResolveError, FileTarget, ResolvedFile, resolve_file_blocking}; + +/// The request that opens a file nobody saved, through the driver that +/// opens files. +pub(crate) fn file_request(identity: &FileIdentity, drivers: &DriverRegistry) -> Result { + let driver = file_driver(drivers).ok_or_else(|| ConnectError::UnknownDriver("sqlite".to_owned()))?; + let path = identity + .canonical_path + .clone() + .unwrap_or_else(|| PathBuf::from(&identity.uri)); + let name = path + .file_name() + .map_or_else(|| identity.uri.clone(), |name| name.to_string_lossy().into_owned()); + let endpoint = FileEndpoint::open_existing(path).map_err(DriverError::from)?; + Ok(ConnectRequest { + metadata: ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name, + driver_id: driver.id().to_owned(), + }, + driver, + target: RequestTarget::File(endpoint), + workspace_settings: WorkspaceSettings::workspace(false), + }) +} + +fn file_driver(drivers: &DriverRegistry) -> Option> { + drivers + .get("sqlite") + .filter(|driver| driver.capabilities().is_file_based()) + .or_else(|| { + drivers + .iter() + .find(|driver| driver.capabilities().is_file_based()) + .cloned() + }) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::services::file_resolution::resolve_file_identity_blocking; + + #[gtk4::test] + fn symlinked_path_canonicalises() { + let runtime = crate::test_support::test_runtime(); + let root = tempfile::tempdir().expect("tempdir"); + let database = root.path().join("app.db"); + std::fs::write(&database, b"").expect("seed"); + let link = root.path().join("link.db"); + std::os::unix::fs::symlink(&database, &link).expect("symlink"); + let uri = format!("file://{}", link.display()); + + let resolving = runtime + .tasks() + .spawn_blocking_task(move || resolve_file_identity_blocking(Some(&link), &uri)); + let identity = crate::test_support::finish_on_main(Duration::from_secs(10), resolving) + .expect("the resolve to end") + .expect("the task to finish") + .expect("the file resolves"); + + let canonical = std::fs::canonicalize(&database).expect("canonical"); + assert_eq!(identity.canonical_path.as_deref(), Some(canonical.as_path())); + assert!( + identity.uri.ends_with("/app.db"), + "the key followed the link, not the file: {}", + identity.uri + ); + runtime.shutdown(); + } + + #[test] + fn an_unsaved_file_opens_through_the_driver_that_opens_files() { + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(tablepro_test_support::FakeDriver::connecting( + &tablepro_test_support::FakeDriver::FILE_CAPABILITIES, + tablepro_test_support::ConnectionScript::default(), + ))); + let identity = FileIdentity::new(Some(PathBuf::from("/srv/app.db")), "file:///srv/app.db".to_owned()); + + let request = file_request(&identity, &drivers).expect("a request"); + + assert_eq!(request.metadata.name, "app.db"); + assert!(matches!(request.target, RequestTarget::File(_))); + assert!(file_request(&identity, &DriverRegistry::new()).is_err()); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_page.rs b/linux/crates/app/src/ui/session_window/session_page.rs new file mode 100644 index 0000000000..74838a549e --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_page.rs @@ -0,0 +1,57 @@ +use std::sync::Arc; + +use tablepro_session::{ConnectSource, Session, SessionClaim}; + +use super::session_window_command::ConnectFailure; +use crate::ui::workspace::Workspace; + +/// Which page a window shows, and what it holds for that page. The +/// claim on the session's key lives here, so leaving the page releases +/// it. +#[derive(Default)] +pub(crate) enum SessionPage { + /// The saved connections, to pick one from. + #[default] + Picker, + Connecting { + claim: SessionClaim, + source: ConnectSource, + }, + ConnectFailed { + claim: SessionClaim, + source: ConnectSource, + failure: ConnectFailure, + }, + Workspace { + claim: SessionClaim, + session: Arc, + workspace: Workspace, + }, +} + +impl SessionPage { + #[cfg(test)] + pub(crate) fn name(&self) -> &'static str { + match self { + Self::Picker => "picker", + Self::Connecting { .. } => "connecting", + Self::ConnectFailed { .. } => "connect-failed", + Self::Workspace { .. } => "workspace", + } + } +} + +impl std::fmt::Debug for SessionPage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Picker => f.write_str("Picker"), + Self::Connecting { claim, .. } => f.debug_struct("Connecting").field("key", claim.key()).finish(), + Self::ConnectFailed { claim, failure, .. } => f + .debug_struct("ConnectFailed") + .field("key", claim.key()) + .field("failure", failure) + .finish(), + Self::Workspace { claim, .. } => f.debug_struct("Workspace").field("key", claim.key()).finish(), + } + } +} diff --git a/linux/crates/app/src/ui/session_window/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs new file mode 100644 index 0000000000..7f4cdded46 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -0,0 +1,175 @@ +//! The ordered teardown of a window's session, for a disconnect and an +//! approved close. + +use std::sync::Arc; +use std::time::Duration; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use tablepro_core::EditorSession; +use tablepro_session::Session; +use tablepro_session::runtime::{ProcessHold, Tasks}; + +use super::SessionWindow; +use super::session_page::SessionPage; + +impl SessionWindow { + /// Take the workspace down once its close prompts approved, and + /// close the session under one hold. False when the window shows + /// no workspace. + /// + /// The order matters. What the workspace shows is written before it + /// is freed; the workspace goes before the claim, so nothing of it + /// reads for a key another window can now claim; and the claim goes + /// before the close finishes, so the same connection can be picked + /// again at once. + pub(super) fn tear_down(&self) -> bool { + let imp = self.imp(); + let Some(state) = self.state() else { + return false; + }; + let Ok(mut page) = imp.page.try_borrow_mut() else { + tracing::warn!("the session was torn down while its page was in use"); + return false; + }; + if !matches!(*page, SessionPage::Workspace { .. }) { + return false; + } + let SessionPage::Workspace { + claim, + session, + workspace, + } = std::mem::take(&mut *page) + else { + return false; + }; + drop(page); + workspace.flush(); + let editor_sessions = workspace.take_editor_sessions(); + imp.toast_overlay.set_child(gtk::Widget::NONE); + self.set_title(Some(&crate::i18n::gettext("TablePro"))); + workspace.shutdown(); + drop(workspace); + drop(claim); + close_session( + session, + editor_sessions, + &*state.services.hold(), + &state.services.tasks, + state.context.close_timeout(), + ); + imp.dirty.set(false); + imp.saving.set(false); + self.refresh_inhibitor(); + super::window_actions::sync(self.upcast_ref(), &SessionPage::Picker, false); + true + } +} + +/// Close the session and every editor tab's connection together, under +/// the close deadline. One hold keeps the process alive until all of +/// them end, so quitting right after a disconnect closes them rather +/// than cutting them off. +fn close_session( + session: Arc, + editor_sessions: Vec>, + hold: &dyn ProcessHold, + tasks: &Tasks, + close_timeout: Duration, +) { + let hold = hold.acquire(); + let editors_closing = tablepro_session::close_editor_sessions(editor_sessions, close_timeout, tasks); + let closing = session.begin_shutdown(); + drop(session); + glib::spawn_future_local(async move { + let (closed, editors) = futures::future::join(closing, editors_closing).await; + match closed { + Ok(report) => tracing::debug!(?report, "the session closed"), + Err(failure) => tracing::warn!(%failure, "the session close task failed"), + } + match editors { + Ok(reports) => tracing::debug!(?reports, "the editor tabs' sessions closed"), + Err(failure) => tracing::warn!(%failure, "closing the editor tabs' sessions failed"), + } + drop(hold); + }); +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::EditorSession; + use tablepro_test_support::{CallCounters, CloseBehaviour, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use super::*; + use crate::test_support::wait_until; + use crate::ui::session_window::SessionWindowInput; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + #[gtk4::test] + fn disconnect_runs_ordered_teardown() { + let test = TestWindow::connected("OrderedTeardown"); + let workspace = test.workspace().downgrade(); + let key = test.key(); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + + let child = test.window.imp().toast_overlay.child(); + assert!( + child.is_some_and(|child| child.type_().name() == "TableProPickerPage"), + "the picker did not replace the workspace" + ); + let counters = test.connection().counters(); + wait_until(WAIT, || CallCounters::get(&counters.close) == 1).expect("the connection to close"); + crate::test_support::drain_main_context(); + assert_eq!( + CallCounters::get(&counters.close), + 1, + "the connection closed more than once" + ); + assert!(workspace.upgrade().is_none(), "the workspace outlived its teardown"); + let claim = test + .services + .sessions + .claim(key, test.window.id() + 100) + .expect("the key is free again at once"); + drop(claim); + } + + #[gtk4::test] + fn disconnect_closes_editor_sessions_once() { + let delayed = || { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new())); + std::sync::Arc::new(FakeEditorSession::with_close( + runs, + CloseBehaviour::Delay(Duration::from_millis(100)), + )) + }; + let (first, second) = (delayed(), delayed()); + let test = TestWindow::connected_with("EditorSessions", |script| { + let (a, b): (Arc, Arc) = (first.clone(), second.clone()); + script.open_editor_session.once(FakeReply::ok(a)); + script.open_editor_session.once(FakeReply::ok(b)); + }); + test.run_in_new_editor_tab(); + test.run_in_new_editor_tab(); + wait_until(WAIT, || !first.texts().is_empty() && !second.texts().is_empty()) + .expect("both tabs to open their sessions"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + assert!(test.hold.live() > 0, "nothing held the process for the closes"); + wait_until(WAIT, || test.hold.live() == 0).expect("the holds to end"); + + assert!( + first.is_closed() && second.is_closed(), + "the hold ended before both delayed closes did" + ); + assert_eq!((first.close_count(), second.close_count()), (1, 1)); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_window_command.rs b/linux/crates/app/src/ui/session_window/session_window_command.rs new file mode 100644 index 0000000000..15d42fd812 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_window_command.rs @@ -0,0 +1,47 @@ +use tablepro_session::PendingSession; +use tablepro_session::connect::AttemptGeneration; +use tablepro_storage::SavedConnection; + +use crate::ui::error_text::TlsRemedy; + +/// What background work a window started brings back to it. +#[derive(Debug)] +pub(crate) enum SessionWindowCommand { + /// A connect attempt ended. Its generation says which one, so a + /// result that lands after the user moved on is discarded. + Attempt { + generation: AttemptGeneration, + result: Result, + }, +} + +/// A connect that worked, waiting for the window to adopt it. +#[derive(Debug)] +pub(crate) struct Connected { + pub pending: PendingSession, + /// The saved entry it connected to, as saved, which a reconnect + /// reads its secrets for. `None` for a file nobody saved. + pub saved: Option, + /// Secrets the keyring refused to keep, one sentence each. + pub warnings: Vec, +} + +#[derive(Debug)] +pub(crate) enum AttemptError { + /// The user stopped it, which needs no error. + Cancelled, + Failed(ConnectFailure), +} + +/// What the failed page says, and the setting that would fix it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ConnectFailure { + pub message: String, + pub remedy: Option, +} + +impl ConnectFailure { + pub(crate) fn new(message: String) -> Self { + Self { message, remedy: None } + } +} diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs new file mode 100644 index 0000000000..363985c6f7 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -0,0 +1,71 @@ +use tablepro_storage::{ConnectionColor, SavedConnection}; +use uuid::Uuid; + +use super::resolve_file_identity::ResolvedFile; +use super::session_window_command::SessionWindowCommand; +use crate::ui::connect_dialog::ConnectSubmission; +use crate::workspace::CloseReason; + +#[derive(Debug)] +pub(crate) enum SessionWindowInput { + /// Open a saved connection: in this window from the picker, in + /// another one otherwise. + OpenSaved(Box), + /// Open a database file, as the file manager or the command line + /// hands it over. + OpenFile(gio::File), + /// The file resolved, off the GTK thread, to the session it opens. + FileResolved(Box), + OpenConnectDialog, + /// The connect dialog's Connect: connect, then save the entry. + ConnectFromDialog(Box), + DialogClosed, + /// Stop the connect in progress, or leave a failed one, for the + /// picker. + CancelConnect, + /// Connect again from where the failed connect came from, reading + /// the keyring again. + RetryConnect, + /// Open the connect dialog on a connection that failed to connect, + /// with the focus on the setting that fixes it. + EditConnection, + /// Close the workspace through its prompts and return to the + /// picker. + Disconnect, + /// The prompts approved the disconnect. + TeardownApproved, + /// The health banner's Retry: ask the session to reconnect now. + Reconnect, + /// The toast's Reset: move the workspace's unreadable saved tabs + /// aside. + ResetWorkspaceLayout, + /// The application asks the window to close. + CloseRequested(CloseReason), + /// The workspace gained or lost something a logout would lose. + DirtyChanged(bool), + /// The number of saves running in the workspace changed. + SavesInFlightChanged(usize), + TitleChanged(String), + Command(Box), + ReloadConnections, + ConnectionsLoaded(Vec), + ConnectionListUnavailable, + ResetConnectionList, + SecretDeleteFailed(Uuid), + DeleteConnection(Uuid), + DuplicateConnection(Box), + /// A colour picked from a connection row's menu, or `None` when the + /// user cleared it. + SetConnectionColor(Uuid, Option), + /// A group picked from a connection row's menu, or `None` when the + /// user took it out of one. + SetConnectionGroup(Uuid, Option), + ExportConnections, + ImportConnections, + /// The file the user picked to import, read off the GTK thread. + ConnectionsFileRead { + path: std::path::PathBuf, + bytes: Vec, + }, + ShowToast(String), +} diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs new file mode 100644 index 0000000000..3a09a4e581 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -0,0 +1,505 @@ +use std::rc::Rc; +use std::sync::Arc; +use std::time::Duration; + +use gtk4::prelude::*; +use libadwaita as adw; +use tablepro_core::{AuthMode, DriverError, DriverRegistry, LivenessPolicy, TlsConfig}; +use tablepro_session::fakes::{FakeConnector, FakeCredentialSource, FakeEstablish}; +use tablepro_session::operation::DefaultDeadlines; +use tablepro_session::runtime::CountingHold; +use tablepro_session::{ConnectError, CredentialSource, SessionKey}; +use tablepro_storage::{SavedConnection, StoragePaths}; +use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeDriver, FakeReply, FakeSecretVault}; + +use super::*; +use crate::application::{AppServicesParts, TableProApplication}; +use crate::services::session_context::CredentialSourceFactory; +use crate::storage::AppStorage; +use crate::test_support::{MemorySettings, assert_labelled, descendants, drain_main_context, wait_until}; + +pub(crate) const WAIT: Duration = Duration::from_secs(10); + +/// Every saved connection, whatever it is, reads its secrets from one +/// fake, so a test can count the reads. +struct FakeSources(FakeCredentialSource); + +impl CredentialSourceFactory for FakeSources { + fn for_connection(&self, _saved: Option<&SavedConnection>) -> Arc { + Arc::new(self.0.clone()) + } +} + +/// What a scripted world can change before the application starts. +#[derive(Default)] +pub(crate) struct Script { + pub connection: ConnectionScript, + /// What each connect gets, before `connection` from then on. + pub establish: Vec, + pub credentials: Option, +} + +/// A TablePro over fakes with one window open on the picker. The +/// application has an id of its own, so it never meets another test's, +/// or a TablePro the developer has open, on the session bus. +pub(crate) struct TestWindow { + pub app: TableProApplication, + pub window: SessionWindow, + pub services: Rc, + pub hold: Rc, + pub connector: FakeConnector, + pub saved: SavedConnection, + runtime: Option, + _settings: MemorySettings, + _dir: tempfile::TempDir, +} + +impl TestWindow { + pub(crate) fn picker(name: &str) -> Self { + Self::with_script(name, Script::default()) + } + + pub(crate) fn with_script(name: &str, script: Script) -> Self { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + if let Some(settings) = gtk4::Settings::default() { + settings.set_gtk_enable_animations(false); + } + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let dir = tempfile::tempdir().expect("a temporary directory"); + let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); + let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + let Script { + mut connection, + establish, + credentials, + } = script; + connection.probe.always(FakeReply::ok(())); + connection.list_schemas.always(FakeReply::ok(Vec::new())); + connection.list_objects.always(FakeReply::ok(Vec::new())); + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(FakeDriver::connecting( + &FakeDriver::FILE_CAPABILITIES, + connection.clone(), + ))); + let connector = FakeConnector::new(establish).always(FakeEstablish::Connect(connection)); + let liveness = LivenessPolicy::DESKTOP; + let hold = Rc::new(CountingHold::new()); + let mut services = AppServices::new(AppServicesParts { + drivers, + connector: Arc::new(connector.clone()), + deadlines: Arc::new(DefaultDeadlines::new(liveness)), + liveness, + settings: settings.get().clone(), + storage, + tasks: runtime.tasks(), + }) + .with_hold(hold.clone()); + if let Some(credentials) = credentials { + services = services.with_credentials(Rc::new(FakeSources(credentials))); + } + let app = TableProApplication::new(services); + app.set_application_id(Some(&format!("{}.Test.Window{name}", crate::config::SCHEMA_ID))); + app.register(gio::Cancellable::NONE).expect("register the application"); + let window = app + .open_window(WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a session window"); + let services = app.services().expect("the application's services"); + let saved = saved_connection(&dir.path().join("fake.db")); + let test = Self { + app, + window, + services, + hold, + connector, + saved, + runtime: Some(runtime), + _settings: settings, + _dir: dir, + }; + test.wait_for_page("picker"); + test + } + + /// A window that opened the saved connection and shows its + /// workspace. + pub(crate) fn connected(name: &str) -> Self { + Self::connected_with(name, |_| {}) + } + + pub(crate) fn connected_with(name: &str, script: impl FnOnce(&mut ConnectionScript)) -> Self { + let mut connection = ConnectionScript::default(); + script(&mut connection); + let test = Self::with_script( + name, + Script { + connection, + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + test + } + + pub(crate) fn wait_for_page(&self, name: &str) { + wait_until(WAIT, || self.window.page_name() == name) + .unwrap_or_else(|_| panic!("the window stayed on {}, not {name}", self.window.page_name())); + } + + pub(crate) fn workspace(&self) -> Workspace { + self.window.workspace().expect("the window shows a workspace") + } + + pub(crate) fn key(&self) -> SessionKey { + SessionKey::Saved(self.saved.id) + } + + /// The connection the last connect built. + pub(crate) fn connection(&self) -> Arc { + self.connector.connections().pop().expect("a connection") + } + + /// Open an editor tab through its window action and run what it + /// holds, which opens the tab's own editor session. + pub(crate) fn run_in_new_editor_tab(&self) { + let before = editors(&self.window); + WidgetExt::activate_action(&self.window, "win.open-editor", None).expect("win.open-editor"); + wait_until(WAIT, || editors(&self.window).len() == before.len() + 1).expect("a new editor tab"); + let editor = editors(&self.window) + .into_iter() + .find(|editor| !before.contains(editor)) + .expect("the new editor"); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + } +} + +impl Drop for TestWindow { + fn drop(&mut self) { + for window in self.app.windows() { + window.destroy(); + } + drain_main_context(); + if let Some(runtime) = self.runtime.take() { + runtime.shutdown(); + } + } +} + +fn editors(window: &SessionWindow) -> Vec { + descendants(window) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .collect() +} + +fn saved_connection(database: &std::path::Path) -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Fake".to_owned(), + driver_id: "fake".to_owned(), + host: String::new(), + port: 0, + database: database.display().to_string(), + username: String::new(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +fn refused() -> FakeEstablish { + FakeEstablish::Fail(ConnectError::Driver(DriverError::Auth { diagnostics: None })) +} + +#[gtk4::test] +fn a_connected_window_shows_the_workspace_and_claims_its_key() { + let test = TestWindow::connected("Connects"); + + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); + assert_eq!(test.services.sessions.live_count(), 1); + assert!( + test.window + .lookup_action("disconnect") + .is_some_and(|action| action.is_enabled()) + ); +} + +#[gtk4::test] +fn the_header_icon_buttons_are_labelled() { + let test = TestWindow::connected("Labels"); + let picker = test.window.imp().picker.get().expect("the picker").clone(); + + for root in [picker.upcast::(), test.workspace().upcast()] { + // The tab overview's own header is libadwaita's. + let headers = descendants(&root).into_iter().filter(|widget| { + widget.is::() && widget.ancestor(adw::TabOverview::static_type()).is_none() + }); + let buttons: Vec = headers + .flat_map(|header| descendants(&header)) + // A menu button's own toggle is labelled through the menu + // button. + .filter(|widget| { + widget.is::() + || (widget.is::() + && !widget.parent().is_some_and(|parent| parent.is::())) + }) + .collect(); + assert!(!buttons.is_empty(), "no header buttons under {}", root.type_().name()); + for button in buttons { + assert_labelled(&button).unwrap_or_else(|problem| panic!("{problem:?}: {:?}", button.tooltip_text())); + } + } +} + +#[gtk4::test] +fn claiming_open_key_presents_owner_window() { + let test = TestWindow::connected("Owner"); + let other = test + .app + .open_window(WindowLaunch::Picker) + .and_then(|window| window.downcast::().ok()) + .expect("a second window"); + wait_until(WAIT, || other.page_name() == "picker").expect("the second window's picker"); + test.window.set_visible(false); + + other.send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + wait_until(WAIT, || test.window.is_visible()).expect("the owner to come forward"); + + assert_eq!(other.page_name(), "picker", "the second window connected too"); + assert_eq!(test.connector.calls().len(), 1, "a second connection was built"); + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); +} + +#[gtk4::test] +fn cancel_connect_discards_late_attempt() { + let test = TestWindow::with_script( + "CancelLate", + Script { + establish: vec![FakeEstablish::Late { + after: Duration::from_millis(300), + script: ConnectionScript::default(), + }], + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + + test.window.send(SessionWindowInput::CancelConnect); + test.wait_for_page("picker"); + assert_eq!( + test.services.sessions.window_for(&test.key()), + None, + "the claim outlived the cancel" + ); + wait_until(WAIT, || { + test.connector + .connections() + .first() + .is_some_and(|connection| connection.is_closed()) + }) + .expect("the late connection to close"); + drain_main_context(); + + assert_eq!(test.window.page_name(), "picker", "the late connect was adopted"); + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[gtk4::test] +fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { + let test = TestWindow::with_script( + "Failed", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let page = test + .window + .imp() + .toast_overlay + .child() + .and_then(|child| child.downcast::().ok()) + .expect("the failed page"); + assert!(page.message().is_some_and(|message| !message.is_empty())); + assert!( + test.window + .lookup_action("retry-connect") + .is_some_and(|action| action.is_enabled()) + ); + assert_eq!(test.services.sessions.window_for(&test.key()), Some(test.window.id())); + + WidgetExt::activate_action(&test.window, "win.cancel-connect", None).expect("win.cancel-connect"); + test.wait_for_page("picker"); + + assert_eq!(test.services.sessions.window_for(&test.key()), None); +} + +#[gtk4::test] +fn retry_rereads_credentials() { + let credentials = FakeCredentialSource::new(); + let test = TestWindow::with_script( + "Retry", + Script { + establish: vec![refused()], + credentials: Some(credentials.clone()), + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let before = credentials.refreshes().len(); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + + assert_eq!(credentials.refreshes().len(), before + 1); +} + +#[gtk4::test] +fn missing_file_goes_to_connect_failed() { + let test = TestWindow::picker("MissingFile"); + let missing = test._dir.path().join("gone.db"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&missing))); + test.wait_for_page("connect-failed"); + + let message = test + .window + .imp() + .toast_overlay + .child() + .and_then(|child| child.downcast::().ok()) + .and_then(|page| page.message()) + .expect("the failed page's message"); + assert!(message.contains("gone.db"), "{message}"); + assert!(test.connector.calls().is_empty(), "a missing file was connected to"); +} + +#[gtk4::test] +fn an_unsaved_file_opens_a_session_of_its_own() { + let test = TestWindow::picker("File"); + let database = test._dir.path().join("local.db"); + std::fs::write(&database, b"").expect("seed"); + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("workspace"); + + let uri = glib::filename_to_uri(std::fs::canonicalize(&database).expect("canonical"), None) + .expect("a uri") + .to_string(); + assert_eq!( + test.services.sessions.window_for(&SessionKey::File(uri)), + Some(test.window.id()) + ); +} + +#[gtk4::test] +fn late_show_create_reply_after_disconnect_is_dropped() { + let test = TestWindow::connected_with("LateShowCreate", |script| { + script.dialect = Arc::new(drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))); + script.fetch_table_definition.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(users_table()), + }); + }); + let counters = test.connection().counters(); + let input = test.workspace().input().expect("the workspace's input"); + input.deliver(WorkspaceInput::ShowCreateTableForExisting { + schema: Some("public".to_owned()), + table: "users".to_owned(), + }); + wait_until(WAIT, || CallCounters::get(&counters.fetch_table_definition) == 1) + .expect("the definition was asked for"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + let _ = wait_until(Duration::from_millis(600), || false); + drain_main_context(); + + assert_eq!(test.window.page_name(), "picker"); + assert!(editors(&test.window).is_empty(), "the late reply opened an editor tab"); +} + +#[gtk4::test] +fn quit_closes_a_connected_window_and_its_session() { + let test = TestWindow::connected("Quit"); + let counters = test.connection().counters(); + + let decision = crate::test_support::finish_on_main(WAIT, crate::application::QuitSequence::run(test.app.clone())) + .expect("the quit to finish"); + + assert_eq!(decision, CloseDecision::Approved); + wait_until(WAIT, || CallCounters::get(&counters.close) == 1).expect("the connection to close"); + assert!(test.app.windows().is_empty()); + assert_eq!(test.services.sessions.live_count(), 0); +} + +#[expect( + clippy::disallowed_methods, + reason = "a test stands in for the catalogue a driver would read" +)] +fn users_table() -> tablepro_core::ddl::TableDefinition { + use tablepro_core::column::{ + CatalogType, ColumnDefault, ColumnKind, ColumnType, ReadForm, SqlTypeExpr, TextLength, + }; + use tablepro_core::ddl::{ColumnAttributes, ColumnDefinition, TableDefinition}; + use tablepro_core::meta::TableRef; + + let column = ColumnDefinition::new( + tablepro_core::ColumnInfo { + name: "id".to_owned(), + column_type: ColumnType { + name: "text".to_owned(), + kind: ColumnKind::Text { + length: TextLength::Unbounded, + }, + catalog: Some(CatalogType { + declaration: SqlTypeExpr::from_catalog_text("text"), + cast_target: SqlTypeExpr::from_catalog_text("text"), + }), + dynamic_storage: false, + read_form: ReadForm::Native, + }, + nullable: false, + primary_key: true, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + }, + ColumnAttributes::default(), + ); + TableDefinition { + table: TableRef::new(Some("public".to_owned()), "users"), + columns: vec![column], + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + } +} diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs new file mode 100644 index 0000000000..1962d99077 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -0,0 +1,184 @@ +//! The window's own actions and shortcuts. +//! +//! The actions go on the window's own action map, which GTK already +//! exports as `win`: a second group inserted under that name would hide +//! it from the application's accelerators and from D-Bus. + +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::SessionWindowInput; +use super::session_page::SessionPage; +use crate::runtime::{DeliverInput, InputSender}; + +/// The window's actions and the input each one sends. A closure holds +/// only the window's input sender, so an action that outlives the window +/// drops its input rather than reaching a window that is gone. +/// Builds the input an action sends. +type ActionInput = fn() -> SessionWindowInput; + +const ACTIONS: [(&str, ActionInput); 8] = [ + ("disconnect", || SessionWindowInput::Disconnect), + ("reconnect", || SessionWindowInput::Reconnect), + ("cancel-connect", || SessionWindowInput::CancelConnect), + ("retry-connect", || SessionWindowInput::RetryConnect), + ("edit-connection", || SessionWindowInput::EditConnection), + ("reset-workspace-layout", || SessionWindowInput::ResetWorkspaceLayout), + ("export-connections", || SessionWindowInput::ExportConnections), + ("import-connections", || SessionWindowInput::ImportConnections), +]; + +pub(super) fn install(window: &adw::ApplicationWindow, input: &InputSender) { + let entries = ACTIONS.map(|(name, message)| { + let input = input.clone(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| input.deliver(message())) + .build() + }); + window.add_action_entries(entries); +} + +/// Enable each action on the page it means something on, so its menu +/// item and button are insensitive elsewhere. +pub(super) fn sync(window: &adw::ApplicationWindow, page: &SessionPage, editable: bool) { + let connected = matches!(page, SessionPage::Workspace { .. }); + let failed = matches!(page, SessionPage::ConnectFailed { .. }); + for (name, enabled) in [ + ("disconnect", connected), + ("reconnect", connected), + ("reset-workspace-layout", connected), + ( + "cancel-connect", + matches!(page, SessionPage::Connecting { .. }) || failed, + ), + ("retry-connect", failed), + ("edit-connection", failed && editable), + ] { + if let Some(action) = window.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } + } +} + +/// Quit, New Window and Preferences are application accelerators, which +/// GTK runs before these, so none of their keys is bound here. The +/// workspace adds the actions these name while it is open. +pub(crate) fn install_window_shortcuts(window: &adw::ApplicationWindow) { + use gtk::gdk::{Key, ModifierType}; + let primary = ModifierType::CONTROL_MASK; + let primary_shift = ModifierType::CONTROL_MASK | ModifierType::SHIFT_MASK; + let controller = gtk::ShortcutController::new(); + controller.set_scope(gtk::ShortcutScope::Global); + controller.add_shortcut(make_shortcut(Key::w, primary, "win.close-current")); + controller.add_shortcut(make_shortcut(Key::e, primary, "win.open-editor")); + // Ctrl+T mirrors Ctrl+E for the browser's "new tab". + controller.add_shortcut(make_shortcut(Key::t, primary, "win.open-editor")); + controller.add_shortcut(make_shortcut(Key::F5, ModifierType::empty(), "win.refresh-page")); + controller.add_shortcut(make_shortcut(Key::f, primary, "win.open-filter")); + controller.add_shortcut(make_shortcut(Key::h, primary, "win.show-history")); + controller.add_shortcut(make_shortcut(Key::s, primary, "win.save-changes")); + controller.add_shortcut(make_shortcut(Key::z, primary, "win.undo-change")); + controller.add_shortcut(make_shortcut(Key::y, primary, "win.redo-change")); + controller.add_shortcut(make_shortcut(Key::z, primary_shift, "win.redo-change")); + controller.add_shortcut(make_shortcut(Key::t, primary_shift, "win.reopen-closed-tab")); + controller.add_shortcut(make_shortcut(Key::s, primary_shift, "win.save-query")); + window.add_controller(controller); +} + +fn make_shortcut(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, action: &str) -> gtk::Shortcut { + gtk::Shortcut::builder() + .trigger(>k::KeyvalTrigger::new(key, modifiers)) + .action(>k::NamedAction::new(action)) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::runtime::Delivery; + use crate::ui::session_window::tests::TestWindow; + + fn shortcut_action_names(window: &adw::ApplicationWindow) -> Vec { + let controllers = window.observe_controllers(); + let mut names = Vec::new(); + for index in 0..controllers.n_items() { + let Some(controller) = controllers.item(index).and_downcast::() else { + continue; + }; + for position in 0..controller.n_items() { + let Some(shortcut) = controller.item(position).and_downcast::() else { + continue; + }; + if let Some(action) = shortcut.action().and_downcast::() { + names.push(action.action_name().to_string()); + } + } + } + names + } + + #[gtk4::test] + fn window_shortcuts_leave_the_application_keys_alone() { + let window = adw::ApplicationWindow::builder().build(); + + install_window_shortcuts(&window); + let names = shortcut_action_names(&window); + + assert!(names.iter().any(|name| name == "win.close-current"), "{names:?}"); + for owned_by_the_application in ["win.quit", "win.preferences", "win.shortcuts"] { + assert!( + !names.iter().any(|name| name == owned_by_the_application), + "{owned_by_the_application} is an application action now: {names:?}" + ); + } + } + + #[gtk4::test] + fn actions_live_on_application_window_map() { + let window = adw::ApplicationWindow::builder().build(); + let (input, mut receiver) = crate::runtime::input_queue::(); + + install(&window, &input); + sync(&window, &SessionPage::Picker, false); + + assert!( + window + .lookup_action("disconnect") + .is_some_and(|action| !action.is_enabled()) + ); + for moved in ["quit", "about", "preferences"] { + assert!( + window.lookup_action(moved).is_none(), + "win.{moved} is still on the window" + ); + } + WidgetExt::activate_action(&window, "win.export-connections", None) + .expect("win resolves to the window's own map"); + assert!(matches!(receiver.try_recv(), Ok(SessionWindowInput::ExportConnections))); + } + + #[gtk4::test] + fn action_after_workspace_drop_does_not_panic() { + let test = TestWindow::connected("ActionAfterDrop"); + let action = test + .window + .lookup_action("refresh-page") + .expect("the workspace's refresh action"); + let input = test.workspace().input().expect("the workspace's input"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + crate::test_support::drain_main_context(); + + assert!( + test.window.lookup_action("refresh-page").is_none(), + "the action outlived its workspace" + ); + action.activate(None); + assert_eq!( + input.try_deliver(crate::ui::workspace::WorkspaceInput::RefreshPage), + Delivery::ReceiverGone + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/close_flow.rs b/linux/crates/app/src/ui/workspace/close_flow.rs new file mode 100644 index 0000000000..1ea98e2423 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/close_flow.rs @@ -0,0 +1,200 @@ +use std::sync::Arc; + +use gtk4::glib; +use gtk4::prelude::*; +use tablepro_core::EditorSession; +use uuid::Uuid; + +use super::workspace_persistence::WorkspaceTabs; +use super::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; +use crate::runtime::DeliverInput; +use crate::workspace::CloseDecision; + +/// Every editor tab's connection, taken from its tab. Each tab's run is +/// stopped first, so nothing is still writing to the session it gives +/// up. +fn take_editor_sessions(tabs: &WorkspaceTabs) -> Vec> { + tabs.borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Editor(slot) => slot.editor.take_editor_session(), + WorkspaceTab::Structure(_) | WorkspaceTab::Table(_) => None, + }) + .collect() +} + +impl WorkspaceModel { + /// Write what the workspace shows as it is now: the last column drag + /// and filter change, the editor text and the tab list. The process + /// is held until every write lands, so quitting right after a + /// disconnect does not lose them. + pub(super) fn flush(&self) { + let hold = self.context.hold.acquire(); + let column_widths = self.storage.column_widths().flush(); + let filter_settings = self.storage.filter_settings().flush(); + let editor_drafts = self.drafts.flush_all(); + let writer = self.drafts.clone(); + let tabs = self.workspace.detach(); + glib::spawn_future_local(async move { + column_widths.await; + filter_settings.await; + for failure in editor_drafts.await { + tracing::warn!(failure, "an editor draft was not saved"); + } + writer.release_closed(); + if let Some(tabs) = tabs + && let Err(error) = tabs.await + { + tracing::debug!(%error, "the open tabs were not saved"); + } + drop(hold); + }); + } + + pub(super) fn take_editor_sessions(&self) -> Vec> { + take_editor_sessions(&self.workspace_tabs) + } + + /// A tab asked to close. One with nothing to lose closes now; any + /// other waits for the prompts. + pub(super) fn close_workspace_tab_by_id(&mut self, id: Uuid) { + let Some(tab_view) = self.workspace_tab_view.clone() else { + return; + }; + if self.close_guards.may_close_tab(&self.workspace_handle, id) { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let Some(parent) = self.dialog_parent() else { + return; + }; + let input = self.input.clone(); + self.close_guards + .ask_close_tab(&parent, &self.workspace_handle, id, move |decision| { + input.deliver(WorkspaceInput::TabCloseDecided { + id, + approved: decision == CloseDecision::Approved, + }); + }); + } + + pub(super) fn on_tab_close_decided(&mut self, id: Uuid, approved: bool) { + let Some(tab_view) = self.workspace_tab_view.clone() else { + return; + }; + if approved { + self.finish_close_workspace_tab(id, &tab_view); + return; + } + let page = self.workspace_tabs.borrow().get(&id).map(|tab| tab.page().clone()); + if let Some(page) = page { + tab_view.close_page_finish(&page, false); + } + } + + /// Tell the window whether a logout would lose something here, so it + /// holds the session back while one would. + pub(super) fn refresh_inhibitor(&self) { + let dirty = !self.workspace_handle.dirty_tabs().is_empty(); + if self.reported_dirty.replace(Some(dirty)) == Some(dirty) { + return; + } + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("dirty-changed", &[&dirty]); + } + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::HashMap; + use std::rc::Rc; + use std::time::Duration; + + use libadwaita as adw; + use tablepro_core::TransactionState; + use tablepro_session::runtime::CountingHold; + use tablepro_storage::DraftId; + use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; + + use super::*; + use crate::services::active_session::ActiveSession; + use crate::test_support::{FakeSession, MemorySettings, wait_until}; + use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::window_tabs::WindowTabs; + use crate::ui::workspace::{EditorTabSlot, write_workspace_tab_id}; + use crate::workspace::{TabDirectory, TabEntry}; + + const WAIT: Duration = Duration::from_secs(10); + + #[gtk4::test] + fn an_open_transaction_is_named_and_its_session_handed_over_once() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let settings = MemorySettings::new(); + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + let editor_session = Arc::new(FakeEditorSession::new(runs)); + let mut script = ConnectionScript::default(); + let opened: Arc = editor_session.clone(); + script.open_editor_session.always(FakeReply::ok(opened)); + script.probe.always(FakeReply::ok(())); + let fake = FakeSession::start(script, [], runtime.tasks()); + let active = ActiveSession::new(); + active.replace(fake.session.clone()); + let hold = Rc::new(CountingHold::new()); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some("BEGIN; UPDATE t SET a = 1".to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active.clone(), + hold: hold.clone(), + close_timeout: Duration::from_secs(5), + }); + editor.run(); + wait_until(WAIT, || !editor.is_running()).expect("the run to end"); + let view = adw::TabView::new(); + let page = view.append(&editor); + page.set_title("• Query 1"); + let id = Uuid::new_v4(); + write_workspace_tab_id(&page, id); + let tabs: WorkspaceTabs = Rc::new(RefCell::new(HashMap::from([( + id, + WorkspaceTab::Editor(EditorTabSlot { + editor: editor.clone(), + page, + draft: DraftId::new(), + }), + )]))); + let directory = WindowTabs::new(tabs.clone()); + directory.set_view(Some(&view)); + + assert_eq!( + directory.tabs(), + [TabEntry { + id, + title: "Query 1".to_owned(), + open_transaction: true, + }] + ); + + let taken = take_editor_sessions(&tabs); + editor.shutdown(); + crate::test_support::drain_main_context(); + + assert_eq!(taken.len(), 1, "the tab's session was not handed over"); + assert!(take_editor_sessions(&tabs).is_empty(), "handed over twice"); + assert_eq!( + editor_session.close_count(), + 0, + "the tab closed the session it handed over" + ); + assert_eq!(hold.live(), 0); + drop((taken, tabs)); + crate::test_support::drain_main_context(); + runtime.shutdown(); + } +} diff --git a/linux/crates/app/src/ui/workspace/health_banner.rs b/linux/crates/app/src/ui/workspace/health_banner.rs new file mode 100644 index 0000000000..d893962918 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/health_banner.rs @@ -0,0 +1,85 @@ +//! The banner over the tabs while the session needs attention. + +use tablepro_session::{FailureCause, SessionHealth}; + +use super::WorkspaceModel; + +impl WorkspaceModel { + /// GNOME apps keep banners for states the user can act on, so the + /// banner shows only while the session is not online. Its Retry is + /// `win.reconnect`. + pub(super) fn refresh_health_banner(&self, health: &SessionHealth) { + match health_banner_text(health) { + Some(title) => { + self.reconnect_banner.set_title(&title); + self.reconnect_banner.set_revealed(true); + } + None => self.reconnect_banner.set_revealed(false), + } + } +} + +/// The banner's sentence for a session that needs attention, `None` +/// while it is online. +fn health_banner_text(health: &SessionHealth) -> Option { + match health { + SessionHealth::Online => None, + SessionHealth::Reconnecting { attempt } => Some(crate::i18n::gettext_f( + "Connection lost. Reconnecting (attempt {n}).", + &[("n", &attempt.to_string())], + )), + SessionHealth::Offline => Some(crate::i18n::gettext( + "The server cannot be reached. TablePro will keep checking.", + )), + SessionHealth::Failed { cause } => Some(failed_banner_text(*cause)), + } +} + +fn failed_banner_text(cause: FailureCause) -> String { + match cause { + FailureCause::Authentication => { + crate::i18n::gettext("The server refused the login. Retry to enter the password again.") + } + FailureCause::Tls => crate::i18n::gettext("The secure connection failed. Check the connection's TLS settings."), + FailureCause::Configuration => { + crate::i18n::gettext("The connection settings no longer work. Check them and retry.") + } + FailureCause::HostKeyUnknown => crate::i18n::gettext("The SSH server's host key is not trusted yet."), + FailureCause::HostKeyChanged => crate::i18n::gettext( + "The SSH server's host key changed. Check with its administrator before connecting again.", + ), + FailureCause::HostKeyRevoked => crate::i18n::gettext("The SSH server's host key was revoked."), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_banner_shows_only_when_the_session_needs_attention() { + assert_eq!(health_banner_text(&SessionHealth::Online), None); + let reconnecting = health_banner_text(&SessionHealth::Reconnecting { attempt: 3 }).expect("a banner"); + assert!(reconnecting.contains('3'), "{reconnecting}"); + assert!(health_banner_text(&SessionHealth::Offline).is_some()); + } + + #[test] + fn each_parked_cause_has_its_own_banner() { + let causes = [ + FailureCause::Authentication, + FailureCause::Tls, + FailureCause::Configuration, + FailureCause::HostKeyUnknown, + FailureCause::HostKeyChanged, + FailureCause::HostKeyRevoked, + ]; + + let texts: std::collections::HashSet = causes + .into_iter() + .map(|cause| health_banner_text(&SessionHealth::Failed { cause }).expect("a banner")) + .collect(); + + assert_eq!(texts.len(), causes.len()); + } +} diff --git a/linux/crates/app/src/ui/workspace/imp.rs b/linux/crates/app/src/ui/workspace/imp.rs new file mode 100644 index 0000000000..dddbe4dbdd --- /dev/null +++ b/linux/crates/app/src/ui/workspace/imp.rs @@ -0,0 +1,91 @@ +use std::cell::RefCell; +use std::sync::OnceLock; + +use glib::subclass::Signal; +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::subclass::prelude::*; + +use super::WorkspaceModel; + +#[derive(Default, gtk::CompositeTemplate)] +#[template(resource = "/app/tablepro/TablePro/ui/workspace.ui")] +pub struct Workspace { + #[template_child] + pub window_title: TemplateChild, + #[template_child] + pub read_only_badge: TemplateChild, + #[template_child] + pub row_op_spinner: TemplateChild, + #[template_child] + pub primary_menu_button: TemplateChild, + #[template_child] + pub sidebar_root: TemplateChild, + #[template_child] + pub sidebar_title: TemplateChild, + #[template_child] + pub table_search_toggle: TemplateChild, + #[template_child] + pub table_search_bar: TemplateChild, + #[template_child] + pub table_search: TemplateChild, + #[template_child] + pub sidebar_scroll: TemplateChild, + #[template_child] + pub toast_overlay: TemplateChild, + #[template_child] + pub reconnect_banner: TemplateChild, + #[template_child] + pub workspace_outer_stack: TemplateChild, + + pub(super) model: RefCell>, + /// Handles the queued input, and with it every reply the workspace + /// is still waiting on. Aborted at shutdown, which drops them. + pub(super) pump: RefCell>>, +} + +#[glib::object_subclass] +impl ObjectSubclass for Workspace { + const NAME: &'static str = "TableProWorkspace"; + type Type = super::Workspace; + type ParentType = adw::BreakpointBin; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + } + + fn instance_init(object: &glib::subclass::InitializingObject) { + object.init_template(); + } +} + +impl ObjectImpl for Workspace { + fn signals() -> &'static [Signal] { + static SIGNALS: OnceLock> = OnceLock::new(); + SIGNALS.get_or_init(|| { + vec![ + Signal::builder("dirty-changed") + .param_types([bool::static_type()]) + .build(), + Signal::builder("saves-in-flight-changed") + .param_types([u32::static_type()]) + .build(), + Signal::builder("title-changed") + .param_types([String::static_type()]) + .build(), + ] + }) + } + + /// A workspace dropped without a teardown still stops its reads and + /// lets go of its tabs. + fn dispose(&self) { + self.obj().shutdown(); + } +} + +impl WidgetImpl for Workspace {} +impl BreakpointBinImpl for Workspace {} diff --git a/linux/crates/app/src/ui/app/import.rs b/linux/crates/app/src/ui/workspace/import.rs similarity index 91% rename from linux/crates/app/src/ui/app/import.rs rename to linux/crates/app/src/ui/workspace/import.rs index 7d90a8b8db..f239ae99e7 100644 --- a/linux/crates/app/src/ui/app/import.rs +++ b/linux/crates/app/src/ui/workspace/import.rs @@ -1,8 +1,9 @@ use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{Component, ComponentController, ComponentSender, gtk}; +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita::prelude::*; +use relm4::{Component, ComponentController}; use tablepro_core::dialect::SqlDialect; use tablepro_core::edit::CellInput; @@ -14,7 +15,7 @@ use tablepro_session::{OperationError, OperationKind, Session}; use crate::ui::file_read::{FileReadError, read_capped_file}; use crate::ui::import_dialog::{CsvImport, MAX_CSV_FILE_BYTES}; -use super::{App, AppMsg}; +use super::{WorkspaceInput, WorkspaceModel}; use crate::runtime::DeliverInput; use crate::workspace::RanStatements; @@ -22,10 +23,10 @@ use crate::workspace::RanStatements; /// announces it, so a failed import has something to read back. const IMPORT_LOG_SAMPLE: usize = 3; -impl App { +impl WorkspaceModel { /// Ask for a CSV, then read it and the table's columns together so /// the mapping dialog opens with both. - pub(super) fn on_import_csv_requested(&self, schema: Option, table: String, sender: ComponentSender) { + pub(super) fn on_import_csv_requested(&self, schema: Option, table: String) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("Connect to a database first.")); return; @@ -49,9 +50,9 @@ impl App { .filters(&filters) .build(); - let input = sender.input_sender().clone(); + let input = self.input.clone(); let tasks = self.context.tasks.clone(); - dialog.open(Some(&self.window), gio::Cancellable::NONE, move |outcome| { + dialog.open(self.window().as_ref(), gio::Cancellable::NONE, move |outcome| { let Ok(file) = outcome else { return }; let input = input.clone(); let schema = schema.clone(); @@ -62,7 +63,7 @@ impl App { let bytes = match read_csv_file(&file).await { Ok(bytes) => bytes, Err(message) => { - let _ = input.send(AppMsg::ShowToast(message)); + input.deliver(WorkspaceInput::ShowToast(message)); return; } }; @@ -75,7 +76,7 @@ impl App { Ok(Ok(columns)) => columns, Ok(Err(error)) => { tracing::warn!(%error, "could not read the table to import into"); - let _ = input.send(AppMsg::ShowToast(crate::i18n::gettext( + input.deliver(WorkspaceInput::ShowToast(crate::i18n::gettext( "The table's columns could not be read.", ))); return; @@ -85,7 +86,7 @@ impl App { return; } }; - let _ = input.send(AppMsg::ImportCsvReady { + input.deliver(WorkspaceInput::ImportCsvReady { schema, table, columns, @@ -101,7 +102,6 @@ impl App { table: String, columns: Vec, bytes: Vec, - sender: ComponentSender, ) { use crate::ui::import_dialog::{ImportCsvDialog, ImportCsvDialogInit, ImportCsvDialogOutput}; @@ -112,17 +112,22 @@ impl App { columns, bytes, }) - .forward(sender.input_sender(), |out| match out { - ImportCsvDialogOutput::Import(import) => AppMsg::ImportCsvRun(import), - ImportCsvDialogOutput::ShowToast(text) => AppMsg::ShowToast(text), + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + ImportCsvDialogOutput::Import(import) => WorkspaceInput::ImportCsvRun(import), + ImportCsvDialogOutput::ShowToast(text) => WorkspaceInput::ShowToast(text), + }); + } }); - dialog.model().dialog().present(Some(&self.window)); + dialog.model().dialog().present(self.dialog_parent().as_ref()); self.import_dialog = Some(dialog); } /// Read the whole file, turn every row into an INSERT and write them /// as one batch, so a file either lands whole or not at all. - pub(super) fn on_import_csv_run(&self, import: CsvImport, sender: ComponentSender) { + pub(super) fn on_import_csv_run(&self, import: CsvImport) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("Connect to a database first.")); return; @@ -143,7 +148,8 @@ impl App { let table = TableRef::new(import.schema.clone(), import.table.clone()); let history = self.history.store(); let connection = self.context.active.metadata(); - self.context.tasks.spawn_task(async move { + let input = self.input.clone(); + drop(self.context.tasks.spawn_task(async move { let written = write_import(&session, table, import.columns, rows).await; match written { Ok((sql, report)) => { @@ -153,8 +159,8 @@ impl App { RanStatements::starting(history, connection, sql) .finished(touched, tablepro_storage::query_history::Outcome::Success) .await; - sender.deliver(AppMsg::RefreshPage); - sender.deliver(AppMsg::ShowToast(crate::i18n::ngettext_f( + input.deliver(WorkspaceInput::RefreshPage); + input.deliver(WorkspaceInput::ShowToast(crate::i18n::ngettext_f( "Imported {n} row.", "Imported {n} rows.", u32::try_from(count).unwrap_or(u32::MAX), @@ -163,13 +169,13 @@ impl App { } Err(error) => { let message = crate::ui::error_text::operation_error_message(&error); - sender.deliver(AppMsg::ShowAlert { + input.deliver(WorkspaceInput::ShowAlert { title: crate::i18n::gettext("Couldn't import"), body: message, }); } } - }); + })); } } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs new file mode 100644 index 0000000000..633c353a0a --- /dev/null +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -0,0 +1,821 @@ +//! One connection session's workspace: the sidebar of its tables and +//! the tabs open on them. A workspace never changes session; a +//! disconnect drops it and the window shows the connection picker. + +mod close_flow; +mod health_banner; +mod imp; +mod import; +mod row_ops; +mod saves; +mod sidebar; +mod sidebar_section_header; +mod status_pages; +mod structure; +mod window_tabs; +mod workspace_actions; +mod workspace_persistence; +mod workspace_tabs; + +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, VecDeque}; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::factory::FactoryVecDeque; +use relm4::{ComponentController, Controller}; + +use tablepro_core::{EditorSession, QueryResult}; +use tablepro_session::runtime::WatchForwarder; +use tablepro_session::{ObjectListSnapshot, Session, SessionHealth, SessionKey}; +use uuid::Uuid; + +use super::browse_tab::{BrowseTab, BrowseTabInput}; +use super::editor::SqlEditor; +use super::history_dialog::HistoryDialog; +use super::sidebar_row::SidebarRow; +use crate::application::AppServices; +use crate::runtime::{DeliverInput, InputSender}; +use crate::services::change_tracker_registry::ChangeTrackerRegistry; +use crate::services::session_context::SessionContext; +use crate::services::structure_tracker_registry::StructureTrackerRegistry; +use crate::workspace::{CloseGuards, WorkspaceHandle}; + +glib::wrapper! { + pub struct Workspace(ObjectSubclass) + @extends adw::BreakpointBin, gtk::Widget, + @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; +} + +/// What a window hands the workspace it builds for an adopted session. +pub(crate) struct WorkspaceInit { + pub key: SessionKey, + pub session: Arc, + pub services: Rc, + /// The window's own, whose session slot the workspace does not use. + pub context: Rc, + pub window: adw::ApplicationWindow, + /// The window's prompts, so a tab's close and the window's never ask + /// at once. + pub close_guards: Rc, +} + +pub(crate) type WorkspaceSender = InputSender; + +pub(crate) struct WorkspaceModel { + context: Rc, + /// What the session's tabs are saved under. + key: SessionKey, + input: WorkspaceSender, + this: glib::WeakRef, + window: glib::WeakRef, + settings: Rc, + storage: crate::storage::SharedStorage, + tasks: tablepro_session::runtime::Tasks, + history: crate::services::history_service::HistoryService, + drafts: Rc, + window_title: adw::WindowTitle, + sidebar_title: adw::WindowTitle, + /// Watches `HistoryService` and flips `win.show-history`. Aborted + /// with the workspace, whose actions it acts on. + history_gate: glib::JoinHandle<()>, + /// The window's actions this workspace added, removed with it. + actions: Vec<&'static str>, + sidebar_factory: FactoryVecDeque, + /// Takes the sidebar rows' output to `input`. + sidebar_bridge: glib::JoinHandle<()>, + sidebar_schemas: Rc>>>, + toast_overlay: adw::ToastOverlay, + reconnect_banner: adw::Banner, + row_op_spinner: adw::Spinner, + read_only_badge: gtk::Label, + table_search: gtk::SearchEntry, + /// `"empty"` while no tab is open, `"tabs"` otherwise. + workspace_outer_stack: gtk::Stack, + /// AdwTabOverview wrapping the unified AdwTabBar + AdwTabView. + workspace_root: Option, + workspace_tab_view: Option, + /// Idempotency flag for `ensure_workspace_root`. + workspace_root_added: Cell, + /// Per-tab state, by tab id. Display order is read from the tab + /// view's pages. + workspace_tabs: Rc>>, + /// Pending row edits, one tracker per Table tab. + change_trackers: Rc, + /// Pending DDL, one tracker per Structure tab. + structure_trackers: Rc, + /// Starts, stops and settles the saves, and says what closing any + /// part of the workspace would lose. + workspace_handle: WorkspaceHandle, + /// The tabs as the close prompts read them. + window_tabs: Rc, + close_guards: Rc, + /// Whether the window was last told there is something to lose. + reported_dirty: Cell>, + history_dialog: Option>, + saved_queries_dialog: Option>, + import_dialog: Option>, + driver_id: String, + read_only: bool, + /// Writes the tab list a moment after the last change. + workspace: Rc, + /// Recently closed tabs for Ctrl+Shift+T. Pending edits are not + /// kept: the close has already cleared them. + closed_tabs_stack: Rc>>, + /// Feed the reconnect banner and the sidebar, and stop with the + /// workspace, so nothing reads for a session it let go of. + _health_watch: WatchForwarder, + _catalog_watch: WatchForwarder, +} + +/// Snapshot of a tab the user just closed, retained for Ctrl+Shift+T +/// reopen, so reopen routes back through the same `append_*` +/// constructors as a restore. +#[derive(Debug, Clone)] +pub enum ClosedTabDescriptor { + Editor { + query: String, + }, + Table { + schema: Option, + table: String, + offset: u64, + page_size: u64, + sort: Option<(usize, bool)>, + }, + Structure { + schema: Option, + table: String, + }, +} + +pub(super) const CLOSED_TABS_CAPACITY: usize = 10; + +pub struct EditorTabSlot { + pub editor: SqlEditor, + pub page: adw::TabPage, + /// The file this tab's text is written to. The text itself lives in + /// the buffer, not here. + pub draft: tablepro_storage::DraftId, +} + +pub struct StructureTabSlot { + pub id: Uuid, + pub structure: crate::ui::structure_tab::StructureTab, + pub page: adw::TabPage, + pub schema: Option, + /// Empty in `New` mode until SaveCompleted carries the canonical + /// table name back from the driver. Edit mode populates from the + /// sidebar click or restore record. + pub table: String, + pub mode: crate::ui::structure_tab::StructureMode, +} + +/// A tab showing one table's rows. The table's structure opens in a +/// tab of its own: one TabPage per surface, as GNOME Files and Builder +/// do. +pub struct TableTabSlot { + pub id: Uuid, + pub page: adw::TabPage, + pub schema: Option, + pub table: String, + pub browse: BrowseTab, +} + +/// A tab in the unified workspace. +/// +/// - **Table**: one (schema, table) entity in Data (Browse grid) +/// view. Default for every sidebar single-click. +/// - **Editor**: a free-form SQL workspace, orthogonal to any one +/// table. +/// - **Structure**: the DDL editor, for a new table's draft or an +/// existing table. +pub enum WorkspaceTab { + Editor(EditorTabSlot), + Structure(StructureTabSlot), + Table(TableTabSlot), +} + +impl WorkspaceTab { + /// The Browse tab. Only `Table` slots carry one. + pub fn browse_tab(&self) -> Option<&BrowseTab> { + match self { + WorkspaceTab::Table(s) => Some(&s.browse), + _ => None, + } + } + + /// The Structure-side controller. Only `Structure` slots carry + /// one (Table slots no longer fuse the DDL editor in). + pub fn structure_tab(&self) -> Option<&crate::ui::structure_tab::StructureTab> { + match self { + WorkspaceTab::Structure(s) => Some(&s.structure), + _ => None, + } + } + + /// `(schema, table)` when the slot is pinned to one. Editor + /// returns `None`. + pub fn schema_table(&self) -> Option<(Option<&str>, &str)> { + match self { + WorkspaceTab::Structure(s) => Some((s.schema.as_deref(), &s.table)), + WorkspaceTab::Table(s) => Some((s.schema.as_deref(), &s.table)), + WorkspaceTab::Editor(_) => None, + } + } + + pub fn page(&self) -> &adw::TabPage { + match self { + WorkspaceTab::Editor(slot) => &slot.page, + WorkspaceTab::Structure(slot) => &slot.page, + WorkspaceTab::Table(slot) => &slot.page, + } + } +} + +#[derive(Debug, Clone, Copy)] +pub enum OpenMode { + /// Plain sidebar click: activate the table's tab, or append one. + SwitchOrAppend, + /// Ctrl+click / right-click "Open in new tab": always append a + /// new tab even when the same table is already open. + NewTab, +} + +// The page carries only the tab's id; its kind is in `workspace_tabs`. +fn workspace_tab_id_quark() -> glib::Quark { + static QUARK: std::sync::OnceLock = std::sync::OnceLock::new(); + *QUARK.get_or_init(|| glib::Quark::from_str("tp-workspace-tab-id")) +} + +pub(super) fn write_workspace_tab_id(page: &adw::TabPage, id: Uuid) { + unsafe { + page.set_qdata(workspace_tab_id_quark(), id); + } +} + +pub(super) fn read_workspace_tab_id(page: &adw::TabPage) -> Option { + unsafe { page.qdata::(workspace_tab_id_quark()).map(|p| *p.as_ref()) } +} + +#[derive(Debug)] +pub(crate) enum WorkspaceInput { + /// The session's health moved. + HealthChanged(SessionHealth), + /// The session's object list for the sidebar changed: a reconnect + /// read it again, or a schema change refreshed it. + CatalogChanged(ObjectListSnapshot), + SelectTable { + schema: Option, + name: String, + open_mode: OpenMode, + }, + /// "+ New query" button or Ctrl+T → append a new editor tab. + NewEditorTab, + /// Ctrl+W → close active workspace tab (browse or editor). + CloseActiveWorkspaceTab, + EditorTabRunStateChanged(Uuid, bool), + EditorTabContentChanged(Uuid), + /// The session's saved tabs and their editor text, read off the GTK + /// thread. + WorkspaceLoaded(workspace_tabs::LoadedWorkspace), + /// The toast's Reset: move the unreadable saved tabs aside. + ResetWorkspaceLayout, + WorkspaceLayoutReset(Result, crate::workspace::WorkspaceSaveError>), + ShowHistory, + ShowSavedQueries, + /// Keep the active editor tab's SQL under a name the user picks. + SaveActiveQuery, + SaveQueryNamed { + name: String, + query: String, + }, + /// "Import CSV…" from a table's own menu: ask for a file, then + /// open the mapping dialog against that table's columns. + ImportCsvRequested { + schema: Option, + table: String, + }, + /// The file came back with the table's columns beside it, so the + /// mapping dialog has everything it needs. + ImportCsvReady { + schema: Option, + table: String, + columns: Vec, + bytes: Vec, + }, + /// The mapping is settled, so the rows can be written. + ImportCsvRun(Box), + OpenHistoryQuery(String), + ReplaceActiveTabQuery(String), + /// The close prompts decided about one tab's close. + TabCloseDecided { + id: Uuid, + approved: bool, + }, + RefreshPage, + ExportResults { + result: QueryResult, + name: String, + target: Option, + }, + CopyToClipboard(String), + /// User clicked the close-X on any workspace tab. + WorkspaceTabClosed(Uuid), + /// Tab right-click "Close Other Tabs": each tab still asks about + /// its own unsaved changes. + CloseOtherWorkspaceTabs(Uuid), + /// Tab right-click "Close Tabs to the Right", in tab order. + CloseWorkspaceTabsToRight(Uuid), + /// The tab order, the selection or a tab's state changed, so the + /// workspace is saved. + WorkspaceTabsChanged, + /// A tab's own Save: write what its tracker holds. + SaveTab(Uuid), + /// A tab's Stop Saving. + StopSaving(Uuid), + /// A save started or settled, or tabs' changes were dropped. + Workspace(crate::workspace::WorkspaceEvent), + /// The number of saves running in this workspace changed. + SavesInFlightChanged(usize), + /// An editor tab's transaction opened or ended. + EditorTransactionChanged, + /// Ctrl+S, for the active tab. + SaveActiveTab, + /// Ctrl+Z, for the active Table tab. + UndoActiveBrowseTab, + /// Ctrl+Y, for the active Table tab. + RedoActiveBrowseTab, + ShowAlert { + title: String, + body: String, + }, + ShowToast(String), + /// A browse tab's tracker moved between empty and non-empty, so its + /// title gains or loses the "•" GNOME Text Editor puts on unsaved + /// documents. + BrowseTabDirtyChanged(Uuid, bool), + /// Sidebar right-click → "New Table…" or schema-header "+" button. + /// Always appends a fresh draft Structure tab. + NewTableTab { + schema: Option, + }, + /// Sidebar right-click → "Edit Structure". Switches to the table's + /// open Structure tab, or appends one. + EditStructureTab { + schema: Option, + table: String, + }, + /// Sidebar right-click → "Show CREATE TABLE". The SQL opens in a + /// fresh editor tab. + ShowCreateTableForExisting { + schema: Option, + table: String, + }, + /// The CREATE statement `ShowCreateTableForExisting` built, for a + /// new editor tab. + ShowCreateTableLoaded(String), + /// Sidebar right-click → "Drop Table…", or in-tab Drop button. + DropTablePrompt { + schema: Option, + table: String, + }, + /// The user confirmed the drop. + DropTableConfirmed { + schema: Option, + table: String, + }, + /// The table is gone, so its tabs close without asking. + DropTableSucceeded(tablepro_core::TableRef), + /// A Structure tab's tracker crossed the empty / non-empty line. + StructureTabDirtyChanged(Uuid, bool), + /// Schema state changed (table created / dropped / altered): the + /// catalog reads the sidebar's list again and drops the table's + /// columns, and the tabs open on the table read it again. + SchemaChanged { + schema: Option, + table: Option, + }, + /// Ctrl+Shift+T → reopen the most recently closed tab. + ReopenClosedTab, + /// Ctrl+F → open the filter strip for the active Browse tab. + ShowFilterDialog, +} + +/// Tabs in either registry with unsaved changes. +fn dirty_tabs(changes: &ChangeTrackerRegistry, structures: &StructureTrackerRegistry) -> Vec { + let mut dirty = changes.dirty(); + dirty.extend(structures.dirty()); + dirty.sort_unstable(); + dirty.dedup(); + dirty +} + +impl Workspace { + pub(crate) fn new(init: WorkspaceInit) -> Self { + let WorkspaceInit { + key, + session, + services, + context, + window, + close_guards, + } = init; + let workspace: Self = glib::Object::new(); + let imp = workspace.imp(); + let (input, receiver) = crate::runtime::input_queue::(); + imp.pump.replace(Some(crate::runtime::pump_inputs( + &workspace, + receiver, + |workspace, message| workspace.handle_input(message), + ))); + + // The workspace's own slot: a tab reads it for the session, and + // it empties when the workspace goes. + let context = Rc::new(SessionContext { + active: crate::services::active_session::ActiveSession::new(), + ..(*context).clone() + }); + context.active.replace(session.clone()); + + let settings = services.settings.clone(); + let storage = services.storage.clone(); + let tasks = services.tasks.clone(); + let history = services.history(); + let drafts = crate::workspace::DraftWriter::new( + tablepro_storage::DraftStore::new(storage.paths().drafts_dir()), + tasks.clone(), + ); + let persistence = workspace_persistence::WorkspacePersistence::new(storage.workspaces().clone()); + let change_trackers = Rc::new(ChangeTrackerRegistry::default()); + let structure_trackers = Rc::new(StructureTrackerRegistry::default()); + let workspace_tabs: workspace_persistence::WorkspaceTabs = Rc::new(RefCell::new(HashMap::new())); + let window_tabs = Rc::new(window_tabs::WindowTabs::new(workspace_tabs.clone())); + let events = input.clone(); + let workspace_handle = WorkspaceHandle::new(crate::workspace::WorkspaceDeps { + session: context.active.clone(), + tasks: context.tasks.clone(), + hold: context.hold.clone(), + history: Some(history.clone()), + rows: change_trackers.clone(), + structures: structure_trackers.clone(), + directory: window_tabs.clone(), + events: Rc::new(move |event| events.deliver(WorkspaceInput::Workspace(event))), + }); + let saves = input.clone(); + workspace_handle + .ledger() + .on_change(move |count| saves.deliver(WorkspaceInput::SavesInFlightChanged(count))); + + let sidebar = sidebar::build(&workspace, &context, &input); + let actions = workspace_actions::install(&window, &input); + let history_gate = workspace_actions::spawn_history_gate(&window, history.availability()); + + let health_input = input.clone(); + let health_watch = WatchForwarder::new( + session.health(), + move |health| health_input.deliver(WorkspaceInput::HealthChanged(health)), + sidebar::spawn_local, + ); + let catalog_input = input.clone(); + let catalog_watch = sidebar::catalog_watch(&session, move |snapshot| { + catalog_input.deliver(WorkspaceInput::CatalogChanged(snapshot)); + }); + + let driver_id = session.metadata().driver_id.clone(); + let model = WorkspaceModel { + context, + key, + input, + this: workspace.downgrade(), + window: window.downgrade(), + settings, + storage, + tasks, + history, + drafts, + window_title: imp.window_title.get(), + sidebar_title: imp.sidebar_title.get(), + history_gate, + actions, + sidebar_factory: sidebar.factory, + sidebar_bridge: sidebar.bridge, + sidebar_schemas: sidebar.schemas, + toast_overlay: imp.toast_overlay.get(), + reconnect_banner: imp.reconnect_banner.get(), + row_op_spinner: imp.row_op_spinner.get(), + read_only_badge: imp.read_only_badge.get(), + table_search: imp.table_search.get(), + workspace_outer_stack: imp.workspace_outer_stack.get(), + workspace_root: None, + workspace_tab_view: None, + workspace_root_added: Cell::new(false), + workspace_tabs, + change_trackers, + structure_trackers, + workspace_handle, + window_tabs, + close_guards, + reported_dirty: Cell::new(None), + history_dialog: None, + saved_queries_dialog: None, + import_dialog: None, + driver_id, + read_only: session.read_only(), + workspace: persistence, + closed_tabs_stack: Rc::new(RefCell::new(VecDeque::with_capacity(CLOSED_TABS_CAPACITY))), + _health_watch: health_watch, + _catalog_watch: catalog_watch, + }; + imp.model.replace(Some(model)); + workspace + } + + /// Show the session: its tables, the empty tab area, and the tabs + /// saved for it. Called once the window listens for the + /// workspace's signals, so it hears the first title. + pub(crate) fn start(&self) { + self.with_model(WorkspaceModel::open); + } + + /// What the workspace's actions, tabs and background work send it. + pub(crate) fn input(&self) -> Option { + self.read(|model| model.input.clone()) + } + + /// The saves and the close prompts' view of the tabs. + pub(crate) fn handle(&self) -> Option { + self.read(|model| model.workspace_handle.clone()) + } + + /// Write what the workspace shows: the tab list, the editor text, + /// and the last column drag and filter change. The process is held + /// until every write lands. + pub(crate) fn flush(&self) { + self.with_model(|model| model.flush()); + } + + /// Stop every editor run and take each editor tab's session, so the + /// teardown closes them all under one hold. + pub(crate) fn take_editor_sessions(&self) -> Vec> { + self.with_model(|model| model.take_editor_sessions()) + .unwrap_or_default() + } + + /// Stop the tabs, their reads and the watchers, take the + /// workspace's actions off the window and drop what it holds. Any + /// reply still on its way is dropped with the input queue. + pub(crate) fn shutdown(&self) { + let imp = self.imp(); + if let Some(pump) = imp.pump.take() { + pump.abort(); + } + let model = match imp.model.try_borrow_mut() { + Ok(mut model) => model.take(), + Err(_) => { + tracing::warn!("the workspace was shut down while it was handling input"); + return; + } + }; + if let Some(model) = model { + model.shutdown(); + } + } + + pub(crate) fn connect_dirty_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "dirty-changed", + false, + glib::closure_local!(move |workspace: &Self, dirty: bool| f(workspace, dirty)), + ) + } + + pub(crate) fn connect_saves_in_flight_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "saves-in-flight-changed", + false, + glib::closure_local!(move |workspace: &Self, count: u32| { + f(workspace, usize::try_from(count).unwrap_or(usize::MAX)); + }), + ) + } + + pub(crate) fn connect_title_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "title-changed", + false, + glib::closure_local!(move |workspace: &Self, title: String| f(workspace, title)), + ) + } + + fn handle_input(&self, message: WorkspaceInput) { + self.with_model(|model| model.update(message)); + } + + fn with_model(&self, f: impl FnOnce(&mut WorkspaceModel) -> R) -> Option { + let mut model = self.imp().model.try_borrow_mut().ok()?; + model.as_mut().map(f) + } + + fn read(&self, f: impl FnOnce(&WorkspaceModel) -> R) -> Option { + let model = self.imp().model.try_borrow().ok()?; + model.as_ref().map(f) + } +} + +impl WorkspaceModel { + pub(super) fn dirty_tabs(&self) -> Vec { + dirty_tabs(&self.change_trackers, &self.structure_trackers) + } + + /// The window this workspace is in, for dialogs to sit over. + pub(super) fn window(&self) -> Option { + self.window.upgrade() + } + + /// What a dialog sits over: the window, or the workspace itself + /// while it is not in one. + pub(super) fn dialog_parent(&self) -> Option { + self.window() + .map(Cast::upcast) + .or_else(|| self.this.upgrade().map(Cast::upcast)) + } + + /// Show the session it was built for: the sidebar, the empty tab + /// area, and the tabs saved for it. + fn open(&mut self) { + self.read_only_badge.set_visible(self.read_only); + self.reconnect_banner.set_revealed(false); + self.table_search.set_text(""); + self.ensure_workspace_root(); + let objects = self + .context + .active + .get() + .map(|session| { + session + .subscribe_objects(tablepro_core::meta::ObjectScope::DefaultSchema) + .borrow() + .objects + .to_vec() + }) + .unwrap_or_default(); + tracing::info!(driver = %self.driver_id, table_count = objects.len(), "workspace ready"); + self.show_objects(&objects); + self.refresh_window_title(); + self.restore_workspace_tabs(self.key.clone()); + self.refresh_inhibitor(); + } + + /// The window's title names the table in front of the user and the + /// connection, the way GNOME apps name the document; the header + /// names the connection and its driver. A "•" leads while any tab + /// has unsaved changes, so it shows in the overview and Alt+Tab. + pub(super) fn refresh_window_title(&self) { + let metadata = self.context.active.metadata(); + let name = metadata.as_ref().map_or("", |metadata| metadata.name.as_str()); + let mut title = match self.selected_browse_slot_table() { + Some((schema, table)) => format!("{} · {name}", qualified_label(schema.as_deref(), &table)), + None => name.to_owned(), + }; + if !self.dirty_tabs().is_empty() { + title = format!("• {title}"); + } + self.window_title.set_subtitle(&format!("{name} · {}", self.driver_id)); + self.sidebar_title.set_title(name); + if let Some(workspace) = self.this.upgrade() { + workspace.emit_by_name::<()>("title-changed", &[&title]); + } + } + + fn shutdown(mut self) { + self.history_gate.abort(); + self.sidebar_bridge.abort(); + // Their entries name this session's tables and queries. + if let Some(dialog) = self.history_dialog.take() { + dialog.model().dialog().close(); + } + if let Some(dialog) = self.saved_queries_dialog.take() { + dialog.model().dialog().close(); + } + if let Some(dialog) = self.import_dialog.take() { + dialog.model().dialog().close(); + } + if let Some(window) = self.window() { + for action in &self.actions { + window.remove_action(action); + } + } + self.close_tabs(); + self.context.active.take(); + } + + fn update(&mut self, msg: WorkspaceInput) { + match msg { + WorkspaceInput::HealthChanged(health) => self.refresh_health_banner(&health), + WorkspaceInput::CatalogChanged(snapshot) => self.on_catalog_changed(&snapshot), + WorkspaceInput::TabCloseDecided { id, approved } => self.on_tab_close_decided(id, approved), + WorkspaceInput::SelectTable { + schema, + name, + open_mode, + } => self.dispatch_select_table(schema, name, open_mode), + WorkspaceInput::WorkspaceTabsChanged => self.on_workspace_tabs_changed(), + WorkspaceInput::SaveTab(id) => self.request_save(id), + WorkspaceInput::StopSaving(id) => self.stop_saving(id), + WorkspaceInput::Workspace(event) => self.on_workspace_event(event), + WorkspaceInput::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), + WorkspaceInput::EditorTransactionChanged => self.refresh_inhibitor(), + WorkspaceInput::SaveActiveTab => { + if let Some(id) = self.selected_workspace_tab_id() { + self.request_save(id); + } + } + WorkspaceInput::UndoActiveBrowseTab => { + // Structure editing has no per-keystroke history: its + // undo is Discard, and a field's own text undo is GTK's. + if let Some(id) = self.selected_browse_tab_id() { + self.dispatch_to_tab(id, BrowseTabInput::Undo); + } + } + WorkspaceInput::RedoActiveBrowseTab => { + if let Some(id) = self.selected_browse_tab_id() { + self.dispatch_to_tab(id, BrowseTabInput::Redo); + } + } + WorkspaceInput::WorkspaceTabClosed(id) => self.close_workspace_tab_by_id(id), + WorkspaceInput::CloseOtherWorkspaceTabs(id) => self.close_other_workspace_tabs(id), + WorkspaceInput::CloseWorkspaceTabsToRight(id) => self.close_workspace_tabs_to_right(id), + WorkspaceInput::CloseActiveWorkspaceTab => self.close_active_workspace_tab(), + WorkspaceInput::ShowAlert { title, body } => self.show_error_alert(&title, &body), + WorkspaceInput::ShowToast(msg) => self.show_toast(&msg), + WorkspaceInput::BrowseTabDirtyChanged(tab_id, dirty) => { + self.refresh_browse_tab_dirty(tab_id, dirty); + self.refresh_inhibitor(); + } + WorkspaceInput::NewTableTab { schema } => self.on_new_table_tab(schema), + WorkspaceInput::EditStructureTab { schema, table } => self.on_edit_structure_tab(schema, table), + WorkspaceInput::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table), + WorkspaceInput::ShowCreateTableLoaded(sql) => self.append_editor_tab(Some(sql)), + WorkspaceInput::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table), + WorkspaceInput::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table), + WorkspaceInput::DropTableSucceeded(table) => self.on_drop_table_succeeded(table), + WorkspaceInput::StructureTabDirtyChanged(tab_id, dirty) => { + self.refresh_structure_tab_dirty(tab_id, dirty); + self.refresh_inhibitor(); + } + WorkspaceInput::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), + WorkspaceInput::NewEditorTab => self.append_editor_tab(None), + WorkspaceInput::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), + WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), + WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), + WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), + WorkspaceInput::WorkspaceLayoutReset(outcome) => self.on_workspace_layout_reset(outcome), + WorkspaceInput::ShowHistory => self.on_show_history(), + WorkspaceInput::ShowSavedQueries => self.on_show_saved_queries(), + WorkspaceInput::ImportCsvRequested { schema, table } => self.on_import_csv_requested(schema, table), + WorkspaceInput::ImportCsvReady { + schema, + table, + columns, + bytes, + } => self.on_import_csv_ready(schema, table, columns, bytes), + WorkspaceInput::ImportCsvRun(import) => self.on_import_csv_run(*import), + WorkspaceInput::SaveActiveQuery => self.on_save_active_query(), + WorkspaceInput::SaveQueryNamed { name, query } => self.on_save_query_named(name, query), + WorkspaceInput::OpenHistoryQuery(text) => self.append_editor_tab(Some(text)), + WorkspaceInput::ReplaceActiveTabQuery(text) => self.on_replace_active_tab_query(text), + WorkspaceInput::RefreshPage => self.on_refresh_active_tab(), + WorkspaceInput::ExportResults { result, name, target } => { + if let Some(window) = self.window() { + let dialect = self.context.active.get().and_then(|session| session.current_dialect()); + super::export_dialog::present( + &window, + &self.toast_overlay, + result, + name, + target, + dialect, + &self.settings, + ); + } + } + WorkspaceInput::CopyToClipboard(text) => self.on_copy_to_clipboard(text), + WorkspaceInput::ReopenClosedTab => self.on_reopen_closed_tab(), + WorkspaceInput::ShowFilterDialog => self.on_show_filter_dialog(), + } + } +} + +fn qualified_label(schema: Option<&str>, table: &str) -> String { + match schema { + Some(s) => format!("{s}.{table}"), + None => table.to_string(), + } +} diff --git a/linux/crates/app/src/ui/app/row_ops.rs b/linux/crates/app/src/ui/workspace/row_ops.rs similarity index 88% rename from linux/crates/app/src/ui/app/row_ops.rs rename to linux/crates/app/src/ui/workspace/row_ops.rs index bef9aaaf5d..ff9145b81f 100644 --- a/linux/crates/app/src/ui/app/row_ops.rs +++ b/linux/crates/app/src/ui/workspace/row_ops.rs @@ -1,8 +1,8 @@ -use relm4::adw::prelude::*; +use gtk4::prelude::*; -use super::App; +use super::WorkspaceModel; -impl App { +impl WorkspaceModel { pub(super) fn set_row_op_in_flight(&self, in_flight: bool) { self.row_op_spinner.set_visible(in_flight); } @@ -11,7 +11,7 @@ impl App { match clipboard_refusal(&text) { Some(refusal) => self.show_toast(&refusal), None => { - self.window.clipboard().set_text(&text); + self.toast_overlay.clipboard().set_text(&text); self.show_toast(&crate::i18n::gettext("Copied to clipboard")); } } diff --git a/linux/crates/app/src/ui/app/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs similarity index 90% rename from linux/crates/app/src/ui/app/saves.rs rename to linux/crates/app/src/ui/workspace/saves.rs index 025b7fc3ab..7fc1836027 100644 --- a/linux/crates/app/src/ui/app/saves.rs +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -1,14 +1,15 @@ -use relm4::{ComponentSender, adw}; +use gtk4::prelude::*; +use libadwaita as adw; use uuid::Uuid; use tablepro_core::WriteKind; -use super::{App, WorkspaceTab}; +use super::WorkspaceModel; use crate::ui::browse_tab::BrowseTabInput; use crate::ui::structure_tab::StructureTabInput; use crate::workspace::{CloseScope, DirtyTab, SaveOutcome, SaveSettled, SaveStart, WorkspaceEvent}; -impl App { +impl WorkspaceModel { /// Save one tab, from its own Save button or Ctrl+S. pub(super) fn request_save(&self, tab: Uuid) { for start in self.workspace_handle.save(&[tab]) { @@ -26,13 +27,13 @@ impl App { self.workspace_handle.stop_saves(CloseScope::Tab(tab)); } - pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent, sender: ComponentSender) { + pub(super) fn on_workspace_event(&mut self, event: WorkspaceEvent) { match event { WorkspaceEvent::SaveStarted { tab } => { self.dispatch_to_tab(tab, BrowseTabInput::SaveStarted); self.send_to_structure_tab(tab, StructureTabInput::SaveStarted); } - WorkspaceEvent::SaveSettled(settled) => self.on_save_settled(*settled, sender), + WorkspaceEvent::SaveSettled(settled) => self.on_save_settled(*settled), WorkspaceEvent::Discarded { tabs, reload } => { for tab in tabs { let browse = match reload { @@ -53,10 +54,14 @@ impl App { pub(super) fn on_saves_in_flight_changed(&mut self, count: usize) { self.set_row_op_in_flight(count > 0); + if let Some(workspace) = self.this.upgrade() { + let count = u32::try_from(count).unwrap_or(u32::MAX); + workspace.emit_by_name::<()>("saves-in-flight-changed", &[&count]); + } self.refresh_inhibitor(); } - fn on_save_settled(&mut self, settled: SaveSettled, sender: ComponentSender) { + fn on_save_settled(&mut self, settled: SaveSettled) { let SaveSettled { tab, kind, @@ -89,7 +94,7 @@ impl App { self.dispatch_to_tab(tab, BrowseTabInput::SaveOutcomeUnknown(message)); } (WriteKind::SchemaChanges, SaveOutcome::Committed { .. }) => { - self.on_structure_save_completed(tab, saved_table_name, sender); + self.on_structure_save_completed(tab, saved_table_name); } (WriteKind::SchemaChanges, SaveOutcome::RolledBack(cause)) => { let input = match cause.found_changes() { @@ -109,12 +114,7 @@ impl App { /// it. fn tab_name(&self, tab: Uuid) -> String { let title = self.workspace_tabs.borrow().get(&tab).map(|slot| { - let page = match slot { - WorkspaceTab::Editor(slot) => &slot.page, - WorkspaceTab::Structure(slot) => &slot.page, - WorkspaceTab::Table(slot) => &slot.page, - }; - let title = page.title(); + let title = slot.page().title(); title.strip_prefix("• ").unwrap_or(&title).to_owned() }); DirtyTab::quote(&title.unwrap_or_default()) diff --git a/linux/crates/app/src/ui/workspace/sidebar.rs b/linux/crates/app/src/ui/workspace/sidebar.rs new file mode 100644 index 0000000000..7d7e39aefb --- /dev/null +++ b/linux/crates/app/src/ui/workspace/sidebar.rs @@ -0,0 +1,358 @@ +//! The sidebar of the session's tables. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use relm4::factory::FactoryVecDeque; + +use tablepro_core::meta::{ObjectInfo, ObjectScope}; +use tablepro_session::runtime::WatchForwarder; +use tablepro_session::{ObjectListSnapshot, Session}; + +use super::{OpenMode, Workspace, WorkspaceInput, WorkspaceModel, WorkspaceSender, sidebar_section_header}; +use crate::runtime::DeliverInput; +use crate::services::session_context::SessionContext; +use crate::ui::sidebar_row::{SidebarRow, SidebarRowOutput}; + +/// The sidebar's rows, and the schema each one is in, by row index. +pub(super) struct Sidebar { + pub factory: FactoryVecDeque, + /// Takes the rows' output to the workspace. + pub bridge: glib::JoinHandle<()>, + pub schemas: Rc>>>, +} + +pub(super) fn spawn_local(future: futures::future::LocalBoxFuture<'static, ()>) { + glib::spawn_future_local(future); +} + +/// Deliver each new object list the session's catalog commits for the +/// sidebar's scope. The list on screen when the watch starts is not +/// delivered again, and a refresh that failed keeps the list it had. +pub(super) fn catalog_watch( + session: &Arc, + mut deliver: impl FnMut(ObjectListSnapshot) + 'static, +) -> WatchForwarder { + let objects = session.subscribe_objects(ObjectScope::DefaultSchema); + let mut shown = objects.borrow().generation; + WatchForwarder::new( + objects, + move |snapshot: ObjectListSnapshot| { + if snapshot.generation != shown { + shown = snapshot.generation; + deliver(snapshot); + } else if let Some(error) = &snapshot.last_error { + tracing::warn!(%error, "the table list could not be read again"); + } + }, + spawn_local, + ) +} + +pub(super) fn build(workspace: &Workspace, context: &Rc, input: &WorkspaceSender) -> Sidebar { + let imp = workspace.imp(); + let schemas: Rc>>> = Rc::new(RefCell::new(Vec::new())); + let (bridge_sender, bridge) = crate::runtime::relm4_bridge(input.clone()); + let factory: FactoryVecDeque = FactoryVecDeque::builder() + .launch( + gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::Single) + .activate_on_single_click(true) + .css_classes(["navigation-sidebar"]) + .build(), + ) + .forward(&bridge_sender, |out| match out { + // A plain click and Enter come through the list box's + // row-activated below, the one signal that fires for both. + SidebarRowOutput::OpenInNewTab { schema, name } => WorkspaceInput::SelectTable { + schema, + name, + open_mode: OpenMode::NewTab, + }, + SidebarRowOutput::EditStructure { schema, name } => { + WorkspaceInput::EditStructureTab { schema, table: name } + } + SidebarRowOutput::ShowCreateTable { schema, name } => { + WorkspaceInput::ShowCreateTableForExisting { schema, table: name } + } + SidebarRowOutput::ImportCsv { schema, name } => WorkspaceInput::ImportCsvRequested { schema, table: name }, + SidebarRowOutput::DropTable { schema, name } => WorkspaceInput::DropTablePrompt { schema, table: name }, + }); + + let listbox = factory.widget(); + imp.sidebar_scroll.set_child(Some(listbox)); + + let schemas_for_activate = schemas.clone(); + let activate_input = input.clone(); + listbox.connect_row_activated(move |_, row| { + let name = row.widget_name().to_string(); + let index = usize::try_from(row.index()).unwrap_or_default(); + let schema = schemas_for_activate.borrow().get(index).cloned().unwrap_or(None); + activate_input.deliver(WorkspaceInput::SelectTable { + schema, + name, + open_mode: OpenMode::SwitchOrAppend, + }); + }); + + let search = imp.table_search.get(); + let schemas_for_filter = schemas.clone(); + listbox.set_filter_func(move |row| { + let query = search.text().to_lowercase(); + // The schema matches too, so "auth" finds every table in that + // schema, and so does the dotted name the tab title shows. + sidebar_row_matches( + &query, + &row.widget_name(), + schemas_for_filter + .borrow() + .get(usize::try_from(row.index()).unwrap_or_default()) + .and_then(|schema| schema.as_deref()), + ) + }); + let listbox_for_invalidate = listbox.clone(); + imp.table_search.connect_search_changed(move |_| { + listbox_for_invalidate.invalidate_filter(); + }); + imp.table_search_bar.connect_entry(&*imp.table_search); + imp.table_search_bar.set_key_capture_widget(Some(&*imp.sidebar_root)); + + // An empty list with no placeholder reads as broken. + let placeholder = adw::StatusPage::builder() + .icon_name(crate::ui::icons::VIEW_LIST) + .title(crate::i18n::gettext("No tables")) + .description(crate::i18n::gettext( + "Nothing matches the current search, or this connection has no tables yet.", + )) + .build(); + placeholder.add_css_class("compact"); + listbox.set_placeholder(Some(&placeholder)); + + imp.table_search_toggle + .bind_property("active", &*imp.table_search_bar, "search-mode-enabled") + .bidirectional() + .sync_create() + .build(); + + let schemas_for_header = schemas.clone(); + let header_input = input.clone(); + let session = context.active.clone(); + listbox.set_header_func(move |row, before| { + let schemas = schemas_for_header.borrow(); + let distinct: std::collections::BTreeSet<&str> = schemas.iter().filter_map(|s| s.as_deref()).collect(); + // A connection with one schema still gets a header, for its + // "New Table" button. + let multi_schema = distinct.len() >= 2; + let current = schemas + .get(usize::try_from(row.index()).unwrap_or_default()) + .cloned() + .flatten(); + let previous = before + .and_then(|before| schemas.get(usize::try_from(before.index()).unwrap_or_default())) + .cloned() + .flatten(); + let needs = match (¤t, &previous) { + (Some(current), Some(previous)) => current != previous, + (Some(_), None) => true, + (None, None) => before.is_none() && !multi_schema, + (None, Some(_)) => false, + }; + if !needs { + row.set_header(gtk::Widget::NONE); + return; + } + let can_create_table = session + .get() + .and_then(|session| session.current_dialect()) + .and_then(|dialect| dialect.ddl().map(|ddl| ddl.capabilities().create_table)) + .unwrap_or(false); + let input = header_input.clone(); + let schema = current.clone(); + let header = sidebar_section_header::sidebar_section_header(current.as_deref(), can_create_table, move || { + input.deliver(WorkspaceInput::NewTableTab { schema: schema.clone() }); + }); + row.set_header(Some(&header)); + }); + + Sidebar { + factory, + bridge, + schemas, + } +} + +/// Whether a row shows for `query`, already lowercased: by its table +/// name, its schema, or the two as `schema.table`. +fn sidebar_row_matches(query: &str, table: &str, schema: Option<&str>) -> bool { + if query.is_empty() { + return true; + } + let table = table.to_lowercase(); + if table.contains(query) { + return true; + } + let Some(schema) = schema else { + return false; + }; + let schema = schema.to_lowercase(); + schema.contains(query) || format!("{schema}.{table}").contains(query) +} + +/// Only what the grid can page through: routines and sequences have no +/// rows to show. +fn browsable(objects: Vec) -> Vec { + objects + .into_iter() + .filter(|object| object.kind.is_browsable()) + .collect() +} + +impl WorkspaceModel { + pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { + self.repopulate_sidebar(browsable(objects.to_vec())); + } + + fn repopulate_sidebar(&mut self, objects: Vec) { + { + let mut schemas = self.sidebar_schemas.borrow_mut(); + schemas.clear(); + schemas.extend(objects.iter().map(|object| object.schema.clone())); + } + let mut guard = self.sidebar_factory.guard(); + guard.clear(); + for object in objects { + guard.push_back(object); + } + drop(guard); + self.sidebar_factory.widget().invalidate_headers(); + } +} + +#[cfg(test)] +mod tests { + use std::cell::RefCell; + use std::collections::VecDeque; + use std::rc::Rc; + use std::time::Duration; + + use tablepro_core::meta::ObjectKind; + use tablepro_test_support::{CallCounters, ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::FakeSession; + + fn names(snapshot: &ObjectListSnapshot) -> Vec { + snapshot.objects.iter().map(|object| object.name.clone()).collect() + } + + /// The workspace's input queue, holding what the catalog watch + /// posted until the workspace handles it. + type Queue = Rc>>; + + fn post_to(queue: &Queue) -> impl FnMut(ObjectListSnapshot) + 'static { + let queue = queue.clone(); + move |update| queue.borrow_mut().push_back(update) + } + + fn refresh_objects( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + ) -> impl Future + use<> { + let session = session.clone(); + let refreshing = runtime + .tasks() + .spawn_task(async move { session.refresh_objects(ObjectScope::DefaultSchema).await }); + async move { + let _ = refreshing.await; + } + } + + fn object(name: &str, kind: ObjectKind) -> ObjectInfo { + ObjectInfo { + schema: Some("public".to_owned()), + name: name.to_owned(), + kind, + } + } + + /// A workspace drops its catalog watch with itself, so a list read + /// for its session after that reaches nothing. + #[gtk4::test] + fn a_late_catalog_commit_after_the_watch_drops_is_not_delivered() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script.list_objects.always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(vec![object("late", ObjectKind::Table)]), + }); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let watching = catalog_watch(&fake.session, post_to(&queue)); + let late = refresh_objects(&fake.session, &runtime); + let counters = fake.connection.counters(); + crate::test_support::wait_until(Duration::from_secs(5), || { + CallCounters::get(&counters.list_objects) == 1 + }) + .expect("the list was asked for"); + + drop(watching); + crate::test_support::finish_on_main(Duration::from_secs(5), late).expect("the list to land"); + crate::test_support::drain_main_context(); + + assert_eq!( + names(&fake.session.subscribe_objects(ObjectScope::DefaultSchema).borrow()), + ["late"], + "the late list never landed, so the test proved nothing" + ); + assert!(queue.borrow().is_empty(), "a dropped watch still delivered"); + } + + #[gtk4::test] + fn a_new_list_is_delivered_once() { + let runtime = crate::test_support::test_runtime(); + let mut script = ConnectionScript::default(); + script + .list_objects + .always(FakeReply::ok(vec![object("users", ObjectKind::Table)])); + let fake = FakeSession::start(script, [], runtime.tasks()); + let queue = Queue::default(); + let _watching = catalog_watch(&fake.session, post_to(&queue)); + + crate::test_support::finish_on_main(Duration::from_secs(5), refresh_objects(&fake.session, &runtime)) + .expect("the list to land"); + crate::test_support::wait_until(Duration::from_secs(5), || queue.borrow().len() == 1) + .expect("the list was delivered"); + crate::test_support::drain_main_context(); + + assert_eq!(queue.borrow().len(), 1); + assert_eq!(names(&queue.borrow()[0]), ["users"]); + } + + #[test] + fn the_sidebar_lists_only_what_the_grid_can_page() { + let listed = browsable(vec![ + object("users", ObjectKind::Table), + object("active_users", ObjectKind::View), + object("next_id", ObjectKind::Sequence), + object("refresh", ObjectKind::Function), + ]); + + let names: Vec<&str> = listed.iter().map(|object| object.name.as_str()).collect(); + assert_eq!(names, ["users", "active_users"]); + } + + #[test] + fn a_search_matches_the_table_its_schema_or_both() { + assert!(sidebar_row_matches("", "users", None)); + assert!(sidebar_row_matches("use", "Users", Some("public"))); + assert!(sidebar_row_matches("auth", "tokens", Some("auth"))); + assert!(sidebar_row_matches("auth.tok", "tokens", Some("Auth"))); + assert!(!sidebar_row_matches("auth", "tokens", None)); + } +} diff --git a/linux/crates/app/src/ui/app/sidebar_section_header.rs b/linux/crates/app/src/ui/workspace/sidebar_section_header.rs similarity index 100% rename from linux/crates/app/src/ui/app/sidebar_section_header.rs rename to linux/crates/app/src/ui/workspace/sidebar_section_header.rs diff --git a/linux/crates/app/src/ui/app/status_pages.rs b/linux/crates/app/src/ui/workspace/status_pages.rs similarity index 55% rename from linux/crates/app/src/ui/app/status_pages.rs rename to linux/crates/app/src/ui/workspace/status_pages.rs index f251be392a..a8250d6e88 100644 --- a/linux/crates/app/src/ui/app/status_pages.rs +++ b/linux/crates/app/src/ui/workspace/status_pages.rs @@ -1,49 +1,14 @@ -use relm4::adw::prelude::*; -use relm4::{Component, ComponentController, ComponentSender, adw}; +use libadwaita as adw; +use libadwaita::prelude::*; +use relm4::{Component, ComponentController}; use crate::ui::history_dialog::{HistoryDialog, HistoryDialogInit, HistoryDialogOutput}; use crate::ui::saved_queries_dialog::{SavedQueriesDialog, SavedQueriesDialogInit, SavedQueriesDialogOutput}; -use super::{App, AppMsg}; +use super::{WorkspaceInput, WorkspaceModel}; use crate::runtime::DeliverInput; -impl App { - pub(super) fn show_welcome_page(&self, _sender: ComponentSender) { - self.content_holder.set_content(Some(self.welcome_view.widget())); - } - - /// The toast stays until the connect resolves: a remote or tunnelled - /// connect can take longer than any timeout would. - pub(super) fn set_loading_page(&mut self, title: &str, description: &str, sender: &ComponentSender) { - if let Some(prev) = self.connect_progress_toast.take() { - prev.dismiss(); - } - // GNOME inline-metadata separator (` · `) keeps the two - // strings reading as one phrase rather than two sentences - // colliding ("Connecting… Opening MyDB" → "Connecting… · - // Opening MyDB"). Same convention used in the browse - // paginator label and the editor status line. - let body = if description.is_empty() { - title.to_string() - } else { - format!("{title} · {description}") - }; - let toast = adw::Toast::builder().title(&body).timeout(0).build(); - // A connect through SSH or to a server that drops packets can - // take the whole connect timeout, so it can be stopped here. - toast.set_button_label(Some(&crate::i18n::gettext("Cancel"))); - let cancel = sender.clone(); - toast.connect_button_clicked(move |_| cancel.deliver(AppMsg::CancelConnect)); - self.toast_overlay.add_toast(toast.clone()); - self.connect_progress_toast = Some(toast); - } - - pub(super) fn dismiss_loading_page(&mut self) { - if let Some(toast) = self.connect_progress_toast.take() { - toast.dismiss(); - } - } - +impl WorkspaceModel { pub(super) fn show_toast(&self, msg: &str) { self.toast_overlay.add_toast(adw::Toast::new(msg)); } @@ -56,36 +21,50 @@ impl App { dialog.add_response("close", &crate::i18n::gettext("Close")); dialog.set_default_response(Some("close")); dialog.set_close_response("close"); - dialog.present(Some(&self.window)); + dialog.present(self.dialog_parent().as_ref()); } - pub(super) fn on_show_history(&mut self, sender: ComponentSender) { + pub(super) fn on_show_history(&mut self) { let dialog = HistoryDialog::builder() .launch(HistoryDialogInit { history: self.history.clone(), tasks: self.tasks.clone(), }) - .forward(sender.input_sender(), |out| match out { - HistoryDialogOutput::OpenInNewTab(text) => AppMsg::OpenHistoryQuery(text), - HistoryDialogOutput::ReplaceCurrentTabQuery(text) => AppMsg::ReplaceActiveTabQuery(text), + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + HistoryDialogOutput::OpenInNewTab(text) => WorkspaceInput::OpenHistoryQuery(text), + HistoryDialogOutput::ReplaceCurrentTabQuery(text) => { + WorkspaceInput::ReplaceActiveTabQuery(text) + } + }); + } }); - dialog.model().dialog().present(Some(&self.window)); + dialog.model().dialog().present(self.dialog_parent().as_ref()); self.history_dialog = Some(dialog); } - pub(super) fn on_show_saved_queries(&mut self, sender: ComponentSender) { + pub(super) fn on_show_saved_queries(&mut self) { let dialog = SavedQueriesDialog::builder() .launch(SavedQueriesDialogInit { store: self.history.store().map(|history| history.saved_queries()), active_connection: self.context.active.metadata().map(|metadata| metadata.id), }) - .forward(sender.input_sender(), |out| match out { - SavedQueriesDialogOutput::OpenInNewTab(text) => AppMsg::OpenHistoryQuery(text), - SavedQueriesDialogOutput::ReplaceCurrentTabQuery(text) => AppMsg::ReplaceActiveTabQuery(text), - SavedQueriesDialogOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), - SavedQueriesDialogOutput::ShowToast(text) => AppMsg::ShowToast(text), + .connect_receiver({ + let input = self.input.clone(); + move |_, out| { + input.deliver(match out { + SavedQueriesDialogOutput::OpenInNewTab(text) => WorkspaceInput::OpenHistoryQuery(text), + SavedQueriesDialogOutput::ReplaceCurrentTabQuery(text) => { + WorkspaceInput::ReplaceActiveTabQuery(text) + } + SavedQueriesDialogOutput::CopyToClipboard(text) => WorkspaceInput::CopyToClipboard(text), + SavedQueriesDialogOutput::ShowToast(text) => WorkspaceInput::ShowToast(text), + }); + } }); - dialog.model().dialog().present(Some(&self.window)); + dialog.model().dialog().present(self.dialog_parent().as_ref()); self.saved_queries_dialog = Some(dialog); } @@ -93,7 +72,7 @@ impl App { /// /// The text is read now rather than when the dialog closes, so what /// is saved is what the user was looking at when they asked. - pub(super) fn on_save_active_query(&self, sender: ComponentSender) { + pub(super) fn on_save_active_query(&self) { let Some(query) = self.active_editor_query() else { self.show_toast(&crate::i18n::gettext("Open a SQL editor tab to save a query.")); return; @@ -102,12 +81,6 @@ impl App { self.show_toast(&crate::i18n::gettext("There is nothing in the editor to save.")); return; } - if !self.context.active.is_connected() { - self.show_toast(&crate::i18n::gettext( - "Connect to a database first: a saved query is kept under its connection.", - )); - return; - } let dialog = adw::AlertDialog::new(Some(&crate::i18n::gettext("Save Query")), None); let entry = adw::EntryRow::builder().title(crate::i18n::gettext("Name")).build(); @@ -121,7 +94,7 @@ impl App { dialog.set_default_response(Some("save")); dialog.set_close_response("cancel"); - let sender = sender.input_sender().clone(); + let input = self.input.clone(); dialog.connect_response(None, move |dialog, response| { dialog.close(); if response != "save" { @@ -131,15 +104,15 @@ impl App { if name.trim().is_empty() { return; } - let _ = sender.send(AppMsg::SaveQueryNamed { + input.deliver(WorkspaceInput::SaveQueryNamed { name, query: query.clone(), }); }); - dialog.present(Some(&self.window)); + dialog.present(self.dialog_parent().as_ref()); } - pub(super) fn on_save_query_named(&self, name: String, query: String, sender: ComponentSender) { + pub(super) fn on_save_query_named(&self, name: String, query: String) { let Some(store) = self.history.store().map(|history| history.saved_queries()) else { self.show_toast(&crate::i18n::gettext("The query database is not open yet.")); return; @@ -156,24 +129,20 @@ impl App { connection_id: metadata.id, connection_name: metadata.name, }; - let sender_for_result = sender.clone(); - sender.command(move |_, shutdown| { - shutdown - .register(async move { - let message = match store.save(new_query).await { - Ok(outcome) if outcome.replaced => { - crate::i18n::gettext_f("Replaced “{name}”.", &[("name", &name)]) - } - Ok(_) => crate::i18n::gettext_f("Saved “{name}”.", &[("name", &name)]), - Err(error) => { - tracing::warn!(%error, "could not save the query"); - crate::i18n::gettext("Could not save that query.") - } - }; - sender_for_result.deliver(AppMsg::ShowToast(message)); - }) - .drop_on_shutdown() - }); + // The save lands even when the workspace is gone by then; only + // its toast is dropped. + let input = self.input.clone(); + drop(self.tasks.spawn_task(async move { + let message = match store.save(new_query).await { + Ok(outcome) if outcome.replaced => crate::i18n::gettext_f("Replaced “{name}”.", &[("name", &name)]), + Ok(_) => crate::i18n::gettext_f("Saved “{name}”.", &[("name", &name)]), + Err(error) => { + tracing::warn!(%error, "could not save the query"); + crate::i18n::gettext("Could not save that query.") + } + }; + input.deliver(WorkspaceInput::ShowToast(message)); + })); } } diff --git a/linux/crates/app/src/ui/app/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs similarity index 88% rename from linux/crates/app/src/ui/app/structure.rs rename to linux/crates/app/src/ui/workspace/structure.rs index 341295fe6b..b014ae44cb 100644 --- a/linux/crates/app/src/ui/app/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -1,11 +1,11 @@ -//! App-side handlers for the Structure (DDL) workspace tab: opening +//! Workspace handlers for the Structure (DDL) tab: opening //! one, dropping a table, and following a save that landed. The save //! itself runs through the workspace handle, like a row save. use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::{ComponentSender, adw}; +use libadwaita as adw; +use libadwaita::prelude::*; use uuid::Uuid; use tablepro_core::ddl::{BuildDdlError, DdlOperation, DropTableOptions, TableDefinition}; @@ -16,19 +16,14 @@ use tablepro_session::{OperationError, OperationKind, Session}; use tokio_util::sync::CancellationToken; use crate::runtime::DeliverInput; -use crate::services::from_session::FromSession; -use crate::ui::app::{App, AppMsg, WorkspaceTab}; use crate::ui::error_text; use crate::ui::structure_tab::{StructureMode, StructureTabInput}; +use crate::ui::workspace::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; use crate::workspace::run_schema_plan; -impl App { +impl WorkspaceModel { /// Sidebar right-click → "New Table…" or schema-header "+" button. - pub(super) fn on_new_table_tab(&mut self, schema: Option, sender: ComponentSender) { - if !self.connected { - self.show_toast(&crate::i18n::gettext("Connect to a database first.")); - return; - } + pub(super) fn on_new_table_tab(&mut self, schema: Option) { let creates_tables = self .context .active @@ -42,21 +37,12 @@ impl App { )); return; } - self.append_new_structure_tab(schema, sender); + self.append_new_structure_tab(schema); } /// Select the table's Structure tab, or open one. Its Table tab /// stays open beside it. - pub(super) fn on_edit_structure_tab( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { - if !self.connected { - self.show_toast(&crate::i18n::gettext("Connect to a database first.")); - return; - } + pub(super) fn on_edit_structure_tab(&mut self, schema: Option, table: String) { let existing_structure = self.workspace_tabs.borrow().iter().find_map(|(_, tab)| match tab { WorkspaceTab::Structure(slot) if slot.mode == StructureMode::Edit && slot.schema == schema && slot.table == table => @@ -71,16 +57,11 @@ impl App { tab_view.set_selected_page(&page); return; } - self.append_existing_structure_tab(schema, table, sender); + self.append_existing_structure_tab(schema, table); } /// Right-click → "Drop Table…", or in-tab destructive button. - pub(super) fn on_drop_table_prompt( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { + pub(super) fn on_drop_table_prompt(&mut self, schema: Option, table: String) { let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &table)]); let body = crate::i18n::gettext( "All rows and the table definition will be removed. This can't be undone from inside TablePro.", @@ -91,19 +72,19 @@ impl App { dialog.set_response_appearance("drop", adw::ResponseAppearance::Destructive); dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); - let sender_for_resp = sender.clone(); + let input = self.input.clone(); let schema_for_resp = schema.clone(); let table_for_resp = table.clone(); dialog.connect_response(None, move |dlg, response| { dlg.close(); if response == "drop" { - sender_for_resp.deliver(AppMsg::DropTableConfirmed { + input.deliver(WorkspaceInput::DropTableConfirmed { schema: schema_for_resp.clone(), table: table_for_resp.clone(), }); } }); - dialog.present(Some(&self.window)); + dialog.present(self.dialog_parent().as_ref()); } /// User confirmed the drop. Run DROP TABLE async; only close any @@ -111,12 +92,7 @@ impl App { /// returns Ok. Tabs stay open if DROP fails (FK violation, /// privileges) so the user doesn't lose their state on a failed /// destructive action. - pub(super) fn on_drop_table_confirmed( - &mut self, - schema: Option, - table: String, - sender: ComponentSender, - ) { + pub(super) fn on_drop_table_confirmed(&mut self, schema: Option, table: String) { let Some(session) = self.context.active.get() else { self.show_error_alert( &crate::i18n::gettext("Cannot drop table"), @@ -129,7 +105,6 @@ impl App { if_exists: true, cascade: false, }; - let origin = Arc::downgrade(&session); let table = dropped.clone(); let work = async move { run_schema_plan( @@ -141,10 +116,13 @@ impl App { ) .await }; + // The answer goes through the workspace's input, so one that + // lands after a disconnect is dropped with the workspace. + let input = self.input.clone(); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { match settled(result).and_then(|(_, written)| written) { - Ok(_) => sender.deliver(AppMsg::DropTableSucceeded(FromSession::new(origin, dropped))), - Err(error) => sender.deliver(AppMsg::ShowAlert { + Ok(_) => input.deliver(WorkspaceInput::DropTableSucceeded(dropped)), + Err(error) => input.deliver(WorkspaceInput::ShowAlert { title: crate::i18n::gettext("Drop failed"), body: error_text::operation_error_message(&error), }), @@ -152,9 +130,9 @@ impl App { }); } - pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef, sender: ComponentSender) { + pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef) { self.close_tabs_for_table(table.schema.as_deref(), &table.name); - sender.deliver(AppMsg::SchemaChanged { + self.input.deliver(WorkspaceInput::SchemaChanged { schema: table.schema, table: Some(table.name), }); @@ -188,12 +166,7 @@ impl App { /// 2. **Edit-mode tab**: the slot stays put, taking the new name /// when the save renamed the table; `SaveCompleted` makes the /// tab clear its pending ops and read the table again. - pub(super) fn on_structure_save_completed( - &mut self, - tab_id: Uuid, - new_table_name: Option, - sender: ComponentSender, - ) { + pub(super) fn on_structure_save_completed(&mut self, tab_id: Uuid, new_table_name: Option) { // Decided first, so the tabs are not borrowed while they change. enum SaveKind { PromoteNewToTable(Option, String), @@ -225,23 +198,16 @@ impl App { self.finish_close_workspace_tab(tab_id, &tab_view); } // A table just designed is filled in next. - super::App::append_table_tab( - self, - schema.clone(), - name.clone(), - 0, - u64::from(self.settings.default_page_size()), - None, - sender.clone(), - ); - sender.deliver(AppMsg::SchemaChanged { + let page_size = u64::from(self.settings.default_page_size()); + self.append_table_tab(schema.clone(), name.clone(), 0, page_size, None); + self.input.deliver(WorkspaceInput::SchemaChanged { schema, table: Some(name), }); } SaveKind::UpdateInPlace(schema, table) => { self.send_to_structure_tab(tab_id, StructureTabInput::SaveCompleted { new_table_name }); - sender.deliver(AppMsg::SchemaChanged { + self.input.deliver(WorkspaceInput::SchemaChanged { schema, table: Some(table), }); @@ -254,18 +220,18 @@ impl App { /// table as it is defined now, from the dialect's /// `create_table_script`. The result lands in a fresh editor tab; no /// Structure tab is touched. - pub(super) fn on_show_create_table(&self, schema: Option, table: String, sender: ComponentSender) { + pub(super) fn on_show_create_table(&self, schema: Option, table: String) { let Some(session) = self.context.active.get() else { self.show_toast(&crate::i18n::gettext("No active connection.")); return; }; - let origin = Arc::downgrade(&session); let work = read_definition(session, TableRef::new(schema, table.clone())); + let input = self.input.clone(); crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { let message = match settled(result) { Ok((definition, dialect)) => match create_table_sql(&definition, &*dialect) { Ok(Some(sql)) => { - sender.deliver(AppMsg::ShowCreateTableLoaded(FromSession::new(origin, sql))); + input.deliver(WorkspaceInput::ShowCreateTableLoaded(sql)); return; } Ok(None) => crate::i18n::gettext_f("Table {table} has no columns.", &[("table", &table)]), @@ -279,7 +245,7 @@ impl App { &[("error", &error_text::operation_error_message(&error))], ), }; - sender.deliver(AppMsg::ShowToast(message)); + input.deliver(WorkspaceInput::ShowToast(message)); }); } diff --git a/linux/crates/app/src/ui/app/window_tabs.rs b/linux/crates/app/src/ui/workspace/window_tabs.rs similarity index 96% rename from linux/crates/app/src/ui/app/window_tabs.rs rename to linux/crates/app/src/ui/workspace/window_tabs.rs index 063bf72fa1..a143573543 100644 --- a/linux/crates/app/src/ui/app/window_tabs.rs +++ b/linux/crates/app/src/ui/workspace/window_tabs.rs @@ -1,8 +1,8 @@ use std::cell::RefCell; -use relm4::adw; -use relm4::adw::prelude::*; -use relm4::gtk::glib; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; use super::workspace_persistence::WorkspaceTabs; use super::{WorkspaceTab, read_workspace_tab_id}; diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs new file mode 100644 index 0000000000..dc4e5b9cd5 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -0,0 +1,128 @@ +//! The window actions a workspace adds while it is open, and takes off +//! the window again when it goes. + +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::{WorkspaceInput, WorkspaceSender}; +use crate::runtime::DeliverInput; +use crate::services::history_availability::HistoryAvailability; + +/// Builds the input an action sends. +type ActionInput = fn() -> WorkspaceInput; + +/// The two actions that need the query database, which opens on a +/// background task. +const HISTORY_ACTIONS: [&str; 2] = ["show-history", "show-saved-queries"]; + +/// Add the workspace's actions to the window's own action map, which GTK +/// exports as `win`, and return their names for `shutdown` to remove. +/// Each one sends its input through `input`, so one activated after the +/// workspace is gone is dropped rather than handled. +pub(super) fn install(window: &adw::ApplicationWindow, input: &WorkspaceSender) -> Vec<&'static str> { + let entries: [(&'static str, ActionInput); 11] = [ + ("open-editor", || WorkspaceInput::NewEditorTab), + ("close-current", || WorkspaceInput::CloseActiveWorkspaceTab), + ("refresh-page", || WorkspaceInput::RefreshPage), + ("save-changes", || WorkspaceInput::SaveActiveTab), + ("undo-change", || WorkspaceInput::UndoActiveBrowseTab), + ("redo-change", || WorkspaceInput::RedoActiveBrowseTab), + ("reopen-closed-tab", || WorkspaceInput::ReopenClosedTab), + ("open-filter", || WorkspaceInput::ShowFilterDialog), + ("save-query", || WorkspaceInput::SaveActiveQuery), + ("show-history", || WorkspaceInput::ShowHistory), + ("show-saved-queries", || WorkspaceInput::ShowSavedQueries), + ]; + let actions = entries.map(|(name, message)| { + let input = input.clone(); + gio::ActionEntry::::builder(name) + .activate(move |_, _, _| input.deliver(message())) + .build() + }); + window.add_action_entries(actions); + // Insensitive until the query database reports it is open. + for name in HISTORY_ACTIONS { + set_enabled(window, name, false); + } + entries.iter().map(|(name, _)| *name).collect() +} + +fn set_enabled(window: &adw::ApplicationWindow, name: &str, enabled: bool) { + if let Some(action) = window.lookup_action(name).and_downcast::() { + action.set_enabled(enabled); + } +} + +/// Flip the actions that need the query database whenever +/// `HistoryService` changes state. The watch is read on the GTK thread, +/// so the actions are only touched from the thread that owns them. +pub(super) fn spawn_history_gate( + window: &adw::ApplicationWindow, + mut availability: tokio::sync::watch::Receiver, +) -> glib::JoinHandle<()> { + let window = window.downgrade(); + glib::spawn_future_local(async move { + loop { + let state = availability.borrow_and_update().clone(); + let Some(window) = window.upgrade() else { + return; + }; + for name in HISTORY_ACTIONS { + set_enabled(&window, name, state.is_ready()); + } + drop(window); + if let Some(failure) = state.failure() { + tracing::warn!(error = failure, "query history stays unavailable"); + } + if availability.changed().await.is_err() { + return; + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn the_history_gate_follows_availability() { + let window = adw::ApplicationWindow::builder().build(); + let (input, _receiver) = crate::runtime::input_queue::(); + install(&window, &input); + let (sender, receiver) = tokio::sync::watch::channel(HistoryAvailability::Starting); + let gate = spawn_history_gate(&window, receiver); + let enabled = || { + window + .lookup_action("show-history") + .is_some_and(|action| action.is_enabled()) + }; + + crate::test_support::drain_main_context(); + assert!(!enabled(), "enabled while the database was still opening"); + + sender.send_replace(HistoryAvailability::Ready); + crate::test_support::drain_main_context(); + assert!(enabled(), "stayed disabled after the database opened"); + + sender.send_replace(HistoryAvailability::Failed("no disk".to_owned())); + crate::test_support::drain_main_context(); + assert!(!enabled(), "stayed enabled after the database failed"); + + gate.abort(); + } + + #[gtk4::test] + fn workspace_actions_live_on_the_windows_own_map() { + let window = adw::ApplicationWindow::builder().build(); + let (input, mut receiver) = crate::runtime::input_queue::(); + + let names = install(&window, &input); + + assert!(names.contains(&"refresh-page")); + WidgetExt::activate_action(&window, "win.open-editor", None).expect("win resolves to the window's own map"); + crate::test_support::drain_main_context(); + assert!(matches!(receiver.try_recv(), Ok(WorkspaceInput::NewEditorTab))); + } +} diff --git a/linux/crates/app/src/ui/app/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs similarity index 98% rename from linux/crates/app/src/ui/app/workspace_persistence.rs rename to linux/crates/app/src/ui/workspace/workspace_persistence.rs index daf39339db..692d65bf47 100644 --- a/linux/crates/app/src/ui/app/workspace_persistence.rs +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -3,9 +3,9 @@ use std::collections::HashMap; use std::rc::Rc; use std::time::Duration; -use relm4::adw; -use relm4::adw::prelude::*; -use relm4::gtk::glib; +use gtk4::glib; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_session::SessionKey; use uuid::Uuid; @@ -178,13 +178,13 @@ pub(super) fn reopen( mod tests { use std::time::Duration; - use relm4::gtk; + use gtk4 as gtk; use tablepro_storage::DraftId; use super::*; use crate::test_support::{finish_on_main, wait_until}; - use crate::ui::app::{EditorTabSlot, write_workspace_tab_id}; use crate::ui::editor::{SqlEditor, SqlEditorInit}; + use crate::ui::workspace::{EditorTabSlot, write_workspace_tab_id}; use crate::workspace::WorkspaceLoad; const WAIT: Duration = Duration::from_secs(5); @@ -300,7 +300,7 @@ mod tests { write_workspace_tab_id(&page, id); fixture.tabs.borrow_mut().insert( id, - WorkspaceTab::Structure(crate::ui::app::StructureTabSlot { + WorkspaceTab::Structure(crate::ui::workspace::StructureTabSlot { id, structure, page, diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs similarity index 85% rename from linux/crates/app/src/ui/app/workspace_tabs.rs rename to linux/crates/app/src/ui/workspace/workspace_tabs.rs index 56517eb6cc..132aee3dcf 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -1,17 +1,16 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::rc::Rc; -use std::sync::Arc; -use relm4::adw::prelude::*; -use relm4::gtk::{gio, glib}; -use relm4::{ComponentSender, adw, gtk}; +use gtk4 as gtk; +use gtk4::{gio, glib}; +use libadwaita as adw; +use libadwaita::prelude::*; use tablepro_session::SessionKey; use tablepro_storage::{DraftId, DraftScope}; use uuid::Uuid; -use crate::services::from_session::FromSession; use crate::ui::browse_tab::{BrowseTab, BrowseTabInit, BrowseTabInput, ExportRequest}; use crate::ui::editor::{SqlEditor, SqlEditorInit, derive_tab_label}; use crate::ui::structure_tab::{StructureMode, StructureTab, StructureTabInit}; @@ -21,20 +20,20 @@ use crate::workspace::{ use super::workspace_persistence::reopen; use super::{ - App, AppMsg, CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceTab, - read_workspace_tab_id, write_workspace_tab_id, + CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceInput, WorkspaceModel, + WorkspaceSender, WorkspaceTab, read_workspace_tab_id, write_workspace_tab_id, }; use crate::runtime::DeliverInput; -impl App { +impl WorkspaceModel { /// Builds the unified AdwTabOverview tree once per connection. /// Idempotent via `workspace_root_added`. - pub(super) fn ensure_workspace_root(&mut self, sender: ComponentSender) { + pub(super) fn ensure_workspace_root(&mut self) { if self.workspace_root_added.get() { return; } if self.workspace_root.is_none() { - self.build_workspace_root(sender); + self.build_workspace_root(); } if let Some(root) = self.workspace_root.as_ref() && self.workspace_outer_stack.child_by_name("tabs").is_none() @@ -44,7 +43,7 @@ impl App { self.workspace_root_added.set(true); } - fn build_workspace_root(&mut self, sender: ComponentSender) { + fn build_workspace_root(&mut self) { let settings = self.settings.clone(); let history = self.history.store(); let tasks = self.tasks.clone(); @@ -63,10 +62,10 @@ impl App { let create_tab = overview_editor_tab( self.workspace_tabs.clone(), editor_init, - sender.input_sender().clone(), + self.input.clone(), self.toast_overlay.downgrade(), ); - let chrome = build_workspace_chrome(sender.input_sender(), create_tab); + let chrome = build_workspace_chrome(&self.input, create_tab); self.window_tabs.set_view(Some(&chrome.view)); self.workspace_root = Some(chrome.overview); self.workspace_tab_view = Some(chrome.view); @@ -74,11 +73,8 @@ impl App { /// Read the saved tabs of the session just adopted, and the editor /// text that goes with them, off the GTK thread. - pub(super) fn restore_workspace_tabs(&mut self, key: SessionKey, sender: ComponentSender) { - let Some(session) = self.context.active.get() else { - return; - }; - let origin = Arc::downgrade(&session); + pub(super) fn restore_workspace_tabs(&mut self, key: SessionKey) { + let input = self.input.clone(); let loading = self.storage.workspaces().load(&key); let store = self.drafts.store().clone(); let scope = WorkspaceFileName::for_key(&key).draft_scope(); @@ -91,10 +87,7 @@ impl App { }; let reading = tasks.spawn_blocking_task(move || RestoredDrafts::read_blocking(&store, &scope, referenced)); let drafts = reading.await.unwrap_or_default(); - sender.deliver(AppMsg::WorkspaceLoaded(FromSession::new( - origin, - LoadedWorkspace { key, load, drafts }, - ))); + input.deliver(WorkspaceInput::WorkspaceLoaded(LoadedWorkspace { key, load, drafts })); }); } @@ -102,14 +95,14 @@ impl App { /// /// Nothing is written for the session before this: a write would /// replace the saved tabs before they were read. - pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace, sender: ComponentSender) { + pub(super) fn on_workspace_loaded(&mut self, loaded: LoadedWorkspace) { let LoadedWorkspace { key, load, drafts } = loaded; let RestoredDrafts { mut texts, unreadable } = drafts; match load { WorkspaceLoad::Restored(record) => { self.retain_drafts(&key, saved_drafts(&record)); if let Some(view) = self.workspace_tab_view.clone() { - reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts, sender.clone())); + reopen(&view, record, |tab| self.reopen_tab(tab, &mut texts)); } } WorkspaceLoad::Empty => self.retain_drafts(&key, std::iter::empty()), @@ -149,12 +142,7 @@ impl App { self.drafts.retain(&scope, keep); } - fn reopen_tab( - &mut self, - tab: TabRecord, - drafts: &mut HashMap, - sender: ComponentSender, - ) -> Option { + fn reopen_tab(&mut self, tab: TabRecord, drafts: &mut HashMap) -> Option { match tab { TabRecord::Table { schema, @@ -168,16 +156,14 @@ impl App { offset, page_size, sort.map(|sort| (sort.column, sort.ascending)), - sender, ), - TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table, sender), + TabRecord::Structure { schema, table } => self.append_existing_structure_tab(schema, table), // Its text is on disk already, or there is none to keep, so // nothing is written until the user types. TabRecord::Editor { draft } => self.append_editor_tab_with_draft( Some(drafts.remove(&draft).unwrap_or_default()), draft, DraftWrite::OnChange, - sender, ), } } @@ -193,15 +179,12 @@ impl App { /// Move the unreadable saved tabs aside, so the open ones can be /// saved in their place. - pub(super) fn on_reset_workspace_layout(&self, sender: ComponentSender) { - let (Some(key), Some(session)) = (self.session_key().cloned(), self.context.active.get()) else { - return; - }; - let origin = Arc::downgrade(&session); - let resetting = self.storage.workspaces().reset(&key); + pub(super) fn on_reset_workspace_layout(&self) { + let resetting = self.storage.workspaces().reset(&self.key); + let input = self.input.clone(); glib::spawn_future_local(async move { let outcome = resetting.await; - sender.deliver(AppMsg::WorkspaceLayoutReset(FromSession::new(origin, outcome))); + input.deliver(WorkspaceInput::WorkspaceLayoutReset(outcome)); }); } @@ -224,8 +207,8 @@ impl App { /// flow. Edit-mode is no longer reachable through this helper; /// use `append_table_tab` for the unified Browse + Structure /// view of an existing table. - pub(super) fn append_new_structure_tab(&mut self, schema: Option, sender: ComponentSender) { - self.ensure_workspace_root(sender.clone()); + pub(super) fn append_new_structure_tab(&mut self, schema: Option) { + self.ensure_workspace_root(); let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; @@ -241,7 +224,7 @@ impl App { session, tasks: self.context.tasks.clone(), }); - connect_structure(&structure, tab_id, sender.input_sender()); + connect_structure(&structure, tab_id, &self.input); let page = tab_view.append(&structure); page.set_title(&crate::i18n::gettext("New Table")); @@ -280,9 +263,8 @@ impl App { offset: u64, page_size: u64, sort: Option<(usize, bool)>, - sender: ComponentSender, ) -> Option { - self.ensure_workspace_root(sender.clone()); + self.ensure_workspace_root(); let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); let connection_id = self.context.active.metadata().map(|metadata| metadata.id); @@ -302,7 +284,7 @@ impl App { session, tasks: self.context.tasks.clone(), }); - connect_browse(&browse, tab_id, sender.input_sender()); + connect_browse(&browse, tab_id, &self.input); let page = tab_view.append(&browse); let label = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); @@ -338,9 +320,8 @@ impl App { &mut self, schema: Option, table: String, - sender: ComponentSender, ) -> Option { - self.ensure_workspace_root(sender.clone()); + self.ensure_workspace_root(); let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); let session = self.context.active.get()?; @@ -353,7 +334,7 @@ impl App { session, tasks: self.context.tasks.clone(), }); - connect_structure(&structure, tab_id, sender.input_sender()); + connect_structure(&structure, tab_id, &self.input); let page = tab_view.append(&structure); let title = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); @@ -381,8 +362,8 @@ impl App { } /// Public entry: append an Editor tab with optional initial query. - pub(super) fn append_editor_tab(&mut self, initial_query: Option, sender: ComponentSender) { - self.append_editor_tab_with_draft(initial_query, DraftId::new(), DraftWrite::Now, sender); + pub(super) fn append_editor_tab(&mut self, initial_query: Option) { + self.append_editor_tab_with_draft(initial_query, DraftId::new(), DraftWrite::Now); } /// Append an Editor tab that writes to a given draft, so a restored @@ -392,9 +373,8 @@ impl App { initial_query: Option, draft: DraftId, write: DraftWrite, - sender: ComponentSender, ) -> Option { - self.ensure_workspace_root(sender.clone()); + self.ensure_workspace_root(); let tab_view = self.workspace_tab_view.clone()?; let tab_id = Uuid::new_v4(); // Such a tab shows the editor's sample query, which names nothing. @@ -408,7 +388,7 @@ impl App { hold: self.context.hold.clone(), close_timeout: self.context.close_timeout(), }); - connect_editor(&editor, tab_id, sender.input_sender(), &self.toast_overlay.downgrade()); + connect_editor(&editor, tab_id, &self.input, &self.toast_overlay.downgrade()); let page = tab_view.append(&editor); let preview = EditorPreview::read(&editor.buffer()); let label = if opened_empty || preview.is_blank() { @@ -444,9 +424,7 @@ impl App { /// The directory this session's editor drafts live in. fn draft_scope(&self) -> DraftScope { - self.session_key() - .map(|key| WorkspaceFileName::for_key(key).draft_scope()) - .unwrap_or_default() + WorkspaceFileName::for_key(&self.key).draft_scope() } /// Tear down a tab without prompting. Internal helper called once @@ -492,18 +470,24 @@ impl App { self.refresh_inhibitor(); } - pub(super) fn close_active_workspace_tab(&mut self, sender: ComponentSender) { - let Some(tab_view) = self.workspace_tab_view.as_ref() else { - // No tabs at all (disconnected) → close window. - self.window.close(); - return; - }; - let Some(page) = tab_view.selected_page() else { - self.window.close(); - return; - }; - tab_view.close_page(&page); - let _ = sender; + /// With no tab open, Ctrl+W closes the window, as in GNOME Text + /// Editor. + pub(super) fn close_active_workspace_tab(&mut self) { + match self.workspace_tab_view.as_ref().and_then(adw::TabView::selected_page) { + Some(page) => { + if let Some(tab_view) = self.workspace_tab_view.as_ref() { + tab_view.close_page(&page); + } + } + // The window's close asks this workspace what it would + // lose, which it cannot answer in the middle of handling + // this, so the close waits for the main loop. + None => { + if let Some(window) = self.window() { + glib::idle_add_local_once(move || window.close()); + } + } + } } /// Close every workspace tab except `keep_id`. Each close goes @@ -511,7 +495,7 @@ impl App { /// trigger the unsaved-changes alert; the user can cancel the /// dialog and the tab stays open while the others continue /// closing in the background. - pub(super) fn close_other_workspace_tabs(&mut self, keep_id: Uuid, _sender: ComponentSender) { + pub(super) fn close_other_workspace_tabs(&mut self, keep_id: Uuid) { let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; @@ -534,7 +518,7 @@ impl App { /// Close every workspace tab whose display position is greater /// than the targeted one. Useful for rapidly trimming a long /// session of throwaway tabs the user accumulated. - pub(super) fn close_workspace_tabs_to_right(&mut self, anchor_id: Uuid, _sender: ComponentSender) { + pub(super) fn close_workspace_tabs_to_right(&mut self, anchor_id: Uuid) { let Some(tab_view) = self.workspace_tab_view.clone() else { return; }; @@ -566,13 +550,7 @@ impl App { /// A sidebar click never replaces the active tab: AdwTabView's /// close animation overlapped the append and flashed both tabs. - pub(super) fn dispatch_select_table( - &mut self, - schema: Option, - name: String, - open_mode: OpenMode, - sender: ComponentSender, - ) { + pub(super) fn dispatch_select_table(&mut self, schema: Option, name: String, open_mode: OpenMode) { if matches!(open_mode, OpenMode::SwitchOrAppend) { // Multiple tabs may be open for the same (schema, table) // pair (Ctrl+click duplicates). Prefer the currently- @@ -603,7 +581,7 @@ impl App { // for the same table opens as its own separate tab via the // sidebar right-click "Edit Structure" action. let page_size = u64::from(self.settings.default_page_size()); - self.append_table_tab(schema, name, 0, page_size, None, sender); + self.append_table_tab(schema, name, 0, page_size, None); } /// Ctrl+F / Filter button: toggle the inline filter strip on the @@ -656,7 +634,7 @@ impl App { browse.send(BrowseTabInput::DiscardAndRefresh); } }); - dialog.present(Some(&self.window)); + dialog.present(self.window().as_ref()); } /// Write the tab list a moment after the last change. @@ -665,7 +643,7 @@ impl App { } } -impl App { +impl WorkspaceModel { /// Single handler for `WorkspaceTabsChanged`. Persists tab state, /// refreshes the window title (so tab switches update the subtitle), /// and syncs the sidebar selection to the active Browse tab's table. @@ -758,13 +736,13 @@ impl App { self.refresh_window_title(); } - pub(super) fn teardown_workspace_tabs(&mut self) { - self.flush_workspace_state(); - self.cancel_all_editor_runs(); - // The pending edits name rows of the connection going away, so - // they cannot be saved after it. + /// Stop every tab and let go of the tab view. The pending edits + /// name rows of the connection going away, so they cannot be saved + /// after it. + pub(super) fn close_tabs(&mut self) { for tab in self.workspace_tabs.borrow().values() { match tab { + WorkspaceTab::Editor(slot) => slot.editor.shutdown(), WorkspaceTab::Structure(slot) => { slot.structure.shutdown(); self.structure_trackers.unregister(slot.id); @@ -773,54 +751,17 @@ impl App { slot.browse.shutdown(); self.change_trackers.unregister(slot.id); } - WorkspaceTab::Editor(_) => {} } } if let Some(root) = self.workspace_root.take() - && self.workspace_root_added.get() + && self.workspace_root_added.replace(false) + && self.workspace_outer_stack.child_by_name("tabs").is_some() { - if self.workspace_outer_stack.child_by_name("tabs").is_some() { - self.workspace_outer_stack.remove(&root); - } - self.workspace_root_added.set(false); + self.workspace_outer_stack.remove(&root); } - self.workspace_outer_stack.set_visible_child_name("empty"); self.window_tabs.set_view(None); self.workspace_tab_view = None; self.workspace_tabs.borrow_mut().clear(); - self.refresh_inhibitor(); - } - - /// Write the tabs and the editor text as they are now, before the - /// teardown frees them. The process is held until the writes land, - /// so quitting right after a disconnect does not lose them. - fn flush_workspace_state(&self) { - let tabs = self.workspace.detach(); - let drafts = self.drafts.flush_all(); - let writer = self.drafts.clone(); - let hold = self.context.hold.acquire(); - glib::spawn_future_local(async move { - for failure in drafts.await { - tracing::warn!(failure, "an editor draft was not saved"); - } - writer.release_closed(); - if let Some(tabs) = tabs - && let Err(error) = tabs.await - { - tracing::debug!(%error, "the open tabs were not saved"); - } - drop(hold); - }); - } - - /// Stop every editor run and close each editor tab's connection, - /// for a disconnect that takes the tabs with it. - pub(super) fn cancel_all_editor_runs(&self) { - for tab in self.workspace_tabs.borrow().values() { - if let WorkspaceTab::Editor(s) = tab { - s.editor.shutdown(); - } - } } /// Forward a per-tab Browse input to the right slot. Routes to the @@ -867,14 +808,14 @@ impl App { } } - pub(super) fn on_replace_active_tab_query(&mut self, text: String, sender: ComponentSender) { + pub(super) fn on_replace_active_tab_query(&mut self, text: String) { if let Some(id) = self.selected_workspace_tab_id() && let Some(WorkspaceTab::Editor(slot)) = self.workspace_tabs.borrow().get(&id) { slot.editor.replace_query(&text); return; } - self.append_editor_tab(Some(text), sender); + self.append_editor_tab(Some(text)); } /// The SQL in the editor tab the user is looking at, or `None` @@ -906,7 +847,7 @@ struct WorkspaceChrome { /// Build the tab bar, view and overview. `create_tab` makes the page the /// overview's own new-tab button asks for, which it needs back at once. fn build_workspace_chrome( - input: &relm4::Sender, + input: &WorkspaceSender, create_tab: impl Fn(&adw::TabView) -> adw::TabPage + 'static, ) -> WorkspaceChrome { let tab_view = adw::TabView::new(); @@ -941,14 +882,14 @@ fn build_workspace_chrome( .build(); new_query_button.add_css_class("flat"); let new_tab_input = input.clone(); - new_query_button.connect_clicked(move |_| new_tab_input.emit(AppMsg::NewEditorTab)); + new_query_button.connect_clicked(move |_| new_tab_input.deliver(WorkspaceInput::NewEditorTab)); tab_bar.set_end_action_widget(Some(&new_query_button)); - // A tab with unsaved work asks first, so the App finishes the close. + // A tab with unsaved work asks first, so the workspace finishes the close. let close_input = input.clone(); tab_view.connect_close_page(move |_view, page| { if let Some(id) = read_workspace_tab_id(page) { - close_input.emit(AppMsg::WorkspaceTabClosed(id)); + close_input.deliver(WorkspaceInput::WorkspaceTabClosed(id)); } glib::Propagation::Stop }); @@ -969,7 +910,7 @@ fn build_workspace_chrome( if let Some(page) = target && let Some(id) = read_workspace_tab_id(&page) { - close_others_input.emit(AppMsg::CloseOtherWorkspaceTabs(id)); + close_others_input.deliver(WorkspaceInput::CloseOtherWorkspaceTabs(id)); } }); action_group.add_action(&close_others_action); @@ -981,7 +922,7 @@ fn build_workspace_chrome( if let Some(page) = target && let Some(id) = read_workspace_tab_id(&page) { - close_right_input.emit(AppMsg::CloseWorkspaceTabsToRight(id)); + close_right_input.deliver(WorkspaceInput::CloseWorkspaceTabsToRight(id)); } }); action_group.add_action(&close_right_action); @@ -1000,9 +941,9 @@ fn build_workspace_chrome( // A drag reorder changes only `pages`, and it has to persist too. let selected_input = input.clone(); - tab_view.connect_selected_page_notify(move |_| selected_input.emit(AppMsg::WorkspaceTabsChanged)); + tab_view.connect_selected_page_notify(move |_| selected_input.deliver(WorkspaceInput::WorkspaceTabsChanged)); let pages_input = input.clone(); - tab_view.connect_pages_notify(move |_| pages_input.emit(AppMsg::WorkspaceTabsChanged)); + tab_view.connect_pages_notify(move |_| pages_input.deliver(WorkspaceInput::WorkspaceTabsChanged)); let inner = gtk::Box::builder().orientation(gtk::Orientation::Vertical).build(); inner.append(&tab_bar); @@ -1031,7 +972,7 @@ fn build_workspace_chrome( fn overview_editor_tab( workspace_tabs: Rc>>, editor_init: impl Fn() -> SqlEditorInit + 'static, - input: relm4::Sender, + input: WorkspaceSender, toasts: glib::WeakRef, ) -> impl Fn(&adw::TabView) -> adw::TabPage + 'static { move |tab_view| { @@ -1052,7 +993,7 @@ fn overview_editor_tab( draft: DraftId::new(), }; workspace_tabs.borrow_mut().insert(tab_id, WorkspaceTab::Editor(slot)); - input.emit(AppMsg::WorkspaceTabsChanged); + input.deliver(WorkspaceInput::WorkspaceTabsChanged); page } } @@ -1115,11 +1056,8 @@ fn push_closed_tab( q.push_back(descriptor); } -impl App { - pub(super) fn on_reopen_closed_tab(&mut self, sender: ComponentSender) { - if !self.connected { - return; - } +impl WorkspaceModel { + pub(super) fn on_reopen_closed_tab(&mut self) { let descriptor = self.closed_tabs_stack.borrow_mut().pop_back(); let Some(descriptor) = descriptor else { // Without a toast the shortcut would read as broken. @@ -1129,7 +1067,7 @@ impl App { match descriptor { ClosedTabDescriptor::Editor { query } => { let initial = if query.is_empty() { None } else { Some(query) }; - self.append_editor_tab(initial, sender); + self.append_editor_tab(initial); } ClosedTabDescriptor::Table { schema, @@ -1138,17 +1076,13 @@ impl App { page_size, sort, } => { - self.append_table_tab(schema, table, offset, page_size, sort, sender); + self.append_table_tab(schema, table, offset, page_size, sort); } ClosedTabDescriptor::Structure { schema, table } => { - self.append_existing_structure_tab(schema, table, sender); + self.append_existing_structure_tab(schema, table); } } } - - pub(super) fn clear_closed_tabs_stack(&self) { - self.closed_tabs_stack.borrow_mut().clear(); - } } const PREVIEW_CHARS: i32 = 200; @@ -1213,69 +1147,74 @@ fn saved_drafts(record: &WorkspaceRecord) -> impl Iterator + '_ }) } -/// The structure tab's signals, as this window's inputs for the tab. -fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &relm4::Sender) { +/// The structure tab's signals, as the workspace's inputs for the tab. +fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &WorkspaceSender) { let dirty = input.clone(); - structure.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::StructureTabDirtyChanged(tab_id, is_dirty))); + structure.connect_dirty_changed(move |_, is_dirty| { + dirty.deliver(WorkspaceInput::StructureTabDirtyChanged(tab_id, is_dirty)) + }); let save = input.clone(); - structure.connect_save_requested(move |_| save.emit(AppMsg::SaveTab(tab_id))); + structure.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); let stop = input.clone(); - structure.connect_stop_save_requested(move |_| stop.emit(AppMsg::StopSaving(tab_id))); + structure.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); let drop_table = input.clone(); structure.connect_drop_table_requested(move |_, schema, table| { - drop_table.emit(AppMsg::DropTablePrompt { schema, table }); + drop_table.deliver(WorkspaceInput::DropTablePrompt { schema, table }); }); let toast = input.clone(); - structure.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + structure.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); let alert = input.clone(); - structure.connect_alert(move |_, title, body| alert.emit(AppMsg::ShowAlert { title, body })); + structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); } -/// The browse tab's signals, as this window's inputs for the tab. -fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &relm4::Sender) { +/// The browse tab's signals, as the workspace's inputs for the tab. +fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &WorkspaceSender) { let state = input.clone(); - browse.connect_state_changed(move |_| state.emit(AppMsg::WorkspaceTabsChanged)); + browse.connect_state_changed(move |_| state.deliver(WorkspaceInput::WorkspaceTabsChanged)); let clipboard = input.clone(); - browse.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); + browse.connect_copy_to_clipboard(move |_, text| clipboard.deliver(WorkspaceInput::CopyToClipboard(text))); let export = input.clone(); browse.connect_export_results(move |_, ExportRequest { result, name, target }| { - export.emit(AppMsg::ExportResults { result, name, target }); + export.deliver(WorkspaceInput::ExportResults { result, name, target }); }); let alert = input.clone(); - browse.connect_alert(move |_, title, body| alert.emit(AppMsg::ShowAlert { title, body })); + browse.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); let toast = input.clone(); - browse.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + browse.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); let dirty = input.clone(); - browse.connect_dirty_changed(move |_, is_dirty| dirty.emit(AppMsg::BrowseTabDirtyChanged(tab_id, is_dirty))); + browse.connect_dirty_changed(move |_, is_dirty| { + dirty.deliver(WorkspaceInput::BrowseTabDirtyChanged(tab_id, is_dirty)) + }); let save = input.clone(); - browse.connect_save_requested(move |_| save.emit(AppMsg::SaveTab(tab_id))); + browse.connect_save_requested(move |_| save.deliver(WorkspaceInput::SaveTab(tab_id))); let stop = input.clone(); - browse.connect_stop_save_requested(move |_| stop.emit(AppMsg::StopSaving(tab_id))); + browse.connect_stop_save_requested(move |_| stop.deliver(WorkspaceInput::StopSaving(tab_id))); } -/// Forward an editor tab's signals into the window. The toast overlay -/// is the window's, so a replaced query's Undo button lives there. +/// Forward an editor tab's signals into the workspace. The toast +/// overlay is the workspace's, so a replaced query's Undo button lives +/// there. fn connect_editor( editor: &SqlEditor, tab_id: Uuid, - input: &relm4::Sender, + input: &WorkspaceSender, toasts: &glib::WeakRef, ) { let run_state = input.clone(); editor.connect_run_state_changed(move |_, running| { - run_state.emit(AppMsg::EditorTabRunStateChanged(tab_id, running)); + run_state.deliver(WorkspaceInput::EditorTabRunStateChanged(tab_id, running)); }); let content = input.clone(); - editor.connect_content_changed(move |_| content.emit(AppMsg::EditorTabContentChanged(tab_id))); + editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); let transaction = input.clone(); - editor.connect_transaction_changed(move |_, _| transaction.emit(AppMsg::EditorTransactionChanged)); + editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); let clipboard = input.clone(); - editor.connect_copy_to_clipboard(move |_, text| clipboard.emit(AppMsg::CopyToClipboard(text))); + editor.connect_copy_to_clipboard(move |_, text| clipboard.deliver(WorkspaceInput::CopyToClipboard(text))); let toast = input.clone(); - editor.connect_toast(move |_, text| toast.emit(AppMsg::ShowToast(text))); + editor.connect_toast(move |_, text| toast.deliver(WorkspaceInput::ShowToast(text))); let export = input.clone(); editor.connect_export_results(move |_, result, name| { - export.emit(AppMsg::ExportResults { + export.deliver(WorkspaceInput::ExportResults { result, name, target: None, @@ -1325,7 +1264,7 @@ mod tests { settings: &crate::test_support::MemorySettings, tabs: &Rc>>, toasts: &adw::ToastOverlay, - input: &relm4::Sender, + input: &WorkspaceSender, ) -> WorkspaceChrome { adw::init().expect("libadwaita initialises under the test backend"); crate::register_test_resources(); @@ -1350,7 +1289,7 @@ mod tests { let settings = crate::test_support::MemorySettings::new(); let tabs = Rc::new(RefCell::new(HashMap::new())); let toasts = adw::ToastOverlay::new(); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); toasts.set_child(Some(&chrome.overview)); @@ -1381,7 +1320,7 @@ mod tests { let settings = crate::test_support::MemorySettings::new(); let tabs = Rc::new(RefCell::new(HashMap::new())); let toasts = adw::ToastOverlay::new(); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); let chrome = editor_chrome(&runtime, &settings, &tabs, &toasts, &input); let stack = gtk::Stack::new(); stack.add_named(&chrome.overview, Some("tabs")); @@ -1416,7 +1355,7 @@ mod tests { #[gtk4::test] fn overview_button_shows_from_the_second_tab() { adw::init().expect("libadwaita initialises under the test backend"); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); let chrome = build_workspace_chrome(&input, blank_page); let button = find_by_action_name(&chrome.overview, "overview.open").expect("the overview button"); assert!(!button.is_visible(), "shown with no tabs"); @@ -1473,7 +1412,7 @@ mod tests { }); let toasts = adw::ToastOverlay::new(); toasts.set_child(Some(&editor)); - let (input, _receiver) = relm4::channel::(); + let (input, _receiver) = crate::runtime::input_queue::(); connect_editor(&editor, Uuid::new_v4(), &input, &toasts.downgrade()); let overlay = toasts.downgrade(); diff --git a/linux/crates/app/src/workspace/close_guards.rs b/linux/crates/app/src/workspace/close_guards.rs index f6ee7a2efd..1e4f03fa85 100644 --- a/linux/crates/app/src/workspace/close_guards.rs +++ b/linux/crates/app/src/workspace/close_guards.rs @@ -63,17 +63,21 @@ impl CloseGuards { /// The window closes only once its prompts approve; `on_close` runs /// as it does. A close request while they decide is dropped, since - /// their dialog is already on screen. + /// their dialog is already on screen. `handle` gives the workspace + /// the window shows at the time, and a window with none closes at + /// once. pub(crate) fn connect_window>( self: &Rc, window: &W, - handle: WorkspaceHandle, + handle: impl Fn() -> Option + 'static, on_close: impl Fn(&W) + 'static, ) { let guards = self.clone(); window.connect_close_request(move |window| { guards.requests.set(guards.requests.get() + 1); - if !guards.window_may_close(window.upcast_ref(), &handle) { + if let Some(handle) = handle() + && !guards.window_may_close(window.upcast_ref(), &handle) + { return glib::Propagation::Stop; } on_close(window); @@ -220,7 +224,8 @@ mod tests { let window = window(); let closes = Rc::new(Cell::new(0)); let sink = closes.clone(); - guards.connect_window(&window, workspace.handle.clone(), move |_| sink.set(sink.get() + 1)); + let handle = workspace.handle.clone(); + guards.connect_window(&window, move || Some(handle.clone()), move |_| sink.set(sink.get() + 1)); (window, closes) } @@ -271,6 +276,20 @@ mod tests { assert!(visible_alert(&window).is_none()); } + #[gtk4::test] + fn a_window_with_no_workspace_closes_without_asking() { + let guards = CloseGuards::new(); + let window = window(); + let closes = Rc::new(Cell::new(0)); + let sink = closes.clone(); + guards.connect_window(&window, || None, move |_| sink.set(sink.get() + 1)); + + window.close(); + + assert_eq!(closes.get(), 1); + assert!(visible_alert(&window).is_none()); + } + /// libadwaita takes a close request over a dialog as dismissing the /// dialog, which keeps the window open. #[gtk4::test] diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 96c11d2e9e..43f8172b18 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -5,13 +5,18 @@ shortcuts-dialog.ui ui/browse-tab.ui ui/connect-dialog.ui + ui/connect-failed-page.ui + ui/connecting-page.ui + ui/picker-page.ui ui/preferences-dialog.ui ui/results-pane.ui + ui/session-window.ui ui/sql-editor.ui ui/sqlite-file-row.ui ui/ssh-section.ui ui/structure-tab.ui ui/tls-section.ui + ui/workspace.ui icons/scalable/actions/table-relation-symbolic.svg icons/scalable/actions/statement-pending-symbolic.svg diff --git a/linux/data/resources/ui/connect-failed-page.ui b/linux/data/resources/ui/connect-failed-page.ui new file mode 100644 index 0000000000..e280a1a873 --- /dev/null +++ b/linux/data/resources/ui/connect-failed-page.ui @@ -0,0 +1,59 @@ + + + + + + diff --git a/linux/data/resources/ui/connecting-page.ui b/linux/data/resources/ui/connecting-page.ui new file mode 100644 index 0000000000..9a864a9775 --- /dev/null +++ b/linux/data/resources/ui/connecting-page.ui @@ -0,0 +1,35 @@ + + + + + + diff --git a/linux/data/resources/ui/picker-page.ui b/linux/data/resources/ui/picker-page.ui new file mode 100644 index 0000000000..b21f3486e0 --- /dev/null +++ b/linux/data/resources/ui/picker-page.ui @@ -0,0 +1,77 @@ + + + + + + +
+ + New Window + app.new-window + +
+
+ + Import Connections… + win.import-connections + + + Export Connections… + win.export-connections + +
+
+ + Preferences + app.preferences + +
+
+ + Keyboard Shortcuts + app.shortcuts + + + About TablePro + app.about + + + Quit + app.quit + +
+
+
diff --git a/linux/data/resources/ui/session-window.ui b/linux/data/resources/ui/session-window.ui new file mode 100644 index 0000000000..f1d470a8cf --- /dev/null +++ b/linux/data/resources/ui/session-window.ui @@ -0,0 +1,15 @@ + + + + + + diff --git a/linux/data/resources/ui/workspace.ui b/linux/data/resources/ui/workspace.ui new file mode 100644 index 0000000000..ac7306f84c --- /dev/null +++ b/linux/data/resources/ui/workspace.ui @@ -0,0 +1,216 @@ + + + + + + +
+ + New Window + app.new-window + +
+
+ + Disconnect + win.disconnect + action-disabled + +
+
+ + Query History + win.show-history + + + Saved Queries + win.show-saved-queries + +
+
+ + Import Connections… + win.import-connections + + + Export Connections… + win.export-connections + +
+
+ + Preferences + app.preferences + +
+
+ + Keyboard Shortcuts + app.shortcuts + + + About TablePro + app.about + + + Quit + app.quit + +
+
+
diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index f314d2332d..1a84535725 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -24,6 +24,7 @@ crates/app/src/runtime/deliver.rs crates/app/src/runtime/deliver_input.rs crates/app/src/runtime/failed_requests.rs crates/app/src/runtime/gio_process_hold.rs +crates/app/src/runtime/input_queue.rs crates/app/src/runtime/mod.rs crates/app/src/runtime/request_sequencer.rs crates/app/src/runtime/request_stamp.rs @@ -34,7 +35,7 @@ crates/app/src/services/cell_overlay.rs crates/app/src/services/change_tracker.rs crates/app/src/services/change_tracker_registry.rs crates/app/src/services/column_relayout.rs -crates/app/src/services/from_session.rs +crates/app/src/services/file_resolution.rs crates/app/src/services/history_availability.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs @@ -63,19 +64,6 @@ crates/app/src/test_support/tests.rs crates/app/src/test_support/unlabelled_widget.rs crates/app/src/test_support/wait_timed_out.rs crates/app/src/test_support/widget_lookup.rs -crates/app/src/ui/app/close_flow.rs -crates/app/src/ui/app/connect_flow.rs -crates/app/src/ui/app/connection.rs -crates/app/src/ui/app/import.rs -crates/app/src/ui/app/mod.rs -crates/app/src/ui/app/row_ops.rs -crates/app/src/ui/app/saves.rs -crates/app/src/ui/app/sidebar_section_header.rs -crates/app/src/ui/app/status_pages.rs -crates/app/src/ui/app/structure.rs -crates/app/src/ui/app/window_tabs.rs -crates/app/src/ui/app/workspace_persistence.rs -crates/app/src/ui/app/workspace_tabs.rs crates/app/src/ui/browse_tab/browse_command.rs crates/app/src/ui/browse_tab/browse_request_kind.rs crates/app/src/ui/browse_tab/confirming_delete.rs @@ -186,6 +174,20 @@ crates/app/src/ui/mod.rs crates/app/src/ui/preferences_dialog/imp.rs crates/app/src/ui/preferences_dialog/mod.rs crates/app/src/ui/saved_queries_dialog.rs +crates/app/src/ui/session_window/connect_failed_page.rs +crates/app/src/ui/session_window/connect_flow.rs +crates/app/src/ui/session_window/connecting_page.rs +crates/app/src/ui/session_window/connections.rs +crates/app/src/ui/session_window/imp.rs +crates/app/src/ui/session_window/mod.rs +crates/app/src/ui/session_window/picker_page.rs +crates/app/src/ui/session_window/resolve_file_identity.rs +crates/app/src/ui/session_window/session_page.rs +crates/app/src/ui/session_window/session_teardown.rs +crates/app/src/ui/session_window/session_window_command.rs +crates/app/src/ui/session_window/session_window_input.rs +crates/app/src/ui/session_window/tests.rs +crates/app/src/ui/session_window/window_actions.rs crates/app/src/ui/sidebar_row.rs crates/app/src/ui/ssh_inputs.rs crates/app/src/ui/ssh_section.rs @@ -201,6 +203,21 @@ crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs +crates/app/src/ui/workspace/close_flow.rs +crates/app/src/ui/workspace/health_banner.rs +crates/app/src/ui/workspace/imp.rs +crates/app/src/ui/workspace/import.rs +crates/app/src/ui/workspace/mod.rs +crates/app/src/ui/workspace/row_ops.rs +crates/app/src/ui/workspace/saves.rs +crates/app/src/ui/workspace/sidebar.rs +crates/app/src/ui/workspace/sidebar_section_header.rs +crates/app/src/ui/workspace/status_pages.rs +crates/app/src/ui/workspace/structure.rs +crates/app/src/ui/workspace/window_tabs.rs +crates/app/src/ui/workspace/workspace_actions.rs +crates/app/src/ui/workspace/workspace_persistence.rs +crates/app/src/ui/workspace/workspace_tabs.rs crates/app/src/workspace/close_decision.rs crates/app/src/workspace/close_guard.rs crates/app/src/workspace/close_guards.rs @@ -254,10 +271,15 @@ data/app.tablepro.TablePro.metainfo.xml.in.in data/resources/shortcuts-dialog.ui data/resources/ui/browse-tab.ui data/resources/ui/connect-dialog.ui +data/resources/ui/connect-failed-page.ui +data/resources/ui/connecting-page.ui +data/resources/ui/picker-page.ui data/resources/ui/preferences-dialog.ui data/resources/ui/results-pane.ui +data/resources/ui/session-window.ui data/resources/ui/sql-editor.ui data/resources/ui/sqlite-file-row.ui data/resources/ui/ssh-section.ui data/resources/ui/structure-tab.ui data/resources/ui/tls-section.ui +data/resources/ui/workspace.ui From 7675a656ab000f352c46d853664aedcd6bbca8e4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 12:28:51 +0700 Subject: [PATCH 037/301] fix(linux): close a connect that lands after its window went --- linux/crates/app/src/ui/session_window/mod.rs | 49 ++++++++++++------- .../crates/app/src/ui/session_window/tests.rs | 21 ++++++++ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index ae3b4e4a3e..845830b3e9 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -31,6 +31,7 @@ use relm4::prelude::*; use tablepro_session::connect::StoreConnectionWrites; use tablepro_session::registry::FileIdentity; +use tablepro_session::runtime::ProcessHold; use tablepro_session::{ ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, Session, SessionClaim, SessionKey, @@ -425,20 +426,10 @@ impl SessionWindow { workspace.connect_title_changed(move |_, title| input.deliver(SessionWindowInput::TitleChanged(title))); } - /// Throw away a connection nobody adopted, closing it under the - /// close deadline while the process stays alive for it. fn discard_pending(&self, pending: PendingSession) { - let Some(state) = self.state() else { - return; - }; - let hold = state.services.hold().acquire(); - let closing = state.services.tasks.spawn_task(pending.discard()); - glib::spawn_future_local(async move { - if let Ok(report) = closing.await { - tracing::debug!(?report, "closed a connection that was not adopted"); - } - drop(hold); - }); + if let Some(state) = self.state() { + discard_pending(pending, &state.services.tasks, &*state.services.hold()); + } } /// Claim the key, then connect, so a second window asking for the @@ -587,17 +578,26 @@ impl SessionWindow { let work = connect_work(state, &source, cancel); self.show_page(SessionPage::Connecting { claim, source }); let input = self.input(); + let (tasks, hold) = (state.services.tasks.clone(), state.services.hold()); crate::runtime::spawn_and_deliver(&state.services.tasks, work, move |outcome| { let result = outcome.unwrap_or_else(|failure| { Err(AttemptError::Failed(ConnectFailure::new( crate::ui::error_text::task_failure_message(&failure), ))) }); - if let Some(input) = input { - input.deliver(SessionWindowInput::Command(Box::new(SessionWindowCommand::Attempt { - generation, - result, - }))); + let attempt = SessionWindowCommand::Attempt { generation, result }; + let Some(input) = input else { + return; + }; + // A connect that lands after its window went is closed, not + // dropped, so the server sees it end. + if let Err(unsent) = input.send(SessionWindowInput::Command(Box::new(attempt))) + && let SessionWindowInput::Command(command) = unsent.0 + && let SessionWindowCommand::Attempt { + result: Ok(connected), .. + } = *command + { + discard_pending(connected.pending, &tasks, &*hold); } }); } @@ -696,6 +696,19 @@ impl SessionWindow { } } +/// Throw away a connection nobody adopted, closing it under the close +/// deadline while the process stays alive for it. +fn discard_pending(pending: PendingSession, tasks: &tablepro_session::runtime::Tasks, hold: &dyn ProcessHold) { + let hold = hold.acquire(); + let closing = tasks.spawn_task(pending.discard()); + glib::spawn_future_local(async move { + if let Ok(report) = closing.await { + tracing::debug!(?report, "closed a connection that was not adopted"); + } + drop(hold); + }); +} + /// Supersede the connect in flight, not only cancel it: a driver /// blocked in a socket call can still connect afterwards, and that late /// session must not be adopted. diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 3a09a4e581..13a8b149c2 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -315,6 +315,27 @@ fn cancel_connect_discards_late_attempt() { assert_eq!(test.services.sessions.live_count(), 0); } +#[gtk4::test] +fn a_connect_that_lands_after_its_window_closed_is_closed() { + let test = TestWindow::picker("LandsAfterClose"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connecting"); + // The connect ends on the runtime while the main loop waits, so its + // answer reaches the window only after the window is gone. + let deadline = std::time::Instant::now() + WAIT; + while test.connector.connections().is_empty() && std::time::Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + std::thread::sleep(Duration::from_millis(100)); + + test.window.destroy(); + let connection = test.connection(); + wait_until(WAIT, || connection.is_closed()).expect("the late connection to close"); + + assert_eq!(test.services.sessions.live_count(), 0); +} + #[gtk4::test] fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { let test = TestWindow::with_script( From f55a230a11a0efbb4ee164f16a8a34e3ff6e6927 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 12:30:00 +0700 Subject: [PATCH 038/301] docs(linux): put the window actions comment above their list --- linux/crates/app/src/ui/session_window/window_actions.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index 1962d99077..189bc09215 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -12,12 +12,12 @@ use super::SessionWindowInput; use super::session_page::SessionPage; use crate::runtime::{DeliverInput, InputSender}; -/// The window's actions and the input each one sends. A closure holds -/// only the window's input sender, so an action that outlives the window -/// drops its input rather than reaching a window that is gone. /// Builds the input an action sends. type ActionInput = fn() -> SessionWindowInput; +/// The window's actions and the input each one sends. A closure holds +/// only the window's input sender, so an action that outlives the window +/// drops its input rather than reaching a window that is gone. const ACTIONS: [(&str, ActionInput); 8] = [ ("disconnect", || SessionWindowInput::Disconnect), ("reconnect", || SessionWindowInput::Reconnect), From ff7a40d53dc39057c19fa5a8fd0d0bf6f82f6766 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:06:35 +0700 Subject: [PATCH 039/301] fix(linux): keep Ctrl+W working on every page of a window --- linux/crates/app/src/ui/session_window/mod.rs | 10 ++++++ .../ui/session_window/session_window_input.rs | 3 ++ .../crates/app/src/ui/session_window/tests.rs | 2 +- .../src/ui/session_window/window_actions.rs | 35 +++++++++++++++++-- .../app/src/ui/workspace/workspace_actions.rs | 3 +- 5 files changed, 47 insertions(+), 6 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 845830b3e9..b98e9101c5 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -247,6 +247,7 @@ impl SessionWindow { tracing::info!("disconnected"); } } + SessionWindowInput::CloseCurrent => self.on_close_current(), SessionWindowInput::Reconnect => { if let SessionPage::Workspace { session, .. } = &*self.imp().page.borrow() { session.signal(MonitorSignal::RetryNow); @@ -662,6 +663,15 @@ impl SessionWindow { }); } + /// Ctrl+W: the workspace closes its current tab, and with no + /// workspace the window closes, through its close prompts. + fn on_close_current(&self) { + match self.workspace().and_then(|workspace| workspace.input()) { + Some(input) => input.deliver(WorkspaceInput::CloseActiveWorkspaceTab), + None => self.close(), + } + } + /// The close prompts approved the window's close, which goes ahead /// once this returns: what it holds is written and closed first. fn on_close_approved(&self) { diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index 363985c6f7..b166592896 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -34,6 +34,9 @@ pub(crate) enum SessionWindowInput { Disconnect, /// The prompts approved the disconnect. TeardownApproved, + /// Close the workspace's current tab, or the window when it shows + /// no tab. + CloseCurrent, /// The health banner's Retry: ask the session to reconnect now. Reconnect, /// The toast's Reset: move the workspace's unreadable saved tabs diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 13a8b149c2..5b4e8ef4ae 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -190,7 +190,7 @@ impl Drop for TestWindow { } } -fn editors(window: &SessionWindow) -> Vec { +pub(crate) fn editors(window: &SessionWindow) -> Vec { descendants(window) .into_iter() .filter_map(|widget| widget.downcast::().ok()) diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index 189bc09215..1a91fd4bfe 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -18,7 +18,8 @@ type ActionInput = fn() -> SessionWindowInput; /// The window's actions and the input each one sends. A closure holds /// only the window's input sender, so an action that outlives the window /// drops its input rather than reaching a window that is gone. -const ACTIONS: [(&str, ActionInput); 8] = [ +const ACTIONS: [(&str, ActionInput); 9] = [ + ("close-current", || SessionWindowInput::CloseCurrent), ("disconnect", || SessionWindowInput::Disconnect), ("reconnect", || SessionWindowInput::Reconnect), ("cancel-connect", || SessionWindowInput::CancelConnect), @@ -63,7 +64,8 @@ pub(super) fn sync(window: &adw::ApplicationWindow, page: &SessionPage, editable /// Quit, New Window and Preferences are application accelerators, which /// GTK runs before these, so none of their keys is bound here. The -/// workspace adds the actions these name while it is open. +/// workspace adds the actions these name while it is open, apart from +/// close-current, which the window keeps so Ctrl+W works on every page. pub(crate) fn install_window_shortcuts(window: &adw::ApplicationWindow) { use gtk::gdk::{Key, ModifierType}; let primary = ModifierType::CONTROL_MASK; @@ -97,7 +99,8 @@ fn make_shortcut(key: gtk::gdk::Key, modifiers: gtk::gdk::ModifierType, action: mod tests { use super::*; use crate::runtime::Delivery; - use crate::ui::session_window::tests::TestWindow; + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT, editors}; fn shortcut_action_names(window: &adw::ApplicationWindow) -> Vec { let controllers = window.observe_controllers(); @@ -181,4 +184,30 @@ mod tests { Delivery::ReceiverGone ); } + + #[gtk4::test] + fn close_current_closes_a_window_with_no_workspace() { + let test = TestWindow::picker("CloseCurrentPicker"); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the picker's window to close"); + } + + #[gtk4::test] + fn close_current_closes_the_tab_and_still_works_after_a_disconnect() { + let test = TestWindow::connected("CloseCurrentTab"); + WidgetExt::activate_action(&test.window, "win.open-editor", None).expect("win.open-editor"); + wait_until(WAIT, || editors(&test.window).len() == 1).expect("an editor tab"); + + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current"); + wait_until(WAIT, || editors(&test.window).is_empty()).expect("the tab to close"); + assert_eq!(test.app.windows().len(), 1, "the window closed with its tab"); + + test.window.send(SessionWindowInput::Disconnect); + test.wait_for_page("picker"); + WidgetExt::activate_action(&test.window, "win.close-current", None).expect("win.close-current on the picker"); + + wait_until(WAIT, || test.app.windows().is_empty()).expect("the window to close"); + } } diff --git a/linux/crates/app/src/ui/workspace/workspace_actions.rs b/linux/crates/app/src/ui/workspace/workspace_actions.rs index dc4e5b9cd5..df75a5a7a8 100644 --- a/linux/crates/app/src/ui/workspace/workspace_actions.rs +++ b/linux/crates/app/src/ui/workspace/workspace_actions.rs @@ -21,9 +21,8 @@ const HISTORY_ACTIONS: [&str; 2] = ["show-history", "show-saved-queries"]; /// Each one sends its input through `input`, so one activated after the /// workspace is gone is dropped rather than handled. pub(super) fn install(window: &adw::ApplicationWindow, input: &WorkspaceSender) -> Vec<&'static str> { - let entries: [(&'static str, ActionInput); 11] = [ + let entries: [(&'static str, ActionInput); 10] = [ ("open-editor", || WorkspaceInput::NewEditorTab), - ("close-current", || WorkspaceInput::CloseActiveWorkspaceTab), ("refresh-page", || WorkspaceInput::RefreshPage), ("save-changes", || WorkspaceInput::SaveActiveTab), ("undo-change", || WorkspaceInput::UndoActiveBrowseTab), From 74ec9cfdb18d2aaf39b0cb0a8c35d3405e05495c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:06:53 +0700 Subject: [PATCH 040/301] fix(linux): close a connect still queued when its window shuts down --- linux/crates/app/src/runtime/input_queue.rs | 70 ++++++++++++++++++- linux/crates/app/src/runtime/mod.rs | 2 +- linux/crates/app/src/ui/session_window/imp.rs | 4 +- linux/crates/app/src/ui/session_window/mod.rs | 35 +++++++--- .../crates/app/src/ui/session_window/tests.rs | 20 ++++++ 5 files changed, 115 insertions(+), 16 deletions(-) diff --git a/linux/crates/app/src/runtime/input_queue.rs b/linux/crates/app/src/runtime/input_queue.rs index 974e9391a7..45c4be4477 100644 --- a/linux/crates/app/src/runtime/input_queue.rs +++ b/linux/crates/app/src/runtime/input_queue.rs @@ -21,17 +21,35 @@ pub fn input_queue() -> (InputSender, UnboundedReceiver) { /// the widget while it is in the middle of handling another message. pub fn pump_inputs( object: &T, - mut receiver: UnboundedReceiver, + receiver: UnboundedReceiver, handle: impl Fn(&T, M) + 'static, ) -> glib::JoinHandle<()> +where + T: IsA, + M: 'static, +{ + pump_inputs_with_leftovers(object, receiver, handle, drop) +} + +/// As [`pump_inputs`], and once the pump stops, aborted or with `object` +/// gone, every message still queued goes to `leftover` rather than being +/// dropped, for one that owns something that has to be closed. +pub fn pump_inputs_with_leftovers( + object: &T, + receiver: UnboundedReceiver, + handle: impl Fn(&T, M) + 'static, + leftover: impl FnMut(M) + 'static, +) -> glib::JoinHandle<()> where T: IsA, M: 'static, { let weak = object.downgrade(); + let mut queue = Leftovers { receiver, leftover }; glib::spawn_future_local(async move { - while let Some(message) = receiver.recv().await { + while let Some(message) = queue.receiver.recv().await { let Some(object) = weak.upgrade() else { + (queue.leftover)(message); return; }; handle(&object, message); @@ -39,6 +57,23 @@ where }) } +/// The pump's end of the queue. It drains when the pump's future drops. +struct Leftovers { + receiver: UnboundedReceiver, + leftover: F, +} + +impl Drop for Leftovers { + fn drop(&mut self) { + // Closed first, so a sender gets its message back instead of + // adding to a queue nobody reads. + self.receiver.close(); + while let Ok(message) = self.receiver.try_recv() { + (self.leftover)(message); + } + } +} + /// A relm4 sender whose messages land in `input`, for a relm4 child that /// can only forward its output to one. Abort the handle with the widget /// that owns `input`. @@ -94,6 +129,37 @@ mod tests { assert_eq!(sender.try_deliver(1), Delivery::ReceiverGone); } + #[gtk4::test] + fn messages_still_queued_when_the_pump_stops_go_to_leftover() { + let object = glib::Object::new::(); + let (sender, receiver) = input_queue::(); + let (handled, left) = (Rc::new(RefCell::new(Vec::new())), Rc::new(RefCell::new(Vec::new()))); + let (handled_sink, left_sink) = (handled.clone(), left.clone()); + let pump = pump_inputs_with_leftovers( + &object, + receiver, + move |_, message| handled_sink.borrow_mut().push(message), + move |message| left_sink.borrow_mut().push(message), + ); + sender.deliver(1); + drain_main_context(); + + sender.deliver(2); + sender.deliver(3); + pump.abort(); + drop(pump); + drain_main_context(); + + assert_eq!(*handled.borrow(), [1]); + assert_eq!(*left.borrow(), [2, 3]); + assert_eq!(sender.try_deliver(4), Delivery::ReceiverGone); + assert_eq!( + *left.borrow(), + [2, 3], + "a message sent after the pump stopped was taken" + ); + } + #[gtk4::test] fn a_bridge_forwards_until_its_target_is_gone() { let (input, mut receiver) = input_queue::(); diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index 72fb1dd8df..d9b2bad3cb 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -14,7 +14,7 @@ pub use deliver::spawn_and_deliver; pub use deliver_input::{DeliverInput, Delivery}; pub use failed_requests::FailedRequests; pub use gio_process_hold::GioProcessHold; -pub use input_queue::{InputSender, input_queue, pump_inputs, relm4_bridge}; +pub use input_queue::{InputSender, input_queue, pump_inputs, pump_inputs_with_leftovers, relm4_bridge}; pub use request_sequencer::RequestSequencer; pub use request_stamp::RequestStamp; pub use request_ticket::RequestTicket; diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index ae508ca3bd..d6d1cf7574 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -27,8 +27,8 @@ pub struct SessionWindow { /// What the application handed the window, set once it is added. pub(super) state: OnceCell, pub(super) input: OnceCell>, - /// Handles the queued input. Aborted at shutdown, which drops every - /// reply still on its way. + /// Handles the queued input. Aborted at shutdown, which closes a + /// connect still queued and drops every other reply. pub(super) pump: RefCell>>, /// Ends the credential prompt loop with the window. pub(super) prompt_presenter: RefCell>>, diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index b98e9101c5..bd49d94a40 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -98,10 +98,17 @@ impl SessionWindow { let (context, prompts) = services.window_context(); let (input, receiver) = crate::runtime::input_queue::(); let _ = imp.input.set(input.clone()); - imp.pump - .replace(Some(crate::runtime::pump_inputs(self, receiver, |window, message| { - window.handle_input(message) - }))); + let (tasks, hold) = (services.tasks.clone(), services.hold()); + imp.pump.replace(Some(crate::runtime::pump_inputs_with_leftovers( + self, + receiver, + |window, message| window.handle_input(message), + move |message| { + if let Some(pending) = unadopted(message) { + discard_pending(pending, &tasks, &*hold); + } + }, + ))); if crate::config::profile() == crate::config::Profile::Development { self.add_css_class("devel"); } @@ -590,15 +597,10 @@ impl SessionWindow { let Some(input) = input else { return; }; - // A connect that lands after its window went is closed, not - // dropped, so the server sees it end. if let Err(unsent) = input.send(SessionWindowInput::Command(Box::new(attempt))) - && let SessionWindowInput::Command(command) = unsent.0 - && let SessionWindowCommand::Attempt { - result: Ok(connected), .. - } = *command + && let Some(pending) = unadopted(unsent.0) { - discard_pending(connected.pending, &tasks, &*hold); + discard_pending(pending, &tasks, &*hold); } }); } @@ -706,6 +708,17 @@ impl SessionWindow { } } +/// The connection a message carries that its window will never adopt +/// now. It is closed rather than dropped, so the server sees it end. +fn unadopted(message: SessionWindowInput) -> Option { + match message { + SessionWindowInput::Command(command) => match *command { + SessionWindowCommand::Attempt { result, .. } => result.ok().map(|connected| connected.pending), + }, + _ => None, + } +} + /// Throw away a connection nobody adopted, closing it under the close /// deadline while the process stays alive for it. fn discard_pending(pending: PendingSession, tasks: &tablepro_session::runtime::Tasks, hold: &dyn ProcessHold) { diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 5b4e8ef4ae..6d9a0cc2e3 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -336,6 +336,26 @@ fn a_connect_that_lands_after_its_window_closed_is_closed() { assert_eq!(test.services.sessions.live_count(), 0); } +#[gtk4::test] +fn a_connect_still_queued_when_its_window_shuts_down_is_closed() { + let test = TestWindow::picker("QueuedAtShutdown"); + let request = FakeConnector::request().expect("a request"); + let (pending, connection) = FakeConnector::pending_session(request, ConnectionScript::default()); + let attempt = SessionWindowCommand::Attempt { + generation: tablepro_session::connect::AttemptGeneration::FIRST, + result: Ok(Connected { + pending, + saved: None, + warnings: Vec::new(), + }), + }; + + test.window.send(SessionWindowInput::Command(Box::new(attempt))); + test.window.shutdown(); + + wait_until(WAIT, || connection.is_closed()).expect("the queued connection to close"); +} + #[gtk4::test] fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { let test = TestWindow::with_script( From b418f26165fb26617b8db2fb53e5c934c7868d66 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:04 +0700 Subject: [PATCH 041/301] fix(linux): retry a saved connection as it is saved now --- .../app/src/ui/session_window/connections.rs | 15 ++++- linux/crates/app/src/ui/session_window/mod.rs | 10 +++ .../crates/app/src/ui/session_window/tests.rs | 66 +++++++++++++++++-- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index a68bd0e411..fe236b4d88 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -12,7 +12,7 @@ use relm4::ComponentController; use tablepro_session::connect::StoreConnectionWrites; use tablepro_session::{ConnectSource, PendingSave, RequestTarget}; -use tablepro_storage::SavedConnection; +use tablepro_storage::{ConnectionListState, SavedConnection}; use uuid::Uuid; use super::{SessionWindow, SessionWindowInput}; @@ -162,6 +162,19 @@ impl SessionWindow { } } + /// `saved` as the connection list holds it now, or `None` once it is + /// deleted. The list every window shares is read without touching + /// the disk; before it is first read, `saved` is all there is. + pub(super) fn saved_as_stored(&self, saved: &SavedConnection) -> Option { + let Some(state) = self.state() else { + return Some(saved.clone()); + }; + match state.services.storage.connections().snapshot().state { + ConnectionListState::Ready(list) => list.iter().find(|entry| entry.id == saved.id).cloned(), + ConnectionListState::Loading | ConnectionListState::Unavailable(_) => Some(saved.clone()), + } + } + fn saved_connection(&self, id: Uuid) -> Option { self.imp() .saved_connections diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index bd49d94a40..4d293f96ee 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -631,6 +631,16 @@ impl SessionWindow { self.show_page(SessionPage::Picker); self.on_open_file(gio::File::for_uri(&identity.uri)); } + // The entry may have been edited or deleted in another window + // since the connect failed. + ConnectSource::Saved(saved) => match self.saved_as_stored(&saved) { + Some(current) => self.begin_connect(claim, ConnectSource::Saved(Box::new(current))), + None => { + drop(claim); + self.show_page(SessionPage::Picker); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + } + }, source => self.begin_connect(claim, source), } } diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 6d9a0cc2e3..07d8068ee2 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -39,9 +39,10 @@ pub(crate) struct Script { pub credentials: Option, } -/// A TablePro over fakes with one window open on the picker. The -/// application has an id of its own, so it never meets another test's, -/// or a TablePro the developer has open, on the session bus. +/// A TablePro over fakes with one window open on the picker and `saved` +/// in its connection list. The application has an id of its own, so it +/// never meets another test's, or a TablePro the developer has open, on +/// the session bus. pub(crate) struct TestWindow { pub app: TableProApplication, pub window: SessionWindow, @@ -70,6 +71,11 @@ impl TestWindow { let dir = tempfile::tempdir().expect("a temporary directory"); let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); + let saved = saved_connection(&dir.path().join("fake.db")); + storage + .connections() + .upsert_blocking(saved.clone()) + .expect("save the test's connection"); let Script { mut connection, establish, @@ -107,7 +113,6 @@ impl TestWindow { .and_then(|window| window.downcast::().ok()) .expect("a session window"); let services = app.services().expect("the application's services"); - let saved = saved_connection(&dir.path().join("fake.db")); let test = Self { app, window, @@ -294,6 +299,8 @@ fn cancel_connect_discards_late_attempt() { test.window .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); test.wait_for_page("connecting"); + // A cancel before the driver is asked builds no connection at all. + wait_until(WAIT, || !test.connector.calls().is_empty()).expect("the connect to reach the driver"); test.window.send(SessionWindowInput::CancelConnect); test.wait_for_page("picker"); @@ -412,6 +419,57 @@ fn retry_rereads_credentials() { assert_eq!(credentials.refreshes().len(), before + 1); } +#[gtk4::test] +fn retry_connects_to_the_entry_as_saved_now() { + let test = TestWindow::with_script( + "RetryEdited", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + let store = test.services.storage.connections().clone(); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let mut renamed = test.saved.clone(); + renamed.name = "Renamed".to_owned(); + store.upsert_blocking(renamed).expect("edit the entry"); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("workspace"); + + let names: Vec = test + .connector + .calls() + .iter() + .map(|call| call.request.metadata.name.clone()) + .collect(); + assert_eq!(names, ["Fake", "Renamed"]); +} + +#[gtk4::test] +fn retry_after_the_entry_was_deleted_returns_to_the_picker() { + let test = TestWindow::with_script( + "RetryDeleted", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + let store = test.services.storage.connections().clone(); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + store.remove_blocking(test.saved.id).expect("delete the entry"); + + test.window.send(SessionWindowInput::RetryConnect); + test.wait_for_page("picker"); + + assert_eq!(test.connector.calls().len(), 1, "the deleted entry was connected to"); + assert_eq!(test.services.sessions.window_for(&test.key()), None); +} + #[gtk4::test] fn missing_file_goes_to_connect_failed() { let test = TestWindow::picker("MissingFile"); From 9ac93d5a906564f7842cb79e33e0d9b315d67b9d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:19 +0700 Subject: [PATCH 042/301] fix(linux): go to the failed page when resolving a file stops --- .../app/src/services/file_resolution.rs | 3 +++ linux/crates/app/src/ui/session_window/mod.rs | 23 ++++++++++++------- .../crates/app/src/ui/session_window/tests.rs | 19 +++++++++++++++ 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/linux/crates/app/src/services/file_resolution.rs b/linux/crates/app/src/services/file_resolution.rs index 4e9611c1b9..27cf46f999 100644 --- a/linux/crates/app/src/services/file_resolution.rs +++ b/linux/crates/app/src/services/file_resolution.rs @@ -17,6 +17,9 @@ pub enum FileResolveError { NotLocal, #[error("the file could not be found: {0}")] NotFound(#[source] std::io::Error), + /// The background work that resolves it did not finish. + #[error("the file could not be resolved: {0}")] + Interrupted(#[source] tablepro_session::runtime::TaskFailure), } /// What a file turned out to open. diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 4d293f96ee..579f7b09dc 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -494,20 +494,20 @@ impl SessionWindow { } let path = file.path(); let uri = file.uri().to_string(); + let resolving_uri = uri.clone(); let connections = state.services.storage.connections().clone(); let resolving = state .services .tasks - .spawn_blocking_task(move || resolve_file_blocking(path, uri, &connections)); + .spawn_blocking_task(move || resolve_file_blocking(path, resolving_uri, &connections)); let input = self.input(); glib::spawn_future_local(async move { - match resolving.await { - Ok(resolved) => { - if let Some(input) = input { - input.deliver(SessionWindowInput::FileResolved(Box::new(resolved))); - } - } - Err(failure) => tracing::warn!(%failure, "the file could not be resolved"), + let resolved = resolving.await.unwrap_or_else(|failure| ResolvedFile { + uri, + target: Err(FileResolveError::Interrupted(failure)), + }); + if let Some(input) = input { + input.deliver(SessionWindowInput::FileResolved(Box::new(resolved))); } }); } @@ -906,5 +906,12 @@ fn file_failure_message(uri: &str, error: &FileResolveError) -> String { &[("file", &name)], ), FileResolveError::NotFound(_) => crate::i18n::gettext_f("{file} could not be found.", &[("file", &name)]), + FileResolveError::Interrupted(failure) => crate::i18n::gettext_f( + "{file} could not be opened. {reason}", + &[ + ("file", &name), + ("reason", &crate::ui::error_text::task_failure_message(failure)), + ], + ), } } diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 07d8068ee2..09268e1068 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -491,6 +491,25 @@ fn missing_file_goes_to_connect_failed() { assert!(test.connector.calls().is_empty(), "a missing file was connected to"); } +#[gtk4::test] +fn a_file_whose_resolve_never_ran_goes_to_connect_failed() { + let mut test = TestWindow::picker("ResolveStopped"); + let database = test._dir.path().join("local.db"); + // With the runtime gone, the resolve is cancelled before it starts. + if let Some(runtime) = test.runtime.take() { + runtime.shutdown(); + } + + test.window + .send(SessionWindowInput::OpenFile(gio::File::for_path(&database))); + test.wait_for_page("connect-failed"); + + assert!( + test.connector.calls().is_empty(), + "a file nobody resolved was connected to" + ); +} + #[gtk4::test] fn an_unsaved_file_opens_a_session_of_its_own() { let test = TestWindow::picker("File"); From f2b4921862307495d278a5b59191f042e69d33db Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:23 +0700 Subject: [PATCH 043/301] fix(linux): look for SSH keys off the GTK thread --- .github/workflows/build-linux.yml | 7 ++-- linux/CHANGELOG.md | 2 +- linux/crates/app/src/services/mod.rs | 1 + .../app/src/services/ssh_environment.rs | 25 ++++++++++++++ .../app/src/ui/session_window/connections.rs | 34 +++++++++++++++++-- linux/crates/app/src/ui/session_window/imp.rs | 3 ++ .../crates/app/src/ui/session_window/tests.rs | 16 +++++++++ linux/crates/app/src/ui/ssh_inputs.rs | 22 ++---------- linux/crates/app/src/ui/ssh_section.rs | 11 ++++-- linux/po/POTFILES.in | 1 + 10 files changed, 93 insertions(+), 29 deletions(-) create mode 100644 linux/crates/app/src/services/ssh_environment.rs diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 23f33b3b95..11e0e614f9 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -110,15 +110,16 @@ jobs: # This code runs on the GTK thread, where a slow disk or a # network mount freezes the window. A storage call ending in # _blocking is handed to spawn_blocking_task on the same line, - # which is what this check can see. Tests may do either. + # which is what this check can see. A Path method that stats the + # disk blocks the same way. Tests may do either. run: | # The $0 in the awk program is awk's record, not a shell variable. # shellcheck disable=SC2016 found=$(find crates/app/src/ui -name '*.rs' ! -name tests.rs -print0 \ - | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') + | xargs -0 awk '/^mod tests \{/ { nextfile } /std::fs::/ || /\.(exists|try_exists|is_dir|is_file|is_symlink|canonicalize|read_dir|read_link|symlink_metadata)\(/ || (/_blocking\(/ && !/spawn_blocking_task\(/) { print FILENAME ":" FNR ": " $0 }') if [ -n "$found" ]; then echo "$found" - echo "::error::crates/app/src/ui must not call std::fs, or a _blocking function outside spawn_blocking_task" + echo "::error::crates/app/src/ui must not call std::fs or a Path method that reads the disk, or a _blocking function outside spawn_blocking_task" exit 1 fi - name: Window actions on the window's own map diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 15262bc3d6..02b8fac8f0 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -49,7 +49,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format, a query opened from history and a loaded SQL file could not be undone with Ctrl+Z. - Format could split `+=`, `b'01'` or `@@version`, or detach a MySQL function from its parenthesis, and change the statement. - Opening a SQL, CSV or connections file from a pipe or a device never finished, or read past its size limit. -- Exporting results or query history to a slow disk or a network folder froze the window until the write ended. +- Exporting results or query history, or opening the connect dialog, froze the window on a slow disk or network folder. - The sidebar's table search button had no name for screen readers. - PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. - MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index a8674c0790..9397b2266c 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -11,6 +11,7 @@ pub mod save_batch; pub mod secret_labels; pub mod secret_save_report; pub mod session_context; +pub mod ssh_environment; pub mod structure_tracker; pub mod structure_tracker_handle; pub mod structure_tracker_registry; diff --git a/linux/crates/app/src/services/ssh_environment.rs b/linux/crates/app/src/services/ssh_environment.rs new file mode 100644 index 0000000000..ca8b1fafae --- /dev/null +++ b/linux/crates/app/src/services/ssh_environment.rs @@ -0,0 +1,25 @@ +//! What the machine offers for SSH logins. + +use std::path::PathBuf; + +/// What the machine offers for SSH logins, read each time the connect +/// dialog opens so a test can say what it has instead. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct SshEnvironment { + pub agent: bool, + pub default_key: Option, +} + +impl SshEnvironment { + /// Looking for a key file reads the disk, so this runs on the + /// blocking pool, never on the GTK thread. + pub fn detect_blocking() -> Self { + let agent = std::env::var_os("SSH_AUTH_SOCK").is_some_and(|socket| !socket.is_empty()); + let ssh_dir = glib::home_dir().join(".ssh"); + let default_key = ["id_ed25519", "id_ecdsa", "id_rsa"] + .into_iter() + .map(|candidate| ssh_dir.join(candidate)) + .find(|path| path.exists()); + Self { agent, default_key } + } +} diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index fe236b4d88..80230cada7 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -51,11 +51,41 @@ pub(super) fn prefill_for(source: &ConnectSource) -> Option { impl SessionWindow { /// Show the connect dialog over this window, filled from `prefill` - /// when a connect from it failed, with the focus on `focus`. + /// when a connect from it failed, with the focus on `focus`. It + /// opens once the user's SSH keys are looked for, which reads the + /// disk and so runs off the GTK thread. pub(super) fn present_connect_dialog(&self, prefill: Option, focus: Option) { + let Some(state) = self.state() else { + return; + }; + // A second request while the first waits would stack two dialogs. + if self.imp().dialog_opening.replace(true) { + return; + } + let detecting = state + .services + .tasks + .spawn_blocking_task(SshEnvironment::detect_blocking); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let environment = detecting.await.unwrap_or_else(|failure| { + tracing::warn!(%failure, "could not look for SSH keys"); + SshEnvironment::default() + }); + if let Some(window) = window.upgrade() { + window.imp().dialog_opening.set(false); + window.show_connect_dialog(prefill, focus, environment); + } + }); + } + + fn show_connect_dialog(&self, prefill: Option, focus: Option, ssh: SshEnvironment) { let (Some(state), Some(input)) = (self.state(), self.input()) else { return; }; + if self.imp().shut_down.get() { + return; + } let context = &state.context; let dialog = ConnectDialog::new(ConnectDialogInit { drivers: context.drivers.clone(), @@ -64,7 +94,7 @@ impl SessionWindow { tasks: state.services.tasks.clone(), saved_connections: Arc::from(self.imp().saved_connections.borrow().clone()), remembered_driver: state.services.settings.connect_dialog_driver(), - ssh_environment: SshEnvironment::detect(), + ssh_environment: ssh, prefill, }); let submitted = input.clone(); diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index d6d1cf7574..17aa5c87d2 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -46,6 +46,9 @@ pub struct SessionWindow { pub(super) welcome: RefCell>>, pub(super) picker: OnceCell, pub(super) dialog: RefCell>, + /// The connect dialog was asked for and opens once the SSH keys are + /// looked for. + pub(super) dialog_opening: Cell, pub(super) shut_down: Cell, } diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 09268e1068..0a2a0b57e8 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -265,6 +265,22 @@ fn the_header_icon_buttons_are_labelled() { } } +#[gtk4::test] +fn the_connect_dialog_opens_once_over_its_own_window() { + let test = TestWindow::picker("ConnectDialog"); + + test.window.send(SessionWindowInput::OpenConnectDialog); + test.window.send(SessionWindowInput::OpenConnectDialog); + wait_until(WAIT, || test.window.imp().dialog.borrow().is_some()).expect("the connect dialog"); + drain_main_context(); + + let dialogs = descendants(&test.window) + .into_iter() + .filter(|widget| widget.is::()) + .count(); + assert_eq!(dialogs, 1); +} + #[gtk4::test] fn claiming_open_key_presents_owner_window() { let test = TestWindow::connected("Owner"); diff --git a/linux/crates/app/src/ui/ssh_inputs.rs b/linux/crates/app/src/ui/ssh_inputs.rs index 5a829fe15a..36784746aa 100644 --- a/linux/crates/app/src/ui/ssh_inputs.rs +++ b/linux/crates/app/src/ui/ssh_inputs.rs @@ -4,6 +4,8 @@ use secrecy::{ExposeSecret, SecretString}; use tablepro_ssh::{SshAuth, SshConfig, SshDestination}; use tablepro_storage::{SavedSshAuth, SavedSshConfig}; +pub use crate::services::ssh_environment::SshEnvironment; + /// How the SSH server is logged in to, in the order the combo shows /// them. OpenSSH does the work for each; the app only picks which one /// it tries first. @@ -67,26 +69,6 @@ impl SshAuthChoice { } } -/// What the machine offers for SSH logins, read once when the dialog -/// opens so a test can say what it has instead. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct SshEnvironment { - pub agent: bool, - pub default_key: Option, -} - -impl SshEnvironment { - pub fn detect() -> Self { - let agent = std::env::var_os("SSH_AUTH_SOCK").is_some_and(|socket| !socket.is_empty()); - let ssh_dir = glib::home_dir().join(".ssh"); - let default_key = ["id_ed25519", "id_ecdsa", "id_rsa"] - .into_iter() - .map(|candidate| ssh_dir.join(candidate)) - .find(|path| path.exists()); - Self { agent, default_key } - } -} - /// The SSH rows as typed. Port, username and jump hosts may be empty: /// OpenSSH then takes them from the user's SSH configuration, which is /// also where a host alias is resolved. diff --git a/linux/crates/app/src/ui/ssh_section.rs b/linux/crates/app/src/ui/ssh_section.rs index 38d4c0bc97..d0de2c89a2 100644 --- a/linux/crates/app/src/ui/ssh_section.rs +++ b/linux/crates/app/src/ui/ssh_section.rs @@ -265,9 +265,14 @@ impl SshSection { .filters(&filters) .default_filter(&filter) .build(); - let ssh_dir = glib::home_dir().join(".ssh"); - if ssh_dir.is_dir() { - dialog.set_initial_folder(Some(&gio::File::for_path(&ssh_dir))); + // Asked through GIO, which reads the disk on its own threads. + let ssh_dir = gio::File::for_path(glib::home_dir().join(".ssh")); + let is_dir = ssh_dir + .query_info_future("standard::type", gio::FileQueryInfoFlags::NONE, glib::Priority::DEFAULT) + .await + .is_ok_and(|info| info.file_type() == gio::FileType::Directory); + if is_dir { + dialog.set_initial_folder(Some(&ssh_dir)); } let parent = self.root().and_downcast::(); if let Some(path) = dialog diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 1a84535725..89ec136834 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -44,6 +44,7 @@ crates/app/src/services/save_batch.rs crates/app/src/services/secret_labels.rs crates/app/src/services/secret_save_report.rs crates/app/src/services/session_context.rs +crates/app/src/services/ssh_environment.rs crates/app/src/services/structure_tracker.rs crates/app/src/services/structure_tracker_handle.rs crates/app/src/services/structure_tracker_registry.rs From 70f0574b052490318dae31fd3dadefb540c28f4f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:47 +0700 Subject: [PATCH 044/301] fix(linux): hold the process until a saved query or edit is written --- linux/CHANGELOG.md | 1 + linux/crates/app/src/runtime/deliver.rs | 39 ++++++++++++++++++- linux/crates/app/src/runtime/mod.rs | 2 +- .../app/src/ui/session_window/connections.rs | 4 +- linux/crates/app/src/ui/session_window/mod.rs | 10 ++--- .../crates/app/src/ui/session_window/tests.rs | 28 +++++++++++++ .../app/src/ui/workspace/status_pages.rs | 31 ++++++++++++++- 7 files changed, 102 insertions(+), 13 deletions(-) diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 02b8fac8f0..890970a71e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -50,6 +50,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Format could split `+=`, `b'01'` or `@@version`, or detach a MySQL function from its parenthesis, and change the statement. - Opening a SQL, CSV or connections file from a pipe or a device never finished, or read past its size limit. - Exporting results or query history, or opening the connect dialog, froze the window on a slow disk or network folder. +- A query saved just before quitting could be lost. - The sidebar's table search button had no name for screen readers. - PostgreSQL statement timeouts showed as a plain server error when the server's messages were not in English. - MySQL and MariaDB connections that verify the certificate did not trust certificate authorities installed on the system. diff --git a/linux/crates/app/src/runtime/deliver.rs b/linux/crates/app/src/runtime/deliver.rs index de5093cd2f..80e5584e55 100644 --- a/linux/crates/app/src/runtime/deliver.rs +++ b/linux/crates/app/src/runtime/deliver.rs @@ -1,6 +1,6 @@ use std::future::Future; -use tablepro_session::runtime::{TaskFailure, Tasks}; +use tablepro_session::runtime::{ProcessHold, TaskFailure, Tasks}; /// Run `work` on the app runtime and hand its result to `deliver` on the /// GTK thread. @@ -21,13 +21,50 @@ where }) } +/// Run `work` on the app runtime with the process held until it ends, +/// so a quit right after a write started waits for it instead of +/// cutting it off. +pub fn spawn_held(tasks: &Tasks, hold: &dyn ProcessHold, work: W) +where + W: Future + Send + 'static, +{ + let token = hold.acquire(); + let running = tasks.spawn_task(work); + glib::spawn_future_local(async move { + if let Err(failure) = running.await { + tracing::warn!(%failure, "a held background task failed"); + } + drop(token); + }); +} + #[cfg(test)] mod tests { use std::cell::RefCell; use std::rc::Rc; + use tablepro_session::runtime::CountingHold; + use super::*; + #[gtk4::test] + fn held_work_keeps_the_process_until_it_ends() { + let runtime = crate::test_support::test_runtime(); + let hold = CountingHold::new(); + let (finish, finished) = tokio::sync::oneshot::channel::<()>(); + + spawn_held(&runtime.tasks(), &hold, async move { + let _ = finished.await; + }); + crate::test_support::drain_main_context(); + assert_eq!(hold.live(), 1, "the work was not held"); + + let _ = finish.send(()); + crate::test_support::wait_until(std::time::Duration::from_secs(5), || hold.live() == 0) + .expect("the hold to end with the work"); + runtime.shutdown(); + } + #[gtk4::test] fn the_result_arrives_on_the_main_thread() { let runtime = crate::test_support::test_runtime(); diff --git a/linux/crates/app/src/runtime/mod.rs b/linux/crates/app/src/runtime/mod.rs index d9b2bad3cb..6f8863caa9 100644 --- a/linux/crates/app/src/runtime/mod.rs +++ b/linux/crates/app/src/runtime/mod.rs @@ -10,7 +10,7 @@ mod request_ticket; mod stamped; pub use app_runtime::AppRuntime; -pub use deliver::spawn_and_deliver; +pub use deliver::{spawn_and_deliver, spawn_held}; pub use deliver_input::{DeliverInput, Delivery}; pub use failed_requests::FailedRequests; pub use gio_process_hold::GioProcessHold; diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index 80230cada7..98d83c7110 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -121,7 +121,7 @@ impl SessionWindow { state.services.storage.connections().clone(), state.services.tasks.clone(), ); - drop(state.services.tasks.spawn_task(async move { + crate::runtime::spawn_held(&state.services.tasks, &*state.services.hold(), async move { match tablepro_session::persist_pending_save(&*secrets, &writes, save).await { Ok(report) => { for (_, error) in &report.secret_failures { @@ -139,7 +139,7 @@ impl SessionWindow { } } input.deliver(SessionWindowInput::ReloadConnections); - })); + }); } /// Stamp the connection's last-opened time, then read the list again diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 579f7b09dc..fe131b5bde 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -732,13 +732,9 @@ fn unadopted(message: SessionWindowInput) -> Option { /// Throw away a connection nobody adopted, closing it under the close /// deadline while the process stays alive for it. fn discard_pending(pending: PendingSession, tasks: &tablepro_session::runtime::Tasks, hold: &dyn ProcessHold) { - let hold = hold.acquire(); - let closing = tasks.spawn_task(pending.discard()); - glib::spawn_future_local(async move { - if let Ok(report) = closing.await { - tracing::debug!(?report, "closed a connection that was not adopted"); - } - drop(hold); + crate::runtime::spawn_held(tasks, hold, async move { + let report = pending.discard().await; + tracing::debug!(?report, "closed a connection that was not adopted"); }); } diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 0a2a0b57e8..1256a5ef60 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -578,6 +578,34 @@ fn late_show_create_reply_after_disconnect_is_dropped() { assert!(editors(&test.window).is_empty(), "the late reply opened an editor tab"); } +#[gtk4::test] +fn an_edit_kept_for_its_next_connect_is_written_under_a_hold() { + let test = TestWindow::picker("PersistHeld"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let mut edited = test.saved.clone(); + edited.name = "Edited".to_owned(); + + test.window.persist_pending_save(tablepro_session::PendingSave { + connection: edited, + secrets: Vec::new(), + forget: Vec::new(), + is_new: false, + }); + assert_eq!(test.hold.live(), 1, "the write started without holding the process"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + let stored = test + .services + .storage + .connections() + .load_blocking() + .expect("the connection list"); + assert!( + stored.iter().any(|saved| saved.name == "Edited"), + "the hold ended before the write" + ); +} + #[gtk4::test] fn quit_closes_a_connected_window_and_its_session() { let test = TestWindow::connected("Quit"); diff --git a/linux/crates/app/src/ui/workspace/status_pages.rs b/linux/crates/app/src/ui/workspace/status_pages.rs index a8250d6e88..072c14c2c2 100644 --- a/linux/crates/app/src/ui/workspace/status_pages.rs +++ b/linux/crates/app/src/ui/workspace/status_pages.rs @@ -132,7 +132,7 @@ impl WorkspaceModel { // The save lands even when the workspace is gone by then; only // its toast is dropped. let input = self.input.clone(); - drop(self.tasks.spawn_task(async move { + crate::runtime::spawn_held(&self.tasks, &*self.context.hold, async move { let message = match store.save(new_query).await { Ok(outcome) if outcome.replaced => crate::i18n::gettext_f("Replaced “{name}”.", &[("name", &name)]), Ok(_) => crate::i18n::gettext_f("Saved “{name}”.", &[("name", &name)]), @@ -142,7 +142,7 @@ impl WorkspaceModel { } }; input.deliver(WorkspaceInput::ShowToast(message)); - })); + }); } } @@ -169,6 +169,33 @@ fn suggested_query_name(query: &str) -> String { #[cfg(test)] mod tests { use super::suggested_query_name; + use crate::test_support::{finish_on_main, wait_until}; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + #[gtk4::test] + fn a_saved_query_is_written_under_a_hold() { + let test = TestWindow::connected("SaveQueryHeld"); + let history = test.services.history(); + let availability = history.availability(); + wait_until(WAIT, || availability.borrow().is_ready()).expect("the query database to open"); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + + test.workspace() + .with_model(|model| model.on_save_query_named("Orders".to_owned(), "SELECT 1".to_owned())); + assert_eq!(test.hold.live(), 1, "the save started without holding the process"); + wait_until(WAIT, || test.hold.live() == 0).expect("the hold to end"); + + let store = history.store().expect("the query database").saved_queries(); + let listing = test.services.tasks.spawn_task(async move { store.list(None).await }); + let saved = finish_on_main(WAIT, listing) + .expect("the list to load") + .expect("the listing task") + .expect("the saved queries"); + assert!( + saved.iter().any(|query| query.name == "Orders"), + "the hold ended before the save" + ); + } #[test] fn the_suggested_name_is_the_first_real_line() { From 55974d964b67f9d6a17e619ff70f0149e8700900 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:51 +0700 Subject: [PATCH 045/301] fix(linux): close a window whose page is in use without a panic --- linux/crates/app/src/ui/session_window/mod.rs | 9 ++++++++- .../crates/app/src/ui/session_window/tests.rs | 20 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index fe131b5bde..8544a814a7 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -701,7 +701,14 @@ impl SessionWindow { if self.page_is(|page| matches!(page, SessionPage::Connecting { .. })) { stop_connecting(&self.imp().attempts); } - drop(self.imp().page.replace(SessionPage::Picker)); + let left = match self.imp().page.try_borrow_mut() { + Ok(mut page) => std::mem::take(&mut *page), + Err(_) => { + tracing::warn!("the window closed while its page was in use"); + return; + } + }; + drop(left); } fn refresh_inhibitor(&self) { diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index 1256a5ef60..dbef0cba5b 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -413,6 +413,26 @@ fn a_failed_connect_shows_why_and_choose_connection_returns_to_the_picker() { assert_eq!(test.services.sessions.window_for(&test.key()), None); } +#[gtk4::test] +fn a_close_while_the_page_is_read_does_not_panic() { + let test = TestWindow::with_script( + "CloseWhileRead", + Script { + establish: vec![refused()], + ..Script::default() + }, + ); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + let page = test.window.imp().page.borrow(); + test.window.release_session(); + drop(page); + + assert_eq!(test.window.page_name(), "connect-failed"); +} + #[gtk4::test] fn retry_rereads_credentials() { let credentials = FakeCredentialSource::new(); From a14b1a49c5e68e186ee6378f8023885d02cdc588 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:55 +0700 Subject: [PATCH 046/301] test(linux): pin the teardown order, late replies and NUL copies --- linux/crates/app/src/ui/editor/tests.rs | 15 +++++++++ .../src/ui/session_window/session_teardown.rs | 30 +++++++++++++++++ .../crates/app/src/ui/session_window/tests.rs | 11 +++++++ linux/crates/app/src/ui/workspace/row_ops.rs | 33 +++++++++++++++++++ 4 files changed, 89 insertions(+) diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 043dd78299..2d07c8b458 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -291,6 +291,21 @@ fn a_dropped_file_goes_in_at_the_cursor_as_one_undo_step() { assert_eq!(buffer_text(&buffer), "SELECT 1;\n"); } +#[gtk4::test] +fn a_copy_from_a_result_grid_reaches_the_tab_whole() { + let harness = editor_with(answering(), "SELECT 1"); + let copied = Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .editor + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + let grid = harness.editor.imp().grid_sink.get().expect("the grid's sink").clone(); + + grid(GridMsg::CopyToClipboard("a\0b".to_owned())); + + assert_eq!(*copied.borrow(), ["a\0b".to_owned()]); +} + #[gtk4::test] fn closing_the_tab_closes_its_connection() { let harness = editor_with(answering(), "SELECT 1"); diff --git a/linux/crates/app/src/ui/session_window/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs index 7f4cdded46..936d0648c9 100644 --- a/linux/crates/app/src/ui/session_window/session_teardown.rs +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -104,6 +104,11 @@ mod tests { use tablepro_core::EditorSession; use tablepro_test_support::{CallCounters, CloseBehaviour, FakeEditorSession, FakeReply, FakeRun, Replies}; + use std::cell::RefCell; + use std::rc::Rc; + + use tablepro_session::RegistryEvent; + use super::*; use crate::test_support::wait_until; use crate::ui::session_window::SessionWindowInput; @@ -114,10 +119,35 @@ mod tests { let test = TestWindow::connected("OrderedTeardown"); let workspace = test.workspace().downgrade(); let key = test.key(); + wait_until(WAIT, || test.hold.live() == 0).expect("the workspace's opening writes to land"); + // Each step records what had happened before it. Only the flush + // holds the process before the claim goes, and the close's hold + // comes after it. + let steps = Rc::new(RefCell::new(Vec::new())); + let (detached, hold) = (steps.clone(), test.hold.clone()); + test.workspace() + .connect_notify_local(Some("parent"), move |workspace, _| { + if workspace.parent().is_none() { + detached.borrow_mut().push(("detached", hold.live() > 0)); + } + }); + let (released, gone) = (steps.clone(), workspace.clone()); + let _subscription = test.services.sessions.subscribe(move |event| { + if matches!(event, RegistryEvent::Released { .. }) { + let shut_down = gone.upgrade().is_none_or(|workspace| workspace.handle().is_none()); + released.borrow_mut().push(("released", shut_down)); + } + }); test.window.send(SessionWindowInput::Disconnect); test.wait_for_page("picker"); + assert_eq!( + *steps.borrow(), + [("detached", true), ("released", true)], + "a step ran before the one it depends on" + ); + let child = test.window.imp().toast_overlay.child(); assert!( child.is_some_and(|child| child.type_().name() == "TableProPickerPage"), diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index dbef0cba5b..cd96f3a98c 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -14,6 +14,7 @@ use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, Fake use super::*; use crate::application::{AppServicesParts, TableProApplication}; +use crate::runtime::Delivery; use crate::services::session_context::CredentialSourceFactory; use crate::storage::AppStorage; use crate::test_support::{MemorySettings, assert_labelled, descendants, drain_main_context, wait_until}; @@ -581,6 +582,7 @@ fn late_show_create_reply_after_disconnect_is_dropped() { }); }); let counters = test.connection().counters(); + let workspace = test.workspace().downgrade(); let input = test.workspace().input().expect("the workspace's input"); input.deliver(WorkspaceInput::ShowCreateTableForExisting { schema: Some("public".to_owned()), @@ -591,9 +593,18 @@ fn late_show_create_reply_after_disconnect_is_dropped() { test.window.send(SessionWindowInput::Disconnect); test.wait_for_page("picker"); + drain_main_context(); + // The reply comes back through the workspace's input, which the + // teardown closes before the reply is due. + assert_eq!( + input.try_deliver(WorkspaceInput::RefreshPage), + Delivery::ReceiverGone, + "the torn-down workspace still takes input" + ); let _ = wait_until(Duration::from_millis(600), || false); drain_main_context(); + assert!(workspace.upgrade().is_none(), "the workspace outlived its teardown"); assert_eq!(test.window.page_name(), "picker"); assert!(editors(&test.window).is_empty(), "the late reply opened an editor tab"); } diff --git a/linux/crates/app/src/ui/workspace/row_ops.rs b/linux/crates/app/src/ui/workspace/row_ops.rs index ff9145b81f..5fde8f49fc 100644 --- a/linux/crates/app/src/ui/workspace/row_ops.rs +++ b/linux/crates/app/src/ui/workspace/row_ops.rs @@ -30,10 +30,43 @@ fn clipboard_refusal(text: &str) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::runtime::DeliverInput; + use crate::test_support::{descendants, drain_main_context}; + use crate::ui::session_window::tests::TestWindow; + use crate::ui::workspace::WorkspaceInput; #[test] fn text_with_a_nul_is_refused() { assert!(clipboard_refusal("a\0b").is_some()); assert_eq!(clipboard_refusal("a\tb\nc"), None); } + + #[gtk4::test] + fn a_copy_with_a_nul_says_so_and_leaves_the_clipboard_alone() { + let test = TestWindow::connected("CopyNul"); + let clipboard = test.window.clipboard(); + clipboard.set_text("before"); + let input = test.workspace().input().expect("the workspace's input"); + + input.deliver(WorkspaceInput::CopyToClipboard("a\0b".to_owned())); + drain_main_context(); + + assert_eq!(local_text(&clipboard).as_deref(), Some("before")); + let refusal = clipboard_refusal("a\0b").expect("a refusal"); + assert!( + descendants(&test.workspace()) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == refusal), + "no toast said why" + ); + + input.deliver(WorkspaceInput::CopyToClipboard("a\tb".to_owned())); + drain_main_context(); + assert_eq!(local_text(&clipboard).as_deref(), Some("a\tb")); + } + + fn local_text(clipboard: >k4::gdk::Clipboard) -> Option { + clipboard.content()?.value(String::static_type()).ok()?.get().ok() + } } From 7d071a608114d3c9247cc27aafb718167ed84191 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:55 +0700 Subject: [PATCH 047/301] refactor(linux): drop what-comments from the workspace input --- linux/crates/app/src/ui/workspace/mod.rs | 37 +----------------------- 1 file changed, 1 insertion(+), 36 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 633c353a0a..4b64de79f4 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -98,10 +98,8 @@ pub(crate) struct WorkspaceModel { table_search: gtk::SearchEntry, /// `"empty"` while no tab is open, `"tabs"` otherwise. workspace_outer_stack: gtk::Stack, - /// AdwTabOverview wrapping the unified AdwTabBar + AdwTabView. workspace_root: Option, workspace_tab_view: Option, - /// Idempotency flag for `ensure_workspace_root`. workspace_root_added: Cell, /// Per-tab state, by tab id. Display order is read from the tab /// view's pages. @@ -266,7 +264,6 @@ pub(super) fn read_workspace_tab_id(page: &adw::TabPage) -> Option { #[derive(Debug)] pub(crate) enum WorkspaceInput { - /// The session's health moved. HealthChanged(SessionHealth), /// The session's object list for the sidebar changed: a reconnect /// read it again, or a schema change refreshed it. @@ -276,9 +273,7 @@ pub(crate) enum WorkspaceInput { name: String, open_mode: OpenMode, }, - /// "+ New query" button or Ctrl+T → append a new editor tab. NewEditorTab, - /// Ctrl+W → close active workspace tab (browse or editor). CloseActiveWorkspaceTab, EditorTabRunStateChanged(Uuid, bool), EditorTabContentChanged(Uuid), @@ -290,14 +285,11 @@ pub(crate) enum WorkspaceInput { WorkspaceLayoutReset(Result, crate::workspace::WorkspaceSaveError>), ShowHistory, ShowSavedQueries, - /// Keep the active editor tab's SQL under a name the user picks. SaveActiveQuery, SaveQueryNamed { name: String, query: String, }, - /// "Import CSV…" from a table's own menu: ask for a file, then - /// open the mapping dialog against that table's columns. ImportCsvRequested { schema: Option, table: String, @@ -310,11 +302,9 @@ pub(crate) enum WorkspaceInput { columns: Vec, bytes: Vec, }, - /// The mapping is settled, so the rows can be written. ImportCsvRun(Box), OpenHistoryQuery(String), ReplaceActiveTabQuery(String), - /// The close prompts decided about one tab's close. TabCloseDecided { id: Uuid, approved: bool, @@ -326,31 +316,22 @@ pub(crate) enum WorkspaceInput { target: Option, }, CopyToClipboard(String), - /// User clicked the close-X on any workspace tab. WorkspaceTabClosed(Uuid), - /// Tab right-click "Close Other Tabs": each tab still asks about - /// its own unsaved changes. + /// Each tab still asks about its own unsaved changes. CloseOtherWorkspaceTabs(Uuid), - /// Tab right-click "Close Tabs to the Right", in tab order. CloseWorkspaceTabsToRight(Uuid), /// The tab order, the selection or a tab's state changed, so the /// workspace is saved. WorkspaceTabsChanged, - /// A tab's own Save: write what its tracker holds. SaveTab(Uuid), - /// A tab's Stop Saving. StopSaving(Uuid), /// A save started or settled, or tabs' changes were dropped. Workspace(crate::workspace::WorkspaceEvent), - /// The number of saves running in this workspace changed. SavesInFlightChanged(usize), /// An editor tab's transaction opened or ended. EditorTransactionChanged, - /// Ctrl+S, for the active tab. SaveActiveTab, - /// Ctrl+Z, for the active Table tab. UndoActiveBrowseTab, - /// Ctrl+Y, for the active Table tab. RedoActiveBrowseTab, ShowAlert { title: String, @@ -361,50 +342,34 @@ pub(crate) enum WorkspaceInput { /// title gains or loses the "•" GNOME Text Editor puts on unsaved /// documents. BrowseTabDirtyChanged(Uuid, bool), - /// Sidebar right-click → "New Table…" or schema-header "+" button. - /// Always appends a fresh draft Structure tab. NewTableTab { schema: Option, }, - /// Sidebar right-click → "Edit Structure". Switches to the table's - /// open Structure tab, or appends one. EditStructureTab { schema: Option, table: String, }, - /// Sidebar right-click → "Show CREATE TABLE". The SQL opens in a - /// fresh editor tab. ShowCreateTableForExisting { schema: Option, table: String, }, - /// The CREATE statement `ShowCreateTableForExisting` built, for a - /// new editor tab. ShowCreateTableLoaded(String), - /// Sidebar right-click → "Drop Table…", or in-tab Drop button. DropTablePrompt { schema: Option, table: String, }, - /// The user confirmed the drop. DropTableConfirmed { schema: Option, table: String, }, /// The table is gone, so its tabs close without asking. DropTableSucceeded(tablepro_core::TableRef), - /// A Structure tab's tracker crossed the empty / non-empty line. StructureTabDirtyChanged(Uuid, bool), - /// Schema state changed (table created / dropped / altered): the - /// catalog reads the sidebar's list again and drops the table's - /// columns, and the tabs open on the table read it again. SchemaChanged { schema: Option, table: Option, }, - /// Ctrl+Shift+T → reopen the most recently closed tab. ReopenClosedTab, - /// Ctrl+F → open the filter strip for the active Browse tab. ShowFilterDialog, } From 2eb7fb657f207a0097fc2b059cffe79f21e70758 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:07:56 +0700 Subject: [PATCH 048/301] docs(linux): record the window per session decision in ADR 0012 --- linux/ARCHITECTURE.md | 2 +- .../docs/decisions/0003-relm4-architecture.md | 2 +- .../docs/decisions/0012-window-per-session.md | 34 +++++++++++++++++++ linux/docs/decisions/README.md | 3 +- linux/docs/state-management.md | 14 +++++++- 5 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 linux/docs/decisions/0012-window-per-session.md diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 2c3a546e47..cc710c3ea8 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -91,7 +91,7 @@ Each window is a `SessionWindow` for one connection session. It opens on the pic ## UI architecture: Relm4 -The `app` crate uses [Relm4](https://relm4.org) for component-based UI structure. +`SessionWindow` and `Workspace` are gtk-rs subclasses with CompositeTemplate `.ui` files ([ADR 0012](docs/decisions/0012-window-per-session.md)). The `app` crate's other views use [Relm4](https://relm4.org) for component-based UI structure. - **Component**: a unit of UI with explicit `Init`, `Input`, `Output`, `CmdOutput` types. State is private. All transitions go through `update`. - **AsyncComponent**: same shape, but `init` and `update` may be `async`. Used for components that load data on creation. diff --git a/linux/docs/decisions/0003-relm4-architecture.md b/linux/docs/decisions/0003-relm4-architecture.md index bb32dee1cc..0f684a1a46 100644 --- a/linux/docs/decisions/0003-relm4-architecture.md +++ b/linux/docs/decisions/0003-relm4-architecture.md @@ -1,6 +1,6 @@ # 0003 — Relm4 for app architecture -- **Status**: Accepted +- **Status**: Accepted; superseded for windows and the workspace by [0012](0012-window-per-session.md) - **Date**: 2026-04-26 ## Context diff --git a/linux/docs/decisions/0012-window-per-session.md b/linux/docs/decisions/0012-window-per-session.md new file mode 100644 index 0000000000..e00b4f1e28 --- /dev/null +++ b/linux/docs/decisions/0012-window-per-session.md @@ -0,0 +1,34 @@ +# 0012: One window per connection session, built from gtk-rs subclasses + +- **Status**: Accepted +- **Date**: 2026-10-14 + +## Context + +Each window used to be one Relm4 `App` component that connected, disconnected and switched connections in place. Everything that read the session (tabs, the sidebar, watches, replies still on their way) had to be reset by hand at every switch, and a reply from the old session could land in the new one. + +The 2026-09-14 dependency review found Relm4 used by none of the 31 Rust apps in GNOME Core and Circle, and found the plan wrapping its runtime and spawn functions and banning parts of its API to work around it. + +## Decision + +A window is a `SessionWindow` that holds at most one connection session: it shows the picker, the connect in progress, the failed connect, then the session's `Workspace`, and a disconnect tears the workspace down and returns to the picker. `SessionWindow` and `Workspace` are gtk-rs subclasses with CompositeTemplate `.ui` files in the GResource, not Relm4 components. Their actions go on the window's own action map through `add_action_entries`, and their input goes through `runtime::input_queue`, handled one message at a time on the GTK thread. + +## Rationale + +- A workspace that never changes session has nothing to reset. Its teardown writes what it shows, closes its input queue and drops it before the session closes, so a late reply has nowhere to land. +- Every connect claims the session's key in the `SessionRegistry` first, so a second window asking for the same database brings the first forward instead of connecting. +- Subclasses with templates are what GNOME's Rust apps and the gtk-rs book use. The input queue keeps what Relm4 gave: one message at a time, never handled inside the signal or action that sent it, and dropped once the widget is gone. + +## Consequences + +- This supersedes [0003](0003-relm4-architecture.md) for windows and the workspace. The Relm4 components that remain follow 0003 until a rewrite turns them into subclasses, so two patterns coexist until then. +- A message sent to a window or workspace after it shut down is dropped. One that owns something, such as a connection that finished connecting, is closed instead. +- Ctrl+W and the close prompts belong to the window, so they work on every page, not only while a session is open. + +## Alternatives considered + +**Keep one `App` per window that swaps sessions.** The source of the cross-session replies and the per-switch resets. + +**A Relm4 `SessionWindow` component.** Keeps the wrappers the dependency review removes. + +**One process-wide session service shared by every window.** Ties every window to one session's lifetime and brings back the reset problem one level up. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index d3b6e9c058..0484a26d39 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -41,13 +41,14 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin |---|---|---|---| | [0001](0001-no-plugin-system.md) | No plugin system; drivers are static | Accepted | Drivers are crates, registered at compile time. No `.tableplugin`-equivalent on Linux. | | [0002](0002-rust-gtk4-libadwaita.md) | Rust + GTK4 + libadwaita | Accepted | Validated by spike. Only stack with a production virtualized data grid (GtkColumnView). | -| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted | Elm-style components scale to TablePro's view count. | +| [0003](0003-relm4-architecture.md) | Relm4 for app architecture | Accepted; superseded in part by 0012 | Elm-style components scale to TablePro's view count. | | [0004](0004-libsecret-secret-storage.md) | libsecret via oo7 for password storage | Accepted | Secret Service API is the universal Linux secret backend. | | [0005](0005-supply-chain-policy.md) | Supply-chain policy | Accepted | Maintained crates first; custom code needs recorded evidence and a removal trigger. | | [0006](0006-meson-cargo-build.md) | Meson drives Cargo | Accepted | Meson owns install, data files and the build-time config; Cargo stays the Rust compiler. | | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | +| [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | A window holds at most one session and returns to the picker on disconnect; it and its workspace are subclasses with templates, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index d18e19cbcd..5dbcc9bde2 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -1,6 +1,18 @@ # State management with Relm4 -The `app` crate uses [Relm4](https://relm4.org) on top of gtk4-rs. The choice is recorded in [decisions/0003-relm4-architecture.md](decisions/0003-relm4-architecture.md). This file describes the patterns we follow inside the codebase. Read the official Relm4 book first; this document only covers the conventions specific to TablePro Linux. +The `app` crate's windows and workspace are gtk-rs subclasses ([decisions/0012-window-per-session.md](decisions/0012-window-per-session.md)); its other views are still [Relm4](https://relm4.org) components ([decisions/0003-relm4-architecture.md](decisions/0003-relm4-architecture.md)). This file describes the patterns we follow inside the codebase. For the Relm4 components, read the official Relm4 book first; this document only covers the conventions specific to TablePro Linux. + +## Windows and the workspace + +`SessionWindow` and `Workspace` are subclasses with a CompositeTemplate `.ui` file in the GResource, and each owns an input queue from `runtime::input_queue`: + +- Actions, signal handlers and background work send it a message, and `pump_inputs` hands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. +- Background work runs on the app's `Tasks` and answers through the queue with `DeliverInput::deliver`, which drops the answer once the widget has shut down. +- Shutdown aborts the pump, which closes the queue. A message still queued that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (`pump_inputs_with_leftovers`) and is closed rather than dropped. +- Window actions go on the window's own action map with `add_action_entries`; the workspace removes the ones it added when it shuts down. +- Work the user expects to finish, such as a save, runs through `runtime::spawn_held`, which holds the process until it ends. + +The rest of this page applies to the Relm4 components. ## When to use which component flavour From 0ef6d6fe0d28ff00344f66db2089fdb8aa15c9f6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:26:26 +0700 Subject: [PATCH 049/301] fix(linux): keep Ctrl+Home and Ctrl+End for the editor and grid in tabs --- linux/CHANGELOG.md | 1 + linux/crates/app/src/ui/workspace/mod.rs | 1 + .../src/ui/workspace/workspace_tab_view.rs | 46 +++++++++++++++++++ .../app/src/ui/workspace/workspace_tabs.rs | 3 +- linux/po/POTFILES.in | 1 + 5 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 linux/crates/app/src/ui/workspace/workspace_tab_view.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 1621cda40b..27be4f5b5e 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -126,6 +126,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Structure tab could not save two columns swapping names, or a column taking the name of one being removed. - Changing the type of a SQL Server column with a default failed. - A bulk delete confirmed after the table's primary key changed marked rows by their old key. +- Ctrl+Home and Ctrl+End jumped to the first or last tab instead of the start or end of the SQL editor or table. ### Security diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 4b64de79f4..877e74a523 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -15,6 +15,7 @@ mod structure; mod window_tabs; mod workspace_actions; mod workspace_persistence; +mod workspace_tab_view; mod workspace_tabs; use std::cell::{Cell, RefCell}; diff --git a/linux/crates/app/src/ui/workspace/workspace_tab_view.rs b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs new file mode 100644 index 0000000000..0769c9f677 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/workspace_tab_view.rs @@ -0,0 +1,46 @@ +use libadwaita as adw; + +/// AdwTabView runs its shortcuts in a capture-phase, global controller, +/// so Ctrl+Home and Ctrl+End would switch tabs before the SQL editor or +/// a grid saw them. GNOME Text Editor removes the same four. +const CHILD_NAVIGATION_KEYS: adw::TabViewShortcuts = adw::TabViewShortcuts::CONTROL_HOME + .union(adw::TabViewShortcuts::CONTROL_END) + .union(adw::TabViewShortcuts::CONTROL_SHIFT_HOME) + .union(adw::TabViewShortcuts::CONTROL_SHIFT_END); + +pub(super) fn new_workspace_tab_view() -> adw::TabView { + let view = adw::TabView::new(); + view.remove_shortcuts(CHILD_NAVIGATION_KEYS); + view +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn workspace_tab_view_leaves_buffer_navigation_keys_to_children() { + adw::init().expect("libadwaita initialises under the test backend"); + + let shortcuts = new_workspace_tab_view().shortcuts(); + + assert!( + !shortcuts.intersects(CHILD_NAVIGATION_KEYS), + "the tab view still takes {:?}", + shortcuts & CHILD_NAVIGATION_KEYS + ); + let switching = adw::TabViewShortcuts::CONTROL_TAB + | adw::TabViewShortcuts::CONTROL_SHIFT_TAB + | adw::TabViewShortcuts::CONTROL_PAGE_UP + | adw::TabViewShortcuts::CONTROL_PAGE_DOWN + | adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_UP + | adw::TabViewShortcuts::CONTROL_SHIFT_PAGE_DOWN + | adw::TabViewShortcuts::ALT_DIGITS + | adw::TabViewShortcuts::ALT_ZERO; + assert!( + shortcuts.contains(switching), + "a tab switching key was lost: {:?}", + switching - shortcuts + ); + } +} diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 132aee3dcf..7a3d571a94 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -19,6 +19,7 @@ use crate::workspace::{ }; use super::workspace_persistence::reopen; +use super::workspace_tab_view::new_workspace_tab_view; use super::{ CLOSED_TABS_CAPACITY, ClosedTabDescriptor, EditorTabSlot, OpenMode, WorkspaceInput, WorkspaceModel, WorkspaceSender, WorkspaceTab, read_workspace_tab_id, write_workspace_tab_id, @@ -850,7 +851,7 @@ fn build_workspace_chrome( input: &WorkspaceSender, create_tab: impl Fn(&adw::TabView) -> adw::TabPage + 'static, ) -> WorkspaceChrome { - let tab_view = adw::TabView::new(); + let tab_view = new_workspace_tab_view(); let tab_bar = adw::TabBar::builder() .view(&tab_view) .autohide(false) diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index c448495f22..b21e80fce3 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -223,6 +223,7 @@ crates/app/src/ui/workspace/structure.rs crates/app/src/ui/workspace/window_tabs.rs crates/app/src/ui/workspace/workspace_actions.rs crates/app/src/ui/workspace/workspace_persistence.rs +crates/app/src/ui/workspace/workspace_tab_view.rs crates/app/src/ui/workspace/workspace_tabs.rs crates/app/src/workspace/close_decision.rs crates/app/src/workspace/close_guard.rs From 46f8058e954c0698a87cbc00153b91ff221e0701 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 13:28:15 +0700 Subject: [PATCH 050/301] feat(linux): ask for a refused password again from the failed page --- linux/CHANGELOG.md | 2 +- .../ui/session_window/connect_failed_page.rs | 28 +- .../app/src/ui/session_window/connect_flow.rs | 77 +++- .../app/src/ui/session_window/connections.rs | 2 +- linux/crates/app/src/ui/session_window/imp.rs | 3 + linux/crates/app/src/ui/session_window/mod.rs | 76 ++- .../app/src/ui/session_window/secret_retry.rs | 434 ++++++++++++++++++ .../session_window/session_window_command.rs | 10 +- .../ui/session_window/session_window_input.rs | 4 + .../crates/app/src/ui/session_window/tests.rs | 57 ++- .../src/ui/session_window/window_actions.rs | 20 + .../data/resources/ui/connect-failed-page.ui | 17 +- linux/docs/storage.md | 22 + linux/po/POTFILES.in | 1 + 14 files changed, 706 insertions(+), 47 deletions(-) create mode 100644 linux/crates/app/src/ui/session_window/secret_retry.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 1621cda40b..7a187d13a2 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Password prompt on connect when the keyring holds no password, with a choice to remember it. +- Password prompt on connect when the keyring holds no password or the server refused it, with a choice to remember it. - SSH tunnels through the system OpenSSH client, with your agent, host aliases and jump hosts, confirming new host keys. - Security group in the connect dialog: encryption mode, CA certificate, client certificate and TLS server name. - SQLite file chooser in the connect dialog, with New Database to create a file. diff --git a/linux/crates/app/src/ui/session_window/connect_failed_page.rs b/linux/crates/app/src/ui/session_window/connect_failed_page.rs index 21e961ff1d..bbfd601a5d 100644 --- a/linux/crates/app/src/ui/session_window/connect_failed_page.rs +++ b/linux/crates/app/src/ui/session_window/connect_failed_page.rs @@ -3,6 +3,7 @@ use gtk4::glib; use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; +use tablepro_core::credentials::SecretKind; mod imp { use super::*; @@ -14,6 +15,10 @@ mod imp { #[template_child] pub status_page: TemplateChild, #[template_child] + pub reenter_button: TemplateChild, + #[template_child] + pub retry_button: TemplateChild, + #[template_child] pub edit_button: TemplateChild, } @@ -38,8 +43,8 @@ mod imp { } glib::wrapper! { - /// A connect that failed: why, and Retry, Edit Connection and - /// Choose Connection as the ways on. + /// A connect that failed: why, and Enter Password, Retry, Edit + /// Connection and Choose Connection as the ways on. pub struct ConnectFailedPage(ObjectSubclass) @extends adw::Bin, gtk::Widget, @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget; @@ -48,8 +53,9 @@ glib::wrapper! { impl ConnectFailedPage { /// `edit` labels the button that opens the connect dialog, and is /// `None` for a connection the dialog cannot edit, such as a file - /// nobody saved. - pub fn new(message: &str, edit: Option<&str>) -> Self { + /// nobody saved. `reenter` is the secret the server refused, which + /// the user is offered to type again. + pub fn new(message: &str, edit: Option<&str>, reenter: Option) -> Self { let page: Self = glib::Object::new(); let imp = page.imp(); imp.status_page.set_description(Some(message)); @@ -57,6 +63,13 @@ impl ConnectFailedPage { imp.edit_button.set_label(label); imp.edit_button.set_visible(true); } + if let Some(kind) = reenter { + imp.reenter_button.set_label(&super::secret_retry::reenter_label(kind)); + imp.reenter_button + .set_action_target_value(Some(&kind.attribute().to_variant())); + imp.reenter_button.set_visible(true); + imp.retry_button.remove_css_class("suggested-action"); + } page } @@ -64,4 +77,11 @@ impl ConnectFailedPage { pub fn message(&self) -> Option { self.imp().status_page.description().map(|text| text.to_string()) } + + /// The button that types a refused secret again, while it shows. + #[cfg(test)] + pub fn reenter_button(&self) -> Option { + let button = self.imp().reenter_button.get(); + button.is_visible().then_some(button) + } } diff --git a/linux/crates/app/src/ui/session_window/connect_flow.rs b/linux/crates/app/src/ui/session_window/connect_flow.rs index af426a187a..2c27aca930 100644 --- a/linux/crates/app/src/ui/session_window/connect_flow.rs +++ b/linux/crates/app/src/ui/session_window/connect_flow.rs @@ -71,24 +71,20 @@ async fn unsave(secrets: &dyn SecretVault, writes: &dyn ConnectionWrites, id: Uu } /// A secret typed into a prompt is kept only once the login it was -/// typed for has worked. +/// typed for has worked. `demand` names a secret the server refused, +/// which is asked for again rather than read from the keyring. pub(super) async fn connect_saved( saved: &SavedConnection, drivers: &DriverRegistry, credentials: &dyn CredentialSource, connector: &dyn Connector, interaction: CredentialInteraction, + demand: CredentialDemand, cancel: CancellationToken, ) -> Result<(PendingSession, Vec<(SecretKind, SecretError)>), ConnectError> { // Dropping the resolve on a cancel drops its prompt too, which is // what takes a password dialog still on screen down with it. - let resolving = tablepro_session::resolve_saved( - saved, - drivers, - credentials, - interaction.clone(), - CredentialDemand::default(), - ); + let resolving = tablepro_session::resolve_saved(saved, drivers, credentials, interaction.clone(), demand); let refreshed = cancel .run_until_cancelled(resolving) .await @@ -365,6 +361,7 @@ mod tests { &credentials, &connector, CredentialInteraction::Attended(prompter), + CredentialDemand::default(), cancel.clone(), ); let asking = async { @@ -381,6 +378,69 @@ mod tests { assert!(connector.calls().is_empty(), "a cancelled attempt still connected"); } + #[tokio::test(start_paused = true)] + async fn a_refused_password_is_asked_for_again_and_kept_once_it_works() { + use tablepro_core::credentials::{PromptPurpose, PromptReason, PromptReply}; + use tablepro_test_support::Call; + + let saved = connection(Uuid::new_v4()); + let vault = Arc::new(FakeSecretVault::new()); + vault + .store( + saved.id, + SecretKind::DatabasePassword, + &SecretString::from("old"), + "label", + ) + .await + .expect("the refused password"); + let credentials = KeyringCredentials::for_saved(vault.clone(), &saved); + let (prompter, mut queue) = GtkCredentialPrompter::new(); + let prompter: Arc = Arc::new(prompter); + let connector = FakeConnector::new([FakeEstablish::Connect(ConnectionScript::default())]); + let drivers = drivers(); + let loads_before = vault.calls().len(); + + let connecting = connect_saved( + &saved, + &drivers, + &credentials, + &connector, + CredentialInteraction::Attended(prompter), + CredentialDemand::reenter(SecretKind::DatabasePassword), + CancellationToken::new(), + ); + let answering = async { + let shown = queue.next().await.expect("the password prompt"); + assert_eq!( + shown.prompt.purpose, + PromptPurpose::DatabasePassword { + reason: PromptReason::Rejected + } + ); + shown + .reply + .send(PromptReply::Submitted { + values: vec![SecretString::from("new")], + remember: true, + }) + .expect("the connect is waiting"); + }; + let (outcome, ()) = tokio::join!(connecting, answering); + + let (pending, refused) = outcome.expect("a connection"); + assert!(refused.is_empty()); + assert!( + !vault.calls()[loads_before..].contains(&Call::Load(saved.id, SecretKind::DatabasePassword)), + "the refused password was read again" + ); + assert_eq!( + vault.stored_secret(saved.id, SecretKind::DatabasePassword).as_deref(), + Some("new") + ); + let _closed = pending.discard().await; + } + #[tokio::test(start_paused = true)] async fn a_saved_connect_that_lands_after_its_cancel_keeps_no_typed_secret() { let saved = connection(Uuid::new_v4()); @@ -398,6 +458,7 @@ mod tests { &credentials, &connector, CredentialInteraction::Attended(prompter), + CredentialDemand::default(), cancel.clone(), ); let answering = async { diff --git a/linux/crates/app/src/ui/session_window/connections.rs b/linux/crates/app/src/ui/session_window/connections.rs index 98d83c7110..ed4695ddd1 100644 --- a/linux/crates/app/src/ui/session_window/connections.rs +++ b/linux/crates/app/src/ui/session_window/connections.rs @@ -469,7 +469,7 @@ impl SessionWindow { )); toast.set_button_label(Some(&crate::i18n::gettext("Retry"))); toast.connect_button_clicked(move |_| input.deliver(SessionWindowInput::DeleteConnection(id))); - self.imp().toast_overlay.add_toast(toast); + self.add_toast(toast); } pub(super) fn on_reset_connection_list(&self) { diff --git a/linux/crates/app/src/ui/session_window/imp.rs b/linux/crates/app/src/ui/session_window/imp.rs index 17aa5c87d2..d77d0d59df 100644 --- a/linux/crates/app/src/ui/session_window/imp.rs +++ b/linux/crates/app/src/ui/session_window/imp.rs @@ -50,6 +50,9 @@ pub struct SessionWindow { /// looked for. pub(super) dialog_opening: Cell, pub(super) shut_down: Cell, + /// Every toast shown, which the overlay does not list. + #[cfg(test)] + pub(super) toasts: RefCell>, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 8544a814a7..5ce5b68e47 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -10,6 +10,7 @@ mod connections; mod imp; mod picker_page; mod resolve_file_identity; +mod secret_retry; mod session_page; mod session_teardown; mod session_window_command; @@ -36,7 +37,7 @@ use tablepro_session::{ ClaimError, ConnectError, ConnectIntent, ConnectSource, MonitorSignal, PendingSession, Session, SessionClaim, SessionKey, }; -use tablepro_storage::SavedConnection; +use tablepro_storage::{CredentialDemand, SavedConnection}; use self::connect_failed_page::ConnectFailedPage; use self::connecting_page::ConnectingPage; @@ -246,6 +247,7 @@ impl SessionWindow { SessionWindowInput::DialogClosed => drop(self.imp().dialog.take()), SessionWindowInput::CancelConnect => self.on_cancel_connect(), SessionWindowInput::RetryConnect => self.on_retry_connect(), + SessionWindowInput::RetryWithNewSecret(kind) => self.on_retry_with_new_secret(kind), SessionWindowInput::EditConnection => self.on_edit_connection(), SessionWindowInput::Disconnect => self.on_disconnect(), SessionWindowInput::TeardownApproved => { @@ -312,7 +314,13 @@ impl SessionWindow { } fn show_toast(&self, message: &str) { - self.imp().toast_overlay.add_toast(adw::Toast::new(message)); + self.add_toast(adw::Toast::new(message)); + } + + fn add_toast(&self, toast: adw::Toast) { + #[cfg(test)] + self.imp().toasts.borrow_mut().push(toast.clone()); + self.imp().toast_overlay.add_toast(toast); } /// Move to `page`. The page left behind drops with what it held, @@ -327,9 +335,14 @@ impl SessionWindow { imp.picker.get().map(|picker| picker.clone().upcast()) } SessionPage::Connecting { source, .. } => Some(ConnectingPage::new(&source_name(source)).upcast()), - SessionPage::ConnectFailed { source, failure, .. } => { - Some(ConnectFailedPage::new(&failure.message, edit_label(source, failure).as_deref()).upcast()) - } + SessionPage::ConnectFailed { source, failure, .. } => Some( + ConnectFailedPage::new( + &failure.message, + edit_label(source, failure).as_deref(), + failure.reenter, + ) + .upcast(), + ), SessionPage::Workspace { workspace, .. } => Some(workspace.clone().upcast()), }; let editable = match &page { @@ -577,13 +590,17 @@ impl SessionWindow { } fn begin_connect(&self, claim: SessionClaim, source: ConnectSource) { + self.begin_connect_with(claim, source, CredentialDemand::default()); + } + + fn begin_connect_with(&self, claim: SessionClaim, source: ConnectSource, demand: CredentialDemand) { let Some(state) = self.state() else { return; }; let ticket = self.imp().attempts.begin(); let generation = ticket.generation(); let cancel = ticket.cancel().clone(); - let work = connect_work(state, &source, cancel); + let work = connect_work(state, &source, demand, cancel); self.show_page(SessionPage::Connecting { claim, source }); let input = self.input(); let (tasks, hold) = (state.services.tasks.clone(), state.services.hold()); @@ -631,20 +648,24 @@ impl SessionWindow { self.show_page(SessionPage::Picker); self.on_open_file(gio::File::for_uri(&identity.uri)); } - // The entry may have been edited or deleted in another window - // since the connect failed. - ConnectSource::Saved(saved) => match self.saved_as_stored(&saved) { - Some(current) => self.begin_connect(claim, ConnectSource::Saved(Box::new(current))), - None => { - drop(claim); - self.show_page(SessionPage::Picker); - self.show_toast(&crate::i18n::gettext("The connection was deleted.")); - } - }, + ConnectSource::Saved(saved) => self.reconnect_saved(claim, *saved, CredentialDemand::default()), source => self.begin_connect(claim, source), } } + /// Connect a failed saved connection again, as it is saved now: the + /// entry may have been edited or deleted in another window since. + fn reconnect_saved(&self, claim: SessionClaim, saved: SavedConnection, demand: CredentialDemand) { + match self.saved_as_stored(&saved) { + Some(current) => self.begin_connect_with(claim, ConnectSource::Saved(Box::new(current)), demand), + None => { + drop(claim); + self.show_page(SessionPage::Picker); + self.show_toast(&crate::i18n::gettext("The connection was deleted.")); + } + } + } + fn on_edit_connection(&self) { let prefill = match &*self.imp().page.borrow() { SessionPage::ConnectFailed { source, failure, .. } => connections::prefill_for(source) @@ -759,6 +780,7 @@ fn stop_connecting(attempts: &tablepro_session::ConnectAttempts) { fn connect_work( state: &WindowState, source: &ConnectSource, + demand: CredentialDemand, cancel: tokio_util::sync::CancellationToken, ) -> BoxFuture<'static, Result> { let context = &state.context; @@ -770,9 +792,16 @@ fn connect_work( let drivers = context.drivers.clone(); let credentials = context.credentials_for(Some(&saved)); async move { - let connected = - connect_flow::connect_saved(&saved, &drivers, &*credentials, &*connector, interaction, cancel) - .await; + let connected = connect_flow::connect_saved( + &saved, + &drivers, + &*credentials, + &*connector, + interaction, + demand, + cancel, + ) + .await; match connected { Ok((pending, refused)) => Ok(Connected { pending, @@ -782,7 +811,13 @@ fn connect_work( .map(|(_, error)| crate::services::secret_save_report::not_saved_message(error)) .collect(), }), - Err(error) => Err(attempt_error(&error)), + Err(error) => Err(match attempt_error(&error) { + AttemptError::Failed(failure) => AttemptError::Failed(ConnectFailure { + reenter: secret_retry::refused_secret(&error, &saved), + ..failure + }), + cancelled => cancelled, + }), } } .boxed() @@ -866,6 +901,7 @@ fn attempt_error(error: &ConnectError) -> AttemptError { error => AttemptError::Failed(ConnectFailure { message: crate::ui::error_text::connect_error_message(error), remedy: crate::ui::error_text::connect_remedy(error), + reenter: None, }), } } diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs new file mode 100644 index 0000000000..9da425fdb4 --- /dev/null +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -0,0 +1,434 @@ +//! A failed connect whose secret the server refused offers to take it +//! again, and the retry asks for it rather than reading the keyring's +//! copy, which is the one that was just refused. + +use gtk4::subclass::prelude::*; +use tablepro_core::credentials::SecretKind; +use tablepro_core::{AuthMode, DriverError, SshFailure, TransportError}; +use tablepro_session::{ConnectError, ConnectSource}; +use tablepro_storage::{CredentialDemand, SavedConnection, SavedSshAuth}; + +use super::SessionWindow; +use super::session_page::SessionPage; + +impl SessionWindow { + /// The failed page's Enter Password: connect again, asking for + /// `kind` whatever the keyring holds. A stale activation, from a + /// page that has moved on, does nothing. + pub(super) fn on_retry_with_new_secret(&self, kind: SecretKind) { + let imp = self.imp(); + let offered = self.page_is(|page| { + matches!( + page, + SessionPage::ConnectFailed { + source: ConnectSource::Saved(_), + failure, + .. + } if failure.reenter == Some(kind) + ) + }); + if !offered { + return; + } + let SessionPage::ConnectFailed { + claim, + source: ConnectSource::Saved(saved), + .. + } = imp.page.replace(SessionPage::Picker) + else { + return; + }; + self.reconnect_saved(claim, *saved, CredentialDemand::reenter(kind)); + } +} + +/// The secret of `saved` that `error` says the server refused, when the +/// user can type it in TablePro. +/// +/// A key's passphrase counts only when the keyring holds it: without +/// one, ssh asks for it at each connect anyway. A keyring error names +/// the secret it could not read, and typing it skips the keyring. +pub(super) fn refused_secret(error: &ConnectError, saved: &SavedConnection) -> Option { + match error { + ConnectError::Keyring(error) => Some(error.kind()), + ConnectError::Driver(DriverError::Auth { .. }) => { + (saved.auth_mode == AuthMode::Password).then_some(SecretKind::DatabasePassword) + } + ConnectError::Driver(DriverError::Transport(TransportError::Ssh(SshFailure::AuthenticationRejected { + .. + }))) => match saved.ssh.as_ref().map(|ssh| &ssh.auth)? { + SavedSshAuth::Password => Some(SecretKind::SshPassword), + SavedSshAuth::PrivateKey { + has_passphrase: true, .. + } => Some(SecretKind::SshPassphrase), + SavedSshAuth::PrivateKey { .. } | SavedSshAuth::Agent | SavedSshAuth::KeyboardInteractive => None, + }, + _ => None, + } +} + +/// The label of the failed page's button for typing `kind` again. +pub(super) fn reenter_label(kind: SecretKind) -> String { + match kind { + SecretKind::DatabasePassword => crate::i18n::gettext("Enter Password…"), + SecretKind::SshPassword => crate::i18n::gettext("Enter SSH Password…"), + SecretKind::SshPassphrase => crate::i18n::gettext("Enter Passphrase…"), + } +} + +/// The kind an action target names, as `SecretKind::attribute` spells +/// it. +pub(super) fn secret_kind_named(name: &str) -> Option { + SecretKind::ALL.into_iter().find(|kind| kind.attribute() == name) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use tablepro_core::TlsConfig; + use tablepro_storage::{KeyringError, SavedSshConfig}; + use uuid::Uuid; + + use super::*; + + fn saved(auth_mode: AuthMode, ssh: Option) -> SavedConnection { + SavedConnection { + id: Uuid::new_v4(), + name: "Prod".to_owned(), + driver_id: "postgres".to_owned(), + host: "db.example.com".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode, + ssh: ssh.map(|auth| SavedSshConfig { + host: "bastion.example.com".to_owned(), + port: None, + username: Some("deploy".to_owned()), + jump_hosts: Vec::new(), + auth, + }), + last_opened_at: None, + color: None, + group: None, + } + } + + fn ssh_rejected() -> ConnectError { + ConnectError::Driver(DriverError::Transport(TransportError::Ssh( + SshFailure::AuthenticationRejected { + user: "deploy".to_owned(), + host: "bastion.example.com".to_owned(), + methods: vec!["password".to_owned()], + }, + ))) + } + + #[test] + fn a_refused_database_login_offers_the_database_password() { + let refused = ConnectError::Driver(DriverError::auth(None)); + + assert_eq!( + refused_secret(&refused, &saved(AuthMode::Password, None)), + Some(SecretKind::DatabasePassword) + ); + assert_eq!( + refused_secret(&refused, &saved(AuthMode::Kerberos, None)), + None, + "a Kerberos login has no password to type" + ); + } + + #[test] + fn a_refused_ssh_login_offers_the_secret_the_keyring_holds_for_it() { + let password = saved(AuthMode::Password, Some(SavedSshAuth::Password)); + let stored_passphrase = saved( + AuthMode::Password, + Some(SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: true, + }), + ); + let asked_by_ssh = saved( + AuthMode::Password, + Some(SavedSshAuth::PrivateKey { + path: Some(PathBuf::from("/home/u/.ssh/id_ed25519")), + has_passphrase: false, + }), + ); + let agent = saved(AuthMode::Password, Some(SavedSshAuth::Agent)); + + assert_eq!( + refused_secret(&ssh_rejected(), &password), + Some(SecretKind::SshPassword) + ); + assert_eq!( + refused_secret(&ssh_rejected(), &stored_passphrase), + Some(SecretKind::SshPassphrase) + ); + assert_eq!(refused_secret(&ssh_rejected(), &asked_by_ssh), None); + assert_eq!(refused_secret(&ssh_rejected(), &agent), None); + } + + #[test] + fn an_unreadable_keyring_offers_the_secret_it_could_not_read() { + let unreadable = ConnectError::Keyring(KeyringError::Secret { + kind: SecretKind::SshPassword, + error: tablepro_core::credentials::SecretError::Backend { + detail: "broken".to_owned(), + }, + }); + + assert_eq!( + refused_secret(&unreadable, &saved(AuthMode::Password, Some(SavedSshAuth::Password))), + Some(SecretKind::SshPassword) + ); + } + + #[test] + fn failures_a_secret_cannot_fix_offer_nothing() { + let lost = ConnectError::Driver(DriverError::ConnectionLost { + during: tablepro_core::LossPhase::Connect, + }); + + assert_eq!(refused_secret(&lost, &saved(AuthMode::Password, None)), None); + assert_eq!( + refused_secret(&ConnectError::Cancelled, &saved(AuthMode::Password, None)), + None + ); + } + + #[test] + fn every_kind_round_trips_through_its_action_target() { + for kind in SecretKind::ALL { + assert_eq!(secret_kind_named(kind.attribute()), Some(kind)); + } + assert_eq!(secret_kind_named("password"), None); + } +} + +#[cfg(test)] +mod window_tests { + use gtk4::glib; + use gtk4::prelude::*; + use libadwaita as adw; + use libadwaita::prelude::*; + use secrecy::{ExposeSecret, SecretString}; + use tablepro_core::Credentials; + use tablepro_core::credentials::{SecretError, SecretVault}; + use tablepro_session::RequestTarget; + use tablepro_session::fakes::{EstablishCall, FakeCredentialSource}; + use tablepro_test_support::Call; + + use super::*; + use crate::test_support::{descendants, finish_on_main, wait_until}; + use crate::ui::session_window::SessionWindowInput; + use crate::ui::session_window::connect_failed_page::ConnectFailedPage; + use crate::ui::session_window::tests::{Script, TestWindow, WAIT, refused}; + + fn open_prompt(window: &SessionWindow) -> Option { + window + .dialogs() + .iter::() + .filter_map(|item| item.ok()?.downcast::().ok()) + .next() + } + + /// Type `secret` into the credential prompt the window shows and + /// press its Connect, as the user would. Returns what the prompt + /// said. + fn answer_prompt(window: &SessionWindow, secret: &str, remember: bool) -> String { + let mut shown = None; + wait_until(WAIT, || { + shown = open_prompt(window); + shown.is_some() + }) + .expect("a credential prompt"); + let alert = shown.expect("the prompt"); + let body = alert.body().to_string(); + let rows = alert.extra_child().expect("the prompt's rows"); + for row in descendants(&rows) { + if let Some(entry) = row.downcast_ref::() { + entry.set_text(secret); + } else if let Some(switch) = row.downcast_ref::() { + switch.set_active(remember); + } + } + alert.emit_by_name::<()>("response", &[&"accept"]); + alert.force_close(); + body + } + + fn password_of(call: &EstablishCall) -> Option { + match &call.request.target { + RequestTarget::Network { + credentials: Credentials::Password { password, .. }, + .. + } => Some(password.expose_secret().to_owned()), + _ => None, + } + } + + fn failed_page(window: &SessionWindow) -> ConnectFailedPage { + window + .imp() + .toast_overlay + .child() + .and_then(|child| child.downcast::().ok()) + .expect("the failed page") + } + + fn toasts(window: &SessionWindow) -> Vec { + window + .imp() + .toasts + .borrow() + .iter() + .filter_map(|toast| toast.title().map(|title| title.to_string())) + .collect() + } + + fn password_loads(test: &TestWindow) -> usize { + test.vault + .calls() + .iter() + .filter(|call| **call == Call::Load(test.saved.id, SecretKind::DatabasePassword)) + .count() + } + + #[gtk4::test] + fn a_connect_the_user_starts_may_ask_for_what_is_missing() { + let credentials = FakeCredentialSource::new(); + let test = TestWindow::with_script( + "Attended", + Script { + credentials: Some(credentials.clone()), + ..Script::default() + }, + ); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("workspace"); + + assert_eq!(credentials.refreshes(), [true]); + assert!(test.connector.calls().iter().all(|call| call.attended)); + } + + #[gtk4::test] + fn enter_password_asks_again_and_skips_the_refused_secret() { + let test = TestWindow::with_script( + "EnterPassword", + Script { + establish: vec![refused()], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the old password stored"); + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + let button = failed_page(&test.window) + .reenter_button() + .expect("an Enter Password button"); + assert_eq!(button.label().as_deref(), Some("Enter Password…")); + assert!( + test.window + .lookup_action("retry-with-new-secret") + .is_some_and(|action| action.is_enabled()) + ); + let loads = password_loads(&test); + + button.emit_clicked(); + let asked = answer_prompt(&test.window, "new", true); + test.wait_for_page("workspace"); + + assert!(asked.contains("refused"), "{asked}"); + assert_eq!(password_loads(&test), loads, "the refused password was read again"); + let calls = test.connector.calls(); + assert_eq!(password_of(&calls[0]).as_deref(), Some("old")); + assert_eq!(password_of(&calls[1]).as_deref(), Some("new")); + assert_eq!( + test.vault.stored_secret(id, SecretKind::DatabasePassword).as_deref(), + Some("new"), + "the password that worked was not remembered" + ); + } + + #[gtk4::test] + fn a_failure_no_password_fixes_offers_no_enter_password() { + let lost = tablepro_session::fakes::FakeEstablish::Fail(ConnectError::Driver(DriverError::ConnectionLost { + during: tablepro_core::LossPhase::Connect, + })); + let test = TestWindow::with_script( + "NoEnterPassword", + Script { + establish: vec![lost], + network: true, + ..Script::default() + }, + ); + let (vault, id) = (test.vault.clone(), test.saved.id); + finish_on_main(WAIT, async move { + vault + .store(id, SecretKind::DatabasePassword, &SecretString::from("old"), "label") + .await + }) + .expect("the store to end") + .expect("the password stored"); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + test.wait_for_page("connect-failed"); + + assert!(failed_page(&test.window).reenter_button().is_none()); + assert!( + test.window + .lookup_action("retry-with-new-secret") + .is_some_and(|action| !action.is_enabled()) + ); + } + + #[gtk4::test] + fn adoption_stores_remembered_secret_and_toasts_failure() { + let test = TestWindow::with_script( + "RememberRefused", + Script { + network: true, + ..Script::default() + }, + ); + test.vault.fail_store(SecretKind::DatabasePassword, SecretError::Locked); + + test.window + .send(SessionWindowInput::OpenSaved(Box::new(test.saved.clone()))); + let asked = answer_prompt(&test.window, "typed", true); + test.wait_for_page("workspace"); + + assert!(asked.contains("No password is saved"), "{asked}"); + assert_eq!(password_of(&test.connector.calls()[0]).as_deref(), Some("typed")); + assert!( + test.vault + .calls() + .contains(&Call::Store(test.saved.id, SecretKind::DatabasePassword)), + "the typed password was not offered to the keyring" + ); + wait_until(WAIT, || { + toasts(&test.window) + .iter() + .any(|toast| toast.starts_with("Password not saved in the keyring")) + }) + .expect("the refused save to be reported"); + } +} diff --git a/linux/crates/app/src/ui/session_window/session_window_command.rs b/linux/crates/app/src/ui/session_window/session_window_command.rs index 15d42fd812..fb1be8ce85 100644 --- a/linux/crates/app/src/ui/session_window/session_window_command.rs +++ b/linux/crates/app/src/ui/session_window/session_window_command.rs @@ -1,3 +1,4 @@ +use tablepro_core::credentials::SecretKind; use tablepro_session::PendingSession; use tablepro_session::connect::AttemptGeneration; use tablepro_storage::SavedConnection; @@ -38,10 +39,17 @@ pub(crate) enum AttemptError { pub(crate) struct ConnectFailure { pub message: String, pub remedy: Option, + /// The secret the server refused, which the user can type again in + /// place of the keyring's copy. + pub reenter: Option, } impl ConnectFailure { pub(crate) fn new(message: String) -> Self { - Self { message, remedy: None } + Self { + message, + remedy: None, + reenter: None, + } } } diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index b166592896..109cea05e9 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -1,3 +1,4 @@ +use tablepro_core::credentials::SecretKind; use tablepro_storage::{ConnectionColor, SavedConnection}; use uuid::Uuid; @@ -26,6 +27,9 @@ pub(crate) enum SessionWindowInput { /// Connect again from where the failed connect came from, reading /// the keyring again. RetryConnect, + /// Connect a saved connection whose login was refused again, asking + /// for this secret instead of reading the keyring's copy. + RetryWithNewSecret(SecretKind), /// Open the connect dialog on a connection that failed to connect, /// with the focus on the setting that fixes it. EditConnection, diff --git a/linux/crates/app/src/ui/session_window/tests.rs b/linux/crates/app/src/ui/session_window/tests.rs index cd96f3a98c..d13b16a5e5 100644 --- a/linux/crates/app/src/ui/session_window/tests.rs +++ b/linux/crates/app/src/ui/session_window/tests.rs @@ -38,6 +38,9 @@ pub(crate) struct Script { /// What each connect gets, before `connection` from then on. pub establish: Vec, pub credentials: Option, + /// The saved connection logs in to a server with a password, rather + /// than opening a file. + pub network: bool, } /// A TablePro over fakes with one window open on the picker and `saved` @@ -51,6 +54,9 @@ pub(crate) struct TestWindow { pub hold: Rc, pub connector: FakeConnector, pub saved: SavedConnection, + /// The keyring every saved connection reads, unless the script + /// brings its own credentials. + pub vault: Arc, runtime: Option, _settings: MemorySettings, _dir: tempfile::TempDir, @@ -71,25 +77,33 @@ impl TestWindow { let settings = MemorySettings::new(); let dir = tempfile::tempdir().expect("a temporary directory"); let paths = StoragePaths::under(dir.path(), "tablepro", crate::config::APP_ID); - let storage = AppStorage::new(paths, Arc::new(FakeSecretVault::new()), &runtime.tasks()); - let saved = saved_connection(&dir.path().join("fake.db")); - storage - .connections() - .upsert_blocking(saved.clone()) - .expect("save the test's connection"); + let vault = Arc::new(FakeSecretVault::new()); + let storage = AppStorage::new(paths, vault.clone(), &runtime.tasks()); let Script { mut connection, establish, credentials, + network, } = script; + let saved = if network { + network_connection() + } else { + saved_connection(&dir.path().join("fake.db")) + }; + storage + .connections() + .upsert_blocking(saved.clone()) + .expect("save the test's connection"); connection.probe.always(FakeReply::ok(())); connection.list_schemas.always(FakeReply::ok(Vec::new())); connection.list_objects.always(FakeReply::ok(Vec::new())); let mut drivers = DriverRegistry::new(); - drivers.register(Arc::new(FakeDriver::connecting( - &FakeDriver::FILE_CAPABILITIES, - connection.clone(), - ))); + let capabilities = if network { + &FakeDriver::NETWORK_CAPABILITIES + } else { + &FakeDriver::FILE_CAPABILITIES + }; + drivers.register(Arc::new(FakeDriver::connecting(capabilities, connection.clone()))); let connector = FakeConnector::new(establish).always(FakeEstablish::Connect(connection)); let liveness = LivenessPolicy::DESKTOP; let hold = Rc::new(CountingHold::new()); @@ -121,6 +135,7 @@ impl TestWindow { hold, connector, saved, + vault, runtime: Some(runtime), _settings: settings, _dir: dir, @@ -222,7 +237,27 @@ fn saved_connection(database: &std::path::Path) -> SavedConnection { } } -fn refused() -> FakeEstablish { +/// A server on this machine, so no reachability check runs. +fn network_connection() -> SavedConnection { + SavedConnection { + id: uuid::Uuid::new_v4(), + name: "Server".to_owned(), + driver_id: "fake".to_owned(), + host: "127.0.0.1".to_owned(), + port: 5432, + database: "shop".to_owned(), + username: "app".to_owned(), + tls: TlsConfig::verify_full(), + read_only: false, + auth_mode: AuthMode::Password, + ssh: None, + last_opened_at: None, + color: None, + group: None, + } +} + +pub(crate) fn refused() -> FakeEstablish { FakeEstablish::Fail(ConnectError::Driver(DriverError::Auth { diagnostics: None })) } diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index 1a91fd4bfe..ea6b1e2159 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -5,6 +5,7 @@ //! it from the application's accelerators and from D-Bus. use gtk4 as gtk; +use gtk4::glib; use gtk4::prelude::*; use libadwaita as adw; @@ -38,6 +39,23 @@ pub(super) fn install(window: &adw::ApplicationWindow, input: &InputSender) -> gio::ActionEntry { + gio::ActionEntry::builder("retry-with-new-secret") + .parameter_type(Some(glib::VariantTy::STRING)) + .activate(move |_, _, target| { + if let Some(kind) = target + .and_then(|target| target.str()) + .and_then(super::secret_retry::secret_kind_named) + { + input.deliver(SessionWindowInput::RetryWithNewSecret(kind)); + } + }) + .build() } /// Enable each action on the page it means something on, so its menu @@ -45,6 +63,7 @@ pub(super) fn install(window: &adw::ApplicationWindow, input: &InputSender() { diff --git a/linux/data/resources/ui/connect-failed-page.ui b/linux/data/resources/ui/connect-failed-page.ui index e280a1a873..8a060c9fb6 100644 --- a/linux/data/resources/ui/connect-failed-page.ui +++ b/linux/data/resources/ui/connect-failed-page.ui @@ -18,7 +18,22 @@ 12 center - + + + false + Enter Password… + win.retry-with-new-secret + + + + + Retry win.retry-connect @@ -174,7 +174,7 @@ @@ -190,7 +190,7 @@ Rows: page_size_dropdown @@ -207,7 +207,7 @@ diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index db4c41f638..88f531b348 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -1,11 +1,7 @@ crates/app/src/application/app_actions.rs crates/app/src/application/app_services.rs -crates/app/src/application/app_services.rs -crates/app/src/application/app_services.rs crates/app/src/application/app_window_host.rs crates/app/src/application/imp.rs -crates/app/src/application/imp.rs -crates/app/src/application/imp.rs crates/app/src/application/mod.rs crates/app/src/application/open_files.rs crates/app/src/application/quit_sequence.rs @@ -171,6 +167,7 @@ crates/app/src/ui/grid/cell_editor_kind.rs crates/app/src/ui/grid/cell_focus.rs crates/app/src/ui/grid/cell_lock.rs crates/app/src/ui/grid/cell_preset.rs +crates/app/src/ui/grid/cell_tooltip.rs crates/app/src/ui/grid/column_drawing.rs crates/app/src/ui/grid/column_factory.rs crates/app/src/ui/grid/column_width_binding.rs @@ -180,6 +177,7 @@ crates/app/src/ui/grid/display_text.rs crates/app/src/ui/grid/edit_commit.rs crates/app/src/ui/grid/edit_navigation.rs crates/app/src/ui/grid/edit_snapshot.rs +crates/app/src/ui/grid/grid_attributes.rs crates/app/src/ui/grid/grid_cell.rs crates/app/src/ui/grid/grid_columns.rs crates/app/src/ui/grid/grid_keyboard.rs @@ -215,8 +213,6 @@ crates/app/src/ui/session_window/connections.rs crates/app/src/ui/session_window/imp.rs crates/app/src/ui/session_window/locate_file.rs crates/app/src/ui/session_window/mod.rs -crates/app/src/ui/session_window/mod.rs -crates/app/src/ui/session_window/mod.rs crates/app/src/ui/session_window/open_file_tests.rs crates/app/src/ui/session_window/picker_page.rs crates/app/src/ui/session_window/resolve_file_identity.rs @@ -226,8 +222,6 @@ crates/app/src/ui/session_window/session_restore.rs crates/app/src/ui/session_window/session_teardown.rs crates/app/src/ui/session_window/session_window_command.rs crates/app/src/ui/session_window/session_window_input.rs -crates/app/src/ui/session_window/session_window_input.rs -crates/app/src/ui/session_window/session_window_input.rs crates/app/src/ui/session_window/sqlite_location_dialog.rs crates/app/src/ui/session_window/tests.rs crates/app/src/ui/session_window/window_actions.rs @@ -326,8 +320,6 @@ crates/app/src/workspace/workspace_load_error.rs crates/app/src/workspace/workspace_record.rs crates/app/src/workspace/workspace_save_error.rs crates/app/src/workspace/workspace_store.rs -crates/app/src/workspace/workspace_store.rs -crates/app/src/workspace/workspace_store.rs crates/app/src/workspace/write_job.rs crates/app/src/workspace/writes_in_flight.rs data/app.tablepro.TablePro.desktop.in.in From f84290731d3288a482f36b0f11651f67f6ee2c02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:15:38 +0700 Subject: [PATCH 104/301] test(linux): run the open_files test, which the harness skipped --- linux/crates/app/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/linux/crates/app/Cargo.toml b/linux/crates/app/Cargo.toml index b8ad1d15da..aadf337c07 100644 --- a/linux/crates/app/Cargo.toml +++ b/linux/crates/app/Cargo.toml @@ -44,6 +44,7 @@ harness = false [[test]] name = "open_files" path = "tests/open_files.rs" +harness = false [[test]] name = "application_session_restore" From dfc918dd2e377b1e3f5aa66b8d94e4ba87c199a8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:16:03 +0700 Subject: [PATCH 105/301] docs(linux): document the application, windows and sessions --- linux/AGENTS.md | 4 +- linux/ARCHITECTURE.md | 62 ++++-- linux/README.md | 6 +- linux/ROADMAP.md | 15 +- .../docs/decisions/0012-window-per-session.md | 10 +- linux/docs/decisions/README.md | 2 +- linux/docs/state-management.md | 189 +++++++----------- linux/docs/testing.md | 66 +++++- 8 files changed, 197 insertions(+), 157 deletions(-) diff --git a/linux/AGENTS.md b/linux/AGENTS.md index 2dd063cec0..faf7b32efd 100644 --- a/linux/AGENTS.md +++ b/linux/AGENTS.md @@ -1,6 +1,6 @@ # TablePro for Linux -The native Linux app: Rust, GTK 4 and libadwaita through Relm4, built with Meson and Cargo. The platform floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88). It shares no code with the macOS app. +The native Linux app: Rust, GTK 4 and libadwaita through gtk-rs, built with Meson and Cargo. The platform floor is GNOME 50 (GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88). It shares no code with the macOS app. This is the guide for work under `linux/`. The root `AGENTS.md` describes the macOS and iOS apps; of it, only the principles named below, "Writing style" and the commit rules apply here. Claude Code loads this file through `linux/CLAUDE.md` once a file under `linux/` is read; Codex reads it when started in `linux/`, so otherwise read it yourself. @@ -18,7 +18,7 @@ This is the guide for work under `linux/`. The root `AGENTS.md` describes the ma ## Map -- `crates/app`: the binary and every Relm4 component. `crates/core`: domain types and traits, no GUI. `crates/storage`: secrets, settings and files. `crates/session`, `crates/net`, `crates/ssh`: sessions, the network layer and the OpenSSH transport. `crates/drivers/*`: one crate per engine. `crates/test-fixtures` and `crates/test-support`: test-only. +- `crates/app`: the binary, the GTK application, its windows and views. `crates/core`: domain types and traits, no GUI. `crates/storage`: secrets, settings and files. `crates/session`, `crates/net`, `crates/ssh`: sessions, the network layer and the OpenSSH transport. `crates/drivers/*`: one crate per engine. `crates/test-fixtures` and `crates/test-support`: test-only. - `data/`: desktop entry, D-Bus service, metainfo, GSettings schema, icons and GResource. `po/`: translations. `build-aux/flatpak/`: the Flatpak manifest. - `ARCHITECTURE.md`, `CONTRIBUTING.md` and `docs/` (testing, storage, error handling, state management, adding a driver, decisions) are the reference. Read the one for the area you touch. diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 51efc6961a..c3499288e2 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -12,7 +12,7 @@ linux/ ├── build-aux/flatpak/ Devel Flatpak manifest and krb5.conf ├── po/ translation catalogue └── crates/ - ├── app/ binary, GTK4 entry point, Relm4 components + ├── app/ binary, the GTK application, its windows and views ├── core/ domain types and the driver contract, no GUI deps ├── net/ direct TCP transport and rustls client configs ├── ssh/ system OpenSSH transport and the askpass helper @@ -74,29 +74,57 @@ Adding a new driver = adding one workspace member + one `register` call. There i ## Async architecture -Two runtimes coexist: +The GTK main loop owns every widget and runs on the main thread. Everything else runs on one tokio runtime, `AppRuntime`, which `run()` builds before GTK starts: multi-threaded, two to four workers named `tablepro-rt`. Code reaches it only through `Tasks` (`tablepro_session::runtime`), which `AppServices` hands to every window: -- **glib's main context** runs the UI. Single-threaded. Owns all GTK widgets. -- **tokio runtime** owned by Relm4 runs all DB driver work and other async tasks. +- `spawn_task` for async work, `spawn_blocking_task` for a blocking call such as a durable write, and `spawn_owned` for a loop that stops when its handle drops. +- Once the main loop returns, `AppRuntime::shutdown` gives the work still in flight two seconds. -Bridging uses Relm4's built-in primitives instead of hand-rolled channels: +Work crosses between the two through `crate::runtime`: -- `sender.command(move |out, shutdown| shutdown.register(async move { ... out.send(...) }).drop_on_shutdown())`: a component-scoped tokio task that cancels when the component drops. The `out` sender feeds `CmdOutput` back into the component's update loop on the GTK thread. Used for every per-tab fetch (browse rows, schema introspection, save transaction). -- `relm4::spawn(async move { ... })`: a fire-and-forget tokio task with no component lifetime tie. Used for storage writes (`touch_last_opened`, `query_history::record`, column-width persistence) and other side-effect work. -- A window and its workspace are gtk-rs widgets with an input queue (`runtime::input_queue`). Their actions, signal handlers and background work send it messages, which are handled one at a time on the GTK thread. The queue closes when the widget shuts down, so a reply that lands later is dropped rather than shown. +- `spawn_and_deliver` runs work on `Tasks` and hands its result to a closure on the GTK thread. Aborting the returned handle drops the delivery, never the work. +- `spawn_held` runs work the user expects to finish, such as a save or a write to the connection list, with the process held until it ends. +- A window's and a workspace's input queue (`input_queue`) takes the messages their actions, signal handlers and background work send. `pump_inputs` hands them over one at a time on the GTK thread, never inside the signal or action that sent one. `DeliverInput::deliver` drops a message once the widget has shut down and logs that at debug, so a reply that lands after a disconnect is never shown. +- A value the session publishes on a `tokio::sync::watch` (its health, its link generation, a schema's objects) reaches a view through a `WatchForwarder`, which delivers on the GTK thread and stops when the view drops it. -`run()` builds the app's own tokio runtime (`AppRuntime`) and hands its `Tasks` to `TableProApplication`, an `adw::Application` subclass that owns the windows, the app actions and D-Bus uniqueness. Its startup opens the query history on that runtime. Quit asks each window through its close prompts and never calls `g_application_quit`, so the process ends when the last window and the last close or save hold are gone, and the runtime shuts down after that. +`crates/app/clippy.toml` bans the ways around this: `tokio::spawn`, `Handle::current`, `relm4::spawn` and its siblings, `MainContext::block_on`, `ComponentSender::input`, `RelmApp::run` and `gio::Application::quit`. The fast CI job also fails on `std::fs`, a `Path` method that reads the disk, or a `_blocking` call outside `spawn_blocking_task` under `crates/app/src/ui`. -Each window is a `SessionWindow` for one connection session. It opens on the picker of saved connections, shows the connect while it runs and the failure when it fails, then the session's `Workspace`. Every connect claims the session's key in the `SessionRegistry` before it starts, so a second window asking for the same database brings the first forward. A workspace never moves to another session: Disconnect writes what it shows, takes its editor sessions, drops the workspace and its claim, and closes the session and those editor sessions under one hold before the window returns to the picker. An approved close runs the same teardown. +One second runtime remains. Relm4 runs a component's commands (`sender.command`, `oneshot_command`) on a runtime of its own, built the first time one runs, with one worker since nothing sets `RELM_THREADS`. The history and saved-queries dialogs still load through commands, so that runtime starts when either opens. -## UI architecture: Relm4 +## The application -`SessionWindow` and `Workspace` are gtk-rs subclasses with CompositeTemplate `.ui` files ([ADR 0012](docs/decisions/0012-window-per-session.md)). The `app` crate's other views use [Relm4](https://relm4.org) for component-based UI structure. +`TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the `SessionRegistry`, the settings, the storage, `Tasks`, the process hold, the open-sessions record and the query history. -- **Component**: a unit of UI with explicit `Init`, `Input`, `Output`, `CmdOutput` types. State is private. All transitions go through `update`. -- **AsyncComponent**: same shape, but `init` and `update` may be `async`. Used for components that load data on creation. -- **Factory**: drives a list / grid of homogeneous child components from a model. Used for the table sidebar and similar lists. -- **CmdOutput**: how a component receives async results. The tokio bridge sends `CmdOutput` messages back into the component's update loop. +- **One process per profile.** GApplication owns the application id on the session bus, so a second launch hands its command line to the running process and exits. `--new-window` (`-n`) asks the running process for `app.new-window`. The development profile has an id of its own and runs beside the stable app. +- **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`) and their accelerators, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. +- **Windows.** `open_window` builds a `SessionWindow` for a `WindowLaunch`: the picker, a saved connection, a file, or a session to restore. The application keeps each window in its `WindowRegistry` by window id, and a window reaches the others through its `WindowHost`, which holds the application weakly. +- **Holds.** A `ProcessHold` keeps the process alive while work that must finish runs: a session closing, a save, a durable write, a file being resolved. The app's `GioProcessHold` is `gio::Application::hold`, so the process ends once the last window and the last hold are gone. +- **Quit.** `request_quit` runs `QuitSequence`, which asks each window in turn through its close prompts and never calls `g_application_quit`. A second Quit while a window asks brings that window forward. At logout, `query-end` brings forward the first window that holds the session back. + +### Restoring sessions and opening files + +`OpenSessions` (`crates/app/src/workspace/open_sessions.rs`) records the session each window adopts, a file or a saved connection, and drops it on a disconnect or a closed window. With the `restore-session` key on (the default, under Preferences), the first activate of a launch reads the record under a hold, opens a window for each session in it with `WindowLaunch::Restore`, then opens the files handed over while it read, or the picker when that opens nothing. A restored saved connection is looked up in the list again, and one deleted since says so in a toast. With the key off, the first activate opens the picker. + +A quit freezes the record before it asks the first window, so each window it closes stays on it, and writes it before the process may end. An approved quit then empties the list in memory; a cancelled one keeps only the windows still showing or connecting their session. Shutdown freezes it too: windows that go with the process are not the user closing them. An approved quit can leave the process running while a session closes, and an activate that reaches it then restores the record as a new launch would. + +Files come from Open With in a file manager or from the command line (`HANDLES_OPEN`). They wait until the restore has read the record, since a file's window adds to it. Each file is resolved off the GTK thread, its path made canonical and matched against the saved connections, then it opens in the window that has it open, in the active window while that shows only the picker, or in a new window. + +## Windows and sessions + +Each window is a `SessionWindow` (`crates/app/src/ui/session_window/`) for one connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)). Its `SessionPage` is the picker of saved connections, the connect while it runs, the failure when it fails, or the session's `Workspace`. Every page past the picker holds the `SessionClaim` on the session's key, so leaving the page releases it. + +- A connect claims the key in the `SessionRegistry` before it starts, so a second window asking for the same database brings the first forward. The connect runs on `Tasks` under a cancel token, and a result for an attempt the user cancelled or replaced is closed instead of adopted. +- Adopting a connected session starts its `Session`, attaches it to the claim, which `SessionSignals` hears through the registry, records the key in `OpenSessions` and builds the `Workspace`. +- A workspace never moves to another session. Disconnect asks the close prompts, then `tear_down` writes what the workspace shows, takes its editor sessions, shuts the workspace down, drops the claim and the record's key, and closes the session and those editor sessions under one hold before the window returns to the picker. An approved close runs the same teardown. +- The close prompts (`CloseGuards`) belong to the window, so a tab's close, the close button and Ctrl+W ask on every page, one prompt at a time. While the workspace has something a logout would lose, a `WindowInhibitor` holds logout back, and suspend too while a save runs. +- The window's actions (`disconnect`, `reconnect`, `close-current` and the rest) go on its own action map, which GTK exports as `win`. + +The `Workspace` (`crates/app/src/ui/workspace/`) shows one session: the sidebar of its objects, the tab view and the banners. It adds its own actions to the window and removes them when it shuts down, follows the session's health and object list through `WatchForwarder`s, and tells the window what it shows through its `title-changed`, `dirty-changed` and `saves-in-flight-changed` signals. Its `WorkspaceHandle` runs the saves and tells the close prompts what closing any part of it would lose. [Workspace tab system](#workspace-tab-system) covers its tabs, and [Sessions and health](#sessions-and-health) the `Session` itself. + +## UI architecture + +The application, its windows, the workspace and every view a rewrite has reached are gtk-rs subclasses. A widget with a layout has a CompositeTemplate `.ui` file under `data/resources/ui/`, compiled into the GResource: the session window and its pages, the workspace, the browse, structure and SQL editor tabs, the results pane, and the connect and preferences dialogs with their sections. A subclass keeps its state in its `imp` struct and reports through signals. The window and the workspace take input through their input queues; a browse or structure tab handles its input in `send` at once, and one that arrives while another is being handled waits for it. + +A few views are still [Relm4](https://relm4.org) components ([ADR 0003](docs/decisions/0003-relm4-architecture.md)): the picker's `WelcomeView` with its `ConnectionRow` factory, the sidebar's `SidebarRow` factory, and the history, saved-queries and CSV import dialogs. Their output reaches the subclass that hosts them through `connect_receiver` or `relm4_bridge`, which put it on that subclass's queue. The two patterns coexist until a rewrite turns each component into a subclass. See [docs/state-management.md](docs/state-management.md) for the patterns and naming we use. @@ -249,7 +277,7 @@ The Structure tab is **definition + draft**, not a per-change log. The `TableDef `WorkspaceStore` reads a session's file once its session is adopted, and the window writes the tab list only after that read, so a write never lands on tabs not yet restored. A file that cannot be read, that another version wrote, or that is over 1 MiB is left as it is and nothing is written over it until the user picks Reset on the toast, which moves it aside as `.json.corrupt-`. Every open tab is saved and restored, with no cap on the count. Writes go through one task that keeps the newest record per file. Editor records carry a draft id, never the text, and the editor's `content-changed` signal carries no text either: the tab label reads at most 200 characters. -`OpenSessions` records the session each window adopts, a file or a saved connection, and drops it on a disconnect or a closed window. A quit freezes the record before it asks the first window and writes it before the process may end, so the first activate of the next launch reopens those windows when `restore-session` is on. An activate that reaches the old process while it still closes a session restores the same way. A cancelled quit keeps the keys of the windows still showing or connecting their session. A record that cannot be read is moved aside as `open-sessions.json.corrupt-`. +`OpenSessions` writes the record of open sessions off the GTK thread, holding the process until each write lands; [Restoring sessions and opening files](#restoring-sessions-and-opening-files) says when it changes. A record that cannot be read is moved aside as `open-sessions.json.corrupt-`. A restore deletes the drafts no saved or open tab names, except while a moved-aside copy of the session's file is there, since that copy may name them. A draft that cannot be read is moved aside as `.sql.corrupt-`, which the cleanup never deletes, and its tab opens empty with a toast. A disconnect writes the open tabs' text before their buffers go. diff --git a/linux/README.md b/linux/README.md index 2623330f83..c2a20b7c6d 100644 --- a/linux/README.md +++ b/linux/README.md @@ -12,8 +12,8 @@ Phases 2 and 3 in progress (see [ROADMAP.md](ROADMAP.md)). The stack (Rust + GTK |---|---| | Language | Rust 1.98+ | | GUI toolkit | GTK4 4.22+ + libadwaita 1.9+ + GtkSourceView 5.18+ (GNOME 50, [ADR 0015](docs/decisions/0015-gnome-50-platform-floor.md)) | -| App architecture | [Relm4](https://relm4.org) — Elm-style components on gtk4-rs | -| Async | tokio (DB drivers) bridged to glib main loop (UI) | +| App architecture | gtk-rs subclasses with composite templates, one window per connection session ([ADR 0012](docs/decisions/0012-window-per-session.md)); a few views still on [Relm4](https://relm4.org) | +| Async | the app's tokio runtime (`AppRuntime`) for background work, results delivered to the GLib main loop | | DB drivers | sqlx (PG / MySQL / SQLite), tiberius (MSSQL), official `clickhouse` crate; planned: fred (Redis), official mongodb / duckdb crates, etc. | | Persistence | libsecret (passwords), gio::Settings (prefs), JSON files (connection metadata) | | Distribution | Flathub primary, .deb / .rpm / AppImage secondary | @@ -133,7 +133,7 @@ worth knowing: | Roadmap and current phase | [ROADMAP.md](ROADMAP.md) | | Contributing: dev workflow, lint, commits, PRs | [CONTRIBUTING.md](CONTRIBUTING.md) | | **Adding a database driver** | [docs/adding-drivers.md](docs/adding-drivers.md) | -| State management with Relm4 | [docs/state-management.md](docs/state-management.md) | +| State management: windows, sessions, background work | [docs/state-management.md](docs/state-management.md) | | Persistence: secrets, settings, files | [docs/storage.md](docs/storage.md) | | Error handling conventions | [docs/error-handling.md](docs/error-handling.md) | | Testing conventions | [docs/testing.md](docs/testing.md) | diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index 1474b0f361..e014139905 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -12,7 +12,7 @@ This is **past demo-grade**, but still **not beta-shippable**. The gap between " |---|---| | Type system | Done. `Value` covers Null/Bool/Int/Float/Text/Bytes/Date/Time/DateTime/TimestampTz/Decimal/Uuid/Json | | Result scaling | Streams via sqlx `fetch` with `MAX_QUERY_ROWS` cap; full result still held in the grid model | -| Connection management | `DatabaseService` + `AdwTabView` workspace tabs done; one active connection at a time, multi-window still open | +| Connection management | Done. One window per connection session, each with its own `AdwTabView` workspace; the sessions open at quit open again at launch | | Network security | SSH tunnelling + TLS toggle present; cert-path / verify-mode UI still thin | | Distribution | Flatpak manifest + metainfo + desktop + icon present; never built end-to-end on CI | | Internationalization | gettext through `crates/app/src/i18n.rs` + `po/` template; extraction reaches every call site and `POTFILES.in` matches the tree. The committed template still predates that fix and needs regenerating on gettext 0.24 or newer | @@ -86,13 +86,14 @@ Exit criterion: a developer can demo the basic flows (connect, browse, edit, que - [ ] Memory-bounded grid: drop rows outside viewport (GTK virtualizes paint; model still holds all loaded rows) - [ ] Cancellation: drop the stream when user navigates away -### Multi-connection / multi-tab architecture (partial) +### Multi-connection / multi-tab architecture ✅ -- [x] `DatabaseService` owning active connection(s) +- [x] A `Session` per window, claimed in the `SessionRegistry` so one database opens in one window - [x] `AdwTabView` for multiple open tables / queries within one connection -- [x] Workspace tab persistence (`workspace_state.json`) -- [ ] Switch the active connection without reconnecting (`DatabaseService` has no `set_active`) -- [ ] Multi-window: each window holds its own active connection via `gtk::Application::add_window` +- [x] Workspace tab persistence, one file per session under `$XDG_STATE_HOME/tablepro/workspaces/` +- [x] Multi-window: each `SessionWindow` holds its own session, and another connection opens in another window rather than replacing it ([ADR 0012](docs/decisions/0012-window-per-session.md)) +- [x] Session restore: the windows open at quit open again at the next launch, behind the `restore-session` preference +- [x] Open With: a database file from the file manager opens in a window of its own, or brings forward the window that has it open ### Security baseline (partial) @@ -363,7 +364,7 @@ This is a separate undertaking and is not blocking any phase. It moves only afte | Stage | Effort | Calendar | |---|---|---| | Phase 0 + 1 | done | done | -| Phase 2 remainder (streaming backpressure, driver-side query cancel, multi-window, TLS UI polish) | ~2 weeks FT | next | +| Phase 2 remainder (streaming backpressure, driver-side query cancel, TLS UI polish) | ~2 weeks FT | next | | Phase 3 remainder — Beta release | ~3 weeks FT | following | | Phase 4 — Beta polish | 3 weeks FT | after Beta | | **Beta on Flathub** | **~8 weeks FT from this revision** | **after Phase 4** | diff --git a/linux/docs/decisions/0012-window-per-session.md b/linux/docs/decisions/0012-window-per-session.md index e00b4f1e28..b07daa670f 100644 --- a/linux/docs/decisions/0012-window-per-session.md +++ b/linux/docs/decisions/0012-window-per-session.md @@ -5,18 +5,19 @@ ## Context -Each window used to be one Relm4 `App` component that connected, disconnected and switched connections in place. Everything that read the session (tabs, the sidebar, watches, replies still on their way) had to be reset by hand at every switch, and a reply from the old session could land in the new one. +Each window used to be one Relm4 `App` component that connected, disconnected and switched connections in place. Everything that read the session (tabs, the sidebar, watches, replies still on their way) had to be reset by hand at every switch, and a reply from the old session could land in the new one. `RelmApp::run` opened that one window, and a `flock` on a file in the runtime directory kept a second launch out. The 2026-09-14 dependency review found Relm4 used by none of the 31 Rust apps in GNOME Core and Circle, and found the plan wrapping its runtime and spawn functions and banning parts of its API to work around it. ## Decision -A window is a `SessionWindow` that holds at most one connection session: it shows the picker, the connect in progress, the failed connect, then the session's `Workspace`, and a disconnect tears the workspace down and returns to the picker. `SessionWindow` and `Workspace` are gtk-rs subclasses with CompositeTemplate `.ui` files in the GResource, not Relm4 components. Their actions go on the window's own action map through `add_action_entries`, and their input goes through `runtime::input_queue`, handled one message at a time on the GTK thread. +The process is one `TableProApplication`, an `adw::Application` subclass that owns the windows, the app actions and the record of open sessions, and keeps one process per profile through GApplication's D-Bus uniqueness. A window is a `SessionWindow` that holds at most one connection session: it shows the picker, the connect in progress, the failed connect, then the session's `Workspace`, and a disconnect tears the workspace down and returns to the picker. `SessionWindow` and `Workspace` are gtk-rs subclasses with CompositeTemplate `.ui` files in the GResource, not Relm4 components. Their actions go on the window's own action map through `add_action_entries`, and their input goes through `runtime::input_queue`, handled one message at a time on the GTK thread. ## Rationale - A workspace that never changes session has nothing to reset. Its teardown writes what it shows, closes its input queue and drops it before the session closes, so a late reply has nowhere to land. - Every connect claims the session's key in the `SessionRegistry` first, so a second window asking for the same database brings the first forward instead of connecting. +- GApplication hands a second launch's command line and files to the running process, so `--new-window` and Open With reach the windows that already exist, and Quit can ask each window through its own prompts. - Subclasses with templates are what GNOME's Rust apps and the gtk-rs book use. The input queue keeps what Relm4 gave: one message at a time, never handled inside the signal or action that sent it, and dropped once the widget is gone. ## Consequences @@ -24,6 +25,7 @@ A window is a `SessionWindow` that holds at most one connection session: it show - This supersedes [0003](0003-relm4-architecture.md) for windows and the workspace. The Relm4 components that remain follow 0003 until a rewrite turns them into subclasses, so two patterns coexist until then. - A message sent to a window or workspace after it shut down is dropped. One that owns something, such as a connection that finished connecting, is closed instead. - Ctrl+W and the close prompts belong to the window, so they work on every page, not only while a session is open. +- Each window records the session it adopts, so a launch can open them again when `restore-session` is on. ## Alternatives considered @@ -32,3 +34,7 @@ A window is a `SessionWindow` that holds at most one connection session: it show **A Relm4 `SessionWindow` component.** Keeps the wrappers the dependency review removes. **One process-wide session service shared by every window.** Ties every window to one session's lifetime and brings back the reset problem one level up. + +**A `RelmApp` root.** It builds an application of its own around one root component, which knows neither the other windows nor the app actions, so every second window, Quit and D-Bus activation would work around it. + +**A `flock` for one instance.** A hand-written gate for what GApplication already does, and it only refuses a second launch: that launch's command line and files still have to reach the running process. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 0484a26d39..c7e28eb3c5 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -48,7 +48,7 @@ ADRs are short on purpose. If yours is more than a page, you are probably arguin | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | | [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | -| [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | A window holds at most one session and returns to the picker on disconnect; it and its workspace are subclasses with templates, not Relm4 components. | +| [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | | [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index 5dbcc9bde2..f581482db1 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -1,150 +1,101 @@ -# State management with Relm4 +# State management -The `app` crate's windows and workspace are gtk-rs subclasses ([decisions/0012-window-per-session.md](decisions/0012-window-per-session.md)); its other views are still [Relm4](https://relm4.org) components ([decisions/0003-relm4-architecture.md](decisions/0003-relm4-architecture.md)). This file describes the patterns we follow inside the codebase. For the Relm4 components, read the official Relm4 book first; this document only covers the conventions specific to TablePro Linux. +The `app` crate's application, windows, workspace and tabs are gtk-rs subclasses ([decisions/0012-window-per-session.md](decisions/0012-window-per-session.md)). A few views are still [Relm4](https://relm4.org) components ([decisions/0003-relm4-architecture.md](decisions/0003-relm4-architecture.md)): `WelcomeView`, the `ConnectionRow` and `SidebarRow` factories, and the history, saved-queries and CSV import dialogs. New views are subclasses. This page covers where state lives, the rules every view follows, and the patterns for each kind. -## Windows and the workspace +## Where state lives -`SessionWindow` and `Workspace` are subclasses with a CompositeTemplate `.ui` file in the GResource, and each owns an input queue from `runtime::input_queue`: +| State | Owner | Lives as long as | +|---|---|---| +| Driver registry, connector, deadlines, settings, storage, `Tasks`, `SessionRegistry`, query history, the open-sessions record | `AppServices`, built by `run()` and handed to every window | the process | +| The claim on a session's key | the window's `SessionPage` | the page | +| The connection | `Session`, shared by the window's page and its workspace | the page, until the teardown closes it | +| Schemas, objects and columns | the session's `SchemaCatalog` | the session | +| Tabs, pending edits, running saves | the `Workspace`: `ChangeTrackerRegistry`, `StructureTrackerRegistry`, `WorkspaceHandle` with its `SaveLedger` | the workspace | +| A tab's rows, filter and requests | the tab's model, in its `imp` struct | the tab | -- Actions, signal handlers and background work send it a message, and `pump_inputs` hands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. -- Background work runs on the app's `Tasks` and answers through the queue with `DeliverInput::deliver`, which drops the answer once the widget has shut down. -- Shutdown aborts the pump, which closes the queue. A message still queued that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (`pump_inputs_with_leftovers`) and is closed rather than dropped. -- Window actions go on the window's own action map with `add_action_entries`; the workspace removes the ones it added when it shuts down. -- Work the user expects to finish, such as a save, runs through `runtime::spawn_held`, which holds the process until it ends. +Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `crates/app/src/services`, since a registry shared that way would show one window's unsaved edits in every other window. -The rest of this page applies to the Relm4 components. +## Rules for every view -## When to use which component flavour +**Background work goes through `Tasks`.** Use `crate::runtime::spawn_and_deliver` to run work on the app runtime and get its result on the GTK thread, and `spawn_held` for work the user expects to finish, such as a save. `crates/app/clippy.toml` bans `tokio::spawn`, `Handle::current`, `relm4::spawn` and their siblings, so nothing reaches for whichever runtime happens to be ambient. -| Flavour | Use when | -|---|---| -| `Component` | Synchronous init, no async work in `update`. Default choice. | -| `AsyncComponent` | Loading data on init or in update is the core of the component. Connection list, table content viewer. | -| `SimpleComponent` | A leaf widget with no `Output` to its parent. Avoid; very few cases. | -| `Factory` (`FactoryComponent`) | A homogeneous list of children driven by a model. Connection sidebar entries, tab strip items. | -| `Worker` | Background unit that does not own widgets. Use for the driver registry interaction; receives requests, returns results. | +**Input goes through `DeliverInput::deliver`.** It works on an input queue's sender, a `relm4::Sender`, a `ComponentSender` and a `FactorySender`. A message to a view that has shut down is dropped and logged at debug under the `component` target, so a read or a save that comes back after its window closed changes nothing. `ComponentSender::input` and `FactorySender::input` are banned: relm4 0.11 calls `expect` on a closed channel, which would take every window down. Use `try_deliver` when the caller must act on a dropped message, as `QuitSequence` does when a window is already gone. -If you find yourself reaching for a static `Mutex`, you are not using the framework. Stop and re-read the model. +**A watch loop is a `WatchForwarder`.** The session publishes its health (`Session::health`), its link generation (`link_changes`) and each scope's objects (`subscribe_objects`) on `tokio::sync::watch` channels. A view keeps a `WatchForwarder` in a field: it delivers the current value, then every change, on the GTK thread, and stops when the field drops. A value published after the view went away is never delivered. -## Component skeleton +**A view holds the session, never a connection.** Every read and write goes through the session (`Session::run`, `run_cancellable`, the catalog reads), which leases the current link through its `OperationRunner` and carries the deadline to the driver. A view asks the session for the dialect (`current_dialect`) and asks again when the link changes, since a reconnect can reach another server version. A save plans with the dialect of the connection that runs it. -```rust -use relm4::{Component, ComponentParts, ComponentSender}; - -pub struct ConnectionListModel { - connections: Vec, - selected: Option, -} - -#[derive(Debug)] -pub enum ConnectionListInput { - Select(ConnectionId), - Connect(ConnectionId), - Delete(ConnectionId), - Reload, -} - -#[derive(Debug)] -pub enum ConnectionListOutput { - OpenConnection(SavedConnection), -} - -#[derive(Debug)] -pub enum ConnectionListCmd { - Reloaded(Vec), -} - -impl Component for ConnectionListModel { - type Init = (); - type Input = ConnectionListInput; - type Output = ConnectionListOutput; - type CommandOutput = ConnectionListCmd; - type Root = gtk::Box; - type Widgets = ConnectionListWidgets; - - fn init(_: Self::Init, root: Self::Root, sender: ComponentSender) -> ComponentParts { - // Build widgets, attach handlers, return ComponentParts - } +**One request of each kind is current.** A browse or structure tab keeps a `RequestSequencer` keyed by request kind. `issue` returns a `RequestTicket` with a stamp and a cancel token, and stops the request of that kind still running, so the server drops the statement. `accept` takes only the reply to the current request; a reply to a replaced one is dropped. Dropping the sequencer stops every request, which is how closing a tab stops its reads. - fn update(&mut self, msg: Self::Input, sender: ComponentSender, _: &Self::Root) { - match msg { - ConnectionListInput::Select(id) => self.selected = Some(id), - ConnectionListInput::Reload => sender.command(|out, shutdown| { - shutdown.register(async move { - let conns = storage::load_connections().await.unwrap_or_default(); - out.send(ConnectionListCmd::Reloaded(conns)).ok(); - }).drop_on_shutdown() - }), - // ... - } - } +**A failed read is asked again on the next link.** `FailedRequests` holds the kinds whose last accepted reply failed. When `link_changes` reports a newer `LinkGeneration`, the tab asks those again on the new connection; a reply that failed after the link had already moved is asked again at once. A tab whose reads all landed keeps its rows. - fn update_cmd(&mut self, msg: Self::CommandOutput, _: ComponentSender, _: &Self::Root) { - match msg { - ConnectionListCmd::Reloaded(conns) => self.connections = conns, - } - } -} -``` +**The catalog belongs to the session.** `SchemaCatalog` (`crates/session/src/session/schema_catalog.rs`) holds the schemas, each scope's objects and each table's columns. Views read through `Session::schemas`, `objects` and `columns`, or `refresh_objects` and `refresh_columns` to fetch again. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A schema change calls `invalidate_table`, which marks the table stale and keeps its data until the next fetch lands. + +**Saves go through the workspace.** `WorkspaceHandle` starts, stops and settles every save. Its `SaveLedger` records each running save with its tab, its kind, a cancel token and a process hold, and refuses a second save of the same kind for the same tab. Stop Saving, or a close that chose to stop, cancels the token, which reaches the driver through `run_cancellable`; the save settles when the driver answers. -Naming: +**Closing asks through the window's prompts.** `CloseGuards` runs one `CloseGuard` at a time per window, so a tab's close and the window's never ask about the same tab at once. A guard reads the workspace again after every answer and every settled save: it offers to save, waits for or stops running saves, asks about a save whose outcome is unknown, and confirms open transactions. A disconnect asks the same prompts before the teardown. + +## Windows and the workspace -- Model: `Model`. Holds private state. -- Input: `Input`, an enum. Every UI interaction is a message. -- Output: `Output`, an enum. Only the messages a parent should react to. -- Command output: `Cmd`, an enum. Async work finishes by sending one of these back. +`SessionWindow` and `Workspace` each own an input queue from `runtime::input_queue`: -## Async work via commands +- Actions, signal handlers and background work send it a message, and `pump_inputs` hands the messages to the widget one at a time on the GTK thread, never inside the signal or action that sent one. +- Shutdown aborts the pump, which closes the queue. A queued message that owns something, such as a connection that finished connecting, goes to the pump's leftover handler (`pump_inputs_with_leftovers`) and is closed rather than dropped. +- Window actions go on the window's own action map with `add_action_entries`, which GTK exports as `win`; CI fails on a group inserted as `win`. The workspace removes the actions it added when it shuts down. +- The workspace reports to the window through signals (`title-changed`, `dirty-changed`, `saves-in-flight-changed`), and the window turns each into one of its own inputs. -Components do not call `tokio::spawn` directly. They issue commands: +A background read follows this shape, trimmed from `SessionWindow::on_restore`, which looks up the saved connection `id` names: ```rust -sender.command(|out, shutdown| { - shutdown - .register(async move { - let result = some_async_work().await; - out.send(MyCmd::Done(result)).ok(); - }) - .drop_on_shutdown() -}) +let (Some(state), Some(input)) = (self.state(), self.input()) else { + return; +}; +let store = state.services.storage.connections().clone(); +let reading = state.services.tasks.spawn_blocking_task(move || store.load_blocking()); +glib::spawn_future_local(async move { + if let Ok(Ok(connections)) = reading.await { + let saved = connections.iter().find(|saved| saved.id == id).cloned().map(Box::new); + input.deliver(SessionWindowInput::RestoreSaved { id, saved }); + } +}); ``` -The command runs on the runtime owned by `app::runtime`. It is automatically cancelled when the component is destroyed. **Always use `drop_on_shutdown`** unless the work is critical to complete (rare; saving user data is the main case). +The closure holds only the sender, so a window that closed before the read ends is not kept alive, and the answer is dropped. -## Talking to drivers +## Tabs -Driver interaction is centralised in `app::services::DatabaseService`. Components never call `core::DriverRegistry` directly. They send a request to the service worker and receive a typed reply. +A browse or structure tab is a subclass whose model sits in its `imp` struct. `send` hands the model one input and handles it at once; an input a widget signal sets off while another is being handled waits in the tab's queue until that one finishes. The model's update returns effects: more inputs for the queue, and outputs the tab emits as signals. The workspace connects those signals, tags them with the tab's id and queues them as `WorkspaceInput`, so the tabs never call each other. -```rust -let req = DatabaseRequest::FetchRows { - connection_id, - table: "users".into(), - offset: 0, - limit: 1000, -}; -db_service.send(req); -// In update_cmd: -DatabaseReply::Rows { rows, .. } => { /* update model */ } -``` +## Relm4 components -Why centralise: connection lifecycles, retries, health pings, cancellation, logging are concerns the UI must not see. +The components that remain follow these conventions until a rewrite turns them into subclasses: -## State that does not belong in a component +- Input reaches them through `deliver`, and their output reaches the subclass that hosts them through `connect_receiver` or `runtime::relm4_bridge`, which put it on that subclass's queue. +- Their commands (`sender.command`, `oneshot_command`) run on Relm4's own runtime, not on `Tasks`. The history and saved-queries dialogs still load that way; new work goes through `spawn_and_deliver`. +- They present their dialogs on the window they belong to. `ComponentBuilder::transient_for` is banned, because Relm4 applies it only from `RelmApp`, which this app does not use. -Some state is genuinely global: open connections, the driver registry, app settings. We model these as `Worker` components or as `Arc>` owned by `app::main` and passed to component `init` payloads. Never reach for global statics other than the tokio runtime handle and the application identifier. +## Naming -## Anti-patterns to flag in review +- `Input`: every message a view handles, as an enum (`SessionWindowInput`, `WorkspaceInput`, `BrowseTabInput`). +- `Output`: only the messages a parent should react to. +- `Cmd`: a Relm4 command's result. +- `Init`: what the parent hands a view when it builds it (`SessionWindowInit`, `WorkspaceInit`). + +## Alert dialogs -- `gtk::glib::clone!` capturing `&mut` references to model fields. Use `ComponentSender` and route via `Input`. -- Async work spawned with raw `tokio::spawn` from inside a component. Use `sender.command`. -- Components reading from a `Mutex`. Pass state in via `Init` or via parent → child `Input`. -- A `Component` doing async work in its `init`. Promote to `AsyncComponent`. -- One enormous `Input` enum with 30 variants. Split the component. +- `AdwAlertDialog` closes itself before it emits `response`. A response handler that calls `close()` again finds the dialog gone, and libadwaita logs a critical when animations are off. +- To wait for an answer, use `workspace::respond`, which also answers when the dialog goes without a response. `choose_future` is not used: its callback aborts the process in that case. + +## Anti-patterns to flag in review -## Testing components +- `tokio::spawn`, `relm4::spawn` or `Handle::current` in app code. Use `Tasks`. +- `ComponentSender::input` or `FactorySender::input`. Use `deliver`. +- A hand-written loop over a watch channel. Use `WatchForwarder`. +- A view that keeps a `Connection`, or a dialect past a link change. +- State in a `thread_local!` or a static. It belongs to the workspace or to `AppServices`. +- A `glib::clone!` that captures a view strongly in a callback the view itself holds. Capture its input sender or a weak reference. +- Blocking work on the GTK thread: `std::fs`, a `_blocking` call outside `spawn_blocking_task`, or `MainContext::block_on`. -Relm4 ships test helpers but they require a running GTK main loop, which is awkward in CI. Our policy: +## Testing -- Test pure logic by extracting it into plain Rust functions or a separate `services` module. Test those. -- Do not write component-level tests until we hit a bug that they would have caught. -- Prefer integration tests at the driver layer and unit tests at the model layer. +[testing.md](testing.md#app--ui-tests) covers widget tests, the test windows and the application tests. diff --git a/linux/docs/testing.md b/linux/docs/testing.md index fb002a2513..4e051dfdd4 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -219,9 +219,11 @@ GTK code is tested with `#[gtk4::test]`, which runs each test on a single GTK th Helpers live in `crates/app/src/test_support`, compiled only for tests: -- `drain_main_context()` runs pending main-loop sources. `wait_until(timeout, condition)` iterates the main loop until the condition holds or the timeout passes, so a missing signal fails the test instead of hanging it. +- `drain_main_context()` runs pending main-loop sources. `wait_until(timeout, condition)` iterates the main loop until the condition holds or the timeout passes, so a missing signal fails the test instead of hanging it. `finish_on_main(timeout, future)` runs a future on the main context and gives it up at the timeout. - `SignalLog::connect(object, signal, extract)` records emissions and disconnects when dropped. - `descendants`, `find_by_action_name` and `first_descendant_of_type` walk a widget tree. +- `MemorySettings` compiles the schema into a `TempDir` behind the memory backend, so a test never touches the developer's dconf database. +- `FakeSession` is a session over a fake connection; `FakeClocks` and `FakeProbe` stand in for the suspend clocks and the reachability check. - `assert_labelled(widget)` checks that a screen reader can name a control: - an `adw::PreferencesRow` needs a non-empty title; - an `adw::EntryRow` or `adw::PasswordEntryRow` must keep its inner text field labelled by that title; @@ -230,16 +232,68 @@ Helpers live in `crates/app/src/test_support`, compiled only for tests: CI sets `GTK_A11Y=test` so results never depend on an AT-SPI bus. With `GTK_A11Y=none`, GTK records no accessible properties and `assert_labelled` returns `TestBackendMissing` instead of a wrong verdict. +Two harnesses build the app's real objects over fakes: + +- `TestWindow` (`ui/session_window/tests.rs`) starts a `TableProApplication` with one window on the picker, a saved connection in its list and a `FakeConnector`. `TestWindow::connected` opens that connection and waits for its workspace. Each test registers an application id of its own, so it never meets another test, or a TablePro the developer has open, on the session bus. `test.hold` is the application's `CountingHold`. +- `workspace::test_workspace::Workspace` builds a `WorkspaceHandle` with one tab over a scripted connection, for the save and close prompt tests. + +Both turn GTK animations off, so a dialog or a page change finishes before the next line runs. + +### Background work and time + +A `#[gtk4::test]` has no tokio runtime. `test_runtime()` builds an `AppRuntime` the test owns, and its `tasks()` go to the code under test. A `#[tokio::test]` gets the `Tasks` of its own runtime from `paused_tasks()`, and with `start_paused = true` tokio's clock stands still and jumps to the next timer whenever every task is waiting, so a test of a deadline, a backoff or a debounce runs in no time and never flakes on a slow runner. The session crate's tests use the same pattern for the health monitor, the catalog and the operation runner. + +### Holds + +`CountingHold` (`tablepro_session::runtime`) is the test `ProcessHold`: it counts live tokens. A test that work holds the process checks that `live()` is 1 while the work runs and 0 once it ends, and that the work's effect is in place before the count drops. + +### Dialogs + +- `workspace::answer(window, response)` clicks the button of the alert `window` shows, so the alert closes itself and emits its response in the order a user's click does. `workspace::visible_alert(window)` returns that alert. +- `test_support::respond(dialog, response)` emits a response on a dialog the test holds, then force-closes it. + +A test that a step logs no GLib critical turns criticals fatal around that step and restores the old mask after: + +```rust +let previous = glib::log_set_always_fatal(glib::LogLevels::LEVEL_CRITICAL); +let answered = crate::workspace::answer(&test.window, "reset"); +crate::test_support::drain_main_context(); +glib::log_set_always_fatal(previous); +``` + +A critical then aborts the test process, and the runner reports the test as failed with the message, for example libadwaita's "Trying to close AdwAlertDialog ... that's not presented". + +### Fakes + +`tablepro-test-support` holds the driver contract's fakes, which every crate's tests share: `FakeDriver`, `FakeConnection` scripted through `ConnectionScript`, `FakeEditorSession` with `FakeRun`, `FakeSecretVault`, `FakePrompter` and `FaultTransport`. A `FakeReply` answers at once, after a delay unless the deadline or cancel comes first, late whatever the caller asked, only once stopped, or never. `CallCounters` counts the calls a connection received after it was handed over. `tablepro_session::fakes` adds `FakeConnector` and `FakeCredentialSource` behind the session crate's `fakes` feature, which only the app's dev-dependencies turn on. + +### Application tests + +The tests in `crates/app/tests` that start TablePro run without the test harness (`harness = false` in `crates/app/Cargo.toml`): GTK allows one application per process, on its main thread. Each binary has its own `main` and runs TablePro in child processes. The ones that talk to it over D-Bus give it a private session bus through `gio::TestDBus`, so a TablePro the developer has open never answers. + +| Test | What it checks | +|---|---| +| `application_uniqueness` | a second launch hands its command line to the running instance and exits without opening a window, and `--new-window` opens one there | +| `application_lifecycle` | Quit waits for a session's close within the close timeout and gives up on a close that hangs, a cancelled close keeps the app running, and an editor session's close holds the process | +| `application_session_restore` | the windows open at quit open again at the next launch, and a window closed first does not | +| `open_files` | a file handed over D-Bus opens a window on it, the same file brings that window forward, and another file opens a second window | +| `startup`, `i18n_catalog`, `logging` | the command-line options, a missing schema, the translation catalogue and the log setup | + +They need a display and a session bus, so run them the way CI does: + +```bash +xvfb-run -a dbus-run-session -- meson test -C _build --suite gtk --print-errorlogs +``` + +or one at a time, `xvfb-run -a dbus-run-session -- meson devenv -C _build cargo test -p tablepro --test application_lifecycle`. `startup` needs the build Meson configures, so it fails under plain Cargo. cargo-nextest asks each test binary to list its tests, which a harness-free binary does not do, so run these with `cargo test`. + Policy: - Pure logic: extract to `app::services::` and write plain unit tests. - Widgets the app builds: a `#[gtk4::test]` that constructs the widget, drives it through its actions or signals, checks the result, and runs `assert_labelled` on the controls a user reaches. +- A flow across windows or launches: an application test. - UI changes still carry before and after screenshots in the PR description. -## End-to-end test - -There is no app-level end-to-end test yet. Driving the GTK app under `xvfb-run` through its registered `gtk::Application` actions is the intended shape when we add one. - ## CI GitHub Actions (`.github/workflows/build-linux.yml`), Ubuntu runner: @@ -255,6 +309,6 @@ Tracked with `cargo-llvm-cov` once the codebase has substance. No hard coverage ## Mocking -Avoid mock objects. We do not mock drivers, the filesystem, or `tokio::time`. Either use a real implementation (testcontainers, `tempfile`, `tokio::time::pause`) or extract the logic to a pure function and test that. +Avoid mock objects. We do not mock drivers, the filesystem, or `tokio::time`. Either use a real implementation (testcontainers, `tempfile`, `tokio::time::pause`) or extract the logic to a pure function and test that. The fakes in `tablepro-test-support` are not mocks: they implement the driver contract and keep its rules, so the code under test meets what a conforming driver does, and each real driver runs the conformance suites against its own server. If a test cannot be written without a mock, the design is wrong. Refactor before writing the mock. From 99fa3a7aa11df54e4b62870686877b83fa32cb5f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:18:35 +0700 Subject: [PATCH 106/301] feat(linux): classify SQL that carries credentials for history --- linux/crates/core/src/dialect/sql_dialect.rs | 8 +- linux/crates/core/src/dialect/test_dialect.rs | 14 +- .../core/src/sql_syntax/credential_rules.rs | 137 ++++++++++ .../core/src/sql_syntax/history_recording.rs | 250 ++++++++++++++++++ linux/crates/core/src/sql_syntax/mod.rs | 5 + .../core/src/sql_syntax/statement_tokens.rs | 161 +++++++++++ .../crates/drivers/clickhouse/src/dialect.rs | 85 +++++- linux/crates/drivers/mssql/src/dialect.rs | 79 +++++- linux/crates/drivers/mysql/src/dialect.rs | 56 +++- linux/crates/drivers/postgres/src/dialect.rs | 55 +++- linux/crates/drivers/sqlite/src/dialect.rs | 44 ++- linux/crates/test-support/src/fake_dialect.rs | 20 +- .../crates/test-support/src/history_cases.rs | 46 ++++ linux/crates/test-support/src/lib.rs | 1 + linux/docs/adding-drivers.md | 2 + 15 files changed, 955 insertions(+), 8 deletions(-) create mode 100644 linux/crates/core/src/sql_syntax/credential_rules.rs create mode 100644 linux/crates/core/src/sql_syntax/history_recording.rs create mode 100644 linux/crates/core/src/sql_syntax/statement_tokens.rs create mode 100644 linux/crates/test-support/src/history_cases.rs diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index fccf61160f..1bfb37ccdc 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -1,7 +1,7 @@ use crate::column::{ColumnInfo, ColumnType, SqlExpression}; use crate::ddl::DdlDialect; use crate::meta::{EngineRowId, EngineRowIdPart, TableRef}; -use crate::sql_syntax::{ExpressionSyntaxError, SqlGrammar, TypeSyntaxError}; +use crate::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError}; use crate::value::Value; use super::{ @@ -70,6 +70,12 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { fn parse_expression(&self, text: &str) -> Result; + /// Whether SQL the user ran may be kept in the query history. Each + /// engine names the statements that can hold a password or a + /// connection secret (`classify_for_history` with its own + /// `CredentialRules`), and those are never recorded. + fn history_recording(&self, sql: &str) -> HistoryRecording; + /// The engine's structure-change spelling, or `None` where the app /// does not edit this engine's structure. fn ddl(&self) -> Option<&dyn DdlDialect> { diff --git a/linux/crates/core/src/dialect/test_dialect.rs b/linux/crates/core/src/dialect/test_dialect.rs index 15cd33942e..3044c014e2 100644 --- a/linux/crates/core/src/dialect/test_dialect.rs +++ b/linux/crates/core/src/dialect/test_dialect.rs @@ -2,7 +2,10 @@ use std::sync::Mutex; use crate::column::{ColumnInfo, ColumnType, SqlExpression, SqlTypeExpr}; use crate::meta::{EngineRowId, EngineRowIdPart}; -use crate::sql_syntax::{ExpressionSyntaxError, SqlGrammar, TypeSyntaxError, parse_expression, parse_type}; +use crate::sql_syntax::{ + CredentialRules, ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError, classify_for_history, + parse_expression, parse_type, +}; use crate::statement::{BoundParam, ParamType}; use crate::value::Value; @@ -250,6 +253,15 @@ impl SqlDialect for TestDialect { let parsed = parse_expression(self.grammar, text, crate::sql_syntax::MAX_EXPRESSION_LEN)?; Ok(SqlExpression::from_catalog_text(parsed.text)) } + + fn history_recording(&self, sql: &str) -> HistoryRecording { + const RULES: CredentialRules = CredentialRules { + statement_prefixes: &[&["CREATE", "USER"], &["ALTER", "USER"]], + conditional_prefixes: &[], + carriers: &[], + }; + classify_for_history(self.grammar, &RULES, sql) + } } fn row_id_type(part: EngineRowIdPart) -> &'static str { diff --git a/linux/crates/core/src/sql_syntax/credential_rules.rs b/linux/crates/core/src/sql_syntax/credential_rules.rs new file mode 100644 index 0000000000..f28e453440 --- /dev/null +++ b/linux/crates/core/src/sql_syntax/credential_rules.rs @@ -0,0 +1,137 @@ +use super::statement_tokens::StatementToken; + +/// What marks a statement as one whose text can hold a password or a +/// connection secret, for one engine. Each driver declares its own next +/// to its dialect. Words compare without regard to case. +/// +/// Prefixes are looked for at every word, not only where a statement +/// starts: a T-SQL batch needs no semicolon between its statements, and +/// a routine body holds whole statements of its own. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CredentialRules { + /// Unquoted words that begin such a statement, such as `CREATE USER`. + /// An `OR REPLACE` straight after the first word is skipped. + pub statement_prefixes: &'static [&'static [&'static str]], + /// A prefix that counts only when one of the words with it follows + /// in the same statement, such as `GRANT` with `IDENTIFIED`. + pub conditional_prefixes: &'static [(&'static [&'static str], &'static [&'static str])], + /// Functions, procedures and table engines whose arguments can hold + /// a login or a connection string. + pub carriers: &'static [&'static str], +} + +/// The word before a name being declared or written to, where a carrier +/// name followed by a parenthesis is a table and its column list, not a +/// call: `CREATE TABLE s3 (id int)`. +const NAME_POSITIONS: [&str; 6] = ["TABLE", "INTO", "VIEW", "REFERENCES", "EXISTS", "ON"]; + +impl CredentialRules { + pub(crate) fn matches(&self, tokens: &[StatementToken<'_>]) -> bool { + (0..tokens.len()) + .any(|at| self.prefix_at(tokens, at) || self.conditional_at(tokens, at) || self.carrier_at(tokens, at)) + } + + fn prefix_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { + self.statement_prefixes + .iter() + .any(|prefix| words_at(tokens, at, prefix).is_some()) + } + + fn conditional_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { + self.conditional_prefixes.iter().any(|(prefix, triggers)| { + words_at(tokens, at, prefix).is_some_and(|end| { + tokens.get(end..).unwrap_or_default().iter().any(|token| { + token + .name() + .is_some_and(|name| triggers.iter().any(|trigger| trigger.eq_ignore_ascii_case(name))) + }) + }) + }) + } + + fn carrier_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { + let Some(name) = tokens.get(at).and_then(StatementToken::name) else { + return false; + }; + if !self.carriers.iter().any(|carrier| carrier.eq_ignore_ascii_case(name)) { + return false; + } + let next = tokens.get(at + 1); + if next.is_some_and(|token| token.is_punct('.')) { + return false; + } + let start = qualified_name_start(tokens, at); + if next == Some(&StatementToken::OpenParen) && !names_an_object(tokens, start) { + return true; + } + // T-SQL runs the first statement of a batch as a procedure call + // without EXEC. + start == 0 || follows_exec(tokens, start) + } +} + +/// Where `words` match at `at`, the index just past them. +fn words_at(tokens: &[StatementToken<'_>], at: usize, words: &[&str]) -> Option { + let (first, rest) = words.split_first()?; + if !tokens.get(at)?.is_word(first) { + return None; + } + let mut next = at + 1; + let or_replace = tokens.get(next).is_some_and(|token| token.is_word("OR")) + && tokens.get(next + 1).is_some_and(|token| token.is_word("REPLACE")); + if or_replace { + next += 2; + } + for word in rest { + if !tokens.get(next)?.is_word(word) { + return None; + } + next += 1; + } + Some(next) +} + +/// The first part of the dotted name that ends at `at`, so `master..sp_addlogin` +/// and `public.dblink(` are read as one name. +fn qualified_name_start(tokens: &[StatementToken<'_>], at: usize) -> usize { + let mut start = at; + loop { + let mut dots = start; + while dots > 0 && tokens.get(dots - 1).is_some_and(|token| token.is_punct('.')) { + dots -= 1; + } + let qualifier = dots.checked_sub(1).filter(|_| dots < start); + match qualifier.and_then(|index| tokens.get(index)?.name().map(|_| index)) { + Some(index) => start = index, + None => return start, + } + } +} + +fn names_an_object(tokens: &[StatementToken<'_>], start: usize) -> bool { + let Some(before) = start.checked_sub(1).and_then(|index| tokens.get(index)) else { + return false; + }; + if !NAME_POSITIONS.iter().any(|word| before.is_word(word)) { + return false; + } + // ClickHouse describes a table function's result: DESCRIBE TABLE s3(...). + let described = start + .checked_sub(2) + .and_then(|index| tokens.get(index)) + .is_some_and(|token| token.is_word("DESCRIBE") || token.is_word("DESC")); + !(before.is_word("TABLE") && described) +} + +/// `EXEC name`, or `EXEC @status = name`. +fn follows_exec(tokens: &[StatementToken<'_>], start: usize) -> bool { + let token_before = |back: usize| start.checked_sub(back).and_then(|index| tokens.get(index)); + let is_exec = |token: Option<&StatementToken<'_>>| { + token.is_some_and(|token| token.is_word("EXEC") || token.is_word("EXECUTE")) + }; + if is_exec(token_before(1)) { + return true; + } + let status_variable = matches!(token_before(2), Some(StatementToken::Word(word)) if word.starts_with('@')); + token_before(1).is_some_and(|token| token.is_punct('=')) && status_variable && is_exec(token_before(3)) +} diff --git a/linux/crates/core/src/sql_syntax/history_recording.rs b/linux/crates/core/src/sql_syntax/history_recording.rs new file mode 100644 index 0000000000..0a8286b46a --- /dev/null +++ b/linux/crates/core/src/sql_syntax/history_recording.rs @@ -0,0 +1,250 @@ +use super::SqlGrammar; +use super::credential_rules::CredentialRules; +use super::statement_tokens::{StatementToken, lex_statements}; + +/// Whether what the user ran may go into the query history. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryRecording { + Record, + Withhold(WithholdReason), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WithholdReason { + /// A statement sets a password, a key or a link to another server. + CredentialStatement, + /// A string, quoted name or comment never closes, so where the + /// statements are, and what is inside a literal, is a guess. + Untokenizable, +} + +/// Dollar-quoted bodies are read for the statements inside them this +/// many levels deep: a DO block in a function body is as far as real +/// scripts nest. +const MAX_BODY_DEPTH: u8 = 4; + +/// Whether `sql` may be kept, split the way the editor splits it for +/// `grammar`. The text is never rewritten: an entry is kept whole or not +/// at all, so History can neither show nor run a statement with a secret +/// cut out of it. +pub fn classify_for_history(grammar: SqlGrammar, rules: &CredentialRules, sql: &str) -> HistoryRecording { + let script = lex_statements(sql, grammar); + if script.unterminated { + return HistoryRecording::Withhold(WithholdReason::Untokenizable); + } + match carries_credentials(grammar, rules, &script.statements, 0) { + true => HistoryRecording::Withhold(WithholdReason::CredentialStatement), + false => HistoryRecording::Record, + } +} + +fn carries_credentials( + grammar: SqlGrammar, + rules: &CredentialRules, + statements: &[Vec>], + depth: u8, +) -> bool { + statements.iter().any(|tokens| { + rules.matches(tokens) + || tokens.iter().any(|token| match token { + // A body that does not lex cleanly is text in a literal, + // not a routine, so only the statements it does hold count. + StatementToken::DollarBody(body) if depth < MAX_BODY_DEPTH => { + carries_credentials(grammar, rules, &lex_statements(body, grammar).statements, depth + 1) + } + _ => false, + }) + }) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + const RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "NAMED", "COLLECTION"], + ], + conditional_prefixes: &[(&["GRANT"], &["IDENTIFIED"])], + carriers: &["dblink_connect", "sp_addlogin", "s3"], + }; + + const WITHHELD: HistoryRecording = HistoryRecording::Withhold(WithholdReason::CredentialStatement); + + fn classify(grammar: SqlGrammar, sql: &str) -> HistoryRecording { + classify_for_history(grammar, &RULES, sql) + } + + #[test] + fn plain_statements_are_recorded_in_every_grammar() { + let plain = [ + "SELECT id FROM users WHERE password IS NULL AND email = 'a@b'", + "UPDATE accounts SET secret = NULL WHERE name = 'x'", + "DELETE FROM creds WHERE password = 'p'", + "SELECT 'password' AS label", + "CREATE TABLE s3 (id int)", + "SELECT s3 FROM t", + "-- only a note\n/* and another */", + "", + ]; + for grammar in SqlGrammar::ALL { + for sql in plain { + assert_eq!(classify(grammar, sql), HistoryRecording::Record, "{grammar:?}: {sql}"); + } + } + } + + #[test] + fn one_credential_statement_withholds_the_whole_script() { + assert_eq!( + classify(SqlGrammar::PostgreSql, "SELECT 1; CREATE USER u PASSWORD 'p'"), + WITHHELD + ); + } + + #[test] + fn or_replace_does_not_hide_a_prefix() { + assert_eq!( + classify(SqlGrammar::ClickHouse, "CREATE OR REPLACE USER u IDENTIFIED BY 'p'"), + WITHHELD + ); + } + + #[test] + fn an_open_literal_or_comment_cannot_be_read() { + let untokenizable = HistoryRecording::Withhold(WithholdReason::Untokenizable); + + assert_eq!(classify(SqlGrammar::PostgreSql, "SELECT 'abc"), untokenizable); + assert_eq!(classify(SqlGrammar::MySql, "SELECT 1 /* note"), untokenizable); + assert_eq!(classify(SqlGrammar::MsSql, "SELECT [abc"), untokenizable); + } + + #[test] + fn words_in_strings_comments_and_names_do_not_count() { + for sql in [ + "SELECT 'CREATE USER u' AS text", + "SELECT 1 -- CREATE USER u\n", + "SELECT create_user FROM t", + "SELECT \"CREATE\", \"USER\" FROM t", + "SELECT 'sp_addlogin x'", + ] { + assert_eq!(classify(SqlGrammar::PostgreSql, sql), HistoryRecording::Record, "{sql}"); + } + } + + #[test] + fn a_conditional_prefix_needs_its_word_in_the_same_statement() { + assert_eq!( + classify(SqlGrammar::MySql, "GRANT SELECT ON t TO u"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::MySql, "GRANT SELECT ON t TO u; SELECT identified FROM t"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::MySql, "GRANT ALL ON *.* TO u IDENTIFIED BY 'p'"), + WITHHELD + ); + } + + #[test] + fn a_prefix_counts_anywhere_in_a_batch() { + assert_eq!( + classify(SqlGrammar::MsSql, "SELECT 1\nCREATE USER u WITH PASSWORD = 'p'"), + WITHHELD + ); + assert_eq!( + classify( + SqlGrammar::MySql, + "DELIMITER //\nCREATE PROCEDURE p() BEGIN CREATE USER u IDENTIFIED BY 'p'; END //" + ), + WITHHELD + ); + } + + #[test] + fn a_dollar_quoted_body_is_read_for_statements() { + assert_eq!( + classify( + SqlGrammar::PostgreSql, + "DO $$ BEGIN IF NOT EXISTS (SELECT 1) THEN CREATE USER app PASSWORD 'p'; END IF; END $$" + ), + WITHHELD + ); + assert_eq!( + classify(SqlGrammar::PostgreSql, "SELECT $$it's a note$$"), + HistoryRecording::Record + ); + } + + #[test] + fn a_carrier_counts_as_a_call() { + for (grammar, sql) in [ + (SqlGrammar::PostgreSql, "SELECT dblink_connect('password=x')"), + (SqlGrammar::PostgreSql, "SELECT public.dblink_connect('password=x')"), + (SqlGrammar::PostgreSql, "SELECT \"dblink_connect\"('password=x')"), + (SqlGrammar::ClickHouse, "SELECT * FROM s3('url', 'key', 'secret')"), + ( + SqlGrammar::ClickHouse, + "INSERT INTO FUNCTION s3('url', 'key', 'secret') VALUES (1)", + ), + (SqlGrammar::ClickHouse, "DESCRIBE TABLE s3('url', 'key', 'secret')"), + ( + SqlGrammar::ClickHouse, + "CREATE TABLE t (id Int32) ENGINE = S3('url', 'key', 'secret')", + ), + (SqlGrammar::MsSql, "sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXEC sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXECUTE master.dbo.sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXEC master..sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "EXEC [dbo].[sp_addlogin] 'l', 'p'"), + (SqlGrammar::MsSql, "DECLARE @rc int\nEXEC @rc = sp_addlogin 'l', 'p'"), + (SqlGrammar::MsSql, "SELECT 1\nGO\nsp_addlogin 'l', 'p'"), + ] { + assert_eq!(classify(grammar, sql), WITHHELD, "{sql}"); + } + } + + #[test] + fn a_carrier_name_that_names_a_table_is_not_a_call() { + for sql in [ + "CREATE TABLE s3 (id Int32) ENGINE = Memory", + "CREATE TABLE IF NOT EXISTS db.s3 (id Int32) ENGINE = Memory", + "INSERT INTO s3 (id) VALUES (1)", + "CREATE VIEW s3 (id) AS SELECT 1", + "SELECT s3.id FROM s3", + "SELECT 1 AS s3", + ] { + assert_eq!(classify(SqlGrammar::ClickHouse, sql), HistoryRecording::Record, "{sql}"); + } + } + + #[test] + fn a_procedure_mid_batch_needs_exec() { + assert_eq!( + classify(SqlGrammar::MsSql, "SELECT 1\nPRINT sp_addlogin"), + HistoryRecording::Record + ); + } + + proptest! { + #[test] + fn any_text_classifies_without_panicking(sql in "\\PC{0,120}", grammar in 0usize..5) { + let grammar = SqlGrammar::ALL[grammar]; + let _ = classify(grammar, &sql); + } + + #[test] + fn a_credential_statement_is_withheld_whatever_runs_before_it( + before in "[A-Za-z0-9_ ,=()]{0,60}", + ) { + let sql = format!("SELECT {before};\nCREATE USER u PASSWORD 'p'"); + prop_assert_eq!(classify(SqlGrammar::PostgreSql, &sql), WITHHELD); + } + } +} diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index a3ebc84d12..4a5a85d810 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -1,14 +1,19 @@ +mod credential_rules; mod errors; mod grammar; +mod history_recording; mod keywords; mod literal_token; mod parse_expression; mod parse_type; mod reserved_words; pub mod script; +mod statement_tokens; +pub use credential_rules::CredentialRules; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; pub use grammar::SqlGrammar; +pub use history_recording::{HistoryRecording, WithholdReason, classify_for_history}; pub use literal_token::LiteralToken; pub use parse_expression::{MAX_EXPRESSION_LEN, ValidatedExpression, parse_expression}; pub use parse_type::{MAX_TYPE_LEN, TypeShape, ValidatedType, parse_type}; diff --git a/linux/crates/core/src/sql_syntax/statement_tokens.rs b/linux/crates/core/src/sql_syntax/statement_tokens.rs new file mode 100644 index 0000000000..287a05ba6d --- /dev/null +++ b/linux/crates/core/src/sql_syntax/statement_tokens.rs @@ -0,0 +1,161 @@ +use std::ops::Range; + +use crate::sql_syntax::SqlGrammar; +use crate::sql_syntax::script::{LexicalSettings, ScriptPlan, ScriptTokenKind}; + +/// One token of a statement, with comments, whitespace and client +/// directives gone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum StatementToken<'a> { + /// A keyword or a bare name. + Word(&'a str), + /// A quoted name, without its quotes. + QuotedName(&'a str), + /// What a dollar quote holds, which is usually a routine body. + DollarBody(&'a str), + OpenParen, + Punct(char), + /// A string, a number or a closing parenthesis. + Other, +} + +impl<'a> StatementToken<'a> { + pub(crate) fn is_word(&self, word: &str) -> bool { + matches!(self, Self::Word(text) if text.eq_ignore_ascii_case(word)) + } + + pub(crate) fn is_punct(&self, punct: char) -> bool { + *self == Self::Punct(punct) + } + + /// The name it spells, quoted or not. + pub(crate) fn name(&self) -> Option<&'a str> { + match self { + Self::Word(text) | Self::QuotedName(text) => Some(text), + _ => None, + } + } +} + +/// A script split into statements the way the editor runs it. +#[derive(Debug)] +pub(crate) struct LexedScript<'a> { + pub statements: Vec>>, + /// A string, quoted name or comment runs to the end of the text, so + /// where the statements end is a guess. + pub unterminated: bool, +} + +pub(crate) fn lex_statements(sql: &str, grammar: SqlGrammar) -> LexedScript<'_> { + let plan = ScriptPlan::build(sql, grammar, LexicalSettings::default_for(grammar)); + let statements = plan + .statements() + .iter() + .map(|statement| { + plan.tokens(statement.range.clone()) + .filter_map(|token| significant(sql, token.kind, token.range)) + .collect() + }) + .collect(); + LexedScript { + statements, + unterminated: !plan.diagnostics().is_empty(), + } +} + +fn significant(sql: &str, kind: ScriptTokenKind, range: Range) -> Option> { + let text = sql.get(range).unwrap_or_default(); + let token = match kind { + ScriptTokenKind::Whitespace + | ScriptTokenKind::LineComment + | ScriptTokenKind::BlockComment + | ScriptTokenKind::Directive => return None, + ScriptTokenKind::Word => StatementToken::Word(text), + ScriptTokenKind::QuotedIdentifier => StatementToken::QuotedName(unquoted(text)), + ScriptTokenKind::DollarQuoted => StatementToken::DollarBody(dollar_body(text)), + ScriptTokenKind::OpenParen => StatementToken::OpenParen, + ScriptTokenKind::Punct => text.chars().next().map_or(StatementToken::Other, StatementToken::Punct), + ScriptTokenKind::StringLiteral + | ScriptTokenKind::Number + | ScriptTokenKind::Semicolon + | ScriptTokenKind::CloseParen + | ScriptTokenKind::Unterminated => StatementToken::Other, + }; + Some(token) +} + +/// Every quote the lexer accepts for a name (`"`, `` ` ``, `[` and `]`) +/// is one byte, so dropping the first and last byte leaves the name. +fn unquoted(text: &str) -> &str { + text.get(1..text.len().saturating_sub(1)).unwrap_or_default() +} + +/// `$tag$body$tag$` gives `body`. +fn dollar_body(text: &str) -> &str { + let Some(opener) = text.get(1..).and_then(|rest| rest.find('$')).map(|close| close + 2) else { + return ""; + }; + text.get(opener..text.len().saturating_sub(opener)).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn comments_whitespace_and_strings_carry_no_words() { + let script = lex_statements("SELECT /* CREATE */ 'USER' -- ROLE\n FROM t", SqlGrammar::PostgreSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("SELECT"), + StatementToken::Other, + StatementToken::Word("FROM"), + StatementToken::Word("t"), + ]] + ); + assert!(!script.unterminated); + } + + #[test] + fn quoted_names_lose_their_quotes() { + let script = lex_statements("EXEC [dbo].[sp_who]", SqlGrammar::MsSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("EXEC"), + StatementToken::QuotedName("dbo"), + StatementToken::Punct('.'), + StatementToken::QuotedName("sp_who"), + ]] + ); + } + + #[test] + fn a_dollar_quote_gives_its_body() { + let script = lex_statements("DO $body$ SELECT 1 $body$", SqlGrammar::PostgreSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("DO"), + StatementToken::DollarBody(" SELECT 1 ") + ]] + ); + } + + #[test] + fn statements_split_as_the_editor_splits_them() { + let script = lex_statements("SELECT 1\nGO\nSELECT 2", SqlGrammar::MsSql); + + assert_eq!(script.statements.len(), 2); + } + + #[test] + fn an_open_string_is_reported() { + assert!(lex_statements("SELECT 'abc", SqlGrammar::PostgreSql).unterminated); + assert!(lex_statements("SELECT 1 /* note", SqlGrammar::MySql).unterminated); + } +} diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index 68a8c6c1a1..0d9b4b1501 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -10,7 +10,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart, TableRef}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -155,6 +156,10 @@ impl SqlDialect for ClickHouseDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::ClickHouse, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -341,6 +346,57 @@ fn float_literal(value: f64) -> Option { None } +/// Statements whose text can hold a password or an access key. The +/// carriers are table functions and engines (`ENGINE = MySQL(...)`) +/// whose arguments include the remote login; the Kafka, RabbitMQ and +/// NATS engines take theirs as settings. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "NAMED", "COLLECTION"], + &["ALTER", "NAMED", "COLLECTION"], + ], + conditional_prefixes: &[ + (&["CREATE", "DICTIONARY"], &["PASSWORD"]), + ( + &["CREATE"], + &["KAFKA_SASL_PASSWORD", "RABBITMQ_PASSWORD", "NATS_PASSWORD"], + ), + ( + &["ALTER"], + &["KAFKA_SASL_PASSWORD", "RABBITMQ_PASSWORD", "NATS_PASSWORD"], + ), + ], + carriers: &[ + "mysql", + "postgresql", + "mongodb", + "redis", + "s3", + "s3Cluster", + "S3Queue", + "azureBlobStorage", + "azureBlobStorageCluster", + "AzureQueue", + "gcs", + "remote", + "remoteSecure", + "MaterializedPostgreSQL", + "MaterializedMySQL", + "ExternalDistributed", + "deltaLake", + "deltaLakeCluster", + "iceberg", + "icebergS3", + "icebergS3Cluster", + "icebergAzure", + "icebergAzureCluster", + "hudi", + "hudiCluster", + ], +}; + #[cfg(test)] mod tests { use clickhouse::sql::Identifier; @@ -529,4 +585,31 @@ mod tests { })) ); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE USER u IDENTIFIED WITH sha256_password BY 'p'", + "SELECT * FROM mysql('h','d','t','u','p')", + "CREATE TABLE t ENGINE = PostgreSQL('h','d','t','u','p')", + "SELECT * FROM remoteSecure('h', d.t, 'u', 'p')", + "CREATE NAMED COLLECTION c AS password = 'p'", + "SELECT * FROM deltaLake('url', 'key', 'secret')", + "CREATE TABLE q (m String) ENGINE = Kafka SETTINGS kafka_sasl_password = 'p'", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index d821c19358..f247c9d323 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -9,7 +9,8 @@ use tablepro_core::dialect::{ }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -208,6 +209,10 @@ impl SqlDialect for MssqlDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::MsSql, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -362,6 +367,50 @@ fn integer_param_type(kind: IntegerKind) -> ParamType { } } +/// Statements whose text can hold a password or a connection string. +/// The procedures run without EXEC as the first statement of a batch. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "LOGIN"], + &["ALTER", "LOGIN"], + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "APPLICATION", "ROLE"], + &["ALTER", "APPLICATION", "ROLE"], + &["CREATE", "CREDENTIAL"], + &["ALTER", "CREDENTIAL"], + &["CREATE", "DATABASE", "SCOPED", "CREDENTIAL"], + &["ALTER", "DATABASE", "SCOPED", "CREDENTIAL"], + &["CREATE", "MASTER", "KEY"], + &["ALTER", "MASTER", "KEY"], + &["ALTER", "SERVICE", "MASTER", "KEY"], + &["CREATE", "CERTIFICATE"], + &["ALTER", "CERTIFICATE"], + &["CREATE", "SYMMETRIC", "KEY"], + &["ALTER", "SYMMETRIC", "KEY"], + &["CREATE", "ASYMMETRIC", "KEY"], + &["ALTER", "ASYMMETRIC", "KEY"], + &["OPEN", "MASTER", "KEY"], + &["OPEN", "SYMMETRIC", "KEY"], + ], + conditional_prefixes: &[ + (&["BACKUP"], &["PASSWORD", "MEDIAPASSWORD", "ENCRYPTION"]), + (&["RESTORE"], &["PASSWORD", "MEDIAPASSWORD", "ENCRYPTION"]), + ], + carriers: &[ + "sp_addlogin", + "sp_password", + "sp_addlinkedsrvlogin", + "sp_addapprole", + "sp_setapprole", + "sp_approlepassword", + "sp_control_dbmasterkey_password", + "sp_change_users_login", + "OPENROWSET", + "OPENDATASOURCE", + ], +}; + #[cfg(test)] mod tests { use chrono::{DateTime, NaiveDate}; @@ -612,4 +661,32 @@ mod tests { assert!(dialect.parse_expression(&literal).is_ok(), "{literal}"); } } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE LOGIN l WITH PASSWORD = N'x'", + "EXEC sp_password 'o','n'", + "sp_addlogin 'l','p'", + "SELECT * FROM OPENROWSET('SQLNCLI','Server=x;PWD=y','q')", + "BACKUP DATABASE d TO DISK='f' WITH PASSWORD='x'", + "SELECT 1\nCREATE LOGIN l WITH PASSWORD = 'x'", + "ALTER SERVICE MASTER KEY WITH OLD_ACCOUNT = 'a', OLD_PASSWORD = 'p'", + "EXEC sp_change_users_login 'Auto_Fix', 'u', NULL, 'p'", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index fb3a725043..9608513f48 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -11,7 +11,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -181,6 +182,10 @@ impl SqlDialect for MySqlDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::MySql, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -364,6 +369,28 @@ fn string_literal(text: &str) -> String { format!("'{}'", text.replace('\'', "''")) } +/// Statements whose text can hold a password or a connection string. A +/// FEDERATED or CONNECT table keeps its remote login in `CONNECTION`. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "SERVER"], + &["ALTER", "SERVER"], + &["SET", "PASSWORD"], + &["CHANGE", "MASTER"], + &["CHANGE", "REPLICATION", "SOURCE"], + ], + conditional_prefixes: &[ + (&["GRANT"], &["IDENTIFIED"]), + (&["START", "SLAVE"], &["PASSWORD"]), + (&["START", "REPLICA"], &["PASSWORD"]), + (&["CREATE"], &["CONNECTION"]), + (&["ALTER"], &["CONNECTION"]), + ], + carriers: &[], +}; + #[cfg(test)] mod tests { use chrono::{DateTime, NaiveDate}; @@ -609,4 +636,31 @@ mod tests { ); assert!(dialect.parse_type("int; DROP TABLE t").is_err()); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE USER 'u'@'%' IDENTIFIED BY 'p'", + "SET PASSWORD FOR 'u'@'h' = 'x'", + "CHANGE REPLICATION SOURCE TO SOURCE_PASSWORD='x'", + "CHANGE MASTER TO MASTER_PASSWORD='x'", + "GRANT ALL ON *.* TO u IDENTIFIED BY 'p'", + "START REPLICA USER='u' PASSWORD='p'", + "CREATE TABLE t (id INT) ENGINE=FEDERATED CONNECTION='mysql://u:p@h:3306/d/t'", + ] { + assert_eq!( + dialect(MySqlFlavour::MySql).history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect(MySqlFlavour::MySql)), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index b6b15a5f5c..24ec538ce9 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -11,7 +11,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::{Temporal, Value}; @@ -146,6 +147,10 @@ impl SqlDialect for PgDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::PostgreSql, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -337,6 +342,26 @@ fn quoted(text: &str) -> String { escape_literal(text).trim_start().to_owned() } +/// Statements whose text can hold a password or a connection string. +/// `CREATE USER` also covers `CREATE USER MAPPING`, whose options carry +/// the remote login. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "ROLE"], + &["ALTER", "ROLE"], + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "GROUP"], + &["ALTER", "GROUP"], + &["CREATE", "SERVER"], + &["ALTER", "SERVER"], + &["CREATE", "SUBSCRIPTION"], + &["ALTER", "SUBSCRIPTION"], + ], + conditional_prefixes: &[(&["ALTER", "SYSTEM"], &["PRIMARY_CONNINFO"])], + carriers: &["dblink", "dblink_connect", "dblink_connect_u", "dblink_exec"], +}; + #[cfg(test)] mod tests { use tablepro_core::column::{ColumnDefault, IntegerKind}; @@ -571,4 +596,32 @@ mod tests { assert_eq!(dialect.quote_identifier("a\"b"), "\"a\"\"b\""); assert!(dialect.ddl().is_some_and(|ddl| ddl.capabilities().partial_indexes)); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "CREATE ROLE r LOGIN PASSWORD 'p'", + "ALTER USER u WITH PASSWORD $$x$$", + "CREATE USER MAPPING FOR u SERVER s OPTIONS (password 'b')", + "CREATE SUBSCRIPTION s CONNECTION 'password=x' PUBLICATION p", + "SELECT dblink_connect('password=x')", + "ALTER SYSTEM SET primary_conninfo = 'password=x'", + "SELECT 1; CREATE USER u PASSWORD 'p'", + "DO $$ BEGIN CREATE ROLE app LOGIN PASSWORD 'p'; END $$", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/drivers/sqlite/src/dialect.rs b/linux/crates/drivers/sqlite/src/dialect.rs index 275293b296..447e77f6db 100644 --- a/linux/crates/drivers/sqlite/src/dialect.rs +++ b/linux/crates/drivers/sqlite/src/dialect.rs @@ -7,7 +7,8 @@ use tablepro_core::dialect::{ use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::Value; @@ -160,6 +161,10 @@ impl SqlDialect for SqliteDialect { Ok(SqlExpression::from_catalog_text(parsed.text)) } + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::Sqlite, &CREDENTIAL_RULES, sql) + } + fn ddl(&self) -> Option<&dyn DdlDialect> { Some(self) } @@ -233,6 +238,20 @@ fn value_param(value: Value) -> Result<(Value, ParamType), BindError> { Ok((value, param_type)) } +/// Statements whose text can hold an encryption key, on builds with +/// SQLCipher or the SQLite Encryption Extension. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[], + conditional_prefixes: &[ + ( + &["PRAGMA"], + &["KEY", "REKEY", "HEXKEY", "HEXREKEY", "TEXTKEY", "TEXTREKEY"], + ), + (&["ATTACH"], &["KEY"]), + ], + carriers: &[], +}; + #[cfg(test)] mod tests { use tablepro_core::column::{BoolStorage, ColumnDefault, ReadForm, TextLength}; @@ -422,4 +441,27 @@ mod tests { ); assert!(dialect().ddl().is_some_and(|ddl| ddl.capabilities().partial_indexes)); } + + #[test] + fn history_withholds_statements_that_carry_credentials() { + for sql in [ + "PRAGMA key = 'x'", + "PRAGMA main.rekey = 'x'", + "ATTACH DATABASE 'other.db' AS other KEY 'x'", + ] { + assert_eq!( + dialect().history_recording(sql), + HistoryRecording::Withhold(tablepro_core::sql_syntax::WithholdReason::CredentialStatement), + "{sql}" + ); + } + } + + #[test] + fn history_records_plain_statements_as_they_are() { + assert_eq!( + tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), + Vec::<&str>::new() + ); + } } diff --git a/linux/crates/test-support/src/fake_dialect.rs b/linux/crates/test-support/src/fake_dialect.rs index 26b4448880..fef324188f 100644 --- a/linux/crates/test-support/src/fake_dialect.rs +++ b/linux/crates/test-support/src/fake_dialect.rs @@ -4,7 +4,8 @@ use tablepro_core::dialect::{ }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ - ExpressionSyntaxError, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, TypeSyntaxError, parse_expression, parse_type, + CredentialRules, ExpressionSyntaxError, HistoryRecording, MAX_EXPRESSION_LEN, MAX_TYPE_LEN, SqlGrammar, + TypeSyntaxError, classify_for_history, parse_expression, parse_type, }; use tablepro_core::statement::{BoundParam, ParamType}; use tablepro_core::value::Value; @@ -87,8 +88,25 @@ impl SqlDialect for FakeDialect { let parsed = parse_expression(SqlGrammar::PostgreSql, text, MAX_EXPRESSION_LEN)?; Ok(SqlExpression::from_catalog_text(parsed.text)) } + + fn history_recording(&self, sql: &str) -> HistoryRecording { + classify_for_history(SqlGrammar::PostgreSql, &CREDENTIAL_RULES, sql) + } } +/// Logins and roles, the credential statements every engine with users +/// has. +const CREDENTIAL_RULES: CredentialRules = CredentialRules { + statement_prefixes: &[ + &["CREATE", "USER"], + &["ALTER", "USER"], + &["CREATE", "ROLE"], + &["ALTER", "ROLE"], + ], + conditional_prefixes: &[], + carriers: &[], +}; + /// The parameter type that carries a value as it is, so a fake bind /// never fails on a type the test did not mean to check. fn param_type_of(value: &Value) -> ParamType { diff --git a/linux/crates/test-support/src/history_cases.rs b/linux/crates/test-support/src/history_cases.rs new file mode 100644 index 0000000000..5afc30dffa --- /dev/null +++ b/linux/crates/test-support/src/history_cases.rs @@ -0,0 +1,46 @@ +//! Statements every engine's dialect has to let into the query history. + +use tablepro_core::dialect::SqlDialect; +use tablepro_core::sql_syntax::HistoryRecording; + +/// SQL that names passwords, secrets or a carrier without setting a +/// credential, so withholding any of it would only lose history. +pub const PLAIN_STATEMENTS: [&str; 7] = [ + "SELECT id FROM users WHERE password IS NULL AND email = 'a@b'", + "UPDATE accounts SET secret = NULL WHERE name = 'x'", + "DELETE FROM creds WHERE password = 'p'", + "SELECT 'password' AS label", + "CREATE TABLE s3 (id int)", + "SELECT s3 FROM t", + "-- only a note\n/* and another */", +]; + +/// The plain statements `dialect` would not record, for a test to +/// expect none of. +pub fn unrecorded_plain_statements(dialect: &dyn SqlDialect) -> Vec<&'static str> { + PLAIN_STATEMENTS + .into_iter() + .filter(|sql| dialect.history_recording(sql) != HistoryRecording::Record) + .collect() +} + +#[cfg(test)] +mod tests { + use tablepro_core::sql_syntax::WithholdReason; + + use super::*; + use crate::FakeDialect; + + #[test] + fn the_fake_dialect_records_plain_statements() { + assert_eq!(unrecorded_plain_statements(&FakeDialect), Vec::<&str>::new()); + } + + #[test] + fn the_fake_dialect_withholds_a_login() { + assert_eq!( + FakeDialect.history_recording("SELECT 1; CREATE USER u PASSWORD 'p'"), + HistoryRecording::Withhold(WithholdReason::CredentialStatement) + ); + } +} diff --git a/linux/crates/test-support/src/lib.rs b/linux/crates/test-support/src/lib.rs index f02954726a..21a17fd313 100644 --- a/linux/crates/test-support/src/lib.rs +++ b/linux/crates/test-support/src/lib.rs @@ -21,6 +21,7 @@ mod fake_reply; mod fake_run; mod fake_secret_vault; mod fault; +pub mod history_cases; mod replies; pub use call_counters::CallCounters; diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index 2a28fba5a4..0813e5dc49 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -307,6 +307,8 @@ A run streams `ResultEvent`s into a `ResultSink`: `Connection::dialect()` returns the engine's `SqlDialect`, built from `ServerInfo` at connect. Every builder in `tablepro-core` (pages, filters, row edits, exports) goes through it: quoting, placeholders, literals, the page window, keyset comparison, the engine's row address and type parsing. A dialect never reads session state. `SqlDialect::ddl()` returns the engine's `DdlDialect`, or `None` where the app does not edit the engine's structure. Never match on a driver id in core; add a dialect method. +`SqlDialect::history_recording` says whether SQL the user ran may go into the query history. Implement it as `classify_for_history(, &CREDENTIAL_RULES, sql)`, with a `CredentialRules` const next to the dialect naming the engine's statements, conditional statements and functions that can carry a password or a connection string, and test each one ([storage.md](storage.md#what-goes-into-the-history)). + ### Structure changes `DdlDialect` lives in the driver's `src/ddl.rs`, implemented on the dialect type itself, so `ddl()` returns `Some(self)`. It plans every structure change the Structure tab makes, against the `TableDefinition` that `fetch_table_definition` returned: From 5b77af12cab7495c7f818b50debc5b591fd2c36a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:18:39 +0700 Subject: [PATCH 107/301] feat(linux-storage): add the save-query-history setting --- linux/crates/storage/src/settings/keys.rs | 1 + linux/crates/storage/src/settings/mod.rs | 10 ++++++++++ linux/crates/storage/tests/settings.rs | 11 +++++++++++ linux/data/app.tablepro.TablePro.gschema.xml | 6 ++++++ 4 files changed, 28 insertions(+) diff --git a/linux/crates/storage/src/settings/keys.rs b/linux/crates/storage/src/settings/keys.rs index 2665c6ad72..ddfbe67a1c 100644 --- a/linux/crates/storage/src/settings/keys.rs +++ b/linux/crates/storage/src/settings/keys.rs @@ -1,5 +1,6 @@ pub const DEFAULT_PAGE_SIZE: &str = "default-page-size"; pub const CONFIRM_DESTRUCTIVE: &str = "confirm-destructive"; +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"; pub const USE_SYSTEM_FONT: &str = "use-system-font"; diff --git a/linux/crates/storage/src/settings/mod.rs b/linux/crates/storage/src/settings/mod.rs index 34ceebf780..b60e04d100 100644 --- a/linux/crates/storage/src/settings/mod.rs +++ b/linux/crates/storage/src/settings/mod.rs @@ -75,6 +75,16 @@ impl AppSettings { self.set_boolean(keys::CONFIRM_DESTRUCTIVE, confirm) } + /// Whether runs go into the query history. Off records nothing and + /// leaves the entries already there. + pub fn save_query_history(&self) -> bool { + self.settings.boolean(keys::SAVE_QUERY_HISTORY) + } + + pub fn set_save_query_history(&self, save: bool) -> Result<(), SettingsError> { + self.set_boolean(keys::SAVE_QUERY_HISTORY, save) + } + pub fn history_retention_days(&self) -> u32 { self.settings.uint(keys::HISTORY_RETENTION_DAYS) } diff --git a/linux/crates/storage/tests/settings.rs b/linux/crates/storage/tests/settings.rs index e5cd1b9940..cecb9adafa 100644 --- a/linux/crates/storage/tests/settings.rs +++ b/linux/crates/storage/tests/settings.rs @@ -50,6 +50,7 @@ fn defaults_match_schema() -> TestResult { assert_eq!(fixture.settings.style_scheme(), "Adwaita"); assert_eq!(fixture.settings.csv_options(), CsvOptions::default()); assert!(fixture.settings.restore_session()); + assert!(fixture.settings.save_query_history()); Ok(()) } @@ -63,6 +64,16 @@ fn restore_session_round_trip() -> TestResult { Ok(()) } +#[test] +fn save_query_history_round_trip() -> TestResult { + let fixture = Fixture::new()?; + + fixture.settings.set_save_query_history(false)?; + + assert!(!fixture.settings.save_query_history()); + Ok(()) +} + #[test] fn csv_options_round_trip() -> TestResult { let fixture = Fixture::new()?; diff --git a/linux/data/app.tablepro.TablePro.gschema.xml b/linux/data/app.tablepro.TablePro.gschema.xml index cfbd309c23..341907a9cb 100644 --- a/linux/data/app.tablepro.TablePro.gschema.xml +++ b/linux/data/app.tablepro.TablePro.gschema.xml @@ -58,6 +58,12 @@ Ask before dropping a table, deleting rows, or discarding pending edits. + + true + Save query history + Whether the SQL you run is kept in the query history. Statements that contain passwords or connection secrets are never saved. Turning this off keeps the entries already saved. + + 30 From 46b0b61d611da5c8aa37ebbff93dcd3b66e8937e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:22:02 +0700 Subject: [PATCH 108/301] feat(linux): turn off query history and skip credential statements --- linux/CHANGELOG.md | 2 + .../app/src/application/app_services.rs | 7 +- .../app/src/services/history_preferences.rs | 63 +++++++ .../app/src/services/history_service.rs | 163 +++++++++++++++--- linux/crates/app/src/services/mod.rs | 1 + linux/crates/app/src/ui/editor/mod.rs | 35 ++-- linux/crates/app/src/ui/editor/tests.rs | 67 ++++++- .../app/src/ui/preferences_dialog/imp.rs | 2 + .../app/src/ui/preferences_dialog/mod.rs | 20 +++ linux/crates/app/src/ui/workspace/import.rs | 5 +- .../app/src/ui/workspace/workspace_tabs.rs | 4 +- .../app/src/workspace/ran_statements.rs | 76 ++++++-- .../app/src/workspace/workspace_handle.rs | 8 +- linux/data/resources/ui/preferences-dialog.ui | 7 +- linux/docs/storage.md | 21 +++ linux/po/POTFILES.in | 11 +- 16 files changed, 417 insertions(+), 75 deletions(-) create mode 100644 linux/crates/app/src/services/history_preferences.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 189a164090..f219825f56 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Arrow keys move between grid cells; F2 or Return edits one, Tab carries an edit to the next cell, and Shift+F10 opens the cell menu. - Screen readers say when a grid cell is modified and when a row is new or marked for deletion. - Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. +- Save query history in Preferences turns the query history off and keeps the entries already saved. ### Changed @@ -150,3 +151,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Changing a SQL Server column's type or nullability in the Structure tab removed its data mask. - A jump host whose ssh_config sets StrictHostKeyChecking to accept-new or no was trusted without the host key dialog. - Editor SQL saved by an earlier build stayed on disk in a tab file nothing reads any more. +- Statements that set a password, an encryption key or a connection secret were saved in the query history. diff --git a/linux/crates/app/src/application/app_services.rs b/linux/crates/app/src/application/app_services.rs index f72780f31c..d57252d11d 100644 --- a/linux/crates/app/src/application/app_services.rs +++ b/linux/crates/app/src/application/app_services.rs @@ -16,7 +16,8 @@ use crate::config; use crate::runtime::GioProcessHold; use crate::services::active_session::ActiveSession; use crate::services::connection_deletion::ConnectionDeletion; -use crate::services::history_service::{HistoryService, publish_retention}; +use crate::services::history_preferences::HistoryPreferences; +use crate::services::history_service::HistoryService; use crate::services::session_context::{CredentialSourceFactory, KeyringSources, SessionContext}; use crate::storage::{AppStorage, SharedStorage}; use crate::ui::credential_prompt::{GtkCredentialPrompter, PromptQueue}; @@ -145,8 +146,8 @@ impl AppServices { pub(crate) fn history(&self) -> HistoryService { self.history .get_or_init(|| { - let retention = publish_retention(&self.settings); - HistoryService::start(self.storage.paths().clone(), retention, &self.tasks) + let preferences = HistoryPreferences::follow(&self.settings); + HistoryService::start(self.storage.paths().clone(), preferences, &self.tasks) }) .clone() } diff --git a/linux/crates/app/src/services/history_preferences.rs b/linux/crates/app/src/services/history_preferences.rs new file mode 100644 index 0000000000..7739f9eadb --- /dev/null +++ b/linux/crates/app/src/services/history_preferences.rs @@ -0,0 +1,63 @@ +use std::rc::Rc; + +use gio::prelude::SettingsExt; +use tablepro_storage::AppSettings; +use tablepro_storage::settings::keys; +use tokio::sync::watch; + +/// The history preferences, mirrored where the history's worker and the +/// tasks that record can read them. +/// +/// `gio::Settings` lives on the GTK thread, so neither can hold it. Each +/// channel keeps the newest value, which is the one a prune or a record +/// must go by. +#[derive(Debug, Clone)] +pub struct HistoryPreferences { + pub retention_days: watch::Receiver, + pub save: watch::Receiver, +} + +impl HistoryPreferences { + pub fn follow(settings: &Rc) -> Self { + Self { + retention_days: mirror( + settings, + keys::HISTORY_RETENTION_DAYS, + AppSettings::history_retention_days, + ), + save: mirror(settings, keys::SAVE_QUERY_HISTORY, AppSettings::save_query_history), + } + } +} + +fn mirror( + settings: &Rc, + key: &str, + read: fn(&AppSettings) -> T, +) -> watch::Receiver { + let (sender, receiver) = watch::channel(read(settings)); + let settings_for_change = settings.clone(); + settings.gio().connect_changed(Some(key), move |_, _| { + sender.send_replace(read(&settings_for_change)); + }); + receiver +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::MemorySettings; + + #[test] + fn a_changed_preference_reaches_the_channel() { + let settings = MemorySettings::new(); + let preferences = HistoryPreferences::follow(settings.get()); + assert!(*preferences.save.borrow()); + + settings.get().set_save_query_history(false).expect("store the flag"); + settings.get().set_history_retention_days(7).expect("store the days"); + + assert!(!*preferences.save.borrow()); + assert_eq!(*preferences.retention_days.borrow(), 7); + } +} diff --git a/linux/crates/app/src/services/history_service.rs b/linux/crates/app/src/services/history_service.rs index e95f659305..ed7fd0ca27 100644 --- a/linux/crates/app/src/services/history_service.rs +++ b/linux/crates/app/src/services/history_service.rs @@ -1,18 +1,22 @@ -use gio::prelude::SettingsExt; use std::sync::{Arc, Mutex}; use std::time::Duration; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::sql_syntax::HistoryRecording; use tablepro_session::runtime::Tasks; +use tablepro_storage::query_history::NewEntry; use tablepro_storage::{QueryHistory, StoragePaths}; use tokio::sync::watch; use super::history_availability::HistoryAvailability; +use super::history_preferences::HistoryPreferences; /// How often unpinned history is pruned while the app runs. Startup does /// one pass, and this keeps a long session from growing without bound. const PRUNE_INTERVAL: Duration = Duration::from_secs(3600); -/// Opens the query history off the GTK thread and prunes it on a timer. +/// Opens the query history off the GTK thread, prunes it on a timer and +/// decides what goes into it. /// /// Opening runs a migration and a prune, which is too slow to block /// startup on, so the app starts and this reports when the database is @@ -21,31 +25,19 @@ const PRUNE_INTERVAL: Duration = Duration::from_secs(3600); pub struct HistoryService { availability: watch::Receiver, store: Arc>>, + save: watch::Receiver, _worker: Arc>, } -/// Mirror the retention preference onto a channel the prune worker can -/// read. -/// -/// `gio::Settings` lives on the GTK thread, so the worker cannot hold -/// it. This keeps the newest value where the worker can see it. -pub fn publish_retention(settings: &std::rc::Rc) -> watch::Receiver { - let (sender, receiver) = watch::channel(settings.history_retention_days()); - let settings_for_change = settings.clone(); - settings.gio().connect_changed( - Some(tablepro_storage::settings::keys::HISTORY_RETENTION_DAYS), - move |_, _| { - sender.send_replace(settings_for_change.history_retention_days()); - }, - ); - receiver -} - impl HistoryService { /// `retention` is read at every tick rather than captured, so a /// value the user typed through on the way to another one never /// decides what is deleted an hour later. - pub fn start(paths: StoragePaths, retention: watch::Receiver, tasks: &Tasks) -> Self { + pub fn start(paths: StoragePaths, preferences: HistoryPreferences, tasks: &Tasks) -> Self { + let HistoryPreferences { + retention_days: retention, + save, + } = preferences; let (sender, availability) = watch::channel(HistoryAvailability::Starting); let store: Arc>> = Arc::new(Mutex::new(None)); let store_for_worker = store.clone(); @@ -78,10 +70,46 @@ impl HistoryService { Self { availability, store, + save, _worker: Arc::new(worker), } } + /// Record one run once the database is open, unless the user turned + /// the history off or the SQL can carry a credential. + /// + /// `dialect` is the connection's: without it nobody can say which + /// statements hold a secret, so nothing is recorded. The setting is + /// read now, so a run that ends after the history was turned off is + /// not recorded either. + pub fn record( + &self, + entry: NewEntry, + dialect: Option>, + ) -> impl Future + Send + use<> { + let saving = *self.save.borrow(); + let store = self.ready_store(); + async move { + if !saving { + return; + } + let Some(dialect) = dialect else { + tracing::debug!(connection = %entry.connection_id, "no dialect, so the run is not recorded"); + return; + }; + if let HistoryRecording::Withhold(reason) = dialect.history_recording(&entry.query) { + tracing::debug!(connection = %entry.connection_id, ?reason, "a run was kept out of the history"); + return; + } + let Some(history) = store.await else { + return; + }; + if let Err(error) = history.record(entry).await { + tracing::warn!(%error, "history record failed"); + } + } + } + pub fn availability(&self) -> watch::Receiver { self.availability.clone() } @@ -139,8 +167,13 @@ mod tests { use super::*; use crate::test_support::paused_tasks; - fn retention(days: u32) -> watch::Receiver { - watch::channel(days).1 + /// Preferences that stay as given: a dropped sender leaves the + /// receiver on its last value. + fn preferences(days: u32, save: bool) -> HistoryPreferences { + HistoryPreferences { + retention_days: watch::channel(days).1, + save: watch::channel(save).1, + } } fn paths(root: &tempfile::TempDir) -> StoragePaths { @@ -162,7 +195,7 @@ mod tests { let root = tempfile::tempdir().expect("tempdir"); let tasks = paused_tasks(); - let service = HistoryService::start(paths(&root), retention(30), &tasks); + let service = HistoryService::start(paths(&root), preferences(30, true), &tasks); let state = wait_for_ready(&service).await; assert_eq!(state, HistoryAvailability::Ready); @@ -173,7 +206,7 @@ mod tests { async fn ready_store_waits_for_the_open() { let root = tempfile::tempdir().expect("tempdir"); - let service = HistoryService::start(paths(&root), retention(30), &paused_tasks()); + let service = HistoryService::start(paths(&root), preferences(30, true), &paused_tasks()); assert!(service.store().is_none()); assert!(service.ready_store().await.is_some()); @@ -184,7 +217,7 @@ mod tests { let root = tempfile::tempdir().expect("tempdir"); let tasks = paused_tasks(); - let service = HistoryService::start(paths(&root), retention(30), &tasks); + let service = HistoryService::start(paths(&root), preferences(30, true), &tasks); // Nothing has been polled yet, so the worker cannot have run. assert!(service.store().is_none()); @@ -228,7 +261,11 @@ mod tests { /// A service whose store is open, with its retention channel. async fn ready_service(root: &tempfile::TempDir, days: u32) -> (QueryHistory, watch::Sender) { let (retention, receiver) = watch::channel(days); - let service = HistoryService::start(paths(root), receiver.clone(), &paused_tasks()); + let preferences = HistoryPreferences { + retention_days: receiver, + save: watch::channel(true).1, + }; + let service = HistoryService::start(paths(root), preferences, &paused_tasks()); let state = wait_for_ready(&service).await; assert_eq!(state, HistoryAvailability::Ready, "the history did not open"); (service.store().expect("the store"), retention) @@ -286,10 +323,82 @@ mod tests { std::fs::write(&paths.state, b"not a directory").expect("seed"); let tasks = paused_tasks(); - let service = HistoryService::start(paths, retention(30), &tasks); + let service = HistoryService::start(paths, preferences(30, true), &tasks); let state = wait_for_ready(&service).await; assert!(state.failure().is_some(), "{state:?}"); assert!(service.store().is_none()); } + + fn run_of(query: &str) -> NewEntry { + NewEntry { + query: query.to_owned(), + driver_id: "postgres".to_owned(), + connection_id: uuid::Uuid::new_v4(), + connection_name: "local".to_owned(), + executed_at: std::time::SystemTime::now(), + duration_ms: Some(1), + rows_affected: None, + outcome: tablepro_storage::query_history::Outcome::Success, + } + } + + fn fake_dialect() -> Option> { + Some(Arc::new(tablepro_test_support::FakeDialect)) + } + + /// Record `query` through a service built with `save`, and read back + /// what the store holds. + async fn recorded(save: bool, query: &str, dialect: Option>) -> Vec { + let root = tempfile::tempdir().expect("tempdir"); + let service = HistoryService::start(paths(&root), preferences(30, save), &paused_tasks()); + + service.record(run_of(query), dialect).await; + + let history = service.ready_store().await.expect("the store"); + queries(&history).await + } + + #[tokio::test] + async fn history_service_skips_when_setting_off() { + assert_eq!(recorded(false, "SELECT 1", fake_dialect()).await, Vec::::new()); + } + + #[tokio::test] + async fn history_service_skips_without_dialect() { + assert_eq!(recorded(true, "SELECT 1", None).await, Vec::::new()); + } + + #[tokio::test] + async fn history_service_skips_credential_statement() { + assert_eq!( + recorded(true, "SELECT 1; CREATE USER u PASSWORD 'p'", fake_dialect()).await, + Vec::::new() + ); + } + + #[tokio::test] + async fn history_service_records_plain_select_unchanged() { + let query = "SELECT id FROM users WHERE password IS NULL AND email = 'a@b' -- kept as typed\n"; + + assert_eq!(recorded(true, query, fake_dialect()).await, vec![query.to_owned()]); + } + + #[tokio::test] + async fn turning_the_history_off_stops_the_next_record() { + let root = tempfile::tempdir().expect("tempdir"); + let (save, receiver) = watch::channel(true); + let preferences = HistoryPreferences { + retention_days: watch::channel(30).1, + save: receiver, + }; + let service = HistoryService::start(paths(&root), preferences, &paused_tasks()); + service.record(run_of("SELECT 'kept'"), fake_dialect()).await; + + save.send_replace(false); + service.record(run_of("SELECT 'dropped'"), fake_dialect()).await; + + let history = service.ready_store().await.expect("the store"); + assert_eq!(queries(&history).await, vec!["SELECT 'kept'".to_owned()]); + } } diff --git a/linux/crates/app/src/services/mod.rs b/linux/crates/app/src/services/mod.rs index c948779f05..9abb70cc2a 100644 --- a/linux/crates/app/src/services/mod.rs +++ b/linux/crates/app/src/services/mod.rs @@ -10,6 +10,7 @@ pub mod file_location; mod file_location_kind; pub mod file_resolution; pub mod history_availability; +pub mod history_preferences; pub mod history_service; pub mod pending_structure; pub mod save_batch; diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index ccc4b5c3b6..4745f10fdd 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -23,6 +23,7 @@ mod tests; mod transaction_dirty; use std::rc::Rc; +use std::sync::Arc; use std::time::{Duration, SystemTime}; use gtk4::glib; @@ -31,6 +32,7 @@ use gtk4::subclass::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; use sourceview5::prelude::*; +use tablepro_core::dialect::SqlDialect; use tablepro_core::sql_syntax::SqlGrammar; use tablepro_core::{QueryResult, RowLimit}; use tablepro_session::runtime::{ProcessHold, Tasks}; @@ -44,6 +46,7 @@ use self::script_runner::{ScriptRun, plan_batches, run_in_tab}; use super::grid::{GridMsg, GridSink}; use super::handler_guard::HandlerGuard; use crate::services::active_session::ActiveSession; +use crate::services::history_service::HistoryService; glib::wrapper! { pub struct SqlEditor(ObjectSubclass) @@ -54,7 +57,7 @@ glib::wrapper! { pub struct SqlEditorInit { pub initial_query: Option, pub settings: Rc, - pub history: Option, + pub history: Option, pub tasks: Tasks, /// The window's session, which the tab opens its own connection /// from on the first run. @@ -66,7 +69,7 @@ pub struct SqlEditorInit { pub(crate) struct EditorDeps { settings: Rc, - history: Option, + history: Option, tasks: Tasks, session: ActiveSession, hold: Rc, @@ -368,6 +371,7 @@ impl SqlEditor { row_limit: RowLimit::EDITOR_DEFAULT, }; let metadata = session.metadata().clone(); + let dialect = session.current_dialect(); let started_at = SystemTime::now(); let cancel = CancellationToken::new(); let (events, mut received) = tokio::sync::mpsc::channel(8); @@ -399,7 +403,7 @@ impl SqlEditor { let finished = matches!(event, RunEvent::Finished); editor.apply_event(event); if finished { - editor.finish_run(query, &metadata, started_at); + editor.finish_run(query, &metadata, started_at, dialect); return; } } @@ -407,7 +411,7 @@ impl SqlEditor { // does; the list still has to end and the tab unlock. if let Some(editor) = weak.upgrade() { editor.apply_event(RunEvent::Finished); - editor.finish_run(query, &metadata, started_at); + editor.finish_run(query, &metadata, started_at, dialect); } }); imp.running.replace(Some(RunningScript { cancel, drain })); @@ -443,7 +447,13 @@ impl SqlEditor { })) } - fn finish_run(&self, query: String, metadata: &tablepro_session::ConnectionMetadata, started_at: SystemTime) { + fn finish_run( + &self, + query: String, + metadata: &tablepro_session::ConnectionMetadata, + started_at: SystemTime, + dialect: Option>, + ) { let imp = self.imp(); drop(imp.running.take()); imp.run_button.set_sensitive(true); @@ -470,22 +480,21 @@ impl SqlEditor { if imp.transaction_dirty.replace(dirty) != dirty { self.emit_by_name::<()>("transaction-changed", &[&dirty]); } - self.record_history(entry); + self.record_history(entry, dialect); self.emit_by_name::<()>("run-state-changed", &[&false]); } - fn record_history(&self, entry: tablepro_storage::query_history::NewEntry) { + /// `dialect` is the one the run started on, which still names the + /// engine when the connection dropped during the run. + fn record_history(&self, entry: tablepro_storage::query_history::NewEntry, dialect: Option>) { let Some(deps) = self.deps() else { return; }; - let Some(history) = deps.history.clone() else { + let Some(history) = deps.history.as_ref() else { return; }; - drop(deps.tasks.spawn_task(async move { - if let Err(error) = history.record(entry).await { - tracing::warn!(%error, "history record failed"); - } - })); + let dialect = dialect.or_else(|| deps.session.get().and_then(|session| session.current_dialect())); + drop(deps.tasks.spawn_task(history.record(entry, dialect))); } /// Put the cursor where the server says a statement went wrong. diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 2d07c8b458..7ab0caf30e 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -8,6 +8,7 @@ use tablepro_session::operation::DefaultDeadlines; use tablepro_test_support::{ConnectionScript, FakeEditorSession, FakeReply, FakeRun, Replies}; use super::*; +use crate::services::history_preferences::HistoryPreferences; use crate::test_support::{MemorySettings, SignalLog, wait_until}; const WAIT: Duration = Duration::from_secs(10); @@ -15,8 +16,9 @@ const WAIT: Duration = Duration::from_secs(10); struct Harness { editor: SqlEditor, fake: Arc, + history: Option, _settings: MemorySettings, - _runtime: crate::runtime::AppRuntime, + runtime: crate::runtime::AppRuntime, } fn session_with(fake: Arc, tasks: Tasks) -> Arc { @@ -38,16 +40,21 @@ fn session_with(fake: Arc, tasks: Tasks) -> Arc { } fn editor_with(runs: Replies, text: &str) -> Harness { + build_editor(runs, text, |_| None) +} + +fn build_editor(runs: Replies, text: &str, history: impl FnOnce(&Tasks) -> Option) -> Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let settings = MemorySettings::new(); let fake = Arc::new(FakeEditorSession::new(runs)); let active = ActiveSession::new(); active.replace(session_with(fake.clone(), runtime.tasks())); + let history = history(&runtime.tasks()); let editor = SqlEditor::new(SqlEditorInit { initial_query: Some(text.to_owned()), settings: settings.get().clone(), - history: None, + history: history.clone(), tasks: runtime.tasks(), session: active, hold: Rc::new(tablepro_session::runtime::CountingHold::new()), @@ -56,8 +63,9 @@ fn editor_with(runs: Replies, text: &str) -> Harness { Harness { editor, fake, + history, _settings: settings, - _runtime: runtime, + runtime, } } @@ -330,6 +338,59 @@ fn the_icon_buttons_have_accessible_names() { )); } +#[gtk4::test] +fn a_run_is_recorded_unless_it_carries_a_credential() { + let root = tempfile::tempdir().expect("tempdir"); + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let harness = build_editor(answering(), "CREATE USER u PASSWORD 'p';", |tasks| { + let preferences = HistoryPreferences { + retention_days: tokio::sync::watch::channel(30).1, + save: tokio::sync::watch::channel(true).1, + }; + Some(HistoryService::start(paths, preferences, tasks)) + }); + run_and_wait(&harness); + + harness.editor.buffer().set_text("SELECT 2;"); + run_and_wait(&harness); + + wait_until(WAIT, || { + recorded(&harness).iter().any(|query| query.contains("SELECT 2")) + }) + .expect("the plain run was never recorded"); + let recorded = recorded(&harness); + assert!( + recorded.iter().all(|query| !query.contains("CREATE USER")), + "a statement with a password was recorded: {recorded:?}" + ); +} + +/// What the harness's history holds, newest first. +fn recorded(harness: &Harness) -> Vec { + let Some(history) = harness.history.as_ref().and_then(HistoryService::store) else { + return Vec::new(); + }; + let search = history.search(tablepro_storage::query_history::SearchFilter { + limit: 100, + ..Default::default() + }); + block_on(&harness.runtime, search) + .expect("search") + .into_iter() + .map(|entry| entry.query) + .collect() +} + +/// The test drives the runtime from outside it, which is the one place +/// blocking on it cannot deadlock. +#[expect( + clippy::disallowed_methods, + reason = "a GTK test has no runtime of its own, so it drives the app's from outside" +)] +fn block_on(runtime: &crate::runtime::AppRuntime, future: F) -> F::Output { + runtime.tasks().handle().block_on(future) +} + fn unconnected_editor(settings: &MemorySettings, tasks: Tasks) -> SqlEditor { crate::register_test_resources(); SqlEditor::new(SqlEditorInit { diff --git a/linux/crates/app/src/ui/preferences_dialog/imp.rs b/linux/crates/app/src/ui/preferences_dialog/imp.rs index 6df0bbce9e..15011d0935 100644 --- a/linux/crates/app/src/ui/preferences_dialog/imp.rs +++ b/linux/crates/app/src/ui/preferences_dialog/imp.rs @@ -17,6 +17,8 @@ pub struct PreferencesDialog { #[template_child] pub confirm_row: TemplateChild, #[template_child] + pub save_history_row: TemplateChild, + #[template_child] pub retention_row: TemplateChild, #[template_child] pub clear_row: TemplateChild, diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index 3f8cd6179c..9168cc4def 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -49,6 +49,10 @@ impl PreferencesDialog { gio_settings .bind(keys::CONFIRM_DESTRUCTIVE, &imp.confirm_row.get(), "active") .build(); + // Off stops new records only; Clear is what removes the old ones. + gio_settings + .bind(keys::SAVE_QUERY_HISTORY, &imp.save_history_row.get(), "active") + .build(); gio_settings .bind(keys::HISTORY_RETENTION_DAYS, &imp.retention_row.get(), "value") .build(); @@ -251,6 +255,22 @@ mod tests { assert!(row.is_active()); } + #[gtk4::test] + fn preferences_history_switch_bound_to_setting() { + let settings = MemorySettings::new(); + let runtime = test_runtime(); + let storage = test_storage(&runtime.tasks()); + let dialog = new_dialog(&settings, &storage, None, &runtime.tasks()); + let row = dialog.imp().save_history_row.get(); + assert!(row.is_active(), "the row does not show the default"); + + row.set_active(false); + assert!(!settings.get().save_query_history()); + + settings.get().set_save_query_history(true).expect("store the flag"); + assert!(row.is_active()); + } + #[gtk4::test] fn the_font_row_is_insensitive_while_the_system_font_is_on() { let settings = MemorySettings::new(); diff --git a/linux/crates/app/src/ui/workspace/import.rs b/linux/crates/app/src/ui/workspace/import.rs index f239ae99e7..93a78572f2 100644 --- a/linux/crates/app/src/ui/workspace/import.rs +++ b/linux/crates/app/src/ui/workspace/import.rs @@ -146,8 +146,9 @@ impl WorkspaceModel { let count = rows.len(); let table = TableRef::new(import.schema.clone(), import.table.clone()); - let history = self.history.store(); + let history = Some(self.history.clone()); let connection = self.context.active.metadata(); + let dialect = session.current_dialect(); let input = self.input.clone(); drop(self.context.tasks.spawn_task(async move { let written = write_import(&session, table, import.columns, rows).await; @@ -156,7 +157,7 @@ impl WorkspaceModel { // An import changes the database, so it belongs in // the same history as a statement run from the editor. let touched = report.total_rows().and_then(|rows| i64::try_from(rows).ok()); - RanStatements::starting(history, connection, sql) + RanStatements::starting(history, connection, dialect, sql) .finished(touched, tablepro_storage::query_history::Outcome::Success) .await; input.deliver(WorkspaceInput::RefreshPage); diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 7a3d571a94..1d22c95edf 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -46,7 +46,7 @@ impl WorkspaceModel { fn build_workspace_root(&mut self) { let settings = self.settings.clone(); - let history = self.history.store(); + let history = Some(self.history.clone()); let tasks = self.tasks.clone(); let session = self.context.active.clone(); let hold = self.context.hold.clone(); @@ -383,7 +383,7 @@ impl WorkspaceModel { let editor = SqlEditor::new(SqlEditorInit { initial_query, settings: self.settings.clone(), - history: self.history.store(), + history: Some(self.history.clone()), tasks: self.tasks.clone(), session: self.context.active.clone(), hold: self.context.hold.clone(), diff --git a/linux/crates/app/src/workspace/ran_statements.rs b/linux/crates/app/src/workspace/ran_statements.rs index 984be6f058..c78e1ebac6 100644 --- a/linux/crates/app/src/workspace/ran_statements.rs +++ b/linux/crates/app/src/workspace/ran_statements.rs @@ -1,9 +1,12 @@ +use std::sync::Arc; use std::time::SystemTime; +use tablepro_core::dialect::SqlDialect; use tablepro_session::ConnectionMetadata; -use tablepro_storage::QueryHistory; use tablepro_storage::query_history::{NewEntry, Outcome}; +use crate::services::history_service::HistoryService; + /// What the app needs to record a statement batch it ran on the user's /// behalf. /// @@ -12,10 +15,11 @@ use tablepro_storage::query_history::{NewEntry, Outcome}; /// "what did this to my table" has to carry them too. It is captured /// before the work starts, because the connection can be gone by the /// time the answer comes back. -#[derive(Debug, Clone)] +#[derive(Clone)] pub(crate) struct RanStatements { - history: Option, + history: Option, connection: Option, + dialect: Option>, sql: String, started_at: SystemTime, } @@ -24,22 +28,24 @@ impl RanStatements { /// Capture the batch about to run. `sql` reads as one entry in the /// history list, which is how the user thinks of one save. pub(crate) fn starting( - history: Option, + history: Option, connection: Option, + dialect: Option>, statements: impl IntoIterator, ) -> Self { Self { history, connection, + dialect, sql: join(statements), started_at: SystemTime::now(), } } - /// Record how it went. Does nothing when the history is closed or - /// the batch was empty, and never fails the save it describes: a - /// history that cannot be written is worth a log line, not a lost - /// edit. + /// Record how it went, through the same gate as an editor run. Does + /// nothing when the history is closed or the batch was empty, and + /// never fails the save it describes: a history that cannot be + /// written is worth a log line, not a lost edit. pub(crate) async fn finished(self, rows_affected: Option, outcome: Outcome) { let (Some(history), Some(connection)) = (self.history, self.connection) else { return; @@ -61,9 +67,7 @@ impl RanStatements { rows_affected, outcome, }; - if let Err(error) = history.record(entry).await { - tracing::warn!(%error, "could not record the statements in history"); - } + history.record(entry, self.dialect).await; } } @@ -97,6 +101,56 @@ mod tests { assert_eq!(join(["SELECT 1;".to_owned()]), "SELECT 1;"); } + #[tokio::test] + async fn a_save_goes_through_the_history_gate() { + use tokio::sync::watch; + + use crate::services::history_preferences::HistoryPreferences; + + let root = tempfile::tempdir().expect("tempdir"); + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + let (save, receiver) = watch::channel(true); + let preferences = HistoryPreferences { + retention_days: watch::channel(30).1, + save: receiver, + }; + let history = HistoryService::start(paths, preferences, &crate::test_support::paused_tasks()); + let dialect: Arc = Arc::new(tablepro_test_support::FakeDialect); + let connection = ConnectionMetadata { + id: uuid::Uuid::new_v4(), + name: "local".to_owned(), + driver_id: "postgres".to_owned(), + }; + let save_of = |sql: &str| { + RanStatements::starting( + Some(history.clone()), + Some(connection.clone()), + Some(dialect.clone()), + [sql.to_owned()], + ) + }; + + save_of("UPDATE t SET a = 1").finished(Some(1), Outcome::Success).await; + save_of("CREATE USER u PASSWORD 'p'") + .finished(None, Outcome::Success) + .await; + save.send_replace(false); + save_of("UPDATE t SET a = 2").finished(Some(1), Outcome::Success).await; + + let store = history.ready_store().await.expect("the store"); + let queries: Vec = store + .search(tablepro_storage::query_history::SearchFilter { + limit: 100, + ..Default::default() + }) + .await + .expect("search") + .into_iter() + .map(|entry| entry.query) + .collect(); + assert_eq!(queries, vec!["UPDATE t SET a = 1;".to_owned()]); + } + #[test] fn an_empty_batch_records_nothing() { assert_eq!(join(Vec::::new()), ""); diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index 03807df607..79f086f221 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -439,8 +439,9 @@ impl WorkspaceHandle { /// the time the answer comes back. fn history_entry(&self, session: &Session) -> HistoryEntry { HistoryEntry { - history: self.0.deps.history.as_ref().and_then(HistoryService::store), + history: self.0.deps.history.clone(), connection: Some(session.metadata().clone()), + dialect: session.current_dialect(), } } } @@ -454,8 +455,9 @@ impl std::fmt::Debug for WorkspaceHandle { } struct HistoryEntry { - history: Option, + history: Option, connection: Option, + dialect: Option>, } /// A save changes the database as surely as a statement typed into the @@ -467,7 +469,7 @@ async fn record( written: &Result, resolution: Option<&Result>, ) { - let ran = RanStatements::starting(entry.history, entry.connection, statements); + let ran = RanStatements::starting(entry.history, entry.connection, entry.dialect, statements); let (rows, outcome) = match (written, resolution) { (Ok(report), _) => ( report.total_rows().and_then(|rows| i64::try_from(rows).ok()), diff --git a/linux/data/resources/ui/preferences-dialog.ui b/linux/data/resources/ui/preferences-dialog.ui index 42db7ad505..e6e458c827 100644 --- a/linux/data/resources/ui/preferences-dialog.ui +++ b/linux/data/resources/ui/preferences-dialog.ui @@ -49,7 +49,12 @@ Query history - Persistent record of every SQL query you run. + + + Save query history + Keep a searchable record of the SQL you run. Statements that contain passwords or connection secrets are never saved. + + Retention (days) diff --git a/linux/docs/storage.md b/linux/docs/storage.md index f1c8e3a32d..9f0d23b687 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -179,6 +179,26 @@ When it deleted anything it runs FTS5 `optimize`, because the delete triggers otherwise leave tombstones that grow the index as the table shrinks. It returns a `PruneReport { expired, over_cap }`. +### What goes into the history + +`HistoryService::record` is the one way in, for editor runs, saves and +imports. It records nothing while `save-query-history` is off, or when the +connection has no dialect, such as while its link is down. Otherwise the +dialect's `history_recording` decides, reading the SQL with the editor's own +script lexer: + +- A statement that can carry a credential withholds the whole entry. Each + engine lists its own in a `CredentialRules`: logins, roles, keys and links + to other servers (`CREATE USER`, `CHANGE MASTER`), statements that do so + only with a given word (`GRANT ... IDENTIFIED`), and functions or engines + that take a password (`dblink`, `OPENROWSET`, `s3`). A dollar-quoted body + is read for the statements inside it. +- A string, quoted name or comment that never closes withholds it too, since + what is inside a literal is then a guess. + +An entry is kept whole or not at all, never rewritten to cut a secret out. +Turning the setting off keeps what is already there; Clear removes it. + ## App preferences with `gio::Settings` `data/app.tablepro.TablePro.gschema.xml` holds every app-wide preference and the window geometry. Meson installs it to `$datadir/glib-2.0/schemas` and compiles it; see [ADR 0007](decisions/0007-gsettings-preferences.md). @@ -193,6 +213,7 @@ Schema id and path: `app.tablepro.TablePro`. | `default-page-size` | `u` | `1000` | 100 to 100000 | | `confirm-destructive` | `b` | `true` | | | `restore-session` | `b` | `true` | | +| `save-query-history` | `b` | `true` | | | `history-retention-days` | `u` | `30` | 0 to 365 | | `query-timeout-secs` | `u` | `60` | 0 to 3600 | | `use-system-font` | `b` | `true` | | diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index db4c41f638..5e3d2f877a 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -1,11 +1,7 @@ crates/app/src/application/app_actions.rs crates/app/src/application/app_services.rs -crates/app/src/application/app_services.rs -crates/app/src/application/app_services.rs crates/app/src/application/app_window_host.rs crates/app/src/application/imp.rs -crates/app/src/application/imp.rs -crates/app/src/application/imp.rs crates/app/src/application/mod.rs crates/app/src/application/open_files.rs crates/app/src/application/quit_sequence.rs @@ -57,6 +53,7 @@ crates/app/src/services/file_location.rs crates/app/src/services/file_location_kind.rs crates/app/src/services/file_resolution.rs crates/app/src/services/history_availability.rs +crates/app/src/services/history_preferences.rs crates/app/src/services/history_service.rs crates/app/src/services/mod.rs crates/app/src/services/pending_structure.rs @@ -215,8 +212,6 @@ crates/app/src/ui/session_window/connections.rs crates/app/src/ui/session_window/imp.rs crates/app/src/ui/session_window/locate_file.rs crates/app/src/ui/session_window/mod.rs -crates/app/src/ui/session_window/mod.rs -crates/app/src/ui/session_window/mod.rs crates/app/src/ui/session_window/open_file_tests.rs crates/app/src/ui/session_window/picker_page.rs crates/app/src/ui/session_window/resolve_file_identity.rs @@ -226,8 +221,6 @@ crates/app/src/ui/session_window/session_restore.rs crates/app/src/ui/session_window/session_teardown.rs crates/app/src/ui/session_window/session_window_command.rs crates/app/src/ui/session_window/session_window_input.rs -crates/app/src/ui/session_window/session_window_input.rs -crates/app/src/ui/session_window/session_window_input.rs crates/app/src/ui/session_window/sqlite_location_dialog.rs crates/app/src/ui/session_window/tests.rs crates/app/src/ui/session_window/window_actions.rs @@ -326,8 +319,6 @@ crates/app/src/workspace/workspace_load_error.rs crates/app/src/workspace/workspace_record.rs crates/app/src/workspace/workspace_save_error.rs crates/app/src/workspace/workspace_store.rs -crates/app/src/workspace/workspace_store.rs -crates/app/src/workspace/workspace_store.rs crates/app/src/workspace/write_job.rs crates/app/src/workspace/writes_in_flight.rs data/app.tablepro.TablePro.desktop.in.in From 3ad7fbc4a675608e9c314afdc9599c99d3fd22bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:33:14 +0700 Subject: [PATCH 109/301] perf(linux-grid): own each browse page once and load it in one splice Rows are still keyed in the identity the save path binds (the primary key in column order); RowKeyBuilder takes the page's identity range once saves plan with the page identity. --- linux/CHANGELOG.md | 1 + linux/crates/app/src/services/cell_overlay.rs | 25 +- .../crates/app/src/services/change_tracker.rs | 69 ++-- .../app/src/services/tab_tracker_handle.rs | 29 +- linux/crates/app/src/ui/browse_tab/fetch.rs | 26 +- linux/crates/app/src/ui/browse_tab/mod.rs | 221 ++++++------ .../app/src/ui/browse_tab/page_reply.rs | 4 + .../app/src/ui/browse_tab/page_state.rs | 16 + linux/crates/app/src/ui/browse_tab/tests.rs | 332 +++++++++++++++--- linux/crates/app/src/ui/editor/mod.rs | 1 + linux/crates/app/src/ui/editor/result_rows.rs | 29 ++ .../app/src/ui/editor/result_widgets.rs | 19 +- linux/crates/app/src/ui/editor/run_log.rs | 15 +- .../crates/app/src/ui/grid/column_factory.rs | 46 +-- linux/crates/app/src/ui/grid/grid_content.rs | 16 + linux/crates/app/src/ui/grid/grid_keyboard.rs | 12 +- linux/crates/app/src/ui/grid/grid_menus.rs | 22 +- linux/crates/app/src/ui/grid/mod.rs | 8 +- .../crates/app/src/ui/grid/row_key_builder.rs | 231 ++++++++++++ linux/crates/app/src/ui/grid/row_object.rs | 6 + linux/crates/app/src/ui/grid/row_snapshot.rs | 108 ++++++ linux/crates/app/src/ui/grid/selection.rs | 32 +- .../app/src/ui/grid/tab_grid_context.rs | 62 ++-- linux/crates/app/src/ui/grid/test_grid.rs | 20 +- linux/po/POTFILES.in | 5 + 25 files changed, 991 insertions(+), 364 deletions(-) create mode 100644 linux/crates/app/src/ui/browse_tab/page_state.rs create mode 100644 linux/crates/app/src/ui/editor/result_rows.rs create mode 100644 linux/crates/app/src/ui/grid/grid_content.rs create mode 100644 linux/crates/app/src/ui/grid/row_key_builder.rs create mode 100644 linux/crates/app/src/ui/grid/row_snapshot.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 00ded6cb7c..8f3dafe3f9 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -97,6 +97,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Copy as INSERT could copy the wrong row, drop the schema or unsaved edits, or write a value where the server fills one. - Duplicate left out the row's unsaved edits. - Cells of a table without a primary key opened for editing, then dropped what was typed. +- A row whose primary key the driver could not read opened for editing, then failed to save. - A save that failed on a new row did not flash that row. - Undo after editing a cell twice restored the fetched value instead of the first edit. - An edit made while a save ran was dropped when it set the cell back to the value it was read with. diff --git a/linux/crates/app/src/services/cell_overlay.rs b/linux/crates/app/src/services/cell_overlay.rs index 0bc7a4def8..ee2ec871a9 100644 --- a/linux/crates/app/src/services/cell_overlay.rs +++ b/linux/crates/app/src/services/cell_overlay.rs @@ -1,14 +1,31 @@ +#[cfg(test)] use tablepro_core::Value; +use tablepro_core::edit::CellInput; use super::change_tracker::CellState; -/// One cell as the grid draws it, copied out of the tracker so a bind -/// holds no borrow while it updates widgets. +/// One cell's pending state, copied out of the tracker so a draw holds +/// no borrow while it updates widgets. Only a pending value is copied: a +/// clean cell is drawn from the row itself. #[derive(Debug, Clone, PartialEq)] pub struct CellOverlay { - /// The pending edit when there is one, else the fetched value. - pub value: Value, + /// The cell's pending input, `None` when it shows what was read. A + /// draft's cell is always the tracker's. + pub pending: Option, pub state: CellState, /// The row produced the statement a save just failed on. pub error_flash: bool, } + +#[cfg(test)] +impl CellOverlay { + /// The value the cell shows over `fetched`, a cell left to the + /// server reading as NULL. + pub fn shown(self, fetched: Value) -> Value { + match self.pending { + Some(CellInput::Value(value)) => value, + Some(CellInput::Default) => Value::Null, + None => fetched, + } + } +} diff --git a/linux/crates/app/src/services/change_tracker.rs b/linux/crates/app/src/services/change_tracker.rs index b6c1576dae..a44bb5caa4 100644 --- a/linux/crates/app/src/services/change_tracker.rs +++ b/linux/crates/app/src/services/change_tracker.rs @@ -493,15 +493,15 @@ impl TabChangeTracker { } } - pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { + pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { CellOverlay { - value: self.current_cell_value(row_key, col, original), + pending: self.pending_input(row_key, col), state: self.cell_state(row_key, col), error_flash: self.error_row.as_ref() == Some(row_key), } } - pub fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { + fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { if matches!(row_key, RowKey::Draft(_)) { return CellState::InsertDraft; } @@ -511,9 +511,7 @@ impl TabChangeTracker { CellState::Clean } - /// A cell's pending value, `None` when it shows what was read. A - /// draft's cell is always the tracker's. - pub fn pending_input(&self, row_key: &RowKey, col: usize) -> Option { + fn pending_input(&self, row_key: &RowKey, col: usize) -> Option { match row_key { RowKey::Draft(draft_id) => self.draft_input(*draft_id, col), RowKey::Persisted(_) => self @@ -523,8 +521,19 @@ impl TabChangeTracker { } } - pub fn is_error_row(&self, row_key: &RowKey) -> bool { - self.error_row.as_ref() == Some(row_key) + /// A saved row's pending values among its first `width` columns, in + /// column order. + pub fn pending_values(&self, row_key: &RowKey, width: usize) -> Vec<(usize, Value)> { + if self.updates.is_empty() { + return Vec::new(); + } + let mut cell = (row_key.clone(), 0); + (0..width) + .filter_map(|col| { + cell.1 = col; + self.updates.get(&cell).map(|edit| (col, edit.pending.clone())) + }) + .collect() } /// Coarse row state. PendingDelete wins over Modified. @@ -565,23 +574,6 @@ impl TabChangeTracker { true } - /// A draft's cells live only here, so for a draft `original` is - /// what an out-of-range column reads as, and a cell left to the - /// server reads as NULL. - fn current_cell_value(&self, row_key: &RowKey, col: usize, original: &Value) -> Value { - if let RowKey::Draft(draft_id) = row_key { - return match self.draft_input(*draft_id, col) { - Some(CellInput::Value(value)) => value, - Some(CellInput::Default) => Value::Null, - None => original.clone(), - }; - } - match self.updates.get(&(row_key.clone(), col)) { - Some(edit) => edit.pending.clone(), - None => original.clone(), - } - } - /// A draft's values, with a cell left to the server as NULL. pub fn draft_values(&self, draft_id: u64) -> Option> { self.drafts @@ -1215,7 +1207,7 @@ mod tests { /// The cell as the grid draws it, over a fetched "a". fn pending(t: &TabChangeTracker, id: i64) -> Value { - t.cell_overlay(&rk(&[Value::Int(id)]), 1, &text("a")).value + t.cell_overlay(&rk(&[Value::Int(id)]), 1).shown(text("a")) } /// Each change as a verb and the key or draft it names, which is @@ -1755,14 +1747,11 @@ mod tests { input: CellInput::Value(Value::Text("b".into())), }); - let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1, &Value::Null); + let overlay = t.cell_overlay(&RowKey::Draft(draft_id), 1); - assert_eq!(overlay.value, Value::Text("b".into())); + assert_eq!(overlay.pending, Some(CellInput::Value(Value::Text("b".into())))); assert_eq!(overlay.state, CellState::InsertDraft); - assert_eq!( - t.cell_overlay(&RowKey::Draft(draft_id), 9, &Value::Null).value, - Value::Null - ); + assert_eq!(t.cell_overlay(&RowKey::Draft(draft_id), 9).pending, None); } #[test] @@ -1877,12 +1866,12 @@ mod tests { let fetched = Value::Text("a".into()); t.track_cell_edit(key.clone(), 1, fetched.clone(), Value::Text("b".into())); - let edited = t.cell_overlay(&key, 1, &fetched); - let untouched = t.cell_overlay(&key, 0, &Value::Int(1)); + let edited = t.cell_overlay(&key, 1); + let untouched = t.cell_overlay(&key, 0); - assert_eq!(edited.value, Value::Text("b".into())); + assert_eq!(edited.pending, Some(CellInput::Value(Value::Text("b".into())))); assert_eq!(edited.state, CellState::Modified); - assert_eq!(untouched.value, Value::Int(1)); + assert_eq!(untouched.pending, None, "a clean cell is drawn from its row"); assert_eq!(untouched.state, CellState::Clean); } @@ -2120,7 +2109,7 @@ mod tests { /// The cell as the grid draws it at `col`, over a fetched "Ada". fn pending_at(t: &TabChangeTracker, id: i64, col: usize) -> Value { - t.cell_overlay(&rk(&[Value::Int(id)]), col, &text("Ada")).value + t.cell_overlay(&rk(&[Value::Int(id)]), col).shown(text("Ada")) } #[test] @@ -2132,9 +2121,9 @@ mod tests { let new = t.flash_error_row(second.clone()); assert!(!t.clear_error_row_if(old)); - assert!(t.cell_overlay(&second, 0, &Value::Null).error_flash); - assert!(!t.cell_overlay(&first, 0, &Value::Null).error_flash); + assert!(t.cell_overlay(&second, 0).error_flash); + assert!(!t.cell_overlay(&first, 0).error_flash); assert!(t.clear_error_row_if(new)); - assert!(!t.cell_overlay(&second, 0, &Value::Null).error_flash); + assert!(!t.cell_overlay(&second, 0).error_flash); } } diff --git a/linux/crates/app/src/services/tab_tracker_handle.rs b/linux/crates/app/src/services/tab_tracker_handle.rs index 8d780550b4..55c73630a2 100644 --- a/linux/crates/app/src/services/tab_tracker_handle.rs +++ b/linux/crates/app/src/services/tab_tracker_handle.rs @@ -6,7 +6,7 @@ use tablepro_core::edit::CellInput; use tablepro_core::{ColumnInfo, Value}; use super::cell_overlay::CellOverlay; -use super::change_tracker::{CellState, DraftRow, RowKey, RowState, TabChangeTracker, TrackedChanges}; +use super::change_tracker::{DraftRow, RowKey, RowState, TabChangeTracker, TrackedChanges}; use super::save_batch::{SaveBatch, SaveTarget}; use super::tracker_delta::TrackerDelta; use super::tracker_edit::TrackerEdit; @@ -19,24 +19,16 @@ use crate::workspace::{PendingState, SaveOutcome, SaveRejection, SaveSnapshot}; pub struct TabTrackerHandle(Rc>); impl TabTrackerHandle { - pub fn cell_overlay(&self, row_key: &RowKey, col: usize, original: &Value) -> CellOverlay { - self.0.borrow().cell_overlay(row_key, col, original) + pub fn cell_overlay(&self, row_key: &RowKey, col: usize) -> CellOverlay { + self.0.borrow().cell_overlay(row_key, col) } pub fn row_state(&self, row_key: &RowKey) -> RowState { self.0.borrow().row_state(row_key) } - pub fn cell_state(&self, row_key: &RowKey, col: usize) -> CellState { - self.0.borrow().cell_state(row_key, col) - } - - pub fn pending_input(&self, row_key: &RowKey, col: usize) -> Option { - self.0.borrow().pending_input(row_key, col) - } - - pub fn is_error_row(&self, row_key: &RowKey) -> bool { - self.0.borrow().is_error_row(row_key) + pub fn pending_values(&self, row_key: &RowKey, width: usize) -> Vec<(usize, Value)> { + self.0.borrow().pending_values(row_key, width) } #[must_use = "the grid shows stale rows until the delta is applied"] @@ -232,8 +224,8 @@ mod tests { assert_eq!(delta.removed_drafts.len(), 1); assert!(tracker.drafts().is_empty(), "the inserted draft stayed pending"); assert_eq!(tracker.pending_state(), PendingState::Unsaved); - assert_eq!(tracker.cell_overlay(&row(1), 1, &Value::Int(0)).value, Value::Int(3)); - assert_eq!(tracker.cell_overlay(&row(4), 1, &Value::Int(0)).value, Value::Int(5)); + assert_eq!(tracker.cell_overlay(&row(1), 1).shown(Value::Int(0)), Value::Int(3)); + assert_eq!(tracker.cell_overlay(&row(4), 1).shown(Value::Int(0)), Value::Int(5)); } #[test] @@ -327,10 +319,7 @@ mod tests { assert_eq!(undone.changed, vec![row(1)]); assert_eq!(undone.pending_count, 1); - assert_eq!( - tracker.cell_overlay(&row(1), 1, &fetched).value, - Value::Text("b".into()) - ); + assert_eq!(tracker.cell_overlay(&row(1), 1).shown(fetched), Value::Text("b".into())); } #[test] @@ -361,7 +350,7 @@ mod tests { persisted: vec![(key.clone(), vec![Value::Int(1)].into())], drafts: Vec::new(), }); - assert_eq!(tracker.cell_overlay(key, 0, &Value::Int(1)).value, Value::Int(2)); + assert_eq!(tracker.cell_overlay(key, 0).shown(Value::Int(1)), Value::Int(2)); } } } diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index b6ce8f046b..9756fa5b81 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -1,7 +1,7 @@ use std::future::Future; use std::sync::Arc; -use tablepro_core::browse::{PageRequest, SortKey, build_count, build_page, split_page}; +use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, build_count, build_page}; use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; use tablepro_core::statement::StatementBuilder; use tablepro_core::{ColumnInfo, FilterSet, QueryResult, RowLimit}; @@ -123,14 +123,15 @@ impl BrowseModel { )?; let limit = RowLimit::new(plan.limit).unwrap_or(RowLimit::EDITOR_DEFAULT); let result = lease.query_bound(&spec.statement, limit, options).await?; - let page = split_page(result, &spec)?; - Ok(QueryResult::new(page.columns, page.rows).truncated(page.truncated)) + check_shape(&result, &spec.layout)?; + Ok((result, spec.layout)) } }) .await - .map(|result| PageReply { + .map(|(result, layout)| PageReply { position: reply_position, result, + layout, }) }; self.deliver(ticket, work, BrowseCommand::Page, OperationError::from); @@ -218,7 +219,7 @@ impl BrowseModel { /// stays with a toast; without one, the error page says why rather /// than a spinner that nothing will replace. fn on_filter_problem(&mut self, message: &str, fx: &mut Effects) { - if self.current_result.is_some() { + if self.page.is_some() { self.refresh_inner_stack_visibility(); fx.output(BrowseTabOutput::ShowToast(message.to_owned())); return; @@ -260,7 +261,7 @@ impl BrowseModel { match value { Ok(reply) => { let offset = reply.offset().unwrap_or(self.current_offset); - self.on_rows_loaded(offset, reply.result, fx); + self.on_rows_loaded(offset, reply, fx); } Err(error) => { self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); @@ -332,6 +333,19 @@ impl BrowseModel { } } +/// Every row has the columns the page asked for, visible then identity, +/// so the split on the GTK thread takes each row apart at the same place. +fn check_shape(result: &QueryResult, layout: &PageLayout) -> Result<(), BrowseSqlError> { + let expected = layout.width(); + let found = std::iter::once(result.columns.len()) + .chain(result.rows.iter().map(Vec::len)) + .find(|width| *width != expected); + match found { + Some(found) => Err(BrowseSqlError::ShapeMismatch { expected, found }), + None => Ok(()), + } +} + /// What a failed read was, for the log. A server's message can quote /// the statement it rejected, and with it a literal from the user's /// filter, so the log names the failure and leaves its text to the UI. diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 92695d31ec..81ef35d040 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -11,6 +11,7 @@ mod confirming_delete; mod fetch; mod imp; mod page_reply; +mod page_state; mod row_deletion; #[cfg(test)] mod tests; @@ -38,10 +39,12 @@ pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; use self::confirming_delete::ConfirmingDelete; pub(crate) use self::page_reply::PageReply; +use self::page_state::PageState; use self::row_deletion::{RowDeletion, plan_row_deletion}; use super::grid::{ - CellPreset, ColumnWidthBinding, DeleteAvailability, GridMsg, GridRowId, GridRowIndex, GridSink, RowObject, - TabGridContext, build_column_view, cell_editability, cell_presets, delete_availability, edit_context, edit_row, + CellPreset, ColumnWidthBinding, DeleteAvailability, GridContent, GridMsg, GridRowId, GridRowIndex, GridSink, + RowKeyBuilder, RowObject, TabGridContext, build_column_view, cell_editability, cell_presets, delete_availability, + edit_context, edit_row, export_snapshot, snapshot_result, }; use crate::runtime::{FailedRequests, RequestSequencer}; use crate::services::change_tracker::{RowKey, StatementSource, new_draft_cell, pk_identity}; @@ -182,7 +185,7 @@ impl BrowseTab { current_sort: init.initial_sort, current_filter: initial_filter, current_columns: Vec::new(), - current_result: None, + page: None, shown_view: None, current_selection: None, current_total_rows: None, @@ -288,10 +291,6 @@ impl BrowseTab { self.imp().model.try_borrow().ok()?.as_ref().map(read) } - pub fn snapshot(&self) -> Option { - self.read(|model| model.current_result.clone()).flatten() - } - pub fn columns(&self) -> Vec { self.read(|model| model.current_columns.clone()).unwrap_or_default() } @@ -567,9 +566,10 @@ pub(crate) struct BrowseModel { /// schema and table. current_filter: tablepro_core::FilterSet, current_columns: Vec, - current_result: Option, - /// What `current_result` was read under. A read that fails while - /// asking for that same page again leaves it on screen. + /// The page on screen, whose rows the grid's store holds. + page: Option, + /// What `page` was read under. A read that fails while asking for + /// that same page again leaves it on screen. shown_view: Option, current_selection: Option, current_total_rows: Option, @@ -760,17 +760,6 @@ impl BrowseModel { } } - /// A page's rows, each named by its primary key, which is the key - /// the tracker files its edits under. - fn page_rows(&self, result: &QueryResult) -> Vec { - let pk_indices = self.pk_indices(); - result - .rows - .iter() - .map(|cells| RowObject::persisted(persisted_key(cells, &pk_indices), cells.clone().into())) - .collect() - } - /// Checked again here because a keyboard shortcut or a late event /// can reach a locked cell. fn refuses_edit(&self, row: &RowObject, column: usize) -> bool { @@ -788,29 +777,16 @@ impl BrowseModel { false } - fn pk_indices(&self) -> Vec { - self.current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect() - } - - /// What the paginator's Export button writes. Built from the live - /// grid so it matches the context menu's Export Results row for - /// row; falls back to the fetch itself before the grid exists. + /// What the paginator's Export button writes: the grid's rows, so + /// it matches the context menu's Export Results row for row. fn export_payload(&self) -> Option { - let current = self.current_result.as_ref()?; - let Some(column_view) = self.current_column_view.as_ref() else { - return Some(current.clone()); - }; - Some(super::grid::export_snapshot( - column_view, - ¤t.columns, - current.truncated, - &self.grid_context(), - )) + let page = self.page.as_ref()?; + let rows = self + .current_column_view + .as_ref() + .map(|column_view| export_snapshot(column_view, &self.grid_context())) + .unwrap_or_default(); + Some(snapshot_result(page.result_columns.clone(), &rows, page.truncated)) } /// Resolve a preset against the column it lands in. The menu offers @@ -1080,40 +1056,49 @@ impl BrowseModel { } } - /// Build the `ColumnView` once both the columns and the current - /// page are in. Until then the stack stays on the loading page, so - /// the user cannot edit cells whose editability is not known yet. + /// The columns came back the same, so the page on screen still + /// stands under them: only the chrome follows. fn render_grid_if_ready(&mut self, fx: &mut Effects) { - let Some(result) = self.current_result.clone() else { - return; - }; - if self.current_columns.is_empty() { + if self.page.is_none() || !self.column_view_matches_current_columns() { return; } + self.refresh_grid_chrome(); + self.restore_focused_row(); + fx.output(BrowseTabOutput::StateChanged); + } - // Same columns as the last render (sort flips, page changes, - // save reloads): keep the `ColumnView` and only refresh the - // `ListStore`. + /// Put a page's rows on screen. Under the same columns the store + /// takes them in one splice, which the column view sees as a single + /// change; otherwise the view is built for them. + fn show_page(&mut self, rows: Vec, fx: &mut Effects) { + let Some(page) = self.page.as_ref() else { + return; + }; if self.column_view_matches_current_columns() && let Some(store) = self.list_store() { - self.refresh_grid_data(&result, &store); - self.refresh_grid_chrome(&result); + store.splice(0, store.n_items(), &rows); + self.reindex_rows(); + self.sync_delete_action(); + self.refresh_grid_chrome(); self.restore_focused_row(); fx.output(BrowseTabOutput::StateChanged); return; } clear_box(&self.grid_holder); - let tab_ctx = self.grid_context(); + let content = GridContent { + columns: page.result_columns.clone(), + rows, + truncated: page.truncated, + }; let (column_view, selection) = build_column_view( - &result, - self.page_rows(&result), + content, &self.current_columns, self.grid_sink.clone(), self.current_sort, true, - tab_ctx, + self.grid_context(), ); self.current_selection = Some(selection); self.current_column_view = Some(column_view.clone()); @@ -1138,9 +1123,6 @@ impl BrowseModel { update_selection_chrome(&self.selection_label, 0); } - // Pending draft rows survive page changes, sort flips and F5: - // the tracker holds them, and the rebuilt store would not. - self.reprepend_drafts(); self.reindex_rows(); self.sync_delete_action(); @@ -1150,7 +1132,7 @@ impl BrowseModel { .vexpand(true) .build(); self.grid_holder.append(&scrolled); - self.refresh_grid_chrome(&result); + self.refresh_grid_chrome(); self.restore_focused_row(); fx.output(BrowseTabOutput::StateChanged); } @@ -1184,18 +1166,6 @@ impl BrowseModel { self.show_loading(); } - /// New rows under the same columns. The drafts go back on top so - /// they survive the swap. - fn refresh_grid_data(&mut self, result: &QueryResult, store: >k::gio::ListStore) { - store.remove_all(); - for row in self.page_rows(result) { - store.append(&row); - } - self.reprepend_drafts(); - self.reindex_rows(); - self.sync_delete_action(); - } - fn reindex_rows(&mut self) { match self.list_store() { Some(store) => self.row_index.rebuild(&store), @@ -1204,14 +1174,14 @@ impl BrowseModel { } /// Paginator label, button sensitivity and stack child: chrome that - /// depends on the result but not the column structure. - fn refresh_grid_chrome(&self, result: &QueryResult) { + /// depends on the page but not the column structure. + fn refresh_grid_chrome(&self) { self.refresh_crud_buttons(); self.update_paginator_label(); let on_first_page = self.current_offset == 0; self.first_button.set_sensitive(!on_first_page); self.prev_button.set_sensitive(!on_first_page); - let n_rows = result.rows.len() as u64; + let n_rows = self.page.as_ref().map_or(0, |page| page.row_count as u64); self.next_button.set_sensitive(n_rows == self.page_size); // Last only enables when the total is known and the tab is not // already on the last page. @@ -1231,20 +1201,11 @@ impl BrowseModel { /// status page hid the column view, and inserting a draft into it /// then scrolled a view with no allocated adjustments. fn refresh_inner_stack_visibility(&self) { - if self.current_result.is_some() { + if self.page.is_some() { self.inner_stack.set_visible_child_name("grid"); } } - fn reprepend_drafts(&self) { - let Some(store) = self.list_store() else { - return; - }; - for draft in self.tracker.drafts() { - store.insert(0, &RowObject::draft(draft.draft_id)); - } - } - fn has_primary_key(&self) -> bool { self.current_columns.iter().any(|c| c.primary_key) } @@ -1292,11 +1253,11 @@ impl BrowseModel { } fn update_paginator_label(&self) { - let Some(result) = self.current_result.as_ref() else { + let Some(page) = self.page.as_ref() else { self.paginator_label.set_label(""); return; }; - let n_rows = result.rows.len(); + let n_rows = page.row_count; if n_rows == 0 { // Reachable when the user navigated past the end of a // table that shrank in another session, before the count @@ -1305,8 +1266,8 @@ impl BrowseModel { .set_label(&crate::i18n::gettext("No rows on this page")); return; } - let start = self.current_offset + 1; - let end = self.current_offset + n_rows as u64; + let start = page.start_ordinal + 1; + let end = page.start_ordinal + n_rows as u64; let start_s = format_thousands(start); let end_s = format_thousands(end); let label = match self.current_total_rows { @@ -1335,7 +1296,7 @@ impl BrowseModel { /// before are dropped, so a later reload does not render against /// them before its own page arrives. fn show_error(&mut self, message: &str) { - self.current_result = None; + self.page = None; self.shown_view = None; self.current_total_rows = None; self.paginator_label.set_label(""); @@ -1347,16 +1308,43 @@ impl BrowseModel { self.inner_stack.set_visible_child_name("error"); } - fn on_rows_loaded(&mut self, offset: u64, mut result: QueryResult, fx: &mut Effects) { + /// Take the page apart into rows the store owns. Each row's cells + /// move into it, and the identity cells after them name the row and + /// are dropped, so nothing past the visible columns reaches a copy + /// or an export. The tab's drafts stay on top. + fn on_rows_loaded(&mut self, offset: u64, reply: PageReply, fx: &mut Effects) { self.current_offset = offset; - // A zero-row page has no columns of its own; the catalog's list - // lets the headers render anyway. - if result.columns.is_empty() && !self.current_columns.is_empty() { - result.columns = result_columns(&self.current_columns); - } - self.current_result = Some(result); + let PageReply { result, layout, .. } = reply; + let QueryResult { + columns, + rows, + truncated, + } = result; + // The tracker saves a row by its primary key, so that is what + // names it here too. + let keys = RowKeyBuilder::for_page(&pk_identity(&self.current_columns), &self.current_columns, &layout); + let row_count = rows.len(); + let drafts = self.tracker.drafts(); + let mut objects = Vec::with_capacity(drafts.len() + row_count); + objects.extend(drafts.iter().rev().map(|draft| RowObject::draft(draft.draft_id))); + let visible_end = layout.visible.end; + objects.extend(rows.into_iter().map(|mut cells| { + let identity = cells.split_off(visible_end.min(cells.len())); + let key = keys.as_ref().and_then(|keys| keys.key_for(&cells, &identity)); + RowObject::persisted(key, cells.into()) + })); + let result_columns = match columns.len() == layout.visible.len() { + true => columns, + false => columns.get(layout.visible.clone()).map(Arc::from).unwrap_or_default(), + }; + self.page = Some(PageState { + result_columns, + truncated, + row_count, + start_ordinal: offset, + }); self.shown_view = Some(self.page_view()); - self.render_grid_if_ready(fx); + self.show_page(objects, fx); } fn page_view(&self) -> PageView { @@ -1372,7 +1360,7 @@ impl BrowseModel { /// and a schema change do, the page stays and a toast says why; any /// other page on screen no longer matches what the tab asks for. fn on_read_failed(&mut self, message: &str, fx: &mut Effects) { - if self.current_result.is_some() && self.shown_view.as_ref() == Some(&self.page_view()) { + if self.page.is_some() && self.shown_view.as_ref() == Some(&self.page_view()) { self.refresh_inner_stack_visibility(); fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( "Couldn't refresh the rows: {error}", @@ -1387,7 +1375,7 @@ impl BrowseModel { // A page read under other columns would put their names in the // headers, so the grid waits for the page read under these. if self.current_columns != columns { - self.current_result = None; + self.page = None; self.shown_view = None; self.drop_grid(); } @@ -1398,11 +1386,6 @@ impl BrowseModel { columns: columns.clone(), }); self.apply_tracker_delta(&delta, fx); - if let Some(result) = self.current_result.as_mut() - && result.columns.is_empty() - { - result.columns = result_columns(&columns); - } self.refresh_crud_buttons(); // Operator lists narrow per type, so the strip rebuilds against // the new columns. @@ -1611,7 +1594,7 @@ impl BrowseModel { BrowseTabInput::GridExportResults(mut result) => { // The grid owns the rows it shows; whether the fetch was // cut short belongs to the tab. - result.truncated = self.current_result.as_ref().is_some_and(|r| r.truncated); + result.truncated = self.page.as_ref().is_some_and(|page| page.truncated); fx.output(BrowseTabOutput::ExportResults(ExportRequest { result, name: self.export_name(), @@ -1875,7 +1858,12 @@ impl BrowseModel { .tracker .draft_cells(*draft_id, &self.current_columns) .unwrap_or_default(), - _ => ctx.effective_cells(row).into_iter().map(CellInput::Value).collect(), + _ => ctx + .snapshot(row) + .to_values() + .into_iter() + .map(CellInput::Value) + .collect(), }; match tablepro_core::export::render_insert(&*dialect, &self.table_ref(), &self.current_columns, &cells) { Ok(sql) => statements.push(sql), @@ -1913,7 +1901,7 @@ impl BrowseModel { let rows: Vec> = positions .iter() .filter_map(|position| selection.item(*position).and_downcast::()) - .map(|row| ctx.effective_cells(&row)) + .map(|row| ctx.snapshot(&row).to_values()) .collect(); if rows.is_empty() { return; @@ -1977,17 +1965,6 @@ fn clear_box(b: >k::Box) { } } -/// A fetched row's key from its primary key cells, in the table's -/// column order. `None` when the table has no primary key or the row is -/// too short to hold it. -fn persisted_key(cells: &[Value], pk_indices: &[usize]) -> Option { - let pk_values: Vec = pk_indices - .iter() - .map(|&i| cells.get(i).cloned()) - .collect::>()?; - RowKey::from_pk_values(&pk_values) -} - /// Update the selection-count badge in response to a `MultiSelection` /// change. Hidden for 0–1 rows; shows "{n} selected · press Delete to /// remove" once the user multi-selects, so the affordance stays diff --git a/linux/crates/app/src/ui/browse_tab/page_reply.rs b/linux/crates/app/src/ui/browse_tab/page_reply.rs index 3bac9deb07..0630cd83d4 100644 --- a/linux/crates/app/src/ui/browse_tab/page_reply.rs +++ b/linux/crates/app/src/ui/browse_tab/page_reply.rs @@ -1,10 +1,14 @@ use tablepro_core::QueryResult; +use tablepro_core::browse::PageLayout; use tablepro_core::meta::PagePosition; +/// A page as the server returned it, the row identity cells still after +/// the visible ones. #[derive(Debug, Clone)] pub(crate) struct PageReply { pub position: PagePosition, pub result: QueryResult, + pub layout: PageLayout, } impl PageReply { diff --git a/linux/crates/app/src/ui/browse_tab/page_state.rs b/linux/crates/app/src/ui/browse_tab/page_state.rs new file mode 100644 index 0000000000..c7569925fd --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/page_state.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; + +/// What the tab knows of the page on screen. Its rows are the grid's: +/// the store holds the only copy. +#[derive(Debug, Clone)] +pub(crate) struct PageState { + /// The page's visible columns. + pub result_columns: Arc<[ResultColumn]>, + /// The server stopped at the row limit. + pub truncated: bool, + pub row_count: usize, + /// How many rows of the table come before the page. + pub start_ordinal: u64, +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 1ba7ac0021..9c5410f096 100644 --- a/linux/crates/app/src/ui/browse_tab/tests.rs +++ b/linux/crates/app/src/ui/browse_tab/tests.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use std::time::Duration; +use gtk4::glib; use gtk4::prelude::*; use tablepro_core::column::{ColumnDefault, ColumnType}; use tablepro_core::edit::CellInput; @@ -12,7 +13,7 @@ use tablepro_session::fakes::FakeEstablish; use tablepro_test_support::{CallCounters, ConnectionScript, FakeConnection, FakeReply}; use super::row_deletion::RowDeletion; -use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands, persisted_key}; +use super::{BrowseTab, BrowseTabInit, BrowseTabInput, Effects, apply_draft_changes, format_thousands}; use crate::services::change_tracker::{RowKey, StatementSource}; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; @@ -79,47 +80,6 @@ fn format_thousands_handles_edges() { assert_eq!(format_thousands(999), "999"); } -#[test] -fn a_fetched_row_is_keyed_by_its_primary_key_cells() { - let cells = vec![Value::Int(42), Value::Text("alice".into())]; - - assert_eq!(persisted_key(&cells, &[0]), RowKey::from_pk_values(&[Value::Int(42)])); -} - -#[test] -fn a_composite_key_keeps_the_key_order() { - // Two tables with the same columns in a different key order must not - // produce colliding keys. - let cells = vec![ - Value::Text("eu-west".into()), - Value::Text("ignored".into()), - Value::Int(7), - ]; - - assert_eq!( - persisted_key(&cells, &[2, 0]), - RowKey::from_pk_values(&[Value::Int(7), Value::Text("eu-west".into())]) - ); -} - -#[test] -fn a_row_without_its_key_cells_is_unkeyed() { - assert!( - persisted_key(&[Value::Int(1)], &[]).is_none(), - "a table with no primary key" - ); - assert!(persisted_key(&[Value::Int(1)], &[5]).is_none()); - assert!(persisted_key(&[Value::Int(1)], &[0, 1]).is_none()); -} - -#[test] -fn a_null_key_cell_still_names_the_row() { - assert!(matches!( - persisted_key(&[Value::Null, Value::Text("x".into())], &[0]), - Some(RowKey::Persisted(_)) - )); -} - fn row_at(store: >k4::gio::ListStore, position: u32) -> RowObject { store.item(position).and_downcast::().expect("a row") } @@ -241,11 +201,30 @@ impl Harness { tab } + /// The second cell of each saved row on screen, empty while the + /// tab shows no page. fn shown(&self) -> Vec { - self.tab() - .snapshot() - .map(|result| result.rows.into_iter().filter_map(|row| row.get(1).cloned()).collect()) - .unwrap_or_default() + self.saved_rows() + .iter() + .filter_map(|row| row.cell(1).cloned()) + .collect() + } + + /// The saved rows the grid holds for the page on screen. + fn saved_rows(&self) -> Vec { + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = self.tab().imp().model.borrow(); + let Some(selection) = model + .as_ref() + .filter(|model| model.page.is_some()) + .and_then(|model| model.current_selection.clone()) + else { + return Vec::new(); + }; + (0..selection.n_items()) + .filter_map(|position| selection.item(position).and_downcast::()) + .filter(|row| row.draft_id().is_none()) + .collect() } fn visible_page(&self) -> Option { @@ -899,6 +878,255 @@ fn key(id: i64) -> RowKey { RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") } +/// `names` keyed from `first` on, as the next page of `rows` would be. +fn rows_from(first: i64, names: &[&str]) -> QueryResult { + let mut page = rows(names); + for (row, id) in page.rows.iter_mut().zip(first..) { + row[0] = Value::Int(id); + } + page +} + +/// A partitioned PostgreSQL table with no key, whose page carries the +/// partition, the row address and its version after the visible cells. +fn partitioned_table() -> ConnectionScript { + let server = tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0); + let mut script = ConnectionScript { + dialect: Arc::new(drivers_postgres::PgDialect::new(&server)), + ..ConnectionScript::default() + }; + script + .fetch_columns + .always(FakeReply::ok(vec![column("id", false), column("name", false)])); + script.row_identity.always(FakeReply::ok(RowIdentity::EngineRowId( + tablepro_core::meta::EngineRowId::PostgresTableoidCtid, + ))); + script.count_rows.always(FakeReply::ok(2)); + script.probe.always(FakeReply::ok(())); + let mut columns: Vec<_> = [column("id", false), column("name", false)] + .iter() + .map(ColumnInfo::result_column) + .collect(); + for hidden in ["__tablepro_row_oid", "__tablepro_row_id", "__tablepro_row_xmin"] { + columns.push(column(hidden, false).result_column()); + } + let rows = [("Ada", "(0,1)"), ("Grace", "(0,1)")] + .iter() + .zip(0..) + .map(|((name, ctid), id)| { + vec![ + Value::Int(id), + Value::Text((*name).to_owned()), + Value::Int(16_401 + id), + Value::Text((*ctid).to_owned()), + Value::Text("812".to_owned()), + ] + }) + .collect(); + script + .query_bound + .always(FakeReply::ok(QueryResult::new(columns, rows))); + script +} + +fn visible_only(values: &[Value]) { + assert_eq!(values.len(), 2, "a row carried hidden cells: {values:?}"); +} + +#[gtk4::test] +fn row_objects_hold_only_visible_cells() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + visible_only(&row.cells().expect("a saved row's cells")); + assert_eq!(row.key(), None, "a table with no primary key took edits"); + } + use gtk4::subclass::prelude::ObjectSubclassIsExt; + let model = harness.tab().imp().model.borrow(); + let page = model.as_ref().and_then(|model| model.page.clone()).expect("the page"); + assert_eq!(page.result_columns.len(), 2, "the grid was given the hidden columns"); +} + +#[gtk4::test] +fn page_rows_have_single_owner() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + + for row in harness.saved_rows() { + let cells = row.shared_cells().expect("a saved row's cells"); + assert_eq!(Arc::strong_count(cells), 1, "the tab kept its own copy of the page"); + } +} + +#[gtk4::test] +fn copy_as_insert_and_duplicate_contain_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let copied = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = copied.clone(); + harness + .tab() + .connect_copy_to_clipboard(move |_, text| sink.borrow_mut().push(text)); + let rows = harness.saved_rows(); + + harness.tab().send(BrowseTabInput::GridCopyRowsAsInsert(rows.clone())); + harness.tab().send(BrowseTabInput::DuplicateRows(rows)); + + let copied = copied.borrow(); + assert_eq!(copied.len(), 1); + for hidden in ["16401", "(0,1)", "812", "__tablepro"] { + assert!(!copied[0].contains(hidden), "{hidden} reached the copy: {}", copied[0]); + } + let drafts = harness.tracker.drafts(); + assert_eq!(drafts.len(), 2); + for draft in drafts { + assert_eq!( + draft.cells.len(), + 2, + "a duplicate carried hidden cells: {:?}", + draft.cells + ); + } +} + +#[gtk4::test] +fn page_export_snapshot_contains_no_hidden_columns() { + let harness = Harness::open(partitioned_table(), []); + wait_until(WAIT, || harness.saved_rows().len() == 2).expect("the first page"); + let exported = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let sink = exported.clone(); + harness + .tab() + .connect_export_results(move |_, request| sink.borrow_mut().push(request.result)); + + harness.tab().send(BrowseTabInput::ExportCurrentPage); + + let exported = exported.borrow(); + let [result] = exported.as_slice() else { + panic!("one export, not {}", exported.len()); + }; + let names: Vec<&str> = result.columns.iter().map(|column| column.name.as_str()).collect(); + assert_eq!(names, ["id", "name"]); + assert_eq!(result.rows.len(), 2); + for row in &result.rows { + visible_only(row); + } +} + +/// A browse page served first, then `next` for every page after it. +fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { + let mut script = serving_table(); + script.count_rows.always(FakeReply::ok(1_000)); + script + .query_bound + .once(FakeReply::ok(first)) + .always(FakeReply::ok(next)); + script +} + +/// Rows enough for a full page, so Next has somewhere to go. +fn full_page(first: i64) -> QueryResult { + let names: Vec = (0..100).map(|n| format!("row {n}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + rows_from(first, &names) +} + +#[gtk4::test] +fn page_load_emits_one_items_changed_with_drafts_prepended() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus", "Ken"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + harness.tab().send(BrowseTabInput::InsertRow); + let store = harness.store(); + let changes = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); + let log = changes.clone(); + store.connect_items_changed(move |_, position, removed, added| { + log.borrow_mut().push((position, removed, added)); + }); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus", "Ken"])).expect("the next page"); + + assert_eq!(*changes.borrow(), [(0, 101, 3)], "the page was not one splice"); + assert!(harness.grid_row(0).draft_id().is_some(), "the draft left the top"); + assert_eq!(harness.grid_row(1).key(), Some(&key(100))); +} + +#[gtk4::test] +fn second_page_load_drops_previous_rows() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let _shown = Shown::new(harness.tab()); + let _drawn = grid_cell_of(harness.tab(), 0, 1); + let first: Vec> = harness.saved_rows().iter().map(ObjectExt::downgrade).collect(); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + + wait_until(WAIT, || first.iter().all(|row| row.upgrade().is_none())).expect("the first page's rows were let go"); +} + +/// The row holding the focus leaves in the splice, which takes the +/// focus from it first: the open edit ends as a focus change ends it. +#[gtk4::test] +fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { + let harness = Harness::open(paging(full_page(0), rows_from(100, &["Linus"])), []); + wait_until(WAIT, || harness.shown().len() == 100).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = view + .columns() + .item(1) + .and_downcast::() + .expect("the name column"); + view.scroll_to(1, Some(&name), gtk4::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); + wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(1))).expect("a cell takes the focus"); + let cell = focused_cell().expect("the focused cell"); + let _editor = type_into(&cell, "typed"); + // A focus left in a row the splice took out of the window is what a + // window focus-in then trips over. + let stranded = std::rc::Rc::new(std::cell::Cell::new(false)); + let seen = stranded.clone(); + let window = shown.0.downgrade(); + harness.store().connect_items_changed(move |_, _, _, _| { + let focus = window + .upgrade() + .and_then(|window| gtk4::prelude::GtkWindowExt::focus(&window)); + seen.set(seen.get() || focus.is_some_and(|focus| focus.root().is_none())); + }); + + harness.tab().send(BrowseTabInput::NextPage); + wait_until(WAIT, || harness.shown() == text(&["Linus"])).expect("the next page"); + after_next_frame(&shown.0); + + assert!(!stranded.get(), "the focus stayed in a row the page removed"); + + assert!(!cell.is_editing(), "the edit outlived its row"); + let pending = harness.tracker.cell_overlay(&key(1), 1).pending; + assert!( + matches!(&pending, Some(CellInput::Value(Value::Other(typed))) if typed.text == "typed"), + "what was typed was lost, or landed on another row: {pending:?}" + ); + assert_eq!(harness.tracker.pending_count(), 1); + let focus = gtk4::prelude::GtkWindowExt::focus(&shown.0); + assert!( + focus.is_some_and(|focus| focus == *view.upcast_ref::() || focus.is_ancestor(&view)), + "the focus left the grid" + ); +} + +fn after_next_frame(window: >k4::Window) { + let clock = window.frame_clock().expect("the window's frame clock"); + let painted = std::rc::Rc::new(std::cell::Cell::new(false)); + let flag = painted.clone(); + let handler = clock.connect_after_paint(move |_| flag.set(true)); + clock.request_phase(gtk4::gdk::FrameClockPhase::AFTER_PAINT); + wait_until(WAIT, || painted.get()).expect("the window paints a frame"); + clock.disconnect(handler); +} + /// The cell showing `column` of the saved row keyed `id`, once drawn. fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { let key = key(id); @@ -1040,10 +1268,7 @@ fn rejected_edit_restores_cell_display() { vec![vec![Value::Int(0), Value::Int(36)]], ))); let harness = Harness::open(script, []); - wait_until(WAIT, || { - harness.tab().snapshot().is_some_and(|page| page.rows.len() == 1) - }) - .expect("the first page"); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); let toasts = toasts(harness.tab()); let _shown = Shown::new(harness.tab()); let cell = grid_cell_of(harness.tab(), 0, 1); @@ -1597,17 +1822,14 @@ fn one_cell(column: ColumnInfo, value: Value) -> (Harness, Shown) { vec![vec![Value::Int(0), value]], ))); let harness = Harness::open(script, []); - wait_until(WAIT, || { - harness.tab().snapshot().is_some_and(|page| page.rows.len() == 1) - }) - .expect("the first page"); + wait_until(WAIT, || harness.saved_rows().len() == 1).expect("the first page"); let shown = Shown::new(harness.tab()); (harness, shown) } /// The value the tracker holds for row 0's second cell. fn pending_value(harness: &Harness, fetched: &Value) -> Value { - harness.tracker.cell_overlay(&key(0), 1, fetched).value + harness.tracker.cell_overlay(&key(0), 1).shown(fetched.clone()) } /// Open the cell's one-line editor and paste `text` over its value diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index ccc4b5c3b6..8c2f3038a5 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -8,6 +8,7 @@ mod editor_session_slot; mod format_plan; mod imp; mod result_item; +mod result_rows; mod result_widgets; mod results_pane; mod run_history; diff --git a/linux/crates/app/src/ui/editor/result_rows.rs b/linux/crates/app/src/ui/editor/result_rows.rs new file mode 100644 index 0000000000..6fc810883c --- /dev/null +++ b/linux/crates/app/src/ui/editor/result_rows.rs @@ -0,0 +1,29 @@ +use std::sync::Arc; + +use tablepro_core::Value; +use tablepro_core::column::ResultColumn; + +/// A result set as the results list keeps it. The grid that shows it +/// shares each row, so a page built again for the same result copies no +/// cell. +#[derive(Debug, Clone)] +pub struct ResultRows { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec>, + /// The driver stopped at the row limit. + pub truncated: bool, +} + +impl ResultRows { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { + columns, + rows: Vec::new(), + truncated: false, + } + } + + pub fn row_count(&self) -> usize { + self.rows.len() + } +} diff --git a/linux/crates/app/src/ui/editor/result_widgets.rs b/linux/crates/app/src/ui/editor/result_widgets.rs index 89c175b456..058fa46c65 100644 --- a/linux/crates/app/src/ui/editor/result_widgets.rs +++ b/linux/crates/app/src/ui/editor/result_widgets.rs @@ -2,7 +2,7 @@ use gtk4::prelude::*; use libadwaita as adw; use super::run_log::{EntryKind, RunLogEntry}; -use crate::ui::grid::{GridSink, RowObject, TabGridContext, build_column_view}; +use crate::ui::grid::{GridContent, GridSink, RowObject, TabGridContext, build_column_view}; use crate::ui::icons; /// The widget a results list line shows. `go_to_error` puts the editor @@ -10,16 +10,13 @@ use crate::ui::icons; pub fn entry_widget(entry: &RunLogEntry, grid_sink: &GridSink, go_to_error: impl Fn(usize) + 'static) -> gtk4::Widget { match &entry.kind { EntryKind::ResultSet { result, total_rows } => { - let rows = result.rows.iter().map(|cells| RowObject::result(cells.clone().into())); - let (column_view, _selection) = build_column_view( - result, - rows, - &[], - grid_sink.clone(), - None, - false, - TabGridContext::default(), - ); + let content = GridContent { + columns: result.columns.clone(), + rows: result.rows.iter().cloned().map(RowObject::result).collect(), + truncated: result.truncated, + }; + let (column_view, _selection) = + build_column_view(content, &[], grid_sink.clone(), None, false, TabGridContext::default()); let scrolled = gtk4::ScrolledWindow::builder() .child(&column_view) .hexpand(true) diff --git a/linux/crates/app/src/ui/editor/run_log.rs b/linux/crates/app/src/ui/editor/run_log.rs index 3baf121063..d25594c525 100644 --- a/linux/crates/app/src/ui/editor/run_log.rs +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -1,14 +1,15 @@ use tablepro_core::sql_syntax::script::BatchErrorPolicy; use tablepro_core::{ - DriverError, EditorSessionState, ErrorCategory, QueryResult, ResultEvent, RunEnd, RunSummary, TransactionState, - Truncation, + DriverError, EditorSessionState, ErrorCategory, ResultEvent, RunEnd, RunSummary, TransactionState, Truncation, }; +use super::result_rows::ResultRows; + /// One line of the results list. #[derive(Debug, Clone)] pub enum EntryKind { ResultSet { - result: QueryResult, + result: ResultRows, /// What the server says the whole set held, when it says. total_rows: Option, }, @@ -108,7 +109,7 @@ impl BatchCollector { ResultEvent::ResultSetStarted { columns } => { self.sets.push(self.entries.len()); self.entries.push(EntryKind::ResultSet { - result: QueryResult::new(columns, Vec::new()), + result: ResultRows::new(columns), total_rows: None, }); } @@ -118,7 +119,7 @@ impl BatchCollector { } } ResultEvent::Rows(rows) => match self.last_set() { - Some(result) => result.rows.extend(rows), + Some(result) => result.rows.extend(rows.into_iter().map(Into::into)), // Rows with no columns would draw a grid that cannot say // what any cell is. None => tracing::warn!(rows = rows.len(), "rows arrived before their result set"), @@ -196,7 +197,7 @@ impl BatchCollector { (self.entries, verdict, transaction) } - fn set(&mut self, set_index: usize) -> Option<&mut QueryResult> { + fn set(&mut self, set_index: usize) -> Option<&mut ResultRows> { let index = *self.sets.get(set_index)?; match self.entries.get_mut(index) { Some(EntryKind::ResultSet { result, .. }) => Some(result), @@ -204,7 +205,7 @@ impl BatchCollector { } } - fn last_set(&mut self) -> Option<&mut QueryResult> { + fn last_set(&mut self) -> Option<&mut ResultRows> { let last = self.sets.len().checked_sub(1)?; self.set(last) } diff --git a/linux/crates/app/src/ui/grid/column_factory.rs b/linux/crates/app/src/ui/grid/column_factory.rs index e252f0ea6d..0cdd4ff26d 100644 --- a/linux/crates/app/src/ui/grid/column_factory.rs +++ b/linux/crates/app/src/ui/grid/column_factory.rs @@ -3,8 +3,8 @@ use std::rc::Rc; use gtk4::prelude::*; use gtk4::{self as gtk, glib}; +use tablepro_core::ColumnInfo; use tablepro_core::column::{ColumnKind, ResultColumn}; -use tablepro_core::{ColumnInfo, QueryResult}; use super::cell_edit::install_edit_triggers; use super::cell_editability::column_takes_edits; @@ -16,25 +16,27 @@ use super::grid_keyboard::GridKeyboard; use super::grid_menus::{GridMenuInit, GridMenus, install_grid_context_menus}; use super::row_factory::install_row_factory; use super::toggle_cell::ToggleCell; -use super::{GridCell, GridMsg, GridSink, RowObject, TabGridContext}; +use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext}; const WIDE_TABLE_THRESHOLD: usize = 8; /// About 14 characters at the default font size. const MIN_COLUMN_WIDTH_PX: i32 = 120; pub fn build_column_view( - result: &QueryResult, - rows: impl IntoIterator, + content: GridContent, schema_columns: &[ColumnInfo], sink: GridSink, sort: Option<(usize, bool)>, sortable: bool, tab_ctx: TabGridContext, ) -> (gtk::ColumnView, gtk::MultiSelection) { + let GridContent { + columns: result_columns, + rows, + truncated, + } = content; let store = gtk4::gio::ListStore::new::(); - for row in rows { - store.append(&row); - } + store.splice(0, 0, &rows); let selection = gtk::MultiSelection::new(Some(store)); // Tab leaves the grid, and the arrow keys move between cells. let column_view = gtk::ColumnView::builder() @@ -50,8 +52,8 @@ pub fn build_column_view( let grid_menus = install_grid_context_menus(GridMenuInit { column_view: &column_view, sink: sink.clone(), - columns: result.columns.clone(), - truncated: result.truncated, + columns: result_columns.clone(), + truncated, tab_ctx: tab_ctx.clone(), }); GridKeyboard::install(&column_view, grid_menus.clone(), sink.clone()); @@ -59,15 +61,15 @@ pub fn build_column_view( // Sharing the viewport by `expand` alone leaves a wide table with 20 // to 30 px columns that show nothing, so a column with no saved width // starts at a readable minimum there. - let default_min_width = if result.columns.len() > WIDE_TABLE_THRESHOLD { + let default_min_width = if result_columns.len() > WIDE_TABLE_THRESHOLD { Some(MIN_COLUMN_WIDTH_PX) } else { None }; - let mut columns: Vec = Vec::with_capacity(result.columns.len()); + let mut columns: Vec = Vec::with_capacity(result_columns.len()); let grid_columns = Rc::new(GridColumns::default()); let attributes = Rc::new(GridAttributes::new()); - for (i, column) in result.columns.iter().enumerate() { + for (i, column) in result_columns.iter().enumerate() { let col = build_column(ColumnInit { info: column, idx: i, @@ -294,8 +296,8 @@ mod tests { use gtk4::prelude::*; use gtk4::{self as gtk, glib}; + use tablepro_core::Value; use tablepro_core::edit::CellInput; - use tablepro_core::{ColumnInfo, QueryResult, Value}; use super::build_column_view; use crate::services::change_tracker::RowKey; @@ -305,8 +307,8 @@ mod tests { use crate::ui::grid::cell_editability::column_takes_edits; use crate::ui::grid::display_text::editable_null_sentinel; use crate::ui::grid::test_grid::{ - Grid, WAIT, col, display_text, edited_rows, flag_rows, key, keyed_by_id, named, persisted, recorder, text, - tracked, two_text_rows, type_into, + Grid, WAIT, col, content, display_text, edited_rows, flag_rows, key, keyed_by_id, named, persisted, recorder, + text, tracked, two_text_rows, type_into, }; use crate::ui::grid::{CellPreset, ColumnWidthBinding, GridCell, GridMsg, RowObject, TabGridContext, begin_edit}; @@ -738,10 +740,6 @@ mod tests { let root = tempfile::tempdir().expect("tempdir"); let columns = vec![named("name", "text", false)]; let rows = vec![vec![Value::Text("a".into())]]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); let (sink, _messages) = recorder(); let context = TabGridContext { editable: true, @@ -749,8 +747,7 @@ mod tests { ..TabGridContext::default() }; let (view, selection) = build_column_view( - &result, - persisted(&rows), + content(&columns, persisted(&rows)), &columns, sink, Some((0, true)), @@ -787,15 +784,10 @@ mod tests { named("flag", "boolean", false), ]; let rows = vec![vec![Value::Int(7), Value::Text("a".into()), Value::Bool(true)]]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); let tracker = TabTrackerHandle::default(); let (sink, _messages) = recorder(); let (view, selection) = build_column_view( - &result, - persisted(&rows), + content(&columns, persisted(&rows)), &columns, sink, None, diff --git a/linux/crates/app/src/ui/grid/grid_content.rs b/linux/crates/app/src/ui/grid/grid_content.rs new file mode 100644 index 0000000000..e65ca1968d --- /dev/null +++ b/linux/crates/app/src/ui/grid/grid_content.rs @@ -0,0 +1,16 @@ +use std::sync::Arc; + +use tablepro_core::column::ResultColumn; + +use super::RowObject; + +/// What a grid is built to show: the result's columns and its rows, +/// which the grid's store then owns. +#[derive(Debug, Clone)] +pub struct GridContent { + pub columns: Arc<[ResultColumn]>, + pub rows: Vec, + /// From the read that filled the grid, so Export Results reports the + /// same cut the footer does. + pub truncated: bool, +} diff --git a/linux/crates/app/src/ui/grid/grid_keyboard.rs b/linux/crates/app/src/ui/grid/grid_keyboard.rs index ba41486c93..0086dd9cd0 100644 --- a/linux/crates/app/src/ui/grid/grid_keyboard.rs +++ b/linux/crates/app/src/ui/grid/grid_keyboard.rs @@ -131,11 +131,11 @@ fn set_null(cell: &GridCell, sink: &GridSink) -> glib::Propagation { mod tests { use gtk4::prelude::*; use gtk4::{self as gtk, gdk}; - use tablepro_core::{ColumnInfo, QueryResult, Value}; + use tablepro_core::Value; use crate::application::APP_ACCELS; use crate::test_support::{descendants, wait_until}; - use crate::ui::grid::test_grid::{Grid, WAIT, edited_rows, key, named, recorder, text, two_text_rows}; + use crate::ui::grid::test_grid::{Grid, WAIT, content, edited_rows, key, named, recorder, text, two_text_rows}; use crate::ui::grid::{CellPreset, GridCell, GridMsg, RowObject, TabGridContext, begin_edit, build_column_view}; type Accel = (gdk::Key, gdk::ModifierType); @@ -444,15 +444,9 @@ mod tests { #[gtk4::test] fn a_result_grid_opens_its_read_only_menu_from_the_keyboard() { let columns = [named("id", "integer", true)]; - let rows = vec![vec![Value::Int(7)]]; - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); let (sink, _messages) = recorder(); let (view, _selection) = build_column_view( - &result, - rows.iter().map(|cells| RowObject::result(cells.clone().into())), + content(&columns, vec![RowObject::result(vec![Value::Int(7)].into())]), &[], sink, None, diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index d6dc45c6d9..f0417c3f86 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -11,7 +11,7 @@ use super::cell_preset::cell_presets; use super::context_menu::CellContext; use super::menu_rows::menu_rows_of; use super::row_json_dialog::show_row_json_dialog; -use super::selection::export_snapshot; +use super::selection::{export_snapshot, snapshot_result}; use super::{ CellPreset, DeleteAvailability, GridCell, GridMsg, GridSink, RowObject, TabGridContext, delete_availability, }; @@ -239,7 +239,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let s = sink.clone(); let tab = tab.clone(); rows_action!($name, |_cell, rows| { - let $rows: Vec<_> = rows.iter().map(|row| tab.effective_cells(row)).collect(); + let $rows = row_values(&tab, &rows); s($message); }) }}; @@ -261,15 +261,13 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let tab = tab.clone(); let cols = columns.clone(); rows_action!("copy", |cell, rows| { - let rows: Vec<_> = rows.iter().map(|row| tab.effective_cells(row)).collect(); let column = cell.column(); let message = match rows.as_slice() { [row] => GridMsg::CopyToClipboard( - row.get(column) - .and_then(|value| tablepro_core::export::cell_text(&cols, column, value)) + tablepro_core::export::cell_text(&cols, column, tab.snapshot(row).value(column)) .unwrap_or_default(), ), - many => clipboard_message(tablepro_core::export::render_tsv(&cols, many, false)), + many => clipboard_message(tablepro_core::export::render_tsv(&cols, &row_values(&tab, many), false)), }; s(message); }) @@ -322,7 +320,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let s = sink.clone(); let tab = tab.clone(); rows_action!("copy-in-clause", |cell, rows| { - let rows: Vec<_> = rows.iter().map(|row| tab.effective_cells(row)).collect(); + let rows = row_values(&tab, &rows); let clause = tablepro_core::export::render_in_clause(&rows, cell.column()); if clause.sql.is_empty() { s(GridMsg::ShowToast(crate::i18n::gettext( @@ -357,7 +355,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { let tab = tab.clone(); cell_action!("show-row-json", |_cell, row| { let Some(cv) = view.upgrade() else { return }; - let text = tablepro_core::export::row_to_json(&cols, &tab.effective_cells(&row)); + let text = tablepro_core::export::row_to_json(&cols, &tab.snapshot(&row).to_values()); show_row_json_dialog(&cv, text); }) }; @@ -384,7 +382,8 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { gio::ActionEntry::builder("export") .activate(move |_, _, _| { let Some(cv) = view.upgrade() else { return }; - s(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))); + let rows = export_snapshot(&cv, &tab); + s(GridMsg::ExportResults(snapshot_result(cols.clone(), &rows, truncated))); }) .build() }; @@ -504,6 +503,11 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { } } +/// The rows a menu item copies, as the grid shows them. +fn row_values(tab: &TabGridContext, rows: &[RowObject]) -> Vec> { + rows.iter().map(|row| tab.snapshot(row).to_values()).collect() +} + fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> Option { group .lookup_action(name) diff --git a/linux/crates/app/src/ui/grid/mod.rs b/linux/crates/app/src/ui/grid/mod.rs index 25e97b3305..494cccb51b 100644 --- a/linux/crates/app/src/ui/grid/mod.rs +++ b/linux/crates/app/src/ui/grid/mod.rs @@ -20,6 +20,7 @@ mod edit_snapshot; mod grid_attributes; mod grid_cell; mod grid_columns; +mod grid_content; mod grid_keyboard; mod grid_menus; mod grid_msg; @@ -30,7 +31,9 @@ mod menu_rows; mod multiline_editor; mod row_factory; mod row_json_dialog; +mod row_key_builder; mod row_object; +mod row_snapshot; mod selection; mod tab_grid_context; #[cfg(test)] @@ -49,9 +52,12 @@ pub(crate) use self::delete_availability::{DeleteAvailability, delete_availabili pub(crate) use self::edit_commit::edit_context; pub(crate) use self::edit_snapshot::EditSnapshot; pub(crate) use self::grid_cell::GridCell; +pub(crate) use self::grid_content::GridContent; pub(crate) use self::grid_msg::{GridMsg, GridSink}; pub(crate) use self::grid_row_id::GridRowId; pub(crate) use self::grid_row_index::GridRowIndex; +pub(crate) use self::row_key_builder::RowKeyBuilder; pub(crate) use self::row_object::RowObject; -pub(crate) use self::selection::{export_snapshot, selected_positions}; +pub(crate) use self::row_snapshot::RowSnapshot; +pub(crate) use self::selection::{export_snapshot, selected_positions, snapshot_result}; pub(crate) use self::tab_grid_context::TabGridContext; diff --git a/linux/crates/app/src/ui/grid/row_key_builder.rs b/linux/crates/app/src/ui/grid/row_key_builder.rs new file mode 100644 index 0000000000..bc67f336fd --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_key_builder.rs @@ -0,0 +1,231 @@ +use std::ops::Range; + +use tablepro_core::browse::PageLayout; +use tablepro_core::meta::RowIdentity; +use tablepro_core::{ColumnInfo, Value}; + +use crate::services::change_tracker::{KeyValue, RowKey}; + +/// Names each row of a page by every cell of the identity it was keyed +/// on, so two rows that share part of it never share a key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RowKeyBuilder { + source: KeySource, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum KeySource { + /// Positions among the visible cells, in key order. + Visible(Box<[usize]>), + /// Positions among the identity cells the page projected after the + /// visible ones. + Identity(Range), +} + +impl RowKeyBuilder { + /// `None` when nothing on the page names a row, so its rows are + /// read-only: a sorting key can match several rows. + pub fn for_page(identity: &RowIdentity, columns: &[ColumnInfo], layout: &PageLayout) -> Option { + let source = match identity { + RowIdentity::UniqueKey { columns: key } if !key.is_empty() => { + let positions = key + .iter() + .map(|name| { + columns + .iter() + .position(|column| column.name == *name) + .filter(|position| layout.visible.contains(position)) + }) + .collect::>>()?; + KeySource::Visible(positions) + } + RowIdentity::EngineRowId(_) if !layout.identity.is_empty() => KeySource::Identity(0..layout.identity.len()), + RowIdentity::UniqueKey { .. } + | RowIdentity::EngineRowId(_) + | RowIdentity::SortingKey { .. } + | RowIdentity::Unordered => return None, + }; + Some(Self { source }) + } + + /// The row's key, `None` when a cell of it could not be read and so + /// cannot name the row to the server again. + pub fn key_for(&self, visible: &[Value], identity: &[Value]) -> Option { + let parts = match &self.source { + KeySource::Visible(positions) => positions + .iter() + .map(|position| visible.get(*position).filter(|value| readable(value))) + .map(|value| value.cloned().map(KeyValue::new)) + .collect::>>()?, + // A null row address names nothing. + KeySource::Identity(range) => identity + .get(range.clone())? + .iter() + .map(|value| (readable(value) && !value.is_null()).then(|| KeyValue::new(value.clone()))) + .collect::>>()?, + }; + Some(RowKey::Persisted(parts)) + } +} + +fn readable(value: &Value) -> bool { + !matches!(value, Value::Undecodable(_)) +} + +#[cfg(test)] +mod tests { + use tablepro_core::Value; + use tablepro_core::browse::{PageLayout, PageRequest, build_page}; + use tablepro_core::column::{ColumnDefault, ColumnType}; + use tablepro_core::meta::{EngineRowId, PagePosition, RowIdentity, TableRef}; + use tablepro_core::value::{UndecodableReason, UndecodedValue}; + use tablepro_core::{ColumnInfo, FilterSet}; + + use super::RowKeyBuilder; + use crate::services::change_tracker::RowKey; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn columns(names: &[&str]) -> Vec { + names.iter().map(|name| column(name)).collect() + } + + /// The layout the page builder gives `identity` over `columns`. + fn layout(identity: &RowIdentity, columns: &[ColumnInfo]) -> PageLayout { + let dialect = drivers_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + build_page( + &dialect, + &PageRequest { + table: &TableRef::unqualified("t"), + columns, + filter: &FilterSet::default(), + sort: &[], + identity, + position: &PagePosition::First, + limit: 100, + }, + ) + .expect("a page") + .layout + } + + fn key(values: &[Value]) -> Option { + RowKey::from_pk_values(values) + } + + fn text(value: &str) -> Value { + Value::Text(value.to_owned()) + } + + #[test] + fn row_key_builder_unique_key_uses_visible_key_columns() { + let columns = columns(&["tenant", "name", "id"]); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned(), "tenant".to_owned()], + }; + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + let row = [text("eu"), text("Ada"), Value::Int(7)]; + + assert!(layout.identity.is_empty(), "a unique key needs no hidden column"); + assert_eq!(builder.key_for(&row, &[]), key(&[Value::Int(7), text("eu")])); + } + + #[test] + fn row_key_builder_tableoid_ctid_keys_on_both_identity_columns() { + let columns = columns(&["name"]); + let identity = RowIdentity::EngineRowId(EngineRowId::PostgresTableoidCtid); + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + let ctid = text("(0,1)"); + let xmin = text("812"); + + let first = builder.key_for(&[text("a")], &[Value::Int(16_401), ctid.clone(), xmin.clone()]); + let second = builder.key_for(&[text("a")], &[Value::Int(16_402), ctid, xmin]); + + assert_eq!(layout.identity.len(), 3, "tableoid, ctid and xmin"); + assert!(first.is_some()); + assert_ne!(first, second, "a ctid from another partition named the same row"); + } + + #[test] + fn row_key_builder_rowid_uses_single_identity_column() { + let columns = columns(&["name", "note"]); + let identity = RowIdentity::EngineRowId(EngineRowId::SqliteRowid); + let layout = tablepro_core::browse::PageLayout { + visible: 0..2, + identity: 2..3, + }; + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + assert_eq!( + builder.key_for(&[text("a"), text("b")], &[Value::Int(11)]), + key(&[Value::Int(11)]) + ); + assert_eq!( + builder.key_for(&[text("a"), text("b")], &[Value::Null]), + None, + "a null row address named a row" + ); + } + + #[test] + fn row_key_builder_returns_none_for_undecodable_key() { + let unreadable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geography".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let columns = columns(&["id", "name"]); + let identity = RowIdentity::UniqueKey { + columns: vec!["id".to_owned()], + }; + let layout = layout(&identity, &columns); + let builder = RowKeyBuilder::for_page(&identity, &columns, &layout).expect("a keyed page"); + + assert_eq!(builder.key_for(&[unreadable, text("a")], &[]), None); + assert_eq!( + builder.key_for(&[Value::Null, text("a")], &[]), + key(&[Value::Null]), + "a NULL key cell still names its row, as SQLite allows" + ); + } + + #[test] + fn a_page_nothing_names_has_no_builder() { + let columns = columns(&["day", "hits"]); + let layout = PageLayout { + visible: 0..2, + identity: 2..2, + }; + let sorting = RowIdentity::SortingKey { + expressions: vec!["day".to_owned()], + }; + let missing = RowIdentity::UniqueKey { + columns: vec!["gone".to_owned()], + }; + + assert_eq!(RowKeyBuilder::for_page(&sorting, &columns, &layout), None); + assert_eq!( + RowKeyBuilder::for_page(&RowIdentity::Unordered, &columns, &layout), + None + ); + assert_eq!(RowKeyBuilder::for_page(&missing, &columns, &layout), None); + assert_eq!( + RowKeyBuilder::for_page(&RowIdentity::UniqueKey { columns: Vec::new() }, &columns, &layout), + None, + "a table with no primary key" + ); + } +} diff --git a/linux/crates/app/src/ui/grid/row_object.rs b/linux/crates/app/src/ui/grid/row_object.rs index 869ca8fe25..8d89294aff 100644 --- a/linux/crates/app/src/ui/grid/row_object.rs +++ b/linux/crates/app/src/ui/grid/row_object.rs @@ -91,6 +91,12 @@ impl RowObject { self.imp().cells.get()?.get(column) } + /// The row's own hold on its cells, to count who else holds them. + #[cfg(test)] + pub(crate) fn shared_cells(&self) -> Option<&Arc<[Value]>> { + self.imp().cells.get() + } + pub(crate) fn bump_revision(&self) { let revision = &self.imp().revision; revision.set(revision.get().wrapping_add(1)); diff --git a/linux/crates/app/src/ui/grid/row_snapshot.rs b/linux/crates/app/src/ui/grid/row_snapshot.rs new file mode 100644 index 0000000000..4a98b76d1f --- /dev/null +++ b/linux/crates/app/src/ui/grid/row_snapshot.rs @@ -0,0 +1,108 @@ +use std::sync::Arc; + +use tablepro_core::Value; + +static NULL: Value = Value::Null; + +/// A row as the grid shows it, pending edits included, for a copy or an +/// export. It shares the cells the page read and holds its own copy of +/// only the cells the user changed, and it can leave the GTK thread. +#[derive(Debug, Clone)] +pub struct RowSnapshot { + cells: Arc<[Value]>, + /// Pending values by column, in column order. + overrides: Box<[(usize, Value)]>, +} + +impl RowSnapshot { + pub fn new(cells: Arc<[Value]>, overrides: Vec<(usize, Value)>) -> Self { + Self { + cells, + overrides: overrides.into_boxed_slice(), + } + } + + /// NULL for a column past the row's end. + pub fn value(&self, column: usize) -> &Value { + match self.overrides.binary_search_by_key(&column, |(col, _)| *col) { + Ok(index) => &self.overrides[index].1, + Err(_) => self.cells.get(column).unwrap_or(&NULL), + } + } + + pub fn len(&self) -> usize { + self.cells.len() + } + + /// The row as the owned cells an encoder takes. + pub fn to_values(&self) -> Vec { + (0..self.len()).map(|column| self.value(column).clone()).collect() + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use tablepro_core::Value; + + use super::RowSnapshot; + use crate::services::change_tracker::RowKey; + use crate::services::tab_tracker_handle::TabTrackerHandle; + use crate::services::tracker_edit::TrackerEdit; + use crate::ui::grid::{RowObject, TabGridContext}; + + #[test] + fn row_snapshot_shares_unmodified_cells() { + let tracker = TabTrackerHandle::default(); + let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: key.clone(), + col: 2, + original: Value::Text("old".into()), + value: Value::Text("new".into()), + }); + let cells: Arc<[Value]> = vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("old".into())].into(); + let row = RowObject::persisted(Some(key), cells.clone()); + let context = TabGridContext { + tracker: Some(tracker), + ..TabGridContext::default() + }; + + let snapshot = context.snapshot(&row); + + assert!(Arc::ptr_eq(&snapshot.cells, &cells), "the row's cells were copied"); + assert_eq!(snapshot.overrides.len(), 1, "an unedited cell was copied"); + assert_eq!(snapshot.overrides[0].0, 2); + assert_eq!( + snapshot.to_values(), + vec![Value::Int(1), Value::Text("Ada".into()), Value::Text("new".into())] + ); + assert_eq!(snapshot.value(9), &Value::Null); + } + + #[test] + fn a_draft_snapshot_reads_a_default_as_null() { + let tracker = TabTrackerHandle::default(); + let delta = tracker.apply(TrackerEdit::InsertDrafts { + rows: vec![vec![ + tablepro_core::edit::CellInput::Default, + tablepro_core::edit::CellInput::Value(Value::Int(3)), + ]], + }); + let draft = RowObject::draft(delta.inserted_drafts[0]); + let context = TabGridContext { + tracker: Some(tracker), + ..TabGridContext::default() + }; + + assert_eq!(context.snapshot(&draft).to_values(), vec![Value::Null, Value::Int(3)]); + } + + #[test] + fn a_snapshot_can_leave_the_gtk_thread() { + fn sendable() {} + + sendable::(); + } +} diff --git a/linux/crates/app/src/ui/grid/selection.rs b/linux/crates/app/src/ui/grid/selection.rs index 69621ceb8f..45d88b171b 100644 --- a/linux/crates/app/src/ui/grid/selection.rs +++ b/linux/crates/app/src/ui/grid/selection.rs @@ -1,10 +1,12 @@ +use std::sync::Arc; + use gtk4 as gtk; use gtk4::prelude::*; use tablepro_core::QueryResult; use tablepro_core::column::ResultColumn; -use super::{RowObject, TabGridContext}; +use super::{RowObject, RowSnapshot, TabGridContext}; pub(crate) fn selected_positions(selection: >k::MultiSelection) -> Vec { let bitset = selection.selection(); @@ -38,19 +40,19 @@ pub(super) fn select_row_for_menu(column_view: >k::ColumnView, position: u32) } /// The paginator's Export button and the menu's Export Results both -/// build their payload here, so one label cannot mean two files. -pub(crate) fn export_snapshot( - column_view: >k::ColumnView, - columns: &[ResultColumn], - truncated: bool, - ctx: &TabGridContext, -) -> QueryResult { - let rows = match column_view.model() { - Some(model) => (0..model.n_items()) - .filter_map(|p| row_at(column_view, p)) - .map(|r| ctx.effective_cells(&r)) - .collect(), - None => Vec::new(), +/// read the rows here, so one label cannot mean two files. +pub(crate) fn export_snapshot(column_view: >k::ColumnView, ctx: &TabGridContext) -> Vec { + let Some(model) = column_view.model() else { + return Vec::new(); }; - QueryResult::new(columns.to_vec(), rows).truncated(truncated) + (0..model.n_items()) + .filter_map(|position| row_at(column_view, position)) + .map(|row| ctx.snapshot(&row)) + .collect() +} + +/// Rows for an encoder that takes owned cells, under the columns the +/// grid shows. +pub(crate) fn snapshot_result(columns: Arc<[ResultColumn]>, rows: &[RowSnapshot], truncated: bool) -> QueryResult { + QueryResult::new(columns, rows.iter().map(RowSnapshot::to_values).collect()).truncated(truncated) } diff --git a/linux/crates/app/src/ui/grid/tab_grid_context.rs b/linux/crates/app/src/ui/grid/tab_grid_context.rs index 719a6548d7..da45135f44 100644 --- a/linux/crates/app/src/ui/grid/tab_grid_context.rs +++ b/linux/crates/app/src/ui/grid/tab_grid_context.rs @@ -1,8 +1,11 @@ +use std::sync::Arc; + use tablepro_core::Value; use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; -use super::{ColumnWidthBinding, GridRowId, RowObject}; +use super::{ColumnWidthBinding, GridRowId, RowObject, RowSnapshot}; +use crate::services::cell_overlay::CellOverlay; use crate::services::change_tracker::{CellState, RowKey, RowState}; use crate::services::tab_tracker_handle::TabTrackerHandle; @@ -43,32 +46,32 @@ impl TabGridContext { .map_or(RowState::Clean, |(tracker, key)| tracker.row_state(&key)) } - /// Every copy and export path reads this rather than - /// `RowObject::cells`, which holds a saved row as fetched and a - /// draft not at all. - pub(crate) fn effective_cells(&self, row: &RowObject) -> Vec { + /// The row as the grid shows it, for every copy and export path: + /// `RowObject::cells` holds a saved row as read and a draft not at + /// all. A draft's cell left to the server reads as NULL. + pub(crate) fn snapshot(&self, row: &RowObject) -> RowSnapshot { if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { - return tracker.draft_values(*draft_id).unwrap_or_default(); + return RowSnapshot::new(tracker.draft_values(*draft_id).unwrap_or_default().into(), Vec::new()); } - let Some(raw) = row.cells() else { - return Vec::new(); - }; - let Some((tracker, key)) = self.tracked_key(row) else { - return raw.to_vec(); - }; - raw.iter() - .enumerate() - .map(|(col, value)| tracker.cell_overlay(&key, col, value).value) - .collect() + let cells = row.cells().unwrap_or_else(|| Arc::from([])); + let overrides = self + .tracked_key(row) + .map(|(tracker, key)| tracker.pending_values(&key, cells.len())) + .unwrap_or_default(); + RowSnapshot::new(cells, overrides) } - /// Like `effective_cells`, except a draft's cell left to the server - /// stays `Default` rather than reading as NULL. + /// Like `snapshot`, except a draft's cell left to the server stays + /// `Default` rather than reading as NULL. pub(crate) fn effective_inputs(&self, row: &RowObject) -> Vec { if let (GridRowId::Draft(draft_id), Some(tracker)) = (row.id(), self.tracker.as_ref()) { return tracker.draft_inputs(*draft_id).unwrap_or_default(); } - self.effective_cells(row).into_iter().map(CellInput::Value).collect() + self.snapshot(row) + .to_values() + .into_iter() + .map(CellInput::Value) + .collect() } /// What an editor opens on: a draft's cell as the tracker holds it, @@ -86,7 +89,7 @@ impl TabGridContext { pub(crate) fn with_effective_value(&self, row: &RowObject, col: usize, f: impl FnOnce(&Value, bool) -> R) -> R { let pending = self .tracked_key(row) - .and_then(|(tracker, key)| tracker.pending_input(&key, col)); + .and_then(|(tracker, key)| tracker.cell_overlay(&key, col).pending); match (pending, row.cell(col)) { (Some(CellInput::Value(value)), _) => f(&value, false), (Some(CellInput::Default), _) => f(&Value::Null, true), @@ -101,21 +104,20 @@ impl TabGridContext { let Some((tracker, key)) = self.tracked_key(row) else { return (Vec::new(), CellState::Clean); }; - let state = tracker.cell_state(&key, col); - let error_flash = col == 0 && tracker.is_error_row(&key); - (pending_classes(tracker.row_state(&key), state, error_flash), state) + let overlay = tracker.cell_overlay(&key, col); + (pending_classes(tracker.row_state(&key), &overlay, col), overlay.state) } } -fn pending_classes(row_state: RowState, cell_state: CellState, error_flash: bool) -> Vec<&'static str> { +fn pending_classes(row_state: RowState, overlay: &CellOverlay, col: usize) -> Vec<&'static str> { let mut classes = Vec::new(); - match (row_state, cell_state) { + match (row_state, overlay.state) { (RowState::PendingDelete, _) => classes.push("tp-row-pending-delete"), (RowState::InsertDraft, _) => classes.push("tp-row-pending-insert"), (_, CellState::Modified) => classes.push("tp-cell-modified"), _ => {} } - if error_flash { + if col == 0 && overlay.error_flash { classes.push("tp-row-leftmost-error-flash"); } classes @@ -199,7 +201,7 @@ mod tests { } #[test] - fn effective_cells_include_pending_edits_and_draft_cells() { + fn snapshots_include_pending_edits_and_draft_cells() { let tracker = TabTrackerHandle::default(); let key = RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"); let _ = tracker.apply(TrackerEdit::Cell { @@ -212,11 +214,11 @@ mod tests { let saved = RowObject::persisted(Some(key), vec![Value::Int(1), Value::Text("old".into())].into()); assert_eq!( - context(&tracker).effective_cells(&saved), + context(&tracker).snapshot(&saved).to_values(), vec![Value::Int(1), Value::Text("new".into())] ); assert_eq!( - context(&tracker).effective_cells(&RowObject::draft(draft_id)), + context(&tracker).snapshot(&RowObject::draft(draft_id)).to_values(), vec![Value::Null, Value::Text("draft".into())] ); } @@ -232,7 +234,7 @@ mod tests { assert_eq!(context(&tracker).edit_input(&draft, 0), CellInput::Default); assert_eq!(context(&tracker).edit_input(&draft, 1), CellInput::Value(Value::Null)); assert_eq!( - context(&tracker).effective_cells(&draft), + context(&tracker).snapshot(&draft).to_values(), vec![Value::Null, Value::Null], "a copy has no value to give a default" ); diff --git a/linux/crates/app/src/ui/grid/test_grid.rs b/linux/crates/app/src/ui/grid/test_grid.rs index b6316699af..7633eb5bdd 100644 --- a/linux/crates/app/src/ui/grid/test_grid.rs +++ b/linux/crates/app/src/ui/grid/test_grid.rs @@ -7,9 +7,9 @@ use gtk4::{self as gtk, gio}; use tablepro_core::edit::CellInput; use tablepro_core::meta::RowIdentity; -use tablepro_core::{ColumnInfo, QueryResult, Value}; +use tablepro_core::{ColumnInfo, Value}; -use super::{GridCell, GridMsg, GridSink, RowObject, TabGridContext, begin_edit, build_column_view}; +use super::{GridCell, GridContent, GridMsg, GridSink, RowObject, TabGridContext, begin_edit, build_column_view}; use crate::services::change_tracker::RowKey; use crate::services::tab_tracker_handle::TabTrackerHandle; use crate::services::tracker_delta::TrackerDelta; @@ -80,6 +80,15 @@ pub(super) fn persisted(rows: &[Vec]) -> Vec { .collect() } +/// `rows` under the result columns of `columns`. +pub(super) fn content(columns: &[ColumnInfo], rows: Vec) -> GridContent { + GridContent { + columns: columns.iter().map(ColumnInfo::result_column).collect(), + rows, + truncated: false, + } +} + pub(super) fn recorder() -> (GridSink, Rc>>) { let messages = Rc::new(RefCell::new(Vec::new())); let log = messages.clone(); @@ -98,15 +107,10 @@ pub(super) struct Grid { impl Grid { /// `rows` keyed by their first cell, in a grid that edits them. pub(super) fn open(columns: Vec, rows: Vec>) -> Self { - let result = QueryResult::new( - columns.iter().map(ColumnInfo::result_column).collect::>(), - rows.clone(), - ); let tracker = TabTrackerHandle::default(); let (sink, messages) = recorder(); let (view, selection) = build_column_view( - &result, - persisted(&rows), + content(&columns, persisted(&rows)), &columns, sink, None, diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 88f531b348..86f2962831 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -91,6 +91,7 @@ crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs crates/app/src/ui/browse_tab/mod.rs crates/app/src/ui/browse_tab/page_reply.rs +crates/app/src/ui/browse_tab/page_state.rs crates/app/src/ui/browse_tab/row_deletion.rs crates/app/src/ui/browse_tab/tests.rs crates/app/src/ui/connect_dialog/connect_form.rs @@ -130,6 +131,7 @@ crates/app/src/ui/editor/format_plan.rs crates/app/src/ui/editor/imp.rs crates/app/src/ui/editor/mod.rs crates/app/src/ui/editor/result_item.rs +crates/app/src/ui/editor/result_rows.rs crates/app/src/ui/editor/result_widgets.rs crates/app/src/ui/editor/results_pane.rs crates/app/src/ui/editor/run_history.rs @@ -180,6 +182,7 @@ crates/app/src/ui/grid/edit_snapshot.rs crates/app/src/ui/grid/grid_attributes.rs crates/app/src/ui/grid/grid_cell.rs crates/app/src/ui/grid/grid_columns.rs +crates/app/src/ui/grid/grid_content.rs crates/app/src/ui/grid/grid_keyboard.rs crates/app/src/ui/grid/grid_menus.rs crates/app/src/ui/grid/grid_msg.rs @@ -191,7 +194,9 @@ crates/app/src/ui/grid/mod.rs crates/app/src/ui/grid/multiline_editor.rs crates/app/src/ui/grid/row_factory.rs crates/app/src/ui/grid/row_json_dialog.rs +crates/app/src/ui/grid/row_key_builder.rs crates/app/src/ui/grid/row_object.rs +crates/app/src/ui/grid/row_snapshot.rs crates/app/src/ui/grid/selection.rs crates/app/src/ui/grid/tab_grid_context.rs crates/app/src/ui/grid/test_grid.rs From bf8dc7a4fe211436e61c3b04c2dc53c9622d35ed Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:38:47 +0700 Subject: [PATCH 110/301] fix(linux): hand over a credential prompt's Connect, not a cancel AdwAlertDialog emits closed before the response a click chose, and the closed handler answered Cancel first. Answer through workspace::respond, which defers its close response to an idle callback. --- .../app/src/ui/credential_prompt/presenter.rs | 73 ++++++++++++------- .../app/src/ui/session_window/secret_retry.rs | 6 +- 2 files changed, 49 insertions(+), 30 deletions(-) diff --git a/linux/crates/app/src/ui/credential_prompt/presenter.rs b/linux/crates/app/src/ui/credential_prompt/presenter.rs index 3cd0545d29..b1df2fec7d 100644 --- a/linux/crates/app/src/ui/credential_prompt/presenter.rs +++ b/linux/crates/app/src/ui/credential_prompt/presenter.rs @@ -1,6 +1,4 @@ -use std::cell::RefCell; use std::pin::pin; -use std::rc::Rc; use adw::prelude::*; use futures::future::{Either, select}; @@ -8,7 +6,7 @@ use libadwaita as adw; use tablepro_core::credentials::PromptReply; use tokio::sync::oneshot; -use super::prompt_form::{CANCEL, PromptForm}; +use super::prompt_form::PromptForm; use super::prompter::{PendingPrompt, PromptQueue}; /// Shows queued credential prompts over a window, one at a time. @@ -57,36 +55,12 @@ async fn response_while_wanted( parent: >k4::Widget, reply: &mut oneshot::Sender, ) -> Option { - match select(pin!(ask(dialog, parent)), pin!(reply.closed())).await { + match select(pin!(crate::workspace::respond(dialog, parent)), pin!(reply.closed())).await { Either::Left((response, _)) => Some(response), Either::Right(((), _)) => None, } } -/// Present the dialog and wait for the response it closes with. -/// -/// The closed signal answers too, as a cancel: a dialog torn down with -/// its window never sends a response, and the prompt must still end. -/// `choose_future` is not used because its callback aborts the process -/// when the dialog goes away without one. -async fn ask(dialog: &adw::AlertDialog, parent: >k4::Widget) -> String { - let (sender, receiver) = oneshot::channel(); - let sender = Rc::new(RefCell::new(Some(sender))); - let on_response = sender.clone(); - dialog.connect_response(None, move |_, response| { - if let Some(sender) = on_response.borrow_mut().take() { - let _ = sender.send(response.to_owned()); - } - }); - dialog.connect_closed(move |_| { - if let Some(sender) = sender.borrow_mut().take() { - let _ = sender.send(CANCEL.to_owned()); - } - }); - dialog.present(Some(parent)); - receiver.await.unwrap_or_else(|_| CANCEL.to_owned()) -} - #[cfg(test)] mod tests { use std::time::Duration; @@ -191,6 +165,49 @@ mod tests { crate::test_support::drain_main_context(); } + #[gtk4::test] + fn connect_hands_over_the_typed_password_with_animations_on_or_off() { + use secrecy::ExposeSecret; + + adw::init().expect("libadwaita initialises under the test backend"); + let settings = gtk4::Settings::default().expect("the default settings"); + let animations = settings.is_gtk_enable_animations(); + for enabled in [true, false] { + settings.set_gtk_enable_animations(enabled); + let window = adw::Window::new(); + window.present(); + let (prompter, queue) = GtkCredentialPrompter::new(); + let presenter = CredentialPromptPresenter::start(queue, &window); + let reply = std::rc::Rc::new(std::cell::RefCell::new(None)); + let asking = prompter.prompt(prompt("accepted")); + let sink = reply.clone(); + glib::spawn_future_local(async move { + *sink.borrow_mut() = Some(asking.await); + }); + crate::test_support::wait_until(Duration::from_secs(5), || open_alerts(&window).len() == 1) + .expect("the prompt"); + let rows = open_alerts(&window)[0].extra_child().expect("the prompt's rows"); + crate::test_support::descendants(&rows) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .expect("the password row") + .set_text("hunter2"); + + assert!(crate::workspace::answer(&window, super::super::prompt_form::ACCEPT)); + + crate::test_support::wait_until(Duration::from_secs(5), || reply.borrow().is_some()).expect("an answer"); + let answered = reply.borrow_mut().take(); + let Some(PromptReply::Submitted { values, .. }) = answered else { + panic!("Connect answered {answered:?} with animations {enabled}"); + }; + assert_eq!(values[0].expose_secret(), "hunter2"); + presenter.abort(); + window.close(); + crate::test_support::drain_main_context(); + } + settings.set_gtk_enable_animations(animations); + } + #[gtk4::test] fn a_prompt_whose_dialog_is_forced_shut_is_cancelled() { adw::init().expect("libadwaita initialises under the test backend"); diff --git a/linux/crates/app/src/ui/session_window/secret_retry.rs b/linux/crates/app/src/ui/session_window/secret_retry.rs index dcf2b1e385..c65d1853fc 100644 --- a/linux/crates/app/src/ui/session_window/secret_retry.rs +++ b/linux/crates/app/src/ui/session_window/secret_retry.rs @@ -258,8 +258,10 @@ mod window_tests { switch.set_active(remember); } } - alert.emit_by_name::<()>("response", &[&"accept"]); - alert.force_close(); + assert!( + crate::workspace::answer(window, "accept"), + "the prompt has no Connect button" + ); body } From a58a4ebf9d60e6f744bde9e2e95f621e27951ab4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:38:51 +0700 Subject: [PATCH 111/301] test(linux): answer test alerts by clicking their button --- linux/crates/app/src/test_support.rs | 2 +- .../app/src/test_support/alert_response.rs | 33 ++++++++++++++----- .../app/src/ui/delete_connection_dialog.rs | 16 ++++++--- .../app/src/ui/preferences_dialog/mod.rs | 2 +- .../app/src/workspace/dialog_response.rs | 14 +------- 5 files changed, 40 insertions(+), 27 deletions(-) diff --git a/linux/crates/app/src/test_support.rs b/linux/crates/app/src/test_support.rs index bc8e381113..0de2ec87de 100644 --- a/linux/crates/app/src/test_support.rs +++ b/linux/crates/app/src/test_support.rs @@ -12,7 +12,7 @@ mod wait_timed_out; mod widget_lookup; pub(crate) use accessible_label::assert_labelled; -pub(crate) use alert_response::respond; +pub(crate) use alert_response::{click_response, respond}; pub(crate) use fake_clocks::FakeClocks; pub(crate) use fake_reachability::{Attached, FakeProbe, attach_remote}; pub(crate) use fake_session::FakeSession; diff --git a/linux/crates/app/src/test_support/alert_response.rs b/linux/crates/app/src/test_support/alert_response.rs index 5540cc057a..791c491bf7 100644 --- a/linux/crates/app/src/test_support/alert_response.rs +++ b/linux/crates/app/src/test_support/alert_response.rs @@ -1,13 +1,30 @@ -use gtk4::glib; use gtk4::prelude::*; use libadwaita as adw; -use libadwaita::prelude::AdwDialogExt; +use libadwaita::prelude::*; -/// Answer `dialog` with `response` the way its button does: with the -/// response as the signal's detail too, which a handler connected for -/// that one response needs. The dialog is closed after, as a click -/// closes it. +/// Answer the presented `dialog` with `response` by clicking its button. +/// Emitting `response` by hand would skip the close a click makes first, +/// and with it the `closed` that reaches handlers before the response. pub(crate) fn respond(dialog: &adw::AlertDialog, response: &str) { - dialog.emit_by_name_with_details::<()>("response", glib::Quark::from_str(response), &[&response]); - dialog.force_close(); + assert!( + click_response(dialog, response), + "the dialog has no “{response}” button" + ); +} + +/// Click the button for `response` in `dialog`. False when it has none. +pub(crate) fn click_response(dialog: &adw::AlertDialog, response: &str) -> bool { + if !dialog.has_response(response) { + return false; + } + let label = dialog.response_label(response); + let Some(button) = super::descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.label().as_deref() == Some(label.as_str())) + else { + return false; + }; + button.emit_clicked(); + true } diff --git a/linux/crates/app/src/ui/delete_connection_dialog.rs b/linux/crates/app/src/ui/delete_connection_dialog.rs index 3fa7408fd9..342969720a 100644 --- a/linux/crates/app/src/ui/delete_connection_dialog.rs +++ b/linux/crates/app/src/ui/delete_connection_dialog.rs @@ -157,17 +157,25 @@ mod tests { #[gtk4::test] fn delete_hands_over_the_history_choice() { adw::init().expect("libadwaita initialises under the test backend"); - let asking = DeleteConnectionDialog::new("Prod"); + let window = adw::Window::new(); + window.present(); let chosen = Rc::new(Cell::new(None)); - let sink = chosen.clone(); - asking.connect_delete(move |options| sink.set(Some(options))); + let ask = || { + let asking = DeleteConnectionDialog::new("Prod"); + let sink = chosen.clone(); + asking.connect_delete(move |options| sink.set(Some(options))); + asking.dialog.present(Some(&window)); + asking + }; - crate::test_support::respond(&asking.dialog, CANCEL); + crate::test_support::respond(&ask().dialog, CANCEL); assert_eq!(chosen.get(), None, "Cancel deleted"); + let asking = ask(); asking.history.set_active(true); crate::test_support::respond(&asking.dialog, DELETE); assert_eq!(chosen.get(), Some(DeletionOptions { delete_history: true })); + window.close(); } #[gtk4::test] diff --git a/linux/crates/app/src/ui/preferences_dialog/mod.rs b/linux/crates/app/src/ui/preferences_dialog/mod.rs index 3f8cd6179c..424a604505 100644 --- a/linux/crates/app/src/ui/preferences_dialog/mod.rs +++ b/linux/crates/app/src/ui/preferences_dialog/mod.rs @@ -411,7 +411,7 @@ mod tests { crate::test_support::drain_main_context(); let alert = crate::test_support::first_descendant_of_type::(window).expect("the confirmation alert"); - alert.emit_by_name::<()>("response", &[&"clear"]); + crate::test_support::respond(&alert, "clear"); crate::test_support::drain_main_context(); } } diff --git a/linux/crates/app/src/workspace/dialog_response.rs b/linux/crates/app/src/workspace/dialog_response.rs index b5ce72d067..d42c320a21 100644 --- a/linux/crates/app/src/workspace/dialog_response.rs +++ b/linux/crates/app/src/workspace/dialog_response.rs @@ -43,19 +43,7 @@ pub(crate) async fn respond(dialog: &adw::AlertDialog, parent: >k::Widget) -> /// button, so the signals come in the order a user's click sends them. #[cfg(test)] pub(crate) fn answer(window: &impl IsA, response: &str) -> bool { - let Some(dialog) = visible_alert(window) else { - return false; - }; - let label = dialog.response_label(response); - let Some(button) = crate::test_support::descendants(&dialog) - .into_iter() - .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.label().as_deref() == Some(label.as_str())) - else { - return false; - }; - button.emit_clicked(); - true + visible_alert(window).is_some_and(|dialog| crate::test_support::click_response(&dialog, response)) } /// The alert `window` shows, if any. `window` is an `AdwWindow` or an From af488c29e32c81e6983b6ded0ddbdbd39ca47fd1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:38:56 +0700 Subject: [PATCH 112/301] docs(linux): say what a second launch forwards and when closed fires --- linux/ARCHITECTURE.md | 2 +- linux/crates/app/tests/application_uniqueness.rs | 2 +- linux/docs/decisions/0012-window-per-session.md | 6 +++--- linux/docs/state-management.md | 3 ++- linux/docs/testing.md | 10 +++++----- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index c3499288e2..4d0d9d520f 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -94,7 +94,7 @@ One second runtime remains. Relm4 runs a component's commands (`sender.command`, `TableProApplication` (`crates/app/src/application/`) is an `adw::Application` subclass. `run()` builds it with the `AppServices` every window shares: the driver registry, the connector and its deadlines, the `SessionRegistry`, the settings, the storage, `Tasks`, the process hold, the open-sessions record and the query history. -- **One process per profile.** GApplication owns the application id on the session bus, so a second launch hands its command line to the running process and exits. `--new-window` (`-n`) asks the running process for `app.new-window`. The development profile has an id of its own and runs beside the stable app. +- **One process per profile.** GApplication owns the application id on the session bus. A second launch parses its own options, sends the running process an activate, or an open with the files it was given, and exits. With `--new-window` (`-n`), `handle_local_options` sends `app.new-window` instead. The development profile has an id of its own and runs beside the stable app. - **Startup**, in the primary process only, adds the app actions (`new-window`, `quit`, `preferences`, `about`) and their accelerators, opens the query history, checks the askpass helper, ends SSH masters a crashed instance left running, and starts `SessionSignals`, which tells every session about network changes and a resume from suspend. - **Windows.** `open_window` builds a `SessionWindow` for a `WindowLaunch`: the picker, a saved connection, a file, or a session to restore. The application keeps each window in its `WindowRegistry` by window id, and a window reaches the others through its `WindowHost`, which holds the application weakly. - **Holds.** A `ProcessHold` keeps the process alive while work that must finish runs: a session closing, a save, a durable write, a file being resolved. The app's `GioProcessHold` is `gio::Application::hold`, so the process ends once the last window and the last hold are gone. diff --git a/linux/crates/app/tests/application_uniqueness.rs b/linux/crates/app/tests/application_uniqueness.rs index 5d729b7af5..bf8804fab6 100644 --- a/linux/crates/app/tests/application_uniqueness.rs +++ b/linux/crates/app/tests/application_uniqueness.rs @@ -1,4 +1,4 @@ -//! One TablePro per session: a second launch hands its command line to +//! One TablePro per session: a second launch forwards its activation to //! the running one over D-Bus and exits. #![expect( diff --git a/linux/docs/decisions/0012-window-per-session.md b/linux/docs/decisions/0012-window-per-session.md index b07daa670f..72de86b1ca 100644 --- a/linux/docs/decisions/0012-window-per-session.md +++ b/linux/docs/decisions/0012-window-per-session.md @@ -17,7 +17,7 @@ The process is one `TableProApplication`, an `adw::Application` subclass that ow - A workspace that never changes session has nothing to reset. Its teardown writes what it shows, closes its input queue and drops it before the session closes, so a late reply has nowhere to land. - Every connect claims the session's key in the `SessionRegistry` first, so a second window asking for the same database brings the first forward instead of connecting. -- GApplication hands a second launch's command line and files to the running process, so `--new-window` and Open With reach the windows that already exist, and Quit can ask each window through its own prompts. +- A second launch forwards an activate, the files it was given or `app.new-window` to the running process over D-Bus, so Open With and `--new-window` reach the windows that already exist, and Quit can ask each window through its own prompts. - Subclasses with templates are what GNOME's Rust apps and the gtk-rs book use. The input queue keeps what Relm4 gave: one message at a time, never handled inside the signal or action that sent it, and dropped once the widget is gone. ## Consequences @@ -35,6 +35,6 @@ The process is one `TableProApplication`, an `adw::Application` subclass that ow **One process-wide session service shared by every window.** Ties every window to one session's lifetime and brings back the reset problem one level up. -**A `RelmApp` root.** It builds an application of its own around one root component, which knows neither the other windows nor the app actions, so every second window, Quit and D-Bus activation would work around it. +**A `relm4::RelmApp` root.** It builds an application of its own around one root component, which knows neither the other windows nor the app actions, so every second window, Quit and D-Bus activation would work around it. -**A `flock` for one instance.** A hand-written gate for what GApplication already does, and it only refuses a second launch: that launch's command line and files still have to reach the running process. +**A `flock` for one instance.** A hand-written gate for what GApplication already does, and it only refuses a second launch: that launch's files and `--new-window` still have to reach the running process. diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index f581482db1..ed6b61bb18 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -72,7 +72,7 @@ The components that remain follow these conventions until a rewrite turns them i - Input reaches them through `deliver`, and their output reaches the subclass that hosts them through `connect_receiver` or `runtime::relm4_bridge`, which put it on that subclass's queue. - Their commands (`sender.command`, `oneshot_command`) run on Relm4's own runtime, not on `Tasks`. The history and saved-queries dialogs still load that way; new work goes through `spawn_and_deliver`. -- They present their dialogs on the window they belong to. `ComponentBuilder::transient_for` is banned, because Relm4 applies it only from `RelmApp`, which this app does not use. +- They present their dialogs on the window they belong to. `ComponentBuilder::transient_for` is banned, because Relm4 applies it only from `relm4::RelmApp`, which this app does not use. ## Naming @@ -84,6 +84,7 @@ The components that remain follow these conventions until a rewrite turns them i ## Alert dialogs - `AdwAlertDialog` closes itself before it emits `response`. A response handler that calls `close()` again finds the dialog gone, and libadwaita logs a critical when animations are off. +- That close emits `closed` before the `response` the click chose. A `closed` handler that answers at once turns every click into the close response. `workspace::respond` defers its close response to an idle callback, so the click's response comes first. - To wait for an answer, use `workspace::respond`, which also answers when the dialog goes without a response. `choose_future` is not used: its callback aborts the process in that case. ## Anti-patterns to flag in review diff --git a/linux/docs/testing.md b/linux/docs/testing.md index 4e051dfdd4..ce71c18e81 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -4,7 +4,7 @@ Three layers, three tools. Each crate's test policy follows from its position in | Crate | Layer | Tools | Required for merge? | |---|---|---|---| -| `core` | Pure traits + types | Unit tests in `src/`, table-driven for type mappers | Yes | +| `core` | Pure traits + types | Unit tests in `crates/core/src/`, table-driven for type mappers | Yes | | `storage` | Filesystem + libsecret + GSchema | Unit tests + integration tests with `tempfile` | Yes | | `session` | Sessions, connect, health | Unit tests with the `fakes` feature + `testcontainers-rs` tests | Yes | | `drivers/` | Real engines | Unit tests, the conformance suites, `testcontainers-rs` integration tests | Yes | @@ -234,7 +234,7 @@ CI sets `GTK_A11Y=test` so results never depend on an AT-SPI bus. With `GTK_A11Y Two harnesses build the app's real objects over fakes: -- `TestWindow` (`ui/session_window/tests.rs`) starts a `TableProApplication` with one window on the picker, a saved connection in its list and a `FakeConnector`. `TestWindow::connected` opens that connection and waits for its workspace. Each test registers an application id of its own, so it never meets another test, or a TablePro the developer has open, on the session bus. `test.hold` is the application's `CountingHold`. +- `TestWindow` (`crates/app/src/ui/session_window/tests.rs`) starts a `TableProApplication` with one window on the picker, a saved connection in its list and a `FakeConnector`. `TestWindow::connected` opens that connection and waits for its workspace. Each test registers an application id of its own, so it never meets another test, or a TablePro the developer has open, on the session bus. `test.hold` is the application's `CountingHold`. - `workspace::test_workspace::Workspace` builds a `WorkspaceHandle` with one tab over a scripted connection, for the save and close prompt tests. Both turn GTK animations off, so a dialog or a page change finishes before the next line runs. @@ -250,7 +250,7 @@ A `#[gtk4::test]` has no tokio runtime. `test_runtime()` builds an `AppRuntime` ### Dialogs - `workspace::answer(window, response)` clicks the button of the alert `window` shows, so the alert closes itself and emits its response in the order a user's click does. `workspace::visible_alert(window)` returns that alert. -- `test_support::respond(dialog, response)` emits a response on a dialog the test holds, then force-closes it. +- `test_support::respond(dialog, response)` clicks the button of a presented alert the test holds. Never emit `response` by name: that skips the close a click makes first, so a `closed` handler sees the signals in an order no user can produce. A test that a step logs no GLib critical turns criticals fatal around that step and restores the old mask after: @@ -261,7 +261,7 @@ crate::test_support::drain_main_context(); glib::log_set_always_fatal(previous); ``` -A critical then aborts the test process, and the runner reports the test as failed with the message, for example libadwaita's "Trying to close AdwAlertDialog ... that's not presented". +A critical then aborts the process with the message, for example libadwaita's "Trying to close AdwAlertDialog ... that's not presented". cargo-nextest runs each test in a process of its own, so it reports that one test as failed. `cargo test`, which the Meson `gtk` suite runs, has every test of the `tablepro` library in one process: the abort ends the whole binary and the tests after it never run. ### Fakes @@ -273,7 +273,7 @@ The tests in `crates/app/tests` that start TablePro run without the test harness | Test | What it checks | |---|---| -| `application_uniqueness` | a second launch hands its command line to the running instance and exits without opening a window, and `--new-window` opens one there | +| `application_uniqueness` | a second plain launch activates the running instance and exits without opening a window, and `--new-window` opens one there | | `application_lifecycle` | Quit waits for a session's close within the close timeout and gives up on a close that hangs, a cancelled close keeps the app running, and an editor session's close holds the process | | `application_session_restore` | the windows open at quit open again at the next launch, and a window closed first does not | | `open_files` | a file handed over D-Bus opens a window on it, the same file brings that window forward, and another file opens a second window | From fadd32c8db7ba2e48081a2530fdc339695f6474e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:38:57 +0700 Subject: [PATCH 113/301] ci(linux): check the paths and types the app docs name --- .github/workflows/build-linux.yml | 10 ++++++++ scripts/check-doc-symbols.sh | 39 ++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 11e0e614f9..32bca21ca1 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -131,6 +131,16 @@ jobs: echo "::error::add window actions with add_action_entries on the window, not a group inserted as win" exit 1 fi + - name: Docs name paths and types that exist + # A renamed type or a moved file stays named in prose until + # something asks. GTK and GLib names resolve against the GIR + # files the -dev packages install. + run: | + ../scripts/check-doc-symbols.sh \ + linux/ARCHITECTURE.md \ + linux/docs/state-management.md \ + linux/docs/testing.md \ + linux/docs/decisions/0012-window-per-session.md - name: Clippy run: meson devenv -C _build cargo clippy --locked --workspace --all-targets -- -D warnings - name: Clippy without default features diff --git a/scripts/check-doc-symbols.sh b/scripts/check-doc-symbols.sh index 08eb2630a2..f1a93de46b 100755 --- a/scripts/check-doc-symbols.sh +++ b/scripts/check-doc-symbols.sh @@ -6,7 +6,7 @@ # script stays named in the docs until something asks. This asks. # # Scope: CLAUDE.md, AGENTS.md, the same two under linux/, and every .md under .claude/rules and -# .claude/skills. +# .claude/skills. Documents named on the command line replace that set. # # What is checked. Only mechanical claims, because those are the ones that rot silently: # paths a backticked repo-relative path must exist @@ -27,6 +27,7 @@ # Usage: # scripts/check-doc-symbols.sh # exit 1 if anything is stale # scripts/check-doc-symbols.sh --list # also print what passed and what was skipped +# scripts/check-doc-symbols.sh [--list] linux/ARCHITECTURE.md ... # check only these set -uo pipefail @@ -34,15 +35,25 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$REPO_ROOT" || exit 3 LIST=0 -[ "${1:-}" = "--list" ] && LIST=1 +if [ "${1:-}" = "--list" ]; then + LIST=1 + shift +fi DOCS=() -for top in CLAUDE.md AGENTS.md linux/CLAUDE.md linux/AGENTS.md; do - [ -f "$top" ] && DOCS+=("$top") -done -while IFS= read -r f; do DOCS+=("$f"); done < <( - find .claude/rules .claude/skills -name '*.md' -type f 2> /dev/null | sort -) +if [ "$#" -gt 0 ]; then + for doc in "$@"; do + [ -f "$doc" ] || { echo "no such document: $doc" >&2; exit 2; } + DOCS+=("$doc") + done +else + for top in CLAUDE.md AGENTS.md linux/CLAUDE.md linux/AGENTS.md; do + [ -f "$top" ] && DOCS+=("$top") + done + while IFS= read -r f; do DOCS+=("$f"); done < <( + find .claude/rules .claude/skills -name '*.md' -type f 2> /dev/null | sort + ) +fi BUILTIN_SKILLS="code-review security-review simplify swiftui-pro run init update-config loop schedule" @@ -59,7 +70,7 @@ CloudKit Security CoreText QuartzCore CoreGraphics OSLog" PLACEHOLDER_PATTERN='^(Some|My|Your|Foo|Bar|Example)[A-Z][a-z]|^(TypeName|ClassName|SuiteType|TestClassName)$' FRAMEWORK_PREFIX_PATTERN='^(NS|CK|CF|CG|CA|CT|UI|XC|OS)[A-Z]' -GNOME_PREFIX_PATTERN='^(Gtk|Gdk|Gsk|Adw|GtkSource|Gio|GLib|GObject|Pango|Secret)[A-Z]' +GNOME_PREFIX_PATTERN='^(Gtk|Gdk|Gsk|Adw|GtkSource|Gio|GLib|GObject|Pango|Secret|G)[A-Z]' # The GObject Introspection files the Linux app's ground truth comes from, when installed. GIR_FILES="Gtk-4.0 Gdk-4.0 Gsk-4.0 Adw-1 GtkSource-5 Gio-2.0 GObject-2.0 GLib-2.0 Pango-1.0 Secret-1" TAB="$(printf '\t')" @@ -197,10 +208,12 @@ resolve_path() { # expected state, and flagging Secrets.xcconfig or Libs/*.a would train everyone to ignore this. # A pattern ending in a slash, like Libs/dylibs/, matches only a directory, and git cannot tell # that a path it has never seen is one, so an absent directory is asked about with the slash. - if git check-ignore -q "$token" 2> /dev/null || git check-ignore -q "$token/" 2> /dev/null; then - printf '%s\t (gitignored, optional by design)\n' "$token" - return 0 - fi + for candidate in "$token" "$dir/$token" "$dir/../$token"; do + if git check-ignore -q "$candidate" 2> /dev/null || git check-ignore -q "$candidate/" 2> /dev/null; then + printf '%s\t (gitignored, optional by design)\n' "$candidate" + return 0 + fi + done return 1 } From e4d4a2e7470c1e8005db76c09c3f8990b9ec91fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:54:47 +0700 Subject: [PATCH 114/301] fix(linux): read routine strings, /*! comments and OPENROWSET calls --- .../core/src/sql_syntax/credential_rules.rs | 49 ++++-- .../core/src/sql_syntax/history_recording.rs | 155 ++++++++++++++++-- .../core/src/sql_syntax/reserved_words.rs | 2 +- .../core/src/sql_syntax/statement_tokens.rs | 141 +++++++++++++--- linux/docs/storage.md | 6 +- 5 files changed, 306 insertions(+), 47 deletions(-) diff --git a/linux/crates/core/src/sql_syntax/credential_rules.rs b/linux/crates/core/src/sql_syntax/credential_rules.rs index f28e453440..d48164e7a2 100644 --- a/linux/crates/core/src/sql_syntax/credential_rules.rs +++ b/linux/crates/core/src/sql_syntax/credential_rules.rs @@ -1,3 +1,4 @@ +use super::SqlGrammar; use super::statement_tokens::StatementToken; /// What marks a statement as one whose text can hold a password or a @@ -22,13 +23,15 @@ pub struct CredentialRules { /// The word before a name being declared or written to, where a carrier /// name followed by a parenthesis is a table and its column list, not a -/// call: `CREATE TABLE s3 (id int)`. +/// call: `CREATE TABLE s3 (id int)`. A carrier the engine reserves, such +/// as T-SQL's OPENROWSET, cannot name a table, so there it is a call. const NAME_POSITIONS: [&str; 6] = ["TABLE", "INTO", "VIEW", "REFERENCES", "EXISTS", "ON"]; impl CredentialRules { - pub(crate) fn matches(&self, tokens: &[StatementToken<'_>]) -> bool { - (0..tokens.len()) - .any(|at| self.prefix_at(tokens, at) || self.conditional_at(tokens, at) || self.carrier_at(tokens, at)) + pub(crate) fn matches(&self, grammar: SqlGrammar, tokens: &[StatementToken<'_>]) -> bool { + (0..tokens.len()).any(|at| { + self.prefix_at(tokens, at) || self.conditional_at(tokens, at) || self.carrier_at(grammar, tokens, at) + }) } fn prefix_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { @@ -49,8 +52,11 @@ impl CredentialRules { }) } - fn carrier_at(&self, tokens: &[StatementToken<'_>], at: usize) -> bool { - let Some(name) = tokens.get(at).and_then(StatementToken::name) else { + fn carrier_at(&self, grammar: SqlGrammar, tokens: &[StatementToken<'_>], at: usize) -> bool { + let Some(token) = tokens.get(at) else { + return false; + }; + let Some(name) = token.name() else { return false; }; if !self.carriers.iter().any(|carrier| carrier.eq_ignore_ascii_case(name)) { @@ -61,12 +67,33 @@ impl CredentialRules { return false; } let start = qualified_name_start(tokens, at); - if next == Some(&StatementToken::OpenParen) && !names_an_object(tokens, start) { - return true; + if next == Some(&StatementToken::OpenParen) { + let reserved = matches!(token, StatementToken::Word(_)) && grammar.is_reserved(name); + if reserved || !names_an_object(tokens, start) { + return true; + } + } + // T-SQL runs a procedure without EXEC only as the first statement + // of a batch. At the start of a later statement, or anywhere with + // its arguments, the batch fails, and history keeps failed runs. + starts_statement(tokens, start) || follows_exec(tokens, start) || arguments_start(tokens, at + 1) + } +} + +fn starts_statement(tokens: &[StatementToken<'_>], start: usize) -> bool { + start == 0 || tokens.get(start - 1).is_some_and(|token| token.is_punct(';')) +} + +/// A string, T-SQL's `N'...'` or a `@parameter`, which only a call puts +/// straight after a name. +fn arguments_start(tokens: &[StatementToken<'_>], at: usize) -> bool { + match tokens.get(at) { + Some(StatementToken::StringLiteral(_)) => true, + Some(StatementToken::Word(word)) if word.starts_with('@') => true, + Some(token) if token.is_word("N") => { + matches!(tokens.get(at + 1), Some(StatementToken::StringLiteral(_))) } - // T-SQL runs the first statement of a batch as a procedure call - // without EXEC. - start == 0 || follows_exec(tokens, start) + _ => false, } } diff --git a/linux/crates/core/src/sql_syntax/history_recording.rs b/linux/crates/core/src/sql_syntax/history_recording.rs index 0a8286b46a..3f362fa662 100644 --- a/linux/crates/core/src/sql_syntax/history_recording.rs +++ b/linux/crates/core/src/sql_syntax/history_recording.rs @@ -18,9 +18,9 @@ pub enum WithholdReason { Untokenizable, } -/// Dollar-quoted bodies are read for the statements inside them this -/// many levels deep: a DO block in a function body is as far as real -/// scripts nest. +/// Routine bodies are read for the statements inside them this many +/// levels deep: a DO block in a function body is as far as real scripts +/// nest. const MAX_BODY_DEPTH: u8 = 4; /// Whether `sql` may be kept, split the way the editor splits it for @@ -44,19 +44,77 @@ fn carries_credentials( statements: &[Vec>], depth: u8, ) -> bool { + // A body that does not lex cleanly is text in a literal, not a + // routine, so only the statements it does hold count. + let body_carries = |body: &str| { + depth < MAX_BODY_DEPTH + && carries_credentials(grammar, rules, &lex_statements(body, grammar).statements, depth + 1) + }; statements.iter().any(|tokens| { - rules.matches(tokens) - || tokens.iter().any(|token| match token { - // A body that does not lex cleanly is text in a literal, - // not a routine, so only the statements it does hold count. - StatementToken::DollarBody(body) if depth < MAX_BODY_DEPTH => { - carries_credentials(grammar, rules, &lex_statements(body, grammar).statements, depth + 1) + rules.matches(grammar, tokens) + || tokens.iter().enumerate().any(|(at, token)| match token { + StatementToken::DollarBody(body) => body_carries(body), + StatementToken::StringLiteral(text) if quoted_routine_body(grammar, tokens, at) => { + body_carries(&string_value(text)) } _ => false, }) }) } +/// PostgreSQL also takes a DO block or a function body as a plain +/// string: `DO 'BEGIN ... END'`, `CREATE FUNCTION f() ... AS 'SELECT 1'`. +fn quoted_routine_body(grammar: SqlGrammar, tokens: &[StatementToken<'_>], at: usize) -> bool { + if grammar != SqlGrammar::PostgreSql { + return false; + } + let before = |back: usize| at.checked_sub(back).and_then(|index| tokens.get(index)); + let is_word = |back: usize, word: &str| before(back).is_some_and(|token| token.is_word(word)); + if is_word(1, "DO") || (is_word(3, "DO") && is_word(2, "LANGUAGE")) { + return true; + } + is_word(1, "AS") && defines_routine(tokens.get(..at).unwrap_or_default()) +} + +fn defines_routine(tokens: &[StatementToken<'_>]) -> bool { + tokens.windows(2).any(|pair| { + let creates = pair[0].is_word("CREATE") || pair[0].is_word("REPLACE"); + creates && (pair[1].is_word("FUNCTION") || pair[1].is_word("PROCEDURE")) + }) +} + +/// The text a PostgreSQL string literal stands for: `'it''s'` and +/// `E'it\'s'` both give `it's`. +fn string_value(text: &str) -> String { + let (escapes, quoted) = match text.strip_prefix(['E', 'e']) { + Some(rest) => (true, rest), + None => (false, text), + }; + let inner = quoted + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .unwrap_or_default(); + let mut value = String::with_capacity(inner.len()); + let mut chars = inner.chars(); + while let Some(c) = chars.next() { + match c { + '\'' => { + chars.next(); + value.push('\''); + } + '\\' if escapes => match chars.next() { + Some('n') => value.push('\n'), + Some('t') => value.push('\t'), + Some('r') => value.push('\r'), + Some(escaped) => value.push(escaped), + None => {} + }, + _ => value.push(c), + } + } + value +} + #[cfg(test)] mod tests { use proptest::prelude::*; @@ -70,7 +128,7 @@ mod tests { &["CREATE", "NAMED", "COLLECTION"], ], conditional_prefixes: &[(&["GRANT"], &["IDENTIFIED"])], - carriers: &["dblink_connect", "sp_addlogin", "s3"], + carriers: &["dblink_connect", "sp_addlogin", "s3", "OPENROWSET"], }; const WITHHELD: HistoryRecording = HistoryRecording::Withhold(WithholdReason::CredentialStatement); @@ -225,13 +283,88 @@ mod tests { } #[test] - fn a_procedure_mid_batch_needs_exec() { + fn a_procedure_named_mid_batch_without_arguments_is_not_a_call() { assert_eq!( classify(SqlGrammar::MsSql, "SELECT 1\nPRINT sp_addlogin"), HistoryRecording::Record ); } + #[test] + fn a_procedure_mid_batch_with_its_arguments_is_a_call() { + for sql in [ + "SELECT 1;\nsp_addlogin 'l', 'p'", + "SELECT 1;\nsp_addlogin", + "SELECT 1\nsp_addlogin 'l', 'p'", + "SELECT 1\nsp_addlogin N'l', N'p'", + "SELECT 1\nsp_addlogin @loginame = 'l', @passwd = 'p'", + ] { + assert_eq!(classify(SqlGrammar::MsSql, sql), WITHHELD, "{sql}"); + } + } + + #[test] + fn a_reserved_carrier_is_a_call_where_a_table_name_would_go() { + assert_eq!( + classify( + SqlGrammar::MsSql, + "INSERT INTO OPENROWSET('SQLNCLI', 'Server=s;Uid=sa;Pwd=secret;', 'SELECT * FROM t') SELECT * FROM t" + ), + WITHHELD + ); + assert_eq!( + classify(SqlGrammar::MsSql, "INSERT INTO [OPENROWSET] (c) VALUES (1)"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::ClickHouse, "INSERT INTO s3 (id) VALUES (1)"), + HistoryRecording::Record + ); + } + + #[test] + fn a_quoted_postgres_routine_body_is_read_for_statements() { + for sql in [ + "DO 'BEGIN CREATE USER u PASSWORD ''p''; END'", + "DO LANGUAGE plpgsql 'BEGIN CREATE USER u PASSWORD ''p''; END'", + "CREATE FUNCTION f() RETURNS void AS 'SELECT dblink_connect(''password=x'')' LANGUAGE sql", + r"CREATE OR REPLACE PROCEDURE p() LANGUAGE sql AS E'SELECT dblink_connect(\'password=x\')'", + ] { + assert_eq!(classify(SqlGrammar::PostgreSql, sql), WITHHELD, "{sql}"); + } + assert_eq!( + classify( + SqlGrammar::PostgreSql, + "CREATE FUNCTION f() RETURNS text AS 'SELECT ''CREATE USER u''' LANGUAGE sql" + ), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::MySql, "DO 'CREATE USER u'"), + HistoryRecording::Record + ); + } + + #[test] + fn a_mysql_executable_comment_is_read() { + for sql in [ + "/*!80000 CREATE USER u IDENTIFIED BY 'p' */", + "SELECT 1;\n/*!80000 CREATE USER u IDENTIFIED BY 'p' */;", + "GRANT ALL ON *.* TO u /*!50000 IDENTIFIED BY 'p' */", + "/*M!100100 CREATE USER u IDENTIFIED BY 'p' */", + ] { + assert_eq!(classify(SqlGrammar::MySql, sql), WITHHELD, "{sql}"); + } + assert_eq!( + classify(SqlGrammar::MySql, "SELECT 1 /* CREATE USER u */"), + HistoryRecording::Record + ); + assert_eq!( + classify(SqlGrammar::PostgreSql, "SELECT 1 /*! CREATE USER u */"), + HistoryRecording::Record + ); + } + proptest! { #[test] fn any_text_classifies_without_panicking(sql in "\\PC{0,120}", grammar in 0usize..5) { diff --git a/linux/crates/core/src/sql_syntax/reserved_words.rs b/linux/crates/core/src/sql_syntax/reserved_words.rs index 48460febc8..558982f9f5 100644 --- a/linux/crates/core/src/sql_syntax/reserved_words.rs +++ b/linux/crates/core/src/sql_syntax/reserved_words.rs @@ -111,7 +111,7 @@ impl SqlGrammar { start && chars.all(part) && !folds && !self.is_reserved(name) } - fn is_reserved(self, word: &str) -> bool { + pub(crate) fn is_reserved(self, word: &str) -> bool { self.reserved_words() .split_ascii_whitespace() .any(|reserved| reserved.eq_ignore_ascii_case(word)) diff --git a/linux/crates/core/src/sql_syntax/statement_tokens.rs b/linux/crates/core/src/sql_syntax/statement_tokens.rs index 287a05ba6d..4584aa60dc 100644 --- a/linux/crates/core/src/sql_syntax/statement_tokens.rs +++ b/linux/crates/core/src/sql_syntax/statement_tokens.rs @@ -13,9 +13,13 @@ pub(crate) enum StatementToken<'a> { QuotedName(&'a str), /// What a dollar quote holds, which is usually a routine body. DollarBody(&'a str), + /// A string as written, quotes and any `E` prefix included. + StringLiteral(&'a str), OpenParen, + /// Punctuation, and `;` wherever a statement keeps one, as a T-SQL + /// batch does. Punct(char), - /// A string, a number or a closing parenthesis. + /// A number or a closing parenthesis. Other, } @@ -48,40 +52,75 @@ pub(crate) struct LexedScript<'a> { pub(crate) fn lex_statements(sql: &str, grammar: SqlGrammar) -> LexedScript<'_> { let plan = ScriptPlan::build(sql, grammar, LexicalSettings::default_for(grammar)); - let statements = plan - .statements() - .iter() - .map(|statement| { - plan.tokens(statement.range.clone()) - .filter_map(|token| significant(sql, token.kind, token.range)) - .collect() - }) - .collect(); + // Split at the terminators rather than taken statement by statement, + // so a comment the plan leaves outside every statement still counts: + // MySQL runs a `/*!` comment with whatever stands between the same + // two terminators, and the history keeps the whole text. + let mut statements = Vec::new(); + let mut current = Vec::new(); + for token in plan.tokens(0..sql.len()) { + let ends_statement = match token.kind { + ScriptTokenKind::Directive => true, + ScriptTokenKind::Semicolon => !plan + .statement_at(token.range.start) + .is_some_and(|statement| statement.range.contains(&token.range.start)), + _ => false, + }; + if ends_statement { + if !current.is_empty() { + statements.push(std::mem::take(&mut current)); + } + continue; + } + push_significant(sql, grammar, token.kind, token.range, &mut current); + } + if !current.is_empty() { + statements.push(current); + } LexedScript { statements, unterminated: !plan.diagnostics().is_empty(), } } -fn significant(sql: &str, kind: ScriptTokenKind, range: Range) -> Option> { +fn push_significant<'a>( + sql: &'a str, + grammar: SqlGrammar, + kind: ScriptTokenKind, + range: Range, + tokens: &mut Vec>, +) { let text = sql.get(range).unwrap_or_default(); let token = match kind { - ScriptTokenKind::Whitespace - | ScriptTokenKind::LineComment - | ScriptTokenKind::BlockComment - | ScriptTokenKind::Directive => return None, + ScriptTokenKind::BlockComment => { + // MySQL and MariaDB run what a `/*!` comment holds as part of + // the statement around it. + if let Some(inner) = executable_comment(grammar, text) { + tokens.extend(lex_statements(inner, grammar).statements.into_iter().flatten()); + } + return; + } + ScriptTokenKind::Whitespace | ScriptTokenKind::LineComment | ScriptTokenKind::Directive => return, ScriptTokenKind::Word => StatementToken::Word(text), ScriptTokenKind::QuotedIdentifier => StatementToken::QuotedName(unquoted(text)), ScriptTokenKind::DollarQuoted => StatementToken::DollarBody(dollar_body(text)), + ScriptTokenKind::StringLiteral => StatementToken::StringLiteral(text), ScriptTokenKind::OpenParen => StatementToken::OpenParen, + ScriptTokenKind::Semicolon => StatementToken::Punct(';'), ScriptTokenKind::Punct => text.chars().next().map_or(StatementToken::Other, StatementToken::Punct), - ScriptTokenKind::StringLiteral - | ScriptTokenKind::Number - | ScriptTokenKind::Semicolon - | ScriptTokenKind::CloseParen - | ScriptTokenKind::Unterminated => StatementToken::Other, + ScriptTokenKind::Number | ScriptTokenKind::CloseParen | ScriptTokenKind::Unterminated => StatementToken::Other, }; - Some(token) + tokens.push(token); +} + +/// `/*!50100 text */` or MariaDB's `/*M!100100 text */` gives `text`. +fn executable_comment(grammar: SqlGrammar, text: &str) -> Option<&str> { + if grammar != SqlGrammar::MySql { + return None; + } + let body = text.strip_prefix("/*!").or_else(|| text.strip_prefix("/*M!"))?; + let body = body.strip_suffix("*/")?; + Some(body.trim_start_matches(|c: char| c.is_ascii_digit())) } /// Every quote the lexer accepts for a name (`"`, `` ` ``, `[` and `]`) @@ -110,7 +149,7 @@ mod tests { script.statements, vec![vec![ StatementToken::Word("SELECT"), - StatementToken::Other, + StatementToken::StringLiteral("'USER'"), StatementToken::Word("FROM"), StatementToken::Word("t"), ]] @@ -118,6 +157,64 @@ mod tests { assert!(!script.unterminated); } + #[test] + fn a_mysql_executable_comment_is_part_of_its_statement() { + assert_eq!( + lex_statements("CREATE /*!50100 TEMPORARY */ TABLE t", SqlGrammar::MySql).statements, + vec![vec![ + StatementToken::Word("CREATE"), + StatementToken::Word("TEMPORARY"), + StatementToken::Word("TABLE"), + StatementToken::Word("t"), + ]] + ); + assert_eq!( + lex_statements("SELECT /*M!100100 1 */ x", SqlGrammar::MySql).statements, + vec![vec![ + StatementToken::Word("SELECT"), + StatementToken::Other, + StatementToken::Word("x"), + ]] + ); + assert_eq!( + lex_statements("SELECT /*! TEMPORARY */ x", SqlGrammar::PostgreSql).statements, + vec![vec![StatementToken::Word("SELECT"), StatementToken::Word("x")]] + ); + } + + #[test] + fn a_mysql_executable_comment_between_statements_is_a_statement() { + let script = lex_statements("SELECT 1;\n/*!40101 SET NAMES utf8 */;", SqlGrammar::MySql); + + assert_eq!( + script.statements, + vec![ + vec![StatementToken::Word("SELECT"), StatementToken::Other], + vec![ + StatementToken::Word("SET"), + StatementToken::Word("NAMES"), + StatementToken::Word("utf8"), + ], + ] + ); + } + + #[test] + fn a_batch_keeps_its_semicolons() { + let script = lex_statements("SELECT 1; SELECT 2", SqlGrammar::MsSql); + + assert_eq!( + script.statements, + vec![vec![ + StatementToken::Word("SELECT"), + StatementToken::Other, + StatementToken::Punct(';'), + StatementToken::Word("SELECT"), + StatementToken::Other, + ]] + ); + } + #[test] fn quoted_names_lose_their_quotes() { let script = lex_statements("EXEC [dbo].[sp_who]", SqlGrammar::MsSql); diff --git a/linux/docs/storage.md b/linux/docs/storage.md index 9f0d23b687..1c8c97f7f0 100644 --- a/linux/docs/storage.md +++ b/linux/docs/storage.md @@ -191,8 +191,10 @@ script lexer: engine lists its own in a `CredentialRules`: logins, roles, keys and links to other servers (`CREATE USER`, `CHANGE MASTER`), statements that do so only with a given word (`GRANT ... IDENTIFIED`), and functions or engines - that take a password (`dblink`, `OPENROWSET`, `s3`). A dollar-quoted body - is read for the statements inside it. + that take a password (`dblink`, `OPENROWSET`, `s3`). A routine body is + read for the statements inside it, whether dollar-quoted or, on + PostgreSQL, a plain string after `DO` or `AS`. So is a MySQL `/*!` + comment, which the server runs. - A string, quoted name or comment that never closes withholds it too, since what is inside a literal is then a guess. From 24631146b8d2c874c19f35a8976906d6de67d1ac Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:54:48 +0700 Subject: [PATCH 115/301] fix(linux): add the credential carriers each engine was missing --- .../crates/drivers/clickhouse/src/dialect.rs | 20 ++++++++++++- linux/crates/drivers/mssql/src/dialect.rs | 28 ++++++++++++++++++- linux/crates/drivers/mysql/src/dialect.rs | 22 +++++++++++++-- linux/crates/drivers/postgres/src/dialect.rs | 22 +++++++++++++-- 4 files changed, 86 insertions(+), 6 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index 0d9b4b1501..4b944bc215 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -349,7 +349,7 @@ fn float_literal(value: f64) -> Option { /// Statements whose text can hold a password or an access key. The /// carriers are table functions and engines (`ENGINE = MySQL(...)`) /// whose arguments include the remote login; the Kafka, RabbitMQ and -/// NATS engines take theirs as settings. +/// NATS engines and a password-protected backup take theirs as settings. const CREDENTIAL_RULES: CredentialRules = CredentialRules { statement_prefixes: &[ &["CREATE", "USER"], @@ -359,6 +359,8 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { ], conditional_prefixes: &[ (&["CREATE", "DICTIONARY"], &["PASSWORD"]), + (&["BACKUP"], &["PASSWORD"]), + (&["RESTORE"], &["PASSWORD"]), ( &["CREATE"], &["KAFKA_SASL_PASSWORD", "RABBITMQ_PASSWORD", "NATS_PASSWORD"], @@ -394,6 +396,9 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { "icebergAzureCluster", "hudi", "hudiCluster", + "url", + "odbc", + "jdbc", ], }; @@ -596,6 +601,12 @@ mod tests { "CREATE NAMED COLLECTION c AS password = 'p'", "SELECT * FROM deltaLake('url', 'key', 'secret')", "CREATE TABLE q (m String) ENGINE = Kafka SETTINGS kafka_sasl_password = 'p'", + "BACKUP TABLE t TO Disk('backups', 'b.zip') SETTINGS password = 'x'", + "RESTORE TABLE t FROM Disk('backups', 'b.zip') SETTINGS password = 'x'", + "SELECT * FROM url('http://u:p@h/f.csv', CSV)", + "CREATE TABLE t (id Int32) ENGINE = URL('http://u:p@h/f.csv', CSV)", + "SELECT * FROM odbc('DSN=d;UID=u;PWD=p', 't')", + "SELECT * FROM jdbc('jdbc:mysql://h/?user=u&password=p', 's', 't')", ] { assert_eq!( dialect().history_recording(sql), @@ -611,5 +622,12 @@ mod tests { tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), Vec::<&str>::new() ); + for sql in [ + "SELECT url, domain(url) FROM visits", + "CREATE TABLE url (id Int32) ENGINE = Memory", + "BACKUP TABLE t TO Disk('backups', 'b.zip')", + ] { + assert_eq!(dialect().history_recording(sql), HistoryRecording::Record, "{sql}"); + } } } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index f247c9d323..2f4d4c9546 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -368,7 +368,8 @@ fn integer_param_type(kind: IntegerKind) -> ParamType { } /// Statements whose text can hold a password or a connection string. -/// The procedures run without EXEC as the first statement of a batch. +/// The procedures run without EXEC as the first statement of a batch, +/// and the crypto functions take the passphrase or key password itself. const CREDENTIAL_RULES: CredentialRules = CredentialRules { statement_prefixes: &[ &["CREATE", "LOGIN"], @@ -396,6 +397,8 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { conditional_prefixes: &[ (&["BACKUP"], &["PASSWORD", "MEDIAPASSWORD", "ENCRYPTION"]), (&["RESTORE"], &["PASSWORD", "MEDIAPASSWORD", "ENCRYPTION"]), + (&["ADD", "SIGNATURE"], &["PASSWORD"]), + (&["ADD", "COUNTER", "SIGNATURE"], &["PASSWORD"]), ], carriers: &[ "sp_addlogin", @@ -406,8 +409,17 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { "sp_approlepassword", "sp_control_dbmasterkey_password", "sp_change_users_login", + "sp_addlinkedserver", "OPENROWSET", "OPENDATASOURCE", + "ENCRYPTBYPASSPHRASE", + "DECRYPTBYPASSPHRASE", + "DECRYPTBYASYMKEY", + "DECRYPTBYCERT", + "DECRYPTBYKEYAUTOASYMKEY", + "DECRYPTBYKEYAUTOCERT", + "SIGNBYASYMKEY", + "SIGNBYCERT", ], }; @@ -673,6 +685,13 @@ mod tests { "SELECT 1\nCREATE LOGIN l WITH PASSWORD = 'x'", "ALTER SERVICE MASTER KEY WITH OLD_ACCOUNT = 'a', OLD_PASSWORD = 'p'", "EXEC sp_change_users_login 'Auto_Fix', 'u', NULL, 'p'", + "INSERT INTO OPENROWSET('SQLNCLI', 'Server=s;Uid=sa;Pwd=secret;', 'SELECT * FROM db.dbo.t') SELECT * FROM t", + "INSERT INTO OPENDATASOURCE('SQLNCLI', 'Data Source=s;User ID=u;Password=p').db.dbo.t (c) VALUES (1)", + "SELECT 1;\nsp_addlogin 'l','p'", + "ADD SIGNATURE TO dbo.p BY CERTIFICATE c WITH PASSWORD = 'p'", + "SELECT ENCRYPTBYPASSPHRASE('passphrase', 'data')", + "SELECT DECRYPTBYPASSPHRASE('passphrase', secret) FROM t", + "EXEC sp_addlinkedserver @server = 's', @srvproduct = '', @provider = 'MSOLEDBSQL', @provstr = 'Server=h;Uid=u;Pwd=p;'", ] { assert_eq!( dialect().history_recording(sql), @@ -688,5 +707,12 @@ mod tests { tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect()), Vec::<&str>::new() ); + for sql in [ + "ALTER TABLE users ADD password varchar(100)", + "INSERT INTO [OPENROWSET] (c) VALUES (1)", + "SELECT 1\nPRINT sp_addlogin", + ] { + assert_eq!(dialect().history_recording(sql), HistoryRecording::Record, "{sql}"); + } } } diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index 9608513f48..cef0619386 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -370,7 +370,8 @@ fn string_literal(text: &str) -> String { } /// Statements whose text can hold a password or a connection string. A -/// FEDERATED or CONNECT table keeps its remote login in `CONNECTION`. +/// FEDERATED or CONNECT table keeps its remote login in `CONNECTION`, and +/// `PASSWORD()` takes the password it hashes. const CREDENTIAL_RULES: CredentialRules = CredentialRules { statement_prefixes: &[ &["CREATE", "USER"], @@ -385,10 +386,11 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { (&["GRANT"], &["IDENTIFIED"]), (&["START", "SLAVE"], &["PASSWORD"]), (&["START", "REPLICA"], &["PASSWORD"]), + (&["START", "GROUP_REPLICATION"], &["PASSWORD"]), (&["CREATE"], &["CONNECTION"]), (&["ALTER"], &["CONNECTION"]), ], - carriers: &[], + carriers: &["PASSWORD", "OLD_PASSWORD"], }; #[cfg(test)] @@ -647,6 +649,10 @@ mod tests { "GRANT ALL ON *.* TO u IDENTIFIED BY 'p'", "START REPLICA USER='u' PASSWORD='p'", "CREATE TABLE t (id INT) ENGINE=FEDERATED CONNECTION='mysql://u:p@h:3306/d/t'", + "UPDATE mysql.user SET authentication_string = PASSWORD('x') WHERE User = 'root'", + "SELECT OLD_PASSWORD('x')", + "START GROUP_REPLICATION USER='u', PASSWORD='p'", + "CREATE TABLE t (id int) /*!50100 ENGINE=FEDERATED CONNECTION='mysql://u:p@h/d/t' */", ] { assert_eq!( dialect(MySqlFlavour::MySql).history_recording(sql), @@ -662,5 +668,17 @@ mod tests { tablepro_test_support::history_cases::unrecorded_plain_statements(&dialect(MySqlFlavour::MySql)), Vec::<&str>::new() ); + for sql in [ + "CREATE TABLE users (id INT, password VARCHAR(100))", + "INSERT INTO users (name, password) VALUES ('a', 'b')", + "SELECT start, password FROM sessions", + "START TRANSACTION", + ] { + assert_eq!( + dialect(MySqlFlavour::MySql).history_recording(sql), + HistoryRecording::Record, + "{sql}" + ); + } } } diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index 24ec538ce9..0a766d0e9c 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -344,7 +344,8 @@ fn quoted(text: &str) -> String { /// Statements whose text can hold a password or a connection string. /// `CREATE USER` also covers `CREATE USER MAPPING`, whose options carry -/// the remote login. +/// the remote login, and pgcrypto's `crypt` and passphrase functions take +/// the password itself. const CREDENTIAL_RULES: CredentialRules = CredentialRules { statement_prefixes: &[ &["CREATE", "ROLE"], @@ -359,7 +360,19 @@ const CREDENTIAL_RULES: CredentialRules = CredentialRules { &["ALTER", "SUBSCRIPTION"], ], conditional_prefixes: &[(&["ALTER", "SYSTEM"], &["PRIMARY_CONNINFO"])], - carriers: &["dblink", "dblink_connect", "dblink_connect_u", "dblink_exec"], + carriers: &[ + "dblink", + "dblink_connect", + "dblink_connect_u", + "dblink_exec", + "crypt", + "pgp_sym_encrypt", + "pgp_sym_encrypt_bytea", + "pgp_sym_decrypt", + "pgp_sym_decrypt_bytea", + "pgp_pub_decrypt", + "pgp_pub_decrypt_bytea", + ], }; #[cfg(test)] @@ -608,6 +621,11 @@ mod tests { "ALTER SYSTEM SET primary_conninfo = 'password=x'", "SELECT 1; CREATE USER u PASSWORD 'p'", "DO $$ BEGIN CREATE ROLE app LOGIN PASSWORD 'p'; END $$", + "DO 'BEGIN CREATE ROLE r PASSWORD ''p''; END'", + "CREATE FUNCTION f() RETURNS text AS 'SELECT dblink_connect(''password=x'')' LANGUAGE sql", + "SELECT pgp_sym_encrypt('data', 'passphrase')", + "SELECT pgp_sym_decrypt(secret, 'passphrase') FROM t", + "UPDATE users SET pw = crypt('secret', gen_salt('bf'))", ] { assert_eq!( dialect().history_recording(sql), From 2d98eb58dfad44e8c4ddb195dac0224805d3987f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 15:54:48 +0700 Subject: [PATCH 116/301] fix(linux-editor): judge a run's history entry by its own engine --- linux/crates/app/src/ui/editor/mod.rs | 9 ++-- linux/crates/app/src/ui/editor/tests.rs | 65 +++++++++++++++++++++---- 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 4745f10fdd..681a89e203 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -371,7 +371,11 @@ impl SqlEditor { row_limit: RowLimit::EDITOR_DEFAULT, }; let metadata = session.metadata().clone(); + // Read again at the end from the run's own session, never the + // window's: the link can come up during the run, and the window + // can move to another engine before it ends. let dialect = session.current_dialect(); + let ran_on = session.clone(); let started_at = SystemTime::now(); let cancel = CancellationToken::new(); let (events, mut received) = tokio::sync::mpsc::channel(8); @@ -403,6 +407,7 @@ impl SqlEditor { let finished = matches!(event, RunEvent::Finished); editor.apply_event(event); if finished { + let dialect = dialect.or_else(|| ran_on.current_dialect()); editor.finish_run(query, &metadata, started_at, dialect); return; } @@ -411,6 +416,7 @@ impl SqlEditor { // does; the list still has to end and the tab unlock. if let Some(editor) = weak.upgrade() { editor.apply_event(RunEvent::Finished); + let dialect = dialect.or_else(|| ran_on.current_dialect()); editor.finish_run(query, &metadata, started_at, dialect); } }); @@ -484,8 +490,6 @@ impl SqlEditor { self.emit_by_name::<()>("run-state-changed", &[&false]); } - /// `dialect` is the one the run started on, which still names the - /// engine when the connection dropped during the run. fn record_history(&self, entry: tablepro_storage::query_history::NewEntry, dialect: Option>) { let Some(deps) = self.deps() else { return; @@ -493,7 +497,6 @@ impl SqlEditor { let Some(history) = deps.history.as_ref() else { return; }; - let dialect = dialect.or_else(|| deps.session.get().and_then(|session| session.current_dialect())); drop(deps.tasks.spawn_task(history.record(entry, dialect))); } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 7ab0caf30e..5624de85a1 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -17,12 +17,16 @@ struct Harness { editor: SqlEditor, fake: Arc, history: Option, + active: ActiveSession, _settings: MemorySettings, runtime: crate::runtime::AppRuntime, } fn session_with(fake: Arc, tasks: Tasks) -> Arc { - let mut script = ConnectionScript::default(); + session_on(fake, tasks, ConnectionScript::default()) +} + +fn session_on(fake: Arc, tasks: Tasks, mut script: ConnectionScript) -> Arc { let editor: Arc = fake; script.open_editor_session.always(FakeReply::ok(editor)); let request = FakeConnector::request().expect("a request"); @@ -56,7 +60,7 @@ fn build_editor(runs: Replies, text: &str, history: impl FnOnce(&Tasks) settings: settings.get().clone(), history: history.clone(), tasks: runtime.tasks(), - session: active, + session: active.clone(), hold: Rc::new(tablepro_session::runtime::CountingHold::new()), close_timeout: Duration::from_secs(5), }); @@ -64,11 +68,24 @@ fn build_editor(runs: Replies, text: &str, history: impl FnOnce(&Tasks) editor, fake, history, + active, _settings: settings, runtime, } } +/// An editor whose runs go into a real history under `root`. +fn editor_with_history(root: &tempfile::TempDir, text: &str) -> Harness { + let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); + build_editor(answering(), text, |tasks| { + let preferences = HistoryPreferences { + retention_days: tokio::sync::watch::channel(30).1, + save: tokio::sync::watch::channel(true).1, + }; + Some(HistoryService::start(paths, preferences, tasks)) + }) +} + fn answering() -> Replies { let mut runs = Replies::default(); runs.always(FakeRun::completed(Vec::new())); @@ -341,14 +358,7 @@ fn the_icon_buttons_have_accessible_names() { #[gtk4::test] fn a_run_is_recorded_unless_it_carries_a_credential() { let root = tempfile::tempdir().expect("tempdir"); - let paths = tablepro_storage::StoragePaths::under(root.path(), "tablepro-test", "app.tablepro.TablePro.Devel"); - let harness = build_editor(answering(), "CREATE USER u PASSWORD 'p';", |tasks| { - let preferences = HistoryPreferences { - retention_days: tokio::sync::watch::channel(30).1, - save: tokio::sync::watch::channel(true).1, - }; - Some(HistoryService::start(paths, preferences, tasks)) - }); + let harness = editor_with_history(&root, "CREATE USER u PASSWORD 'p';"); run_and_wait(&harness); harness.editor.buffer().set_text("SELECT 2;"); @@ -365,6 +375,41 @@ fn a_run_is_recorded_unless_it_carries_a_credential() { ); } +#[gtk4::test] +fn a_run_is_judged_by_the_engine_it_ran_on() { + let root = tempfile::tempdir().expect("tempdir"); + let harness = editor_with_history(&root, "CREATE USER u PASSWORD 'p';"); + let ran_on = harness.active.get().expect("the window's session"); + drop(ran_on.begin_shutdown()); + assert!(ran_on.current_dialect().is_none(), "the run's engine is still known"); + + harness.editor.run(); + assert!(harness.editor.is_running(), "the run did not start"); + // SQLite has no logins, so its rules let CREATE USER into the history. + let sqlite = ConnectionScript { + dialect: Arc::new(drivers_sqlite::SqliteDialect::new(drivers_sqlite::SQLITE_MIN_VERSION)), + ..ConnectionScript::default() + }; + let moved_to = Arc::new(FakeEditorSession::new(answering())); + harness + .active + .replace(session_on(moved_to, harness.runtime.tasks(), sqlite)); + wait_until(WAIT, || !harness.editor.is_running()).expect("the run to end"); + + harness.editor.buffer().set_text("SELECT 2;"); + run_and_wait(&harness); + + wait_until(WAIT, || { + recorded(&harness).iter().any(|query| query.contains("SELECT 2")) + }) + .expect("the run on the new engine was never recorded"); + let recorded = recorded(&harness); + assert!( + recorded.iter().all(|query| !query.contains("CREATE USER")), + "a statement with a password was recorded: {recorded:?}" + ); +} + /// What the harness's history holds, newest first. fn recorded(harness: &Harness) -> Vec { let Some(history) = harness.history.as_ref().and_then(HistoryService::store) else { From 2e8719e68d962d3dd9d786be8f03e927ca6a75eb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:29:26 +0700 Subject: [PATCH 117/301] feat(linux): build the About dialog from the app metainfo --- linux/CHANGELOG.md | 2 + linux/crates/app/build.rs | 68 ++++++++++- .../app/src/application/about_dialog.rs | 107 ++++++++++++++++++ .../crates/app/src/application/app_actions.rs | 30 ++--- .../crates/app/src/application/debug_info.rs | 103 +++++++++++++++++ linux/crates/app/src/application/mod.rs | 3 + .../app/src/application/runtime_versions.rs | 47 ++++++++ linux/data/resources/resources.gresource.xml | 2 + .../docs/decisions/0006-meson-cargo-build.md | 4 +- linux/po/POTFILES.in | 3 + 10 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 linux/crates/app/src/application/about_dialog.rs create mode 100644 linux/crates/app/src/application/debug_info.rs create mode 100644 linux/crates/app/src/application/runtime_versions.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 9c6ad6e5d3..2d29a008e4 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -33,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Screen readers say when a grid cell is modified and when a row is new or marked for deletion. - Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. - Save query history in Preferences turns the query history off and keeps the entries already saved. +- Troubleshooting in About TablePro, with the app, toolkit and driver versions to copy into a bug report. ### Changed @@ -54,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Index and Add Foreign Key check the form as you type, and pick the referenced table and columns from the database. - Denser grid rows, with numbers aligned right in even-width digits at the precision their column declares. - A grid value too long to draw says how many bytes it leaves out, and its tooltip points to Show Row as JSON. +- About TablePro links to the website, documentation and issue tracker, and shows a release's notes. ### Removed diff --git a/linux/crates/app/build.rs b/linux/crates/app/build.rs index 46896d77a5..44f8212a03 100644 --- a/linux/crates/app/build.rs +++ b/linux/crates/app/build.rs @@ -1,7 +1,71 @@ -fn main() { +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::ExitCode; + +const METAINFO_TEMPLATE: &str = "../../data/app.tablepro.TablePro.metainfo.xml.in.in"; +/// The fallback `config::APP_ID` takes when Meson sets nothing. +const DEFAULT_APP_ID: &str = "app.tablepro.TablePro.Devel"; + +fn main() -> ExitCode { + let metainfo_dir = match render_metainfo() { + Ok(dir) => dir, + Err(message) => { + println!("cargo::error={message}"); + return ExitCode::FAILURE; + } + }; glib_build_tools::compile_resources( - &["../../data/resources"], + &[PathBuf::from("../../data/resources"), metainfo_dir], "../../data/resources/resources.gresource.xml", "tablepro.gresource", ); + ExitCode::SUCCESS +} + +/// The About dialog reads the metainfo from the GResource. Meson renders +/// the installed copy from the same template with the same values, so a +/// cargo build embeds what a Meson build installs, less translations. +fn render_metainfo() -> Result { + println!("cargo::rerun-if-changed={METAINFO_TEMPLATE}"); + println!("cargo::rerun-if-env-changed=TABLEPRO_APP_ID"); + let out_dir = env::var_os("OUT_DIR").ok_or("OUT_DIR is not set")?; + let app_id = env::var("TABLEPRO_APP_ID").unwrap_or_else(|_| DEFAULT_APP_ID.to_owned()); + let package = env::var("CARGO_PKG_NAME").map_err(|error| format!("CARGO_PKG_NAME: {error}"))?; + let template = fs::read_to_string(METAINFO_TEMPLATE) + .map_err(|error| format!("could not read {METAINFO_TEMPLATE}: {error}"))?; + let rendered = template + .replace("@app-id@", &app_id) + .replace("@gettext-package@", &package); + if let Some(name) = unfilled_placeholder(&rendered) { + return Err(format!( + "{METAINFO_TEMPLATE} uses @{name}@, which build.rs does not fill" + )); + } + + let dir = PathBuf::from(out_dir).join("metainfo"); + fs::create_dir_all(&dir).map_err(|error| format!("could not create {}: {error}", dir.display()))?; + let path = dir.join("metainfo.xml"); + // A rewrite, even of the same bytes, makes cargo run this script and + // rebuild the crate on every later build. + if fs::read_to_string(&path).ok().as_deref() != Some(rendered.as_str()) { + fs::write(&path, rendered).map_err(|error| format!("could not write {}: {error}", path.display()))?; + } + Ok(dir) +} + +/// The name of a Meson `@name@` placeholder still in `text`. +fn unfilled_placeholder(text: &str) -> Option<&str> { + let mut rest = text; + while let Some(start) = rest.find('@') { + let after = &rest[start + 1..]; + let length = after + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_')) + .unwrap_or(after.len()); + if length > 0 && after[length..].starts_with('@') { + return Some(&after[..length]); + } + rest = after; + } + None } diff --git a/linux/crates/app/src/application/about_dialog.rs b/linux/crates/app/src/application/about_dialog.rs new file mode 100644 index 0000000000..39daab6a8c --- /dev/null +++ b/linux/crates/app/src/application/about_dialog.rs @@ -0,0 +1,107 @@ +use libadwaita as adw; +use tablepro_core::DriverRegistry; + +use super::debug_info::debug_info; +use super::runtime_versions::RuntimeVersions; +use crate::config::{self, Profile}; +use crate::i18n::gettext; + +const DEVELOPERS: &[&str] = &["TablePro Authors https://github.com/TableProApp/TablePro/graphs/contributors"]; +const DEBUG_INFO_FILENAME: &str = "tablepro-debug-info.txt"; + +/// The name, icon, developer, links and license come from the metainfo +/// the GResource embeds, so the dialog and the software centre agree. +pub fn build_about_dialog(drivers: &DriverRegistry) -> adw::AboutDialog { + let dialog = adw::AboutDialog::from_appdata(&metainfo_resource(), release_notes_version(config::profile())); + // The metainfo gives the latest release, which a development build is + // not: its version carries the commit. + dialog.set_version(config::VERSION); + dialog.set_developers(DEVELOPERS); + // Translators: your names, one per line, as "Name " or "Name https://site". + dialog.set_translator_credits(&gettext("translator-credits")); + dialog.set_debug_info(&debug_info(&RuntimeVersions::current(), drivers)); + dialog.set_debug_info_filename(DEBUG_INFO_FILENAME); + dialog +} + +fn metainfo_resource() -> String { + format!("{}/metainfo.xml", config::RESOURCE_BASE_PATH) +} + +/// A development version has no release of its own in the metainfo, and +/// libadwaita reports a version it cannot find as a critical. +fn release_notes_version(profile: Profile) -> Option<&'static str> { + match profile { + Profile::Default => Some(config::VERSION), + Profile::Development => None, + } +} + +#[cfg(test)] +mod tests { + use gtk4 as gtk; + + use super::*; + + fn embedded_metainfo() -> String { + crate::register_test_resources(); + let bytes = gio::resources_lookup_data(&metainfo_resource(), gio::ResourceLookupFlags::NONE) + .expect("the metainfo is embedded at the resource base path"); + String::from_utf8(bytes.to_vec()).expect("the metainfo is UTF-8") + } + + /// The text between the element `open` starts and the next `close`. + fn metainfo_text<'a>(xml: &'a str, open: &str, close: &str) -> &'a str { + let start = xml.find(open).unwrap_or_else(|| panic!("{open} is in the metainfo")); + let content = &xml[start..]; + let content = &content[content.find('>').expect("the element's start tag closes") + 1..]; + let end = content.find(close).unwrap_or_else(|| panic!("{close} follows {open}")); + content[..end].trim() + } + + #[gtk4::test] + fn about_dialog_is_built_from_metainfo() { + adw::init().expect("libadwaita initialises under the test backend"); + let xml = embedded_metainfo(); + let homepage = metainfo_text(&xml, ""); + let bugtracker = metainfo_text(&xml, ""); + let help = metainfo_text(&xml, ""); + let developer = metainfo_text(metainfo_text(&xml, ""), ""); + assert!(!homepage.is_empty() && !bugtracker.is_empty() && !help.is_empty() && !developer.is_empty()); + + let dialog = build_about_dialog(&DriverRegistry::new()); + + assert_eq!(dialog.website(), homepage); + assert_eq!(dialog.issue_url(), bugtracker); + assert_eq!(dialog.support_url(), help); + assert_eq!(dialog.developer_name(), developer); + assert_eq!(dialog.application_icon(), config::APP_ID); + assert_eq!(dialog.version(), config::VERSION); + assert_eq!(dialog.license_type(), gtk::License::Agpl30); + assert_eq!(dialog.application_name(), "TablePro"); + assert_eq!( + dialog.developers().iter().map(|name| name.as_str()).collect::>(), + DEVELOPERS + ); + assert_eq!(dialog.debug_info_filename(), DEBUG_INFO_FILENAME); + assert!(dialog.debug_info().contains(config::APP_ID), "{}", dialog.debug_info()); + } + + #[test] + fn embedded_metainfo_names_the_build_app_id() { + let xml = embedded_metainfo(); + + assert_eq!(metainfo_text(&xml, ""), config::APP_ID); + assert_eq!( + metainfo_text(&xml, ""), + format!("{}.desktop", config::APP_ID) + ); + assert!(!xml.contains("@app-id@") && !xml.contains("@gettext-package@"), "{xml}"); + } + + #[test] + fn release_notes_version_absent_in_development_profile() { + assert_eq!(release_notes_version(Profile::Development), None); + assert_eq!(release_notes_version(Profile::Default), Some(config::VERSION)); + } +} diff --git a/linux/crates/app/src/application/app_actions.rs b/linux/crates/app/src/application/app_actions.rs index eeb3bfc9a9..63207b6c10 100644 --- a/linux/crates/app/src/application/app_actions.rs +++ b/linux/crates/app/src/application/app_actions.rs @@ -1,8 +1,7 @@ -use gtk4 as gtk; use gtk4::prelude::*; -use libadwaita as adw; use libadwaita::prelude::*; +use super::about_dialog::build_about_dialog; use super::{TableProApplication, WindowLaunch}; use crate::ui::PreferencesDialog; @@ -29,7 +28,7 @@ pub fn install_app_actions(app: &TableProApplication) { .activate(|app: &TableProApplication, _, _| present_preferences(app)) .build(); let about = gio::ActionEntry::builder("about") - .activate(|app: &TableProApplication, _, _| about_dialog().present(app.active_window().as_ref())) + .activate(|app: &TableProApplication, _, _| present_about(app)) .build(); app.add_action_entries([new_window, quit, preferences, about]); for (action, accels) in APP_ACCELS { @@ -50,24 +49,11 @@ fn present_preferences(app: &TableProApplication) { .present(app.active_window().as_ref()); } -fn about_dialog() -> adw::AboutDialog { - let dialog = adw::AboutDialog::builder() - .application_name(crate::i18n::gettext("TablePro")) - .application_icon(crate::config::APP_ID) - .developer_name(crate::i18n::gettext("TablePro Authors")) - .version(crate::config::VERSION) - .website("https://github.com/TableProApp/TablePro") - .issue_url("https://github.com/TableProApp/TablePro/issues") - .support_url("https://github.com/TableProApp/TablePro/discussions") - .copyright(crate::i18n::gettext("© 2025–2026 TablePro Authors")) - .license_type(gtk::License::Agpl30) - .comments(crate::i18n::gettext( - "A native Linux database client built with GTK4 + libadwaita.", - )) - .build(); - dialog.set_developers(&["TablePro Authors https://github.com/TableProApp/TablePro"]); - dialog.set_translator_credits(&crate::i18n::gettext("translator-credits")); - dialog +fn present_about(app: &TableProApplication) { + let Some(services) = app.services() else { + return; + }; + build_about_dialog(&services.drivers).present(app.active_window().as_ref()); } #[cfg(test)] @@ -76,6 +62,8 @@ mod tests { use std::time::Duration; use gtk::gdk; + use gtk4 as gtk; + use libadwaita as adw; use tablepro_session::runtime::CountingHold; use super::*; diff --git a/linux/crates/app/src/application/debug_info.rs b/linux/crates/app/src/application/debug_info.rs new file mode 100644 index 0000000000..a54a5c9b70 --- /dev/null +++ b/linux/crates/app/src/application/debug_info.rs @@ -0,0 +1,103 @@ +use tablepro_core::DriverRegistry; + +use super::runtime_versions::RuntimeVersions; +use crate::config::{self, Profile}; + +/// The Troubleshooting text of the About dialog. A user pastes it into a +/// public bug report, so it takes nothing that could hold a connection, +/// a host, a user name or a path. +pub fn debug_info(versions: &RuntimeVersions, drivers: &DriverRegistry) -> String { + let mut drivers: Vec<_> = drivers + .iter() + .map(|driver| (driver.id(), driver.display_name())) + .collect(); + // The registry is a hash map; a report reads the same each time. + drivers.sort_unstable(); + + let mut lines = vec![ + format!("TablePro {}", config::VERSION), + format!("Application ID: {}", config::APP_ID), + format!("Profile: {}", profile_name(config::profile())), + String::new(), + format!("GTK: {}", dotted(versions.gtk)), + format!("libadwaita: {}", dotted(versions.adwaita)), + format!("GtkSourceView: {}", dotted(versions.sourceview)), + format!("Sandbox: {}", if versions.flatpak { "Flatpak" } else { "none" }), + String::new(), + "Drivers:".to_owned(), + ]; + lines.extend(drivers.into_iter().map(|(id, name)| format!("- {id}: {name}"))); + lines.push(String::new()); + lines.join("\n") +} + +fn profile_name(profile: Profile) -> &'static str { + match profile { + Profile::Default => "default", + Profile::Development => "development", + } +} + +fn dotted((major, minor, micro): (u32, u32, u32)) -> String { + format!("{major}.{minor}.{micro}") +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + + #[test] + fn debug_info_lists_versions_and_drivers_only() { + let versions = RuntimeVersions { + gtk: (4, 22, 1), + adwaita: (1, 9, 2), + sourceview: (5, 18, 0), + flatpak: true, + }; + let mut drivers = DriverRegistry::new(); + drivers.register(Arc::new(drivers_sqlite::SqliteDriver)); + drivers.register(Arc::new(drivers_postgres::PgDriver)); + + let expected = format!( + "TablePro {version}\n\ + Application ID: {app_id}\n\ + Profile: {profile}\n\ + \n\ + GTK: 4.22.1\n\ + libadwaita: 1.9.2\n\ + GtkSourceView: 5.18.0\n\ + Sandbox: Flatpak\n\ + \n\ + Drivers:\n\ + - postgres: PostgreSQL\n\ + - sqlite: SQLite\n", + version = config::VERSION, + app_id = config::APP_ID, + profile = profile_name(config::profile()), + ); + assert_eq!(debug_info(&versions, &drivers), expected); + } + + #[test] + fn debug_info_says_when_there_is_no_sandbox() { + let versions = RuntimeVersions { + gtk: (4, 22, 0), + adwaita: (1, 9, 0), + sourceview: (5, 18, 0), + flatpak: false, + }; + + let text = debug_info(&versions, &DriverRegistry::new()); + + assert!(text.contains("\nSandbox: none\n"), "{text}"); + assert!(text.ends_with("Drivers:\n"), "{text}"); + } + + #[test] + fn profile_names_match_the_meson_option() { + assert_eq!(profile_name(Profile::Default), "default"); + assert_eq!(profile_name(Profile::Development), "development"); + } +} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs index 033c60dabf..25d4a738fc 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -1,12 +1,15 @@ //! The GApplication: one process per profile, the actions every window //! shares, and the windows themselves. +mod about_dialog; mod app_actions; mod app_services; mod app_window_host; +mod debug_info; mod imp; mod open_files; mod quit_sequence; +mod runtime_versions; mod session_signals; mod window_host; mod window_launch; diff --git a/linux/crates/app/src/application/runtime_versions.rs b/linux/crates/app/src/application/runtime_versions.rs new file mode 100644 index 0000000000..45f9421d1d --- /dev/null +++ b/linux/crates/app/src/application/runtime_versions.rs @@ -0,0 +1,47 @@ +use std::path::Path; + +use gtk4 as gtk; +use libadwaita as adw; + +/// The toolkit libraries the process loaded, which can be newer than the +/// ones it was built against. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimeVersions { + pub gtk: (u32, u32, u32), + pub adwaita: (u32, u32, u32), + pub sourceview: (u32, u32, u32), + pub flatpak: bool, +} + +impl RuntimeVersions { + /// GTK thread only, once GTK is initialised: the bindings assert both. + pub fn current() -> Self { + Self { + gtk: (gtk::major_version(), gtk::minor_version(), gtk::micro_version()), + adwaita: (adw::major_version(), adw::minor_version(), adw::micro_version()), + sourceview: ( + sourceview5::major_version(), + sourceview5::minor_version(), + sourceview5::micro_version(), + ), + // Flatpak writes this file at the root of every sandbox. + flatpak: Path::new("/.flatpak-info").exists(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn current_versions_meet_the_platform_floor() { + adw::init().expect("libadwaita initialises under the test backend"); + + let versions = RuntimeVersions::current(); + + assert!(versions.gtk >= (4, 22, 0), "{:?}", versions.gtk); + assert!(versions.adwaita >= (1, 9, 0), "{:?}", versions.adwaita); + assert!(versions.sourceview >= (5, 18, 0), "{:?}", versions.sourceview); + } +} diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 43f8172b18..002ce77733 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -2,6 +2,8 @@ style.css + + metainfo.xml shortcuts-dialog.ui ui/browse-tab.ui ui/connect-dialog.ui diff --git a/linux/docs/decisions/0006-meson-cargo-build.md b/linux/docs/decisions/0006-meson-cargo-build.md index 473c1a845e..fcb55b81f5 100644 --- a/linux/docs/decisions/0006-meson-cargo-build.md +++ b/linux/docs/decisions/0006-meson-cargo-build.md @@ -35,7 +35,9 @@ Meson is the build system; Cargo stays the Rust compiler. - `data/meson.build` owns the desktop entry, the D-Bus service file, the AppStream metainfo and the icons. The desktop and metainfo templates are `.in.in`: `configure_file` substitutes the application ID, then - `i18n.merge_file` merges the translations. + `i18n.merge_file` merges the translations. `crates/app/build.rs` + renders the metainfo template with the same values into the GResource, + without translations, and the About dialog reads it from there. - Tests run through `meson test`, grouped into the `unit`, `gtk` and `data` suites. The `data` suite runs `desktop-file-validate` and `appstreamcli validate`, which is what Flathub checks. diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index f80aabcfbb..2a937a59f1 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -1,10 +1,13 @@ +crates/app/src/application/about_dialog.rs crates/app/src/application/app_actions.rs crates/app/src/application/app_services.rs crates/app/src/application/app_window_host.rs +crates/app/src/application/debug_info.rs crates/app/src/application/imp.rs crates/app/src/application/mod.rs crates/app/src/application/open_files.rs crates/app/src/application/quit_sequence.rs +crates/app/src/application/runtime_versions.rs crates/app/src/application/session_signals.rs crates/app/src/application/tests.rs crates/app/src/application/window_host.rs From ec7b085703fe7bdb8e03f2b881287490d6b23138 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:33:12 +0700 Subject: [PATCH 118/301] test(linux): pass the settings and askpass tests in one test process --- .../app/src/services/history_preferences.rs | 4 +++- .../test-fixtures/src/scripted_askpass.rs | 22 +++++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/linux/crates/app/src/services/history_preferences.rs b/linux/crates/app/src/services/history_preferences.rs index 7739f9eadb..77fd4cd8df 100644 --- a/linux/crates/app/src/services/history_preferences.rs +++ b/linux/crates/app/src/services/history_preferences.rs @@ -48,7 +48,9 @@ mod tests { use super::*; use crate::test_support::MemorySettings; - #[test] + /// On the GTK thread, which owns the main context GSettings sends + /// its change signals through. + #[gtk4::test] fn a_changed_preference_reaches_the_channel() { let settings = MemorySettings::new(); let preferences = HistoryPreferences::follow(settings.get()); diff --git a/linux/crates/test-fixtures/src/scripted_askpass.rs b/linux/crates/test-fixtures/src/scripted_askpass.rs index 46585402f3..1bd6a5d59b 100644 --- a/linux/crates/test-fixtures/src/scripted_askpass.rs +++ b/linux/crates/test-fixtures/src/scripted_askpass.rs @@ -45,10 +45,24 @@ mod tests { let askpass = ScriptedAskpass::new().unwrap(); let mode = std::fs::metadata(askpass.path()).unwrap().permissions().mode() & 0o777; - let output = Command::new(askpass.path()) - .arg("tablepro@localhost's password: ") - .env(ScriptedAskpass::ANSWER_VARIABLE, "s3cret value") - .output() + // A test thread that forks while the script is still open for + // writing hands its child the write handle until that child + // execs, and exec refuses a file open for writing. + let output = (0..100) + .find_map(|_| { + match Command::new(askpass.path()) + .arg("tablepro@localhost's password: ") + .env(ScriptedAskpass::ANSWER_VARIABLE, "s3cret value") + .output() + { + Err(error) if error.kind() == std::io::ErrorKind::ExecutableFileBusy => { + std::thread::sleep(std::time::Duration::from_millis(10)); + None + } + ran => Some(ran), + } + }) + .expect("the script was busy on every try") .unwrap(); assert_eq!(mode, 0o700); From 96e7765503a750d7e89fce0d5801c5204e8f076e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:34:40 +0700 Subject: [PATCH 119/301] feat(linux-core): add streaming CSV, TSV, JSON and Markdown encoders --- linux/CHANGELOG.md | 1 + linux/Cargo.lock | 1 - linux/Cargo.toml | 2 +- linux/clippy.toml | 12 + linux/crates/app/clippy.toml | 10 + linux/crates/app/src/ui/export_dialog.rs | 8 +- linux/crates/app/src/ui/grid/grid_menus.rs | 4 +- .../app/tests/application_session_restore.rs | 8 +- linux/crates/core/src/export/csv.rs | 22 +- .../core/src/export/encoder/csv_encoder.rs | 58 ++++ .../core/src/export/encoder/encode_error.rs | 9 + .../src/export/encoder/json_array_encoder.rs | 83 +++++ .../src/export/encoder/markdown_encoder.rs | 40 +++ linux/crates/core/src/export/encoder/mod.rs | 76 +++++ .../core/src/export/encoder/row_encoder.rs | 17 ++ linux/crates/core/src/export/encoder/tests.rs | 284 ++++++++++++++++++ .../core/src/export/encoder/tsv_encoder.rs | 54 ++++ linux/crates/core/src/export/json.rs | 22 +- linux/crates/core/src/export/markdown.rs | 27 +- linux/crates/core/src/export/mod.rs | 10 +- linux/crates/core/src/export/tsv.rs | 14 +- linux/crates/core/src/export/xlsx.rs | 16 +- .../tests/integration/keys_and_decimals.rs | 45 ++- linux/crates/storage/src/error.rs | 2 +- 24 files changed, 738 insertions(+), 87 deletions(-) create mode 100644 linux/crates/core/src/export/encoder/csv_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/encode_error.rs create mode 100644 linux/crates/core/src/export/encoder/json_array_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/markdown_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/mod.rs create mode 100644 linux/crates/core/src/export/encoder/row_encoder.rs create mode 100644 linux/crates/core/src/export/encoder/tests.rs create mode 100644 linux/crates/core/src/export/encoder/tsv_encoder.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 9c6ad6e5d3..87bd115824 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -47,6 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Set Value in a grid cell's menu is Set to Empty, Set to NULL and Set to DEFAULT, each shown only where the column takes it. - Set a grid cell to NULL with Ctrl+Backspace instead of Ctrl+Shift+N. - Quit asks each window about its unsaved changes in turn, and waits for open connections to close. +- JSON exports and copies put one row per line, and a value TablePro couldn't read stops the export instead of becoming NULL. - Each window holds one connection: opening one already open brings its window forward, and Disconnect returns to the list. - Connecting shows in the window with Cancel, and a failed connect says why, with Retry, Edit Connection and Choose Connection. - Deleting a connection asks once, offers its window when it is open, and can delete its query history too. diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 2b44d6cf97..b6620317bb 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -3735,7 +3735,6 @@ version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap 2.14.2", "itoa", "memchr", "serde", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index ce5676e5fb..be2a9477c8 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -26,7 +26,7 @@ publish = false async-trait = "0.1" thiserror = "2" serde = { version = "1", features = ["derive"] } -serde_json = { version = "1", features = ["preserve_order", "raw_value"] } +serde_json = { version = "1", features = ["raw_value"] } sqlparser = { version = "0.63", default-features = false, features = ["std"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync", "process"] } tokio-util = { version = "0.7", default-features = false, features = ["codec", "rt"] } diff --git a/linux/clippy.toml b/linux/clippy.toml index 82a664d8a1..4598cd680a 100644 --- a/linux/clippy.toml +++ b/linux/clippy.toml @@ -3,3 +3,15 @@ allow-expect-in-tests = true allow-panic-in-tests = true allow-dbg-in-tests = true allow-print-in-tests = true + +# A serde_json map sorts its keys, which would reorder a document the +# server sent. JSON a row holds stays as JsonText, and JSON the app +# writes goes out in the order it is written. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + +disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, +] diff --git a/linux/crates/app/clippy.toml b/linux/crates/app/clippy.toml index be994fc95e..4d4d39d206 100644 --- a/linux/crates/app/clippy.toml +++ b/linux/crates/app/clippy.toml @@ -4,7 +4,17 @@ allow-panic-in-tests = true allow-dbg-in-tests = true allow-print-in-tests = true +# A serde_json map sorts its keys, which would reorder a document the +# server sent. JSON a row holds stays as JsonText, and JSON the app +# writes goes out in the order it is written. +disallowed-types = [ + { path = "serde_json::Map", reason = "key order is not preserved; keep JSON as JsonText" }, +] + disallowed-methods = [ + { path = "serde_json::Value::as_object", reason = "key order is not preserved; keep JSON as JsonText" }, + { path = "serde_json::Value::as_object_mut", reason = "key order is not preserved; keep JSON as JsonText" }, + # Catalogue text goes into DDL verbatim, so only a driver may build # one from a string. User input goes through SqlDialect::parse_type # or parse_expression first. diff --git a/linux/crates/app/src/ui/export_dialog.rs b/linux/crates/app/src/ui/export_dialog.rs index ee1ed8dec3..7f765c7f68 100644 --- a/linux/crates/app/src/ui/export_dialog.rs +++ b/linux/crates/app/src/ui/export_dialog.rs @@ -386,8 +386,12 @@ fn save_with_file_dialog(parent: &adw::ApplicationWindow, toast_overlay: &adw::T Format::Xlsx => { export::render_xlsx(&result.columns, &result.rows, &title).map_err(|error| error.to_string()) } - Format::Json => Ok(export::render_json(&result.columns, &result.rows).into_bytes()), - Format::Markdown => Ok(export::render_markdown(&result.columns, &result.rows).into_bytes()), + Format::Json => export::render_json(&result.columns, &result.rows) + .map(String::into_bytes) + .map_err(|e| e.to_string()), + Format::Markdown => export::render_markdown(&result.columns, &result.rows) + .map(String::into_bytes) + .map_err(|e| e.to_string()), Format::Html => Ok(export::render_html(&result.columns, &result.rows, &title).into_bytes()), Format::Xml => Ok(export::render_xml(&result.columns, &result.rows).into_bytes()), Format::Sql => match target.as_ref() { diff --git a/linux/crates/app/src/ui/grid/grid_menus.rs b/linux/crates/app/src/ui/grid/grid_menus.rs index f0417c3f86..d38c582b86 100644 --- a/linux/crates/app/src/ui/grid/grid_menus.rs +++ b/linux/crates/app/src/ui/grid/grid_menus.rs @@ -286,7 +286,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { }; let copy_json_action = { let cols = columns.clone(); - copy_action!("copy-json", |rows| GridMsg::CopyToClipboard( + copy_action!("copy-json", |rows| clipboard_message( tablepro_core::export::render_json(&cols, &rows) )) }; @@ -309,7 +309,7 @@ pub(super) fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { }; let copy_markdown_action = { let cols = columns.clone(); - copy_action!("copy-markdown", |rows| GridMsg::CopyToClipboard( + copy_action!("copy-markdown", |rows| clipboard_message( tablepro_core::export::render_markdown(&cols, &rows) )) }; diff --git a/linux/crates/app/tests/application_session_restore.rs b/linux/crates/app/tests/application_session_restore.rs index 2537fb2e09..a36a45b291 100644 --- a/linux/crates/app/tests/application_session_restore.rs +++ b/linux/crates/app/tests/application_session_restore.rs @@ -263,15 +263,15 @@ impl Sandbox { /// Write the files as the sessions open at the last quit. fn record(&self, uris: &[&str]) -> Result<(), String> { - let sessions: Vec = uris + let sessions: Vec = uris .iter() - .map(|uri| serde_json::json!({ "kind": "file", "uri": uri })) + .map(|uri| format!(r#"{{"kind":"file","uri":{}}}"#, serde_json::Value::from(*uri))) .collect(); - let record = serde_json::json!({ "version": 1, "sessions": sessions }); + let record = format!(r#"{{"version":1,"sessions":[{}]}}"#, sessions.join(",")); let path = self.record_path(); let parent = path.parent().ok_or("the record has no directory")?; std::fs::create_dir_all(parent).map_err(|error| format!("could not create {}: {error}", parent.display()))?; - std::fs::write(&path, record.to_string()).map_err(|error| format!("could not write the record: {error}")) + std::fs::write(&path, record).map_err(|error| format!("could not write the record: {error}")) } /// The file URIs recorded, in order. diff --git a/linux/crates/core/src/export/csv.rs b/linux/crates/core/src/export/csv.rs index 44e72c4ea8..f3ff38e741 100644 --- a/linux/crates/core/src/export/csv.rs +++ b/linux/crates/core/src/export/csv.rs @@ -2,8 +2,9 @@ use crate::column::{ColumnType, ResultColumn}; use crate::value::Value; use super::csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use super::encoder::{CsvEncoder, EncodeError, encode_rows}; use super::value_text::value_text; -use super::{EncodeError, column_type, finish}; +use super::{RenderError, finish}; /// A neutralised cell keeps its leading apostrophe as data only when the /// field is quoted, so sanitising quotes every field. @@ -62,7 +63,7 @@ fn collapse_line_breaks(text: String, opts: &CsvOptions) -> String { } } -fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { +pub(super) fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { let Some(text) = value_text(value, column) else { return if opts.null_to_empty { String::new() @@ -82,7 +83,7 @@ fn csv_cell(value: &Value, column: &ColumnType, opts: &CsvOptions) -> String { } } -fn csv_header(name: &str, opts: &CsvOptions) -> String { +pub(super) fn csv_header(name: &str, opts: &CsvOptions) -> String { if opts.sanitize_formulas { neutralise_formula(&Value::Null, name.to_string(), true) } else { @@ -100,23 +101,12 @@ fn csv_text_field(text: &str, opts: &CsvOptions) -> String { } pub fn render_csv(columns: &[ResultColumn], rows: &[Vec], opts: &CsvOptions) -> Result { - let mut writer = csv_writer_builder(opts).from_writer(Vec::new()); - if opts.header_row { - writer.write_record(columns.iter().map(|column| csv_header(&column.name, opts)))?; - } - for row in rows { - writer.write_record( - row.iter() - .enumerate() - .map(|(index, value)| csv_cell(value, column_type(columns, index), opts)), - )?; - } - finish(writer) + encode_rows(CsvEncoder::new(columns.into(), opts.clone()), rows) } /// CSV for records whose fields are all free text, such as exported query /// history. -pub fn render_text_csv(header: &[&str], records: &[Vec], opts: &CsvOptions) -> Result { +pub fn render_text_csv(header: &[&str], records: &[Vec], opts: &CsvOptions) -> Result { let mut writer = csv_writer_builder(opts).from_writer(Vec::new()); if opts.header_row { writer.write_record(header.iter().map(|name| csv_header(name, opts)))?; diff --git a/linux/crates/core/src/export/encoder/csv_encoder.rs b/linux/crates/core/src/export/encoder/csv_encoder.rs new file mode 100644 index 0000000000..a058e80ac2 --- /dev/null +++ b/linux/crates/core/src/export/encoder/csv_encoder.rs @@ -0,0 +1,58 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::csv::{csv_cell, csv_header, csv_writer_builder}; +use super::super::csv_options::CsvOptions; +use super::{EncodeError, RECORD_BUFFER, RowEncoder, readable_cells, write_record}; + +/// CSV under the user's options, with formula-like text neutralised +/// when they ask for it. +pub struct CsvEncoder { + columns: Arc<[ResultColumn]>, + options: CsvOptions, + writer: csv::WriterBuilder, +} + +impl CsvEncoder { + pub fn new(columns: Arc<[ResultColumn]>, options: CsvOptions) -> Self { + let mut writer = csv_writer_builder(&options); + writer.buffer_capacity(RECORD_BUFFER); + Self { + columns, + options, + writer, + } + } +} + +impl RowEncoder for CsvEncoder { + fn begin(&mut self, out: &mut Vec) { + if self.options.header_row { + write_record( + &self.writer, + out, + self.columns + .iter() + .map(|column| csv_header(&column.name, &self.options)), + ); + } + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + write_record( + &self.writer, + out, + cells + .iter() + .enumerate() + .map(|(index, value)| csv_cell(value, column_type(&self.columns, index), &self.options)), + ); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/encode_error.rs b/linux/crates/core/src/export/encoder/encode_error.rs new file mode 100644 index 0000000000..4a7c6c2eef --- /dev/null +++ b/linux/crates/core/src/export/encoder/encode_error.rs @@ -0,0 +1,9 @@ +use thiserror::Error; + +/// Why a row could not be encoded. The text is for logs; the app says it +/// in its own words. +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EncodeError { + #[error("column {column} has an undecodable value")] + Undecodable { column: String }, +} diff --git a/linux/crates/core/src/export/encoder/json_array_encoder.rs b/linux/crates/core/src/export/encoder/json_array_encoder.rs new file mode 100644 index 0000000000..867f2d791b --- /dev/null +++ b/linux/crates/core/src/export/encoder/json_array_encoder.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use serde::Serialize; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::json::JsonCell; +use super::super::json_field_names::json_field_names; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// A JSON array with one object per row, each on a line of its own. +/// +/// The objects are written key by key in column order rather than +/// built as a map first: a map would cost a copy of every row and, with +/// serde_json's default map, sort the keys. +pub struct JsonArrayEncoder { + columns: Arc<[ResultColumn]>, + /// Each column's key as JSON, followed by its colon. + keys: Vec>, + rows: u64, +} + +impl JsonArrayEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + let keys = json_field_names(&columns) + .iter() + .map(|name| { + let mut key = Vec::with_capacity(name.len() + 3); + write_json(&mut key, name); + key.push(b':'); + key + }) + .collect(); + Self { columns, keys, rows: 0 } + } +} + +impl RowEncoder for JsonArrayEncoder { + fn begin(&mut self, out: &mut Vec) { + out.push(b'['); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + let lead: &[u8] = if self.rows == 0 { b"\n{" } else { b",\n{" }; + out.extend_from_slice(lead); + for (index, key) in self.keys.iter().enumerate() { + if index > 0 { + out.push(b','); + } + out.extend_from_slice(key); + match cells.get(index).copied() { + Some(value) => write_json( + out, + &JsonCell { + value, + kind: &column_type(&self.columns, index).kind, + }, + ), + // A row shorter than its header: the cell is missing, + // which is not the same as a NULL the row holds. + None => out.extend_from_slice(b"null"), + } + } + out.push(b'}'); + self.rows += 1; + Ok(()) + } + + fn finish(&mut self, out: &mut Vec) { + if self.rows > 0 { + out.push(b'\n'); + } + out.push(b']'); + } +} + +/// serde_json fails only when its writer does, and a Vec never does. +fn write_json(out: &mut Vec, value: &impl Serialize) { + let _ = serde_json::to_writer(out, value); +} diff --git a/linux/crates/core/src/export/encoder/markdown_encoder.rs b/linux/crates/core/src/export/encoder/markdown_encoder.rs new file mode 100644 index 0000000000..66347c3e01 --- /dev/null +++ b/linux/crates/core/src/export/encoder/markdown_encoder.rs @@ -0,0 +1,40 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::markdown::{markdown_cell, markdown_text}; +use super::{EncodeError, RowEncoder, readable_cells}; + +/// A Markdown table. Each row starts on a line of its own, so the table +/// ends without a trailing line break. +pub struct MarkdownEncoder { + columns: Arc<[ResultColumn]>, +} + +impl MarkdownEncoder { + pub fn new(columns: Arc<[ResultColumn]>) -> Self { + Self { columns } + } +} + +impl RowEncoder for MarkdownEncoder { + fn begin(&mut self, out: &mut Vec) { + let header: Vec = self.columns.iter().map(|column| markdown_text(&column.name)).collect(); + let separator: Vec<&str> = self.columns.iter().map(|_| "---").collect(); + out.extend_from_slice(format!("| {} |\n| {} |", header.join(" | "), separator.join(" | ")).as_bytes()); + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells: Vec = readable_cells(&self.columns, cells)? + .into_iter() + .enumerate() + .map(|(index, value)| markdown_cell(value, column_type(&self.columns, index))) + .collect(); + out.extend_from_slice(format!("\n| {} |", cells.join(" | ")).as_bytes()); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/encoder/mod.rs b/linux/crates/core/src/export/encoder/mod.rs new file mode 100644 index 0000000000..5a1911c381 --- /dev/null +++ b/linux/crates/core/src/export/encoder/mod.rs @@ -0,0 +1,76 @@ +mod csv_encoder; +mod encode_error; +mod json_array_encoder; +mod markdown_encoder; +mod row_encoder; +mod tsv_encoder; + +use crate::column::ResultColumn; +use crate::value::Value; + +pub use csv_encoder::CsvEncoder; +pub use encode_error::EncodeError; +pub use json_array_encoder::JsonArrayEncoder; +pub use markdown_encoder::MarkdownEncoder; +pub use row_encoder::RowEncoder; +pub use tsv_encoder::TsvEncoder; + +/// A whole result through `encoder`, as text. +pub(super) fn encode_rows(mut encoder: impl RowEncoder, rows: &[Vec]) -> Result { + let mut out = Vec::new(); + encoder.begin(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.iter())?; + } + encoder.finish(&mut out); + // Every encoder writes UTF-8 text and ASCII punctuation, so the + // lossy branch is never taken. + Ok(String::from_utf8(out).unwrap_or_else(|error| String::from_utf8_lossy(error.as_bytes()).into_owned())) +} + +/// The row's cells, refused when one of them could not be read: a value +/// the driver could not decode has no text, and writing it as a NULL +/// would put a value in the file that the table does not hold. +fn readable_cells<'a>( + columns: &[ResultColumn], + cells: &mut dyn Iterator, +) -> Result, EncodeError> { + cells + .enumerate() + .map(|(index, value)| match value { + Value::Undecodable(_) => Err(EncodeError::Undecodable { + column: column_name(columns, index), + }), + value => Ok(value), + }) + .collect() +} + +/// A column's name, or its position for a cell past the header. +fn column_name(columns: &[ResultColumn], index: usize) -> String { + columns + .get(index) + .map_or_else(|| (index + 1).to_string(), |column| column.name.clone()) +} + +/// One record through a writer of its own, straight into `out`. +/// +/// Writing into memory cannot fail, and a writer that sees a single +/// record has no earlier field count to disagree with, so neither +/// result carries anything. +fn write_record(builder: &csv::WriterBuilder, out: &mut Vec, record: I) +where + I: IntoIterator, + T: AsRef<[u8]>, +{ + let mut writer = builder.from_writer(out); + let _ = writer.write_record(record); + let _ = writer.flush(); +} + +/// A record rarely needs more, and the writer zeroes its whole buffer +/// for every row. +const RECORD_BUFFER: usize = 1024; + +#[cfg(test)] +mod tests; diff --git a/linux/crates/core/src/export/encoder/row_encoder.rs b/linux/crates/core/src/export/encoder/row_encoder.rs new file mode 100644 index 0000000000..2c6b26c096 --- /dev/null +++ b/linux/crates/core/src/export/encoder/row_encoder.rs @@ -0,0 +1,17 @@ +use crate::value::Value; + +use super::EncodeError; + +/// Writes a result one row at a time into a buffer the caller owns, so +/// the caller decides how much to hold before handing it on. +pub trait RowEncoder: Send { + /// What comes before the first row: a header line, an opening + /// bracket. + fn begin(&mut self, out: &mut Vec); + + /// One row. A row that fails leaves `out` as it was. + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError>; + + /// What comes after the last row. + fn finish(&mut self, out: &mut Vec); +} diff --git a/linux/crates/core/src/export/encoder/tests.rs b/linux/crates/core/src/export/encoder/tests.rs new file mode 100644 index 0000000000..63d47b31da --- /dev/null +++ b/linux/crates/core/src/export/encoder/tests.rs @@ -0,0 +1,284 @@ +use std::sync::Arc; + +use super::super::csv::{csv_cell, csv_header, csv_writer_builder}; +use super::super::csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +use super::super::test_columns::{cols, typed}; +use super::super::tsv::tsv_writer_builder; +use super::super::value_text::value_text; +use super::super::{column_type, render_csv, render_json, render_tsv}; +use super::*; +use crate::column::{ColumnKind, IntegerKind, IntegerWidth}; +use crate::value::{JsonText, UndecodableReason, UndecodedValue}; + +/// Every call's output taken away as soon as it is written, the way a +/// sink hands each chunk on, so an encoder that leaned on what it wrote +/// earlier would show here. +fn streamed(mut encoder: impl RowEncoder, rows: &[Vec]) -> String { + let mut taken = Vec::new(); + let mut out = Vec::new(); + encoder.begin(&mut out); + taken.append(&mut out); + for row in rows { + encoder.row(&mut out, &mut row.iter()).expect("the row"); + taken.append(&mut out); + } + encoder.finish(&mut out); + taken.append(&mut out); + String::from_utf8(taken).expect("utf-8") +} + +fn fixture() -> (Arc<[ResultColumn]>, Vec>) { + let columns: Arc<[ResultColumn]> = vec![ + typed( + "id", + "bigint", + ColumnKind::Integer(IntegerKind { + width: IntegerWidth::W64, + signed: true, + }), + ), + typed( + "amount", + "numeric(10,2)", + ColumnKind::Decimal { + precision: Some(10), + scale: Some(2), + }, + ), + cols(&["=note"]).remove(0), + ] + .into(); + let rows = vec![ + vec![ + Value::Int(-5), + Value::Decimal("-1.5".parse().expect("a decimal")), + Value::Text("=cmd|'/C calc'!A0".into()), + ], + vec![ + Value::Int(7), + Value::Float64(2.25), + Value::Text("line1\r\nline2, \"quoted\"\ttab".into()), + ], + vec![Value::Null, Value::Null, Value::Text(String::new())], + vec![ + Value::UInt(u64::MAX), + Value::Decimal("12.30".parse().expect("a decimal")), + Value::Text("-12".into()), + ], + ]; + (columns, rows) +} + +/// CSV as one writer over the whole result, the way it was written +/// before the encoders. +fn one_writer_csv(columns: &[ResultColumn], rows: &[Vec], options: &CsvOptions) -> String { + let mut writer = csv_writer_builder(options).from_writer(Vec::new()); + if options.header_row { + writer + .write_record(columns.iter().map(|column| csv_header(&column.name, options))) + .expect("the header"); + } + for row in rows { + writer + .write_record( + row.iter() + .enumerate() + .map(|(index, value)| csv_cell(value, column_type(columns, index), options)), + ) + .expect("the row"); + } + String::from_utf8(writer.into_inner().expect("flushed")).expect("utf-8") +} + +#[test] +fn csv_encoder_matches_render_csv_fixture_matrix() { + let (columns, rows) = fixture(); + let mut checked = 0; + for header_row in [true, false] { + for sanitize_formulas in [true, false] { + for null_to_empty in [true, false] { + for line_break_to_space in [true, false] { + for delimiter in CsvDelimiter::ALL { + for quote in CsvQuote::ALL { + for line_break in CsvLineBreak::ALL { + for decimal in CsvDecimal::ALL { + let options = CsvOptions { + null_to_empty, + line_break_to_space, + header_row, + sanitize_formulas, + delimiter, + quote, + line_break, + decimal, + }; + let expected = one_writer_csv(&columns, &rows, &options); + + let encoded = streamed(CsvEncoder::new(columns.clone(), options.clone()), &rows); + + assert_eq!(encoded, expected, "{options:?}"); + assert_eq!( + render_csv(&columns, &rows, &options).expect("the csv"), + expected, + "{options:?}" + ); + checked += 1; + } + } + } + } + } + } + } + } + assert_eq!(checked, 2 * 2 * 2 * 2 * 4 * 3 * 3 * 2); +} + +#[test] +fn tsv_encoder_matches_render_tsv() { + let (columns, rows) = fixture(); + for with_headers in [true, false] { + let mut writer = tsv_writer_builder().from_writer(Vec::new()); + if with_headers { + writer + .write_record(columns.iter().map(|column| column.name.as_str())) + .expect("the header"); + } + for row in &rows { + writer + .write_record(row.iter().enumerate().map(|(index, value)| { + value_text(value, column_type(&columns, index)).unwrap_or_else(|| "NULL".to_owned()) + })) + .expect("the row"); + } + let expected = String::from_utf8(writer.into_inner().expect("flushed")).expect("utf-8"); + + assert_eq!( + streamed(TsvEncoder::new(columns.clone(), with_headers), &rows), + expected + ); + assert_eq!(render_tsv(&columns, &rows, with_headers).expect("the tsv"), expected); + } +} + +#[test] +fn json_encoder_output_parses_and_matches_render_json() { + const CHUNK: usize = 64 * 1024; + let columns: Arc<[ResultColumn]> = cols(&["id", "name", "payload"]).into(); + let rows: Vec> = (0..10_000) + .map(|id| { + vec![ + Value::Int(id), + Value::Text(format!("row \"{id}\"\n")), + Value::Json(JsonText::parse(format!("{{\"n\": {id}}}")).expect("valid json")), + ] + }) + .collect(); + + let mut encoder = JsonArrayEncoder::new(columns.clone()); + let mut chunks: Vec> = Vec::new(); + let mut out = Vec::with_capacity(CHUNK); + encoder.begin(&mut out); + for row in &rows { + encoder.row(&mut out, &mut row.iter()).expect("the row"); + if out.len() >= CHUNK { + chunks.push(std::mem::replace(&mut out, Vec::with_capacity(CHUNK))); + } + } + encoder.finish(&mut out); + chunks.push(out); + + assert!(chunks.len() > 2, "the export fit in one buffer"); + assert!(chunks[..chunks.len() - 1].iter().all(|chunk| chunk.len() >= CHUNK)); + let text = String::from_utf8(chunks.concat()).expect("utf-8"); + assert_eq!(text, render_json(&columns, &rows).expect("the json")); + let parsed: Vec = serde_json::from_str(&text).expect("a JSON array"); + assert_eq!(parsed.len(), 10_000); + assert_eq!(parsed[9_999]["id"], serde_json::json!(9_999)); + assert_eq!(parsed[3]["name"], serde_json::json!("row \"3\"\n")); + assert_eq!(parsed[3]["payload"]["n"], serde_json::json!(3)); +} + +#[test] +fn json_encoder_keeps_column_order_without_preserve_order() { + let columns: Arc<[ResultColumn]> = cols(&["b", "a", "c"]).into(); + let rows = vec![vec![Value::Int(1), Value::Int(2), Value::Int(3)]]; + + assert_eq!( + streamed(JsonArrayEncoder::new(columns), &rows), + "[\n{\"b\":1,\"a\":2,\"c\":3}\n]" + ); +} + +#[test] +fn json_encoder_duplicate_column_names_use_suffixes() { + let columns: Arc<[ResultColumn]> = cols(&["id", "name", "id"]).into(); + let rows = vec![ + vec![Value::Int(1), Value::Text("a".into()), Value::Int(2)], + vec![Value::Int(3), Value::Null, Value::Int(4)], + ]; + + assert_eq!( + streamed(JsonArrayEncoder::new(columns), &rows), + "[\n{\"id\":1,\"name\":\"a\",\"id_2\":2},\n{\"id\":3,\"name\":null,\"id_2\":4}\n]" + ); +} + +#[test] +fn empty_json_export_is_empty_array() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + + assert_eq!(streamed(JsonArrayEncoder::new(columns), &[]), "[]"); +} + +#[test] +fn encoder_reports_undecodable_cell() { + let columns: Arc<[ResultColumn]> = cols(&["id", "shape"]).into(); + let undecodable = Value::Undecodable(Box::new(UndecodedValue { + type_name: "geometry".to_owned(), + reason: UndecodableReason::UnsupportedType, + })); + let encoders: Vec> = vec![ + Box::new(CsvEncoder::new(columns.clone(), CsvOptions::default())), + Box::new(TsvEncoder::new(columns.clone(), true)), + Box::new(JsonArrayEncoder::new(columns.clone())), + Box::new(MarkdownEncoder::new(columns.clone())), + ]; + for mut encoder in encoders { + let mut out = Vec::new(); + encoder.begin(&mut out); + let before = out.clone(); + + let error = encoder + .row(&mut out, &mut [Value::Int(1), undecodable.clone()].iter()) + .expect_err("an undecodable cell"); + + assert_eq!( + error, + EncodeError::Undecodable { + column: "shape".to_owned() + } + ); + assert_eq!(out, before, "a failed row left part of itself behind"); + } +} + +#[test] +fn csv_neutralises_text_not_negative_numbers_through_encoder() { + let columns: Arc<[ResultColumn]> = cols(&["a"]).into(); + let rows = vec![ + vec![Value::Text("=cmd".into())], + vec![Value::Text("-cmd".into())], + vec![Value::Int(-5)], + vec![Value::Decimal("-12.30".parse().expect("a decimal"))], + vec![Value::Float64(-1.5)], + vec![Value::Text("-12".into())], + ]; + + let out = streamed(CsvEncoder::new(columns, CsvOptions::default()), &rows); + + assert_eq!( + out, + "\"a\"\n\"'=cmd\"\n\"'-cmd\"\n\"-5\"\n\"-12.30\"\n\"-1.5\"\n\"-12\"\n" + ); +} diff --git a/linux/crates/core/src/export/encoder/tsv_encoder.rs b/linux/crates/core/src/export/encoder/tsv_encoder.rs new file mode 100644 index 0000000000..8bd1ccb4a5 --- /dev/null +++ b/linux/crates/core/src/export/encoder/tsv_encoder.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use crate::column::ResultColumn; +use crate::value::Value; + +use super::super::column_type; +use super::super::tsv::tsv_writer_builder; +use super::super::value_text::value_text; +use super::{EncodeError, RECORD_BUFFER, RowEncoder, readable_cells, write_record}; + +/// Tab-separated values, the form a spreadsheet pastes into cells. +pub struct TsvEncoder { + columns: Arc<[ResultColumn]>, + with_headers: bool, + writer: csv::WriterBuilder, +} + +impl TsvEncoder { + pub fn new(columns: Arc<[ResultColumn]>, with_headers: bool) -> Self { + let mut writer = tsv_writer_builder(); + writer.buffer_capacity(RECORD_BUFFER); + Self { + columns, + with_headers, + writer, + } + } +} + +impl RowEncoder for TsvEncoder { + fn begin(&mut self, out: &mut Vec) { + if self.with_headers { + write_record( + &self.writer, + out, + self.columns.iter().map(|column| column.name.as_str()), + ); + } + } + + fn row(&mut self, out: &mut Vec, cells: &mut dyn Iterator) -> Result<(), EncodeError> { + let cells = readable_cells(&self.columns, cells)?; + write_record( + &self.writer, + out, + cells.iter().enumerate().map(|(index, value)| { + value_text(value, column_type(&self.columns, index)).unwrap_or_else(|| "NULL".to_owned()) + }), + ); + Ok(()) + } + + fn finish(&mut self, _out: &mut Vec) {} +} diff --git a/linux/crates/core/src/export/json.rs b/linux/crates/core/src/export/json.rs index 81585d0424..f2bcb8b417 100644 --- a/linux/crates/core/src/export/json.rs +++ b/linux/crates/core/src/export/json.rs @@ -5,6 +5,7 @@ use crate::column::{ColumnKind, ResultColumn}; use crate::value::Value; use super::column_type; +use super::encoder::{EncodeError, JsonArrayEncoder, encode_rows}; use super::json_field_names::json_field_names; use super::value_text::{element_kind, kind_text}; @@ -41,9 +42,9 @@ impl Serialize for JsonRow<'_> { /// Numbers go out as the digits the server sent rather than through a /// float: `18446744073709551615` and `1.0000000000` both lose their /// value on the way through one. -struct JsonCell<'a> { - value: &'a Value, - kind: &'a ColumnKind, +pub(super) struct JsonCell<'a> { + pub(super) value: &'a Value, + pub(super) kind: &'a ColumnKind, } impl Serialize for JsonCell<'_> { @@ -113,17 +114,8 @@ pub fn row_to_json(columns: &[ResultColumn], row: &[Value]) -> String { serde_json::to_string_pretty(&value).unwrap_or_else(|_| "{}".to_owned()) } -pub fn render_json(columns: &[ResultColumn], rows: &[Vec]) -> String { - let names = json_field_names(columns); - let values: Vec> = rows - .iter() - .map(|row| JsonRow { - names: &names, - columns, - row, - }) - .collect(); - serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".to_owned()) +pub fn render_json(columns: &[ResultColumn], rows: &[Vec]) -> Result { + encode_rows(JsonArrayEncoder::new(columns.into()), rows) } #[cfg(test)] @@ -326,6 +318,6 @@ mod tests { fn render_json_empty_rows_is_empty_array() { let columns = cols(&["a"]); - assert_eq!(render_json(&columns, &[]), "[]"); + assert_eq!(render_json(&columns, &[]).expect("the array"), "[]"); } } diff --git a/linux/crates/core/src/export/markdown.rs b/linux/crates/core/src/export/markdown.rs index b97c7f0cf1..b4adff2e0b 100644 --- a/linux/crates/core/src/export/markdown.rs +++ b/linux/crates/core/src/export/markdown.rs @@ -1,36 +1,23 @@ use crate::column::{ColumnType, ResultColumn}; use crate::value::Value; -use super::column_type; +use super::encoder::{EncodeError, MarkdownEncoder, encode_rows}; use super::value_text::value_text; -fn markdown_cell(value: &Value, column: &ColumnType) -> String { +pub(super) fn markdown_cell(value: &Value, column: &ColumnType) -> String { markdown_text(&value_text(value, column).unwrap_or_else(|| "NULL".to_string())) } /// A pipe or a line break, in a cell or a column name alike, would end /// the cell or the row early. -fn markdown_text(text: &str) -> String { +pub(super) fn markdown_text(text: &str) -> String { text.replace('|', "\\|") .replace("\r\n", "
") .replace(['\r', '\n'], "
") } -pub fn render_markdown(columns: &[ResultColumn], rows: &[Vec]) -> String { - let mut lines: Vec = Vec::new(); - let header: Vec = columns.iter().map(|c| markdown_text(&c.name)).collect(); - lines.push(format!("| {} |", header.join(" | "))); - let separator: Vec<&str> = columns.iter().map(|_| "---").collect(); - lines.push(format!("| {} |", separator.join(" | "))); - for row in rows { - let cells: Vec = row - .iter() - .enumerate() - .map(|(index, value)| markdown_cell(value, column_type(columns, index))) - .collect(); - lines.push(format!("| {} |", cells.join(" | "))); - } - lines.join("\n") +pub fn render_markdown(columns: &[ResultColumn], rows: &[Vec]) -> Result { + encode_rows(MarkdownEncoder::new(columns.into()), rows) } #[cfg(test)] @@ -43,7 +30,7 @@ mod tests { let columns = cols(&["a"]); let rows = vec![vec![Value::Text("has|pipe\nand newline".into())]]; - let out = render_markdown(&columns, &rows); + let out = render_markdown(&columns, &rows).expect("the table"); assert_eq!(out, "| a |\n| --- |\n| has\\|pipe
and newline |"); } @@ -53,7 +40,7 @@ mod tests { let columns = cols(&["a|b", "c"]); let rows = vec![vec![Value::Int(1), Value::Int(2)]]; - let out = render_markdown(&columns, &rows); + let out = render_markdown(&columns, &rows).expect("the table"); assert_eq!(out, "| a\\|b | c |\n| --- | --- |\n| 1 | 2 |"); } diff --git a/linux/crates/core/src/export/mod.rs b/linux/crates/core/src/export/mod.rs index 05b46ad821..22ff2f468f 100644 --- a/linux/crates/core/src/export/mod.rs +++ b/linux/crates/core/src/export/mod.rs @@ -1,5 +1,6 @@ mod csv; mod csv_options; +mod encoder; mod html; mod in_clause; mod json; @@ -19,6 +20,7 @@ use crate::value::Value; pub use csv::{FORMULA_LEADS, csv_writer_builder, neutralise_formula, render_csv, render_text_csv}; pub use csv_options::{CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; +pub use encoder::{CsvEncoder, EncodeError, JsonArrayEncoder, MarkdownEncoder, RowEncoder, TsvEncoder}; pub use html::render_html; pub use in_clause::{InClause, render_in_clause}; pub use json::{render_json, row_to_json}; @@ -31,8 +33,10 @@ pub use value_text::{value_text, value_to_text}; pub use xlsx::render_xlsx; pub use xml::render_xml; +/// Why a whole document could not be rendered: a workbook past what a +/// sheet holds, or free-text CSV the writer refused. #[derive(Debug, Error)] -pub enum EncodeError { +pub enum RenderError { #[error("CSV encoding failed: {0}")] Csv(#[from] ::csv::Error), @@ -68,10 +72,10 @@ fn column_type(columns: &[ResultColumn], index: usize) -> &ColumnType { .unwrap_or_else(|| &UNTYPED) } -fn finish(writer: ::csv::Writer>) -> Result { +fn finish(writer: ::csv::Writer>) -> Result { let bytes = writer .into_inner() - .map_err(|error| EncodeError::Flush(error.into_error()))?; + .map_err(|error| RenderError::Flush(error.into_error()))?; Ok(String::from_utf8(bytes)?) } diff --git a/linux/crates/core/src/export/tsv.rs b/linux/crates/core/src/export/tsv.rs index a69720f472..960fb86a99 100644 --- a/linux/crates/core/src/export/tsv.rs +++ b/linux/crates/core/src/export/tsv.rs @@ -1,8 +1,7 @@ use crate::column::ResultColumn; use crate::value::Value; -use super::value_text::value_text; -use super::{EncodeError, column_type, finish}; +use super::encoder::{EncodeError, TsvEncoder, encode_rows}; /// A tab, a line break or a quote inside a value would move the following /// text into the next column or the next row, so such a value is quoted @@ -19,16 +18,7 @@ pub fn tsv_writer_builder() -> csv::WriterBuilder { } pub fn render_tsv(columns: &[ResultColumn], rows: &[Vec], with_headers: bool) -> Result { - let mut writer = tsv_writer_builder().from_writer(Vec::new()); - if with_headers { - writer.write_record(columns.iter().map(|column| column.name.as_str()))?; - } - for row in rows { - writer.write_record(row.iter().enumerate().map(|(index, value)| { - value_text(value, column_type(columns, index)).unwrap_or_else(|| "NULL".to_string()) - }))?; - } - finish(writer) + encode_rows(TsvEncoder::new(columns.into(), with_headers), rows) } #[cfg(test)] diff --git a/linux/crates/core/src/export/xlsx.rs b/linux/crates/core/src/export/xlsx.rs index 8dac182854..93911eca9a 100644 --- a/linux/crates/core/src/export/xlsx.rs +++ b/linux/crates/core/src/export/xlsx.rs @@ -1,6 +1,6 @@ use rust_xlsxwriter::{Format, Workbook, Worksheet}; -use super::{EncodeError, cell_text}; +use super::{RenderError, cell_text}; use crate::column::ResultColumn; use crate::value::{Temporal, Value}; @@ -15,16 +15,16 @@ const MAX_COLUMNS: usize = 16_384; /// /// A string cell is never read as a formula, whatever it starts with, /// so unlike CSV this needs no formula neutralising. -pub fn render_xlsx(columns: &[ResultColumn], rows: &[Vec], sheet_name: &str) -> Result, EncodeError> { +pub fn render_xlsx(columns: &[ResultColumn], rows: &[Vec], sheet_name: &str) -> Result, RenderError> { // The header takes a row, so the data has one fewer to fill. if rows.len() + 1 > MAX_ROWS { - return Err(EncodeError::TooManyRows { + return Err(RenderError::TooManyRows { got: rows.len(), limit: MAX_ROWS - 1, }); } if columns.len() > MAX_COLUMNS { - return Err(EncodeError::TooManyColumns { + return Err(RenderError::TooManyColumns { got: columns.len(), limit: MAX_COLUMNS, }); @@ -66,7 +66,7 @@ fn write_cell( value: &Value, date: &Format, timestamp: &Format, -) -> Result<(), EncodeError> { +) -> Result<(), RenderError> { match value { // A blank cell, not an empty string: Excel's COUNT and AVERAGE // read the two differently, and NULL is the absent one. @@ -165,8 +165,8 @@ fn sheet_title(name: &str) -> String { } } -fn xlsx_error(error: rust_xlsxwriter::XlsxError) -> EncodeError { - EncodeError::Xlsx(error.to_string()) +fn xlsx_error(error: rust_xlsxwriter::XlsxError) -> RenderError { + RenderError::Xlsx(error.to_string()) } #[cfg(test)] @@ -276,7 +276,7 @@ mod tests { let error = render_xlsx(&columns, &[], "Results").expect_err("too wide"); - assert!(matches!(error, EncodeError::TooManyColumns { .. }), "{error:?}"); + assert!(matches!(error, RenderError::TooManyColumns { .. }), "{error:?}"); } #[test] diff --git a/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs b/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs index d8179ae8d8..0455eea87f 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/keys_and_decimals.rs @@ -3,9 +3,9 @@ use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::value::{SqlDecimal, Temporal}; -use tablepro_core::{Connection, Value}; +use tablepro_core::{Connection, RowLimit, Value}; -use crate::support::{CALL, connect, count, planned, quoted, rows, setup, start, table, unique, within}; +use crate::support::{CALL, connect, count, planned, quoted, rows, setup, start, statement, table, unique, within}; async fn edit(connection: &dyn Connection, name: &str, key: Vec, column: usize, value: Value) { let changes = ChangeSet { @@ -194,3 +194,44 @@ async fn invalid_utf8_string_key_row_editable_via_unhex() { assert_eq!((edited, untouched), (1, 1)); connection.close().await.expect("a close"); } + +/// Whether `text` holds the JSON key `b` before the key `a`. +fn b_before_a(text: &str) -> bool { + match (text.find("\"b\""), text.find("\"a\"")) { + (Some(b), Some(a)) => b < a, + _ => false, + } +} + +/// A map and a named tuple keep the order the server wrote their keys +/// in, through the driver and through a JSON export, which a sorted +/// JSON map would lose. +#[tokio::test] +#[ignore = "requires docker"] +async fn map_and_tuple_keep_server_key_order() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + + let result = connection + .query_bound( + &statement("SELECT map('b', 1, 'a', 2) AS m, CAST((1, 2) AS Tuple(b UInt8, a UInt8)) AS t"), + RowLimit::EDITOR_DEFAULT, + within(CALL), + ) + .await + .expect("a read"); + + let row = result.rows.first().expect("one row"); + assert_eq!(row.len(), 2); + for value in row { + let text = tablepro_core::export::value_to_text(value).expect("the value's text"); + assert!(b_before_a(&text), "the keys were reordered: {text}"); + } + let json = tablepro_core::export::render_json(&result.columns, &result.rows).expect("the export"); + let exported: serde_json::Value = serde_json::from_str(&json).expect("the export is JSON"); + for column in ["m", "t"] { + let text = exported[0][column].as_str().unwrap_or_default(); + assert!(b_before_a(text), "the export reordered {column}: {json}"); + } + connection.close().await.expect("a close"); +} diff --git a/linux/crates/storage/src/error.rs b/linux/crates/storage/src/error.rs index f4d0bf4367..5ae6f29443 100644 --- a/linux/crates/storage/src/error.rs +++ b/linux/crates/storage/src/error.rs @@ -45,7 +45,7 @@ pub enum StorageError { EmptyName, #[error("export encoding failed: {0}")] - Encode(#[from] tablepro_core::export::EncodeError), + Encode(#[from] tablepro_core::export::RenderError), #[error("settings error: {0}")] Settings(#[from] crate::settings::SettingsError), From b9bfa1ea846113daecf8d15d504eb46dbf84ec39 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:36:47 +0700 Subject: [PATCH 120/301] fix(linux-grid): refresh browse tabs in place and keep the last good page --- linux/ARCHITECTURE.md | 2 +- linux/CHANGELOG.md | 4 + .../app/src/runtime/request_sequencer.rs | 22 ++ linux/crates/app/src/ui/browse_tab/fetch.rs | 40 +-- linux/crates/app/src/ui/browse_tab/imp.rs | 7 + .../app/src/ui/browse_tab/load_tests.rs | 208 ++++++++++++++ linux/crates/app/src/ui/browse_tab/mod.rs | 268 +++++++++++------- .../src/ui/browse_tab/selection_snapshot.rs | 71 +++++ linux/crates/app/src/ui/browse_tab/tests.rs | 107 +++---- linux/crates/app/src/ui/mod.rs | 1 + .../app/src/ui/tab_load/content_load.rs | 227 +++++++++++++++ .../app/src/ui/tab_load/load_failure.rs | 54 ++++ .../app/src/ui/tab_load/load_presentation.rs | 18 ++ linux/crates/app/src/ui/tab_load/mod.rs | 14 + .../app/src/ui/tab_load/stale_banner.rs | 30 ++ linux/crates/app/src/ui/workspace/sidebar.rs | 60 ++++ .../app/src/ui/workspace/workspace_tabs.rs | 18 +- linux/data/resources/ui/browse-tab.ui | 18 +- linux/docs/state-management.md | 2 + linux/po/POTFILES.in | 7 + 20 files changed, 988 insertions(+), 190 deletions(-) create mode 100644 linux/crates/app/src/ui/browse_tab/load_tests.rs create mode 100644 linux/crates/app/src/ui/browse_tab/selection_snapshot.rs create mode 100644 linux/crates/app/src/ui/tab_load/content_load.rs create mode 100644 linux/crates/app/src/ui/tab_load/load_failure.rs create mode 100644 linux/crates/app/src/ui/tab_load/load_presentation.rs create mode 100644 linux/crates/app/src/ui/tab_load/mod.rs create mode 100644 linux/crates/app/src/ui/tab_load/stale_banner.rs diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 4d0d9d520f..02c0c46695 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -244,7 +244,7 @@ Each tab is a gtk-rs widget with a composite template, which the `AdwTabView` ad Routing is hub-and-spoke: a tab emits signals, and the closures the workspace connects tag them with the tab's UUID and queue them as `WorkspaceInput`. The workspace looks up the slot and calls back into the tab. This keeps the tabs ignorant of each other and gives the workspace one place to enforce cross-tab invariants (reload the tabs on a table whose schema changed, close all tabs for a dropped table, etc.). A reply a tab's request brings back after a disconnect reaches a closed queue and is dropped. -A browse or structure tab reads its own data through the session. A `RequestSequencer` keeps one request of each kind current: a new page stops the one it replaces, and a reply to a replaced request is dropped. A page and a count wait for the table's columns from the catalog. A read that failed is asked again once the session reconnects, while a tab whose reads all landed keeps its rows. Closing a tab stops its reads, so the server drops the statement. +A browse or structure tab reads its own data through the session. A `RequestSequencer` keeps one request of each kind current: a new page stops the one it replaces, and a reply to a replaced request is dropped. A page and a count wait for the table's columns from the catalog. A read that failed is asked again once the session reconnects, while a tab whose reads all landed keeps its rows. A read that fails with rows on screen leaves them there under a banner with Retry; the error page is for a tab with nothing to show. Closing a tab stops its reads, so the server drops the statement. Reopening a closed tab uses a 10-deep `VecDeque` snapshot taken in `finish_close_workspace_tab` before the slot is dropped. The stack goes with the workspace on disconnect because descriptors reference tables in its session. diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 9c6ad6e5d3..0dbc13b2ea 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -33,6 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Screen readers say when a grid cell is modified and when a row is new or marked for deletion. - Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. - Save query history in Preferences turns the query history off and keeps the entries already saved. +- Try Again on a table tab's error page, and Retry on a banner over rows a refresh could not read again. +- Table tabs show a spinner in the tab bar while they read. ### Changed @@ -54,6 +56,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Index and Add Foreign Key check the form as you type, and pick the referenced table and columns from the database. - Denser grid rows, with numbers aligned right in even-width digits at the precision their column declares. - A grid value too long to draw says how many bytes it leaves out, and its tooltip points to Show Row as JSON. +- Refresh keeps a table's rows, selection and focused cell on screen while it reads the table again. ### Removed @@ -118,6 +121,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A table or structure tab whose read failed while the connection was lost kept its error page after the reconnect. - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. +- The sidebar lost the selected table each time the table list was read again. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. - Renaming a MySQL table in another database moved it to the connection's database, and renaming a ClickHouse table failed. - Turning a column's primary key on or off in the Structure tab saved nothing. diff --git a/linux/crates/app/src/runtime/request_sequencer.rs b/linux/crates/app/src/runtime/request_sequencer.rs index ba2dc3b1e5..942e60252e 100644 --- a/linux/crates/app/src/runtime/request_sequencer.rs +++ b/linux/crates/app/src/runtime/request_sequencer.rs @@ -56,6 +56,14 @@ impl RequestSequencer { self.current.contains_key(&kind) } + /// Stop the request of this kind still running; its reply is then + /// refused. + pub fn cancel(&mut self, kind: K) { + if let Some((_, cancel)) = self.current.remove(&kind) { + cancel.cancel(); + } + } + pub fn cancel_all(&mut self) { for (_, (_, cancel)) in self.current.drain() { cancel.cancel(); @@ -135,6 +143,20 @@ mod tests { assert!(sequencer.is_pending(Kind::Page)); } + #[test] + fn cancel_stops_only_its_kind() { + let mut sequencer = RequestSequencer::new(); + let page = sequencer.issue(Kind::Page); + let count = sequencer.issue(Kind::Count); + + sequencer.cancel(Kind::Count); + + assert!(count.cancel.is_cancelled()); + assert!(!sequencer.accept(Kind::Count, count.stamp)); + assert!(!page.cancel.is_cancelled()); + assert!(sequencer.accept(Kind::Page, page.stamp)); + } + #[test] fn cancel_all_stops_every_kind() { let mut sequencer = RequestSequencer::new(); diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index 9756fa5b81..6bceeb742b 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -4,13 +4,14 @@ use std::sync::Arc; use tablepro_core::browse::{BrowseSqlError, PageLayout, PageRequest, SortKey, build_count, build_page}; use tablepro_core::meta::{PagePosition, RowIdentity, TableRef}; use tablepro_core::statement::StatementBuilder; -use tablepro_core::{ColumnInfo, FilterSet, QueryResult, RowLimit}; +use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet, QueryResult, RowLimit}; use tablepro_session::{LinkGeneration, OperationError, OperationKind}; use super::{ BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabInput, BrowseTabOutput, ColumnsReply, Effects, PageReply, }; use crate::runtime::{RequestTicket, Stamped}; +use crate::ui::tab_load::LoadFailure; /// What a page or a count reads, copied off the tab so the work owns /// it. The SQL is built inside the operation from the lease it runs on, @@ -91,10 +92,11 @@ impl BrowseModel { let Some(plan) = self.read_plan() else { return; }; - if let Some(message) = self.filter_problem() { - self.on_filter_problem(&message, fx); + if let Some(problem) = self.filter_problem() { + self.on_filter_problem(problem, fx); return; } + self.begin_read(fx); let ticket = self.issue(BrowseRequestKind::Page); let session = self.session.clone(); let cancel = ticket.cancel.clone(); @@ -206,25 +208,27 @@ impl BrowseModel { /// Why the tab's filter cannot be written for the current server, /// if it cannot. - fn filter_problem(&self) -> Option { + fn filter_problem(&self) -> Option { let dialect = self.dialect.as_ref()?; let mut builder = StatementBuilder::new(); - tablepro_core::build_filter(&mut builder, &**dialect, &self.current_columns, &self.current_filter) - .err() - .map(|error| error.to_string()) + tablepro_core::build_filter(&mut builder, &**dialect, &self.current_columns, &self.current_filter).err() } /// A filter the dialect cannot write is the user's to fix, and no - /// page is read under it. A page already read under these columns - /// stays with a toast; without one, the error page says why rather - /// than a spinner that nothing will replace. - fn on_filter_problem(&mut self, message: &str, fx: &mut Effects) { - if self.page.is_some() { - self.refresh_inner_stack_visibility(); - fx.output(BrowseTabOutput::ShowToast(message.to_owned())); + /// page is read under it. Rows already on screen stay with a toast, + /// and a page still on its way under the old filter is stopped; + /// without rows, the error page says why rather than a spinner that + /// nothing will replace. + fn on_filter_problem(&mut self, problem: BuildFilterError, fx: &mut Effects) { + let failure = LoadFailure::Page(Arc::new(OperationError::Browse(problem.into()))); + if !self.load.has_content() { + self.on_load_failed(failure, fx); return; } - self.show_error(message); + self.sequencer.cancel(BrowseRequestKind::Page); + fx.output(BrowseTabOutput::ShowToast(failure.message())); + let presentation = self.load.abandon(); + self.present(presentation, fx); } pub(super) fn on_command(&mut self, command: BrowseCommand, fx: &mut Effects) { @@ -248,7 +252,7 @@ impl BrowseModel { category = ?error.category(), "could not read the table's columns" ); - self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); + self.on_load_failed(LoadFailure::Columns(error), fx); self.retry_if_link_moved(BrowseRequestKind::Columns, fx); } } @@ -264,7 +268,7 @@ impl BrowseModel { self.on_rows_loaded(offset, reply, fx); } Err(error) => { - self.on_read_failed(&crate::ui::error_text::operation_error_message(&error), fx); + self.on_load_failed(LoadFailure::Page(Arc::new(error)), fx); self.retry_if_link_moved(BrowseRequestKind::Page, fx); } } @@ -316,7 +320,7 @@ impl BrowseModel { } let kinds = self.failed.take(); if kinds.contains(&BrowseRequestKind::Page) || kinds.contains(&BrowseRequestKind::Columns) { - self.show_loading(); + self.begin_read(fx); } match kinds.contains(&BrowseRequestKind::Columns) { true => { diff --git a/linux/crates/app/src/ui/browse_tab/imp.rs b/linux/crates/app/src/ui/browse_tab/imp.rs index fb3ea4861f..11892acd5e 100644 --- a/linux/crates/app/src/ui/browse_tab/imp.rs +++ b/linux/crates/app/src/ui/browse_tab/imp.rs @@ -20,6 +20,8 @@ pub struct BrowseTab { #[template_child] pub toolbar: TemplateChild, #[template_child] + pub stale_banner: TemplateChild, + #[template_child] pub read_only_banner: TemplateChild, #[template_child] pub no_pk_banner: TemplateChild, @@ -32,6 +34,8 @@ pub struct BrowseTab { #[template_child] pub error_page: TemplateChild, #[template_child] + pub retry_button: TemplateChild, + #[template_child] pub insert_button: TemplateChild, #[template_child] pub delete_button: TemplateChild, @@ -177,6 +181,9 @@ impl ObjectImpl for BrowseTab { .param_types([bool::static_type()]) .build(), Signal::builder("save-requested").build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), Signal::builder("stop-save-requested").build(), ] }) diff --git a/linux/crates/app/src/ui/browse_tab/load_tests.rs b/linux/crates/app/src/ui/browse_tab/load_tests.rs new file mode 100644 index 0000000000..b93c90c45f --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/load_tests.rs @@ -0,0 +1,208 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Duration; + +use gtk4::prelude::*; +use gtk4::subclass::prelude::ObjectSubclassIsExt; +use libadwaita as adw; +use tablepro_core::{DriverError, LossPhase, QueryResult, Value}; +use tablepro_test_support::{CallCounters, FakeReply}; + +use super::BrowseTabInput; +use super::tests::{Harness, Shown, WAIT, after_next_frame, column_view, key, rows, serving_table, text}; +use crate::test_support::{drain_main_context, wait_until}; +use crate::ui::grid::GridCell; + +fn banner(harness: &Harness) -> adw::Banner { + harness.tab().imp().stale_banner.get() +} + +/// Every value the tab's loading signal carried, in order. +fn loading_log(harness: &Harness) -> Rc>> { + let log = Rc::new(RefCell::new(Vec::new())); + let sink = log.clone(); + harness + .tab() + .connect_loading_changed(move |_, loading| sink.borrow_mut().push(loading)); + log +} + +/// A page whose rows are keyed `ids`, each named after its key. +fn keyed(ids: &[i64]) -> QueryResult { + let names: Vec = ids.iter().map(|id| format!("row {id}")).collect(); + let names: Vec<&str> = names.iter().map(String::as_str).collect(); + let mut page = rows(&names); + for (row, id) in page.rows.iter_mut().zip(ids) { + row[0] = Value::Int(*id); + } + page +} + +#[gtk4::test] +fn refresh_keeps_grid_visible_and_sets_tab_loading() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["before"]))) + .always(FakeReply::Late { + after: Duration::from_millis(300), + outcome: Ok(rows(&["after"])), + }); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["before"])).expect("the first page"); + let loading = loading_log(&harness); + + harness.tab().send(BrowseTabInput::Refresh); + + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert_eq!(harness.shown(), text(&["before"]), "the refresh blanked the rows"); + assert!(harness.tab().is_loading()); + wait_until(WAIT, || harness.shown() == text(&["after"])).expect("the refreshed page"); + assert!(!harness.tab().is_loading()); + assert_eq!(*loading.borrow(), [true, false]); +} + +#[gtk4::test] +fn failed_refresh_keeps_rows_and_reveals_stale_banner() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .always(FakeReply::err(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + let counters = harness.connection.counters(); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh was not shown"); + + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert_eq!(harness.shown(), text(&["kept"]), "the failed refresh dropped the rows"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); + assert!(!harness.tab().is_loading()); + let tooltip = banner(&harness).tooltip_text().map(|text| text.to_string()); + assert_eq!(tooltip.as_deref(), Some("The connection was closed. Try reconnecting.")); +} + +#[gtk4::test] +fn stale_banner_retry_reloads_and_hides_banner() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(rows(&["kept"]))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(rows(&["fresh"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + + banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || harness.shown() == text(&["fresh"])).expect("Retry read the page again"); + assert!( + !banner(&harness).is_revealed(), + "the banner outlived the read that landed" + ); +} + +#[gtk4::test] +fn first_load_failure_shows_error_page_with_try_again() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::err(DriverError::server("relation is gone"))) + .always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + let imp = harness.tab().imp(); + let description = imp + .error_page + .description() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(description.contains("relation <users> is gone"), "{description}"); + assert!(!banner(&harness).is_revealed()); + assert!(imp.retry_button.has_css_class("pill") && imp.retry_button.has_css_class("suggested-action")); + + imp.retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("Try Again read the page"); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn refresh_restores_multi_row_selection_and_focus_by_key() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(keyed(&[0, 1, 2, 3, 4]))) + .always(FakeReply::ok(keyed(&[9, 0, 1, 2, 3, 4]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 5).expect("the first page"); + let shown = Shown::new(harness.tab()); + let view = column_view(harness.tab()); + let name = view + .columns() + .item(1) + .and_downcast::() + .expect("the name column"); + view.scroll_to(3, Some(&name), gtk4::ListScrollFlags::FOCUS, None); + view.grab_focus(); + let focused_cell = || gtk4::prelude::GtkWindowExt::focus(&shown.0).and_then(|focus| GridCell::from_focus(&focus)); + wait_until(WAIT, || focused_cell().is_some_and(|cell| cell.position() == Some(3))).expect("a cell takes the focus"); + let selection = harness.selection(); + let picked = gtk4::Bitset::new_empty(); + picked.add(1); + picked.add(3); + selection.set_selection(&picked, >k4::Bitset::new_range(0, selection.n_items())); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || harness.shown().len() == 6).expect("the refreshed page"); + after_next_frame(&shown.0); + + let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) + .into_iter() + .map(|position| harness.grid_row(position).key().cloned()) + .collect(); + assert_eq!(selected, [Some(key(1)), Some(key(3))]); + wait_until(WAIT, || { + focused_cell() + .is_some_and(|cell| cell.column() == 1 && cell.row().and_then(|row| row.key().cloned()) == Some(key(3))) + }) + .expect("the focus did not come back to the cell it was in"); +} + +#[gtk4::test] +fn a_selection_is_not_put_back_after_a_failed_refresh() { + let mut script = serving_table(); + script + .query_bound + .once(FakeReply::ok(keyed(&[0, 1, 2]))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(keyed(&[0, 1, 2]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown().len() == 3).expect("the first page"); + let selection = harness.selection(); + selection.select_item(0, true); + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + selection.select_item(2, true); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || !banner(&harness).is_revealed()).expect("the second refresh"); + drain_main_context(); + + let selected: Vec<_> = crate::ui::grid::selected_positions(&harness.selection()) + .into_iter() + .map(|position| harness.grid_row(position).key().cloned()) + .collect(); + assert_eq!( + selected, + [Some(key(2))], + "the selection from before the failed refresh came back" + ); +} diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 81ef35d040..1b00d5efaf 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -10,13 +10,16 @@ mod browse_request_kind; mod confirming_delete; mod fetch; mod imp; +#[cfg(test)] +mod load_tests; mod page_reply; mod page_state; mod row_deletion; +mod selection_snapshot; #[cfg(test)] mod tests; -use std::cell::{Cell, RefCell}; +use std::cell::Cell; use std::collections::{BTreeSet, VecDeque}; use std::rc::Rc; use std::sync::Arc; @@ -41,11 +44,13 @@ use self::confirming_delete::ConfirmingDelete; pub(crate) use self::page_reply::PageReply; use self::page_state::PageState; use self::row_deletion::{RowDeletion, plan_row_deletion}; +use self::selection_snapshot::SelectionSnapshot; use super::grid::{ CellPreset, ColumnWidthBinding, DeleteAvailability, GridContent, GridMsg, GridRowId, GridRowIndex, GridSink, RowKeyBuilder, RowObject, TabGridContext, build_column_view, cell_editability, cell_presets, delete_availability, edit_context, edit_row, export_snapshot, snapshot_result, }; +use super::tab_load::{ContentLoad, ContentPage, LoadFailure, LoadPresentation, StaleBanner}; use crate::runtime::{FailedRequests, RequestSequencer}; use crate::services::change_tracker::{RowKey, StatementSource, new_draft_cell, pk_identity}; use crate::services::column_relayout::ColumnRelayout; @@ -146,6 +151,14 @@ impl BrowseTab { imp.toolbar.add_top_bar(&filter_strip.widget); tab.collapse_top_bars_with(&filter_strip.widget); tab.connect_widgets(); + let stale_banner = StaleBanner::new(imp.stale_banner.get(), { + let tab = tab.downgrade(); + move || { + if let Some(tab) = tab.upgrade() { + tab.send(BrowseTabInput::Refresh); + } + } + }); let delete_action = tab.install_delete_action(); // Handled at once: a tracked change redraws the cells bound to its @@ -189,17 +202,21 @@ impl BrowseTab { shown_view: None, current_selection: None, current_total_rows: None, + load: ContentLoad::default(), + tab_loading: false, + stale_banner, inner_stack: imp.inner_stack.get(), grid_holder: imp.grid_holder.get(), loading_page: imp.loading_page.get(), error_page: imp.error_page.get(), current_column_view: None, + view_columns: Vec::new(), row_index: GridRowIndex::default(), rendered_columns: Vec::new(), read_only_banner: imp.read_only_banner.get(), no_pk_banner: imp.no_pk_banner.get(), was_dirty: Cell::new(false), - pending_focus_restore: RefCell::new(None), + pending_restore: None, paginator_label: imp.paginator_label.get(), selection_label: imp.selection_label.get(), first_button: imp.first_button.get(), @@ -316,6 +333,11 @@ impl BrowseTab { self.read(|model| model.current_sort).flatten() } + /// A read of the tab's rows is running. + pub fn is_loading(&self) -> bool { + self.read(|model| model.tab_loading).unwrap_or(false) + } + pub fn connect_state_changed(&self, f: F) -> glib::SignalHandlerId { self.connect_closure("state-changed", false, glib::closure_local!(move |tab: &Self| f(tab))) } @@ -379,6 +401,15 @@ impl BrowseTab { ) } + /// A read of the tab's rows started or ended. + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + fn emit_output(&self, output: BrowseTabOutput) { match output { BrowseTabOutput::StateChanged => self.emit_by_name::<()>("state-changed", &[]), @@ -395,6 +426,7 @@ impl BrowseTab { BrowseTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), BrowseTabOutput::SaveRequested => self.emit_by_name::<()>("save-requested", &[]), BrowseTabOutput::StopSaveRequested => self.emit_by_name::<()>("stop-save-requested", &[]), + BrowseTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), } } @@ -403,29 +435,31 @@ impl BrowseTab { fn collapse_top_bars_with(&self, filter: >k::Revealer) { let imp = self.imp(); // The banners and the strip hold this through their notify - // handlers, and the tab holds them, so it holds all four weakly. + // handlers, and the tab holds them, so it holds them all weakly. let sync: Rc = { let toolbar = imp.toolbar.downgrade(); - let read_only = imp.read_only_banner.downgrade(); - let no_pk = imp.no_pk_banner.downgrade(); + let banners = [ + imp.stale_banner.downgrade(), + imp.read_only_banner.downgrade(), + imp.no_pk_banner.downgrade(), + ]; let filter = filter.downgrade(); Rc::new(move || { - let (Some(toolbar), Some(read_only), Some(no_pk), Some(filter)) = ( - toolbar.upgrade(), - read_only.upgrade(), - no_pk.upgrade(), - filter.upgrade(), - ) else { + let (Some(toolbar), Some(filter)) = (toolbar.upgrade(), filter.upgrade()) else { return; }; - toolbar.set_reveal_top_bars(read_only.is_revealed() || no_pk.is_revealed() || filter.reveals_child()); + let banner_shown = banners + .iter() + .filter_map(glib::WeakRef::upgrade) + .any(|banner| banner.is_revealed()); + toolbar.set_reveal_top_bars(banner_shown || filter.reveals_child()); }) }; sync(); - let read_only_sync = sync.clone(); - imp.read_only_banner.connect_revealed_notify(move |_| read_only_sync()); - let no_pk_sync = sync.clone(); - imp.no_pk_banner.connect_revealed_notify(move |_| no_pk_sync()); + for banner in [&imp.stale_banner, &imp.read_only_banner, &imp.no_pk_banner] { + let sync = sync.clone(); + banner.connect_revealed_notify(move |_| sync()); + } filter.connect_reveal_child_notify(move |_| sync()); } @@ -448,6 +482,7 @@ impl BrowseTab { on_click(&imp.discard_button, || BrowseTabInput::DiscardAll); on_click(&imp.save_button, || BrowseTabInput::CommitSave); on_click(&imp.stop_button, || BrowseTabInput::StopSaving); + on_click(&imp.retry_button, || BrowseTabInput::Refresh); let tab = self.downgrade(); imp.page_size_dropdown.connect_selected_notify(move |dropdown| { let index = usize::try_from(dropdown.selected()).unwrap_or(usize::MAX); @@ -573,12 +608,19 @@ pub(crate) struct BrowseModel { shown_view: Option, current_selection: Option, current_total_rows: Option, + load: ContentLoad, + /// What the tab bar was last told, so it hears only changes. + tab_loading: bool, + stale_banner: StaleBanner, inner_stack: gtk::Stack, grid_holder: gtk::Box, loading_page: adw::StatusPage, error_page: adw::StatusPage, current_column_view: Option, + /// The view's columns in the order of a row's cells, which a drag in + /// the header leaves alone. + view_columns: Vec, /// The store's rows by tracker key, so a tracked change finds the /// row to redraw without a scan of the page. row_index: GridRowIndex, @@ -593,9 +635,9 @@ pub(crate) struct BrowseModel { /// Last-emitted dirty state, so App hears only real transitions and /// not every count change (2 → 3). was_dirty: Cell, - /// The focused row before a sort, save or refresh, so the reloaded - /// page selects it again. - pending_focus_restore: RefCell>, + /// The selection and the focused cell before a sort, save or + /// refresh, so the page read again puts them back. + pending_restore: Option, paginator_label: gtk::Label, selection_label: gtk::Label, first_button: gtk::Button, @@ -724,6 +766,8 @@ pub(crate) enum BrowseTabOutput { DirtyChanged(bool), SaveRequested, StopSaveRequested, + /// A read of the tab's rows started or ended, for the tab bar. + LoadingChanged(bool), } impl BrowseModel { @@ -930,13 +974,46 @@ impl BrowseModel { } /// F5: read the columns again, in case the table changed under the - /// tab, then the page and the count built from them. - fn reload(&mut self) { - self.capture_focus_for_restore(); - self.show_loading(); + /// tab, then the page and the count built from them. The rows on + /// screen stay until the new page lands. + fn reload(&mut self, fx: &mut Effects) { + self.capture_selection(); + self.begin_read(fx); self.reload_from_columns(true); } + /// A read of the page the tab asks for starts. Rows read under the + /// same filter, sort and offset stay through a failure; rows of + /// another page do not, since they are not what the tab shows. + fn begin_read(&mut self, fx: &mut Effects) { + let presentation = match self.shown_view.as_ref() == Some(&self.page_view()) { + true => self.load.begin(), + false => self.load.begin_replacing(), + }; + self.present(presentation, fx); + } + + /// Put the read's state on screen: the spinner, the grid or the + /// error page, the banner over rows that could not be read again, + /// and the tab bar's spinner. + fn present(&mut self, presentation: LoadPresentation, fx: &mut Effects) { + match &presentation.page { + ContentPage::Spinner => self.show_loading(), + ContentPage::Content => self.inner_stack.set_visible_child_name("grid"), + ContentPage::Error(failure) => { + // The status page reads its description as markup. + self.error_page + .set_description(Some(&glib::markup_escape_text(&failure.message()))); + self.inner_stack.set_visible_child_name("error"); + } + } + self.stale_banner.show(presentation.stale.as_ref()); + if self.tab_loading != presentation.tab_loading { + self.tab_loading = presentation.tab_loading; + fx.output(BrowseTabOutput::LoadingChanged(presentation.tab_loading)); + } + } + /// Reveal at most ONE banner at a time. Read-only takes priority /// over no-PK because read-only blocks every kind of edit. fn refresh_banner_visibility(&self) { @@ -994,27 +1071,22 @@ impl BrowseModel { }); } - /// Snapshot the currently focused/first-selected row's identity - /// before an operation that reloads the page (sort flip, save, F5), - /// so the view re-anchors to the same row after the new page. - fn capture_focus_for_restore(&self) { + /// Keep the selection and the focused cell by row key before a sort, + /// save or refresh reads the page again. + fn capture_selection(&mut self) { // A capture still pending (sort and save in quick succession) - // is kept: the first visible focus should anchor to where the - // user was before the chain started. - if self.pending_focus_restore.borrow().is_some() { + // is kept: the user was where the chain started. + if self.pending_restore.is_some() { return; } - let Some(selection) = self.current_selection.as_ref() else { + let (Some(selection), Some(column_view)) = (self.current_selection.as_ref(), self.current_column_view.as_ref()) + else { return; }; - let bitset = selection.selection(); - if bitset.size() == 0 { - return; + let snapshot = SelectionSnapshot::capture(selection, column_view); + if !snapshot.is_empty() { + self.pending_restore = Some(snapshot); } - let Some(row) = selection.item(bitset.nth(0)).and_downcast::() else { - return; - }; - *self.pending_focus_restore.borrow_mut() = row.id().tracker_key(); } /// Edit the first editable cell of the new draft, which is always @@ -1028,32 +1100,35 @@ impl BrowseModel { }; glib::idle_add_local_once(move || { // F5 between the insert and this idle detaches the column - // view. The row was inserted; only the edit is missed. - if cv.root().is_some() { + // view, and a failed read hides it behind the error page. The + // row was inserted; only the edit is missed. + if cv.is_mapped() { edit_row(&cv, 0); } }); } - /// Select the row captured before the reload again, if it is still on - /// the page. The capture is used up either way, so it cannot steer a - /// later reload. - fn restore_focused_row(&mut self) { - let Some(key) = self.pending_focus_restore.borrow_mut().take() else { - return; - }; - let Some(store) = self.list_store() else { + /// Put back the selection captured before the page was read again, + /// on the rows that are still on it. The capture is used up either + /// way, so it cannot steer a later read. + fn restore_selection(&mut self) { + let Some(snapshot) = self.pending_restore.take() else { return; }; - let Some(position) = self.row_index.position_in(&store, &key) else { + let (Some(store), Some(selection), Some(column_view)) = ( + self.list_store(), + self.current_selection.clone(), + self.current_column_view.clone(), + ) else { return; }; - if let Some(selection) = self.current_selection.as_ref() { - selection.select_item(position, true); - } - if let Some(cv) = self.current_column_view.as_ref() { - cv.scroll_to(position, None, gtk::ListScrollFlags::FOCUS, None); - } + snapshot.restore( + &mut self.row_index, + &store, + &selection, + &column_view, + &self.view_columns, + ); } /// The columns came back the same, so the page on screen still @@ -1063,7 +1138,6 @@ impl BrowseModel { return; } self.refresh_grid_chrome(); - self.restore_focused_row(); fx.output(BrowseTabOutput::StateChanged); } @@ -1081,7 +1155,9 @@ impl BrowseModel { self.reindex_rows(); self.sync_delete_action(); self.refresh_grid_chrome(); - self.restore_focused_row(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.restore_selection(); fx.output(BrowseTabOutput::StateChanged); return; } @@ -1102,6 +1178,10 @@ impl BrowseModel { ); self.current_selection = Some(selection); self.current_column_view = Some(column_view.clone()); + let view_columns = column_view.columns(); + self.view_columns = (0..view_columns.n_items()) + .filter_map(|position| view_columns.item(position).and_downcast()) + .collect(); self.rendered_columns = self.current_columns.clone(); // The selection model is new per rebuild, so the previous @@ -1133,7 +1213,9 @@ impl BrowseModel { .build(); self.grid_holder.append(&scrolled); self.refresh_grid_chrome(); - self.restore_focused_row(); + let presentation = self.load.succeed(); + self.present(presentation, fx); + self.restore_selection(); fx.output(BrowseTabOutput::StateChanged); } @@ -1153,17 +1235,20 @@ impl BrowseModel { /// The grid was built for columns the table no longer has, so it /// goes before a click or a key in it lands on a moved column. - fn drop_grid(&mut self) { + fn drop_grid(&mut self, fx: &mut Effects) { if self.current_column_view.take().is_none() { return; } clear_box(&self.grid_holder); self.current_selection = None; + self.view_columns.clear(); self.rendered_columns.clear(); self.row_index = GridRowIndex::default(); + self.pending_restore = None; update_selection_chrome(&self.selection_label, 0); self.sync_delete_action(); - self.show_loading(); + let presentation = self.load.drop_content(); + self.present(presentation, fx); } fn reindex_rows(&mut self) { @@ -1191,21 +1276,9 @@ impl BrowseModel { .map(|t| (t - 1) / self.page_size * self.page_size); self.last_button .set_sensitive(last_target.is_some_and(|target| self.current_offset != target)); - - self.refresh_inner_stack_visibility(); self.suppress_combo_emit.set(false); } - /// Switch the inner stack to the grid once the first page has - /// loaded, and stay there whatever the row count: an empty-list - /// status page hid the column view, and inserting a draft into it - /// then scrolled a view with no allocated adjustments. - fn refresh_inner_stack_visibility(&self) { - if self.page.is_some() { - self.inner_stack.set_visible_child_name("grid"); - } - } - fn has_primary_key(&self) -> bool { self.current_columns.iter().any(|c| c.primary_key) } @@ -1292,22 +1365,6 @@ impl BrowseModel { self.inner_stack.set_visible_child_name("loading"); } - /// Show why no rows could be read. The page and the count from - /// before are dropped, so a later reload does not render against - /// them before its own page arrives. - fn show_error(&mut self, message: &str) { - self.page = None; - self.shown_view = None; - self.current_total_rows = None; - self.paginator_label.set_label(""); - self.first_button.set_sensitive(false); - self.prev_button.set_sensitive(false); - self.next_button.set_sensitive(false); - self.last_button.set_sensitive(false); - self.error_page.set_description(Some(message)); - self.inner_stack.set_visible_child_name("error"); - } - /// Take the page apart into rows the store owns. Each row's cells /// move into it, and the identity cells after them name the row and /// are dropped, so nothing past the visible columns reaches a copy @@ -1356,19 +1413,13 @@ impl BrowseModel { } } - /// A read failed. When it asked again for the page on screen, as F5 - /// and a schema change do, the page stays and a toast says why; any - /// other page on screen no longer matches what the tab asks for. - fn on_read_failed(&mut self, message: &str, fx: &mut Effects) { - if self.page.is_some() && self.shown_view.as_ref() == Some(&self.page_view()) { - self.refresh_inner_stack_visibility(); - fx.output(BrowseTabOutput::ShowToast(crate::i18n::gettext_f( - "Couldn't refresh the rows: {error}", - &[("error", message)], - ))); - return; - } - self.show_error(message); + /// A read failed. Rows read again for the same page stay, under the + /// banner; with no rows, or rows of another page, the error page says + /// why. The paginator stays, so the user can go back. + fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut Effects) { + self.pending_restore = None; + let presentation = self.load.fail(failure); + self.present(presentation, fx); } fn on_columns_loaded(&mut self, columns: Vec, fx: &mut Effects) { @@ -1377,7 +1428,7 @@ impl BrowseModel { if self.current_columns != columns { self.page = None; self.shown_view = None; - self.drop_grid(); + self.drop_grid(fx); } self.current_columns = columns.clone(); self.rebase_confirming_delete(fx); @@ -1425,14 +1476,15 @@ impl BrowseModel { BrowseTabInput::Command(command) => self.on_command(command, fx), BrowseTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), BrowseTabInput::SchemaChanged => { - self.capture_focus_for_restore(); + self.capture_selection(); + self.begin_read(fx); self.reload_from_columns(false); } - BrowseTabInput::Refresh => self.reload(), + BrowseTabInput::Refresh => self.reload(fx), BrowseTabInput::DiscardAndRefresh => { let delta = self.tracker.discard(); self.apply_tracker_delta(&delta, fx); - self.reload(); + self.reload(fx); } BrowseTabInput::ClearSelection => { let Some(sel) = self.current_selection.as_ref() else { @@ -1513,7 +1565,7 @@ impl BrowseModel { } self.current_sort = Some(next); self.current_offset = 0; - self.capture_focus_for_restore(); + self.capture_selection(); self.fetch(BrowseRequestKind::Page, fx); fx.output(BrowseTabOutput::StateChanged); } @@ -1553,7 +1605,6 @@ impl BrowseModel { }) .collect(); self.apply_edit(TrackerEdit::InsertDrafts { rows: copies }, fx); - self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } BrowseTabInput::InsertRow => { @@ -1562,7 +1613,6 @@ impl BrowseModel { } let cells = self.current_columns.iter().map(new_draft_cell).collect(); self.apply_edit(TrackerEdit::InsertDrafts { rows: vec![cells] }, fx); - self.refresh_inner_stack_visibility(); fx.input(BrowseTabInput::FocusInsertedDraft); } BrowseTabInput::DeleteRows(rows) => self.delete_rows(&rows, fx), @@ -1681,7 +1731,7 @@ impl BrowseModel { // reload replaces every row; dropping the saved drafts // from the store first would blank them until it lands. self.sync_pending_chrome(self.tracker.pending_count(), fx); - self.capture_focus_for_restore(); + self.capture_selection(); self.fetch(BrowseRequestKind::Page, fx); self.fetch(BrowseRequestKind::Count, fx); } diff --git a/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs new file mode 100644 index 0000000000..aea5e1ccf8 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/selection_snapshot.rs @@ -0,0 +1,71 @@ +use gtk4 as gtk; +use gtk4::gio; +use gtk4::prelude::*; + +use crate::services::change_tracker::RowKey; +use crate::ui::grid::{GridCell, GridRowIndex, RowObject, selected_positions}; + +/// The selected rows and the focused cell, by row key, so a page read +/// again can put them back wherever the rows land in it. +#[derive(Debug, Clone, Default, PartialEq)] +pub(crate) struct SelectionSnapshot { + keys: Vec, + focus: Option<(RowKey, usize)>, +} + +impl SelectionSnapshot { + pub(crate) fn capture(selection: >k::MultiSelection, column_view: >k::ColumnView) -> Self { + let keys = selected_positions(selection) + .into_iter() + .filter_map(|position| selection.item(position).and_downcast::()) + .filter_map(|row| row.id().tracker_key()) + .collect(); + let focus = column_view + .root() + .and_downcast::() + .and_then(|window| gtk::prelude::GtkWindowExt::focus(&window)) + .filter(|focus| focus.is_ancestor(column_view)) + .and_then(|focus| GridCell::from_focus(&focus)) + .and_then(|cell| Some((cell.row()?.id().tracker_key()?, cell.column()))); + Self { keys, focus } + } + + pub(crate) fn is_empty(&self) -> bool { + self.keys.is_empty() && self.focus.is_none() + } + + /// Rows no longer on the page stay unselected. `columns` are the + /// view's columns in the order of the row's cells, which a drag in + /// the header does not change. + pub(crate) fn restore( + &self, + index: &mut GridRowIndex, + store: &gio::ListStore, + selection: >k::MultiSelection, + column_view: >k::ColumnView, + columns: &[gtk::ColumnViewColumn], + ) { + let selected = gtk::Bitset::new_empty(); + for key in &self.keys { + if let Some(position) = index.position_in(store, key) { + selected.add(position); + } + } + let mask = gtk::Bitset::new_range(0, selection.n_items()); + selection.set_selection(&selected, &mask); + let focused = self + .focus + .as_ref() + .and_then(|(key, column)| Some((index.position_in(store, key)?, *column))); + match focused { + Some((position, column)) => { + column_view.scroll_to(position, columns.get(column), gtk::ListScrollFlags::FOCUS, None); + } + // A sort can move the selection anywhere on the page. + None if !selected.is_empty() => { + column_view.scroll_to(selected.minimum(), None, gtk::ListScrollFlags::NONE, None); + } + None => {} + } + } +} diff --git a/linux/crates/app/src/ui/browse_tab/tests.rs b/linux/crates/app/src/ui/browse_tab/tests.rs index 9c5410f096..e7d5557074 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::tracker_edit::TrackerEdit; use crate::test_support::{FakeSession, drain_main_context, wait_until}; use crate::ui::grid::{GridCell, GridRowIndex, RowObject}; -const WAIT: Duration = Duration::from_secs(10); +pub(super) const WAIT: Duration = Duration::from_secs(10); /// A column carrying the server's spelling and the kind it means. #[expect( @@ -108,7 +108,7 @@ fn draft_changes_add_and_remove_draft_rows() { assert_eq!(index.get(&RowKey::Draft(4)), None); } -fn column(name: &str, primary_key: bool) -> ColumnInfo { +pub(super) fn column(name: &str, primary_key: bool) -> ColumnInfo { ColumnInfo { name: name.to_owned(), column_type: ColumnType::untyped_text("text"), @@ -121,7 +121,7 @@ fn column(name: &str, primary_key: bool) -> ColumnInfo { } } -fn rows(names: &[&str]) -> QueryResult { +pub(super) fn rows(names: &[&str]) -> QueryResult { let columns = [column("id", true), column("name", false)]; QueryResult::new( columns.iter().map(ColumnInfo::result_column).collect::>(), @@ -135,7 +135,7 @@ fn rows(names: &[&str]) -> QueryResult { /// A connection that serves the table's catalog and count, with the /// page left to the test. -fn serving_table() -> ConnectionScript { +pub(super) fn serving_table() -> ConnectionScript { let mut script = ConnectionScript::default(); script .fetch_columns @@ -148,24 +148,24 @@ fn serving_table() -> ConnectionScript { script } -struct Harness { - tab: Option, - tracker: TabTrackerHandle, +pub(super) struct Harness { + pub(super) tab: Option, + pub(super) tracker: TabTrackerHandle, others: Vec, - session: Arc, - connection: Arc, - fake: FakeSession, + pub(super) session: Arc, + pub(super) connection: Arc, + pub(super) fake: FakeSession, root: tempfile::TempDir, runtime: Option, } impl Harness { - fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { + pub(super) fn open(script: ConnectionScript, reconnects: impl IntoIterator) -> Self { Self::open_filtered(script, reconnects, FilterSet::default()) } /// The tab opens under `saved`, the filter the table was left with. - fn open_filtered( + pub(super) fn open_filtered( script: ConnectionScript, reconnects: impl IntoIterator, saved: FilterSet, @@ -189,12 +189,12 @@ impl Harness { } } - fn tab(&self) -> &BrowseTab { + pub(super) fn tab(&self) -> &BrowseTab { self.tab.as_ref().expect("the tab is open") } /// Another tab on the same table and session. - fn open_another(&mut self) -> BrowseTab { + 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); self.others.push(tab.clone()); @@ -203,7 +203,7 @@ impl Harness { /// The second cell of each saved row on screen, empty while the /// tab shows no page. - fn shown(&self) -> Vec { + pub(super) fn shown(&self) -> Vec { self.saved_rows() .iter() .filter_map(|row| row.cell(1).cloned()) @@ -211,7 +211,7 @@ impl Harness { } /// The saved rows the grid holds for the page on screen. - fn saved_rows(&self) -> Vec { + pub(super) fn saved_rows(&self) -> Vec { use gtk4::subclass::prelude::ObjectSubclassIsExt; let model = self.tab().imp().model.borrow(); let Some(selection) = model @@ -227,19 +227,19 @@ impl Harness { .collect() } - fn visible_page(&self) -> Option { + pub(super) fn visible_page(&self) -> Option { self.tab().imp_stack().visible_child_name().map(|name| name.to_string()) } /// The row the grid shows at `position`, drafts included. - fn grid_row(&self, position: u32) -> RowObject { + pub(super) fn grid_row(&self, position: u32) -> RowObject { self.selection() .item(position) .and_downcast::() .expect("a row") } - fn selection(&self) -> gtk4::MultiSelection { + pub(super) fn selection(&self) -> gtk4::MultiSelection { use gtk4::subclass::prelude::ObjectSubclassIsExt; self.tab() .imp() @@ -250,7 +250,7 @@ impl Harness { .expect("the grid is built") } - fn store(&self) -> gtk4::gio::ListStore { + pub(super) fn store(&self) -> gtk4::gio::ListStore { self.selection() .model() .and_downcast::() @@ -268,10 +268,10 @@ impl Harness { } /// The tab in a window, so its grid draws cells. -struct Shown(gtk4::Window); +pub(super) struct Shown(pub(super) gtk4::Window); impl Shown { - fn new(tab: &BrowseTab) -> Self { + pub(super) fn new(tab: &BrowseTab) -> Self { let window = gtk4::Window::new(); window.set_default_size(600, 400); window.set_child(Some(tab)); @@ -287,7 +287,11 @@ impl Drop for Shown { } } -fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir) -> BrowseTab { +pub(super) fn users_tab( + session: &Arc, + runtime: &crate::runtime::AppRuntime, + root: &tempfile::TempDir, +) -> BrowseTab { filtered_users_tab( session, runtime, @@ -297,7 +301,7 @@ fn users_tab(session: &Arc, runtime: &crate::runtime::AppRuntime, root: ) } -fn filtered_users_tab( +pub(super) fn filtered_users_tab( session: &Arc, runtime: &crate::runtime::AppRuntime, root: &tempfile::TempDir, @@ -342,11 +346,11 @@ impl BrowseTab { } } -fn text(values: &[&str]) -> Vec { +pub(super) fn text(values: &[&str]) -> Vec { values.iter().map(|value| Value::Text((*value).to_owned())).collect() } -fn not_null(column: &str) -> FilterSet { +pub(super) fn not_null(column: &str) -> FilterSet { FilterSet { rules: vec![FilterRule { column: column.to_owned(), @@ -357,7 +361,7 @@ fn not_null(column: &str) -> FilterSet { } } -fn not_null_name() -> FilterSet { +pub(super) fn not_null_name() -> FilterSet { not_null("name") } @@ -464,7 +468,7 @@ fn schema_change_reads_the_columns_again_then_the_page() { assert_eq!(CallCounters::get(&counters.fetch_columns), 2); } -fn header_titles(tab: &BrowseTab) -> Vec { +pub(super) fn header_titles(tab: &BrowseTab) -> Vec { let Some(view) = crate::test_support::first_descendant_of_type::(tab) else { return Vec::new(); }; @@ -538,34 +542,13 @@ fn a_tab_that_opens_reads_the_columns_the_table_has_now() { assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); } -fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { +pub(super) fn toasts(tab: &BrowseTab) -> std::rc::Rc>> { let toasts = std::rc::Rc::new(std::cell::RefCell::new(Vec::new())); let sink = toasts.clone(); tab.connect_toast(move |_, text| sink.borrow_mut().push(text)); toasts } -#[gtk4::test] -fn a_failed_refresh_keeps_the_rows_on_screen() { - let mut script = serving_table(); - script - .query_bound - .once(FakeReply::ok(rows(&["kept"]))) - .always(FakeReply::err(DriverError::server("denied"))); - let harness = Harness::open(script, []); - wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); - let toasts = toasts(harness.tab()); - let counters = harness.connection.counters(); - - harness.tab().send(BrowseTabInput::Refresh); - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the failed refresh was not reported"); - - assert_eq!(CallCounters::get(&counters.query_bound), 2); - assert_eq!(harness.shown(), text(&["kept"]), "the failed refresh dropped the rows"); - assert_eq!(harness.visible_page().as_deref(), Some("grid")); - assert!(toasts.borrow()[0].contains("denied"), "{:?}", toasts.borrow()); -} - #[gtk4::test] fn a_failed_page_under_a_new_filter_shows_the_error() { let mut script = serving_table(); @@ -580,7 +563,11 @@ fn a_failed_page_under_a_new_filter_shows_the_error() { wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")) .expect("rows read without the filter stayed under it"); - assert!(harness.shown().is_empty()); + assert_eq!( + harness.shown(), + text(&["unfiltered"]), + "the error page let go of the page the paginator still points back to" + ); } #[gtk4::test] @@ -874,12 +861,12 @@ fn a_copy_with_a_nul_reaches_the_window_whole() { assert_eq!(*copied.borrow(), ["a\0b".to_owned()]); } -fn key(id: i64) -> RowKey { +pub(super) fn key(id: i64) -> RowKey { RowKey::from_pk_values(&[Value::Int(id)]).expect("a key") } /// `names` keyed from `first` on, as the next page of `rows` would be. -fn rows_from(first: i64, names: &[&str]) -> QueryResult { +pub(super) fn rows_from(first: i64, names: &[&str]) -> QueryResult { let mut page = rows(names); for (row, id) in page.rows.iter_mut().zip(first..) { row[0] = Value::Int(id); @@ -1015,7 +1002,7 @@ fn page_export_snapshot_contains_no_hidden_columns() { } /// A browse page served first, then `next` for every page after it. -fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { +pub(super) fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { let mut script = serving_table(); script.count_rows.always(FakeReply::ok(1_000)); script @@ -1026,7 +1013,7 @@ fn paging(first: QueryResult, next: QueryResult) -> ConnectionScript { } /// Rows enough for a full page, so Next has somewhere to go. -fn full_page(first: i64) -> QueryResult { +pub(super) fn full_page(first: i64) -> QueryResult { let names: Vec = (0..100).map(|n| format!("row {n}")).collect(); let names: Vec<&str> = names.iter().map(String::as_str).collect(); rows_from(first, &names) @@ -1117,7 +1104,7 @@ fn a_page_that_replaces_the_row_being_edited_keeps_the_edit_and_the_focus() { ); } -fn after_next_frame(window: >k4::Window) { +pub(super) fn after_next_frame(window: >k4::Window) { let clock = window.frame_clock().expect("the window's frame clock"); let painted = std::rc::Rc::new(std::cell::Cell::new(false)); let flag = painted.clone(); @@ -1128,7 +1115,7 @@ fn after_next_frame(window: >k4::Window) { } /// The cell showing `column` of the saved row keyed `id`, once drawn. -fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { +pub(super) fn grid_cell_of(tab: &BrowseTab, id: i64, column: usize) -> GridCell { let key = key(id); let mut found = None; wait_until(WAIT, || { @@ -1667,7 +1654,7 @@ fn menu_gesture(cell: &GridCell) -> gtk4::GestureClick { .expect("a cell answers a right-click") } -fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { +pub(super) fn column_view(tab: &BrowseTab) -> gtk4::ColumnView { crate::test_support::first_descendant_of_type::(tab).expect("the grid") } @@ -2332,10 +2319,10 @@ fn a_bulk_delete_waiting_while_a_column_is_added_marks_its_rows_under_the_new_co /// What this thread logs, as text. #[derive(Clone, Default)] -struct LogLines(Arc>>); +pub(super) struct LogLines(Arc>>); impl LogLines { - fn text(&self) -> String { + pub(super) fn text(&self) -> String { let bytes = self.0.lock().unwrap_or_else(std::sync::PoisonError::into_inner); String::from_utf8_lossy(&bytes).into_owned() } diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 11c46e6ed7..cb6cf6cd9e 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -28,6 +28,7 @@ mod ssh_section; mod structure_tab; mod style_scheme_grid; mod style_scheme_resolver; +mod tab_load; mod welcome_view; mod window_geometry; mod workspace; diff --git a/linux/crates/app/src/ui/tab_load/content_load.rs b/linux/crates/app/src/ui/tab_load/content_load.rs new file mode 100644 index 0000000000..29713b07c6 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/content_load.rs @@ -0,0 +1,227 @@ +use super::{ContentPage, LoadFailure, LoadPresentation}; + +/// Where a tab's read of its content stands. +/// +/// Content on screen stays there while it is read again, and stays when +/// that read fails, under a banner until a read lands. Only a tab with +/// nothing to show shows the spinner or the error page. +#[derive(Debug, Clone, Default)] +pub(crate) enum ContentLoad { + #[default] + Initial, + Loading, + Loaded, + /// Reading the content on screen again. `stale` is the failure the + /// banner keeps showing until this read lands. + Refreshing { + stale: Option, + }, + /// Reading other content than the one on screen, which stays until + /// the new content lands but is not kept if the read fails: it is no + /// longer what the tab was asked for. + Replacing, + Stale(LoadFailure), + Failed(LoadFailure), +} + +impl ContentLoad { + /// A read of the content on screen, or of the first content. + pub(crate) fn begin(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Initial | Self::Loading | Self::Failed(_) => Self::Loading, + Self::Loaded => Self::Refreshing { stale: None }, + Self::Stale(failure) => Self::Refreshing { stale: Some(failure) }, + running @ (Self::Refreshing { .. } | Self::Replacing) => running, + }; + self.presentation() + } + + /// A read of other content than the one on screen: another page, or + /// the same table under another filter. + pub(crate) fn begin_replacing(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Initial | Self::Loading | Self::Failed(_) => Self::Loading, + Self::Loaded | Self::Stale(_) | Self::Refreshing { .. } | Self::Replacing => Self::Replacing, + }; + self.presentation() + } + + pub(crate) fn succeed(&mut self) -> LoadPresentation { + *self = Self::Loaded; + self.presentation() + } + + pub(crate) fn fail(&mut self, failure: LoadFailure) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loaded | Self::Stale(_) | Self::Refreshing { .. } => Self::Stale(failure), + Self::Initial | Self::Loading | Self::Replacing | Self::Failed(_) => Self::Failed(failure), + }; + self.presentation() + } + + /// The read stopped before it asked for anything, so what was on + /// screen before it stands as it was. + pub(crate) fn abandon(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loading => Self::Initial, + Self::Refreshing { stale: None } | Self::Replacing => Self::Loaded, + Self::Refreshing { stale: Some(failure) } => Self::Stale(failure), + settled => settled, + }; + self.presentation() + } + + /// The content on screen no longer fits what the tab reads, so the + /// read running goes on with nothing shown. + pub(crate) fn drop_content(&mut self) -> LoadPresentation { + *self = match std::mem::take(self) { + Self::Loading | Self::Refreshing { .. } | Self::Replacing => Self::Loading, + Self::Loaded | Self::Stale(_) => Self::Initial, + settled => settled, + }; + self.presentation() + } + + pub(crate) fn has_content(&self) -> bool { + matches!( + self, + Self::Loaded | Self::Refreshing { .. } | Self::Replacing | Self::Stale(_) + ) + } + + pub(crate) fn is_running(&self) -> bool { + matches!(self, Self::Loading | Self::Refreshing { .. } | Self::Replacing) + } + + pub(crate) fn presentation(&self) -> LoadPresentation { + let (page, stale) = match self { + Self::Initial | Self::Loading => (ContentPage::Spinner, None), + Self::Loaded | Self::Replacing => (ContentPage::Content, None), + Self::Refreshing { stale } => (ContentPage::Content, stale.clone()), + Self::Stale(failure) => (ContentPage::Content, Some(failure.clone())), + Self::Failed(failure) => (ContentPage::Error(failure.clone()), None), + }; + LoadPresentation { + page, + tab_loading: self.is_running(), + stale, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[derive(Debug, PartialEq)] + enum Shown { + Spinner, + Content, + Error, + } + + /// The page, whether the tab spins, and whether the banner shows. + fn shown(presentation: &LoadPresentation) -> (Shown, bool, bool) { + let page = match presentation.page { + ContentPage::Spinner => Shown::Spinner, + ContentPage::Content => Shown::Content, + ContentPage::Error(_) => Shown::Error, + }; + (page, presentation.tab_loading, presentation.stale.is_some()) + } + + type Step = fn(&mut ContentLoad) -> LoadPresentation; + /// The steps from a new tab, and the page, spinner and banner after. + type Case = (&'static [Step], (Shown, bool, bool)); + + fn after(steps: &[Step]) -> (ContentLoad, LoadPresentation) { + let mut load = ContentLoad::default(); + let mut last = load.presentation(); + for step in steps { + last = step(&mut load); + } + (load, last) + } + + fn begin(load: &mut ContentLoad) -> LoadPresentation { + load.begin() + } + + fn replace(load: &mut ContentLoad) -> LoadPresentation { + load.begin_replacing() + } + + fn succeed(load: &mut ContentLoad) -> LoadPresentation { + load.succeed() + } + + fn failure() -> LoadFailure { + LoadFailure::Page(std::sync::Arc::new(tablepro_session::OperationError::Closed)) + } + + fn fail(load: &mut ContentLoad) -> LoadPresentation { + load.fail(failure()) + } + + fn abandon(load: &mut ContentLoad) -> LoadPresentation { + load.abandon() + } + + fn drop_content(load: &mut ContentLoad) -> LoadPresentation { + load.drop_content() + } + + #[test] + fn content_load_transition_table() { + let table: &[Case] = &[ + (&[], (Shown::Spinner, false, false)), + (&[begin], (Shown::Spinner, true, false)), + (&[begin, fail, begin], (Shown::Spinner, true, false)), + (&[begin, succeed, begin], (Shown::Content, true, false)), + (&[begin, succeed, begin, fail, begin], (Shown::Content, true, true)), + (&[begin, fail], (Shown::Error, false, false)), + (&[begin, succeed, begin, fail], (Shown::Content, false, true)), + (&[begin, succeed], (Shown::Content, false, false)), + ( + &[begin, succeed, begin, fail, begin, succeed], + (Shown::Content, false, false), + ), + (&[begin, fail, begin, succeed], (Shown::Content, false, false)), + (&[begin, succeed, replace], (Shown::Content, true, false)), + (&[begin, succeed, replace, fail], (Shown::Error, false, false)), + (&[begin, succeed, begin, fail, replace], (Shown::Content, true, false)), + (&[begin, succeed, begin, abandon], (Shown::Content, false, false)), + ( + &[begin, succeed, begin, fail, begin, abandon], + (Shown::Content, false, true), + ), + (&[begin, abandon], (Shown::Spinner, false, false)), + (&[begin, succeed, begin, drop_content], (Shown::Spinner, true, false)), + ( + &[begin, succeed, begin, drop_content, fail], + (Shown::Error, false, false), + ), + ]; + for (steps, expected) in table { + let (_, presentation) = after(steps); + assert_eq!(&shown(&presentation), expected, "after {} steps", steps.len()); + } + } + + #[test] + fn a_refresh_that_fails_again_shows_the_newest_failure() { + let mut load = ContentLoad::default(); + load.begin(); + load.succeed(); + load.begin(); + load.fail(failure()); + load.begin(); + + let presentation = load.fail(LoadFailure::Columns(std::sync::Arc::new( + tablepro_session::OperationError::Cancelled, + ))); + + assert!(matches!(presentation.stale, Some(LoadFailure::Columns(_)))); + assert!(load.has_content()); + } +} diff --git a/linux/crates/app/src/ui/tab_load/load_failure.rs b/linux/crates/app/src/ui/tab_load/load_failure.rs new file mode 100644 index 0000000000..b6df1db022 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/load_failure.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use tablepro_session::OperationError; + +/// Why a tab's read showed nothing new. +#[derive(Debug, Clone)] +pub(crate) enum LoadFailure { + /// The table's columns, or what tells its rows apart. + Columns(Arc), + Page(Arc), +} + +impl LoadFailure { + /// Plain text: a server's message can hold `<` and `&`, so whatever + /// shows it must not read it as markup. + pub(crate) fn message(&self) -> String { + match self { + Self::Columns(error) | Self::Page(error) => crate::ui::error_text::operation_error_message(error), + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::{DriverError, LossPhase, TimeoutPhase}; + + use super::*; + + #[test] + fn load_failure_message_uses_error_category() { + let lost = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::ConnectionLost { + during: LossPhase::Statement, + }))); + let timed_out = LoadFailure::Columns(Arc::new(OperationError::Driver(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: true, + }))); + + assert_eq!(lost.message(), "The connection was closed. Try reconnecting."); + assert_eq!( + timed_out.message(), + "The statement ran past its time limit, so the server stopped it." + ); + } + + #[test] + fn a_server_message_stays_plain_text() { + let failure = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::server( + "relation users & co", + )))); + + assert!(failure.message().contains("users & co"), "{}", failure.message()); + } +} diff --git a/linux/crates/app/src/ui/tab_load/load_presentation.rs b/linux/crates/app/src/ui/tab_load/load_presentation.rs new file mode 100644 index 0000000000..bbd14835f5 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/load_presentation.rs @@ -0,0 +1,18 @@ +use super::LoadFailure; + +/// What a tab shows for where its read stands. +#[derive(Debug, Clone)] +pub(crate) struct LoadPresentation { + pub page: ContentPage, + /// The tab's page in the tab bar spins while a read runs. + pub tab_loading: bool, + /// The read that failed while the content stayed on screen. + pub stale: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum ContentPage { + Spinner, + Content, + Error(LoadFailure), +} diff --git a/linux/crates/app/src/ui/tab_load/mod.rs b/linux/crates/app/src/ui/tab_load/mod.rs new file mode 100644 index 0000000000..ce95019793 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/mod.rs @@ -0,0 +1,14 @@ +//! How a tab shows what it reads: a spinner until the first read lands, +//! the content from then on, and an error page only while there is +//! nothing to show. A read that fails with content on screen leaves the +//! content there under a banner, so a blip never blanks a tab. + +mod content_load; +mod load_failure; +mod load_presentation; +mod stale_banner; + +pub(crate) use self::content_load::ContentLoad; +pub(crate) use self::load_failure::LoadFailure; +pub(crate) use self::load_presentation::{ContentPage, LoadPresentation}; +pub(crate) use self::stale_banner::StaleBanner; diff --git a/linux/crates/app/src/ui/tab_load/stale_banner.rs b/linux/crates/app/src/ui/tab_load/stale_banner.rs new file mode 100644 index 0000000000..21a97459c0 --- /dev/null +++ b/linux/crates/app/src/ui/tab_load/stale_banner.rs @@ -0,0 +1,30 @@ +use gtk4 as gtk; +use gtk4::prelude::*; +use libadwaita as adw; + +use super::LoadFailure; + +/// The banner over content a tab could not read again. Its title is the +/// tab's own, from its template; the failure goes in the tooltip, so the +/// banner stays one line. +pub(crate) struct StaleBanner { + banner: adw::Banner, +} + +impl StaleBanner { + pub(crate) fn new(banner: adw::Banner, retry: impl Fn() + 'static) -> Self { + banner.set_use_markup(false); + banner.set_button_label(Some(&crate::i18n::gettext("Retry"))); + banner.connect_button_clicked(move |_| retry()); + Self { banner } + } + + pub(crate) fn show(&self, stale: Option<&LoadFailure>) { + let message = stale.map(LoadFailure::message); + self.banner.set_tooltip_text(message.as_deref()); + self.banner + .upcast_ref::() + .update_property(&[gtk::accessible::Property::Description(message.as_deref().unwrap_or(""))]); + self.banner.set_revealed(stale.is_some()); + } +} diff --git a/linux/crates/app/src/ui/workspace/sidebar.rs b/linux/crates/app/src/ui/workspace/sidebar.rs index 7d7e39aefb..552ea3ebdf 100644 --- a/linux/crates/app/src/ui/workspace/sidebar.rs +++ b/linux/crates/app/src/ui/workspace/sidebar.rs @@ -214,8 +214,11 @@ fn browsable(objects: Vec) -> Vec { } impl WorkspaceModel { + /// The list is built anew, which drops its selection, so the row of + /// the table tab in front is selected again. pub(super) fn show_objects(&mut self, objects: &[ObjectInfo]) { self.repopulate_sidebar(browsable(objects.to_vec())); + self.sync_sidebar_selection(); } fn repopulate_sidebar(&mut self, objects: Vec) { @@ -334,6 +337,63 @@ mod tests { assert_eq!(names(&queue.borrow()[0]), ["users"]); } + fn sidebar_list(workspace: &Workspace) -> gtk::ListBox { + workspace + .imp() + .sidebar_scroll + .child() + .and_then(|child| { + child + .downcast_ref::() + .cloned() + .or_else(|| child.first_child().and_downcast::()) + }) + .expect("the sidebar's list") + } + + #[gtk4::test] + fn sidebar_selection_survives_tables_reload() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + + let test = TestWindow::connected_with("SidebarReload", |script| { + for _ in 0..4 { + script.list_objects.once(FakeReply::ok(vec![ + object("orders", ObjectKind::Table), + object("users", ObjectKind::Table), + ])); + } + }); + let workspace = test.workspace(); + let list = sidebar_list(&workspace); + let input = workspace.input().expect("the workspace's input"); + let read_tables = || { + input.deliver(WorkspaceInput::SchemaChanged { + schema: Some("public".to_owned()), + table: None, + }); + }; + read_tables(); + wait_until(WAIT, || list.row_at_index(1).is_some()).expect("the tables are listed"); + input.deliver(WorkspaceInput::SelectTable { + schema: Some("public".to_owned()), + name: "users".to_owned(), + open_mode: OpenMode::SwitchOrAppend, + }); + let selected_name = || list.selected_row().map(|row| row.widget_name().to_string()); + wait_until(WAIT, || selected_name().as_deref() == Some("users")).expect("the table's row is selected"); + let before = list.row_at_index(1).expect("the users row"); + + read_tables(); + wait_until(WAIT, || list.row_at_index(1).is_some_and(|row| row != before)).expect("the list was read again"); + + assert_eq!( + selected_name().as_deref(), + Some("users"), + "the reload dropped the selection" + ); + } + #[test] fn the_sidebar_lists_only_what_the_grid_can_page() { let listed = browsable(vec![ diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index df43371e5a..d2478883ba 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -288,6 +288,8 @@ impl WorkspaceModel { connect_browse(&browse, tab_id, &self.input); let page = tab_view.append(&browse); + let follow = follow_loading(&page, browse.is_loading()); + browse.connect_loading_changed(move |_, loading| follow(loading)); let label = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); page.set_title(&label); if let Some(tip) = browse_tab_tooltip(schema.as_deref(), &table, &label) { @@ -655,7 +657,7 @@ impl WorkspaceModel { /// With no Table tab active, no row stays selected: one would say /// the editor shows that table. - fn sync_sidebar_selection(&self) { + pub(super) fn sync_sidebar_selection(&self) { let listbox = self.sidebar_factory.widget(); let Some((schema, table)) = self.selected_browse_slot_table() else { listbox.unselect_all(); @@ -1167,6 +1169,20 @@ fn connect_structure(structure: &StructureTab, tab_id: Uuid, input: &WorkspaceSe structure.connect_alert(move |_, title, body| alert.deliver(WorkspaceInput::ShowAlert { title, body })); } +/// The tab bar spins on a tab's page while the tab reads. The tab +/// started reading before it had a page, so the page starts from where +/// the read stands. The tab holds the handler and the page holds the +/// tab, so the handler holds the page weakly. +fn follow_loading(page: &adw::TabPage, loading: bool) -> impl Fn(bool) + 'static { + page.set_loading(loading); + let page = page.downgrade(); + move |loading| { + if let Some(page) = page.upgrade() { + page.set_loading(loading); + } + } +} + /// The browse tab's signals, as the workspace's inputs for the tab. fn connect_browse(browse: &BrowseTab, tab_id: Uuid, input: &WorkspaceSender) { let state = input.clone(); diff --git a/linux/data/resources/ui/browse-tab.ui b/linux/data/resources/ui/browse-tab.ui index afd7c3c4de..3c41508b78 100644 --- a/linux/data/resources/ui/browse-tab.ui +++ b/linux/data/resources/ui/browse-tab.ui @@ -9,6 +9,12 @@ would draw a separator above the grid even with every banner collapsed. --> flat + + + Couldn't refresh rows. Showing the last loaded page. + false + + Read-only connection. Editing disabled. @@ -56,7 +62,17 @@ dialog-error-symbolic - Couldn't load rows + Couldn't Load Rows + + + Try Again + center + + + diff --git a/linux/docs/state-management.md b/linux/docs/state-management.md index ed6b61bb18..5e30fd4a70 100644 --- a/linux/docs/state-management.md +++ b/linux/docs/state-management.md @@ -29,6 +29,8 @@ Windows share nothing but `AppServices`. CI fails on a `thread_local!` under `cr **A failed read is asked again on the next link.** `FailedRequests` holds the kinds whose last accepted reply failed. When `link_changes` reports a newer `LinkGeneration`, the tab asks those again on the new connection; a reply that failed after the link had already moved is asked again at once. A tab whose reads all landed keeps its rows. +**Content stays through a failed read.** A browse tab tracks its read with `ContentLoad`. The spinner shows only until the first read lands; a read of the content on screen keeps it there and spins the tab's page in the tab bar, and a failure then leaves it under a `StaleBanner` with Retry. The error page, with Try Again, is for a tab with nothing to show, or whose failed read asked for other rows than the ones on screen. + **The catalog belongs to the session.** `SchemaCatalog` (`crates/session/src/session/schema_catalog.rs`) holds the schemas, each scope's objects and each table's columns. Views read through `Session::schemas`, `objects` and `columns`, or `refresh_objects` and `refresh_columns` to fetch again. Callers that ask for the same thing at once share one fetch, a fetch runs as its own task so a view that closes does not stop it, and a failed refresh keeps the list it had. A schema change calls `invalidate_table`, which marks the table stale and keeps its data until the next fetch lands. **Saves go through the workspace.** `WorkspaceHandle` starts, stops and settles every save. Its `SaveLedger` records each running save with its tab, its kind, a cancel token and a process hold, and refuses a second save of the same kind for the same tab. Stop Saving, or a close that chose to stop, cancels the token, which reaches the driver through `run_cancellable`; the save settles when the driver answers. diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index f80aabcfbb..54c27b530e 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -90,10 +90,12 @@ crates/app/src/ui/browse_tab/browse_request_kind.rs crates/app/src/ui/browse_tab/confirming_delete.rs crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs +crates/app/src/ui/browse_tab/load_tests.rs crates/app/src/ui/browse_tab/mod.rs crates/app/src/ui/browse_tab/page_reply.rs crates/app/src/ui/browse_tab/page_state.rs crates/app/src/ui/browse_tab/row_deletion.rs +crates/app/src/ui/browse_tab/selection_snapshot.rs crates/app/src/ui/browse_tab/tests.rs crates/app/src/ui/connect_dialog/connect_form.rs crates/app/src/ui/connect_dialog/connect_submission.rs @@ -256,6 +258,11 @@ crates/app/src/ui/structure_tab/table_choice_object.rs crates/app/src/ui/structure_tab/tests.rs crates/app/src/ui/style_scheme_grid.rs crates/app/src/ui/style_scheme_resolver.rs +crates/app/src/ui/tab_load/content_load.rs +crates/app/src/ui/tab_load/load_failure.rs +crates/app/src/ui/tab_load/load_presentation.rs +crates/app/src/ui/tab_load/mod.rs +crates/app/src/ui/tab_load/stale_banner.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs crates/app/src/ui/workspace/close_flow.rs From bad15f2961a6c57b0f55239d3e3e3a8b755ba11b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:41:34 +0700 Subject: [PATCH 121/301] fix(linux-grid): fail browse tabs on unreadable columns, refetch on refresh --- linux/CHANGELOG.md | 1 + .../app/src/ui/browse_tab/browse_command.rs | 3 +- .../app/src/ui/browse_tab/column_recheck.rs | 131 ++++++++++++++++++ .../app/src/ui/browse_tab/columns_fetch.rs | 41 ++++++ linux/crates/app/src/ui/browse_tab/fetch.rs | 71 ++++++---- .../app/src/ui/browse_tab/load_tests.rs | 118 +++++++++++++++- linux/crates/app/src/ui/browse_tab/mod.rs | 13 +- .../app/src/ui/tab_load/load_failure.rs | 30 ++++ linux/po/POTFILES.in | 2 + 9 files changed, 377 insertions(+), 33 deletions(-) create mode 100644 linux/crates/app/src/ui/browse_tab/column_recheck.rs create mode 100644 linux/crates/app/src/ui/browse_tab/columns_fetch.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 0dbc13b2ea..580c1cd0bd 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -122,6 +122,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A refresh that failed replaced the rows in a table tab with an error page. - Switching connections could show the previous connection's tables in the sidebar, or its reconnect banner. - The sidebar lost the selected table each time the table list was read again. +- A table tab with no columns the account can read showed a page error instead of saying why. - Switching connections during a table drop could close the new connection's tabs on a table of the same name. - Renaming a MySQL table in another database moved it to the connection's database, and renaming a ClickHouse table failed. - Turning a column's primary key on or off in the Structure tab saved nothing. diff --git a/linux/crates/app/src/ui/browse_tab/browse_command.rs b/linux/crates/app/src/ui/browse_tab/browse_command.rs index 9a96baa97a..833a08d1d7 100644 --- a/linux/crates/app/src/ui/browse_tab/browse_command.rs +++ b/linux/crates/app/src/ui/browse_tab/browse_command.rs @@ -6,6 +6,7 @@ use tablepro_session::OperationError; use super::page_reply::PageReply; use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; /// The table's columns from the session's catalog, and how its rows are /// told apart, read together because a page needs both. @@ -19,7 +20,7 @@ pub(crate) struct ColumnsReply { /// answers so a reply to a replaced request is dropped. #[derive(Debug)] pub(crate) enum BrowseCommand { - Columns(Stamped>>), + Columns(Stamped>), Page(Stamped>), Count(Stamped>), } diff --git a/linux/crates/app/src/ui/browse_tab/column_recheck.rs b/linux/crates/app/src/ui/browse_tab/column_recheck.rs new file mode 100644 index 0000000000..3b8109398e --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/column_recheck.rs @@ -0,0 +1,131 @@ +use tablepro_core::ColumnInfo; +use tablepro_core::browse::PageLayout; +use tablepro_core::column::ResultColumn; + +/// Checks that a page came back with the columns the tab built it from. +/// A table altered between the column read and the page shows up here, +/// and the columns are read again once before the tab gives up on it. +#[derive(Debug, Default)] +pub(crate) struct ColumnRecheck { + attempted: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RecheckAction { + Accept, + /// Read the columns again, then the page. + Refetch, + /// The columns moved again after they were read again: + /// `LoadFailure::ColumnsChanged`. + Fail, +} + +impl ColumnRecheck { + pub(crate) fn check( + &mut self, + columns: &[ColumnInfo], + result_columns: &[ResultColumn], + layout: &PageLayout, + ) -> RecheckAction { + let width = layout.visible.len() + layout.identity.len(); + let visible_names = result_columns + .get(layout.visible.clone()) + .unwrap_or_default() + .iter() + .map(|column| column.name.as_str()); + let matches = result_columns.len() == width + && layout.visible.len() == columns.len() + && visible_names.eq(columns.iter().map(|column| column.name.as_str())); + if matches { + self.attempted = false; + return RecheckAction::Accept; + } + match std::mem::replace(&mut self.attempted, true) { + false => RecheckAction::Refetch, + true => RecheckAction::Fail, + } + } +} + +#[cfg(test)] +mod tests { + use tablepro_core::column::{ColumnDefault, ColumnType}; + + use super::*; + + fn column(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.to_owned(), + column_type: ColumnType::untyped_text("text"), + nullable: true, + primary_key: false, + is_auto_increment: false, + is_generated: false, + default: ColumnDefault::None, + comment: None, + } + } + + fn result(names: &[&str]) -> Vec { + names.iter().map(|name| column(name).result_column()).collect() + } + + fn layout(visible: usize, identity: usize) -> PageLayout { + PageLayout { + visible: 0..visible, + identity: visible..visible + identity, + } + } + + #[test] + fn column_recheck_accepts_matching_visible_names_with_identity_tail() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + + let action = recheck.check( + &columns, + &result(&["id", "name", "__tablepro_row_oid", "__tablepro_row_id"]), + &layout(2, 2), + ); + + assert_eq!(action, RecheckAction::Accept); + } + + #[test] + fn column_recheck_refetches_once_then_fails() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + let renamed = result(&["id", "full_name"]); + + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Refetch); + assert_eq!(recheck.check(&columns, &renamed, &layout(2, 0)), RecheckAction::Fail); + assert_eq!( + recheck.check(&columns, &result(&["id", "name"]), &layout(2, 0)), + RecheckAction::Accept + ); + assert_eq!( + recheck.check(&columns, &renamed, &layout(2, 0)), + RecheckAction::Refetch, + "a page that matched did not reset the guard" + ); + } + + #[test] + fn column_recheck_rejects_wrong_identity_width() { + let mut recheck = ColumnRecheck::default(); + let columns = [column("id"), column("name")]; + + let action = recheck.check(&columns, &result(&["id", "name", "__tablepro_row_id"]), &layout(2, 2)); + + assert_eq!(action, RecheckAction::Refetch); + } + + #[test] + fn column_recheck_rejects_a_column_the_tab_does_not_have() { + let mut recheck = ColumnRecheck::default(); + + let action = recheck.check(&[column("id")], &result(&["id", "name"]), &layout(2, 0)); + + assert_eq!(action, RecheckAction::Refetch); + } +} diff --git a/linux/crates/app/src/ui/browse_tab/columns_fetch.rs b/linux/crates/app/src/ui/browse_tab/columns_fetch.rs new file mode 100644 index 0000000000..1ea2691fb1 --- /dev/null +++ b/linux/crates/app/src/ui/browse_tab/columns_fetch.rs @@ -0,0 +1,41 @@ +use std::sync::Arc; + +use tablepro_core::ColumnInfo; +use tablepro_core::meta::TableRef; +use tablepro_session::Session; + +use crate::ui::tab_load::LoadFailure; + +/// Whether a read of the table's columns may take the catalog's copy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ColumnFreshness { + Cached, + /// Ask the server even when the catalog has them. A failure keeps + /// the catalog's copy for the next read. + Refetch, +} + +/// The columns a page is built from. A table with none the account can +/// read is a failure too, since no page can be read from it. +pub(crate) async fn load_columns( + session: Arc, + table: TableRef, + freshness: ColumnFreshness, +) -> Result, LoadFailure> { + let read = match freshness { + ColumnFreshness::Cached => session.columns(table.clone()).await, + ColumnFreshness::Refetch => session.refresh_columns(table.clone()).await, + }; + let failure = match read { + Ok(columns) if !columns.is_empty() => return Ok(columns), + Ok(_) => LoadFailure::NoColumns, + Err(error) => LoadFailure::Columns(error), + }; + tracing::warn!( + schema = ?table.schema, + table = %table.name, + category = ?failure.category(), + "browse columns failed" + ); + Err(failure) +} diff --git a/linux/crates/app/src/ui/browse_tab/fetch.rs b/linux/crates/app/src/ui/browse_tab/fetch.rs index 6bceeb742b..90f370da66 100644 --- a/linux/crates/app/src/ui/browse_tab/fetch.rs +++ b/linux/crates/app/src/ui/browse_tab/fetch.rs @@ -7,6 +7,8 @@ use tablepro_core::statement::StatementBuilder; use tablepro_core::{BuildFilterError, ColumnInfo, FilterSet, QueryResult, RowLimit}; use tablepro_session::{LinkGeneration, OperationError, OperationKind}; +use super::column_recheck::RecheckAction; +use super::columns_fetch::{ColumnFreshness, load_columns}; use super::{ BrowseCommand, BrowseModel, BrowseRequestKind, BrowseTabInput, BrowseTabOutput, ColumnsReply, Effects, PageReply, }; @@ -32,11 +34,11 @@ impl BrowseModel { /// columns it is built from. pub(super) fn fetch(&mut self, kind: BrowseRequestKind, fx: &mut Effects) { match kind { - BrowseRequestKind::Columns => self.fetch_columns(false), + BrowseRequestKind::Columns => self.fetch_columns(ColumnFreshness::Cached), BrowseRequestKind::Page | BrowseRequestKind::Count if self.identity.is_none() => { self.after_columns.insert(kind); if !self.sequencer.is_pending(BrowseRequestKind::Columns) { - self.fetch_columns(false); + self.fetch_columns(ColumnFreshness::Cached); } } BrowseRequestKind::Page => self.fetch_page(fx), @@ -45,11 +47,13 @@ impl BrowseModel { } /// Read the columns, then the page and the count built from them. - /// `refresh` asks the server even when the catalog has the columns. - pub(super) fn reload_from_columns(&mut self, refresh: bool) { + /// No page is asked for before the columns land, since a page is + /// built from them, and a failure to read them fails the read at + /// once rather than leave the tab waiting. + pub(super) fn reload_from_columns(&mut self, freshness: ColumnFreshness) { self.after_columns.insert(BrowseRequestKind::Page); self.after_columns.insert(BrowseRequestKind::Count); - self.fetch_columns(refresh); + self.fetch_columns(freshness); } fn issue(&mut self, kind: BrowseRequestKind) -> RequestTicket { @@ -57,7 +61,7 @@ impl BrowseModel { self.sequencer.issue(kind) } - fn fetch_columns(&mut self, refresh: bool) { + fn fetch_columns(&mut self, freshness: ColumnFreshness) { let ticket = self.issue(BrowseRequestKind::Columns); let session = self.session.clone(); let table = self.table_ref(); @@ -66,13 +70,8 @@ impl BrowseModel { // The catalog's read is shared with other views, so a // replaced request stops waiting on it without stopping it. let columns = tokio::select! { - columns = async { - match refresh { - true => session.refresh_columns(table.clone()).await, - false => session.columns(table.clone()).await, - } - } => columns?, - () = cancel.cancelled() => return Err(Arc::new(OperationError::Cancelled)), + columns = load_columns(session.clone(), table.clone(), freshness) => columns?, + () = cancel.cancelled() => return Err(LoadFailure::Columns(Arc::new(OperationError::Cancelled))), }; let identity = session .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { @@ -80,11 +79,16 @@ impl BrowseModel { async move { Ok(lease.row_identity(&table, options).await?) } }) .await - .map_err(Arc::new)?; + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "browse row identity failed"); + } + LoadFailure::Columns(Arc::new(error)) + })?; Ok(ColumnsReply { columns, identity }) }; self.deliver(ticket, work, BrowseCommand::Columns, |failure| { - Arc::new(OperationError::from(failure)) + LoadFailure::Columns(Arc::new(OperationError::from(failure))) }); } @@ -246,13 +250,8 @@ impl BrowseModel { self.fetch(kind, fx); } } - Err(error) => { - tracing::warn!( - kind = failure_kind(&error), - category = ?error.category(), - "could not read the table's columns" - ); - self.on_load_failed(LoadFailure::Columns(error), fx); + Err(failure) => { + self.on_load_failed(failure, fx); self.retry_if_link_moved(BrowseRequestKind::Columns, fx); } } @@ -263,10 +262,7 @@ impl BrowseModel { } self.failed.record(BrowseRequestKind::Page, &value); match value { - Ok(reply) => { - let offset = reply.offset().unwrap_or(self.current_offset); - self.on_rows_loaded(offset, reply, fx); - } + Ok(reply) => self.on_page_read(reply, fx), Err(error) => { self.on_load_failed(LoadFailure::Page(Arc::new(error)), fx); self.retry_if_link_moved(BrowseRequestKind::Page, fx); @@ -293,6 +289,27 @@ impl BrowseModel { } } + /// A page read under columns the table no longer has would put cells + /// under the wrong headers, so the columns are read again once, then + /// the page; a second mismatch fails the read. + fn on_page_read(&mut self, reply: PageReply, fx: &mut Effects) { + match self + .recheck + .check(&self.current_columns, &reply.result.columns, &reply.layout) + { + RecheckAction::Accept => { + let offset = reply.offset().unwrap_or(self.current_offset); + self.on_rows_loaded(offset, reply, fx); + } + RecheckAction::Refetch => { + tracing::debug!("a page came back with other columns; reading the columns again"); + self.after_columns.insert(BrowseRequestKind::Page); + self.fetch_columns(ColumnFreshness::Refetch); + } + RecheckAction::Fail => self.on_load_failed(LoadFailure::ColumnsChanged, fx), + } + } + /// The session reconnected. Reads that failed are asked again on the /// new connection; a tab whose reads all landed keeps its rows. pub(super) fn on_link_changed(&mut self, generation: LinkGeneration, fx: &mut Effects) { @@ -326,7 +343,7 @@ impl BrowseModel { true => { self.after_columns .extend(kinds.into_iter().filter(|kind| *kind != BrowseRequestKind::Columns)); - self.fetch_columns(false); + self.fetch_columns(ColumnFreshness::Cached); } false => { for kind in kinds { 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 b93c90c45f..fc3d6250e4 100644 --- a/linux/crates/app/src/ui/browse_tab/load_tests.rs +++ b/linux/crates/app/src/ui/browse_tab/load_tests.rs @@ -5,11 +5,13 @@ use std::time::Duration; use gtk4::prelude::*; use gtk4::subclass::prelude::ObjectSubclassIsExt; use libadwaita as adw; -use tablepro_core::{DriverError, LossPhase, QueryResult, Value}; +use tablepro_core::{ColumnInfo, DriverError, LossPhase, QueryResult, Value}; use tablepro_test_support::{CallCounters, FakeReply}; use super::BrowseTabInput; -use super::tests::{Harness, Shown, WAIT, after_next_frame, column_view, key, rows, serving_table, text}; +use super::tests::{ + Harness, Shown, WAIT, after_next_frame, column, column_view, header_titles, key, rows, serving_table, text, +}; use crate::test_support::{drain_main_context, wait_until}; use crate::ui::grid::GridCell; @@ -206,3 +208,115 @@ fn a_selection_is_not_put_back_after_a_failed_refresh() { "the selection from before the failed refresh came back" ); } + +fn error_text(harness: &Harness) -> String { + harness + .tab() + .imp() + .error_page + .description() + .map(|text| text.to_string()) + .unwrap_or_default() +} + +#[gtk4::test] +fn columns_failure_shows_error_page_not_spinner() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .always(FakeReply::err(DriverError::server("permission denied"))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert!( + error_text(&harness).contains("permission denied"), + "{}", + error_text(&harness) + ); + assert!(!harness.tab().is_loading()); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 0); +} + +#[gtk4::test] +fn empty_column_list_is_a_load_failure() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script.fetch_columns.always(FakeReply::ok(Vec::::new())); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the tab stayed on its spinner"); + + assert_eq!( + error_text(&harness), + gtk4::glib::markup_escape_text(&crate::ui::tab_load::LoadFailure::NoColumns.message()).as_str() + ); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 0); +} + +#[gtk4::test] +fn refresh_after_columns_failure_refetches_columns() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::err(DriverError::server("busy"))) + .always(FakeReply::ok(vec![column("id", true), column("name", false)])); + script.query_bound.always(FakeReply::ok(rows(&["back"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the error page"); + + harness.tab().imp().retry_button.emit_clicked(); + + wait_until(WAIT, || harness.shown() == text(&["back"])).expect("the grid after the columns were read again"); + assert_eq!(CallCounters::get(&harness.connection.counters().fetch_columns), 2); + assert_eq!(harness.visible_page().as_deref(), Some("grid")); +} + +#[gtk4::test] +fn refresh_failure_of_columns_keeps_cached_columns() { + let mut script = serving_table(); + script.fetch_columns = Default::default(); + script + .fetch_columns + .once(FakeReply::ok(vec![column("id", true), column("name", false)])) + .always(FakeReply::err(DriverError::server("busy"))); + script.query_bound.always(FakeReply::ok(rows(&["kept"]))); + let harness = Harness::open(script, []); + wait_until(WAIT, || harness.shown() == text(&["kept"])).expect("the first page"); + + harness.tab().send(BrowseTabInput::Refresh); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed refresh was not shown"); + + assert_eq!(header_titles(harness.tab()), ["id", "name"]); + assert_eq!(harness.tab().columns(), [column("id", true), column("name", false)]); + assert_eq!(harness.shown(), text(&["kept"])); + assert_eq!(CallCounters::get(&harness.connection.counters().query_bound), 1); +} + +#[gtk4::test] +fn page_column_mismatch_refetches_columns_once() { + let renamed = [column("id", true), column("full_name", false)]; + let mut script = serving_table(); + script.query_bound.always(FakeReply::ok(QueryResult::new( + renamed.iter().map(ColumnInfo::result_column).collect::>(), + vec![vec![Value::Int(0), Value::Text("Ada".to_owned())]], + ))); + let harness = Harness::open(script, []); + + wait_until(WAIT, || harness.visible_page().as_deref() == Some("error")).expect("the mismatched page was shown"); + + let counters = harness.connection.counters(); + assert_eq!(CallCounters::get(&counters.fetch_columns), 2); + assert_eq!(CallCounters::get(&counters.query_bound), 2); + assert!( + harness.shown().is_empty(), + "rows read under other columns reached the grid" + ); + assert!( + error_text(&harness).contains("columns changed"), + "{}", + error_text(&harness) + ); +} diff --git a/linux/crates/app/src/ui/browse_tab/mod.rs b/linux/crates/app/src/ui/browse_tab/mod.rs index 1b00d5efaf..51a54db59e 100644 --- a/linux/crates/app/src/ui/browse_tab/mod.rs +++ b/linux/crates/app/src/ui/browse_tab/mod.rs @@ -7,6 +7,8 @@ mod browse_command; mod browse_request_kind; +mod column_recheck; +mod columns_fetch; mod confirming_delete; mod fetch; mod imp; @@ -40,6 +42,8 @@ use tablepro_session::{LinkGeneration, Session}; pub(crate) use self::browse_command::{BrowseCommand, ColumnsReply}; pub(crate) use self::browse_request_kind::BrowseRequestKind; +use self::column_recheck::ColumnRecheck; +use self::columns_fetch::ColumnFreshness; use self::confirming_delete::ConfirmingDelete; pub(crate) use self::page_reply::PageReply; use self::page_state::PageState; @@ -182,6 +186,7 @@ impl BrowseTab { sequencer: RequestSequencer::new(), failed: FailedRequests::new(), after_columns: BTreeSet::new(), + recheck: ColumnRecheck::default(), issued_on: std::collections::BTreeMap::new(), link_generation, identity: None, @@ -574,6 +579,7 @@ pub(crate) struct BrowseModel { /// Reads that wait for the columns, because a page and a count are /// built from them. after_columns: BTreeSet, + recheck: ColumnRecheck, /// The connection each running request was issued on. A request /// that fails after the connection was replaced is asked again at /// once, since the reconnect it would wait for has already landed. @@ -979,7 +985,7 @@ impl BrowseModel { fn reload(&mut self, fx: &mut Effects) { self.capture_selection(); self.begin_read(fx); - self.reload_from_columns(true); + self.reload_from_columns(ColumnFreshness::Refetch); } /// A read of the page the tab asks for starts. Rows read under the @@ -1471,14 +1477,15 @@ impl BrowseModel { self.refresh_filter_chrome(); // DDL from the SQL editor or another client never reaches // the catalog, so a tab that opens asks the server. - self.reload_from_columns(true); + self.begin_read(fx); + self.reload_from_columns(ColumnFreshness::Refetch); } BrowseTabInput::Command(command) => self.on_command(command, fx), BrowseTabInput::LinkChanged(generation) => self.on_link_changed(generation, fx), BrowseTabInput::SchemaChanged => { self.capture_selection(); self.begin_read(fx); - self.reload_from_columns(false); + self.reload_from_columns(ColumnFreshness::Cached); } BrowseTabInput::Refresh => self.reload(fx), BrowseTabInput::DiscardAndRefresh => { diff --git a/linux/crates/app/src/ui/tab_load/load_failure.rs b/linux/crates/app/src/ui/tab_load/load_failure.rs index b6df1db022..d4da349a91 100644 --- a/linux/crates/app/src/ui/tab_load/load_failure.rs +++ b/linux/crates/app/src/ui/tab_load/load_failure.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use tablepro_core::ErrorCategory; use tablepro_session::OperationError; /// Why a tab's read showed nothing new. @@ -7,6 +8,11 @@ use tablepro_session::OperationError; pub(crate) enum LoadFailure { /// The table's columns, or what tells its rows apart. Columns(Arc), + /// The catalog lists no column the account can read. + NoColumns, + /// The page came back with other columns than the ones read for it, + /// and again after they were read again. + ColumnsChanged, Page(Arc), } @@ -16,6 +22,21 @@ impl LoadFailure { pub(crate) fn message(&self) -> String { match self { Self::Columns(error) | Self::Page(error) => crate::ui::error_text::operation_error_message(error), + Self::NoColumns => crate::i18n::gettext( + "This table has no columns you can read. It may have been dropped, or your account can't read it.", + ), + Self::ColumnsChanged => crate::i18n::gettext( + "The table's columns changed while its rows were read. Try again to read it as it is now.", + ), + } + } + + /// What kind of problem it was, for the log, which never takes the + /// server's text. + pub(crate) fn category(&self) -> Option { + match self { + Self::Columns(error) | Self::Page(error) => error.category(), + Self::NoColumns | Self::ColumnsChanged => None, } } } @@ -43,6 +64,15 @@ mod tests { ); } + #[test] + fn no_columns_failure_message() { + assert_eq!( + LoadFailure::NoColumns.message(), + "This table has no columns you can read. It may have been dropped, or your account can't read it." + ); + assert_eq!(LoadFailure::NoColumns.category(), None); + } + #[test] fn a_server_message_stays_plain_text() { let failure = LoadFailure::Page(Arc::new(OperationError::Driver(DriverError::server( diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 54c27b530e..83825dfd81 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -87,6 +87,8 @@ crates/app/src/test_support/wait_timed_out.rs crates/app/src/test_support/widget_lookup.rs crates/app/src/ui/browse_tab/browse_command.rs crates/app/src/ui/browse_tab/browse_request_kind.rs +crates/app/src/ui/browse_tab/column_recheck.rs +crates/app/src/ui/browse_tab/columns_fetch.rs crates/app/src/ui/browse_tab/confirming_delete.rs crates/app/src/ui/browse_tab/fetch.rs crates/app/src/ui/browse_tab/imp.rs From 9aec69304013768c9ad309a580451b480111e165 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:44:27 +0700 Subject: [PATCH 122/301] build(linux): ship third-party license notices --- .github/workflows/build-linux.yml | 27 + linux/ARCHITECTURE.md | 4 +- linux/CHANGELOG.md | 1 + linux/CONTRIBUTING.md | 11 + linux/README.md | 2 +- linux/about.toml | 28 + .../build-aux/about/THIRD-PARTY-NOTICE.md.hbs | 27 + linux/build-aux/about/third-party.json.hbs | 16 + .../build-aux/update-third-party-licenses.sh | 29 + .../app/src/application/about_dialog.rs | 7 + .../app/src/application/legal_section.rs | 49 + linux/crates/app/src/application/mod.rs | 2 + .../src/application/third_party_licenses.rs | 300 + linux/data/licenses/THIRD-PARTY-NOTICE.md | 5947 +++++++++++++++++ linux/data/licenses/third-party.json | 1425 ++++ linux/data/meson.build | 2 + linux/data/resources/resources.gresource.xml | 2 + linux/po/POTFILES.in | 2 + 18 files changed, 7879 insertions(+), 2 deletions(-) create mode 100644 linux/about.toml create mode 100644 linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs create mode 100644 linux/build-aux/about/third-party.json.hbs create mode 100755 linux/build-aux/update-third-party-licenses.sh create mode 100644 linux/crates/app/src/application/legal_section.rs create mode 100644 linux/crates/app/src/application/third_party_licenses.rs create mode 100644 linux/data/licenses/THIRD-PARTY-NOTICE.md create mode 100644 linux/data/licenses/third-party.json diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 32bca21ca1..709448e876 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -156,6 +156,33 @@ jobs: - name: Dist run: meson dist -C _build --no-tests --formats xztar + licenses: + name: Third-party licenses + runs-on: ubuntu-24.04 + defaults: + run: + working-directory: linux + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ github.event_name == 'schedule' && 'linux' || github.ref }} + - uses: dtolnay/rust-toolchain@ce678459e9fc7500d337468f904b95f1b5c10b5e # 1.98 + with: + toolchain: "1.98" + - uses: taiki-e/install-action@3f74d7c16a4242f1c95561e98edc25d36adb4375 # v2.87.12 + with: + tool: cargo-about@0.9.2 + - name: Regenerate the notices from Cargo.lock + run: build-aux/update-third-party-licenses.sh + - name: The committed notices cover every shipped crate + # A crate added to or bumped in Cargo.lock changes the regenerated + # files, and so does one whose license text changed. + run: | + if ! git diff --exit-code -- data/licenses; then + echo "::error::data/licenses does not match Cargo.lock: run linux/build-aux/update-third-party-licenses.sh and commit the result" + exit 1 + fi + i18n: name: Translations runs-on: ubuntu-24.04 diff --git a/linux/ARCHITECTURE.md b/linux/ARCHITECTURE.md index 4d0d9d520f..681b6053e5 100644 --- a/linux/ARCHITECTURE.md +++ b/linux/ARCHITECTURE.md @@ -8,8 +8,10 @@ TablePro Linux is a layered Rust workspace with strict, one-directional dependen linux/ ├── meson.build build entry point; owns install and the build-time config ├── Cargo.toml workspace manifest -├── data/ desktop entry, D-Bus service, AppStream metainfo, icons, GResource +├── about.toml cargo-about settings for the third-party license notices +├── data/ desktop entry, D-Bus service, AppStream metainfo, icons, GResource, licenses ├── build-aux/flatpak/ Devel Flatpak manifest and krb5.conf +├── build-aux/about/ templates that turn Cargo.lock into data/licenses ├── po/ translation catalogue └── crates/ ├── app/ binary, the GTK application, its windows and views diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 2d29a008e4..7015999d10 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. - Save query history in Preferences turns the query history off and keeps the entries already saved. - Troubleshooting in About TablePro, with the app, toolkit and driver versions to copy into a bug report. +- Licenses of the third-party crates TablePro is built with, in About TablePro and installed with the app. ### Changed diff --git a/linux/CONTRIBUTING.md b/linux/CONTRIBUTING.md index 959f6fded7..c6375cf546 100644 --- a/linux/CONTRIBUTING.md +++ b/linux/CONTRIBUTING.md @@ -53,6 +53,17 @@ RUST_LOG=debug ./_build/crates/app/tablepro CI sets `GTK_A11Y=test` for GTK tests. With `GTK_A11Y=none` GTK records no accessible properties, and the accessibility helpers report that the test backend is missing. +### Dependencies + +`data/licenses` holds the license notices of every crate the app ships, written from `Cargo.lock` by cargo-about. The About dialog embeds `third-party.json`, and both files install next to `LICENSE`. After a change to `Cargo.lock`, regenerate them and commit the result; the Third-party licenses CI job fails until they match: + +```bash +cargo install --locked cargo-about@0.9.2 --features cli +build-aux/update-third-party-licenses.sh +``` + +A license added to `[licenses].allow` in `deny.toml` goes into `accepted` in `about.toml` at the same position. + ## Code style | Tool | Config | Notes | diff --git a/linux/README.md b/linux/README.md index c2a20b7c6d..c537de7753 100644 --- a/linux/README.md +++ b/linux/README.md @@ -67,7 +67,7 @@ with `GSettings schema app.tablepro.TablePro is not installed`. Install with `meson install -C _build`, which places the binary, the askpass helper, the desktop entry, the D-Bus service, the AppStream -metainfo and the icons. See +metainfo, the icons and the third-party license notices. See [ADR 0006](docs/decisions/0006-meson-cargo-build.md). Before pushing, run the [fast-job commands](CONTRIBUTING.md#fast-job-commands). diff --git a/linux/about.toml b/linux/about.toml new file mode 100644 index 0000000000..f83a975f3e --- /dev/null +++ b/linux/about.toml @@ -0,0 +1,28 @@ +# cargo-about settings for data/licenses, which +# build-aux/update-third-party-licenses.sh writes. +# +# `accepted` is deny.toml's [licenses].allow in the same order, so a crate +# under "MIT OR Apache-2.0" is listed under the first one deny accepts. +accepted = [ + "AGPL-3.0-or-later", + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "Unicode-3.0", + "CC0-1.0", + "MPL-2.0", + "OpenSSL", + "CDLA-Permissive-2.0", + "LGPL-2.1-or-later", + "0BSD", +] +targets = ["x86_64-unknown-linux-gnu", "aarch64-unknown-linux-gnu"] +# Neither kind of crate ends up in the shipped binaries. +ignore-build-dependencies = true +ignore-dev-dependencies = true +# The workspace's own crates are TablePro, under LICENSE. +private = { ignore = true } diff --git a/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs b/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs new file mode 100644 index 0000000000..3937641d9c --- /dev/null +++ b/linux/build-aux/about/THIRD-PARTY-NOTICE.md.hbs @@ -0,0 +1,27 @@ +# Third-party licenses + +TablePro is licensed under the GNU Affero General Public License, version 3 or later, in `LICENSE`. It is built with the Rust crates below, each listed under the license it is used under. + +`build-aux/update-third-party-licenses.sh` writes this file from `Cargo.lock`. Do not edit it by hand. + +## Overview + +{{#each overview}} +- {{{name}}} ({{{id}}}) +{{/each}} + +## License texts +{{#each licenses}} + +### {{{name}}} + +Used by: + +{{#each used_by}} +- {{{crate.name}}} {{{crate.version}}} +{{/each}} + +~~~~text +{{{text}}} +~~~~ +{{/each}} diff --git a/linux/build-aux/about/third-party.json.hbs b/linux/build-aux/about/third-party.json.hbs new file mode 100644 index 0000000000..7d87bcab08 --- /dev/null +++ b/linux/build-aux/about/third-party.json.hbs @@ -0,0 +1,16 @@ +{ + "licenses": [ +{{#each licenses}} + { + "id": {{json id}}, + "name": {{json name}}, + "text": {{json text}}, + "used_by": [ +{{#each used_by}} + { "crate": { "name": {{json crate.name}}, "version": {{json crate.version}} } }{{#unless @last}},{{/unless}} +{{/each}} + ] + }{{#unless @last}},{{/unless}} +{{/each}} + ] +} diff --git a/linux/build-aux/update-third-party-licenses.sh b/linux/build-aux/update-third-party-licenses.sh new file mode 100755 index 0000000000..32126fe880 --- /dev/null +++ b/linux/build-aux/update-third-party-licenses.sh @@ -0,0 +1,29 @@ +#!/bin/sh +# Writes data/licenses from Cargo.lock with cargo-about. CI runs it and +# fails when the committed files differ, so run it after every change to +# Cargo.lock and commit what it writes. +set -eu + +expected_version="cargo-about 0.9.2" +root="$(cd "$(dirname "$0")/.." && pwd)" + +# Another version can render the same lock file differently, which CI +# would report as drift. +version="$(cargo about --version)" +if [ "$version" != "$expected_version" ]; then + echo "update-third-party-licenses.sh: needs $expected_version, found $version" >&2 + exit 1 +fi + +cargo fetch --locked --manifest-path "$root/Cargo.toml" + +# The app manifest, not the workspace, so the test-only crates and their +# dependencies stay out: tablepro-ssh, which builds the askpass helper, +# is one of the app's own dependencies. +for output in third-party.json THIRD-PARTY-NOTICE.md; do + cargo about generate --locked --offline --fail \ + --config "$root/about.toml" \ + --manifest-path "$root/crates/app/Cargo.toml" \ + --output-file "$root/data/licenses/$output" \ + "$root/build-aux/about/$output.hbs" +done diff --git a/linux/crates/app/src/application/about_dialog.rs b/linux/crates/app/src/application/about_dialog.rs index 39daab6a8c..ddd614a1e5 100644 --- a/linux/crates/app/src/application/about_dialog.rs +++ b/linux/crates/app/src/application/about_dialog.rs @@ -3,6 +3,7 @@ use tablepro_core::DriverRegistry; use super::debug_info::debug_info; use super::runtime_versions::RuntimeVersions; +use super::third_party_licenses; use crate::config::{self, Profile}; use crate::i18n::gettext; @@ -21,6 +22,12 @@ pub fn build_about_dialog(drivers: &DriverRegistry) -> adw::AboutDialog { dialog.set_translator_credits(&gettext("translator-credits")); dialog.set_debug_info(&debug_info(&RuntimeVersions::current(), drivers)); dialog.set_debug_info_filename(DEBUG_INFO_FILENAME); + match third_party_licenses::embedded_legal_sections() { + Ok(sections) => { + third_party_licenses::add_legal_sections(&dialog, §ions); + } + Err(error) => tracing::warn!(%error, "the About dialog lists no third-party licenses"), + } dialog } diff --git a/linux/crates/app/src/application/legal_section.rs b/linux/crates/app/src/application/legal_section.rs new file mode 100644 index 0000000000..0b7a3414ef --- /dev/null +++ b/linux/crates/app/src/application/legal_section.rs @@ -0,0 +1,49 @@ +use gtk4 as gtk; + +/// One block on the Legal page of the About dialog. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LegalSection { + pub title: String, + pub license_type: gtk::License, + /// Set only for `gtk::License::Custom`: for a known type the dialog + /// names the license and links to it. + pub license_text: Option, +} + +impl LegalSection { + /// The dialog sets a custom license as Pango markup, and a copyright + /// line such as `Name ` would blank the section. + pub fn license_markup(&self) -> Option { + self.license_text.as_deref().map(glib::markup_escape_text) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn license_markup_escapes_the_text() { + let section = LegalSection { + title: "a".to_owned(), + license_type: gtk::License::Custom, + license_text: Some("Copyright 2020 A & B".to_owned()), + }; + + assert_eq!( + section.license_markup().as_deref(), + Some("Copyright 2020 A <a@example.org> & B") + ); + } + + #[test] + fn license_markup_is_absent_without_a_text() { + let section = LegalSection { + title: "a".to_owned(), + license_type: gtk::License::MitX11, + license_text: None, + }; + + assert_eq!(section.license_markup(), None); + } +} diff --git a/linux/crates/app/src/application/mod.rs b/linux/crates/app/src/application/mod.rs index 25d4a738fc..ccec6a6751 100644 --- a/linux/crates/app/src/application/mod.rs +++ b/linux/crates/app/src/application/mod.rs @@ -7,10 +7,12 @@ mod app_services; mod app_window_host; mod debug_info; mod imp; +mod legal_section; mod open_files; mod quit_sequence; mod runtime_versions; mod session_signals; +mod third_party_licenses; mod window_host; mod window_launch; mod window_registry; diff --git a/linux/crates/app/src/application/third_party_licenses.rs b/linux/crates/app/src/application/third_party_licenses.rs new file mode 100644 index 0000000000..9f484f6336 --- /dev/null +++ b/linux/crates/app/src/application/third_party_licenses.rs @@ -0,0 +1,300 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use gtk4 as gtk; +use libadwaita as adw; +use serde::Deserialize; +use thiserror::Error; + +use super::legal_section::LegalSection; +use crate::config; + +#[derive(Debug, Error)] +pub enum ThirdPartyLicensesError { + #[error("could not read the embedded third-party licenses: {0}")] + Lookup(#[source] glib::Error), + #[error("could not parse the embedded third-party licenses: {0}")] + Parse(#[source] serde_json::Error), +} + +/// The shape `build-aux/about/third-party.json.hbs` writes. +#[derive(Deserialize)] +struct Notices { + licenses: Vec, +} + +#[derive(Deserialize)] +struct LicenseRecord { + id: String, + text: String, + used_by: Vec, +} + +#[derive(Deserialize)] +struct UsedBy { + #[serde(rename = "crate")] + krate: CrateRecord, +} + +#[derive(Deserialize)] +struct CrateRecord { + name: String, +} + +#[derive(Default)] +struct LicenseGroup { + crates: BTreeSet, + texts: Vec, +} + +pub fn resource_path() -> String { + format!("{}/licenses/third-party.json", config::RESOURCE_BASE_PATH) +} + +pub fn embedded_legal_sections() -> Result, ThirdPartyLicensesError> { + let json = gio::resources_lookup_data(&resource_path(), gio::ResourceLookupFlags::NONE) + .map_err(ThirdPartyLicensesError::Lookup)?; + legal_sections(&json) +} + +/// One section per license, titled with the crates under it. +pub fn legal_sections(json: &[u8]) -> Result, ThirdPartyLicensesError> { + let notices: Notices = serde_json::from_slice(json).map_err(ThirdPartyLicensesError::Parse)?; + // cargo-about gives each distinct text of a license its own entry, + // since the copyright lines differ from crate to crate. + let mut groups: BTreeMap = BTreeMap::new(); + for record in notices.licenses { + let group = groups.entry(record.id).or_default(); + group + .crates + .extend(record.used_by.into_iter().map(|used| used.krate.name)); + if !group.texts.contains(&record.text) { + group.texts.push(record.text); + } + } + Ok(groups + .into_iter() + .map(|(id, group)| { + let license_type = gtk_license(&id); + LegalSection { + title: group.crates.into_iter().collect::>().join(", "), + license_type, + license_text: (license_type == gtk::License::Custom).then(|| group.texts.join("\n\n")), + } + }) + .collect()) +} + +/// AdwAboutDialog cannot list its sections back, so the number applied +/// is what a caller can check. +pub fn add_legal_sections(dialog: &adw::AboutDialog, sections: &[LegalSection]) -> usize { + for section in sections { + dialog.add_legal_section( + §ion.title, + None, + section.license_type, + section.license_markup().as_deref(), + ); + } + sections.len() +} + +/// The licenses GTK can name itself. Any other is shown as its text. +fn gtk_license(spdx_id: &str) -> gtk::License { + match spdx_id { + "MIT" => gtk::License::MitX11, + "Apache-2.0" => gtk::License::Apache20, + "BSD-2-Clause" => gtk::License::Bsd, + "BSD-3-Clause" => gtk::License::Bsd3, + "MPL-2.0" => gtk::License::Mpl20, + "LGPL-2.1-or-later" => gtk::License::Lgpl21, + "0BSD" => gtk::License::_0bsd, + _ => gtk::License::Custom, + } +} + +#[cfg(test)] +mod tests { + use gtk4::prelude::*; + use libadwaita::prelude::AdwDialogExt; + use tablepro_core::DriverRegistry; + + use super::*; + use crate::application::about_dialog::build_about_dialog; + use crate::test_support::{descendants, drain_main_context}; + + const FIXTURE: &str = r#"{ + "licenses": [ + { + "id": "ISC", + "name": "ISC License", + "text": "ISC text one", + "used_by": [{ "crate": { "name": "untrusted", "version": "0.9.0" } }] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "ISC text two", + "used_by": [{ "crate": { "name": "aws-lc-sys", "version": "0.32.0" } }] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT text one", + "used_by": [ + { "crate": { "name": "serde", "version": "1.0.0" } }, + { "crate": { "name": "anyhow", "version": "1.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT text two", + "used_by": [ + { "crate": { "name": "windows-sys", "version": "0.52.0" } }, + { "crate": { "name": "windows-sys", "version": "0.59.0" } }, + { "crate": { "name": "serde", "version": "1.0.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "Apache text", + "used_by": [{ "crate": { "name": "fragile", "version": "2.1.0" } }] + } + ] + }"#; + + #[test] + fn spdx_maps_to_gtk_license() { + let expected = [ + ("MIT", gtk::License::MitX11), + ("Apache-2.0", gtk::License::Apache20), + ("BSD-2-Clause", gtk::License::Bsd), + ("BSD-3-Clause", gtk::License::Bsd3), + ("MPL-2.0", gtk::License::Mpl20), + ("LGPL-2.1-or-later", gtk::License::Lgpl21), + ("0BSD", gtk::License::_0bsd), + ("Unicode-3.0", gtk::License::Custom), + ("ISC", gtk::License::Custom), + ]; + for (spdx_id, license) in expected { + assert_eq!(gtk_license(spdx_id), license, "{spdx_id}"); + } + } + + #[test] + fn legal_sections_from_fixture() { + let sections = legal_sections(FIXTURE.as_bytes()).expect("the fixture parses"); + + assert_eq!( + sections, + vec![ + LegalSection { + title: "fragile".to_owned(), + license_type: gtk::License::Apache20, + license_text: None, + }, + LegalSection { + title: "aws-lc-sys, untrusted".to_owned(), + license_type: gtk::License::Custom, + license_text: Some("ISC text one\n\nISC text two".to_owned()), + }, + LegalSection { + title: "anyhow, serde, windows-sys".to_owned(), + license_type: gtk::License::MitX11, + license_text: None, + }, + ] + ); + } + + #[test] + fn legal_sections_rejects_malformed_json() { + for json in [&b"{"[..], br#"{ "licenses": [{ "id": "MIT" }] }"#, b"[]"] { + assert!( + matches!(legal_sections(json), Err(ThirdPartyLicensesError::Parse(_))), + "{}", + String::from_utf8_lossy(json) + ); + } + } + + /// Every SPDX id in the embedded file, read without the parser under test. + fn embedded_license_ids() -> BTreeSet { + crate::register_test_resources(); + let json = gio::resources_lookup_data(&resource_path(), gio::ResourceLookupFlags::NONE) + .expect("the third-party licenses are embedded"); + let value: serde_json::Value = serde_json::from_slice(&json).expect("the embedded file is JSON"); + value["licenses"] + .as_array() + .expect("a licenses array") + .iter() + .map(|license| license["id"].as_str().expect("an id").to_owned()) + .collect() + } + + /// What every label of `dialog` reads once it is shown, which is when + /// it builds its pages. + fn presented_labels(dialog: &adw::AboutDialog) -> Vec { + let window = adw::Window::new(); + window.present(); + dialog.present(Some(&window)); + drain_main_context(); + let labels = descendants(dialog) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|label| label.text().to_string()) + .collect(); + dialog.force_close(); + window.destroy(); + labels + } + + #[gtk4::test] + fn custom_license_text_shows_as_written() { + adw::init().expect("libadwaita initialises under the test backend"); + let text = "Copyright 2020 A & B"; + let dialog = adw::AboutDialog::new(); + let section = LegalSection { + title: "example".to_owned(), + license_type: gtk::License::Custom, + license_text: Some(text.to_owned()), + }; + + add_legal_sections(&dialog, &[section]); + + let labels = presented_labels(&dialog); + assert!(labels.iter().any(|label| label == "example"), "{labels:?}"); + assert!(labels.iter().any(|label| label == text), "{labels:?}"); + } + + #[gtk4::test] + fn about_dialog_shows_every_embedded_section() { + adw::init().expect("libadwaita initialises under the test backend"); + crate::register_test_resources(); + let sections = embedded_legal_sections().expect("the embedded licenses parse"); + + let labels = presented_labels(&build_about_dialog(&DriverRegistry::new())); + + for section in §ions { + assert!(labels.contains(§ion.title), "no heading {}", section.title); + if let Some(text) = §ion.license_text { + assert!(labels.contains(text), "no text under {}", section.title); + } + } + } + + #[gtk4::test] + fn about_dialog_applies_every_section() { + adw::init().expect("libadwaita initialises under the test backend"); + let ids = embedded_license_ids(); + assert!(ids.contains("MIT"), "{ids:?}"); + + let sections = embedded_legal_sections().expect("the embedded licenses parse"); + assert_eq!(sections.len(), ids.len()); + assert!(sections.iter().all(|section| !section.title.is_empty())); + + let dialog = adw::AboutDialog::new(); + assert_eq!(add_legal_sections(&dialog, §ions), ids.len()); + } +} diff --git a/linux/data/licenses/THIRD-PARTY-NOTICE.md b/linux/data/licenses/THIRD-PARTY-NOTICE.md new file mode 100644 index 0000000000..9d8bd63389 --- /dev/null +++ b/linux/data/licenses/THIRD-PARTY-NOTICE.md @@ -0,0 +1,5947 @@ +# Third-party licenses + +TablePro is licensed under the GNU Affero General Public License, version 3 or later, in `LICENSE`. It is built with the Rust crates below, each listed under the license it is used under. + +`build-aux/update-third-party-licenses.sh` writes this file from `Cargo.lock`. Do not edit it by hand. + +## Overview + +- MIT License (MIT) +- Unicode License v3 (Unicode-3.0) +- Apache License 2.0 (Apache-2.0) +- BSD 3-Clause "New" or "Revised" License (BSD-3-Clause) +- ISC License (ISC) +- zlib License (Zlib) +- Community Data License Agreement Permissive 2.0 (CDLA-Permissive-2.0) + +## License texts + +### Apache License 2.0 + +Used by: + +- flagset 0.4.7 +- fragile 2.1.0 + +~~~~text + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +~~~~ + +### Apache License 2.0 + +Used by: + +- zopfli 0.8.3 + +~~~~text + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2011 Google Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +~~~~ + +### Apache License 2.0 + +Used by: + +- sqlparser 0.63.0 + +~~~~text + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +~~~~ + +### Apache License 2.0 + +Used by: + +- aws-lc-sys 0.45.0 +- ryu 1.0.23 + +~~~~text +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- encoding_rs 0.8.41 + +~~~~text +// Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// +// 1. Redistributions of source code must retain the above copyright notice, this +// list of conditions and the following disclaimer. +// +// 2. Redistributions in binary form must reproduce the above copyright notice, +// this list of conditions and the following disclaimer in the documentation +// and/or other materials provided with the distribution. +// +// 3. Neither the name of the copyright holder nor the names of its +// contributors may be used to endorse or promote products derived from +// this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +/// The PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [ + 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826, + 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864, +]; + +/// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder. +pub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [ + [0xA6, 0xD9], + [0xA6, 0xDA], + [0xA6, 0xDB], + [0xA6, 0xDC], + [0xA6, 0xDD], + [0xA6, 0xDE], + [0xA6, 0xDF], + [0xA6, 0xEC], + [0xA6, 0xED], + [0xA6, 0xF3], + [0xFE, 0x59], + [0xFE, 0x61], + [0xFE, 0x66], + [0xFE, 0x67], + [0xFE, 0x6D], + [0xFE, 0x7E], + [0xFE, 0x90], + [0xFE, 0xA0], +]; + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- subtle 2.6.1 + +~~~~text +Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. +Copyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED +TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR +PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- aws-lc-sys 0.45.0 + +~~~~text +Copyright (c) . + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +~~~~ + +### BSD 3-Clause "New" or "Revised" License + +Used by: + +- encoding_rs 0.8.41 + +~~~~text +Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +~~~~ + +### Community Data License Agreement Permissive 2.0 + +Used by: + +- webpki-roots 1.0.9 + +~~~~text +# Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree +as follows: + +## 1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made +available by Data Provider(s) under this agreement if that Data +Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data +Recipient's use, modification, or sharing of any portions of the +Data that are in the public domain or that may be used, modified, +or shared under any other legal exception or limitation. + +## 2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so +long as the Data Recipient makes available the text of this agreement +with the shared Data. + +## 3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations +with respect to the use, modification, or sharing of Results. + +## 4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following +terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +WITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## 5. Definitions + +5.1. "Data" means the material received by a Data Recipient under +this agreement. + +5.2. "Data Provider" means any person who is the source of Data +provided under this agreement and in reliance on a Data Recipient's +agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly +or indirectly from a Data Provider and agrees to the terms of this +agreement. + +5.4. "Results" means any outcome obtained by computational analysis +of Data, including for example machine learning models and models' +insights. + +~~~~ + +### ISC License + +Used by: + +- untrusted 0.9.0 + +~~~~text +// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +~~~~ + +### ISC License + +Used by: + +- rustls-webpki 0.103.15 + +~~~~text +Except as otherwise noted, this project is licensed under the following +(ISC-style) terms: + +Copyright 2015 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The files under third-party/chromium are licensed as described in +third-party/chromium/LICENSE. + +~~~~ + +### ISC License + +Used by: + +- aws-lc-rs 1.18.1 +- aws-lc-sys 0.45.0 + +~~~~text +ISC License: + +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") +Copyright (c) 1995-2003 by Internet Software Consortium + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- dotenvy 0.15.7 + +~~~~text +# The MIT License (MIT) + +Copyright (c) 2014 Santiago Lapresta and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- atomic-waker 1.1.2 +- futures-lite 2.6.1 + +~~~~text +=============================================================================== + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +=============================================================================== + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- sqlx 0.9.0 + +~~~~text +Copyright (C) SQLx Contributors +Portions of this work Copyright (C) LaunchBadge, LLC + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- md-5 0.10.6 +- sha2 0.10.9 + +~~~~text +Copyright (c) 2006-2009 Graydon Hoare +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2016 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- lazy_static 1.5.0 + +~~~~text +Copyright (c) 2010 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hex 0.4.3 + +~~~~text +Copyright (c) 2013-2014 The Rust Project Developers. +Copyright (c) 2015-2020 The rust-hex Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- form_urlencoded 1.2.2 + +~~~~text +Copyright (c) 2013-2016 The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- idna 1.1.0 +- percent-encoding 2.3.2 +- url 2.5.8 + +~~~~text +Copyright (c) 2013-2025 The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cfg-if 1.0.4 +- futures-timer 3.0.4 +- openssl-probe 0.2.1 +- socket2 0.6.5 + +~~~~text +Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- mio 1.2.3 + +~~~~text +Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- errno 0.3.14 + +~~~~text +Copyright (c) 2014 Chris Wong + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- base64ct 1.8.3 + +~~~~text +Copyright (c) 2014 Steve "Sc00bz" Thomas (steve at tobtu dot com) +Copyright (c) 2021-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bnum 0.13.0 + +~~~~text +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- bitflags 2.13.2 +- log 0.4.34 +- num-bigint-dig 0.9.1 +- num-bigint 0.4.8 +- num-complex 0.4.6 +- num-integer 0.1.47 +- num-iter 0.1.46 +- num-rational 0.4.2 +- num-traits 0.2.19 +- num 0.4.3 +- regex-automata 0.4.18 +- regex-syntax 0.8.11 +- regex 1.13.1 + +~~~~text +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- uuid 1.26.1 + +~~~~text +Copyright (c) 2014 The Rust Project Developers +Copyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- libsqlite3-sys 0.37.0 + +~~~~text +Copyright (c) 2014 The rusqlite developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- flate2 1.1.10 + +~~~~text +Copyright (c) 2014-2026 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hyper 1.11.1 + +~~~~text +Copyright (c) 2014-2026 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- either 1.18.0 + +~~~~text +Copyright (c) 2015 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- heck 0.5.0 +- unicode-bidi 0.3.18 +- unicode-normalization 0.1.25 +- unicode-properties 0.1.4 + +~~~~text +Copyright (c) 2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- fallible-iterator 0.2.0 + +~~~~text +Copyright (c) 2015 The rust-openssl-verify Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- unicode_categories 0.1.1 + +~~~~text +Copyright (c) 2015 The unicode-categories Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hkdf 0.12.4 + +~~~~text +Copyright (c) 2015-2018 Vlad Filippov +Copyright (c) 2018-2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- httparse 1.10.1 + +~~~~text +Copyright (c) 2015-2025 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- futures-channel 0.3.34 +- futures-core 0.3.34 +- futures-executor 0.3.34 +- futures-io 0.3.34 +- futures-macro 0.3.34 +- futures-sink 0.3.34 +- futures-task 0.3.34 +- futures-util 0.3.34 +- futures 0.3.34 + +~~~~text +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hashbrown 0.16.1 +- hashbrown 0.17.1 + +~~~~text +Copyright (c) 2016 Amanieu d'Antras + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hyper-rustls 0.27.9 +- rustls-native-certs 0.8.4 +- rustls 0.23.45 + +~~~~text +Copyright (c) 2016 Joseph Birr-Pixton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- lock_api 0.4.14 +- parking_lot 0.12.5 +- parking_lot_core 0.9.12 +- thread_local 1.1.10 + +~~~~text +Copyright (c) 2016 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- indexmap 2.14.2 + +~~~~text +Copyright (c) 2016--2017 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- equivalent 1.0.2 + +~~~~text +Copyright (c) 2016--2023 + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- scopeguard 1.2.0 + +~~~~text +Copyright (c) 2016-2019 Ulrik Sverdrup "bluss" and scopeguard developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cipher 0.4.4 + +~~~~text +Copyright (c) 2016-2020 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- md-5 0.11.0 +- sha1 0.11.0 +- sha2 0.11.0 + +~~~~text +Copyright (c) 2016-2026 The RustCrypto Project Developers +Copyright (c) 2016 Artyom Pavlov +Copyright (c) 2009-2013 Mozilla Foundation +Copyright (c) 2006-2009 Graydon Hoare + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- digest 0.10.7 +- hmac 0.12.1 +- hmac 0.13.0 + +~~~~text +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- pbkdf2 0.12.2 + +~~~~text +Copyright (c) 2017 Artyom Pavlov +Copyright (c) 2018-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- memoffset 0.9.1 + +~~~~text +Copyright (c) 2017 Gilad Naaman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- enumflags2_derive 0.7.12 + +~~~~text +Copyright (c) 2017 Maik Klein + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- stable_deref_trait 1.2.1 + +~~~~text +Copyright (c) 2017 Robert Grosse + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- stringprep 0.1.5 + +~~~~text +Copyright (c) 2017 The rust-stringprep Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- http 1.5.0 + +~~~~text +Copyright (c) 2017 http-rs authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-rustls 0.26.5 + +~~~~text +Copyright (c) 2017 quininer kel + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- signal-hook-registry 1.4.8 + +~~~~text +Copyright (c) 2017 tokio-jsonrpc developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- core_detect 1.0.0 + +~~~~text +Copyright (c) 2017-2020 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- enumflags2 0.7.12 + +~~~~text +Copyright (c) 2017-2023 Maik Klein, Maja Kądziołka + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- digest 0.11.3 + +~~~~text +Copyright (c) 2017-2025 RustCrypto Developers +Copyright (c) 2017 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- aes 0.8.4 + +~~~~text +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bytes 1.12.1 + +~~~~text +Copyright (c) 2018 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- smallvec 1.16.1 + +~~~~text +Copyright (c) 2018 The Servo Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- want 0.3.1 + +~~~~text +Copyright (c) 2018-2019 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- block-buffer 0.10.4 +- block-padding 0.3.3 + +~~~~text +Copyright (c) 2018-2019 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cbc 0.1.2 + +~~~~text +Copyright (c) 2018-2022 RustCrypto Developers +Copyright (c) 2018 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- try-lock 0.2.5 + +~~~~text +Copyright (c) 2018-2023 Sean McArthur +Copyright (c) 2016 Alex Crichton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- block-buffer 0.12.1 + +~~~~text +Copyright (c) 2018-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- signature 3.0.0 + +~~~~text +Copyright (c) 2018-2026 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rand_core 0.10.1 + +~~~~text +Copyright (c) 2018-2026 The Rand Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zeroize 1.9.0 + +~~~~text +Copyright (c) 2018-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- getrandom 0.4.3 + +~~~~text +Copyright (c) 2018-2026 The rust-random Project Developers +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- slab 0.4.12 + +~~~~text +Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- sharded-slab 0.1.7 + +~~~~text +Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- matchers 0.2.0 + +~~~~text +Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- futures-intrusive 0.5.0 + +~~~~text +Copyright (c) 2019 Matthias Einwag + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bumpalo 3.20.3 + +~~~~text +Copyright (c) 2019 Nick Fitzgerald + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tracing-attributes 0.1.31 +- tracing-core 0.1.36 +- tracing-log 0.2.0 +- tracing-serde 0.2.0 +- tracing-subscriber 0.3.23 +- tracing 0.1.44 + +~~~~text +Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tower-service 0.3.3 + +~~~~text +Copyright (c) 2019 Tower Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- http-body-util 0.1.5 +- http-body 1.1.0 + +~~~~text +Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- chacha20 0.10.2 +- zeroize_derive 1.5.0 + +~~~~text +Copyright (c) 2019-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- iana-time-zone 0.1.65 + +~~~~text +Copyright (c) 2020 Andrew D. Straw + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- const-oid 0.9.6 +- der_derive 0.7.3 + +~~~~text +Copyright (c) 2020-2022 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- der 0.7.10 + +~~~~text +Copyright (c) 2020-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cpufeatures 0.2.17 + +~~~~text +Copyright (c) 2020-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- const-oid 0.10.2 +- cpufeatures 0.3.1 +- der 0.8.2 +- pkcs8 0.11.0 + +~~~~text +Copyright (c) 2020-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-common 0.1.7 + +~~~~text +Copyright (c) 2021 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- x509-cert 0.2.5 + +~~~~text +Copyright (c) 2021 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tiberius 0.13.0 + +~~~~text +Copyright (c) 2021 Tiberius Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- spki 0.7.3 + +~~~~text +Copyright (c) 2021-2023 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- pem-rfc7468 1.0.0 +- pkcs1 0.8.0-rc.4 + +~~~~text +Copyright (c) 2021-2025 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-common 0.2.2 + +~~~~text +Copyright (c) 2021-2026 RustCrypto Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-bigint 0.7.5 +- spki 0.8.0 + +~~~~text +Copyright (c) 2021-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- inout 0.1.4 + +~~~~text +Copyright (c) 2022 The RustCrypto Project Developers +Copyright (c) 2022 Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cmov 0.5.4 +- hybrid-array 0.4.15 + +~~~~text +Copyright (c) 2022-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rustls-pki-types 1.15.1 + +~~~~text +Copyright (c) 2023 Dirkjan Ochtman + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- hyper-util 0.1.20 + +~~~~text +Copyright (c) 2023-2025 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cpubits 0.1.1 + +~~~~text +Copyright (c) 2023-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zbus 5.19.0 +- zbus_macros 5.19.0 +- zbus_names 4.3.4 +- zvariant 5.15.0 +- zvariant_derive 5.15.0 + +~~~~text +Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- ctutils 0.4.2 + +~~~~text +Copyright (c) 2025-2026 The RustCrypto Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zcheapstr 1.1.0 + +~~~~text +Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- toml_datetime 1.1.1+spec-1.1.0 +- toml_edit 0.25.15+spec-1.1.0 +- toml_parser 1.1.3+spec-1.1.0 + +~~~~text +Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- libc 0.2.189 + +~~~~text +Copyright (c) The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- idna_adapter 1.2.2 + +~~~~text +Copyright (c) The rust-url developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- synstructure 0.13.2 + +~~~~text +Copyright 2016 Nika Layzell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bnum 0.13.0 + +~~~~text +Copyright 2018 Developers of the Rand project +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- rand 0.10.2 +- rand 0.9.5 +- rand_core 0.9.5 + +~~~~text +Copyright 2018 Developers of the Rand project +Copyright (c) 2014 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- multiversion-macros 0.9.0 +- multiversion 0.9.0 + +~~~~text +Copyright 2019 Caleb Zulawski + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crypto-primes 0.7.2 + +~~~~text +Copyright 2022-* Bogdan Opanchuk + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- zerocopy-derive 0.8.57 +- zerocopy 0.8.57 + +~~~~text +Copyright 2023 The Fuchsia Authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- encoding_rs 0.8.41 +- multiversion_no_op 1.0.0 +- utf8_iter 1.0.4 + +~~~~text +Copyright Mozilla Foundation + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- field-offset 0.3.6 + +~~~~text +MIT License + +Copyright (c) 2016-2021 Diggory Blake, and other contributors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- atoi 2.0.0 + +~~~~text +MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crc 3.4.0 + +~~~~text +MIT License + +Copyright (c) 2017 crc-rs Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- pretty-hex 0.4.2 + +~~~~text +MIT License + +Copyright (c) 2018 Andrei Volnin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crc32fast 1.5.2 + +~~~~text +MIT License + +Copyright (c) 2018 Sam Rijs, Alex Crichton and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- asynchronous-codec 0.7.0 + +~~~~text +MIT License + +Copyright (c) 2019 - 2020 Matt Hunzinger +Copyright (c) 2021 Max Inden + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- crc-catalog 2.5.0 + +~~~~text +MIT License + +Copyright (c) 2019 Akhil Velagapudi + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-postgres-rustls 0.14.0 + +~~~~text +MIT License + +Copyright (c) 2019 Jasper Hugo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-macros 2.7.2 + +~~~~text +MIT License + +Copyright (c) 2019 Yoshua Wuyts +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- secrecy 0.10.3 + +~~~~text +MIT License + +Copyright (c) 2019-2024 iqlusion + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- ashpd 0.13.13 + +~~~~text +MIT License + +Copyright (c) 2020 Bilal Elmoussaoui + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- rustls-platform-verifier 0.7.0 + +~~~~text +MIT License + +Copyright (c) 2022 1Password + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- oo7 0.6.0 + +~~~~text +MIT License + +Copyright (c) 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bnum 0.13.0 + +~~~~text +MIT License + +Copyright (c) 2022 Isaac Holt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- chrono 0.4.45 +- clickhouse-macros 0.3.0 +- clickhouse-types 0.1.3 +- libgssapi-sys 0.3.4 +- libgssapi 0.11.0 +- libm 0.2.16 +- rust_xlsxwriter 0.99.1 +- sqlx-core 0.9.0 +- sqlx-macros-core 0.9.0 +- sqlx-macros 0.9.0 +- sqlx-mysql 0.9.0 +- sqlx-sqlite 0.9.0 +- tiberius-macros 0.1.0 +- whoami 2.1.3 + +~~~~text +MIT License + +Copyright (c) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- tokio-stream 0.1.19 +- tokio-util 0.7.19 +- tokio 1.53.1 + +~~~~text +MIT License + +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- simd-adler32 0.3.10 + +~~~~text +MIT License + +Copyright (c) [2021] [Marvin Countryman] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- simdutf8 0.1.5 + +~~~~text +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- half 2.7.1 + +~~~~text +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- async-recursion 1.1.1 +- flume 0.12.0 +- rsa 0.10.0-rc.18 + +~~~~text +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- async-trait 0.1.92 +- atomic-waker 1.1.2 +- clickhouse 0.15.2 +- displaydoc 0.2.7 +- endi 1.1.1 +- event-listener-strategy 0.5.4 +- event-listener 5.4.2 +- fastrand 2.5.0 +- futures-lite 2.6.1 +- itoa 1.0.18 +- linux-raw-sys 0.12.1 +- once_cell 1.21.4 +- ordered-stream 0.2.0 +- parking 2.2.1 +- pin-project-lite 0.2.17 +- proc-macro-crate 3.5.0 +- proc-macro2 1.0.107 +- quote 1.0.47 +- relm4-macros 0.11.0 +- relm4 0.11.0 +- rustix 1.1.4 +- rustversion 1.0.23 +- serde 1.0.229 +- serde_bytes 0.11.19 +- serde_core 1.0.229 +- serde_derive 1.0.229 +- serde_derive_internals 0.29.1 +- serde_json 1.0.151 +- serde_repr 0.1.21 +- sqlformat 0.5.0 +- syn 2.0.119 +- syn 3.0.5 +- thiserror-impl 2.0.20 +- thiserror 2.0.20 +- typed-path 0.12.3 +- unicode-ident 1.0.24 +- zmij 1.0.23 +- zvariant_utils 4.2.0 + +~~~~text +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- allocator-api2 0.2.21 + +~~~~text +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- winnow 0.7.15 +- winnow 1.0.4 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- siphasher 1.0.4 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- cairo-rs 0.22.9 +- cairo-sys-rs 0.22.9 +- gdk-pixbuf-sys 0.22.9 +- gdk-pixbuf 0.22.0 +- gdk4-sys 0.11.4 +- gdk4 0.11.4 +- gio-sys 0.22.9 +- gio-unix-sys 0.22.9 +- gio-unix 0.22.8 +- gio 0.22.9 +- glib-macros 0.22.9 +- glib-sys 0.22.9 +- glib 0.22.9 +- gobject-sys 0.22.9 +- graphene-rs 0.22.8 +- graphene-sys 0.22.9 +- gsk4-sys 0.11.4 +- gsk4 0.11.4 +- gtk4-macros 0.11.4 +- gtk4-sys 0.11.4 +- gtk4 0.11.4 +- libadwaita-sys 0.9.2 +- libadwaita 0.9.2 +- pango-sys 0.22.9 +- pango 0.22.9 +- sourceview5-sys 0.11.2 +- sourceview5 0.11.2 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- tinyvec 1.13.3 + +~~~~text +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- spin 0.9.9 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- tracing-core 0.1.36 +- zip 8.6.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- typenum 1.20.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Paho Lurie-Gregg + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- phf 0.13.1 +- phf_shared 0.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- base64 0.22.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Alice Maz + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- aho-corasick 1.1.5 +- byteorder 1.5.0 +- csv-core 0.1.13 +- csv 1.4.0 +- memchr 2.8.3 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- aws-lc-sys 0.45.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015-2020 the fiat-crypto authors (see +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- gettext-rs 0.8.0 +- gettext-sys 0.26.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016 Konstantin V. Salikhov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- postgres-protocol 0.6.12 +- postgres-types 0.2.14 +- tokio-postgres 0.7.18 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016 Steven Fackler + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- locale_config 0.3.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2016–2019 Jan Hudec +Copyright (c) 2016 A.J. Gardner +Copyright (c) 2019, Bastien Orivel +Copyright (c) 2019, Igor Gnatenko +Copyright (c) 2019, Sophie Tauchert <999eagle@999eagle.moe> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- bstr 1.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2018-2019 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- deadpool-postgres 0.14.2 +- deadpool-runtime 0.3.1 +- deadpool 0.13.1 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2019 Michael P. Jung + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +~~~~ + +### MIT License + +Used by: + +- crossbeam-queue 0.3.14 +- crossbeam-utils 0.8.23 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2019 The Crossbeam Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- async-broadcast 0.7.2 +- connection-string 0.2.0 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2020 Yoshua Wuyts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- nu-ansi-term 0.50.3 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2014 Benjamin Sago +Copyright (c) 2021-2022 The Nushell Project Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +~~~~ + +### MIT License + +Used by: + +- generic-array 0.14.7 + +~~~~text +The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +~~~~ + +### MIT License + +Used by: + +- hashlink 0.11.1 + +~~~~text +This work is derived in part from the `linked-hash-map` crate, Copyright (c) +2015 The Rust Project Developers + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +~~~~ + +### Unicode License v3 + +Used by: + +- unicode-ident 1.0.24 + +~~~~text +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +~~~~ + +### Unicode License v3 + +Used by: + +- icu_collections 2.3.0 +- icu_locale_core 2.3.0 +- icu_normalizer 2.3.0 +- icu_normalizer_data 2.3.0 +- icu_properties 2.3.0 +- icu_properties_data 2.3.0 +- icu_provider 2.3.1 +- litemap 0.8.3 +- potential_utf 0.1.6 +- tinystr 0.8.4 +- writeable 0.6.4 +- yoke-derive 0.8.2 +- yoke 0.8.3 +- zerofrom-derive 0.1.7 +- zerofrom 0.1.8 +- zerotrie 0.2.5 +- zerovec-derive 0.11.6 +- zerovec 0.11.8 + +~~~~text +UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. + +~~~~ + +### zlib License + +Used by: + +- zlib-rs 0.6.7 + +~~~~text +(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. + +~~~~ + +### zlib License + +Used by: + +- foldhash 0.2.0 + +~~~~text +Copyright (c) 2024 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. +~~~~ diff --git a/linux/data/licenses/third-party.json b/linux/data/licenses/third-party.json new file mode 100644 index 0000000000..055100aac3 --- /dev/null +++ b/linux/data/licenses/third-party.json @@ -0,0 +1,1425 @@ +{ + "licenses": [ + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "\n Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright [yyyy] [name of copyright owner]\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n", + "used_by": [ + { "crate": { "name": "flagset", "version": "0.4.7" } }, + { "crate": { "name": "fragile", "version": "2.1.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\n TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n 1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n 2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n 3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n 4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n 5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n 6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n 7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n 8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n 9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\n END OF TERMS AND CONDITIONS\n\n APPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\n Copyright 2011 Google Inc.\n\n Licensed under the Apache License, Version 2.0 (the \"License\");\n you may not use this file except in compliance with the License.\n You may obtain a copy of the License at\n\n http://www.apache.org/licenses/LICENSE-2.0\n\n Unless required by applicable law or agreed to in writing, software\n distributed under the License is distributed on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n See the License for the specific language governing permissions and\n limitations under the License.\n", + "used_by": [ + { "crate": { "name": "zopfli", "version": "0.8.3" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": " Apache License\n Version 2.0, January 2004\n http://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n \"License\" shall mean the terms and conditions for use, reproduction,\n and distribution as defined by Sections 1 through 9 of this document.\n\n \"Licensor\" shall mean the copyright owner or entity authorized by\n the copyright owner that is granting the License.\n\n \"Legal Entity\" shall mean the union of the acting entity and all\n other entities that control, are controlled by, or are under common\n control with that entity. For the purposes of this definition,\n \"control\" means (i) the power, direct or indirect, to cause the\n direction or management of such entity, whether by contract or\n otherwise, or (ii) ownership of fifty percent (50%) or more of the\n outstanding shares, or (iii) beneficial ownership of such entity.\n\n \"You\" (or \"Your\") shall mean an individual or Legal Entity\n exercising permissions granted by this License.\n\n \"Source\" form shall mean the preferred form for making modifications,\n including but not limited to software source code, documentation\n source, and configuration files.\n\n \"Object\" form shall mean any form resulting from mechanical\n transformation or translation of a Source form, including but\n not limited to compiled object code, generated documentation,\n and conversions to other media types.\n\n \"Work\" shall mean the work of authorship, whether in Source or\n Object form, made available under the License, as indicated by a\n copyright notice that is included in or attached to the work\n (an example is provided in the Appendix below).\n\n \"Derivative Works\" shall mean any work, whether in Source or Object\n form, that is based on (or derived from) the Work and for which the\n editorial revisions, annotations, elaborations, or other modifications\n represent, as a whole, an original work of authorship. For the purposes\n of this License, Derivative Works shall not include works that remain\n separable from, or merely link (or bind by name) to the interfaces of,\n the Work and Derivative Works thereof.\n\n \"Contribution\" shall mean any work of authorship, including\n the original version of the Work and any modifications or additions\n to that Work or Derivative Works thereof, that is intentionally\n submitted to Licensor for inclusion in the Work by the copyright owner\n or by an individual or Legal Entity authorized to submit on behalf of\n the copyright owner. For the purposes of this definition, \"submitted\"\n means any form of electronic, verbal, or written communication sent\n to the Licensor or its representatives, including but not limited to\n communication on electronic mailing lists, source code control systems,\n and issue tracking systems that are managed by, or on behalf of, the\n Licensor for the purpose of discussing and improving the Work, but\n excluding communication that is conspicuously marked or otherwise\n designated in writing by the copyright owner as \"Not a Contribution.\"\n\n \"Contributor\" shall mean Licensor and any individual or Legal Entity\n on behalf of whom a Contribution has been received by Licensor and\n subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n copyright license to reproduce, prepare Derivative Works of,\n publicly display, publicly perform, sublicense, and distribute the\n Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of\n this License, each Contributor hereby grants to You a perpetual,\n worldwide, non-exclusive, no-charge, royalty-free, irrevocable\n (except as stated in this section) patent license to make, have made,\n use, offer to sell, sell, import, and otherwise transfer the Work,\n where such license applies only to those patent claims licensable\n by such Contributor that are necessarily infringed by their\n Contribution(s) alone or by combination of their Contribution(s)\n with the Work to which such Contribution(s) was submitted. If You\n institute patent litigation against any entity (including a\n cross-claim or counterclaim in a lawsuit) alleging that the Work\n or a Contribution incorporated within the Work constitutes direct\n or contributory patent infringement, then any patent licenses\n granted to You under this License for that Work shall terminate\n as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the\n Work or Derivative Works thereof in any medium, with or without\n modifications, and in Source or Object form, provided that You\n meet the following conditions:\n\n (a) You must give any other recipients of the Work or\n Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices\n stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works\n that You distribute, all copyright, patent, trademark, and\n attribution notices from the Source form of the Work,\n excluding those notices that do not pertain to any part of\n the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its\n distribution, then any Derivative Works that You distribute must\n include a readable copy of the attribution notices contained\n within such NOTICE file, excluding those notices that do not\n pertain to any part of the Derivative Works, in at least one\n of the following places: within a NOTICE text file distributed\n as part of the Derivative Works; within the Source form or\n documentation, if provided along with the Derivative Works; or,\n within a display generated by the Derivative Works, if and\n wherever such third-party notices normally appear. The contents\n of the NOTICE file are for informational purposes only and\n do not modify the License. You may add Your own attribution\n notices within Derivative Works that You distribute, alongside\n or as an addendum to the NOTICE text from the Work, provided\n that such additional attribution notices cannot be construed\n as modifying the License.\n\n You may add Your own copyright statement to Your modifications and\n may provide additional or different license terms and conditions\n for use, reproduction, or distribution of Your modifications, or\n for any such Derivative Works as a whole, provided Your use,\n reproduction, and distribution of the Work otherwise complies with\n the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise,\n any Contribution intentionally submitted for inclusion in the Work\n by You to the Licensor shall be under the terms and conditions of\n this License, without any additional terms or conditions.\n Notwithstanding the above, nothing herein shall supersede or modify\n the terms of any separate license agreement you may have executed\n with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade\n names, trademarks, service marks, or product names of the Licensor,\n except as required for reasonable and customary use in describing the\n origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or\n agreed to in writing, Licensor provides the Work (and each\n Contributor provides its Contributions) on an \"AS IS\" BASIS,\n WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or\n implied, including, without limitation, any warranties or conditions\n of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A\n PARTICULAR PURPOSE. You are solely responsible for determining the\n appropriateness of using or redistributing the Work and assume any\n risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory,\n whether in tort (including negligence), contract, or otherwise,\n unless required by applicable law (such as deliberate and grossly\n negligent acts) or agreed to in writing, shall any Contributor be\n liable to You for damages, including any direct, indirect, special,\n incidental, or consequential damages of any character arising as a\n result of this License or out of the use or inability to use the\n Work (including but not limited to damages for loss of goodwill,\n work stoppage, computer failure or malfunction, or any and all\n other commercial damages or losses), even if such Contributor\n has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing\n the Work or Derivative Works thereof, You may choose to offer,\n and charge a fee for, acceptance of support, warranty, indemnity,\n or other liability obligations and/or rights consistent with this\n License. However, in accepting such obligations, You may act only\n on Your own behalf and on Your sole responsibility, not on behalf\n of any other Contributor, and only if You agree to indemnify,\n defend, and hold each Contributor harmless for any liability\n incurred by, or claims asserted against, such Contributor by reason\n of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\n To apply the Apache License to your work, attach the following\n boilerplate notice, with the fields enclosed by brackets \"[]\"\n replaced with your own identifying information. (Don't include\n the brackets!) The text should be enclosed in the appropriate\n comment syntax for the file format. We also recommend that a\n file or class name and description of purpose be included on the\n same \"printed page\" as the copyright notice for easier\n identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n", + "used_by": [ + { "crate": { "name": "sqlparser", "version": "0.63.0" } } + ] + }, + { + "id": "Apache-2.0", + "name": "Apache License 2.0", + "text": "Apache License\nVersion 2.0, January 2004\nhttp://www.apache.org/licenses/\n\nTERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION\n\n1. Definitions.\n\n\"License\" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.\n\n\"Licensor\" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.\n\n\"Legal Entity\" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, \"control\" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.\n\n\"You\" (or \"Your\") shall mean an individual or Legal Entity exercising permissions granted by this License.\n\n\"Source\" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.\n\n\"Object\" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.\n\n\"Work\" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).\n\n\"Derivative Works\" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.\n\n\"Contribution\" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, \"submitted\" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as \"Not a Contribution.\"\n\n\"Contributor\" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.\n\n2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.\n\n3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.\n\n4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:\n\n (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and\n\n (b) You must cause any modified files to carry prominent notices stating that You changed the files; and\n\n (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and\n\n (d) If the Work includes a \"NOTICE\" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.\n\n You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.\n\n5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.\n\n6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.\n\n7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.\n\n8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.\n\n9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.\n\nEND OF TERMS AND CONDITIONS\n\nAPPENDIX: How to apply the Apache License to your work.\n\nTo apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets \"[]\" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same \"printed page\" as the copyright notice for easier identification within third-party archives.\n\nCopyright [yyyy] [name of copyright owner]\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\nhttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } }, + { "crate": { "name": "ryu", "version": "1.0.23" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "// Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).\n//\n// Redistribution and use in source and binary forms, with or without\n// modification, are permitted provided that the following conditions are met:\n//\n// 1. Redistributions of source code must retain the above copyright notice, this\n// list of conditions and the following disclaimer.\n//\n// 2. Redistributions in binary form must reproduce the above copyright notice,\n// this list of conditions and the following disclaimer in the documentation\n// and/or other materials provided with the distribution.\n//\n// 3. Neither the name of the copyright holder nor the names of its\n// contributors may be used to endorse or promote products derived from\n// this software without specific prior written permission.\n//\n// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\n// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\n// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\n// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\n// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\n// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\n// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\n// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\n// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\n// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n\n/// The PUA code points special-cased in the GB18030 encoder.\npub(crate) static GB18030_2022_OVERRIDE_PUA: [u16; 18] = [\n 0xE78D, 0xE78E, 0xE78F, 0xE790, 0xE791, 0xE792, 0xE793, 0xE794, 0xE795, 0xE796, 0xE81E, 0xE826,\n 0xE82B, 0xE82C, 0xE832, 0xE843, 0xE854, 0xE864,\n];\n\n/// The bytes corresponding to the PUA code points special-cased in the GB18030 encoder.\npub(crate) static GB18030_2022_OVERRIDE_BYTES: [[u8; 2]; 18] = [\n [0xA6, 0xD9],\n [0xA6, 0xDA],\n [0xA6, 0xDB],\n [0xA6, 0xDC],\n [0xA6, 0xDD],\n [0xA6, 0xDE],\n [0xA6, 0xDF],\n [0xA6, 0xEC],\n [0xA6, 0xED],\n [0xA6, 0xF3],\n [0xFE, 0x59],\n [0xFE, 0x61],\n [0xFE, 0x66],\n [0xFE, 0x67],\n [0xFE, 0x6D],\n [0xFE, 0x7E],\n [0xFE, 0x90],\n [0xFE, 0xA0],\n];\n", + "used_by": [ + { "crate": { "name": "encoding_rs", "version": "0.8.41" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.\nCopyright (c) 2016-2024 Isis Agora Lovecruft. All rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are\nmet:\n\n1. Redistributions of source code must retain the above copyright\nnotice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright\nnotice, this list of conditions and the following disclaimer in the\ndocumentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\ncontributors may be used to endorse or promote products derived from\nthis software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS\nIS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED\nTO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A\nPARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT\nHOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,\nSPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED\nTO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR\nPROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS\nSOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \n", + "used_by": [ + { "crate": { "name": "subtle", "version": "2.6.1" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "Copyright (c) . \n\nRedistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "BSD-3-Clause", + "name": "BSD 3-Clause \"New\" or \"Revised\" License", + "text": "Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n1. Redistributions of source code must retain the above copyright notice, this\n list of conditions and the following disclaimer.\n\n2. Redistributions in binary form must reproduce the above copyright notice,\n this list of conditions and the following disclaimer in the documentation\n and/or other materials provided with the distribution.\n\n3. Neither the name of the copyright holder nor the names of its\n contributors may be used to endorse or promote products derived from\n this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n", + "used_by": [ + { "crate": { "name": "encoding_rs", "version": "0.8.41" } } + ] + }, + { + "id": "CDLA-Permissive-2.0", + "name": "Community Data License Agreement Permissive 2.0", + "text": "# Community Data License Agreement - Permissive - Version 2.0\n\nThis is the Community Data License Agreement - Permissive, Version\n2.0 (the \"agreement\"). Data Provider(s) and Data Recipient(s) agree\nas follows:\n\n## 1. Provision of the Data\n\n1.1. A Data Recipient may use, modify, and share the Data made\navailable by Data Provider(s) under this agreement if that Data\nRecipient follows the terms of this agreement.\n\n1.2. This agreement does not impose any restriction on a Data\nRecipient's use, modification, or sharing of any portions of the\nData that are in the public domain or that may be used, modified,\nor shared under any other legal exception or limitation.\n\n## 2. Conditions for Sharing Data\n\n2.1. A Data Recipient may share Data, with or without modifications, so\nlong as the Data Recipient makes available the text of this agreement\nwith the shared Data.\n\n## 3. No Restrictions on Results\n\n3.1. This agreement does not impose any restriction or obligations\nwith respect to the use, modification, or sharing of Results.\n\n## 4. No Warranty; Limitation of Liability\n\n4.1. All Data Recipients receive the Data subject to the following\nterms:\n\nTHE DATA IS PROVIDED ON AN \"AS IS\" BASIS, WITHOUT REPRESENTATIONS,\nWARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED\nINCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,\nNON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.\n\nNO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT,\nINCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING\nWITHOUT LIMITATION LOST PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF\nLIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING\nNEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE DATA OR RESULTS,\nEVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.\n\n## 5. Definitions\n\n5.1. \"Data\" means the material received by a Data Recipient under\nthis agreement.\n\n5.2. \"Data Provider\" means any person who is the source of Data\nprovided under this agreement and in reliance on a Data Recipient's\nagreement to its terms.\n\n5.3. \"Data Recipient\" means any person who receives Data directly\nor indirectly from a Data Provider and agrees to the terms of this\nagreement.\n\n5.4. \"Results\" means any outcome obtained by computational analysis\nof Data, including for example machine learning models and models'\ninsights.\n", + "used_by": [ + { "crate": { "name": "webpki-roots", "version": "1.0.9" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "// Copyright 2015-2016 Brian Smith.\n//\n// Permission to use, copy, modify, and/or distribute this software for any\n// purpose with or without fee is hereby granted, provided that the above\n// copyright notice and this permission notice appear in all copies.\n//\n// THE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHORS DISCLAIM ALL WARRANTIES\n// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\n// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR\n// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\n// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\n// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\n// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "untrusted", "version": "0.9.0" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "Except as otherwise noted, this project is licensed under the following\n(ISC-style) terms:\n\nCopyright 2015 Brian Smith.\n\nPermission to use, copy, modify, and/or distribute this software for any\npurpose with or without fee is hereby granted, provided that the above\ncopyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND THE AUTHORS DISCLAIM ALL WARRANTIES\nWITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF\nMERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR\nANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES\nWHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN\nACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF\nOR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n\nThe files under third-party/chromium are licensed as described in\nthird-party/chromium/LICENSE.\n", + "used_by": [ + { "crate": { "name": "rustls-webpki", "version": "0.103.15" } } + ] + }, + { + "id": "ISC", + "name": "ISC License", + "text": "ISC License:\n\nCopyright (c) 2004-2010 by Internet Systems Consortium, Inc. (\"ISC\")\nCopyright (c) 1995-2003 by Internet Software Consortium\n\nPermission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-rs", "version": "1.18.1" } }, + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "# The MIT License (MIT)\n\nCopyright (c) 2014 Santiago Lapresta and contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.", + "used_by": [ + { "crate": { "name": "dotenvy", "version": "0.15.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "===============================================================================\n\nCopyright (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nLicensed under the Apache License, Version 2.0 (the \"License\");\nyou may not use this file except in compliance with the License.\nYou may obtain a copy of the License at\n\n\thttp://www.apache.org/licenses/LICENSE-2.0\n\nUnless required by applicable law or agreed to in writing, software\ndistributed under the License is distributed on an \"AS IS\" BASIS,\nWITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\nSee the License for the specific language governing permissions and\nlimitations under the License.\n\n===============================================================================\n\nCopyright (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "atomic-waker", "version": "1.1.2" } }, + { "crate": { "name": "futures-lite", "version": "2.6.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (C) SQLx Contributors\nPortions of this work Copyright (C) LaunchBadge, LLC\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "sqlx", "version": "0.9.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2006-2009 Graydon Hoare\nCopyright (c) 2009-2013 Mozilla Foundation\nCopyright (c) 2016 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "md-5", "version": "0.10.6" } }, + { "crate": { "name": "sha2", "version": "0.10.9" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2010 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "lazy_static", "version": "1.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2013-2014 The Rust Project Developers.\nCopyright (c) 2015-2020 The rust-hex Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hex", "version": "0.4.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2013-2016 The rust-url developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "form_urlencoded", "version": "1.2.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2013-2025 The rust-url developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "idna", "version": "1.1.0" } }, + { "crate": { "name": "percent-encoding", "version": "2.3.2" } }, + { "crate": { "name": "url", "version": "2.5.8" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Alex Crichton\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cfg-if", "version": "1.0.4" } }, + { "crate": { "name": "futures-timer", "version": "3.0.4" } }, + { "crate": { "name": "openssl-probe", "version": "0.2.1" } }, + { "crate": { "name": "socket2", "version": "0.6.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Carl Lerche and other MIO contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "mio", "version": "1.2.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Chris Wong\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "errno", "version": "0.3.14" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 Steve \"Sc00bz\" Thomas (steve at tobtu dot com)\nCopyright (c) 2021-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "base64ct", "version": "1.8.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "bnum", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bitflags", "version": "2.13.2" } }, + { "crate": { "name": "log", "version": "0.4.34" } }, + { "crate": { "name": "num-bigint-dig", "version": "0.9.1" } }, + { "crate": { "name": "num-bigint", "version": "0.4.8" } }, + { "crate": { "name": "num-complex", "version": "0.4.6" } }, + { "crate": { "name": "num-integer", "version": "0.1.47" } }, + { "crate": { "name": "num-iter", "version": "0.1.46" } }, + { "crate": { "name": "num-rational", "version": "0.4.2" } }, + { "crate": { "name": "num-traits", "version": "0.2.19" } }, + { "crate": { "name": "num", "version": "0.4.3" } }, + { "crate": { "name": "regex-automata", "version": "0.4.18" } }, + { "crate": { "name": "regex-syntax", "version": "0.8.11" } }, + { "crate": { "name": "regex", "version": "1.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The Rust Project Developers\nCopyright (c) 2018 Ashley Mannix, Christopher Armstrong, Dylan DPC, Hunar Roop Kahlon\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "uuid", "version": "1.26.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014 The rusqlite developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "libsqlite3-sys", "version": "0.37.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014-2026 Alex Crichton\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "flate2", "version": "1.1.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2014-2026 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hyper", "version": "1.11.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "either", "version": "1.18.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "heck", "version": "0.5.0" } }, + { "crate": { "name": "unicode-bidi", "version": "0.3.18" } }, + { "crate": { "name": "unicode-normalization", "version": "0.1.25" } }, + { "crate": { "name": "unicode-properties", "version": "0.1.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 The rust-openssl-verify Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "fallible-iterator", "version": "0.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015 The unicode-categories Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "unicode_categories", "version": "0.1.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015-2018 Vlad Filippov\nCopyright (c) 2018-2021 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hkdf", "version": "0.12.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2015-2025 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "httparse", "version": "1.10.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Alex Crichton\nCopyright (c) 2017 The Tokio Authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "futures-channel", "version": "0.3.34" } }, + { "crate": { "name": "futures-core", "version": "0.3.34" } }, + { "crate": { "name": "futures-executor", "version": "0.3.34" } }, + { "crate": { "name": "futures-io", "version": "0.3.34" } }, + { "crate": { "name": "futures-macro", "version": "0.3.34" } }, + { "crate": { "name": "futures-sink", "version": "0.3.34" } }, + { "crate": { "name": "futures-task", "version": "0.3.34" } }, + { "crate": { "name": "futures-util", "version": "0.3.34" } }, + { "crate": { "name": "futures", "version": "0.3.34" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Amanieu d'Antras\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hashbrown", "version": "0.16.1" } }, + { "crate": { "name": "hashbrown", "version": "0.17.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 Joseph Birr-Pixton \n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hyper-rustls", "version": "0.27.9" } }, + { "crate": { "name": "rustls-native-certs", "version": "0.8.4" } }, + { "crate": { "name": "rustls", "version": "0.23.45" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "lock_api", "version": "0.4.14" } }, + { "crate": { "name": "parking_lot", "version": "0.12.5" } }, + { "crate": { "name": "parking_lot_core", "version": "0.9.12" } }, + { "crate": { "name": "thread_local", "version": "1.1.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016--2017\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "indexmap", "version": "2.14.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016--2023\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "equivalent", "version": "1.0.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2019 Ulrik Sverdrup \"bluss\" and scopeguard developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "scopeguard", "version": "1.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2020 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cipher", "version": "0.4.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2016-2026 The RustCrypto Project Developers\nCopyright (c) 2016 Artyom Pavlov\nCopyright (c) 2009-2013 Mozilla Foundation\nCopyright (c) 2006-2009 Graydon Hoare\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "md-5", "version": "0.11.0" } }, + { "crate": { "name": "sha1", "version": "0.11.0" } }, + { "crate": { "name": "sha2", "version": "0.11.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "digest", "version": "0.10.7" } }, + { "crate": { "name": "hmac", "version": "0.12.1" } }, + { "crate": { "name": "hmac", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Artyom Pavlov\nCopyright (c) 2018-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "pbkdf2", "version": "0.12.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Gilad Naaman\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "memoffset", "version": "0.9.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Maik Klein\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "enumflags2_derive", "version": "0.7.12" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 Robert Grosse\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "stable_deref_trait", "version": "1.2.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 The rust-stringprep Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "stringprep", "version": "0.1.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 http-rs authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "http", "version": "1.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 quininer kel\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-rustls", "version": "0.26.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017 tokio-jsonrpc developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "signal-hook-registry", "version": "1.4.8" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017-2020 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "core_detect", "version": "1.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017-2023 Maik Klein, Maja Kądziołka\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "enumflags2", "version": "0.7.12" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2017-2025 RustCrypto Developers\nCopyright (c) 2017 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "digest", "version": "0.11.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aes", "version": "0.8.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018 Carl Lerche\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bytes", "version": "1.12.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018 The Servo Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "smallvec", "version": "1.16.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2019 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "want", "version": "0.3.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2019 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "block-buffer", "version": "0.10.4" } }, + { "crate": { "name": "block-padding", "version": "0.3.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2022 RustCrypto Developers\nCopyright (c) 2018 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cbc", "version": "0.1.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2023 Sean McArthur\nCopyright (c) 2016 Alex Crichton\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "try-lock", "version": "0.2.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "block-buffer", "version": "0.12.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "signature", "version": "3.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 The Rand Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rand_core", "version": "0.10.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "zeroize", "version": "1.9.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2018-2026 The rust-random Project Developers\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "getrandom", "version": "0.4.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Carl Lerche\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "slab", "version": "0.4.12" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Eliza Weisman\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "sharded-slab", "version": "0.1.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Eliza Weisman\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "matchers", "version": "0.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Matthias Einwag\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "futures-intrusive", "version": "0.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Nick Fitzgerald\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bumpalo", "version": "3.20.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Tokio Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tracing-attributes", "version": "0.1.31" } }, + { "crate": { "name": "tracing-core", "version": "0.1.36" } }, + { "crate": { "name": "tracing-log", "version": "0.2.0" } }, + { "crate": { "name": "tracing-serde", "version": "0.2.0" } }, + { "crate": { "name": "tracing-subscriber", "version": "0.3.23" } }, + { "crate": { "name": "tracing", "version": "0.1.44" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019 Tower Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tower-service", "version": "0.3.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019-2026 Sean McArthur & Hyper Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "http-body-util", "version": "0.1.5" } }, + { "crate": { "name": "http-body", "version": "1.1.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2019-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "chacha20", "version": "0.10.2" } }, + { "crate": { "name": "zeroize_derive", "version": "1.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020 Andrew D. Straw\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "iana-time-zone", "version": "0.1.65" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2022 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "const-oid", "version": "0.9.6" } }, + { "crate": { "name": "der_derive", "version": "0.7.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "der", "version": "0.7.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cpufeatures", "version": "0.2.17" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2020-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "const-oid", "version": "0.10.2" } }, + { "crate": { "name": "cpufeatures", "version": "0.3.1" } }, + { "crate": { "name": "der", "version": "0.8.2" } }, + { "crate": { "name": "pkcs8", "version": "0.11.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-common", "version": "0.1.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "x509-cert", "version": "0.2.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021 Tiberius Contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tiberius", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2023 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "spki", "version": "0.7.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2025 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "pem-rfc7468", "version": "1.0.0" } }, + { "crate": { "name": "pkcs1", "version": "0.8.0-rc.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2026 RustCrypto Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-common", "version": "0.2.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2021-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-bigint", "version": "0.7.5" } }, + { "crate": { "name": "spki", "version": "0.8.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2022 The RustCrypto Project Developers\nCopyright (c) 2022 Artyom Pavlov\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "inout", "version": "0.1.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2022-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cmov", "version": "0.5.4" } }, + { "crate": { "name": "hybrid-array", "version": "0.4.15" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2023 Dirkjan Ochtman \n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rustls-pki-types", "version": "1.15.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2023-2025 Sean McArthur\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "hyper-util", "version": "0.1.20" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2023-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "cpubits", "version": "0.1.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2024 Zeeshan Ali Khan & zbus contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "zbus", "version": "5.19.0" } }, + { "crate": { "name": "zbus_macros", "version": "5.19.0" } }, + { "crate": { "name": "zbus_names", "version": "4.3.4" } }, + { "crate": { "name": "zvariant", "version": "5.15.0" } }, + { "crate": { "name": "zvariant_derive", "version": "5.15.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2025-2026 The RustCrypto Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "ctutils", "version": "0.4.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) 2026 Zeeshan Ali Khan & zcheapstr contributors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "zcheapstr", "version": "1.1.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) Individual contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "toml_datetime", "version": "1.1.1+spec-1.1.0" } }, + { "crate": { "name": "toml_edit", "version": "0.25.15+spec-1.1.0" } }, + { "crate": { "name": "toml_parser", "version": "1.1.3+spec-1.1.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "libc", "version": "0.2.189" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright (c) The rust-url developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "idna_adapter", "version": "1.2.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2016 Nika Layzell\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "synstructure", "version": "0.13.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2018 Developers of the Rand project\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "bnum", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2018 Developers of the Rand project\nCopyright (c) 2014 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rand", "version": "0.10.2" } }, + { "crate": { "name": "rand", "version": "0.9.5" } }, + { "crate": { "name": "rand_core", "version": "0.9.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2019 Caleb Zulawski\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "multiversion-macros", "version": "0.9.0" } }, + { "crate": { "name": "multiversion", "version": "0.9.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2022-* Bogdan Opanchuk\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crypto-primes", "version": "0.7.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright 2023 The Fuchsia Authors\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "zerocopy-derive", "version": "0.8.57" } }, + { "crate": { "name": "zerocopy", "version": "0.8.57" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Copyright Mozilla Foundation\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "encoding_rs", "version": "0.8.41" } }, + { "crate": { "name": "multiversion_no_op", "version": "1.0.0" } }, + { "crate": { "name": "utf8_iter", "version": "1.0.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2016-2021 Diggory Blake, and other contributors.\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "field-offset", "version": "0.3.6" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 \n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "atoi", "version": "2.0.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2017 crc-rs Developers\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crc", "version": "3.4.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2018 Andrei Volnin\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "pretty-hex", "version": "0.4.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2018 Sam Rijs, Alex Crichton and contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crc32fast", "version": "1.5.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 - 2020 Matt Hunzinger\nCopyright (c) 2021 Max Inden\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "asynchronous-codec", "version": "0.7.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Akhil Velagapudi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crc-catalog", "version": "2.5.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Jasper Hugo\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-postgres-rustls", "version": "0.14.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019 Yoshua Wuyts\nCopyright (c) Tokio Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-macros", "version": "2.7.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2019-2024 iqlusion\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "secrecy", "version": "0.10.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2020 Bilal Elmoussaoui\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "ashpd", "version": "0.13.13" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2022 1Password\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "rustls-platform-verifier", "version": "0.7.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2022 Bilal Elmoussaoui, Sophie Herold, Maximiliano Sandoval, Dhanuka Warusadura\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "oo7", "version": "0.6.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) 2022 Isaac Holt\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "bnum", "version": "0.13.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) \n\nPermission is hereby granted, free of charge, to any person obtaining a copy of this software and\nassociated documentation files (the \"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the\nfollowing conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial\nportions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT\nLIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO\nEVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE\nUSE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "chrono", "version": "0.4.45" } }, + { "crate": { "name": "clickhouse-macros", "version": "0.3.0" } }, + { "crate": { "name": "clickhouse-types", "version": "0.1.3" } }, + { "crate": { "name": "libgssapi-sys", "version": "0.3.4" } }, + { "crate": { "name": "libgssapi", "version": "0.11.0" } }, + { "crate": { "name": "libm", "version": "0.2.16" } }, + { "crate": { "name": "rust_xlsxwriter", "version": "0.99.1" } }, + { "crate": { "name": "sqlx-core", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-macros-core", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-macros", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-mysql", "version": "0.9.0" } }, + { "crate": { "name": "sqlx-sqlite", "version": "0.9.0" } }, + { "crate": { "name": "tiberius-macros", "version": "0.1.0" } }, + { "crate": { "name": "whoami", "version": "2.1.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) Tokio Contributors\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tokio-stream", "version": "0.1.19" } }, + { "crate": { "name": "tokio-util", "version": "0.7.19" } }, + { "crate": { "name": "tokio", "version": "1.53.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nCopyright (c) [2021] [Marvin Countryman]\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "simd-adler32", "version": "0.3.10" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "simdutf8", "version": "0.1.5" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "MIT License\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "half", "version": "2.7.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "async-recursion", "version": "1.1.1" } }, + { "crate": { "name": "flume", "version": "0.12.0" } }, + { "crate": { "name": "rsa", "version": "0.10.0-rc.18" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "async-trait", "version": "0.1.92" } }, + { "crate": { "name": "atomic-waker", "version": "1.1.2" } }, + { "crate": { "name": "clickhouse", "version": "0.15.2" } }, + { "crate": { "name": "displaydoc", "version": "0.2.7" } }, + { "crate": { "name": "endi", "version": "1.1.1" } }, + { "crate": { "name": "event-listener-strategy", "version": "0.5.4" } }, + { "crate": { "name": "event-listener", "version": "5.4.2" } }, + { "crate": { "name": "fastrand", "version": "2.5.0" } }, + { "crate": { "name": "futures-lite", "version": "2.6.1" } }, + { "crate": { "name": "itoa", "version": "1.0.18" } }, + { "crate": { "name": "linux-raw-sys", "version": "0.12.1" } }, + { "crate": { "name": "once_cell", "version": "1.21.4" } }, + { "crate": { "name": "ordered-stream", "version": "0.2.0" } }, + { "crate": { "name": "parking", "version": "2.2.1" } }, + { "crate": { "name": "pin-project-lite", "version": "0.2.17" } }, + { "crate": { "name": "proc-macro-crate", "version": "3.5.0" } }, + { "crate": { "name": "proc-macro2", "version": "1.0.107" } }, + { "crate": { "name": "quote", "version": "1.0.47" } }, + { "crate": { "name": "relm4-macros", "version": "0.11.0" } }, + { "crate": { "name": "relm4", "version": "0.11.0" } }, + { "crate": { "name": "rustix", "version": "1.1.4" } }, + { "crate": { "name": "rustversion", "version": "1.0.23" } }, + { "crate": { "name": "serde", "version": "1.0.229" } }, + { "crate": { "name": "serde_bytes", "version": "0.11.19" } }, + { "crate": { "name": "serde_core", "version": "1.0.229" } }, + { "crate": { "name": "serde_derive", "version": "1.0.229" } }, + { "crate": { "name": "serde_derive_internals", "version": "0.29.1" } }, + { "crate": { "name": "serde_json", "version": "1.0.151" } }, + { "crate": { "name": "serde_repr", "version": "0.1.21" } }, + { "crate": { "name": "sqlformat", "version": "0.5.0" } }, + { "crate": { "name": "syn", "version": "2.0.119" } }, + { "crate": { "name": "syn", "version": "3.0.5" } }, + { "crate": { "name": "thiserror-impl", "version": "2.0.20" } }, + { "crate": { "name": "thiserror", "version": "2.0.20" } }, + { "crate": { "name": "typed-path", "version": "0.12.3" } }, + { "crate": { "name": "unicode-ident", "version": "1.0.24" } }, + { "crate": { "name": "zmij", "version": "1.0.23" } }, + { "crate": { "name": "zvariant_utils", "version": "4.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any\r\nperson obtaining a copy of this software and associated\r\ndocumentation files (the \"Software\"), to deal in the\r\nSoftware without restriction, including without\r\nlimitation the rights to use, copy, modify, merge,\r\npublish, distribute, sublicense, and/or sell copies of\r\nthe Software, and to permit persons to whom the Software\r\nis furnished to do so, subject to the following\r\nconditions:\r\n\r\nThe above copyright notice and this permission notice\r\nshall be included in all copies or substantial portions\r\nof the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\r\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\r\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\r\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\r\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\r\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\r\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\r\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\r\nDEALINGS IN THE SOFTWARE.\r\n", + "used_by": [ + { "crate": { "name": "allocator-api2", "version": "0.2.21" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining\na copy of this software and associated documentation files (the\n\"Software\"), to deal in the Software without restriction, including\nwithout limitation the rights to use, copy, modify, merge, publish,\ndistribute, sublicense, and/or sell copies of the Software, and to\npermit persons to whom the Software is furnished to do so, subject to\nthe following conditions:\n\nThe above copyright notice and this permission notice shall be\nincluded in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND,\nEXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND\nNONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE\nLIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION\nWITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "winnow", "version": "0.7.15" } }, + { "crate": { "name": "winnow", "version": "1.0.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "siphasher", "version": "1.0.4" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "cairo-rs", "version": "0.22.9" } }, + { "crate": { "name": "cairo-sys-rs", "version": "0.22.9" } }, + { "crate": { "name": "gdk-pixbuf-sys", "version": "0.22.9" } }, + { "crate": { "name": "gdk-pixbuf", "version": "0.22.0" } }, + { "crate": { "name": "gdk4-sys", "version": "0.11.4" } }, + { "crate": { "name": "gdk4", "version": "0.11.4" } }, + { "crate": { "name": "gio-sys", "version": "0.22.9" } }, + { "crate": { "name": "gio-unix-sys", "version": "0.22.9" } }, + { "crate": { "name": "gio-unix", "version": "0.22.8" } }, + { "crate": { "name": "gio", "version": "0.22.9" } }, + { "crate": { "name": "glib-macros", "version": "0.22.9" } }, + { "crate": { "name": "glib-sys", "version": "0.22.9" } }, + { "crate": { "name": "glib", "version": "0.22.9" } }, + { "crate": { "name": "gobject-sys", "version": "0.22.9" } }, + { "crate": { "name": "graphene-rs", "version": "0.22.8" } }, + { "crate": { "name": "graphene-sys", "version": "0.22.9" } }, + { "crate": { "name": "gsk4-sys", "version": "0.11.4" } }, + { "crate": { "name": "gsk4", "version": "0.11.4" } }, + { "crate": { "name": "gtk4-macros", "version": "0.11.4" } }, + { "crate": { "name": "gtk4-sys", "version": "0.11.4" } }, + { "crate": { "name": "gtk4", "version": "0.11.4" } }, + { "crate": { "name": "libadwaita-sys", "version": "0.9.2" } }, + { "crate": { "name": "libadwaita", "version": "0.9.2" } }, + { "crate": { "name": "pango-sys", "version": "0.22.9" } }, + { "crate": { "name": "pango", "version": "0.22.9" } }, + { "crate": { "name": "sourceview5-sys", "version": "0.11.2" } }, + { "crate": { "name": "sourceview5", "version": "0.11.2" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the \"Software\"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tinyvec", "version": "1.13.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Mathijs van de Nes\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "spin", "version": "0.9.9" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Mathijs van de Nes\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "tracing-core", "version": "0.1.36" } }, + { "crate": { "name": "zip", "version": "8.6.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014 Paho Lurie-Gregg\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "typenum", "version": "1.20.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2014-2022 Steven Fackler, Yuki Okushi\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "phf", "version": "0.13.1" } }, + { "crate": { "name": "phf_shared", "version": "0.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Alice Maz\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "base64", "version": "0.22.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015 Andrew Gallant\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aho-corasick", "version": "1.1.5" } }, + { "crate": { "name": "byteorder", "version": "1.5.0" } }, + { "crate": { "name": "csv-core", "version": "0.1.13" } }, + { "crate": { "name": "csv", "version": "1.4.0" } }, + { "crate": { "name": "memchr", "version": "2.8.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2015-2020 the fiat-crypto authors (see\nhttps://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS).\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "aws-lc-sys", "version": "0.45.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016 Konstantin V. Salikhov\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "gettext-rs", "version": "0.8.0" } }, + { "crate": { "name": "gettext-sys", "version": "0.26.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016 Steven Fackler\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "postgres-protocol", "version": "0.6.12" } }, + { "crate": { "name": "postgres-types", "version": "0.2.14" } }, + { "crate": { "name": "tokio-postgres", "version": "0.7.18" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2016–2019 Jan Hudec \nCopyright (c) 2016 A.J. Gardner \nCopyright (c) 2019, Bastien Orivel \nCopyright (c) 2019, Igor Gnatenko \nCopyright (c) 2019, Sophie Tauchert <999eagle@999eagle.moe>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "locale_config", "version": "0.3.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2018-2019 Andrew Gallant\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "bstr", "version": "1.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2019 Michael P. Jung\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\n", + "used_by": [ + { "crate": { "name": "deadpool-postgres", "version": "0.14.2" } }, + { "crate": { "name": "deadpool-runtime", "version": "0.3.1" } }, + { "crate": { "name": "deadpool", "version": "0.13.1" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2019 The Crossbeam Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.\n", + "used_by": [ + { "crate": { "name": "crossbeam-queue", "version": "0.3.14" } }, + { "crate": { "name": "crossbeam-utils", "version": "0.8.23" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\n\nCopyright (c) 2020 Yoshua Wuyts\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n", + "used_by": [ + { "crate": { "name": "async-broadcast", "version": "0.7.2" } }, + { "crate": { "name": "connection-string", "version": "0.2.0" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\r\n\r\nCopyright (c) 2014 Benjamin Sago\r\nCopyright (c) 2021-2022 The Nushell Project Developers\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.\r\n", + "used_by": [ + { "crate": { "name": "nu-ansi-term", "version": "0.50.3" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "The MIT License (MIT)\r\n\r\nCopyright (c) 2015 Bartłomiej Kamiński\r\n\r\nPermission is hereby granted, free of charge, to any person obtaining a copy\r\nof this software and associated documentation files (the \"Software\"), to deal\r\nin the Software without restriction, including without limitation the rights\r\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\r\ncopies of the Software, and to permit persons to whom the Software is\r\nfurnished to do so, subject to the following conditions:\r\n\r\nThe above copyright notice and this permission notice shall be included in all\r\ncopies or substantial portions of the Software.\r\n\r\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\r\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\r\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\r\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\r\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\r\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\r\nSOFTWARE.", + "used_by": [ + { "crate": { "name": "generic-array", "version": "0.14.7" } } + ] + }, + { + "id": "MIT", + "name": "MIT License", + "text": "This work is derived in part from the `linked-hash-map` crate, Copyright (c)\n2015 The Rust Project Developers\n\nPermission is hereby granted, free of charge, to any\nperson obtaining a copy of this software and associated\ndocumentation files (the \"Software\"), to deal in the\nSoftware without restriction, including without\nlimitation the rights to use, copy, modify, merge,\npublish, distribute, sublicense, and/or sell copies of\nthe Software, and to permit persons to whom the Software\nis furnished to do so, subject to the following\nconditions:\n\nThe above copyright notice and this permission notice\nshall be included in all copies or substantial portions\nof the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF\nANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED\nTO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A\nPARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT\nSHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY\nCLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION\nOF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR\nIN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER\nDEALINGS IN THE SOFTWARE.", + "used_by": [ + { "crate": { "name": "hashlink", "version": "0.11.1" } } + ] + }, + { + "id": "Unicode-3.0", + "name": "Unicode License v3", + "text": "UNICODE LICENSE V3\n\nCOPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 1991-2023 Unicode, Inc.\n\nNOTICE TO USER: Carefully read the following legal agreement. BY\nDOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of data files and any associated documentation (the \"Data Files\") or\nsoftware and any associated documentation (the \"Software\") to deal in the\nData Files or Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, and/or sell\ncopies of the Data Files or Software, and to permit persons to whom the\nData Files or Software are furnished to do so, provided that either (a)\nthis copyright and permission notice appear with all copies of the Data\nFiles or Software, or (b) this copyright and permission notice appear in\nassociated Documentation.\n\nTHE DATA FILES AND SOFTWARE ARE PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY\nKIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained in this notice, the name of a copyright holder shall\nnot be used in advertising or otherwise to promote the sale, use or other\ndealings in these Data Files or Software without prior written\nauthorization of the copyright holder.\n", + "used_by": [ + { "crate": { "name": "unicode-ident", "version": "1.0.24" } } + ] + }, + { + "id": "Unicode-3.0", + "name": "Unicode License v3", + "text": "UNICODE LICENSE V3\n\nCOPYRIGHT AND PERMISSION NOTICE\n\nCopyright © 2020-2024 Unicode, Inc.\n\nNOTICE TO USER: Carefully read the following legal agreement. BY\nDOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR\nSOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE\nTERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT\nDOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.\n\nPermission is hereby granted, free of charge, to any person obtaining a\ncopy of data files and any associated documentation (the \"Data Files\") or\nsoftware and any associated documentation (the \"Software\") to deal in the\nData Files or Software without restriction, including without limitation\nthe rights to use, copy, modify, merge, publish, distribute, and/or sell\ncopies of the Data Files or Software, and to permit persons to whom the\nData Files or Software are furnished to do so, provided that either (a)\nthis copyright and permission notice appear with all copies of the Data\nFiles or Software, or (b) this copyright and permission notice appear in\nassociated Documentation.\n\nTHE DATA FILES AND SOFTWARE ARE PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY\nKIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF\nMERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF\nTHIRD PARTY RIGHTS.\n\nIN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE\nBE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,\nOR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,\nWHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,\nARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA\nFILES OR SOFTWARE.\n\nExcept as contained in this notice, the name of a copyright holder shall\nnot be used in advertising or otherwise to promote the sale, use or other\ndealings in these Data Files or Software without prior written\nauthorization of the copyright holder.\n\nSPDX-License-Identifier: Unicode-3.0\n\n—\n\nPortions of ICU4X may have been adapted from ICU4C and/or ICU4J.\nICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.\n", + "used_by": [ + { "crate": { "name": "icu_collections", "version": "2.3.0" } }, + { "crate": { "name": "icu_locale_core", "version": "2.3.0" } }, + { "crate": { "name": "icu_normalizer", "version": "2.3.0" } }, + { "crate": { "name": "icu_normalizer_data", "version": "2.3.0" } }, + { "crate": { "name": "icu_properties", "version": "2.3.0" } }, + { "crate": { "name": "icu_properties_data", "version": "2.3.0" } }, + { "crate": { "name": "icu_provider", "version": "2.3.1" } }, + { "crate": { "name": "litemap", "version": "0.8.3" } }, + { "crate": { "name": "potential_utf", "version": "0.1.6" } }, + { "crate": { "name": "tinystr", "version": "0.8.4" } }, + { "crate": { "name": "writeable", "version": "0.6.4" } }, + { "crate": { "name": "yoke-derive", "version": "0.8.2" } }, + { "crate": { "name": "yoke", "version": "0.8.3" } }, + { "crate": { "name": "zerofrom-derive", "version": "0.1.7" } }, + { "crate": { "name": "zerofrom", "version": "0.1.8" } }, + { "crate": { "name": "zerotrie", "version": "0.2.5" } }, + { "crate": { "name": "zerovec-derive", "version": "0.11.6" } }, + { "crate": { "name": "zerovec", "version": "0.11.8" } } + ] + }, + { + "id": "Zlib", + "name": "zlib License", + "text": "(C) 2024 Trifecta Tech Foundation \n\nThis software is provided 'as-is', without any express or implied\nwarranty. In no event will the authors be held liable for any damages\narising from the use of this software.\n\nPermission is granted to anyone to use this software for any purpose,\nincluding commercial applications, and to alter it and redistribute it\nfreely, subject to the following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not\n claim that you wrote the original software. If you use this software\n in a product, an acknowledgment in the product documentation would be\n appreciated but is not required.\n\n2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n\n3. This notice may not be removed or altered from any source distribution.\n", + "used_by": [ + { "crate": { "name": "zlib-rs", "version": "0.6.7" } } + ] + }, + { + "id": "Zlib", + "name": "zlib License", + "text": "Copyright (c) 2024 Orson Peters\n\nThis software is provided 'as-is', without any express or implied warranty. In\nno event will the authors be held liable for any damages arising from the use of\nthis software.\n\nPermission is granted to anyone to use this software for any purpose, including\ncommercial applications, and to alter it and redistribute it freely, subject to\nthe following restrictions:\n\n1. The origin of this software must not be misrepresented; you must not claim\n that you wrote the original software. If you use this software in a product,\n an acknowledgment in the product documentation would be appreciated but is\n not required.\n\n2. Altered source versions must be plainly marked as such, and must not be\n misrepresented as being the original software.\n\n3. This notice may not be removed or altered from any source distribution.", + "used_by": [ + { "crate": { "name": "foldhash", "version": "0.2.0" } } + ] + } + ] +} diff --git a/linux/data/meson.build b/linux/data/meson.build index 0d776c6b54..4bdc80dec8 100644 --- a/linux/data/meson.build +++ b/linux/data/meson.build @@ -68,6 +68,8 @@ install_data( install_data( meson.project_source_root() / 'LICENSE', + 'licenses' / 'THIRD-PARTY-NOTICE.md', + 'licenses' / 'third-party.json', install_dir: get_option('datadir') / 'licenses' / application_id, ) diff --git a/linux/data/resources/resources.gresource.xml b/linux/data/resources/resources.gresource.xml index 002ce77733..e851cf9302 100644 --- a/linux/data/resources/resources.gresource.xml +++ b/linux/data/resources/resources.gresource.xml @@ -4,6 +4,8 @@ style.css metainfo.xml + + ../licenses/third-party.json shortcuts-dialog.ui ui/browse-tab.ui ui/connect-dialog.ui diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 2a937a59f1..f411160195 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -4,12 +4,14 @@ crates/app/src/application/app_services.rs crates/app/src/application/app_window_host.rs crates/app/src/application/debug_info.rs crates/app/src/application/imp.rs +crates/app/src/application/legal_section.rs crates/app/src/application/mod.rs crates/app/src/application/open_files.rs crates/app/src/application/quit_sequence.rs crates/app/src/application/runtime_versions.rs crates/app/src/application/session_signals.rs crates/app/src/application/tests.rs +crates/app/src/application/third_party_licenses.rs crates/app/src/application/window_host.rs crates/app/src/application/window_launch.rs crates/app/src/application/window_registry.rs From a1287c3870e5b0f385571ce6fbe9f7083c9a1b56 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 14 Oct 2026 16:47:36 +0700 Subject: [PATCH 123/301] fix(linux): keep loaded structure on screen when a structure read fails --- linux/CHANGELOG.md | 5 +- linux/crates/app/src/ui/structure_tab/imp.rs | 5 + linux/crates/app/src/ui/structure_tab/mod.rs | 174 +++++++++++------- .../src/ui/structure_tab/structure_command.rs | 4 +- .../src/ui/structure_tab/structure_fetch.rs | 105 +++++++++++ .../crates/app/src/ui/structure_tab/tests.rs | 160 +++++++++++++++- .../app/src/ui/tab_load/load_failure.rs | 8 +- .../app/src/ui/workspace/workspace_tabs.rs | 2 + linux/data/resources/ui/structure-tab.ui | 10 + linux/docs/state-management.md | 2 +- linux/po/POTFILES.in | 1 + 11 files changed, 397 insertions(+), 79 deletions(-) create mode 100644 linux/crates/app/src/ui/structure_tab/structure_fetch.rs diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 580c1cd0bd..81ee7878aa 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -33,8 +33,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Screen readers say when a grid cell is modified and when a row is new or marked for deletion. - Connections open when TablePro quits open again at the next launch; Restore session in Preferences turns it off. - Save query history in Preferences turns the query history off and keeps the entries already saved. -- Try Again on a table tab's error page, and Retry on a banner over rows a refresh could not read again. -- Table tabs show a spinner in the tab bar while they read. +- Try Again on a table tab's error page, and Retry on a banner over rows or a structure that could not be read again. +- Table and structure tabs show a spinner in the tab bar while they read. ### Changed @@ -57,6 +57,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Denser grid rows, with numbers aligned right in even-width digits at the precision their column declares. - A grid value too long to draw says how many bytes it leaves out, and its tooltip points to Show Row as JSON. - Refresh keeps a table's rows, selection and focused cell on screen while it reads the table again. +- A structure tab keeps the table's structure on screen while it reads it again, a save included. ### Removed diff --git a/linux/crates/app/src/ui/structure_tab/imp.rs b/linux/crates/app/src/ui/structure_tab/imp.rs index bde7daad35..df9a237058 100644 --- a/linux/crates/app/src/ui/structure_tab/imp.rs +++ b/linux/crates/app/src/ui/structure_tab/imp.rs @@ -16,6 +16,8 @@ use super::{StructureModel, StructureTabInput}; #[derive(Default, gtk::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/structure-tab.ui")] pub struct StructureTab { + #[template_child] + pub stale_banner: TemplateChild, #[template_child] pub inner_stack: TemplateChild, #[template_child] @@ -104,6 +106,9 @@ impl ObjectImpl for StructureTab { .param_types([String::static_type(), String::static_type()]) .build(), Signal::builder("toast").param_types([String::static_type()]).build(), + Signal::builder("loading-changed") + .param_types([bool::static_type()]) + .build(), Signal::builder("alert") .param_types([String::static_type(), String::static_type()]) .build(), diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 9b1e950759..ebc7e19fe4 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -33,7 +33,9 @@ //! and reads it again once the session reconnects if the last read //! failed. A change to the table from elsewhere reads it again too, //! unless the draft has edits: then Discard reads it, and a plan that -//! writes the table again from the old definition refuses to save. +//! writes the table again from the old definition refuses to save. A +//! read that fails with a definition on screen leaves it there under a +//! banner with Retry. mod columns; mod context; @@ -52,6 +54,7 @@ mod indexes; mod referential_action_label; pub(crate) mod review_dialog; mod structure_command; +mod structure_fetch; mod table_choice_object; #[cfg(test)] mod tests; @@ -71,7 +74,7 @@ use tablepro_core::ddl::{DraftColumn, DraftType, TableChanges, TableDraft, diff_ use tablepro_core::meta::TableRef; use tablepro_core::{ForeignKeyInfo, IndexInfo}; use tablepro_session::runtime::{Tasks, WatchForwarder}; -use tablepro_session::{LinkGeneration, OperationError, OperationKind, Session}; +use tablepro_session::{LinkGeneration, Session}; use self::columns::build_column_row; use self::context::StructureContext; @@ -83,11 +86,13 @@ use self::index_dialog::IndexDialog; use self::index_object::IndexObject; use self::indexes::build_index_row; pub(crate) use self::structure_command::{LoadedStructure, StructureCommand, StructureRequestKind}; +use self::structure_fetch::fetch_structure; use crate::runtime::{FailedRequests, RequestSequencer, RequestTicket, Stamped}; use crate::services::pending_structure::PendingStructure; use crate::services::structure_tracker_handle::StructureTrackerHandle; use crate::ui::error_text::{build_error_text, save_blocker_text}; use crate::ui::handler_guard::HandlerGuard; +use crate::ui::tab_load::{ContentLoad, ContentPage, LoadFailure, LoadPresentation, StaleBanner}; glib::wrapper! { pub struct StructureTab(ObjectSubclass) @@ -168,6 +173,10 @@ impl StructureTab { StructureMode::Edit => "loading", }); let name_changed = tab.connect_widgets(); + let sender = tab.sender(); + let stale_banner = StaleBanner::new(imp.stale_banner.get(), move || { + sender.input(StructureTabInput::Retry); + }); let context = Rc::new(RefCell::new(StructureContext { mode: init.mode, @@ -226,7 +235,13 @@ impl StructureTab { saving: false, last_dirty: false, refetching: false, - definition_shown: false, + load: match init.mode { + // A new table's draft is the content from the start. + StructureMode::New => ContentLoad::Loaded, + StructureMode::Edit => ContentLoad::default(), + }, + tab_loading: false, + stale_banner, definition_stale: false, _theme_handler: theme_handler, }; @@ -346,6 +361,25 @@ impl StructureTab { ) } + /// A read of the table's definition started or ended. + pub fn connect_loading_changed(&self, f: F) -> glib::SignalHandlerId { + self.connect_closure( + "loading-changed", + false, + glib::closure_local!(move |tab: &Self, loading: bool| f(tab, loading)), + ) + } + + /// A read of the table's definition is running. + pub fn is_loading(&self) -> bool { + self.imp() + .model + .try_borrow() + .ok() + .and_then(|model| model.as_ref().map(|model| model.tab_loading)) + .unwrap_or(false) + } + fn emit_output(&self, output: StructureTabOutput) { match output { StructureTabOutput::DirtyChanged(dirty) => self.emit_by_name::<()>("dirty-changed", &[&dirty]), @@ -356,6 +390,7 @@ impl StructureTab { } StructureTabOutput::ShowToast(text) => self.emit_by_name::<()>("toast", &[&text]), StructureTabOutput::ShowAlert { title, body } => self.emit_by_name::<()>("alert", &[&title, &body]), + StructureTabOutput::LoadingChanged(loading) => self.emit_by_name::<()>("loading-changed", &[&loading]), } } @@ -557,9 +592,12 @@ pub(crate) struct StructureModel { /// pre-save state, so diffing them would surface phantom pending /// changes; `recompute_dirty_state` waits it out. refetching: bool, - /// The editor shows a definition the draft still started from, so a - /// read of it that fails can leave it on screen. - definition_shown: bool, + /// Where the read of the definition stands. A failed read with a + /// definition on screen leaves it there under the banner. + load: ContentLoad, + /// What the tab bar was last told, so it hears only changes. + tab_loading: bool, + stale_banner: StaleBanner, /// The table changed after `definition` was read, while the draft /// had edits that a read would replace. Discard reads it again /// rather than restoring it. @@ -579,7 +617,7 @@ pub(crate) enum StructureTabInput { LinkChanged(LinkGeneration), /// The table's definition changed elsewhere. SchemaChanged, - /// The error page's Try Again. + /// Try Again on the error page, or Retry on the banner. Retry, Save, /// The window started saving the tab's pending structure. @@ -656,6 +694,8 @@ pub(crate) enum StructureTabOutput { title: String, body: String, }, + /// A read of the definition started or ended, for the tab bar. + LoadingChanged(bool), } impl StructureModel { @@ -808,31 +848,51 @@ impl StructureModel { self.name_entry.unblock_signal(&self.name_changed); } - /// Read the table's definition in one call, so the tab rebuilds - /// once with columns, indexes and foreign keys from the same moment. + /// Read the table's definition. A definition on screen stays there + /// while it is read, and through a failure. + fn read_definition(&mut self, fx: &mut StructureEffects) { + let presentation = self.load.begin(); + self.present(presentation, fx); + self.fetch_definition(); + } + + /// Show where the read stands: the spinner, the editor or the error + /// page, the banner over a definition that could not be read again, + /// and the tab bar's spinner. + fn present(&mut self, presentation: LoadPresentation, fx: &mut StructureEffects) { + match &presentation.page { + ContentPage::Spinner => self.inner_stack.set_visible_child_name("loading"), + ContentPage::Content => self.inner_stack.set_visible_child_name("editor"), + ContentPage::Error(failure) => { + // Inline on the status page: a modal alert would say the + // same and make the user dismiss it before seeing the + // page. The page reads its description as markup. + self.error_status + .set_description(Some(&glib::markup_escape_text(&failure.message()))); + self.inner_stack.set_visible_child_name("error"); + } + } + self.stale_banner.show(presentation.stale.as_ref()); + if self.tab_loading != presentation.tab_loading { + self.tab_loading = presentation.tab_loading; + fx.output(StructureTabOutput::LoadingChanged(presentation.tab_loading)); + } + } + + /// One read brings columns, indexes and foreign keys from the same + /// moment, so the tab rebuilds once. fn fetch_definition(&mut self) { self.issued_on .insert(StructureRequestKind::Definition, self.session.link_generation()); let RequestTicket { stamp, cancel } = self.sequencer.issue(StructureRequestKind::Definition); - let session = self.session.clone(); - let table = self.table.clone(); - let work = async move { - session - .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { - let table = table.clone(); - async move { - let definition = lease.fetch_table_definition(&table, options).await?; - Ok(LoadedStructure { - definition, - dialect: lease.dialect(), - }) - } - }) - .await - }; + let work = fetch_structure(self.session.clone(), self.table.clone(), cancel); let tab = self.this.clone(); let _delivery = crate::runtime::spawn_and_deliver(&self.tasks, work, move |joined| { - let value = joined.unwrap_or_else(|failure| Err(OperationError::from(failure))); + let value = joined.unwrap_or_else(|failure| { + Err(LoadFailure::Definition(Arc::new( + tablepro_session::OperationError::from(failure), + ))) + }); if let Some(tab) = tab.upgrade() { tab.send(StructureTabInput::Command(StructureCommand::Loaded(Stamped { stamp, @@ -842,10 +902,6 @@ impl StructureModel { }); } - fn show_loading(&self) { - self.inner_stack.set_visible_child_name("loading"); - } - fn on_loaded(&mut self, loaded: LoadedStructure, fx: &mut StructureEffects) { let draft = TableDraft::from_definition(&loaded.definition); { @@ -855,8 +911,8 @@ impl StructureModel { } self.show_draft(draft); self.sync_add_rows(); - self.inner_stack.set_visible_child_name("editor"); - self.definition_shown = true; + let presentation = self.load.succeed(); + self.present(presentation, fx); self.definition_stale = false; // The definition is current again, so the diff counts. self.refetching = false; @@ -864,19 +920,17 @@ impl StructureModel { self.recompute_dirty_state(fx); } - fn on_load_failed(&mut self, message: &str, fx: &mut StructureEffects) { - if self.definition_shown { - self.inner_stack.set_visible_child_name("editor"); - fx.output(StructureTabOutput::ShowToast(crate::i18n::gettext_f( - "Couldn't refresh the structure: {error}", - &[("error", message)], - ))); + /// With a definition on screen it stays, under the banner. A read + /// after a committed save keeps the draft locked: the definition on + /// screen is from before the save, so the draft's diff against it + /// would offer the saved changes again. + fn on_load_failed(&mut self, failure: LoadFailure, fx: &mut StructureEffects) { + let presentation = self.load.fail(failure); + let content_kept = !matches!(presentation.page, ContentPage::Error(_)); + self.present(presentation, fx); + if content_kept { return; } - // Inline on the status page: a modal alert would say the same - // and make the user dismiss it before seeing the page. - self.error_status.set_description(Some(message)); - self.inner_stack.set_visible_child_name("error"); // A retry from the error page must not find dirty tracking still // frozen. self.refetching = false; @@ -891,11 +945,11 @@ impl StructureModel { self.failed.record(StructureRequestKind::Definition, &value); match value { Ok(loaded) => self.on_loaded(loaded, fx), - Err(error) => { - self.on_load_failed(&crate::ui::error_text::operation_error_message(&error), fx); + Err(failure) => { + self.on_load_failed(failure, fx); let issued = self.issued_on.get(&StructureRequestKind::Definition).copied(); if issued.is_some_and(|issued| self.session.link_generation() > issued) { - self.retry_failed(); + self.retry_failed(fx); } } } @@ -914,15 +968,12 @@ impl StructureModel { self.sync_add_rows(); self.recompute_dirty_state(fx); } - self.retry_failed(); + self.retry_failed(fx); } - fn retry_failed(&mut self) { + fn retry_failed(&mut self, fx: &mut StructureEffects) { if self.failed.take().contains(&StructureRequestKind::Definition) { - if !self.definition_shown { - self.show_loading(); - } - self.fetch_definition(); + self.read_definition(fx); } } @@ -934,7 +985,7 @@ impl StructureModel { match msg { StructureTabInput::Start => { if self.mode() == StructureMode::Edit { - self.fetch_definition(); + self.read_definition(fx); } self.sync_add_rows(); self.regenerate_sql_preview(&self.pending()); @@ -947,16 +998,11 @@ impl StructureModel { if self.mode() == StructureMode::Edit { match self.last_dirty { true => self.definition_stale = true, - false => self.fetch_definition(), + false => self.read_definition(fx), } } } - StructureTabInput::Retry => { - // Without the loading page the click reads as a no-op - // until the reply arrives. - self.show_loading(); - self.fetch_definition(); - } + StructureTabInput::Retry => self.read_definition(fx), StructureTabInput::TableNameEdited(text) => { if self.table.name == text { return; @@ -1009,10 +1055,7 @@ impl StructureModel { // A table the save may have created has nothing to read // yet, so a new one is only cleared. match self.mode() { - StructureMode::Edit => { - self.show_loading(); - self.fetch_definition(); - } + StructureMode::Edit => self.read_definition(fx), StructureMode::New => fx.input(StructureTabInput::Discard), } } @@ -1217,8 +1260,7 @@ impl StructureModel { } self.refetching = true; self.sync_editable(); - self.definition_shown = false; - self.fetch_definition(); + self.read_definition(fx); } } diff --git a/linux/crates/app/src/ui/structure_tab/structure_command.rs b/linux/crates/app/src/ui/structure_tab/structure_command.rs index 4da5eeb6bb..7abf8cabe9 100644 --- a/linux/crates/app/src/ui/structure_tab/structure_command.rs +++ b/linux/crates/app/src/ui/structure_tab/structure_command.rs @@ -2,9 +2,9 @@ use std::sync::Arc; use tablepro_core::ddl::TableDefinition; use tablepro_core::dialect::SqlDialect; -use tablepro_session::OperationError; use crate::runtime::Stamped; +use crate::ui::tab_load::LoadFailure; /// The one read a structure tab makes. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] @@ -24,5 +24,5 @@ pub(crate) struct LoadedStructure { /// reply to a replaced request is dropped. #[derive(Debug)] pub(crate) enum StructureCommand { - Loaded(Stamped>), + Loaded(Stamped>), } diff --git a/linux/crates/app/src/ui/structure_tab/structure_fetch.rs b/linux/crates/app/src/ui/structure_tab/structure_fetch.rs new file mode 100644 index 0000000000..67cea5dcc3 --- /dev/null +++ b/linux/crates/app/src/ui/structure_tab/structure_fetch.rs @@ -0,0 +1,105 @@ +use std::sync::Arc; + +use tablepro_core::meta::TableRef; +use tablepro_session::{OperationError, OperationKind, Session}; +use tokio_util::sync::CancellationToken; + +use super::LoadedStructure; +use crate::ui::tab_load::LoadFailure; + +/// The table's columns, indexes and foreign keys from one read, so the +/// tab never shows lists from different moments side by side, and any +/// part that fails fails the whole read rather than showing an empty +/// list as if the table had none. The read always asks the server: the +/// catalog keeps columns only, and a definition is edited against what +/// the table is now. +pub(crate) async fn fetch_structure( + session: Arc, + table: TableRef, + cancel: CancellationToken, +) -> Result { + let loaded = session + .run_cancellable(OperationKind::Metadata, &cancel, move |lease, options| { + let table = table.clone(); + async move { + let definition = lease.fetch_table_definition(&table, options).await?; + Ok(LoadedStructure { + definition, + dialect: lease.dialect(), + }) + } + }) + .await + .map_err(|error| { + if error != OperationError::Cancelled { + tracing::warn!(category = ?error.category(), "structure read failed"); + } + LoadFailure::Definition(Arc::new(error)) + })?; + match loaded.definition.columns.is_empty() { + true => Err(LoadFailure::NoColumns), + false => Ok(loaded), + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use tablepro_core::DriverError; + use tablepro_core::ddl::TableDefinition; + use tablepro_test_support::{ConnectionScript, FakeReply}; + + use super::*; + use crate::test_support::{FakeSession, finish_on_main}; + + const WAIT: Duration = Duration::from_secs(10); + + fn read(script: ConnectionScript) -> Result { + let runtime = crate::test_support::test_runtime(); + let fake = FakeSession::start(script, [], runtime.tasks()); + let running = runtime.tasks().spawn_task(fetch_structure( + fake.session.clone(), + TableRef::unqualified("users"), + CancellationToken::new(), + )); + let outcome = finish_on_main(WAIT, running).expect("the read ends"); + runtime.shutdown(); + outcome.expect("the read ran to its end") + } + + #[gtk4::test] + fn fetch_structure_fails_whole_on_foreign_key_error() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("foreign keys unreadable"))); + + let failure = read(script).expect_err("a failed read"); + + assert!(matches!(failure, LoadFailure::Definition(_)), "{failure:?}"); + assert!( + failure.message().contains("foreign keys unreadable"), + "{}", + failure.message() + ); + } + + #[gtk4::test] + fn a_definition_without_columns_is_a_failure() { + let mut script = ConnectionScript::default(); + script.fetch_table_definition.always(FakeReply::ok(TableDefinition { + table: TableRef::unqualified("users"), + columns: Vec::new(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::new(), + inbound_foreign_keys: Vec::new(), + source: None, + })); + + let failure = read(script).expect_err("a failed read"); + + assert!(matches!(failure, LoadFailure::NoColumns), "{failure:?}"); + } +} diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index 591e5475cc..6c1623ff2e 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -329,31 +329,179 @@ fn readable_once() -> ConnectionScript { script } +fn banner(harness: &Harness) -> adw::Banner { + harness.tab().imp().stale_banner.get() +} + +fn banner_tooltip(harness: &Harness) -> String { + banner(harness) + .tooltip_text() + .map(|text| text.to_string()) + .unwrap_or_default() +} + #[gtk4::test] -fn a_failed_read_after_a_schema_change_keeps_the_definition() { +fn structure_refresh_failure_keeps_editor_and_shows_banner() { let harness = Harness::open(StructureMode::Edit, readable_once(), []); wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); let toasts = toasts(harness.tab()); harness.tab().send(StructureTabInput::SchemaChanged); - wait_until(WAIT, || !toasts.borrow().is_empty()).expect("the failed read was not reported"); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); assert_eq!(harness.page().as_deref(), Some("editor")); assert_eq!(harness.column_names(), ["id"]); - assert!(toasts.borrow()[0].contains("denied"), "{:?}", toasts.borrow()); + assert!( + banner_tooltip(&harness).contains("denied"), + "{}", + banner_tooltip(&harness) + ); + assert!(toasts.borrow().is_empty(), "{:?}", toasts.borrow()); + assert!(!harness.tab().is_loading()); + assert!(harness.tab().imp().columns_box.is_sensitive()); } +/// The definition on screen is from before the save, so the draft holds +/// still until a read lands: unlocked, its diff would offer the saved +/// changes again. #[gtk4::test] -fn a_failed_read_after_a_save_shows_the_error() { +fn a_failed_read_after_a_save_keeps_the_saved_draft_locked() { let harness = Harness::open(StructureMode::Edit, readable_once(), []); wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + harness + .tab() + .send(StructureTabInput::SaveCompleted { new_table_name: None }); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + + assert_eq!(harness.page().as_deref(), Some("editor")); + assert!(!harness.tab().imp().columns_box.is_sensitive()); + assert!( + !harness.tracker().has_pending(), + "the saved changes came back as pending" + ); +} + +#[gtk4::test] +fn structure_first_load_failure_shows_error_page() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .always(FakeReply::err(DriverError::server("no table"))); + let harness = Harness::open(StructureMode::Edit, script, []); + + wait_until(WAIT, || harness.page().as_deref() == Some("error")).expect("the error page"); + + let imp = harness.tab().imp(); + let description = imp + .error_status + .description() + .map(|text| text.to_string()) + .unwrap_or_default(); + assert!(description.contains("no <such> table"), "{description}"); + assert!(imp.retry_button.is_visible()); + assert!(!banner(&harness).is_revealed()); + assert!(!harness.tab().is_loading()); +} + +#[gtk4::test] +fn index_fetch_failure_keeps_loaded_indexes() { + let indexed = TableDefinition { + indexes: vec![tablepro_core::IndexInfo { + name: "users_id_idx".to_owned(), + keys: vec![tablepro_core::meta::IndexKey::Column { + name: "id".to_owned(), + descending: false, + }], + unique: true, + primary: false, + predicate: None, + }], + ..definition("id") + }; + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(indexed)) + .always(FakeReply::err(DriverError::server("indexes unreadable"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + + harness.tab().send(StructureTabInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the failed read was not shown"); + + let indexes: Vec = harness.draft().indexes.into_iter().map(|index| index.name).collect(); + assert_eq!(indexes, ["users_id_idx"]); + assert_eq!(harness.page().as_deref(), Some("editor")); +} + +#[gtk4::test] +fn structure_retry_after_failure_hides_banner() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(definition("id"))) + .once(FakeReply::err(DriverError::server("denied"))) + .always(FakeReply::ok(definition("email"))); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + harness.tab().send(StructureTabInput::SchemaChanged); + wait_until(WAIT, || banner(&harness).is_revealed()).expect("the stale banner"); + + banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + wait_until(WAIT, || harness.column_names() == ["email"]).expect("Retry read the table again"); + assert!( + !banner(&harness).is_revealed(), + "the banner outlived the read that landed" + ); + assert_eq!( + CallCounters::get(&harness.fake.connection.counters().fetch_table_definition), + 3 + ); +} + +#[gtk4::test] +fn schema_save_shows_new_column_list() { + let mut script = ConnectionScript::default(); + script + .fetch_table_definition + .once(FakeReply::ok(TableDefinition { + columns: vec![integer_column("id", true), integer_column("name", false)], + ..definition("id") + })) + .always(FakeReply::ok(TableDefinition { + columns: vec![ + integer_column("id", true), + integer_column("name", false), + integer_column("email", false), + ], + ..definition("id") + })); + let harness = Harness::open(StructureMode::Edit, script, []); + wait_until(WAIT, || harness.page().as_deref() == Some("editor")).expect("the structure"); + let loading = Rc::new(RefCell::new(Vec::new())); + let sink = loading.clone(); + harness + .tab() + .connect_loading_changed(move |_, busy| sink.borrow_mut().push(busy)); + harness .tab() .send(StructureTabInput::SaveCompleted { new_table_name: None }); - wait_until(WAIT, || harness.page().as_deref() == Some("error")) - .expect("a definition from before the save stayed editable"); + assert_eq!( + harness.page().as_deref(), + Some("editor"), + "the read after the save blanked the tab" + ); + wait_until(WAIT, || column_rows(harness.tab()).len() == 3).expect("the columns the save left"); + assert_eq!(harness.column_names(), ["id", "name", "email"]); + assert_eq!( + CallCounters::get(&harness.fake.connection.counters().fetch_table_definition), + 2 + ); + assert_eq!(*loading.borrow(), [true, false]); } /// The control named `name` in the row the Columns list shows at diff --git a/linux/crates/app/src/ui/tab_load/load_failure.rs b/linux/crates/app/src/ui/tab_load/load_failure.rs index d4da349a91..17f743ccad 100644 --- a/linux/crates/app/src/ui/tab_load/load_failure.rs +++ b/linux/crates/app/src/ui/tab_load/load_failure.rs @@ -14,6 +14,8 @@ pub(crate) enum LoadFailure { /// and again after they were read again. ColumnsChanged, Page(Arc), + /// The table's definition, for the structure tab. + Definition(Arc), } impl LoadFailure { @@ -21,7 +23,9 @@ impl LoadFailure { /// shows it must not read it as markup. pub(crate) fn message(&self) -> String { match self { - Self::Columns(error) | Self::Page(error) => crate::ui::error_text::operation_error_message(error), + Self::Columns(error) | Self::Page(error) | Self::Definition(error) => { + crate::ui::error_text::operation_error_message(error) + } Self::NoColumns => crate::i18n::gettext( "This table has no columns you can read. It may have been dropped, or your account can't read it.", ), @@ -35,7 +39,7 @@ impl LoadFailure { /// server's text. pub(crate) fn category(&self) -> Option { match self { - Self::Columns(error) | Self::Page(error) => error.category(), + Self::Columns(error) | Self::Page(error) | Self::Definition(error) => error.category(), Self::NoColumns | Self::ColumnsChanged => None, } } diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index d2478883ba..a68e99e146 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -340,6 +340,8 @@ impl WorkspaceModel { connect_structure(&structure, tab_id, &self.input); let page = tab_view.append(&structure); + let follow = follow_loading(&page, structure.is_loading()); + structure.connect_loading_changed(move |_, loading| follow(loading)); let title = qualified_browse_tab_label(self.sidebar_schemas_distinct(), schema.as_deref(), &table); // Told apart from the table's own tab, with the middle dot GNOME // apps use in compound titles. diff --git a/linux/data/resources/ui/structure-tab.ui b/linux/data/resources/ui/structure-tab.ui index a8bcc6c94d..e2b2fe6462 100644 --- a/linux/data/resources/ui/structure-tab.ui +++ b/linux/data/resources/ui/structure-tab.ui @@ -6,6 +6,16 @@