diff --git a/Cargo.lock b/Cargo.lock index 4f40173..734fa61 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -148,9 +148,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" [[package]] name = "bytemuck" @@ -979,9 +979,9 @@ dependencies = [ [[package]] name = "native-tls" -version = "0.2.16" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d5d26952a508f321b4d3d2e80e78fc2603eaefcdf0c30783867f19586518bdc" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", diff --git a/README.md b/README.md index dbcb35b..bb1b9be 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ A beautiful, fast TUI SQL editor for PostgreSQL written in Rust. - **Query Results Table**: Scrollable, navigable results with cell selection - **Query History**: Persistent history with search capability - **Connection Management**: Save and manage multiple PostgreSQL connections +- **Table Inspector**: View table structure, columns, indexes, and DDL without writing queries +- **Export Results**: Export query results to CSV, JSON, SQL INSERT, or TSV - **Keyboard-First Design**: Efficient navigation without leaving the keyboard - **Dark Theme**: Easy on the eyes for long coding sessions @@ -135,6 +137,7 @@ cargo install pgrsql | `1` / `2` / `3` | Switch sidebar tab (Databases / Tables / History) | | `Up/Down` | Navigate items | | `Enter` | Select/expand item | +| `Ctrl+I` | Open Table Inspector (when a table is selected) | #### Results | Key | Action | @@ -144,6 +147,28 @@ cargo install pgrsql | `Ctrl+[` / `Ctrl+]` | Previous / Next result set | | `PageUp/PageDown` | Scroll results | | `Home/End` | Jump to first/last column | +| `Ctrl+S` | Export results (opens format picker) | + +#### Table Inspector +| Key | Action | +|-----|--------| +| `D` | Toggle between Structure and DDL views | +| `Ctrl+C` | Copy DDL to clipboard (in DDL view) | +| `Up/Down` | Scroll content | +| `PageUp/PageDown` | Scroll by 10 lines | +| `Esc` or `q` | Close inspector | + +### Table Inspector + +The Table Inspector lets you view table metadata without writing SQL queries. + +1. Switch to the **Tables** tab (`2`) in the sidebar +2. Expand a schema and select a table +3. Press `Ctrl+I` to open the inspector + +**Structure View** (default): Shows columns with their data types, nullability, primary key indicators, and default values. Also displays indexes with their columns and uniqueness. + +**DDL View** (press `D`): Shows the full `CREATE TABLE` statement including all column definitions, constraints, and indexes. Press `Ctrl+C` to copy the DDL to your clipboard. ### Working with Multiple Databases diff --git a/src/ui/app.rs b/src/ui/app.rs index 2bc4124..72dd4ea 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -5,8 +5,9 @@ use tokio::task::JoinHandle; use tokio_postgres::Client; use crate::db::{ - create_client, execute_query, get_databases, get_schemas, get_tables, ColumnDetails, - ConnectionConfig, ConnectionManager, DatabaseInfo, QueryResult, SchemaInfo, SslMode, TableInfo, + create_client, execute_query, get_columns, get_databases, get_indexes, get_schemas, + get_table_ddl, get_tables, ColumnDetails, ConnectionConfig, ConnectionManager, DatabaseInfo, + IndexInfo, QueryResult, SchemaInfo, SslMode, TableInfo, }; use crate::editor::{HistoryEntry, QueryHistory, TextBuffer}; use crate::ui::Theme; @@ -20,9 +21,21 @@ pub enum Focus { Results, ConnectionDialog, Help, + TableInspector, ExportPicker, } +#[derive(Debug, Clone)] +pub struct TableInspectorState { + pub table_name: String, + pub schema_name: String, + pub columns: Vec, + pub indexes: Vec, + pub ddl: String, + pub show_ddl: bool, + pub scroll: usize, +} + #[derive(Debug, Clone, Copy, PartialEq)] pub enum ExportFormat { Csv, @@ -126,6 +139,9 @@ pub struct App { // Help pub show_help: bool, + // Table Inspector + pub table_inspector: Option, + // Export pub export_selected: usize, @@ -288,6 +304,7 @@ impl App { loading_message: String::new(), spinner_frame: 0, show_help: false, + table_inspector: None, export_selected: 0, pending_connection: None, } @@ -373,6 +390,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::TableInspector => self.handle_table_inspector_input(key).await, Focus::ExportPicker => self.handle_export_input(key).await, } } @@ -682,6 +700,9 @@ impl App { self.focus = Focus::ConnectionDialog; self.connection_dialog.active = true; } + KeyCode::Char('i') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.open_table_inspector().await; + } _ => {} } Ok(()) @@ -943,6 +964,56 @@ impl App { Ok(()) } + async fn handle_table_inspector_input(&mut self, key: KeyEvent) -> Result<()> { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + self.table_inspector = None; + self.focus = Focus::Sidebar; + } + KeyCode::Char('d') | KeyCode::Char('D') => { + if let Some(ref mut inspector) = self.table_inspector { + inspector.show_ddl = !inspector.show_ddl; + inspector.scroll = 0; + } + } + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(ref inspector) = self.table_inspector { + if inspector.show_ddl { + if let Ok(mut clipboard) = arboard::Clipboard::new() { + let _ = clipboard.set_text(&inspector.ddl); + self.set_status( + "DDL copied to clipboard".to_string(), + StatusType::Success, + ); + } + } + } + } + KeyCode::Up => { + if let Some(ref mut inspector) = self.table_inspector { + inspector.scroll = inspector.scroll.saturating_sub(1); + } + } + KeyCode::Down => { + if let Some(ref mut inspector) = self.table_inspector { + inspector.scroll += 1; + } + } + KeyCode::PageUp => { + if let Some(ref mut inspector) = self.table_inspector { + inspector.scroll = inspector.scroll.saturating_sub(10); + } + } + KeyCode::PageDown => { + if let Some(ref mut inspector) = self.table_inspector { + inspector.scroll += 10; + } + } + _ => {} + } + Ok(()) + } + async fn handle_export_input(&mut self, key: KeyEvent) -> Result<()> { match key.code { KeyCode::Esc => { @@ -976,6 +1047,67 @@ impl App { Ok(()) } + async fn open_table_inspector(&mut self) { + if self.sidebar_tab != SidebarTab::Tables || self.connection.client.is_none() { + return; + } + + // Find the selected table from the sidebar + let mut index = 0; + let mut target_table: Option<(String, String)> = None; + + for schema in &self.schemas { + if index == self.sidebar_selected { + // Schema is selected, not a table + return; + } + index += 1; + + if self.expanded_schemas.contains(&schema.name) { + for table in &self.tables { + if table.schema == schema.name { + if index == self.sidebar_selected { + target_table = Some((schema.name.clone(), table.name.clone())); + break; + } + index += 1; + } + } + if target_table.is_some() { + break; + } + } + } + + let (schema_name, table_name) = match target_table { + Some(t) => t, + None => return, + }; + + let client = self.connection.client.as_ref().unwrap(); + + let columns = get_columns(client, &schema_name, &table_name) + .await + .unwrap_or_default(); + let indexes = get_indexes(client, &schema_name, &table_name) + .await + .unwrap_or_default(); + let ddl = get_table_ddl(client, &schema_name, &table_name) + .await + .unwrap_or_else(|_| "-- DDL generation failed".to_string()); + + self.table_inspector = Some(TableInspectorState { + table_name, + schema_name, + columns, + indexes, + ddl, + show_ddl: false, + scroll: 0, + }); + self.focus = Focus::TableInspector; + } + fn perform_export(&mut self, format: ExportFormat) { let result = match self.results.get(self.current_result) { Some(r) => r, diff --git a/src/ui/components.rs b/src/ui/components.rs index f1c8333..fb06b8a 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -9,8 +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, + EXPORT_FORMATS, SPINNER_FRAMES, }; pub fn draw(frame: &mut Frame, app: &App) { @@ -44,6 +43,11 @@ pub fn draw(frame: &mut Frame, app: &App) { draw_toasts(frame, app); } + // Draw table inspector if active + if app.table_inspector.is_some() { + draw_table_inspector(frame, app); + } + // Draw connection dialog if active if app.connection_dialog.active { draw_connection_dialog(frame, app); @@ -1070,6 +1074,127 @@ fn draw_toasts(frame: &mut Frame, app: &App) { } } +fn draw_table_inspector(frame: &mut Frame, app: &App) { + let theme = &app.theme; + let inspector = match &app.table_inspector { + Some(i) => i, + None => return, + }; + + let area = frame.area(); + let width = 70.min(area.width.saturating_sub(4)); + let height = (area.height - 4).min(30); + let x = (area.width - width) / 2; + let y = (area.height - height) / 2; + let dialog_area = Rect::new(x, y, width, height); + + frame.render_widget(Clear, dialog_area); + + let title = format!( + " Table: {}.{} ", + inspector.schema_name, inspector.table_name + ); + 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(dialog_area); + frame.render_widget(block, dialog_area); + + let mut lines: Vec = Vec::new(); + + if inspector.show_ddl { + // DDL view + for ddl_line in inspector.ddl.lines() { + lines.push(Line::from(Span::styled( + format!(" {}", ddl_line), + Style::default().fg(theme.text_primary), + ))); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " [D] Structure [Ctrl+C] Copy DDL [Esc] Close", + Style::default().fg(theme.text_muted), + ))); + } else { + // Structure view + lines.push(Line::from(Span::styled( + " COLUMNS", + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ))); + + for col in &inspector.columns { + let pk = if col.is_primary_key { " PK" } else { "" }; + let nullable = if col.is_nullable { "NULL" } else { "NOT NULL" }; + let default = col + .default_value + .as_ref() + .map(|d| format!(" DEFAULT {}", d)) + .unwrap_or_default(); + let line_text = format!( + " {:<20} {:<15} {:<8}{}{}", + col.name, col.data_type, nullable, pk, default + ); + let style = if col.is_primary_key { + Style::default().fg(theme.warning) + } else { + Style::default().fg(theme.text_primary) + }; + lines.push(Line::from(Span::styled(line_text, style))); + } + + if !inspector.indexes.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " INDEXES", + Style::default() + .fg(theme.text_accent) + .add_modifier(Modifier::BOLD), + ))); + + for idx in &inspector.indexes { + let kind = if idx.is_primary { + "PRIMARY" + } else if idx.is_unique { + "UNIQUE" + } else { + "" + }; + let line_text = format!(" {:<30} ({}) {}", idx.name, idx.columns.join(", "), kind); + lines.push(Line::from(Span::styled( + line_text, + Style::default().fg(theme.text_primary), + ))); + } + } + + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + " [D] DDL [Esc] Close", + Style::default().fg(theme.text_muted), + ))); + } + + // Apply scrolling + let visible: Vec = lines + .into_iter() + .skip(inspector.scroll) + .take(inner.height as usize) + .collect(); + + let paragraph = Paragraph::new(visible); + frame.render_widget(paragraph, inner); +} + fn draw_export_picker(frame: &mut Frame, app: &App) { let theme = &app.theme; let area = frame.area(); @@ -1185,6 +1310,7 @@ fn draw_help_overlay(frame: &mut Frame, app: &App) { " 1/2/3 Switch tabs", " Enter Select item", " ↑/↓ Navigate", + " Ctrl+I Inspect table (DDL)", "", " RESULTS", " Tab/Shift+Tab Next/Prev column",