From 9e91522f37316f77fe29d8c9ff63e035c9250974 Mon Sep 17 00:00:00 2001 From: Serge Lapin Date: Mon, 7 Sep 2026 20:32:50 +0300 Subject: [PATCH 1/5] feat(core): add CSV, TSV, JSON, Markdown and IN clause renderers for export --- linux/Cargo.lock | 1 + linux/Cargo.toml | 2 +- linux/crates/core/src/export.rs | 575 ++++++++++++++++++++++++++++++++ linux/crates/core/src/lib.rs | 1 + 4 files changed, 578 insertions(+), 1 deletion(-) create mode 100644 linux/crates/core/src/export.rs diff --git a/linux/Cargo.lock b/linux/Cargo.lock index 9e23880ea9..d286364384 100644 --- a/linux/Cargo.lock +++ b/linux/Cargo.lock @@ -4151,6 +4151,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", diff --git a/linux/Cargo.toml b/linux/Cargo.toml index c2e3a95198..8b45f37104 100644 --- a/linux/Cargo.toml +++ b/linux/Cargo.toml @@ -23,7 +23,7 @@ async-trait = "0.1" anyhow = "1" thiserror = "2" serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde_json = { version = "1", features = ["preserve_order"] } tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "sync"] } tokio-util = { version = "0.7", default-features = false } russh = "0.55" diff --git a/linux/crates/core/src/export.rs b/linux/crates/core/src/export.rs new file mode 100644 index 0000000000..6d51634d7c --- /dev/null +++ b/linux/crates/core/src/export.rs @@ -0,0 +1,575 @@ +use serde::{Deserialize, Serialize}; + +use crate::{ColumnInfo, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvDelimiter { + Comma, + Semicolon, + Tab, + Pipe, +} + +impl CsvDelimiter { + pub const ALL: [CsvDelimiter; 4] = [ + CsvDelimiter::Comma, + CsvDelimiter::Semicolon, + CsvDelimiter::Tab, + CsvDelimiter::Pipe, + ]; + + pub fn as_str(self) -> &'static str { + match self { + CsvDelimiter::Comma => ",", + CsvDelimiter::Semicolon => ";", + CsvDelimiter::Tab => "\t", + CsvDelimiter::Pipe => "|", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvQuote { + Always, + IfNeeded, + Never, +} + +impl CsvQuote { + pub const ALL: [CsvQuote; 3] = [CsvQuote::Always, CsvQuote::IfNeeded, CsvQuote::Never]; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvLineBreak { + Lf, + CrLf, + Cr, +} + +impl CsvLineBreak { + pub const ALL: [CsvLineBreak; 3] = [CsvLineBreak::Lf, CsvLineBreak::CrLf, CsvLineBreak::Cr]; + + pub fn as_str(self) -> &'static str { + match self { + CsvLineBreak::Lf => "\n", + CsvLineBreak::CrLf => "\r\n", + CsvLineBreak::Cr => "\r", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CsvDecimal { + Period, + Comma, +} + +impl CsvDecimal { + pub const ALL: [CsvDecimal; 2] = [CsvDecimal::Period, CsvDecimal::Comma]; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CsvOptions { + pub null_to_empty: bool, + pub line_break_to_space: bool, + pub header_row: bool, + pub sanitize_formulas: bool, + pub delimiter: CsvDelimiter, + pub quote: CsvQuote, + pub line_break: CsvLineBreak, + pub decimal: CsvDecimal, +} + +impl Default for CsvOptions { + fn default() -> Self { + CsvOptions { + null_to_empty: true, + line_break_to_space: false, + header_row: true, + sanitize_formulas: true, + delimiter: CsvDelimiter::Comma, + quote: CsvQuote::IfNeeded, + line_break: CsvLineBreak::Lf, + decimal: CsvDecimal::Period, + } + } +} + +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().map(|b| format!("{b:02x}")).collect() +} + +/// Full text of a value for export. Never truncates. `None` for Null. +pub fn value_to_text(v: &Value) -> Option { + match v { + Value::Null => None, + Value::Bool(b) => Some(if *b { "true".to_string() } else { "false".to_string() }), + Value::Int(i) => Some(i.to_string()), + Value::Float(f) => Some(f.to_string()), + Value::Text(s) => Some(s.clone()), + Value::Bytes(b) => Some(format!("0x{}", hex_encode(b))), + Value::Date(d) => Some(d.format("%Y-%m-%d").to_string()), + Value::Time(t) => Some(t.format("%H:%M:%S").to_string()), + Value::DateTime(dt) => Some(dt.format("%Y-%m-%d %H:%M:%S").to_string()), + Value::TimestampTz(dt) => Some(dt.to_rfc3339()), + Value::Decimal(d) => Some(d.to_string()), + Value::Uuid(u) => Some(u.to_string()), + Value::Json(j) => Some(serde_json::to_string(j).unwrap_or_default()), + } +} + +fn is_plain_decimal(s: &str) -> bool { + let unsigned = s.strip_prefix(['+', '-']).unwrap_or(s); + let Some((int_part, frac_part)) = unsigned.split_once('.') else { + return false; + }; + !int_part.is_empty() + && !frac_part.is_empty() + && int_part.chars().all(|c| c.is_ascii_digit()) + && frac_part.chars().all(|c| c.is_ascii_digit()) +} + +fn quote_field(field: &str) -> String { + format!("\"{}\"", field.replace('"', "\"\"")) +} + +/// `had_line_breaks` carries whether the raw value contained a line +/// break before `line_break_to_space` scrubbed it, so `IfNeeded` +/// still quotes a converted multi-line value even though the +/// resulting text no longer contains `\n`/`\r` itself. +fn escape_field(field: &str, opts: &CsvOptions, had_line_breaks: bool) -> String { + let mut field = field.to_string(); + if opts.sanitize_formulas && field.starts_with(['=', '+', '-', '@']) { + field.insert(0, '\''); + } + match opts.quote { + CsvQuote::Always => quote_field(&field), + CsvQuote::Never => field, + CsvQuote::IfNeeded => { + let delim = opts.delimiter.as_str(); + if field.contains(delim) + || field.contains('"') + || field.contains('\n') + || field.contains('\r') + || had_line_breaks + { + quote_field(&field) + } else { + field + } + } + } +} + +fn format_cell(value: &Value, opts: &CsvOptions) -> String { + let Some(mut text) = value_to_text(value) else { + let empty = if opts.null_to_empty { + String::new() + } else { + "NULL".to_string() + }; + return escape_field(&empty, opts, false); + }; + let had_line_breaks = text.contains('\n') || text.contains('\r'); + if opts.line_break_to_space { + text = text.replace("\r\n", " ").replace(['\r', '\n'], " "); + } + if opts.decimal == CsvDecimal::Comma && is_plain_decimal(&text) { + text = text.replace('.', ","); + } + escape_field(&text, opts, had_line_breaks) +} + +pub fn render_csv(columns: &[ColumnInfo], rows: &[Vec], opts: &CsvOptions) -> String { + let delim = opts.delimiter.as_str(); + let line_break = opts.line_break.as_str(); + let mut out = String::new(); + if opts.header_row { + let header: Vec = columns.iter().map(|c| escape_field(&c.name, opts, false)).collect(); + out.push_str(&header.join(delim)); + out.push_str(line_break); + } + for row in rows { + let cells: Vec = row.iter().map(|v| format_cell(v, opts)).collect(); + out.push_str(&cells.join(delim)); + out.push_str(line_break); + } + out +} + +pub fn render_tsv(columns: &[ColumnInfo], rows: &[Vec], with_headers: bool) -> String { + let mut lines: Vec = Vec::new(); + if with_headers { + let header: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + lines.push(header.join("\t")); + } + for row in rows { + let cells: Vec = row + .iter() + .map(|v| value_to_text(v).unwrap_or_else(|| "NULL".to_string())) + .collect(); + lines.push(cells.join("\t")); + } + lines.join("\n") +} + +fn value_to_json(v: &Value) -> serde_json::Value { + match v { + Value::Null => serde_json::Value::Null, + Value::Bool(b) => serde_json::Value::Bool(*b), + Value::Int(i) => serde_json::Value::Number((*i).into()), + Value::Float(f) => serde_json::Number::from_f64(*f) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::Decimal(d) => { + let s = d.to_string(); + match s.parse::() { + Ok(n) => serde_json::Value::Number(n), + Err(_) => serde_json::Value::String(s), + } + } + Value::Json(j) => j.clone(), + other => match value_to_text(other) { + Some(s) => serde_json::Value::String(s), + None => serde_json::Value::Null, + }, + } +} + +pub fn row_to_json(columns: &[ColumnInfo], row: &[Value]) -> serde_json::Value { + let mut map = serde_json::Map::new(); + for (i, col) in columns.iter().enumerate() { + let value = row.get(i).map(value_to_json).unwrap_or(serde_json::Value::Null); + map.insert(col.name.clone(), value); + } + serde_json::Value::Object(map) +} + +pub fn render_json(columns: &[ColumnInfo], rows: &[Vec]) -> String { + let values: Vec = rows.iter().map(|row| row_to_json(columns, row)).collect(); + serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".to_string()) +} + +fn markdown_cell(value: &Value) -> String { + let text = value_to_text(value).unwrap_or_else(|| "NULL".to_string()); + text.replace('|', "\\|") + .replace("\r\n", "
") + .replace(['\r', '\n'], "
") +} + +pub fn render_markdown(columns: &[ColumnInfo], rows: &[Vec]) -> String { + let mut lines: Vec = Vec::new(); + let header: Vec<&str> = columns.iter().map(|c| c.name.as_str()).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().map(markdown_cell).collect(); + lines.push(format!("| {} |", cells.join(" | "))); + } + lines.join("\n") +} + +fn in_clause_literal(v: &Value) -> Option { + match v { + Value::Null | Value::Bytes(_) => None, + Value::Bool(b) => Some(if *b { "TRUE".to_string() } else { "FALSE".to_string() }), + Value::Int(_) | Value::Float(_) | Value::Decimal(_) => value_to_text(v), + other => value_to_text(other).map(|s| format!("'{}'", s.replace('\'', "''"))), + } +} + +pub fn render_in_clause(rows: &[Vec], col_index: usize) -> String { + let literals: Vec = rows + .iter() + .filter_map(|row| row.get(col_index)) + .filter_map(in_clause_literal) + .collect(); + format!("({})", literals.join(", ")) +} + +#[cfg(test)] +mod tests { + use chrono::{NaiveDate, NaiveTime}; + use rust_decimal::Decimal; + use std::str::FromStr; + use uuid::Uuid; + + use super::*; + + fn col(name: &str) -> ColumnInfo { + ColumnInfo { + name: name.into(), + data_type: "text".into(), + nullable: true, + primary_key: false, + is_auto_increment: false, + default_value: None, + is_generated: false, + } + } + + fn cols(names: &[&str]) -> Vec { + names.iter().map(|n| col(n)).collect() + } + + #[test] + fn value_to_text_covers_every_variant() { + assert_eq!(value_to_text(&Value::Null), None); + assert_eq!(value_to_text(&Value::Bool(true)), Some("true".to_string())); + assert_eq!(value_to_text(&Value::Bool(false)), Some("false".to_string())); + assert_eq!(value_to_text(&Value::Int(42)), Some("42".to_string())); + assert_eq!(value_to_text(&Value::Float(1.5)), Some("1.5".to_string())); + assert_eq!(value_to_text(&Value::Text("hi".into())), Some("hi".to_string())); + assert_eq!( + value_to_text(&Value::Bytes(vec![0xde, 0xad])), + Some("0xdead".to_string()) + ); + assert_eq!( + value_to_text(&Value::Date(NaiveDate::from_ymd_opt(2024, 1, 2).unwrap())), + Some("2024-01-02".to_string()) + ); + assert_eq!( + value_to_text(&Value::Time(NaiveTime::from_hms_opt(13, 5, 9).unwrap())), + Some("13:05:09".to_string()) + ); + assert_eq!( + value_to_text(&Value::DateTime( + NaiveDate::from_ymd_opt(2024, 1, 2) + .unwrap() + .and_hms_opt(13, 5, 9) + .unwrap() + )), + Some("2024-01-02 13:05:09".to_string()) + ); + assert_eq!( + value_to_text(&Value::Decimal(Decimal::from_str("12.30").unwrap())), + Some("12.30".to_string()) + ); + let uuid = Uuid::from_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + assert_eq!(value_to_text(&Value::Uuid(uuid)), Some(uuid.to_string())); + assert_eq!( + value_to_text(&Value::Json(serde_json::json!({"a": 1}))), + Some("{\"a\":1}".to_string()) + ); + } + + #[test] + fn csv_defaults_render_comma_lf_if_needed() { + let columns = cols(&["id", "name"]); + let rows = vec![vec![Value::Int(1), Value::Text("Alice".into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()); + assert_eq!(out, "id,name\n1,Alice\n"); + } + + #[test] + fn csv_quote_if_needed_triggers_on_delimiter() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("has,comma".into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()); + assert_eq!(out, "a\n\"has,comma\"\n"); + } + + #[test] + fn csv_quote_if_needed_triggers_on_quote_char() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("say \"hi\"".into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()); + assert_eq!(out, "a\n\"say \"\"hi\"\"\"\n"); + } + + #[test] + fn csv_quote_if_needed_triggers_on_original_line_break_even_when_converted() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("line1\nline2".into())]]; + let opts = CsvOptions { + line_break_to_space: true, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts); + assert_eq!(out, "a\n\"line1 line2\"\n"); + } + + #[test] + fn csv_quote_always_quotes_everything() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("plain".into())]]; + let opts = CsvOptions { + quote: CsvQuote::Always, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts); + assert_eq!(out, "\"a\"\n\"plain\"\n"); + } + + #[test] + fn csv_quote_never_quotes_nothing_even_with_delimiter() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("has,comma".into())]]; + let opts = CsvOptions { + quote: CsvQuote::Never, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts); + assert_eq!(out, "a\nhas,comma\n"); + } + + #[test] + fn csv_sanitizes_formula_prefixes() { + let columns = cols(&["a"]); + for ch in ['=', '+', '-', '@'] { + let rows = vec![vec![Value::Text(format!("{ch}cmd"))]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()); + assert_eq!(out, format!("a\n'{ch}cmd\n"), "prefix {ch} should be sanitized"); + } + } + + #[test] + fn csv_does_not_sanitize_non_formula_prefixes() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("plain text".into())]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()); + assert_eq!(out, "a\nplain text\n"); + } + + #[test] + fn csv_decimal_comma_only_for_plain_decimals() { + let columns = cols(&["a"]); + let opts = CsvOptions { + decimal: CsvDecimal::Comma, + delimiter: CsvDelimiter::Semicolon, + ..Default::default() + }; + assert_eq!( + render_csv(&columns, &[vec![Value::Text("1.5".into())]], &opts), + "a\n1,5\n" + ); + assert_eq!( + render_csv(&columns, &[vec![Value::Text("1e5".into())]], &opts), + "a\n1e5\n" + ); + assert_eq!( + render_csv(&columns, &[vec![Value::Text("12".into())]], &opts), + "a\n12\n" + ); + assert_eq!( + render_csv(&columns, &[vec![Value::Text("1.2.3".into())]], &opts), + "a\n1.2.3\n" + ); + } + + #[test] + fn csv_null_modes() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Null]]; + let out_empty = render_csv(&columns, &rows, &CsvOptions::default()); + assert_eq!(out_empty, "a\n\n"); + let opts = CsvOptions { + null_to_empty: false, + ..Default::default() + }; + let out_null = render_csv(&columns, &rows, &opts); + assert_eq!(out_null, "a\nNULL\n"); + } + + #[test] + fn csv_crlf_line_break() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Int(1)]]; + let opts = CsvOptions { + line_break: CsvLineBreak::CrLf, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts); + assert_eq!(out, "a\r\n1\r\n"); + } + + #[test] + fn csv_semicolon_delimiter() { + let columns = cols(&["a", "b"]); + let rows = vec![vec![Value::Int(1), Value::Int(2)]]; + let opts = CsvOptions { + delimiter: CsvDelimiter::Semicolon, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts); + assert_eq!(out, "a;b\n1;2\n"); + } + + #[test] + fn csv_header_off() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Int(1)]]; + let opts = CsvOptions { + header_row: false, + ..Default::default() + }; + let out = render_csv(&columns, &rows, &opts); + assert_eq!(out, "1\n"); + } + + #[test] + fn tsv_with_and_without_header() { + let columns = cols(&["a", "b"]); + let rows = vec![vec![Value::Int(1), Value::Null]]; + assert_eq!(render_tsv(&columns, &rows, true), "a\tb\n1\tNULL"); + assert_eq!(render_tsv(&columns, &rows, false), "1\tNULL"); + } + + #[test] + fn json_number_vs_string_handling() { + let columns = cols(&["i", "f", "d", "s"]); + let row = vec![ + Value::Int(5), + Value::Float(1.5), + Value::Decimal(Decimal::from_str("9.99").unwrap()), + Value::Text("hi".into()), + ]; + let json = row_to_json(&columns, &row); + assert_eq!(json["i"], serde_json::json!(5)); + assert_eq!(json["f"], serde_json::json!(1.5)); + assert_eq!(json["d"], serde_json::json!(9.99)); + assert_eq!(json["s"], serde_json::json!("hi")); + } + + #[test] + fn json_missing_cell_is_null() { + let columns = cols(&["a", "b"]); + let row = vec![Value::Int(1)]; + let json = row_to_json(&columns, &row); + assert_eq!(json["b"], serde_json::Value::Null); + } + + #[test] + fn render_json_empty_rows_is_empty_array() { + let columns = cols(&["a"]); + assert_eq!(render_json(&columns, &[]), "[]"); + } + + #[test] + fn markdown_escapes_pipe_and_converts_line_breaks() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("has|pipe\nand newline".into())]]; + let out = render_markdown(&columns, &rows); + assert_eq!(out, "| a |\n| --- |\n| has\\|pipe
and newline |"); + } + + #[test] + fn in_clause_skips_null_and_quotes_text() { + let rows = vec![ + vec![Value::Text("O'Brien".into())], + vec![Value::Null], + vec![Value::Int(5)], + vec![Value::Bool(true)], + ]; + let out = render_in_clause(&rows, 0); + assert_eq!(out, "('O''Brien', 5, TRUE)"); + } + + #[test] + fn in_clause_empty_when_all_skipped() { + let rows = vec![vec![Value::Null], vec![Value::Bytes(vec![1, 2])]]; + assert_eq!(render_in_clause(&rows, 0), "()"); + } +} diff --git a/linux/crates/core/src/lib.rs b/linux/crates/core/src/lib.rs index c36bdd842d..557ff3b48e 100644 --- a/linux/crates/core/src/lib.rs +++ b/linux/crates/core/src/lib.rs @@ -1,6 +1,7 @@ mod connection; mod driver; mod error; +pub mod export; pub mod filter; mod query; mod read_only; From 7fa7eda9292bb25ff0697b8fe3fe965ddd2be1f1 Mon Sep 17 00:00:00 2001 From: Serge Lapin Date: Mon, 7 Sep 2026 20:32:51 +0300 Subject: [PATCH 2/5] feat(linux): add result grid context menu with Copy as and Export Results dialog --- linux/crates/app/src/services/preferences.rs | 4 + linux/crates/app/src/ui/app/browse.rs | 93 +-- linux/crates/app/src/ui/app/mod.rs | 79 +-- linux/crates/app/src/ui/app/workspace_tabs.rs | 11 + linux/crates/app/src/ui/browse_tab.rs | 90 ++- linux/crates/app/src/ui/editor.rs | 32 +- linux/crates/app/src/ui/export_dialog.rs | 292 ++++++++ linux/crates/app/src/ui/grid.rs | 657 ++++++++++-------- linux/crates/app/src/ui/mod.rs | 1 + linux/crates/app/src/ui/preferences.rs | 1 + linux/po/POTFILES.in | 1 + 11 files changed, 785 insertions(+), 476 deletions(-) create mode 100644 linux/crates/app/src/ui/export_dialog.rs diff --git a/linux/crates/app/src/services/preferences.rs b/linux/crates/app/src/services/preferences.rs index 3fa6f17699..dbb086af8b 100644 --- a/linux/crates/app/src/services/preferences.rs +++ b/linux/crates/app/src/services/preferences.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use tablepro_core::export::CsvOptions; use super::config_io::{atomic_write_json, xdg_config_path}; @@ -17,6 +18,8 @@ pub struct Preferences { /// shutdown. #[serde(default = "default_query_timeout_secs")] pub query_timeout_secs: u32, + #[serde(default)] + pub csv_export: CsvOptions, } fn default_history_retention_days() -> u32 { @@ -35,6 +38,7 @@ impl Default for Preferences { editor_font_size: 12, history_retention_days: default_history_retention_days(), query_timeout_secs: default_query_timeout_secs(), + csv_export: CsvOptions::default(), } } } diff --git a/linux/crates/app/src/ui/app/browse.rs b/linux/crates/app/src/ui/app/browse.rs index d6362b69c8..b90749b94e 100644 --- a/linux/crates/app/src/ui/app/browse.rs +++ b/linux/crates/app/src/ui/app/browse.rs @@ -1,6 +1,5 @@ use relm4::adw::prelude::*; -use relm4::gtk::gio; -use relm4::{ComponentController, ComponentSender, adw, gtk}; +use relm4::{ComponentController, ComponentSender, adw}; use tablepro_core::{ColumnInfo, QueryResult}; use uuid::Uuid; @@ -8,7 +7,7 @@ use uuid::Uuid; use crate::services::database_service; use crate::ui::browse_tab::BrowseTabInput; -use super::{App, AppMsg, ExportFormat, OpenMode, render_csv, render_json}; +use super::{App, AppMsg, OpenMode}; impl App { /// Sidebar click — routes via OpenMode (smart switch / new tab). @@ -236,94 +235,6 @@ impl App { } } - pub(super) fn on_export(&self, format: ExportFormat) { - let Some((schema, table)) = self.selected_browse_slot_table() else { - self.show_toast(&crate::tr!("Nothing to export")); - return; - }; - let Some(active_id) = self.selected_browse_tab_id() else { - self.show_toast(&crate::tr!("Nothing to export")); - return; - }; - let result = { - let tabs = self.workspace_tabs.borrow(); - tabs.get(&active_id) - .and_then(|t| t.browse_controller()) - .and_then(|c| c.model().snapshot()) - }; - let Some(result) = result else { - self.show_toast(&crate::tr!("Nothing to export")); - return; - }; - let table_label = match &schema { - Some(s) => format!("{s}.{table}"), - None => table.clone(), - }; - let suggested = match format { - ExportFormat::Csv => format!("{table_label}.csv"), - ExportFormat::Json => format!("{table_label}.json"), - }; - let filter = gtk::FileFilter::new(); - match format { - ExportFormat::Csv => { - filter.set_name(Some(&crate::tr!("CSV files"))); - filter.add_mime_type("text/csv"); - filter.add_suffix("csv"); - } - ExportFormat::Json => { - filter.set_name(Some(&crate::tr!("JSON files"))); - filter.add_mime_type("application/json"); - filter.add_suffix("json"); - } - }; - let filters = gio::ListStore::new::(); - filters.append(&filter); - let dialog = gtk::FileDialog::builder() - .title(match format { - ExportFormat::Csv => crate::tr!("Export as CSV"), - ExportFormat::Json => crate::tr!("Export as JSON"), - }) - .modal(true) - .initial_name(&suggested) - .default_filter(&filter) - .filters(&filters) - .build(); - let parent = self.window.clone(); - let parent_for_alert = parent.clone(); - let toast_overlay = self.toast_overlay.clone(); - dialog.save(Some(&parent), gtk::gio::Cancellable::NONE, move |outcome| { - let Ok(file) = outcome else { return }; - let Some(path) = file.path() else { return }; - let bytes = match format { - ExportFormat::Csv => render_csv(&result), - ExportFormat::Json => render_json(&result), - }; - match std::fs::write(&path, bytes) { - Ok(()) => toast_overlay.add_toast(relm4::adw::Toast::new( - &crate::tr!("Exported to {path}").replace("{path}", &path.display().to_string()), - )), - // Failures use AdwAlertDialog instead of a transient - // toast — the user needs time to read the IO error - // (and probably copy the path to retry elsewhere). - // Matches the Save / Drop error-handling pattern. - Err(e) => { - let alert = adw::AlertDialog::new( - Some(&crate::tr!("Couldn't export")), - Some( - &crate::tr!("Writing {path} failed: {error}") - .replace("{path}", &path.display().to_string()) - .replace("{error}", &e.to_string()), - ), - ); - alert.add_response("close", &crate::tr!("Close")); - alert.set_default_response(Some("close")); - alert.set_close_response("close"); - alert.present(Some(&parent_for_alert)); - } - } - }); - } - /// Ctrl+F / Filter button — toggle the inline filter strip on /// the active Browse tab. Strip lives inside the tab (always /// constructed at init), so this is just a reveal flip. diff --git a/linux/crates/app/src/ui/app/mod.rs b/linux/crates/app/src/ui/app/mod.rs index 1e5d1175bf..d82107576d 100644 --- a/linux/crates/app/src/ui/app/mod.rs +++ b/linux/crates/app/src/ui/app/mod.rs @@ -320,8 +320,10 @@ pub enum AppMsg { ShowPreferences, /// Sort flipped on tab_id's grid for column idx. RowCountLoaded(Uuid, u64), - ExportCsv, - ExportJson, + ExportResults { + result: QueryResult, + name: String, + }, CopyToClipboard(String), CopyRowAsInsert { tab_id: Uuid, @@ -510,12 +512,6 @@ pub enum AppMsg { ShowFilterDialog, } -#[derive(Debug, Clone, Copy)] -enum ExportFormat { - Csv, - Json, -} - /// Determines which icon and styling adw::StatusPage uses. /// /// Replaces the previous title-string sniffing in `set_status_page`, @@ -1432,8 +1428,9 @@ impl SimpleComponent for App { AppMsg::ShowShortcuts => self.on_show_shortcuts(), AppMsg::ShowAbout => self.on_show_about(), AppMsg::ShowPreferences => super::preferences::present(&self.window), - AppMsg::ExportCsv => self.on_export(ExportFormat::Csv), - AppMsg::ExportJson => self.on_export(ExportFormat::Json), + AppMsg::ExportResults { result, name } => { + super::export_dialog::present(&self.window, &self.toast_overlay, result, name) + } AppMsg::CopyToClipboard(text) => self.on_copy_to_clipboard(text), AppMsg::CopyRowAsInsert { tab_id, row_position } => self.on_copy_row_as_insert(tab_id, row_position), AppMsg::DeleteConnection(id) => self.on_delete_connection(id, sender), @@ -1444,66 +1441,6 @@ impl SimpleComponent for App { } } -fn render_csv(result: &QueryResult) -> Vec { - let mut out = String::new(); - let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); - out.push_str(&cols.iter().map(|c| csv_escape(c)).collect::>().join(",")); - out.push('\n'); - for row in &result.rows { - let cells: Vec = row - .iter() - .map(|v| csv_escape(&super::grid::value_to_display_text(v))) - .collect(); - out.push_str(&cells.join(",")); - out.push('\n'); - } - out.into_bytes() -} - -fn csv_escape(s: &str) -> String { - if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s.to_string() - } -} - -fn render_json(result: &QueryResult) -> Vec { - let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); - let rows: Vec = result - .rows - .iter() - .map(|row| { - let mut obj = serde_json::Map::new(); - for (i, col) in cols.iter().enumerate() { - let v = row.get(i).cloned().unwrap_or(Value::Null); - obj.insert((*col).to_string(), value_to_json(&v)); - } - serde_json::Value::Object(obj) - }) - .collect(); - serde_json::to_vec_pretty(&rows).unwrap_or_default() -} - -fn value_to_json(v: &Value) -> serde_json::Value { - use serde_json::Value as J; - match v { - Value::Null => J::Null, - Value::Bool(b) => J::Bool(*b), - Value::Int(i) => J::from(*i), - Value::Float(f) => J::from(*f), - Value::Text(s) => J::String(s.clone()), - Value::Bytes(b) => J::String(format!("<{} bytes>", b.len())), - Value::Date(d) => J::String(d.to_string()), - Value::Time(t) => J::String(t.to_string()), - Value::DateTime(dt) => J::String(dt.to_string()), - Value::TimestampTz(ts) => J::String(ts.to_rfc3339()), - Value::Decimal(d) => J::String(d.to_string()), - Value::Uuid(u) => J::String(u.to_string()), - Value::Json(j) => j.clone(), - } -} - fn qualified_label(schema: Option<&str>, table: &str) -> String { match schema { Some(s) => format!("{s}.{table}"), @@ -1561,8 +1498,6 @@ fn install_window_actions(window: &adw::ApplicationWindow, sender: ComponentSend input_action!("preferences", AppMsg::ShowPreferences), input_action!("show-history", AppMsg::ShowHistory), input_action!("refresh-page", AppMsg::RefreshPage), - input_action!("export-csv", AppMsg::ExportCsv), - input_action!("export-json", AppMsg::ExportJson), input_action!("save-changes", AppMsg::SaveActiveBrowseTab), input_action!("undo-change", AppMsg::UndoActiveBrowseTab), input_action!("redo-change", AppMsg::RedoActiveBrowseTab), diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 5e3fe12166..35cf0682ea 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -169,6 +169,11 @@ impl App { .forward(sender_for_create.input_sender(), move |out| match out { SqlEditorOutput::RunStateChanged(running) => AppMsg::EditorTabRunStateChanged(tab_id, running), SqlEditorOutput::QueryChanged(text) => AppMsg::EditorTabQueryChanged(tab_id, text), + SqlEditorOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), + SqlEditorOutput::ExportResults(result) => AppMsg::ExportResults { + result, + name: "results".to_string(), + }, }); let page = tab_view_for_create.append(editor.widget()); let editor_count = workspace_tabs_for_create @@ -376,6 +381,7 @@ impl App { BrowseTabOutput::StateChanged => AppMsg::WorkspaceTabsChanged, BrowseTabOutput::CopyRowAsInsert { row_position } => AppMsg::CopyRowAsInsert { tab_id, row_position }, BrowseTabOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), + BrowseTabOutput::ExportResults { result, name } => AppMsg::ExportResults { result, name }, BrowseTabOutput::SchemaWordsChanged(_words) => AppMsg::WorkspaceSchemaWordsChanged, BrowseTabOutput::ShowSelectionAlert { title, body } => AppMsg::ShowAlert { title, body }, BrowseTabOutput::ShowToast(msg) => AppMsg::ShowToast(msg), @@ -501,6 +507,11 @@ impl App { .forward(sender.input_sender(), move |out| match out { SqlEditorOutput::RunStateChanged(running) => AppMsg::EditorTabRunStateChanged(tab_id, running), SqlEditorOutput::QueryChanged(text) => AppMsg::EditorTabQueryChanged(tab_id, text), + SqlEditorOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), + SqlEditorOutput::ExportResults(result) => AppMsg::ExportResults { + result, + name: "results".to_string(), + }, }); let page = tab_view.append(editor.widget()); let label = match query.trim().is_empty() { diff --git a/linux/crates/app/src/ui/browse_tab.rs b/linux/crates/app/src/ui/browse_tab.rs index b29613dc1c..cd64a87618 100644 --- a/linux/crates/app/src/ui/browse_tab.rs +++ b/linux/crates/app/src/ui/browse_tab.rs @@ -193,10 +193,13 @@ pub enum BrowseTabInput { col_index: usize, new_value: String, }, - GridSetCellNull { + GridSetCellValue { row_position: u32, col_index: usize, + value: Value, }, + GridExportResults(QueryResult), + ExportCurrentPage, GridDeleteRowAt { row_position: u32, }, @@ -285,6 +288,9 @@ pub enum BrowseTabOutput { CopyRowAsInsert { row_position: u32 }, /// Generic clipboard-copy request from grid. CopyToClipboard(String), + /// "Export Results…" from the grid menu or the paginator button. + /// Carries the rows to write and a suggested file name stem. + ExportResults { result: QueryResult, name: String }, /// Column-name vocabulary for editor autocomplete; App merges across tabs. SchemaWordsChanged(Vec), /// Show a generic info dialog for "Cannot edit / select exactly one row". @@ -315,6 +321,13 @@ impl BrowseTab { self.current_result.clone() } + fn export_name(&self) -> String { + match &self.schema { + Some(s) => format!("{s}.{}", self.table), + None => self.table.clone(), + } + } + pub fn columns(&self) -> &[ColumnInfo] { &self.current_columns } @@ -447,7 +460,7 @@ impl BrowseTab { prev_button.connect_clicked(move |_| sender_for_prev.input(BrowseTabInput::PrevPage)); let sender_for_next = sender.clone(); next_button.connect_clicked(move |_| sender_for_next.input(BrowseTabInput::NextPage)); - let sender_for_last = sender; + let sender_for_last = sender.clone(); last_button.connect_clicked(move |_| sender_for_last.input(BrowseTabInput::LastPage)); // Paginator lives in a native `gtk::ActionBar` to match the @@ -458,18 +471,13 @@ impl BrowseTab { // background, and high-contrast theming come for free. let paginator_bar = gtk::ActionBar::new(); - // Export menu uses win.export-csv / win.export-json (App-level - // actions); they read the active tab's snapshot so the buttons - // implicitly target this tab when this tab is active. - let export_menu = gtk::gio::Menu::new(); - export_menu.append(Some(&crate::tr!("Export as CSV…")), Some("win.export-csv")); - export_menu.append(Some(&crate::tr!("Export as JSON…")), Some("win.export-json")); - let export_button = gtk::MenuButton::builder() + let export_button = gtk::Button::builder() .icon_name("document-save-symbolic") .tooltip_text(crate::tr!("Export results")) - .menu_model(&export_menu) .build(); export_button.add_css_class("flat"); + let export_sender = sender.clone(); + export_button.connect_clicked(move |_| export_sender.input(BrowseTabInput::ExportCurrentPage)); // Filter button — opens the rule editor for server-side WHERE. // Action `win.open-filter` is registered in app/mod.rs and @@ -938,11 +946,6 @@ impl BrowseTab { // changed (rare in practice — would require schema migration // mid-session). Build the full column-view scaffolding. clear_box(&self.grid_holder); - let edit_sender = if self.read_only { - None - } else { - Some(self.grid_sender.clone()) - }; let pk_col_indices: Vec = self .current_columns .iter() @@ -958,7 +961,8 @@ impl BrowseTab { &result, &self.current_columns, &self.table, - edit_sender, + self.grid_sender.clone(), + !self.read_only, self.current_sort, Some(self.grid_sender.clone()), self.connection_id, @@ -1461,9 +1465,10 @@ impl SimpleComponent for BrowseTab { return glib::Propagation::Proceed; }; grid_sender_for_null - .send(GridMsg::SetCellNull { + .send(GridMsg::SetCellValue { row_position, col_index, + value: Value::Null, }) .ok(); glib::Propagation::Stop @@ -1581,13 +1586,16 @@ impl SimpleComponent for BrowseTab { }, GridMsg::CopyToClipboard(text) => BrowseTabInput::GridCopyToClipboard(text), GridMsg::CopyRowAsInsert { row_position } => BrowseTabInput::GridCopyRowAsInsert { row_position }, - GridMsg::SetCellNull { + GridMsg::SetCellValue { row_position, col_index, - } => BrowseTabInput::GridSetCellNull { + value, + } => BrowseTabInput::GridSetCellValue { row_position, col_index, + value, }, + GridMsg::ExportResults(result) => BrowseTabInput::GridExportResults(result), GridMsg::DeleteRowAt { row_position } => BrowseTabInput::GridDeleteRowAt { row_position }, GridMsg::InsertRow => BrowseTabInput::InsertRow, GridMsg::DuplicateRow { row_position } => BrowseTabInput::DuplicateRow { row_position }, @@ -1965,7 +1973,7 @@ impl SimpleComponent for BrowseTab { let Some(selection) = self.current_selection.as_ref() else { return; }; - let positions = selected_positions(selection); + let positions = super::grid::selected_positions(selection); if positions.is_empty() { return; } @@ -2134,16 +2142,42 @@ impl SimpleComponent for BrowseTab { t.track_cell_edit(key, col_index, original, new); }); } - BrowseTabInput::GridSetCellNull { + BrowseTabInput::GridSetCellValue { row_position, col_index, + value, } => { + if let Some(row_obj) = self.row_object_at(row_position) + && let Some(draft_id) = row_obj.draft_id() + { + crate::services::change_tracker::with_tab(self.tab_id, |t| { + t.track_draft_cell_edit(draft_id, col_index, value.clone()); + }); + row_obj.set_cell(col_index, value); + return; + } let Some((key, row)) = self.row_key_at(row_position) else { return; }; let original = row[col_index].clone(); crate::services::change_tracker::with_tab(self.tab_id, |t| { - t.track_cell_edit(key, col_index, original, Value::Null); + t.track_cell_edit(key, col_index, original, value); + }); + } + BrowseTabInput::GridExportResults(result) => { + let _ = sender.output(BrowseTabOutput::ExportResults { + result, + name: self.export_name(), + }); + } + BrowseTabInput::ExportCurrentPage => { + let Some(result) = self.current_result.clone() else { + let _ = sender.output(BrowseTabOutput::ShowToast(crate::tr!("Nothing to export"))); + return; + }; + let _ = sender.output(BrowseTabOutput::ExportResults { + result, + name: self.export_name(), }); } BrowseTabInput::GridDeleteRowAt { row_position } => { @@ -2164,7 +2198,7 @@ impl SimpleComponent for BrowseTab { let Some(selection) = self.current_selection.as_ref() else { return; }; - let positions = selected_positions(selection); + let positions = super::grid::selected_positions(selection); if positions.is_empty() { return; } @@ -2499,16 +2533,6 @@ fn update_selection_chrome(label: >k::Label, n: u32) { label.set_visible(true); } -fn selected_positions(selection: >k::MultiSelection) -> Vec { - let bitset = selection.selection(); - let mut out = Vec::with_capacity(bitset.size() as usize); - for i in 0..bitset.size() { - out.push(bitset.nth(i as u32)); - } - out.sort_unstable(); - out -} - /// Parse a user-typed cell value against the column's declared data /// type. Returns `Err(message)` when the input is unambiguously wrong /// for the column (invalid date, malformed UUID, required field empty, diff --git a/linux/crates/app/src/ui/editor.rs b/linux/crates/app/src/ui/editor.rs index 3d2695b172..b8a9e3e226 100644 --- a/linux/crates/app/src/ui/editor.rs +++ b/linux/crates/app/src/ui/editor.rs @@ -10,7 +10,7 @@ use tokio_util::sync::CancellationToken; use tablepro_core::QueryResult; use tablepro_storage::query_history::{self, NewEntry, Outcome}; -use super::grid::{TabGridContext, build_column_view}; +use super::grid::{GridMsg, TabGridContext, build_column_view}; use crate::services::database_service::{self, ConnectionMetadata}; pub struct SqlEditor { @@ -20,6 +20,7 @@ pub struct SqlEditor { running_spinner: gtk::Spinner, results_holder: gtk::Box, status: gtk::Label, + grid_sender: relm4::Sender, cancel_token: Option, executing_sql: Option, executing_metadata: Option, @@ -81,12 +82,16 @@ pub enum SqlEditorInput { /// Ctrl+/ → toggle SQL line-comment for the selected lines (or /// the cursor's line). Standard IDE shortcut. ToggleLineComment, + /// Context-menu actions from a result grid. + Grid(GridMsg), } #[derive(Debug)] pub enum SqlEditorOutput { RunStateChanged(bool), QueryChanged(String), + CopyToClipboard(String), + ExportResults(QueryResult), } #[relm4::component(pub)] @@ -353,6 +358,9 @@ impl SimpleComponent for SqlEditor { }); widgets.source_view.add_controller(drop_target); + let (grid_sender, grid_receiver) = relm4::channel::(); + relm4::spawn_local(grid_receiver.forward(sender.input_sender().clone(), SqlEditorInput::Grid)); + let model = SqlEditor { source_view: widgets.source_view.clone(), run_button: widgets.run_button.clone(), @@ -360,6 +368,7 @@ impl SimpleComponent for SqlEditor { running_spinner: widgets.running_spinner.clone(), results_holder: widgets.results_holder.clone(), status: widgets.status.clone(), + grid_sender, cancel_token: None, executing_sql: None, executing_metadata: None, @@ -386,6 +395,14 @@ impl SimpleComponent for SqlEditor { toggle_line_comment(&self.source_view.buffer()); } + SqlEditorInput::Grid(GridMsg::CopyToClipboard(text)) => { + let _ = sender.output(SqlEditorOutput::CopyToClipboard(text)); + } + SqlEditorInput::Grid(GridMsg::ExportResults(result)) => { + let _ = sender.output(SqlEditorOutput::ExportResults(result)); + } + SqlEditorInput::Grid(_) => {} + SqlEditorInput::RunAtCursor => { // Walk the buffer's SQL state machine and pick the // statement segment containing the cursor. The user @@ -453,7 +470,7 @@ impl SimpleComponent for SqlEditor { self.status .set_label(&summary_label(n_total, n_ok, total_ms, first_error.is_some())); clear_box(&self.results_holder); - render_outcomes(&self.results_holder, &outcomes); + render_outcomes(&self.results_holder, &outcomes, &self.grid_sender); } SqlEditorInput::ShowCancelled => { @@ -735,14 +752,15 @@ fn summary_label(n_total: usize, n_ok: usize, total_ms: u128, has_error: bool) - /// Mount one StatementOutcome into a parent box (for single-result /// renders) or as an `AdwViewStack` page (multi-result). Wraps grids /// in a ScrolledWindow so the result pane stays scroll-bounded. -fn build_outcome_widget(o: &StatementOutcome, idx: usize) -> gtk::Widget { +fn build_outcome_widget(o: &StatementOutcome, idx: usize, grid_sender: &relm4::Sender) -> gtk::Widget { match &o.kind { StatementOutcomeKind::Rows(result) if !result.rows.is_empty() => { let (column_view, _selection) = build_column_view( result, &result.columns, "", - None, + grid_sender.clone(), + false, None, None, None, @@ -795,7 +813,7 @@ fn outcome_tab_label(idx: usize, o: &StatementOutcome) -> String { } } -fn render_outcomes(holder: >k::Box, outcomes: &[StatementOutcome]) { +fn render_outcomes(holder: >k::Box, outcomes: &[StatementOutcome], grid_sender: &relm4::Sender) { if outcomes.is_empty() { let placeholder = adw::StatusPage::builder() .title(crate::tr!("Empty query")) @@ -807,7 +825,7 @@ fn render_outcomes(holder: >k::Box, outcomes: &[StatementOutcome]) { return; } if outcomes.len() == 1 { - let widget = build_outcome_widget(&outcomes[0], 0); + let widget = build_outcome_widget(&outcomes[0], 0, grid_sender); holder.append(&widget); return; } @@ -817,7 +835,7 @@ fn render_outcomes(holder: >k::Box, outcomes: &[StatementOutcome]) { // app — same widget for "different views of the same execution". let stack = adw::ViewStack::new(); for (idx, o) in outcomes.iter().enumerate() { - let widget = build_outcome_widget(o, idx); + let widget = build_outcome_widget(o, idx, grid_sender); let icon = match &o.kind { StatementOutcomeKind::Rows(_) => "view-grid-symbolic", StatementOutcomeKind::Error(_) => "dialog-error-symbolic", diff --git a/linux/crates/app/src/ui/export_dialog.rs b/linux/crates/app/src/ui/export_dialog.rs new file mode 100644 index 0000000000..d6499bff07 --- /dev/null +++ b/linux/crates/app/src/ui/export_dialog.rs @@ -0,0 +1,292 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use relm4::adw::prelude::*; +use relm4::gtk::gio; +use relm4::{adw, gtk}; + +use tablepro_core::QueryResult; +use tablepro_core::export::{self, CsvDecimal, CsvDelimiter, CsvLineBreak, CsvOptions, CsvQuote}; + +use crate::services::preferences; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Format { + Csv, + Json, +} + +impl Format { + const ALL: [Format; 2] = [Format::Csv, Format::Json]; + + fn label(self) -> &'static str { + match self { + Format::Csv => "CSV", + Format::Json => "JSON", + } + } + + fn extension(self) -> &'static str { + match self { + Format::Csv => "csv", + Format::Json => "json", + } + } + + fn mime_type(self) -> &'static str { + match self { + Format::Csv => "text/csv", + Format::Json => "application/json", + } + } +} + +struct CsvRows { + null_to_empty: adw::SwitchRow, + line_break_to_space: adw::SwitchRow, + header_row: adw::SwitchRow, + sanitize_formulas: adw::SwitchRow, + delimiter: adw::ComboRow, + quote: adw::ComboRow, + line_break: adw::ComboRow, + decimal: adw::ComboRow, +} + +impl CsvRows { + fn show(&self, opts: &CsvOptions) { + self.null_to_empty.set_active(opts.null_to_empty); + self.line_break_to_space.set_active(opts.line_break_to_space); + self.header_row.set_active(opts.header_row); + self.sanitize_formulas.set_active(opts.sanitize_formulas); + self.delimiter + .set_selected(index_of(&CsvDelimiter::ALL, opts.delimiter)); + self.quote.set_selected(index_of(&CsvQuote::ALL, opts.quote)); + self.line_break + .set_selected(index_of(&CsvLineBreak::ALL, opts.line_break)); + self.decimal.set_selected(index_of(&CsvDecimal::ALL, opts.decimal)); + } + + fn read(&self) -> CsvOptions { + CsvOptions { + null_to_empty: self.null_to_empty.is_active(), + line_break_to_space: self.line_break_to_space.is_active(), + header_row: self.header_row.is_active(), + sanitize_formulas: self.sanitize_formulas.is_active(), + delimiter: pick(&CsvDelimiter::ALL, self.delimiter.selected()), + quote: pick(&CsvQuote::ALL, self.quote.selected()), + line_break: pick(&CsvLineBreak::ALL, self.line_break.selected()), + decimal: pick(&CsvDecimal::ALL, self.decimal.selected()), + } + } +} + +fn index_of(all: &[T], value: T) -> u32 { + all.iter().position(|v| *v == value).unwrap_or(0) as u32 +} + +fn pick(all: &[T], index: u32) -> T { + all[(index as usize).min(all.len() - 1)] +} + +fn switch_row(title: &str, subtitle: Option<&str>) -> adw::SwitchRow { + let row = adw::SwitchRow::builder().title(title).build(); + if let Some(subtitle) = subtitle { + row.set_subtitle(subtitle); + } + row +} + +fn combo_row(title: &str, choices: &[&str]) -> adw::ComboRow { + adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(choices)) + .build() +} + +pub fn present(parent: &adw::ApplicationWindow, toast_overlay: &adw::ToastOverlay, result: QueryResult, name: String) { + let prefs = Rc::new(RefCell::new(preferences::load())); + + let page = adw::PreferencesPage::new(); + + let format_group = adw::PreferencesGroup::new(); + let format_labels: Vec<&str> = Format::ALL.iter().map(|f| f.label()).collect(); + let format_row = combo_row(&crate::tr!("Format"), &format_labels); + let rows_label = crate::tr!("{n} rows").replace("{n}", &result.rows.len().to_string()); + format_row.set_subtitle(&rows_label); + format_group.add(&format_row); + page.add(&format_group); + + let csv_group = adw::PreferencesGroup::builder() + .title(crate::tr!("CSV options")) + .build(); + let rows = Rc::new(CsvRows { + null_to_empty: switch_row(&crate::tr!("Convert NULL to empty"), None), + line_break_to_space: switch_row(&crate::tr!("Convert line breaks to spaces"), None), + header_row: switch_row(&crate::tr!("Put field names in the first row"), None), + sanitize_formulas: switch_row( + &crate::tr!("Sanitize formula-like values"), + Some(&crate::tr!( + "Prefix values starting with =, +, - or @ so spreadsheets do not run them" + )), + ), + delimiter: combo_row( + &crate::tr!("Delimiter"), + &[ + &crate::tr!("Comma (,)"), + &crate::tr!("Semicolon (;)"), + &crate::tr!("Tab"), + &crate::tr!("Pipe (|)"), + ], + ), + quote: combo_row( + &crate::tr!("Quote"), + &[ + &crate::tr!("Always"), + &crate::tr!("Quote if needed"), + &crate::tr!("Never"), + ], + ), + line_break: combo_row(&crate::tr!("Line break"), &["LF (\\n)", "CRLF (\\r\\n)", "CR (\\r)"]), + decimal: combo_row( + &crate::tr!("Decimal separator"), + &[&crate::tr!("Period (.)"), &crate::tr!("Comma (,)")], + ), + }); + rows.show(&prefs.borrow().csv_export); + for row in [ + &rows.null_to_empty, + &rows.line_break_to_space, + &rows.header_row, + &rows.sanitize_formulas, + ] { + csv_group.add(row); + } + for row in [&rows.delimiter, &rows.quote, &rows.line_break, &rows.decimal] { + csv_group.add(row); + } + page.add(&csv_group); + + let persist = { + let rows = rows.clone(); + let prefs = prefs.clone(); + Rc::new(move || { + let mut prefs = prefs.borrow_mut(); + prefs.csv_export = rows.read(); + preferences::save(&prefs); + }) + }; + for row in [ + &rows.null_to_empty, + &rows.line_break_to_space, + &rows.header_row, + &rows.sanitize_formulas, + ] { + let persist = persist.clone(); + row.connect_active_notify(move |_| persist()); + } + for row in [&rows.delimiter, &rows.quote, &rows.line_break, &rows.decimal] { + let persist = persist.clone(); + row.connect_selected_notify(move |_| persist()); + } + + let csv_group_for_format = csv_group.clone(); + format_row.connect_selected_notify(move |row| { + csv_group_for_format.set_visible(pick(&Format::ALL, row.selected()) == Format::Csv); + }); + + let reset_button = gtk::Button::builder().label(crate::tr!("Reset to Defaults")).build(); + reset_button.add_css_class("flat"); + let rows_for_reset = rows.clone(); + reset_button.connect_clicked(move |_| rows_for_reset.show(&CsvOptions::default())); + + let export_button = gtk::Button::builder().label(crate::tr!("Export\u{2026}")).build(); + export_button.add_css_class("suggested-action"); + + let footer = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .margin_top(6) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) + .build(); + footer.append(&reset_button); + footer.append(>k::Box::builder().hexpand(true).build()); + footer.append(&export_button); + + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&adw::HeaderBar::new()); + toolbar.set_content(Some(&page)); + toolbar.add_bottom_bar(&footer); + + let dialog = adw::Dialog::builder() + .title(crate::tr!("Export Results")) + .content_width(480) + .child(&toolbar) + .build(); + dialog.set_default_widget(Some(&export_button)); + + let window = parent.clone(); + let toast_overlay = toast_overlay.clone(); + let dialog_for_export = dialog.clone(); + export_button.connect_clicked(move |_| { + let format = pick(&Format::ALL, format_row.selected()); + let options = rows.read(); + dialog_for_export.close(); + save_with_file_dialog(&window, &toast_overlay, format, &name, result.clone(), options); + }); + + dialog.present(Some(parent)); +} + +fn save_with_file_dialog( + parent: &adw::ApplicationWindow, + toast_overlay: &adw::ToastOverlay, + format: Format, + name: &str, + result: QueryResult, + options: CsvOptions, +) { + let filter = gtk::FileFilter::new(); + filter.set_name(Some(&crate::tr!("{format} files").replace("{format}", format.label()))); + filter.add_mime_type(format.mime_type()); + filter.add_suffix(format.extension()); + let filters = gio::ListStore::new::(); + filters.append(&filter); + let file_dialog = gtk::FileDialog::builder() + .title(crate::tr!("Export Results")) + .modal(true) + .initial_name(format!("{name}.{}", format.extension())) + .default_filter(&filter) + .filters(&filters) + .build(); + + let parent_for_alert = parent.clone(); + let toast_overlay = toast_overlay.clone(); + file_dialog.save(Some(parent), gio::Cancellable::NONE, move |outcome| { + let Ok(file) = outcome else { return }; + let Some(path) = file.path() else { return }; + let text = match format { + Format::Csv => export::render_csv(&result.columns, &result.rows, &options), + Format::Json => export::render_json(&result.columns, &result.rows), + }; + match std::fs::write(&path, text) { + Ok(()) => toast_overlay.add_toast(adw::Toast::new( + &crate::tr!("Exported to {path}").replace("{path}", &path.display().to_string()), + )), + Err(e) => { + let alert = adw::AlertDialog::new( + Some(&crate::tr!("Couldn't export")), + Some( + &crate::tr!("Writing {path} failed: {error}") + .replace("{path}", &path.display().to_string()) + .replace("{error}", &e.to_string()), + ), + ); + alert.add_response("close", &crate::tr!("Close")); + alert.set_default_response(Some("close")); + alert.set_close_response("close"); + alert.present(Some(&parent_for_alert)); + } + } + }); +} diff --git a/linux/crates/app/src/ui/grid.rs b/linux/crates/app/src/ui/grid.rs index 9671ba6bd4..c6fd35b344 100644 --- a/linux/crates/app/src/ui/grid.rs +++ b/linux/crates/app/src/ui/grid.rs @@ -4,6 +4,8 @@ use std::rc::Rc; use chrono::Datelike; use gtk4::prelude::*; use gtk4::{self as gtk, gio, glib}; +use relm4::adw; +use relm4::adw::prelude::*; use sourceview5::prelude::*; use tablepro_core::{ColumnInfo, QueryResult, Value}; @@ -29,10 +31,12 @@ pub enum GridMsg { CopyRowAsInsert { row_position: u32, }, - SetCellNull { + SetCellValue { row_position: u32, col_index: usize, + value: Value, }, + ExportResults(QueryResult), DeleteRowAt { row_position: u32, }, @@ -66,7 +70,8 @@ pub fn build_column_view( result: &QueryResult, schema_columns: &[ColumnInfo], table: &str, - edit_sender: Option>, + sender: relm4::Sender, + editable: bool, sort: Option<(usize, bool)>, sort_sender: Option>, connection_id: Option, @@ -83,14 +88,8 @@ pub fn build_column_view( .show_column_separators(true) .build(); - // Install the shared cell + empty-space context menus on the - // ColumnView. One PopoverMenu per menu shape (editable / readonly - // / empty-space) is parented here; cell gestures fill the shared - // CellContext slot then popup the appropriate popover. See - // `install_grid_context_menus` for the architecture. - let grid_menus: Option = edit_sender - .as_ref() - .map(|s| install_grid_context_menus(&column_view, s.clone())); + let grid_menus = + install_grid_context_menus(&column_view, sender.clone(), Rc::new(result.columns.clone()), editable); // For wide tables (~9+ columns) the default `expand: true` per // column shares the viewport fractionally and produces 20-30px @@ -113,13 +112,13 @@ pub fn build_column_view( // hasn't fired yet we fall back to the QueryResult's column // metadata, which only knows name + data_type and conservatively // reports the rest as false. - let editable = is_cell_editable(schema_columns.get(i).unwrap_or(column)); + let cell_editable = editable && is_cell_editable(schema_columns.get(i).unwrap_or(column)); let col = build_column( column, i, - editable, + cell_editable, table.to_string(), - edit_sender.clone(), + sender.clone(), sort_sender.clone(), connection_id, tab_ctx.clone(), @@ -197,73 +196,60 @@ fn build_column( idx: usize, editable: bool, table: String, - sender: Option>, + sender: relm4::Sender, sort_sender: Option>, connection_id: Option, tab_ctx: TabGridContext, default_min_width: Option, column_view: gtk::ColumnView, - grid_menus: Option, + grid_menus: GridMenus, ) -> gtk::ColumnViewColumn { let factory = gtk::SignalListItemFactory::new(); - // Editable cells require a sender to dispatch CellEdited / SetCellNull / - // CopyRowAsInsert events. When `sender` is None (read-only result grids - // in the editor) we always go through the read-only setup path. - let edit_sender = if editable { sender.clone() } else { None }; - let readonly_sender = sender.clone(); let table_for_persist = table; let column_data_type = info.data_type.clone(); let column_name = info.name.clone(); let column_view_for_setup = column_view.clone(); - let grid_menus_for_setup = grid_menus.clone(); factory.connect_setup(move |_, item| { let Some(item) = item.downcast_ref::() else { return; }; - if let Some(edit_sender) = edit_sender.clone() { - // Type-specific cell widgets per HIG: - // - Bool → GtkCheckButton (single-click toggles, native - // Space, no edit-mode dance). - // - Date → CellEditor for display + GtkCalendar - // popover for edit (no inline typing; user picks a day). - // - Other types → CellEditor + text parsing on - // commit (parse_input_for_column on the receiving side - // coerces to the right native Value variant). - if is_bool_type(&column_data_type) { - setup_bool_cell( - item, - idx, - column_name.clone(), - edit_sender, - &column_view_for_setup, - grid_menus_for_setup.as_ref(), - ); - } else { - let editor_kind = classify_editor_kind(&column_data_type); - setup_editable_cell( - item, - idx, - column_name.clone(), - edit_sender, - editor_kind, - &column_view_for_setup, - grid_menus_for_setup.as_ref(), - ); - } + if !editable { + setup_readonly_cell(item, idx, column_name.clone(), &column_view_for_setup, &grid_menus); + return; + } + // Type-specific cell widgets per HIG: + // - Bool → GtkCheckButton (single-click toggles, native + // Space, no edit-mode dance). + // - Date → CellEditor for display + GtkCalendar + // popover for edit (no inline typing; user picks a day). + // - Other types → CellEditor + text parsing on + // commit (parse_input_for_column on the receiving side + // coerces to the right native Value variant). + if is_bool_type(&column_data_type) { + setup_bool_cell( + item, + idx, + column_name.clone(), + sender.clone(), + &column_view_for_setup, + &grid_menus, + ); } else { - setup_readonly_cell( + let editor_kind = classify_editor_kind(&column_data_type); + setup_editable_cell( item, idx, column_name.clone(), - readonly_sender.clone(), + sender.clone(), + editor_kind, &column_view_for_setup, - grid_menus_for_setup.as_ref(), + &grid_menus, ); } }); - let editable_for_bind = editable && sender.is_some(); + let editable_for_bind = editable; // Columns the database auto-fills on INSERT (auto-increment PKs, // generated columns) render their NULL placeholder as `(auto)` // rather than the generic `` / `NULL` sentinels — the user @@ -541,7 +527,7 @@ fn setup_editable_cell( sender: relm4::Sender, editor_kind: CellEditorKind, column_view: >k::ColumnView, - menus: Option<&GridMenus>, + menus: &GridMenus, ) { let label = super::cell_editor::CellEditor::new(); label.set_hexpand(true); @@ -554,17 +540,7 @@ fn setup_editable_cell( COLUMN_SLOT.set(&label, idx); item.set_child(Some(&label)); - if let Some(menus) = menus { - attach_cell_gesture( - label.upcast_ref(), - column_view, - idx, - column_name, - true, - true, // CellEditor → text-editable, "Edit cell" applies - menus, - ); - } + attach_cell_gesture(label.upcast_ref(), column_view, idx, column_name, true, true, menus); install_edit_commit_handler(&label, idx, sender.clone()); install_edit_triggers(&label, idx, sender, editor_kind); } @@ -582,7 +558,7 @@ fn setup_bool_cell( column_name: String, sender: relm4::Sender, column_view: >k::ColumnView, - menus: Option<&GridMenus>, + menus: &GridMenus, ) { let checkbox = gtk::CheckButton::builder() .halign(gtk::Align::Start) @@ -593,17 +569,7 @@ fn setup_bool_cell( COLUMN_SLOT.set(&checkbox, idx); item.set_child(Some(&checkbox)); - if let Some(menus) = menus { - attach_cell_gesture( - checkbox.upcast_ref(), - column_view, - idx, - column_name, - true, - false, // CheckButton: editable but not text-editable - menus, - ); - } + attach_cell_gesture(checkbox.upcast_ref(), column_view, idx, column_name, true, false, menus); checkbox.connect_toggled(move |cb| { // Suppress the echo while the bind callback is driving the // checkbox programmatically. @@ -825,16 +791,12 @@ fn move_focus(widget: &impl IsA, direction: gtk::DirectionType) { window.child_focus(direction); } -/// Setup a read-only cell — plain `gtk::Label` with selectable text + -/// ellipsis on overflow, plus the context menu (Copy value, Copy row -/// as INSERT) for non-mutating actions. fn setup_readonly_cell( item: >k::ListItem, idx: usize, column_name: String, - _sender: Option>, column_view: >k::ColumnView, - menus: Option<&GridMenus>, + menus: &GridMenus, ) { let label = gtk::Label::builder() .xalign(0.0) @@ -845,9 +807,7 @@ fn setup_readonly_cell( .margin_end(8) .build(); item.set_child(Some(&label)); - if let Some(menus) = menus { - attach_cell_gesture(label.upcast_ref(), column_view, idx, column_name, false, false, menus); - } + attach_cell_gesture(label.upcast_ref(), column_view, idx, column_name, false, false, menus); } /// Capture-phase double-click + key handler bundle. Routes F2 / Enter @@ -1248,129 +1208,226 @@ struct CellContext { column_name: String, } -/// Per-grid context-menu surface. Built once in -/// `install_grid_context_menus` and threaded into each cell's -/// `attach_cell_gesture` so every cell shares the same popover + -/// action group rather than constructing its own. #[derive(Clone)] pub(super) struct GridMenus { context: Rc>>, - /// Popover rendered when the user right-clicks an editable cell - /// (CellEditor / CheckButton). Holds the full Edit / Copy / - /// Mutate menu. The Edit-cell action's `enabled` state is toggled - /// per-press; menu items with `hidden-when="action-disabled"` - /// disappear from the popover when their action is disabled, so - /// bool cells never see a useless "Edit cell" entry. editable_popover: gtk::PopoverMenu, - /// Popover rendered for read-only cells (auto-PK, generated - /// columns). Just the copy actions. readonly_popover: gtk::PopoverMenu, - /// `cell.edit` action exposed so cell gestures can toggle its - /// enabled state per right-click without a fresh action-group - /// lookup. Only enabled when the right-clicked cell is text- - /// editable (CellEditor); disabled for CheckButton (bool) cells. edit_action: gio::SimpleAction, } -/// Install the shared cell + empty-space context menus on the -/// ColumnView. Called once from `build_column_view` for editable -/// grids; read-only result grids skip this. -/// -/// One PopoverMenu per menu shape (editable / readonly / empty), -/// parented to the ColumnView. Cell gestures fill the shared -/// `CellContext` slot then popup the appropriate popover; action -/// handlers read from the slot, dispatch the right `GridMsg`. This -/// is the GTK4 idiomatic pattern — same as GtkTabBar's tab context -/// menu, GNOME Files's row context menu, etc. — and replaces the -/// old "one popover + one action group per cell widget" approach -/// which carried ~70 popover instances on a typical 7-column grid. -fn install_grid_context_menus(column_view: >k::ColumnView, sender: relm4::Sender) -> GridMenus { - let context: Rc>> = Rc::new(RefCell::new(None)); +struct MenuShape { + edit_cell: bool, + set_value: bool, + row_ops: bool, +} - // Editable-cell menu model. The Edit-cell item is marked - // hidden-when="action-disabled" so when the cell is a CheckButton - // (no text edit mode) the item disappears entirely instead of - // rendering greyed out. - let editable_menu = gio::Menu::new(); - let edit_section = gio::Menu::new(); - let edit_item = gio::MenuItem::new(Some(&crate::tr!("Edit cell")), Some("cell.edit")); - edit_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); - edit_section.append_item(&edit_item); - editable_menu.append_section(None, &edit_section); +fn build_cell_menu(shape: MenuShape) -> gio::Menu { + let menu = gio::Menu::new(); + if shape.edit_cell { + let edit_section = gio::Menu::new(); + let edit_item = gio::MenuItem::new(Some(&crate::tr!("Edit cell")), Some("cell.edit")); + edit_item.set_attribute_value("hidden-when", Some(&"action-disabled".to_variant())); + edit_section.append_item(&edit_item); + menu.append_section(None, &edit_section); + } + + let copy_as = gio::Menu::new(); + copy_as.append(Some(&crate::tr!("Rows")), Some("cell.copy-rows")); + copy_as.append(Some(&crate::tr!("With Headers")), Some("cell.copy-rows-headers")); + copy_as.append(Some(&crate::tr!("JSON")), Some("cell.copy-json")); + copy_as.append(Some(&crate::tr!("CSV")), Some("cell.copy-csv")); + copy_as.append(Some(&crate::tr!("CSV with Headers")), Some("cell.copy-csv-headers")); + copy_as.append(Some(&crate::tr!("Markdown")), Some("cell.copy-markdown")); + copy_as.append(Some(&crate::tr!("IN Clause")), Some("cell.copy-in-clause")); + if shape.row_ops { + let sql_section = gio::Menu::new(); + sql_section.append(Some(&crate::tr!("INSERT Statement")), Some("cell.copy-row-insert")); + copy_as.append_section(None, &sql_section); + } let copy_section = gio::Menu::new(); - copy_section.append(Some(&crate::tr!("Copy value")), Some("cell.copy-value")); + copy_section.append(Some(&crate::tr!("Copy")), Some("cell.copy")); + copy_section.append_submenu(Some(&crate::tr!("Copy as")), ©_as); copy_section.append(Some(&crate::tr!("Copy column name")), Some("cell.copy-column-name")); - copy_section.append(Some(&crate::tr!("Copy row as INSERT")), Some("cell.copy-row-insert")); - editable_menu.append_section(None, ©_section); - let mutate_section = gio::Menu::new(); - mutate_section.append(Some(&crate::tr!("Insert row")), Some("cell.insert-row")); - mutate_section.append(Some(&crate::tr!("Duplicate row")), Some("cell.duplicate-row")); - mutate_section.append(Some(&crate::tr!("Set to NULL")), Some("cell.set-null")); - mutate_section.append(Some(&crate::tr!("Delete row")), Some("cell.delete-row")); - editable_menu.append_section(None, &mutate_section); - - // Read-only cell menu — copy actions only. Auto-PK / generated - // cells aren't user-editable so Edit / Set NULL / mutate items - // would all be inert; per HIG don't show inert items. - let readonly_menu = gio::Menu::new(); - let copy_section_ro = gio::Menu::new(); - copy_section_ro.append(Some(&crate::tr!("Copy value")), Some("cell.copy-value")); - copy_section_ro.append(Some(&crate::tr!("Copy column name")), Some("cell.copy-column-name")); - copy_section_ro.append(Some(&crate::tr!("Copy row as INSERT")), Some("cell.copy-row-insert")); - readonly_menu.append_section(None, ©_section_ro); - - // Empty-area-below-last-row menu — single Insert row entry. + menu.append_section(None, ©_section); + + let json_section = gio::Menu::new(); + json_section.append(Some(&crate::tr!("Show Row as JSON")), Some("cell.show-row-json")); + menu.append_section(None, &json_section); + + let action_section = gio::Menu::new(); + if shape.set_value { + let set_value = gio::Menu::new(); + set_value.append(Some(&crate::tr!("Empty")), Some("cell.set-empty")); + set_value.append(Some("NULL"), Some("cell.set-null")); + action_section.append_submenu(Some(&crate::tr!("Set Value")), &set_value); + } + action_section.append(Some(&crate::tr!("Export Results\u{2026}")), Some("cell.export")); + if shape.row_ops { + action_section.append(Some(&crate::tr!("Insert row")), Some("cell.insert-row")); + action_section.append(Some(&crate::tr!("Duplicate")), Some("cell.duplicate-row")); + action_section.append(Some(&crate::tr!("Delete")), Some("cell.delete-row")); + } + menu.append_section(None, &action_section); + menu +} + +fn install_grid_context_menus( + column_view: >k::ColumnView, + sender: relm4::Sender, + columns: Rc>, + editable: bool, +) -> GridMenus { + let context: Rc>> = Rc::new(RefCell::new(None)); + + let editable_menu = build_cell_menu(MenuShape { + edit_cell: true, + set_value: true, + row_ops: true, + }); + let row_ops_menu = build_cell_menu(MenuShape { + edit_cell: false, + set_value: false, + row_ops: true, + }); + let readonly_menu = build_cell_menu(MenuShape { + edit_cell: false, + set_value: false, + row_ops: false, + }); let empty_menu = gio::Menu::new(); empty_menu.append(Some(&crate::tr!("Insert row")), Some("cell.insert-row")); - // Single action group on the ColumnView. Every popover (editable, - // readonly, empty) resolves `cell.*` actions through this one - // group via the muxer chain, so menu-item activations always - // reach a live handler. let group = gio::SimpleActionGroup::new(); - let edit_action = { - let ctx = context.clone(); - gio::ActionEntry::builder("edit") - .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() - && let Ok(label) = slot.widget.clone().downcast::() - { - enter_edit_mode(&label); - } + let slot_position = |slot: &CellContext| POSITION_SLOT.get(&slot.widget).unwrap_or(0); + + macro_rules! cell_action { + ($name:literal, |$slot:ident| $body:expr) => {{ + let ctx = context.clone(); + gio::ActionEntry::builder($name) + .activate(move |_, _, _| { + if let Some($slot) = ctx.borrow().as_ref() { + $body; + } + }) + .build() + }}; + } + macro_rules! copy_action { + ($name:literal, |$slot:ident, $rows:ident| $text:expr) => {{ + let s = sender.clone(); + let cv = column_view.clone(); + cell_action!($name, |$slot| { + let $rows = rows_for_copy(&cv, slot_position($slot)); + s.send(GridMsg::CopyToClipboard($text)).ok(); }) - .build() + }}; + } + macro_rules! send_action { + ($name:literal, |$slot:ident| $msg:expr) => {{ + let s = sender.clone(); + cell_action!($name, |$slot| s.send($msg).ok()) + }}; + } + + let edit_action = cell_action!("edit", |slot| { + if let Ok(label) = slot.widget.clone().downcast::() { + enter_edit_mode(&label); + } + }); + let copy_action = { + let cols = columns.clone(); + copy_action!("copy", |slot, rows| if rows.len() > 1 { + tablepro_core::export::render_tsv(&cols, &rows, false) + } else { + cell_text(&slot.widget) + }) }; - let copy_value_action = { - let ctx = context.clone(); - let s = sender.clone(); - gio::ActionEntry::builder("copy-value") - .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() { - s.send(GridMsg::CopyToClipboard(cell_text(&slot.widget))).ok(); - } - }) - .build() + let copy_rows_action = { + let cols = columns.clone(); + copy_action!("copy-rows", |_slot, rows| tablepro_core::export::render_tsv( + &cols, &rows, false + )) }; - let copy_column_name_action = { - let ctx = context.clone(); - let s = sender.clone(); - gio::ActionEntry::builder("copy-column-name") - .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() { - s.send(GridMsg::CopyToClipboard(slot.column_name.clone())).ok(); - } - }) - .build() + let copy_rows_headers_action = { + let cols = columns.clone(); + copy_action!("copy-rows-headers", |_slot, rows| tablepro_core::export::render_tsv( + &cols, &rows, true + )) + }; + let copy_json_action = { + let cols = columns.clone(); + copy_action!("copy-json", |_slot, rows| tablepro_core::export::render_json( + &cols, &rows + )) + }; + let copy_csv_action = { + let cols = columns.clone(); + copy_action!("copy-csv", |_slot, rows| tablepro_core::export::render_csv( + &cols, + &rows, + &tablepro_core::export::CsvOptions { + header_row: false, + ..Default::default() + } + )) + }; + let copy_csv_headers_action = { + let cols = columns.clone(); + copy_action!("copy-csv-headers", |_slot, rows| tablepro_core::export::render_csv( + &cols, + &rows, + &tablepro_core::export::CsvOptions::default() + )) + }; + let copy_markdown_action = { + let cols = columns.clone(); + copy_action!("copy-markdown", |_slot, rows| tablepro_core::export::render_markdown( + &cols, &rows + )) }; - let copy_row_action = { - let ctx = context.clone(); + let copy_in_clause_action = copy_action!("copy-in-clause", |slot, rows| { + tablepro_core::export::render_in_clause(&rows, slot.col_index) + }); + let copy_column_name_action = send_action!("copy-column-name", |slot| GridMsg::CopyToClipboard( + slot.column_name.clone() + )); + let copy_row_insert_action = send_action!("copy-row-insert", |slot| GridMsg::CopyRowAsInsert { + row_position: slot_position(slot), + }); + let show_row_json_action = { + let cols = columns.clone(); + let cv = column_view.clone(); + cell_action!("show-row-json", |slot| { + if let Some(row) = row_at(&cv, slot_position(slot)) { + let json = tablepro_core::export::row_to_json(&cols, &row.cells_clone()); + let text = serde_json::to_string_pretty(&json).unwrap_or_default(); + show_row_json_dialog(&cv, text); + } + }) + }; + let set_empty_action = send_action!("set-empty", |slot| GridMsg::SetCellValue { + row_position: slot_position(slot), + col_index: slot.col_index, + value: Value::Text(String::new()), + }); + let set_null_action = send_action!("set-null", |slot| GridMsg::SetCellValue { + row_position: slot_position(slot), + col_index: slot.col_index, + value: Value::Null, + }); + let export_action = { let s = sender.clone(); - gio::ActionEntry::builder("copy-row-insert") + let cv = column_view.clone(); + let cols = columns.clone(); + gio::ActionEntry::builder("export") .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() { - let position = POSITION_SLOT.get(&slot.widget).unwrap_or(0); - s.send(GridMsg::CopyRowAsInsert { row_position: position }).ok(); - } + let result = QueryResult { + columns: cols.as_ref().clone(), + rows: all_rows(&cv), + truncated: false, + }; + s.send(GridMsg::ExportResults(result)).ok(); }) .build() }; @@ -1382,53 +1439,29 @@ fn install_grid_context_menus(column_view: >k::ColumnView, sender: relm4::Send }) .build() }; - let set_null_action = { - let ctx = context.clone(); - let s = sender.clone(); - gio::ActionEntry::builder("set-null") - .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() { - let position = POSITION_SLOT.get(&slot.widget).unwrap_or(0); - s.send(GridMsg::SetCellNull { - row_position: position, - col_index: slot.col_index, - }) - .ok(); - } - }) - .build() - }; - let delete_row_action = { - let ctx = context.clone(); - let s = sender.clone(); - gio::ActionEntry::builder("delete-row") - .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() { - let position = POSITION_SLOT.get(&slot.widget).unwrap_or(0); - s.send(GridMsg::DeleteRowAt { row_position: position }).ok(); - } - }) - .build() - }; - let duplicate_row_action = { - let ctx = context.clone(); - let s = sender; - gio::ActionEntry::builder("duplicate-row") - .activate(move |_, _, _| { - if let Some(slot) = ctx.borrow().as_ref() { - let position = POSITION_SLOT.get(&slot.widget).unwrap_or(0); - s.send(GridMsg::DuplicateRow { row_position: position }).ok(); - } - }) - .build() - }; + let delete_row_action = send_action!("delete-row", |slot| GridMsg::DeleteRowAt { + row_position: slot_position(slot), + }); + let duplicate_row_action = send_action!("duplicate-row", |slot| GridMsg::DuplicateRow { + row_position: slot_position(slot), + }); group.add_action_entries([ edit_action, - copy_value_action, + copy_action, + copy_rows_action, + copy_rows_headers_action, + copy_json_action, + copy_csv_action, + copy_csv_headers_action, + copy_markdown_action, + copy_in_clause_action, copy_column_name_action, - copy_row_action, - insert_row_action, + copy_row_insert_action, + show_row_json_action, + set_empty_action, set_null_action, + export_action, + insert_row_action, delete_row_action, duplicate_row_action, ]); @@ -1440,58 +1473,136 @@ fn install_grid_context_menus(column_view: >k::ColumnView, sender: relm4::Send .downcast::() .expect("ActionEntry registers SimpleAction"); - // Build the popovers and parent eagerly so each PopoverMenu's - // action muxer snapshots the ColumnView's `cell` group at - // set_parent() time. (Lazy parenting in the gesture handler - // creates the popover in a standalone muxer scope where the - // group isn't visible and every menu-item click is silently - // dropped — see sidebar_row.rs:146 for the same root-cause.) - let editable_popover = gtk::PopoverMenu::from_model(Some(&editable_menu)); - editable_popover.set_has_arrow(true); - editable_popover.set_parent(column_view); - let readonly_popover = gtk::PopoverMenu::from_model(Some(&readonly_menu)); - readonly_popover.set_has_arrow(true); - readonly_popover.set_parent(column_view); - let empty_popover = gtk::PopoverMenu::from_model(Some(&empty_menu)); - empty_popover.set_has_arrow(true); - empty_popover.set_parent(column_view); - - let editable_for_destroy = editable_popover.clone(); - let readonly_for_destroy = readonly_popover.clone(); - let empty_for_destroy = empty_popover.clone(); + // Popovers are parented eagerly so each PopoverMenu's action muxer + // snapshots the ColumnView's `cell` group at set_parent() time. + // Lazy parenting in the gesture handler drops every activation + // silently (see sidebar_row.rs for the same root cause). + let make_popover = |model: &gio::Menu| { + let popover = gtk::PopoverMenu::from_model_full(model, gtk::PopoverMenuFlags::NESTED); + popover.set_has_arrow(true); + popover.set_parent(column_view); + popover + }; + let editable_popover = make_popover(&editable_menu); + let row_ops_popover = make_popover(&row_ops_menu); + let readonly_popover = make_popover(&readonly_menu); + let empty_popover = make_popover(&empty_menu); + + let popovers_for_destroy = [ + editable_popover.clone(), + row_ops_popover.clone(), + readonly_popover.clone(), + empty_popover.clone(), + ]; column_view.connect_destroy(move |_| { - editable_for_destroy.unparent(); - readonly_for_destroy.unparent(); - empty_for_destroy.unparent(); - }); - - // Empty-space gesture on the ColumnView itself. Per-cell gestures - // claim their own sequences; only true empty-area clicks reach - // here (gated by `pick(x, y) == column_view`). - let cv_for_empty = column_view.clone(); - let empty_for_gesture = empty_popover; - let empty_gesture = gtk::GestureClick::builder().button(3).build(); - empty_gesture.connect_pressed(move |g, _, x, y| { - let cv_widget: gtk::Widget = cv_for_empty.clone().upcast(); - if let Some(picked) = cv_for_empty.pick(x, y, gtk::PickFlags::DEFAULT) - && picked != cv_widget - { - return; + for popover in &popovers_for_destroy { + popover.unparent(); } - g.set_state(gtk::EventSequenceState::Claimed); - empty_for_gesture.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); - empty_for_gesture.popup(); }); - column_view.add_controller(empty_gesture); + + if editable { + let cv_for_empty = column_view.clone(); + let empty_gesture = gtk::GestureClick::builder().button(3).build(); + empty_gesture.connect_pressed(move |g, _, x, y| { + let cv_widget: gtk::Widget = cv_for_empty.clone().upcast(); + if let Some(picked) = cv_for_empty.pick(x, y, gtk::PickFlags::DEFAULT) + && picked != cv_widget + { + return; + } + g.set_state(gtk::EventSequenceState::Claimed); + empty_popover.set_pointing_to(Some(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + empty_popover.popup(); + }); + column_view.add_controller(empty_gesture); + } GridMenus { context, editable_popover, - readonly_popover, + readonly_popover: if editable { row_ops_popover } else { readonly_popover }, edit_action: edit_action_obj, } } +pub(super) fn selected_positions(selection: >k::MultiSelection) -> Vec { + let bitset = selection.selection(); + let mut out = Vec::with_capacity(bitset.size() as usize); + for i in 0..bitset.size() { + out.push(bitset.nth(i as u32)); + } + out.sort_unstable(); + out +} + +fn row_at(column_view: >k::ColumnView, position: u32) -> Option { + column_view.model()?.item(position)?.downcast::().ok() +} + +fn rows_for_copy(column_view: >k::ColumnView, clicked: u32) -> Vec> { + let mut positions = column_view + .model() + .and_then(|m| m.downcast::().ok()) + .map(|s| selected_positions(&s)) + .unwrap_or_default(); + if positions.is_empty() { + positions.push(clicked); + } + positions + .iter() + .filter_map(|p| row_at(column_view, *p)) + .map(|r| r.cells_clone()) + .collect() +} + +fn all_rows(column_view: >k::ColumnView) -> Vec> { + let Some(model) = column_view.model() else { + return Vec::new(); + }; + (0..model.n_items()) + .filter_map(|p| row_at(column_view, p)) + .map(|r| r.cells_clone()) + .collect() +} + +fn show_row_json_dialog(parent: &impl IsA, json: String) { + let buffer = sourceview5::Buffer::new(None); + if let Some(lang) = sourceview5::LanguageManager::default().language("json") { + buffer.set_language(Some(&lang)); + } + let scheme_name = if adw::StyleManager::default().is_dark() { + "Adwaita-dark" + } else { + "Adwaita" + }; + buffer.set_style_scheme(sourceview5::StyleSchemeManager::default().scheme(scheme_name).as_ref()); + buffer.set_text(&json); + let view = sourceview5::View::with_buffer(&buffer); + view.set_editable(false); + view.set_monospace(true); + view.set_show_line_numbers(true); + view.set_top_margin(8); + view.set_left_margin(8); + let scrolled = gtk::ScrolledWindow::builder().child(&view).vexpand(true).build(); + + let copy_button = gtk::Button::from_icon_name("edit-copy-symbolic"); + copy_button.set_tooltip_text(Some(&crate::tr!("Copy"))); + copy_button.connect_clicked(move |b| b.clipboard().set_text(&json)); + let header = adw::HeaderBar::new(); + header.pack_end(©_button); + let toolbar = adw::ToolbarView::new(); + toolbar.add_top_bar(&header); + toolbar.set_content(Some(&scrolled)); + + adw::Dialog::builder() + .title(crate::tr!("Row as JSON")) + .content_width(560) + .content_height(480) + .child(&toolbar) + .build() + .present(Some(parent)); +} + /// Wire the right-click + Menu-key gestures on a single cell widget /// against the shared `GridMenus`. The cell stores its identity /// (widget, idx, column_name, is_text_editable) into the shared diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index b733005225..4515ec9bdb 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -5,6 +5,7 @@ mod connect_dialog; mod connection_row; mod editor; pub(crate) mod error_text; +mod export_dialog; mod filter_strip; mod grid; mod history_dialog; diff --git a/linux/crates/app/src/ui/preferences.rs b/linux/crates/app/src/ui/preferences.rs index 45172e32e4..c21901ef90 100644 --- a/linux/crates/app/src/ui/preferences.rs +++ b/linux/crates/app/src/ui/preferences.rs @@ -174,6 +174,7 @@ pub fn present(parent: &impl IsA) { editor_font_size: font.value() as u32, history_retention_days: retention.value() as u32, query_timeout_secs: timeout.value() as u32, + csv_export: preferences::load().csv_export, }); }) }; diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 40f52763f2..eb0093c731 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -6,6 +6,7 @@ crates/app/src/ui/connect_dialog.rs crates/app/src/ui/edit_dialog.rs crates/app/src/ui/editor.rs crates/app/src/ui/error_text.rs +crates/app/src/ui/export_dialog.rs crates/app/src/ui/grid.rs crates/app/src/ui/history_dialog.rs crates/app/src/ui/insert_dialog.rs From 66d598c135ba1c7b2dfd712706f9febacb99642a Mon Sep 17 00:00:00 2001 From: Serge Lapin Date: Mon, 7 Sep 2026 20:32:51 +0300 Subject: [PATCH 3/5] docs(linux): mark export and context menu work in ROADMAP --- linux/ROADMAP.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index a232bb065a..bfc34edbb6 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -131,14 +131,17 @@ Exit criterion: a developer can demo the basic flows (connect, browse, edit, que - [x] ORDER BY wired to `GtkColumnView` header click → server sort - [x] Multi-row select via shift-click + Ctrl-click - [x] Bulk delete with confirmation -- [x] Right-click context menu (copy cell, copy row as INSERT, copy column, set NULL, delete row) +- [x] Right-click context menu (copy, copy as, set value, export, insert, duplicate, delete) - [x] Save column widths per (connection, table) - [ ] Save column order per (connection, table) ### Export / import (~1 week) -- [ ] Export current grid to CSV / JSON / SQL INSERT / Markdown -- [ ] Export with options: include headers, quote style, line endings, UTF-8 BOM toggle +- [x] Export current grid to CSV / JSON from the result grid's right-click menu and the paginator (query results included) +- [x] Export with CSV options: NULL handling, line breaks, header row, formula sanitizing, delimiter, quote style, line endings, decimal separator +- [ ] Export as SQL INSERT / Markdown / HTML / XML / XLSX +- [x] Copy as Rows / With Headers / JSON / CSV / Markdown / IN Clause, Show Row as JSON +- [ ] Paste rows from clipboard; Set Value > NOW() / CURRENT_TIMESTAMP (needs raw SQL expressions in the change tracker) - [ ] Import CSV → table (with column mapping dialog) - [ ] Run SQL file (load + execute via SQL editor) From 0783a054764591e84ddb0ef714b90a46a6bd00a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 8 Sep 2026 15:05:30 +0700 Subject: [PATCH 4/5] fix(datagrid): copy and export the values the grid is showing Claude-Session: https://claude.ai/code/session_01XgEpnwvzBYDyjimPu4nXr5 --- linux/crates/app/src/ui/app/workspace_tabs.rs | 12 +- linux/crates/app/src/ui/browse_tab.rs | 149 ++-- linux/crates/app/src/ui/editor.rs | 50 +- linux/crates/app/src/ui/grid.rs | 669 +++++++++++++----- linux/crates/core/src/export.rs | 200 +++++- 5 files changed, 811 insertions(+), 269 deletions(-) diff --git a/linux/crates/app/src/ui/app/workspace_tabs.rs b/linux/crates/app/src/ui/app/workspace_tabs.rs index 35cf0682ea..82dece1233 100644 --- a/linux/crates/app/src/ui/app/workspace_tabs.rs +++ b/linux/crates/app/src/ui/app/workspace_tabs.rs @@ -170,10 +170,8 @@ impl App { SqlEditorOutput::RunStateChanged(running) => AppMsg::EditorTabRunStateChanged(tab_id, running), SqlEditorOutput::QueryChanged(text) => AppMsg::EditorTabQueryChanged(tab_id, text), SqlEditorOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), - SqlEditorOutput::ExportResults(result) => AppMsg::ExportResults { - result, - name: "results".to_string(), - }, + SqlEditorOutput::ShowToast(msg) => AppMsg::ShowToast(msg), + SqlEditorOutput::ExportResults { result, name } => AppMsg::ExportResults { result, name }, }); let page = tab_view_for_create.append(editor.widget()); let editor_count = workspace_tabs_for_create @@ -508,10 +506,8 @@ impl App { SqlEditorOutput::RunStateChanged(running) => AppMsg::EditorTabRunStateChanged(tab_id, running), SqlEditorOutput::QueryChanged(text) => AppMsg::EditorTabQueryChanged(tab_id, text), SqlEditorOutput::CopyToClipboard(text) => AppMsg::CopyToClipboard(text), - SqlEditorOutput::ExportResults(result) => AppMsg::ExportResults { - result, - name: "results".to_string(), - }, + SqlEditorOutput::ShowToast(msg) => AppMsg::ShowToast(msg), + SqlEditorOutput::ExportResults { result, name } => AppMsg::ExportResults { result, name }, }); let page = tab_view.append(editor.widget()); let label = match query.trim().is_empty() { diff --git a/linux/crates/app/src/ui/browse_tab.rs b/linux/crates/app/src/ui/browse_tab.rs index cd64a87618..8ea566a3aa 100644 --- a/linux/crates/app/src/ui/browse_tab.rs +++ b/linux/crates/app/src/ui/browse_tab.rs @@ -8,7 +8,7 @@ use uuid::Uuid; use tablepro_core::{ColumnInfo, QueryResult, Value}; -use super::grid::{GridMsg, TabGridContext, build_column_view}; +use super::grid::{CellPreset, GridMsg, TabGridContext, build_column_view}; const PAGE_SIZE_OPTIONS: &[u64] = &[100, 500, 1_000, 5_000, 10_000]; const DEFAULT_PAGE_SIZE: u64 = 1_000; @@ -193,11 +193,18 @@ pub enum BrowseTabInput { col_index: usize, new_value: String, }, + /// 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. GridSetCellValue { row_position: u32, col_index: usize, - value: Value, + preset: CellPreset, }, + /// The grid could not carry out a menu action in full and wants + /// to say so. + GridShowToast(String), GridExportResults(QueryResult), ExportCurrentPage, GridDeleteRowAt { @@ -328,6 +335,57 @@ impl BrowseTab { } } + /// The tracker context the grid renders through. Copy and export + /// read the same context, so what leaves the tab is what the user + /// is looking at, pending edits included. + fn grid_context(&self) -> TabGridContext { + TabGridContext { + tab_id: Some(self.tab_id), + pk_col_indices: 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. + 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(), + )) + } + + /// 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) -> Result { + match preset { + CellPreset::Null => Ok(Value::Null), + CellPreset::Empty => { + let col = self.current_columns.get(col_index); + match col { + Some(c) if super::grid::column_accepts_empty(&c.data_type) => Ok(Value::Text(String::new())), + _ => parse_input_for_column("", col), + } + } + } + } + pub fn columns(&self) -> &[ColumnInfo] { &self.current_columns } @@ -946,17 +1004,7 @@ impl BrowseTab { // changed (rare in practice — would require schema migration // mid-session). Build the full column-view scaffolding. clear_box(&self.grid_holder); - let pk_col_indices: Vec = self - .current_columns - .iter() - .enumerate() - .filter(|(_, c)| c.primary_key) - .map(|(i, _)| i) - .collect(); - let tab_ctx = TabGridContext { - tab_id: Some(self.tab_id), - pk_col_indices, - }; + let tab_ctx = self.grid_context(); let (column_view, selection) = build_column_view( &result, &self.current_columns, @@ -1468,7 +1516,7 @@ impl SimpleComponent for BrowseTab { .send(GridMsg::SetCellValue { row_position, col_index, - value: Value::Null, + preset: CellPreset::Null, }) .ok(); glib::Propagation::Stop @@ -1585,15 +1633,16 @@ impl SimpleComponent for BrowseTab { new_value, }, GridMsg::CopyToClipboard(text) => BrowseTabInput::GridCopyToClipboard(text), + GridMsg::ShowToast(text) => BrowseTabInput::GridShowToast(text), GridMsg::CopyRowAsInsert { row_position } => BrowseTabInput::GridCopyRowAsInsert { row_position }, GridMsg::SetCellValue { row_position, col_index, - value, + preset, } => BrowseTabInput::GridSetCellValue { row_position, col_index, - value, + preset, }, GridMsg::ExportResults(result) => BrowseTabInput::GridExportResults(result), GridMsg::DeleteRowAt { row_position } => BrowseTabInput::GridDeleteRowAt { row_position }, @@ -2145,8 +2194,15 @@ impl SimpleComponent for BrowseTab { BrowseTabInput::GridSetCellValue { row_position, col_index, - value, + preset, } => { + let value = match self.resolve_cell_preset(preset, col_index) { + Ok(value) => value, + Err(message) => { + let _ = sender.output(BrowseTabOutput::ShowToast(message)); + return; + } + }; if let Some(row_obj) = self.row_object_at(row_position) && let Some(draft_id) = row_obj.draft_id() { @@ -2164,14 +2220,22 @@ impl SimpleComponent for BrowseTab { t.track_cell_edit(key, col_index, original, value); }); } - BrowseTabInput::GridExportResults(result) => { + BrowseTabInput::GridShowToast(message) => { + let _ = sender.output(BrowseTabOutput::ShowToast(message)); + } + BrowseTabInput::GridExportResults(mut result) => { + // The grid owns the rows it is showing; the fetch that + // produced them belongs to the tab, and the hot-path + // page refresh swaps rows under a ColumnView built for + // an earlier fetch. + result.truncated = self.current_result.as_ref().is_some_and(|r| r.truncated); let _ = sender.output(BrowseTabOutput::ExportResults { result, name: self.export_name(), }); } BrowseTabInput::ExportCurrentPage => { - let Some(result) = self.current_result.clone() else { + let Some(result) = self.export_payload() else { let _ = sender.output(BrowseTabOutput::ShowToast(crate::tr!("Nothing to export"))); return; }; @@ -2195,6 +2259,9 @@ impl SimpleComponent for BrowseTab { let _ = sender.output(BrowseTabOutput::CopyToClipboard(text)); } BrowseTabInput::CopySelectedRowsAsTsv => { + // Same renderer as the context menu's Copy as > Rows: + // one selection cannot produce two different clipboard + // payloads depending on how the user asked for it. let Some(selection) = self.current_selection.as_ref() else { return; }; @@ -2202,27 +2269,20 @@ impl SimpleComponent for BrowseTab { if positions.is_empty() { return; } - let model = match selection.model() { - Some(m) => m, - None => return, + let Some(model) = selection.model() else { + return; }; - let mut rows: Vec = Vec::with_capacity(positions.len()); - for pos in &positions { - let Some(item) = model.item(*pos) else { continue }; - let Ok(row) = item.downcast::() else { - continue; - }; - let cells = row.cells_clone(); - let line: Vec = cells - .iter() - .map(|v| escape_tsv_cell(&super::grid::value_to_display_text(v))) - .collect(); - rows.push(line.join("\t")); - } + let ctx = self.grid_context(); + let rows: Vec> = positions + .iter() + .filter_map(|pos| model.item(*pos)) + .filter_map(|item| item.downcast::().ok()) + .map(|row| ctx.effective_cells(&row)) + .collect(); if rows.is_empty() { return; } - let tsv = rows.join("\n"); + let tsv = tablepro_core::export::render_tsv(&self.current_columns, &rows, false); let _ = sender.output(BrowseTabOutput::CopyToClipboard(tsv)); } BrowseTabInput::PasteNotSupported => { @@ -2463,23 +2523,6 @@ fn clear_box(b: >k::Box) { } } -/// TSV cells can't carry literal tab / newline / CR without breaking -/// the row-or-column boundary. Spreadsheet apps (LibreOffice Calc, -/// Excel) interpret these as field separators on paste, so a cell -/// containing one would silently split. Replace with a single space -/// to preserve the row structure on paste; the user can paste into -/// a plain text view to see the originals. -fn escape_tsv_cell(text: &str) -> String { - let mut out = String::with_capacity(text.len()); - for ch in text.chars() { - match ch { - '\t' | '\n' | '\r' => out.push(' '), - other => out.push(other), - } - } - out -} - /// 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 diff --git a/linux/crates/app/src/ui/editor.rs b/linux/crates/app/src/ui/editor.rs index b8a9e3e226..e9b50eb5b8 100644 --- a/linux/crates/app/src/ui/editor.rs +++ b/linux/crates/app/src/ui/editor.rs @@ -91,7 +91,13 @@ pub enum SqlEditorOutput { RunStateChanged(bool), QueryChanged(String), CopyToClipboard(String), - ExportResults(QueryResult), + ShowToast(String), + /// "Export Results…" from a result grid's context menu, with the + /// file-name stem derived from the statement that produced it. + ExportResults { + result: QueryResult, + name: String, + }, } #[relm4::component(pub)] @@ -398,8 +404,14 @@ impl SimpleComponent for SqlEditor { SqlEditorInput::Grid(GridMsg::CopyToClipboard(text)) => { let _ = sender.output(SqlEditorOutput::CopyToClipboard(text)); } + SqlEditorInput::Grid(GridMsg::ShowToast(text)) => { + let _ = sender.output(SqlEditorOutput::ShowToast(text)); + } SqlEditorInput::Grid(GridMsg::ExportResults(result)) => { - let _ = sender.output(SqlEditorOutput::ExportResults(result)); + let buffer = self.source_view.buffer(); + let (start, end) = buffer.bounds(); + let name = export_name_for_query(&buffer.text(&start, &end, false)); + let _ = sender.output(SqlEditorOutput::ExportResults { result, name }); } SqlEditorInput::Grid(_) => {} @@ -1111,6 +1123,27 @@ pub fn update_schema_buffer(buffer: >k::TextBuffer, schema_words: &[String]) { buffer.set_text(&text); } +/// File-name stem for an editor export, taken from the statement that +/// produced the results: exporting two queries in a row proposes two +/// different files instead of offering to overwrite the first. +pub fn export_name_for_query(query: &str) -> String { + if query.trim().is_empty() { + return crate::tr!("query-results"); + } + let mut stem = String::new(); + for c in derive_tab_label(query).chars() { + if c.is_alphanumeric() { + stem.extend(c.to_lowercase()); + } else if !stem.ends_with('-') { + stem.push('-'); + } + } + match stem.trim_matches('-') { + "" => crate::tr!("query-results"), + trimmed => trimmed.to_string(), + } +} + pub fn derive_tab_label(query: &str) -> String { for line in query.lines() { let trimmed = line.trim(); @@ -1171,7 +1204,18 @@ fn apply_editor_font_size(_view: &sourceview5::View, font_size: u32) { #[cfg(test)] mod tests { - use super::{split_sql_statements, sql_preview, statement_at_cursor, summary_label}; + use super::{export_name_for_query, split_sql_statements, sql_preview, statement_at_cursor, summary_label}; + + #[test] + fn export_name_slugs_the_statement() { + assert_eq!(export_name_for_query("SELECT * FROM users"), "select-from-users"); + assert_eq!(export_name_for_query(" select id\nfrom t"), "select-id"); + } + + #[test] + fn export_name_falls_back_when_there_is_no_statement() { + assert_eq!(export_name_for_query(" \n "), crate::tr!("query-results")); + } #[test] fn splits_on_top_level_semicolons() { diff --git a/linux/crates/app/src/ui/grid.rs b/linux/crates/app/src/ui/grid.rs index c6fd35b344..2f00ff6439 100644 --- a/linux/crates/app/src/ui/grid.rs +++ b/linux/crates/app/src/ui/grid.rs @@ -28,13 +28,21 @@ pub enum GridMsg { new_value: String, }, CopyToClipboard(String), + /// Something the user asked for could not be done in full: an IN + /// clause with nothing left to put in it, a preset the column + /// cannot hold. The owning tab surfaces it as a toast. + ShowToast(String), CopyRowAsInsert { row_position: u32, }, + /// "Set Value" names the intent, not a `Value`: the grid does not + /// know the column's declared type, and writing `Text("")` into an + /// `int` or a `date` column produces an UPDATE the server rejects. + /// The owning tab resolves the preset against its column metadata. SetCellValue { row_position: u32, col_index: usize, - value: Value, + preset: CellPreset, }, ExportResults(QueryResult), DeleteRowAt { @@ -55,6 +63,13 @@ pub enum GridMsg { }, } +/// What the "Set Value" submenu can put in a cell. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CellPreset { + Empty, + Null, +} + /// Per-tab context plumbed into the grid factory so cell bind-time /// callbacks can query the change tracker for pending-state CSS /// classes. `tab_id == None` means the grid is read-only / not @@ -65,6 +80,54 @@ pub struct TabGridContext { pub pk_col_indices: Vec, } +impl TabGridContext { + /// The tracker key for a persisted row. `None` when the grid isn't + /// backed by a tracked tab, when the row is a draft (its own cells + /// are the tracker's mirror and already authoritative), or when + /// the row carries no usable primary key. + fn tracked_key(&self, row: &RowObject) -> Option<(uuid::Uuid, crate::services::change_tracker::RowKey)> { + let tab_id = self.tab_id?; + if row.draft_id().is_some() { + return None; + } + let pk_values: Vec = self.pk_col_indices.iter().map(|&i| row.cell_value(i)).collect(); + let key = crate::services::change_tracker::RowKey::from_pk_values(&pk_values)?; + Some((tab_id, key)) + } + + /// What the grid is showing for one cell: the tracker's pending + /// edit when there is one, else the row's stored value. + fn effective_cell(&self, row: &RowObject, idx: usize) -> Value { + let raw = row.cell_value(idx); + let Some((tab_id, key)) = self.tracked_key(row) else { + return raw; + }; + match crate::services::change_tracker::with_tab_ref(tab_id, |t| t.current_cell_value(&key, idx, &raw).clone()) { + Some(value) => value, + None => raw, + } + } + + /// The whole row as the grid is showing it. Every copy and export + /// path reads this rather than `RowObject::cells_clone`, which for + /// a persisted row holds the untouched values the fetch returned. + pub(super) fn effective_cells(&self, row: &RowObject) -> Vec { + let raw = row.cells_clone(); + let Some((tab_id, key)) = self.tracked_key(row) else { + return raw; + }; + match crate::services::change_tracker::with_tab_ref(tab_id, |t| { + raw.iter() + .enumerate() + .map(|(i, v)| t.current_cell_value(&key, i, v).clone()) + .collect::>() + }) { + Some(cells) => cells, + None => raw, + } + } +} + #[allow(clippy::too_many_arguments)] pub fn build_column_view( result: &QueryResult, @@ -88,8 +151,14 @@ pub fn build_column_view( .show_column_separators(true) .build(); - let grid_menus = - install_grid_context_menus(&column_view, sender.clone(), Rc::new(result.columns.clone()), editable); + let grid_menus = install_grid_context_menus(GridMenuInit { + column_view: &column_view, + sender: sender.clone(), + columns: Rc::new(result.columns.clone()), + truncated: result.truncated, + tab_ctx: tab_ctx.clone(), + editable, + }); // For wide tables (~9+ columns) the default `expand: true` per // column shares the viewport fractionally and produces 20-30px @@ -123,7 +192,7 @@ pub fn build_column_view( connection_id, tab_ctx.clone(), default_min_width, - column_view.clone(), + column_view.downgrade(), grid_menus.clone(), ); column_view.append_column(&col); @@ -201,7 +270,7 @@ fn build_column( connection_id: Option, tab_ctx: TabGridContext, default_min_width: Option, - column_view: gtk::ColumnView, + column_view: glib::WeakRef, grid_menus: GridMenus, ) -> gtk::ColumnViewColumn { let factory = gtk::SignalListItemFactory::new(); @@ -209,6 +278,7 @@ fn build_column( let column_data_type = info.data_type.clone(); let column_name = info.name.clone(); + let accepts_empty = column_accepts_empty(&info.data_type); let column_view_for_setup = column_view.clone(); factory.connect_setup(move |_, item| { let Some(item) = item.downcast_ref::() else { @@ -243,6 +313,7 @@ fn build_column( column_name.clone(), sender.clone(), editor_kind, + accepts_empty, &column_view_for_setup, &grid_menus, ); @@ -278,24 +349,7 @@ fn build_column( // tracker's `inserts.values` AND mirror onto RowObject // (set_cell at edit time), so `row.cell_value(idx)` is // already authoritative for them. - let raw_value = row.cell_value(idx); - let value = if let Some(tab_id) = tab_ctx_for_bind.tab_id - && row.draft_id().is_none() - { - let pk_values: Vec = tab_ctx_for_bind - .pk_col_indices - .iter() - .map(|&i| row.cell_value(i)) - .collect(); - crate::services::change_tracker::with_tab_ref(tab_id, |t| { - crate::services::change_tracker::RowKey::from_pk_values(&pk_values) - .map(|key| t.current_cell_value(&key, idx, &raw_value).clone()) - .unwrap_or_else(|| raw_value.clone()) - }) - .unwrap_or(raw_value) - } else { - raw_value - }; + let value = tab_ctx_for_bind.effective_cell(&row, idx); let is_null = matches!(value, Value::Null); // Editable cells render NULL as the italic sentinel — // distinguishes a true NULL from an empty string visually. @@ -334,20 +388,15 @@ fn build_column( // gutter felt foreign next to the AdwListView idiom, and // the tint+strikethrough already make the row state legible // at a glance. - let pending_classes: Vec<&'static str> = if let Some(_tab_id) = tab_ctx_for_bind.tab_id { - if row.draft_id().is_some() { - vec!["tp-row-pending-insert"] - } else { - let pk_values: Vec = tab_ctx_for_bind - .pk_col_indices - .iter() - .map(|&i| row.cell_value(i)) - .collect(); - crate::services::change_tracker::with_tab_ref(_tab_id, |t| { + let pending_classes: Vec<&'static str> = if tab_ctx_for_bind.tab_id.is_none() { + Vec::new() + } else if row.draft_id().is_some() { + vec!["tp-row-pending-insert"] + } else { + match tab_ctx_for_bind.tracked_key(&row) { + None => Vec::new(), + Some((tab_id, key)) => crate::services::change_tracker::with_tab_ref(tab_id, |t| { let mut v: Vec<&'static str> = Vec::new(); - let Some(key) = crate::services::change_tracker::RowKey::from_pk_values(&pk_values) else { - return v; - }; let row_state = t.row_state(&key); let cell_state = t.cell_state(&key, idx); use crate::services::change_tracker::{CellState, RowState}; @@ -366,10 +415,8 @@ fn build_column( } v }) - .unwrap_or_default() + .unwrap_or_default(), } - } else { - Vec::new() }; let is_pending_delete = pending_classes.contains(&"tp-row-pending-delete"); @@ -526,7 +573,8 @@ fn setup_editable_cell( column_name: String, sender: relm4::Sender, editor_kind: CellEditorKind, - column_view: >k::ColumnView, + accepts_empty: bool, + column_view: &glib::WeakRef, menus: &GridMenus, ) { let label = super::cell_editor::CellEditor::new(); @@ -540,7 +588,18 @@ fn setup_editable_cell( COLUMN_SLOT.set(&label, idx); item.set_child(Some(&label)); - attach_cell_gesture(label.upcast_ref(), column_view, idx, column_name, true, true, menus); + attach_cell_gesture( + label.upcast_ref(), + column_view, + CellMenuTarget { + col_index: idx, + column_name, + editable: true, + text_editable: true, + accepts_empty, + }, + menus, + ); install_edit_commit_handler(&label, idx, sender.clone()); install_edit_triggers(&label, idx, sender, editor_kind); } @@ -557,7 +616,7 @@ fn setup_bool_cell( idx: usize, column_name: String, sender: relm4::Sender, - column_view: >k::ColumnView, + column_view: &glib::WeakRef, menus: &GridMenus, ) { let checkbox = gtk::CheckButton::builder() @@ -569,7 +628,18 @@ fn setup_bool_cell( COLUMN_SLOT.set(&checkbox, idx); item.set_child(Some(&checkbox)); - attach_cell_gesture(checkbox.upcast_ref(), column_view, idx, column_name, true, false, menus); + attach_cell_gesture( + checkbox.upcast_ref(), + column_view, + CellMenuTarget { + col_index: idx, + column_name, + editable: true, + text_editable: false, + accepts_empty: false, + }, + menus, + ); checkbox.connect_toggled(move |cb| { // Suppress the echo while the bind callback is driving the // checkbox programmatically. @@ -746,6 +816,44 @@ fn is_json_type(data_type: &str) -> bool { dt.contains("json") } +/// Whether a column can hold an empty string, which is what the "Set +/// Value > Empty" preset writes. A number, a date, a UUID or a JSON +/// column cannot: the server rejects `''` for them, and NULL is what +/// the menu's other preset is for. The list is positive on purpose, so +/// a type nobody here recognises offers NULL alone rather than an +/// UPDATE the server will refuse. +/// +/// This is the single rule behind both halves of the preset: the grid +/// arms the menu item with it, and the browse tab resolves the preset +/// to a `Value` with it. +pub(super) fn column_accepts_empty(data_type: &str) -> bool { + let dt = data_type.to_ascii_lowercase(); + let base = dt.split('(').next().unwrap_or(&dt).trim(); + matches!( + base, + "text" + | "varchar" + | "char" + | "character" + | "character varying" + | "bpchar" + | "string" + | "nvarchar" + | "nchar" + | "varchar2" + | "nvarchar2" + | "clob" + | "nclob" + | "citext" + | "name" + | "tinytext" + | "mediumtext" + | "longtext" + | "enum" + | "set" + ) +} + /// Per-type cell editor selection. Bool is handled separately via /// `setup_bool_cell` (CheckButton) and never reaches `setup_editable_cell`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -795,7 +903,7 @@ fn setup_readonly_cell( item: >k::ListItem, idx: usize, column_name: String, - column_view: >k::ColumnView, + column_view: &glib::WeakRef, menus: &GridMenus, ) { let label = gtk::Label::builder() @@ -807,7 +915,18 @@ fn setup_readonly_cell( .margin_end(8) .build(); item.set_child(Some(&label)); - attach_cell_gesture(label.upcast_ref(), column_view, idx, column_name, false, false, menus); + attach_cell_gesture( + label.upcast_ref(), + column_view, + CellMenuTarget { + col_index: idx, + column_name, + editable: false, + text_editable: false, + accepts_empty: false, + }, + menus, + ); } /// Capture-phase double-click + key handler bundle. Routes F2 / Enter @@ -1208,20 +1327,72 @@ struct CellContext { column_name: String, } +/// What one cell wants from the shared menu: which popover it shows, +/// and which of the per-cell actions apply to it. +#[derive(Clone)] +struct CellMenuTarget { + col_index: usize, + column_name: String, + editable: bool, + text_editable: bool, + accepts_empty: bool, +} + +/// The actions whose enabled state is a property of the right-clicked +/// cell rather than of the grid. Both their menu items carry +/// `hidden-when="action-disabled"`, so a cell they don't apply to +/// shows a menu without them rather than a menu with dead entries. +/// `None` on a read-only grid, which registers neither. +#[derive(Clone, Default)] +struct CellActions { + edit: Option, + set_empty: Option, +} + +impl CellActions { + fn arm(&self, target: &CellMenuTarget) { + 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); + } + } +} + #[derive(Clone)] pub(super) struct GridMenus { context: Rc>>, editable_popover: gtk::PopoverMenu, readonly_popover: gtk::PopoverMenu, - edit_action: gio::SimpleAction, + actions: CellActions, } +#[derive(Clone, Copy)] struct MenuShape { edit_cell: bool, set_value: bool, row_ops: bool, } +impl MenuShape { + const READ_ONLY: MenuShape = MenuShape { + edit_cell: false, + set_value: false, + row_ops: false, + }; + const ROW_OPS: MenuShape = MenuShape { + edit_cell: false, + set_value: false, + row_ops: true, + }; + const FULL: MenuShape = MenuShape { + edit_cell: true, + set_value: true, + row_ops: true, + }; +} + fn build_cell_menu(shape: MenuShape) -> gio::Menu { let menu = gio::Menu::new(); if shape.edit_cell { @@ -1258,7 +1429,12 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { let action_section = gio::Menu::new(); if shape.set_value { let set_value = gio::Menu::new(); - set_value.append(Some(&crate::tr!("Empty")), Some("cell.set-empty")); + // 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::tr!("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::tr!("Set Value")), &set_value); } @@ -1272,31 +1448,34 @@ fn build_cell_menu(shape: MenuShape) -> gio::Menu { menu } -fn install_grid_context_menus( - column_view: >k::ColumnView, +/// Everything the shared menu needs, named rather than positional. +struct GridMenuInit<'a> { + column_view: &'a gtk::ColumnView, sender: relm4::Sender, columns: Rc>, + /// Carried from the fetch that produced this grid so the menu's + /// Export Results reports the same truncation the paginator does. + truncated: bool, + tab_ctx: TabGridContext, editable: bool, -) -> GridMenus { - let context: Rc>> = Rc::new(RefCell::new(None)); +} - let editable_menu = build_cell_menu(MenuShape { - edit_cell: true, - set_value: true, - row_ops: true, - }); - let row_ops_menu = build_cell_menu(MenuShape { - edit_cell: false, - set_value: false, - row_ops: true, - }); - let readonly_menu = build_cell_menu(MenuShape { - edit_cell: false, - set_value: false, - row_ops: false, - }); - let empty_menu = gio::Menu::new(); - empty_menu.append(Some(&crate::tr!("Insert row")), Some("cell.insert-row")); +fn install_grid_context_menus(init: GridMenuInit<'_>) -> GridMenus { + let GridMenuInit { + column_view, + sender, + columns, + truncated, + tab_ctx, + editable, + } = init; + let context: Rc>> = Rc::new(RefCell::new(None)); + // Every action closure holds the view weakly. The action group + // belongs to the ColumnView, so a strong clone in a closure is a + // cycle: the grid, its selection model, its store and every row it + // holds would outlive the page that built them. + let view = column_view.downgrade(); + let tab = Rc::new(tab_ctx); let group = gio::SimpleActionGroup::new(); let slot_position = |slot: &CellContext| POSITION_SLOT.get(&slot.widget).unwrap_or(0); @@ -1313,12 +1492,16 @@ fn install_grid_context_menus( .build() }}; } + // Renders the rows the menu targets, with the change tracker's + // pending edits applied, and puts the result on the clipboard. macro_rules! copy_action { ($name:literal, |$slot:ident, $rows:ident| $text:expr) => {{ let s = sender.clone(); - let cv = column_view.clone(); + let view = view.clone(); + let tab = tab.clone(); cell_action!($name, |$slot| { - let $rows = rows_for_copy(&cv, slot_position($slot)); + let Some(cv) = view.upgrade() else { return }; + let $rows = rows_for_menu(&cv, &tab, slot_position($slot)); s.send(GridMsg::CopyToClipboard($text)).ok(); }) }}; @@ -1335,12 +1518,26 @@ fn install_grid_context_menus( enter_edit_mode(&label); } }); + // 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 + // at the 10k display cap. let copy_action = { + let s = sender.clone(); + let view = view.clone(); + let tab = tab.clone(); let cols = columns.clone(); - copy_action!("copy", |slot, rows| if rows.len() > 1 { - tablepro_core::export::render_tsv(&cols, &rows, false) - } else { - cell_text(&slot.widget) + cell_action!("copy", |slot| { + let Some(cv) = view.upgrade() else { return }; + let rows = rows_for_menu(&cv, &tab, slot_position(slot)); + let text = match rows.as_slice() { + [row] => row + .get(slot.col_index) + .and_then(tablepro_core::export::value_to_text) + .unwrap_or_default(), + many => tablepro_core::export::render_tsv(&cols, many, false), + }; + s.send(GridMsg::CopyToClipboard(text)).ok(); }) }; let copy_rows_action = { @@ -1386,9 +1583,34 @@ fn install_grid_context_menus( &cols, &rows )) }; - let copy_in_clause_action = copy_action!("copy-in-clause", |slot, rows| { - tablepro_core::export::render_in_clause(&rows, slot.col_index) - }); + // NULL and binary values have no place in an IN list, so the + // clause says how many it left out instead of handing back a + // shorter list that quietly selects different rows. + let copy_in_clause_action = { + let s = sender.clone(); + let view = view.clone(); + let tab = tab.clone(); + cell_action!("copy-in-clause", |slot| { + let Some(cv) = view.upgrade() else { return }; + let rows = rows_for_menu(&cv, &tab, slot_position(slot)); + let clause = tablepro_core::export::render_in_clause(&rows, slot.col_index); + if clause.sql.is_empty() { + s.send(GridMsg::ShowToast(crate::tr!( + "Nothing to copy: an IN clause can't carry NULL or binary values" + ))) + .ok(); + return; + } + s.send(GridMsg::CopyToClipboard(clause.sql)).ok(); + if clause.skipped > 0 { + s.send(GridMsg::ShowToast( + crate::tr!("{n} NULL or binary values left out of the IN clause") + .replace("{n}", &clause.skipped.to_string()), + )) + .ok(); + } + }) + }; let copy_column_name_action = send_action!("copy-column-name", |slot| GridMsg::CopyToClipboard( slot.column_name.clone() )); @@ -1397,37 +1619,38 @@ fn install_grid_context_menus( }); let show_row_json_action = { let cols = columns.clone(); - let cv = column_view.clone(); + let view = view.clone(); + let tab = tab.clone(); cell_action!("show-row-json", |slot| { - if let Some(row) = row_at(&cv, slot_position(slot)) { - let json = tablepro_core::export::row_to_json(&cols, &row.cells_clone()); - let text = serde_json::to_string_pretty(&json).unwrap_or_default(); - show_row_json_dialog(&cv, text); - } + let Some(cv) = view.upgrade() else { return }; + let Some(row) = row_at(&cv, slot_position(slot)) else { + return; + }; + let json = tablepro_core::export::row_to_json(&cols, &tab.effective_cells(&row)); + let text = serde_json::to_string_pretty(&json).unwrap_or_default(); + show_row_json_dialog(&cv, text); }) }; let set_empty_action = send_action!("set-empty", |slot| GridMsg::SetCellValue { row_position: slot_position(slot), col_index: slot.col_index, - value: Value::Text(String::new()), + preset: CellPreset::Empty, }); let set_null_action = send_action!("set-null", |slot| GridMsg::SetCellValue { row_position: slot_position(slot), col_index: slot.col_index, - value: Value::Null, + preset: CellPreset::Null, }); let export_action = { let s = sender.clone(); - let cv = column_view.clone(); + let view = view.clone(); let cols = columns.clone(); + let tab = tab.clone(); gio::ActionEntry::builder("export") .activate(move |_, _, _| { - let result = QueryResult { - columns: cols.as_ref().clone(), - rows: all_rows(&cv), - truncated: false, - }; - s.send(GridMsg::ExportResults(result)).ok(); + let Some(cv) = view.upgrade() else { return }; + s.send(GridMsg::ExportResults(export_snapshot(&cv, &cols, truncated, &tab))) + .ok(); }) .build() }; @@ -1445,8 +1668,8 @@ fn install_grid_context_menus( let duplicate_row_action = send_action!("duplicate-row", |slot| GridMsg::DuplicateRow { row_position: slot_position(slot), }); - group.add_action_entries([ - edit_action, + + let mut entries = vec![ copy_action, copy_rows_action, copy_rows_headers_action, @@ -1456,56 +1679,69 @@ fn install_grid_context_menus( copy_markdown_action, copy_in_clause_action, copy_column_name_action, - copy_row_insert_action, show_row_json_action, - set_empty_action, - set_null_action, export_action, - insert_row_action, - delete_row_action, - duplicate_row_action, - ]); + ]; + // A read-only grid registers none of the mutating actions: it + // shows no menu item for them, and the editor throws their + // messages away at the far end. + if editable { + entries.extend([ + edit_action, + set_empty_action, + set_null_action, + copy_row_insert_action, + insert_row_action, + delete_row_action, + duplicate_row_action, + ]); + } + group.add_action_entries(entries); column_view.insert_action_group("cell", Some(&group)); - let edit_action_obj = group - .lookup_action("edit") - .expect("just registered") - .downcast::() - .expect("ActionEntry registers SimpleAction"); + let actions = CellActions { + edit: editable.then(|| simple_action(&group, "edit")), + set_empty: editable.then(|| simple_action(&group, "set-empty")), + }; // Popovers are parented eagerly so each PopoverMenu's action muxer // snapshots the ColumnView's `cell` group at set_parent() time. // Lazy parenting in the gesture handler drops every activation // silently (see sidebar_row.rs for the same root cause). - let make_popover = |model: &gio::Menu| { - let popover = gtk::PopoverMenu::from_model_full(model, gtk::PopoverMenuFlags::NESTED); + let make_popover = |shape: MenuShape| { + let popover = gtk::PopoverMenu::from_model_full(&build_cell_menu(shape), gtk::PopoverMenuFlags::NESTED); popover.set_has_arrow(true); popover.set_parent(column_view); popover }; - let editable_popover = make_popover(&editable_menu); - let row_ops_popover = make_popover(&row_ops_menu); - let readonly_popover = make_popover(&readonly_menu); - let empty_popover = make_popover(&empty_menu); - - let popovers_for_destroy = [ - editable_popover.clone(), - row_ops_popover.clone(), - readonly_popover.clone(), - empty_popover.clone(), - ]; - column_view.connect_destroy(move |_| { - for popover in &popovers_for_destroy { - popover.unparent(); - } - }); + // One popover per shape the grid can actually show. A read-only + // grid has a single shape and both fields name it. + let (editable_popover, readonly_popover) = if editable { + (make_popover(MenuShape::FULL), make_popover(MenuShape::ROW_OPS)) + } else { + let readonly = make_popover(MenuShape::READ_ONLY); + (readonly.clone(), readonly) + }; + + let mut popovers_for_destroy = vec![editable_popover.clone()]; + if readonly_popover != editable_popover { + popovers_for_destroy.push(readonly_popover.clone()); + } if editable { - let cv_for_empty = column_view.clone(); + let empty_menu = gio::Menu::new(); + empty_menu.append(Some(&crate::tr!("Insert row")), Some("cell.insert-row")); + let empty_popover = gtk::PopoverMenu::from_model_full(&empty_menu, gtk::PopoverMenuFlags::NESTED); + empty_popover.set_has_arrow(true); + empty_popover.set_parent(column_view); + popovers_for_destroy.push(empty_popover.clone()); + + let view_for_empty = view.clone(); let empty_gesture = gtk::GestureClick::builder().button(3).build(); empty_gesture.connect_pressed(move |g, _, x, y| { - let cv_widget: gtk::Widget = cv_for_empty.clone().upcast(); - if let Some(picked) = cv_for_empty.pick(x, y, gtk::PickFlags::DEFAULT) + let Some(cv) = view_for_empty.upgrade() else { return }; + let cv_widget: gtk::Widget = cv.clone().upcast(); + if let Some(picked) = cv.pick(x, y, gtk::PickFlags::DEFAULT) && picked != cv_widget { return; @@ -1517,14 +1753,27 @@ fn install_grid_context_menus( column_view.add_controller(empty_gesture); } + column_view.connect_destroy(move |_| { + for popover in &popovers_for_destroy { + popover.unparent(); + } + }); + GridMenus { context, editable_popover, - readonly_popover: if editable { row_ops_popover } else { readonly_popover }, - edit_action: edit_action_obj, + readonly_popover, + actions, } } +fn simple_action(group: &gio::SimpleActionGroup, name: &str) -> gio::SimpleAction { + group + .lookup_action(name) + .and_then(|a| a.downcast::().ok()) + .expect("registered above as an ActionEntry, which is a SimpleAction") +} + pub(super) fn selected_positions(selection: >k::MultiSelection) -> Vec { let bitset = selection.selection(); let mut out = Vec::with_capacity(bitset.size() as usize); @@ -1539,10 +1788,30 @@ fn row_at(column_view: >k::ColumnView, position: u32) -> Option { column_view.model()?.item(position)?.downcast::().ok() } -fn rows_for_copy(column_view: >k::ColumnView, clicked: u32) -> Vec> { - let mut positions = column_view - .model() - .and_then(|m| m.downcast::().ok()) +fn selection_of(column_view: >k::ColumnView) -> Option { + column_view.model()?.downcast::().ok() +} + +/// Right-click acts on the row under the pointer. A row already in the +/// selection leaves the selection alone, so a right-click inside a +/// multi-row block still copies the block; any other row becomes the +/// selection. That is what every native list does, and it is what +/// keeps Copy and Delete in one menu pointing at the same rows. +fn select_row_for_menu(column_view: >k::ColumnView, position: u32) { + let Some(selection) = selection_of(column_view) else { + return; + }; + if position >= selection.n_items() || selection.is_selected(position) { + return; + } + selection.select_item(position, true); +} + +/// The rows a menu action applies to, with the change tracker's +/// pending edits applied. `clicked` is the fallback for the keyboard +/// path on a grid whose selection is empty. +fn rows_for_menu(column_view: >k::ColumnView, ctx: &TabGridContext, clicked: u32) -> Vec> { + let mut positions = selection_of(column_view) .map(|s| selected_positions(&s)) .unwrap_or_default(); if positions.is_empty() { @@ -1551,18 +1820,33 @@ fn rows_for_copy(column_view: >k::ColumnView, clicked: u32) -> Vec> positions .iter() .filter_map(|p| row_at(column_view, *p)) - .map(|r| r.cells_clone()) + .map(|r| ctx.effective_cells(&r)) .collect() } -fn all_rows(column_view: >k::ColumnView) -> Vec> { - let Some(model) = column_view.model() else { - return Vec::new(); +/// The page as the grid is showing it: every row in the model with the +/// tracker's pending edits applied, and the truncation flag of the +/// fetch that filled it. The paginator's export button and the context +/// menu's Export Results both build their payload here, so one menu +/// label cannot mean two different files. +pub(super) fn export_snapshot( + column_view: >k::ColumnView, + columns: &[ColumnInfo], + 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(), }; - (0..model.n_items()) - .filter_map(|p| row_at(column_view, p)) - .map(|r| r.cells_clone()) - .collect() + QueryResult { + columns: columns.to_vec(), + rows, + truncated, + } } fn show_row_json_dialog(parent: &impl IsA, json: String) { @@ -1611,35 +1895,46 @@ fn show_row_json_dialog(parent: &impl IsA, json: String) { /// once at the ColumnView level. fn attach_cell_gesture( widget: >k::Widget, - column_view: >k::ColumnView, - idx: usize, - column_name: String, - is_editable: bool, - is_text_editable: bool, + column_view: &glib::WeakRef, + target: CellMenuTarget, menus: &GridMenus, ) { - let popover = if is_editable { + let popover = if target.editable { menus.editable_popover.clone() } else { menus.readonly_popover.clone() }; + // Fill the shared context slot, put the clicked row in the + // selection and arm the per-cell actions. Both entry paths (right + // click, Menu key) do exactly this before showing the popover. + let prepare = { + let context = menus.context.clone(); + let actions = menus.actions.clone(); + let target = target.clone(); + move |widget: >k::Widget, column_view: >k::ColumnView| { + *context.borrow_mut() = Some(CellContext { + widget: widget.clone(), + col_index: target.col_index, + column_name: target.column_name.clone(), + }); + actions.arm(&target); + if let Some(position) = POSITION_SLOT.get(widget) { + select_row_for_menu(column_view, position); + } + } + }; + let widget_for_gesture = widget.clone(); - let cv_for_gesture = column_view.clone(); - let context_for_gesture = menus.context.clone(); - let edit_action_for_gesture = menus.edit_action.clone(); + let view_for_gesture = column_view.clone(); let popover_for_gesture = popover.clone(); - let column_name_for_gesture = column_name.clone(); + let prepare_for_gesture = prepare.clone(); let gesture = gtk::GestureClick::new(); gesture.set_button(3); gesture.connect_pressed(move |g, _, x, y| { + let Some(cv) = view_for_gesture.upgrade() else { return }; g.set_state(gtk::EventSequenceState::Claimed); - *context_for_gesture.borrow_mut() = Some(CellContext { - widget: widget_for_gesture.clone(), - col_index: idx, - column_name: column_name_for_gesture.clone(), - }); - edit_action_for_gesture.set_enabled(is_text_editable); + prepare_for_gesture(&widget_for_gesture, &cv); // Translate the click point into the ColumnView's coordinate // space — the popover is parented to the ColumnView so // pointing_to is interpreted there, not in cell-local coords. @@ -1647,7 +1942,7 @@ fn attach_cell_gesture( // deprecated `translate_coordinates`. let local = gtk::graphene::Point::new(x as f32, y as f32); let (cv_x, cv_y) = widget_for_gesture - .compute_point(&cv_for_gesture, &local) + .compute_point(&cv, &local) .map(|p| (p.x() as i32, p.y() as i32)) .unwrap_or((x as i32, y as i32)); popover_for_gesture.set_pointing_to(Some(>k::gdk::Rectangle::new(cv_x, cv_y, 1, 1))); @@ -1656,24 +1951,19 @@ fn attach_cell_gesture( widget.add_controller(gesture); let widget_for_key = widget.clone(); - let cv_for_key = column_view.clone(); - let context_for_key = menus.context.clone(); - let edit_action_for_key = menus.edit_action.clone(); + let view_for_key = column_view.clone(); let popover_for_key = popover; - let column_name_for_key = column_name; let menu_shortcut = gtk::Shortcut::builder() .trigger(>k::ShortcutTrigger::parse_string("Menu").expect("valid trigger")) .action(>k::CallbackAction::new(move |_, _| { - *context_for_key.borrow_mut() = Some(CellContext { - widget: widget_for_key.clone(), - col_index: idx, - column_name: column_name_for_key.clone(), - }); - edit_action_for_key.set_enabled(is_text_editable); + let Some(cv) = view_for_key.upgrade() else { + return glib::Propagation::Proceed; + }; + prepare(&widget_for_key, &cv); // Anchor on the cell's full bounds so the popover lands // visually under the cell rather than at an arbitrary // mouse-position-of-last-click. - if let Some(bounds) = widget_for_key.compute_bounds(&cv_for_key) { + if let Some(bounds) = widget_for_key.compute_bounds(&cv) { let rect = gtk::gdk::Rectangle::new( bounds.x() as i32, bounds.y() as i32, @@ -1693,16 +1983,6 @@ fn attach_cell_gesture( widget.add_controller(shortcut_controller); } -fn cell_text(widget: >k::Widget) -> String { - if let Some(label) = widget.downcast_ref::() { - label.text().to_string() - } else if let Some(label) = widget.downcast_ref::() { - label.text().to_string() - } else { - String::new() - } -} - fn is_cell_editable(col: &ColumnInfo) -> bool { // Primary keys: locked because the grid identifies rows by PK and // editing a PK component would orphan the tracker's row identity. @@ -1912,6 +2192,37 @@ mod tests { assert!(!is_cell_editable(&col("varbinary", false))); } + #[test] + fn only_text_columns_accept_an_empty_string() { + for text in [ + "text", + "VARCHAR(255)", + "char(3)", + "character varying", + "longtext", + "citext", + ] { + assert!(column_accepts_empty(text), "{text} should accept an empty string"); + } + for other in [ + "integer", + "bigint", + "numeric(10,2)", + "date", + "timestamp", + "uuid", + "jsonb", + "boolean", + "bytea", + "some_extension_type", + ] { + assert!( + !column_accepts_empty(other), + "{other} should not accept an empty string" + ); + } + } + #[test] fn bytes_type_detection() { assert!(is_bytes_type("BYTEA")); diff --git a/linux/crates/core/src/export.rs b/linux/crates/core/src/export.rs index 6d51634d7c..8fe2ce8c65 100644 --- a/linux/crates/core/src/export.rs +++ b/linux/crates/core/src/export.rs @@ -1,3 +1,5 @@ +use std::collections::HashSet; + use serde::{Deserialize, Serialize}; use crate::{ColumnInfo, Value}; @@ -134,26 +136,36 @@ fn quote_field(field: &str) -> String { format!("\"{}\"", field.replace('"', "\"\"")) } +/// The four characters a spreadsheet reads as the start of a formula. +pub const FORMULA_PREFIXES: [char; 4] = ['=', '+', '-', '@']; + +/// A leading tab or carriage return leads a formula too: Excel strips +/// it before parsing the cell, so `\t=cmd|'/C calc'!A0` reaches the +/// formula engine exactly as `=cmd|…` would. +fn is_formula_lead(c: char) -> bool { + FORMULA_PREFIXES.contains(&c) || c == '\t' || c == '\r' +} + /// `had_line_breaks` carries whether the raw value contained a line /// break before `line_break_to_space` scrubbed it, so `IfNeeded` /// still quotes a converted multi-line value even though the /// resulting text no longer contains `\n`/`\r` itself. fn escape_field(field: &str, opts: &CsvOptions, had_line_breaks: bool) -> String { let mut field = field.to_string(); - if opts.sanitize_formulas && field.starts_with(['=', '+', '-', '@']) { + let mut neutralised = false; + if opts.sanitize_formulas && field.starts_with(is_formula_lead) { field.insert(0, '\''); + neutralised = true; } match opts.quote { CsvQuote::Always => quote_field(&field), CsvQuote::Never => field, CsvQuote::IfNeeded => { + // A tab splits the field for every tab-aware consumer, and + // a neutralised value has to keep its leading quote as + // data rather than as the start of a bare token. let delim = opts.delimiter.as_str(); - if field.contains(delim) - || field.contains('"') - || field.contains('\n') - || field.contains('\r') - || had_line_breaks - { + if field.contains(delim) || field.contains(['"', '\n', '\r', '\t']) || had_line_breaks || neutralised { quote_field(&field) } else { field @@ -198,16 +210,31 @@ pub fn render_csv(columns: &[ColumnInfo], rows: &[Vec], opts: &CsvOptions out } +/// 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 the value +/// is quoted and its own quotes doubled. That is what a spreadsheet +/// puts on the clipboard for a multi-line cell, and what Calc and +/// Excel parse back on paste; collapsing the character to a space +/// keeps the grid intact but hands the user a value the database +/// never held. +fn tsv_field(text: &str) -> String { + if text.contains(['\t', '\n', '\r', '"']) { + quote_field(text) + } else { + text.to_string() + } +} + pub fn render_tsv(columns: &[ColumnInfo], rows: &[Vec], with_headers: bool) -> String { let mut lines: Vec = Vec::new(); if with_headers { - let header: Vec<&str> = columns.iter().map(|c| c.name.as_str()).collect(); + let header: Vec = columns.iter().map(|c| tsv_field(&c.name)).collect(); lines.push(header.join("\t")); } for row in rows { let cells: Vec = row .iter() - .map(|v| value_to_text(v).unwrap_or_else(|| "NULL".to_string())) + .map(|v| tsv_field(&value_to_text(v).unwrap_or_else(|| "NULL".to_string()))) .collect(); lines.push(cells.join("\t")); } @@ -237,17 +264,51 @@ fn value_to_json(v: &Value) -> serde_json::Value { } } -pub fn row_to_json(columns: &[ColumnInfo], row: &[Value]) -> serde_json::Value { - let mut map = serde_json::Map::new(); - for (i, col) in columns.iter().enumerate() { +/// One JSON key per column, in column order. A join can return the +/// same column name twice (`SELECT a.id, b.id …`) and a JSON object +/// keyed by name alone would keep the last of them and drop the rest, +/// so a repeat is suffixed `_2`, `_3`, … until it is unique against +/// every name already taken, including the literal names of later +/// columns. +pub fn json_field_names(columns: &[ColumnInfo]) -> Vec { + let mut reserved: HashSet = columns.iter().map(|c| c.name.clone()).collect(); + let mut emitted: HashSet = HashSet::with_capacity(columns.len()); + let mut names = Vec::with_capacity(columns.len()); + for col in columns { + let mut name = col.name.clone(); + if !emitted.insert(name.clone()) { + let mut suffix = 2; + loop { + let candidate = format!("{}_{suffix}", col.name); + if !reserved.contains(&candidate) && emitted.insert(candidate.clone()) { + name = candidate; + break; + } + suffix += 1; + } + reserved.insert(name.clone()); + } + names.push(name); + } + names +} + +fn row_to_json_object(names: &[String], row: &[Value]) -> serde_json::Value { + let mut map = serde_json::Map::with_capacity(names.len()); + for (i, name) in names.iter().enumerate() { let value = row.get(i).map(value_to_json).unwrap_or(serde_json::Value::Null); - map.insert(col.name.clone(), value); + map.insert(name.clone(), value); } serde_json::Value::Object(map) } +pub fn row_to_json(columns: &[ColumnInfo], row: &[Value]) -> serde_json::Value { + row_to_json_object(&json_field_names(columns), row) +} + pub fn render_json(columns: &[ColumnInfo], rows: &[Vec]) -> String { - let values: Vec = rows.iter().map(|row| row_to_json(columns, row)).collect(); + let names = json_field_names(columns); + let values: Vec = rows.iter().map(|row| row_to_json_object(&names, row)).collect(); serde_json::to_string_pretty(&values).unwrap_or_else(|_| "[]".to_string()) } @@ -273,6 +334,10 @@ pub fn render_markdown(columns: &[ColumnInfo], rows: &[Vec]) -> String { fn in_clause_literal(v: &Value) -> Option { match v { + // NULL never matches an IN list and turns a NOT IN into a + // list that matches nothing at all; a binary literal has a + // different spelling on every engine. Both are reported to + // the caller rather than written. Value::Null | Value::Bytes(_) => None, Value::Bool(b) => Some(if *b { "TRUE".to_string() } else { "FALSE".to_string() }), Value::Int(_) | Value::Float(_) | Value::Decimal(_) => value_to_text(v), @@ -280,13 +345,27 @@ fn in_clause_literal(v: &Value) -> Option { } } -pub fn render_in_clause(rows: &[Vec], col_index: usize) -> String { - let literals: Vec = rows - .iter() - .filter_map(|row| row.get(col_index)) - .filter_map(in_clause_literal) - .collect(); - format!("({})", literals.join(", ")) +/// The `(…)` list plus the count of values it could not carry. An +/// empty `sql` means every value was skipped: `()` is a syntax error +/// on every engine, so the caller reports it instead of putting it on +/// the clipboard. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct InClause { + pub sql: String, + pub skipped: usize, +} + +pub fn render_in_clause(rows: &[Vec], col_index: usize) -> InClause { + let values: Vec<&Value> = rows.iter().filter_map(|row| row.get(col_index)).collect(); + let literals: Vec = values.iter().filter_map(|v| in_clause_literal(v)).collect(); + InClause { + skipped: values.len() - literals.len(), + sql: if literals.is_empty() { + String::new() + } else { + format!("({})", literals.join(", ")) + }, + } } #[cfg(test)] @@ -417,14 +496,43 @@ mod tests { #[test] fn csv_sanitizes_formula_prefixes() { + // A neutralised value is quoted so its leading apostrophe + // reaches the spreadsheet as data rather than as a text marker + // the importer swallows. let columns = cols(&["a"]); for ch in ['=', '+', '-', '@'] { let rows = vec![vec![Value::Text(format!("{ch}cmd"))]]; let out = render_csv(&columns, &rows, &CsvOptions::default()); - assert_eq!(out, format!("a\n'{ch}cmd\n"), "prefix {ch} should be sanitized"); + assert_eq!(out, format!("a\n\"'{ch}cmd\"\n"), "prefix {ch} should be sanitized"); } } + #[test] + fn csv_sanitizes_formula_lead_hidden_behind_whitespace() { + let columns = cols(&["a"]); + for lead in ['\t', '\r'] { + let rows = vec![vec![Value::Text(format!("{lead}=cmd|'/C calc'!A0"))]]; + let out = render_csv(&columns, &rows, &CsvOptions::default()); + let expected = format!("a\n\"'{lead}=cmd|'/C calc'!A0\"\n"); + assert_eq!(out, expected, "lead {lead:?} should be sanitized and quoted"); + } + } + + #[test] + fn csv_quote_if_needed_triggers_on_tab_and_on_neutralised_value() { + let columns = cols(&["a"]); + let tabbed = vec![vec![Value::Text("has\ttab".into())]]; + assert_eq!( + render_csv(&columns, &tabbed, &CsvOptions::default()), + "a\n\"has\ttab\"\n" + ); + let formula = vec![vec![Value::Text("=SUM(A1)".into())]]; + assert_eq!( + render_csv(&columns, &formula, &CsvOptions::default()), + "a\n\"'=SUM(A1)\"\n" + ); + } + #[test] fn csv_does_not_sanitize_non_formula_prefixes() { let columns = cols(&["a"]); @@ -517,6 +625,27 @@ mod tests { assert_eq!(render_tsv(&columns, &rows, false), "1\tNULL"); } + #[test] + fn tsv_quotes_values_that_would_break_the_grid() { + let columns = cols(&["a", "b"]); + let rows = vec![vec![Value::Text("line1\nline2".into()), Value::Text("has\ttab".into())]]; + assert_eq!(render_tsv(&columns, &rows, false), "\"line1\nline2\"\t\"has\ttab\""); + } + + #[test] + fn tsv_doubles_quotes_inside_a_quoted_value() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("say \"hi\"".into())]]; + assert_eq!(render_tsv(&columns, &rows, false), "\"say \"\"hi\"\"\""); + } + + #[test] + fn tsv_leaves_ordinary_values_bare() { + let columns = cols(&["a"]); + let rows = vec![vec![Value::Text("plain, value".into())]]; + assert_eq!(render_tsv(&columns, &rows, false), "plain, value"); + } + #[test] fn json_number_vs_string_handling() { let columns = cols(&["i", "f", "d", "s"]); @@ -541,6 +670,22 @@ mod tests { assert_eq!(json["b"], serde_json::Value::Null); } + #[test] + fn json_keeps_every_column_when_names_repeat() { + let columns = cols(&["id", "name", "id"]); + let row = vec![Value::Int(1), Value::Text("a".into()), Value::Int(2)]; + assert_eq!(json_field_names(&columns), vec!["id", "name", "id_2"]); + let json = row_to_json(&columns, &row); + assert_eq!(json["id"], serde_json::json!(1)); + assert_eq!(json["id_2"], serde_json::json!(2)); + } + + #[test] + fn json_disambiguation_skips_a_name_a_real_column_already_holds() { + let columns = cols(&["id", "id_2", "id"]); + assert_eq!(json_field_names(&columns), vec!["id", "id_2", "id_3"]); + } + #[test] fn render_json_empty_rows_is_empty_array() { let columns = cols(&["a"]); @@ -556,7 +701,7 @@ mod tests { } #[test] - fn in_clause_skips_null_and_quotes_text() { + fn in_clause_reports_the_values_it_skips() { let rows = vec![ vec![Value::Text("O'Brien".into())], vec![Value::Null], @@ -564,12 +709,15 @@ mod tests { vec![Value::Bool(true)], ]; let out = render_in_clause(&rows, 0); - assert_eq!(out, "('O''Brien', 5, TRUE)"); + assert_eq!(out.sql, "('O''Brien', 5, TRUE)"); + assert_eq!(out.skipped, 1); } #[test] - fn in_clause_empty_when_all_skipped() { + fn in_clause_is_empty_rather_than_invalid_when_all_skipped() { let rows = vec![vec![Value::Null], vec![Value::Bytes(vec![1, 2])]]; - assert_eq!(render_in_clause(&rows, 0), "()"); + let out = render_in_clause(&rows, 0); + assert_eq!(out.sql, ""); + assert_eq!(out.skipped, 2); } } From 89d42c0e5e4bb93b2bfc7de73c1b5dd31c9c30bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 8 Sep 2026 15:05:34 +0700 Subject: [PATCH 5/5] perf(settings): read the preferences file once per process Claude-Session: https://claude.ai/code/session_01XgEpnwvzBYDyjimPu4nXr5 --- linux/crates/app/src/services/preferences.rs | 37 +++++++++++++++++++- linux/crates/app/src/ui/export_dialog.rs | 14 +++----- linux/crates/app/src/ui/preferences.rs | 19 +++++----- 3 files changed, 51 insertions(+), 19 deletions(-) diff --git a/linux/crates/app/src/services/preferences.rs b/linux/crates/app/src/services/preferences.rs index dbb086af8b..87b82cc72e 100644 --- a/linux/crates/app/src/services/preferences.rs +++ b/linux/crates/app/src/services/preferences.rs @@ -1,3 +1,5 @@ +use std::sync::{Mutex, MutexGuard, OnceLock}; + use serde::{Deserialize, Serialize}; use tablepro_core::export::CsvOptions; @@ -43,7 +45,20 @@ impl Default for Preferences { } } -pub fn load() -> Preferences { +/// The file is read once per process. This app is the only writer and +/// every write lands in `save`, so the cached copy cannot drift from +/// what is on disk. Without it a live-saving dialog reads and parses +/// the file again on every spin-button tick, on the GTK main thread. +fn cache() -> &'static Mutex> { + static CACHE: OnceLock>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(None)) +} + +fn lock_cache() -> MutexGuard<'static, Option> { + cache().lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +fn read_from_disk() -> Preferences { let Some(path) = xdg_config_path("preferences.json") else { return Preferences::default(); }; @@ -53,7 +68,18 @@ pub fn load() -> Preferences { .unwrap_or_default() } +pub fn load() -> Preferences { + let mut cached = lock_cache(); + if let Some(prefs) = cached.as_ref() { + return prefs.clone(); + } + let prefs = read_from_disk(); + *cached = Some(prefs.clone()); + prefs +} + pub fn save(prefs: &Preferences) { + *lock_cache() = Some(prefs.clone()); let Some(path) = xdg_config_path("preferences.json") else { return; }; @@ -61,3 +87,12 @@ pub fn save(prefs: &Preferences) { tracing::warn!(path = %path.display(), error = %e, "preferences: write failed"); } } + +/// Read, change, write. A caller that owns one setting cannot drop the +/// others, which a hand-assembled `Preferences` does silently the +/// moment a field is added that the caller doesn't know about. +pub fn update(mutate: impl FnOnce(&mut Preferences)) { + let mut prefs = load(); + mutate(&mut prefs); + save(&prefs); +} diff --git a/linux/crates/app/src/ui/export_dialog.rs b/linux/crates/app/src/ui/export_dialog.rs index d6499bff07..2194a52cb9 100644 --- a/linux/crates/app/src/ui/export_dialog.rs +++ b/linux/crates/app/src/ui/export_dialog.rs @@ -1,4 +1,3 @@ -use std::cell::RefCell; use std::rc::Rc; use relm4::adw::prelude::*; @@ -104,8 +103,6 @@ fn combo_row(title: &str, choices: &[&str]) -> adw::ComboRow { } pub fn present(parent: &adw::ApplicationWindow, toast_overlay: &adw::ToastOverlay, result: QueryResult, name: String) { - let prefs = Rc::new(RefCell::new(preferences::load())); - let page = adw::PreferencesPage::new(); let format_group = adw::PreferencesGroup::new(); @@ -152,7 +149,7 @@ pub fn present(parent: &adw::ApplicationWindow, toast_overlay: &adw::ToastOverla &[&crate::tr!("Period (.)"), &crate::tr!("Comma (,)")], ), }); - rows.show(&prefs.borrow().csv_export); + rows.show(&preferences::load().csv_export); for row in [ &rows.null_to_empty, &rows.line_break_to_space, @@ -166,14 +163,11 @@ pub fn present(parent: &adw::ApplicationWindow, toast_overlay: &adw::ToastOverla } page.add(&csv_group); + // Read-modify-write: the preferences dialog can be open over this + // one, and neither should overwrite the other's settings. let persist = { let rows = rows.clone(); - let prefs = prefs.clone(); - Rc::new(move || { - let mut prefs = prefs.borrow_mut(); - prefs.csv_export = rows.read(); - preferences::save(&prefs); - }) + Rc::new(move || preferences::update(|prefs| prefs.csv_export = rows.read())) }; for row in [ &rows.null_to_empty, diff --git a/linux/crates/app/src/ui/preferences.rs b/linux/crates/app/src/ui/preferences.rs index c21901ef90..25b0d7667e 100644 --- a/linux/crates/app/src/ui/preferences.rs +++ b/linux/crates/app/src/ui/preferences.rs @@ -2,7 +2,7 @@ use relm4::adw::prelude::*; use relm4::gtk::gio; use relm4::{adw, gtk}; -use crate::services::preferences::{self, Preferences}; +use crate::services::preferences; pub fn present(parent: &impl IsA) { let window = adw::PreferencesDialog::builder() @@ -168,13 +168,16 @@ pub fn present(parent: &impl IsA) { let retention = retention_row.clone(); let timeout = timeout_row.clone(); std::rc::Rc::new(move || { - preferences::save(&Preferences { - default_page_size: page_size.value() as u64, - confirm_destructive: confirm.is_active(), - editor_font_size: font.value() as u32, - history_retention_days: retention.value() as u32, - query_timeout_secs: timeout.value() as u32, - csv_export: preferences::load().csv_export, + // Read-modify-write, so a setting this dialog doesn't + // render (the CSV export options, whatever comes next) + // isn't reset to its default the moment the user touches + // one that it does. + preferences::update(|prefs| { + prefs.default_page_size = page_size.value() as u64; + prefs.confirm_destructive = confirm.is_active(); + prefs.editor_font_size = font.value() as u32; + prefs.history_retention_days = retention.value() as u32; + prefs.query_timeout_secs = timeout.value() as u32; }); }) };