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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions linux/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion linux/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 6 additions & 3 deletions linux/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
41 changes: 40 additions & 1 deletion linux/crates/app/src/services/preferences.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
use std::sync::{Mutex, MutexGuard, OnceLock};

use serde::{Deserialize, Serialize};
use tablepro_core::export::CsvOptions;

use super::config_io::{atomic_write_json, xdg_config_path};

Expand All @@ -17,6 +20,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 {
Expand All @@ -35,11 +40,25 @@ 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(),
}
}
}

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<Option<Preferences>> {
static CACHE: OnceLock<Mutex<Option<Preferences>>> = OnceLock::new();
CACHE.get_or_init(|| Mutex::new(None))
}

fn lock_cache() -> MutexGuard<'static, Option<Preferences>> {
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();
};
Expand All @@ -49,11 +68,31 @@ 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;
};
if let Err(e) = atomic_write_json(&path, prefs) {
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);
}
93 changes: 2 additions & 91 deletions linux/crates/app/src/ui/app/browse.rs
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
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;

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).
Expand Down Expand Up @@ -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::<gtk::FileFilter>();
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.
Expand Down
79 changes: 7 additions & 72 deletions linux/crates/app/src/ui/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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`,
Expand Down Expand Up @@ -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),
Expand All @@ -1444,66 +1441,6 @@ impl SimpleComponent for App {
}
}

fn render_csv(result: &QueryResult) -> Vec<u8> {
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::<Vec<_>>().join(","));
out.push('\n');
for row in &result.rows {
let cells: Vec<String> = 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<u8> {
let cols: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect();
let rows: Vec<serde_json::Value> = 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}"),
Expand Down Expand Up @@ -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),
Expand Down
7 changes: 7 additions & 0 deletions linux/crates/app/src/ui/app/workspace_tabs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ 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::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
Expand Down Expand Up @@ -376,6 +379,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),
Expand Down Expand Up @@ -501,6 +505,9 @@ 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::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() {
Expand Down
Loading