From 514ea8ef57e22b08ab7790fc24e1f9fc960d5901 Mon Sep 17 00:00:00 2001 From: muk Date: Wed, 18 Feb 2026 19:53:47 +0000 Subject: [PATCH 1/3] Add table DDL and structure viewer (Ctrl+I) Implements a modal table inspector that shows column details (name, type, nullable, primary key, default) and indexes for the selected table. Press 'D' to toggle DDL view showing the full CREATE TABLE statement, and Ctrl+C to copy DDL to clipboard. Closes #16 Co-Authored-By: Claude Opus 4.6 --- src/ui/app.rs | 136 ++++++++++++++++++++++++++++++++++++++++++- src/ui/components.rs | 126 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index 2ccc163..8d9bc67 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,6 +21,18 @@ pub enum Focus { Results, ConnectionDialog, Help, + TableInspector, +} + +#[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)] @@ -83,6 +96,9 @@ pub struct App { // Help pub show_help: bool, + // Table Inspector + pub table_inspector: Option, + // Async connection task pub pending_connection: Option<(ConnectionConfig, JoinHandle>)>, } @@ -240,6 +256,7 @@ impl App { loading_message: String::new(), spinner_frame: 0, show_help: false, + table_inspector: None, pending_connection: None, } } @@ -324,6 +341,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, } } @@ -632,6 +650,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(()) @@ -833,6 +854,117 @@ 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 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; + } + 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 da943dd..558079b 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -42,6 +42,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); @@ -923,6 +928,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_help_overlay(frame: &mut Frame, app: &App) { let theme = &app.theme; let area = frame.area(); From 6fd3dcfb784f2850a06261b5b7a9cb9ab3a41efc Mon Sep 17 00:00:00 2001 From: muk Date: Thu, 19 Feb 2026 08:27:09 +0000 Subject: [PATCH 2/3] Merge main and add Ctrl+I to help overlay Merge latest main into feature branch (no conflicts). Add the Ctrl+I table inspector shortcut to the help overlay so users can discover the feature. Co-Authored-By: Claude Opus 4.6 --- src/ui/components.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ui/components.rs b/src/ui/components.rs index 104464b..080e19a 100644 --- a/src/ui/components.rs +++ b/src/ui/components.rs @@ -1234,6 +1234,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", From aee17c52c9f68571d4ab0381bae68b261ef1cf60 Mon Sep 17 00:00:00 2001 From: muk Date: Thu, 19 Feb 2026 08:49:29 +0000 Subject: [PATCH 3/3] docs: add Table Inspector to README, cargo update, fmt Add Table Inspector documentation to README including feature description, keyboard shortcuts, and usage instructions. Run cargo update (bumpalo, native-tls) and cargo fmt. Co-Authored-By: Claude Opus 4.6 --- Cargo.lock | 8 ++++---- README.md | 25 +++++++++++++++++++++++++ src/ui/components.rs | 3 +-- 3 files changed, 30 insertions(+), 6 deletions(-) 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/components.rs b/src/ui/components.rs index 26a4e22..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) {