diff --git a/src/export.rs b/src/export.rs new file mode 100644 index 0000000..d20beef --- /dev/null +++ b/src/export.rs @@ -0,0 +1,257 @@ +use crate::db::{CellValue, QueryResult}; + +pub fn to_csv(result: &QueryResult) -> String { + let mut output = String::new(); + + // Header + let headers: Vec = result.columns.iter().map(|c| csv_escape(&c.name)).collect(); + output.push_str(&headers.join(",")); + output.push('\n'); + + // Rows + for row in &result.rows { + let cells: Vec = row + .iter() + .map(|cell| csv_escape(&cell_to_csv(cell))) + .collect(); + output.push_str(&cells.join(",")); + output.push('\n'); + } + + output +} + +pub fn to_json(result: &QueryResult) -> String { + let mut rows_json: Vec = Vec::new(); + + for row in &result.rows { + let mut obj = serde_json::Map::new(); + for (i, cell) in row.iter().enumerate() { + let col_name = result + .columns + .get(i) + .map(|c| c.name.clone()) + .unwrap_or_else(|| format!("column_{}", i)); + obj.insert(col_name, cell_to_json(cell)); + } + rows_json.push(serde_json::Value::Object(obj)); + } + + serde_json::to_string_pretty(&rows_json).unwrap_or_else(|_| "[]".to_string()) +} + +pub fn to_sql_insert(result: &QueryResult, table_name: &str) -> String { + if result.rows.is_empty() || result.columns.is_empty() { + return String::new(); + } + + let mut output = String::new(); + let col_names: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + + for row in &result.rows { + output.push_str(&format!( + "INSERT INTO {} ({}) VALUES\n", + table_name, + col_names.join(", ") + )); + let values: Vec = row.iter().map(cell_to_sql).collect(); + output.push_str(&format!(" ({});\n", values.join(", "))); + } + + output +} + +pub fn to_tsv(result: &QueryResult) -> String { + let mut output = String::new(); + + // Header + let headers: Vec<&str> = result.columns.iter().map(|c| c.name.as_str()).collect(); + output.push_str(&headers.join("\t")); + output.push('\n'); + + // Rows + for row in &result.rows { + let cells: Vec = row + .iter() + .map(|cell| cell_to_csv(cell).replace('\t', " ")) + .collect(); + output.push_str(&cells.join("\t")); + output.push('\n'); + } + + output +} + +fn cell_to_csv(cell: &CellValue) -> String { + match cell { + CellValue::Null => String::new(), + other => other.display(), + } +} + +fn cell_to_json(cell: &CellValue) -> serde_json::Value { + match cell { + CellValue::Null => serde_json::Value::Null, + CellValue::Bool(b) => serde_json::Value::Bool(*b), + CellValue::Int16(i) => serde_json::json!(*i), + CellValue::Int32(i) => serde_json::json!(*i), + CellValue::Int64(i) => serde_json::json!(*i), + CellValue::Float32(f) => serde_json::json!(*f), + CellValue::Float64(f) => serde_json::json!(*f), + CellValue::Json(j) => j.clone(), + CellValue::Array(arr) => { + let items: Vec = arr.iter().map(cell_to_json).collect(); + serde_json::Value::Array(items) + } + other => serde_json::Value::String(other.display()), + } +} + +fn cell_to_sql(cell: &CellValue) -> String { + match cell { + CellValue::Null => "NULL".to_string(), + CellValue::Bool(b) => { + if *b { + "TRUE".to_string() + } else { + "FALSE".to_string() + } + } + CellValue::Int16(i) => i.to_string(), + CellValue::Int32(i) => i.to_string(), + CellValue::Int64(i) => i.to_string(), + CellValue::Float32(f) => f.to_string(), + CellValue::Float64(f) => f.to_string(), + CellValue::Text(s) => format!("'{}'", s.replace('\'', "''")), + CellValue::Json(j) => format!("'{}'", j.to_string().replace('\'', "''")), + other => format!("'{}'", other.display().replace('\'', "''")), + } +} + +fn csv_escape(s: &str) -> String { + if s.contains(',') || s.contains('"') || s.contains('\n') || s.contains('\r') { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{CellValue, ColumnInfo, QueryResult}; + use std::time::Duration; + + fn make_result() -> QueryResult { + QueryResult { + columns: vec![ + ColumnInfo { + name: "id".to_string(), + type_name: "int4".to_string(), + max_width: 2, + }, + ColumnInfo { + name: "name".to_string(), + type_name: "text".to_string(), + max_width: 10, + }, + ColumnInfo { + name: "active".to_string(), + type_name: "bool".to_string(), + max_width: 5, + }, + ], + rows: vec![ + vec![ + CellValue::Int32(1), + CellValue::Text("Alice".to_string()), + CellValue::Bool(true), + ], + vec![ + CellValue::Int32(2), + CellValue::Text("Bob".to_string()), + CellValue::Null, + ], + ], + row_count: 2, + execution_time: Duration::from_millis(10), + affected_rows: None, + error: None, + } + } + + #[test] + fn test_csv_export() { + let result = make_result(); + let csv = to_csv(&result); + assert!(csv.starts_with("id,name,active\n")); + assert!(csv.contains("1,Alice,true\n")); + assert!(csv.contains("2,Bob,\n")); + } + + #[test] + fn test_csv_escaping() { + assert_eq!(csv_escape("hello"), "hello"); + assert_eq!(csv_escape("hello,world"), "\"hello,world\""); + assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\""); + } + + #[test] + fn test_json_export() { + let result = make_result(); + let json = to_json(&result); + let parsed: Vec = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0]["id"], 1); + assert_eq!(parsed[0]["name"], "Alice"); + assert_eq!(parsed[0]["active"], true); + assert!(parsed[1]["active"].is_null()); + } + + #[test] + fn test_sql_insert_export() { + let result = make_result(); + let sql = to_sql_insert(&result, "users"); + assert!(sql.contains("INSERT INTO users (id, name, active) VALUES")); + assert!(sql.contains("(1, 'Alice', TRUE)")); + assert!(sql.contains("(2, 'Bob', NULL)")); + } + + #[test] + fn test_tsv_export() { + let result = make_result(); + let tsv = to_tsv(&result); + assert!(tsv.starts_with("id\tname\tactive\n")); + assert!(tsv.contains("1\tAlice\ttrue\n")); + } + + #[test] + fn test_empty_result_sql_insert() { + let result = QueryResult::empty(); + let sql = to_sql_insert(&result, "users"); + assert!(sql.is_empty()); + } + + #[test] + fn test_sql_single_quote_escaping() { + assert_eq!( + cell_to_sql(&CellValue::Text("O'Brien".to_string())), + "'O''Brien'" + ); + } + + #[test] + fn test_json_null_handling() { + let json = cell_to_json(&CellValue::Null); + assert!(json.is_null()); + } + + #[test] + fn test_json_number_types() { + assert_eq!(cell_to_json(&CellValue::Int32(42)), serde_json::json!(42)); + assert_eq!( + cell_to_json(&CellValue::Bool(true)), + serde_json::json!(true) + ); + } +} diff --git a/src/main.rs b/src/main.rs index 690ab37..5dbea52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ pub mod ast; mod db; mod editor; +mod export; mod ui; use crate::db::ConnectionManager; diff --git a/src/ui/app.rs b/src/ui/app.rs index f6f9296..2bc4124 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -20,6 +20,46 @@ pub enum Focus { Results, ConnectionDialog, Help, + ExportPicker, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ExportFormat { + Csv, + Json, + SqlInsert, + Tsv, + ClipboardCsv, +} + +pub const EXPORT_FORMATS: &[ExportFormat] = &[ + ExportFormat::Csv, + ExportFormat::Json, + ExportFormat::SqlInsert, + ExportFormat::Tsv, + ExportFormat::ClipboardCsv, +]; + +impl ExportFormat { + pub fn label(&self) -> &'static str { + match self { + ExportFormat::Csv => "CSV (.csv)", + ExportFormat::Json => "JSON (.json)", + ExportFormat::SqlInsert => "SQL INSERT (.sql)", + ExportFormat::Tsv => "TSV (.tsv)", + ExportFormat::ClipboardCsv => "Copy to clipboard (CSV)", + } + } + + pub fn extension(&self) -> &'static str { + match self { + ExportFormat::Csv => "csv", + ExportFormat::Json => "json", + ExportFormat::SqlInsert => "sql", + ExportFormat::Tsv => "tsv", + ExportFormat::ClipboardCsv => "csv", + } + } } #[derive(Debug, Clone, Copy, PartialEq)] @@ -86,6 +126,9 @@ pub struct App { // Help pub show_help: bool, + // Export + pub export_selected: usize, + // Async connection task pub pending_connection: Option<(ConnectionConfig, JoinHandle>)>, } @@ -245,6 +288,7 @@ impl App { loading_message: String::new(), spinner_frame: 0, show_help: false, + export_selected: 0, pending_connection: None, } } @@ -329,6 +373,7 @@ impl App { Focus::Editor => self.handle_editor_input(key).await, Focus::Results => self.handle_results_input(key).await, Focus::Help => self.handle_help_input(key).await, + Focus::ExportPicker => self.handle_export_input(key).await, } } @@ -846,6 +891,12 @@ impl App { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { self.copy_selected_cell(); } + KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if !self.results.is_empty() { + self.export_selected = 0; + self.focus = Focus::ExportPicker; + } + } KeyCode::Char('[') if key.modifiers.contains(KeyModifiers::CONTROL) => { if self.current_result > 0 { self.current_result -= 1; @@ -892,6 +943,84 @@ impl App { Ok(()) } + async fn handle_export_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc => { + self.focus = Focus::Results; + } + KeyCode::Up => { + if self.export_selected > 0 { + self.export_selected -= 1; + } + } + KeyCode::Down => { + if self.export_selected < EXPORT_FORMATS.len() - 1 { + self.export_selected += 1; + } + } + KeyCode::Enter => { + let format = EXPORT_FORMATS[self.export_selected]; + self.perform_export(format); + self.focus = Focus::Results; + } + KeyCode::Char(c @ '1'..='5') => { + let idx = (c as usize) - ('1' as usize); + if idx < EXPORT_FORMATS.len() { + let format = EXPORT_FORMATS[idx]; + self.perform_export(format); + self.focus = Focus::Results; + } + } + _ => {} + } + Ok(()) + } + + fn perform_export(&mut self, format: ExportFormat) { + let result = match self.results.get(self.current_result) { + Some(r) => r, + None => { + self.set_status("No results to export".to_string(), StatusType::Warning); + return; + } + }; + + let content = match format { + ExportFormat::Csv => crate::export::to_csv(result), + ExportFormat::Json => crate::export::to_json(result), + ExportFormat::SqlInsert => crate::export::to_sql_insert(result, "results"), + ExportFormat::Tsv => crate::export::to_tsv(result), + ExportFormat::ClipboardCsv => { + let csv = crate::export::to_csv(result); + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&csv); + self.set_status( + format!("Copied {} rows to clipboard", result.row_count), + StatusType::Success, + ); + } else { + self.set_status("Failed to access clipboard".to_string(), StatusType::Error); + } + return; + } + }; + + let timestamp = chrono::Local::now().format("%Y%m%d_%H%M%S"); + let filename = format!("pgrsql_export_{}.{}", timestamp, format.extension()); + + match std::fs::write(&filename, &content) { + Ok(()) => { + self.set_status( + format!("Exported {} rows to {}", result.row_count, filename), + StatusType::Success, + ); + } + Err(e) => { + self.set_status(format!("Export failed: {}", e), StatusType::Error); + } + } + } + async fn handle_sidebar_select(&mut self) -> Result<()> { match self.sidebar_tab { SidebarTab::Databases => { diff --git a/src/ui/components.rs b/src/ui/components.rs index 08315c2..f1c8333 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -9,6 +9,7 @@ use ratatui::{ use crate::db::SslMode; use crate::ui::{ is_sql_function, is_sql_keyword, is_sql_type, App, Focus, SidebarTab, StatusType, Theme, + EXPORT_FORMATS, SPINNER_FRAMES, }; @@ -48,6 +49,11 @@ pub fn draw(frame: &mut Frame, app: &App) { draw_connection_dialog(frame, app); } + // Draw export picker if active + if app.focus == Focus::ExportPicker { + draw_export_picker(frame, app); + } + // Draw help overlay if active if app.show_help { draw_help_overlay(frame, app); @@ -1064,6 +1070,77 @@ fn draw_toasts(frame: &mut Frame, app: &App) { } } +fn draw_export_picker(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let area = frame.area(); + + let row_count = app + .results + .get(app.current_result) + .map(|r| r.row_count) + .unwrap_or(0); + + let picker_width = 40.min(area.width.saturating_sub(4)); + let picker_height = (EXPORT_FORMATS.len() as u16 + 4).min(area.height.saturating_sub(4)); + + let picker_x = (area.width - picker_width) / 2; + let picker_y = (area.height - picker_height) / 2; + + let picker_area = Rect::new(picker_x, picker_y, picker_width, picker_height); + frame.render_widget(Clear, picker_area); + + let title = format!(" Export Results ({} rows) ", row_count); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(theme.border_focused)) + .title(title) + .title_style( + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ) + .style(Style::default().bg(theme.bg_primary)); + + let inner = block.inner(picker_area); + frame.render_widget(block, picker_area); + + let items: Vec = EXPORT_FORMATS + .iter() + .enumerate() + .map(|(i, fmt)| { + let prefix = format!(" {}. ", i + 1); + let style = if i == app.export_selected { + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(theme.text_primary) + }; + ListItem::new(format!("{}{}", prefix, fmt.label())).style(style) + }) + .collect(); + + let list = List::new(items); + let list_area = Rect::new( + inner.x, + inner.y, + inner.width, + inner.height.saturating_sub(1), + ); + frame.render_widget(list, list_area); + + // Hint text at bottom + let hint_area = Rect::new( + inner.x, + inner.y + inner.height.saturating_sub(1), + inner.width, + 1, + ); + let hint = Paragraph::new(" Enter: Export | 1-5: Quick select | Esc: Cancel") + .style(Style::default().fg(theme.text_muted)); + frame.render_widget(hint, hint_area); +} + fn draw_help_overlay(frame: &mut Frame, app: &App) { let theme = &app.theme; let area = frame.area(); @@ -1114,6 +1191,7 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " Arrow keys Navigate cells", " Esc Back to editor", " Ctrl+C Copy cell value", + " Ctrl+S Export results", " Ctrl+[/] Prev/Next result set", " PageUp/Down Scroll results", "",