From de7d373cca206065245e8a72c8430b36dd18ec61 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 13:37:30 +0200 Subject: [PATCH] feat: expand connected databases in a single explorer sidebar --- src/app.rs | 57 ++++------------- src/connection.rs | 160 ++++++++++++++++++++++++++++++++++++++++------ src/main.rs | 8 ++- 3 files changed, 159 insertions(+), 66 deletions(-) diff --git a/src/app.rs b/src/app.rs index b56b3ca..abed56c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::Line, - widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState}, + widgets::{Block, Cell, Paragraph, Row, Table, TableState}, }; use crate::{ @@ -15,8 +15,7 @@ use crate::{ pub struct App { db: Database, - relations: Vec, - selection: ListState, + pub(crate) relations: Vec, active: Option, columns: Vec, page: Page, @@ -24,7 +23,6 @@ pub struct App { rows_state: TableState, schema_state: TableState, column_offset: usize, - pub(crate) sidebar_focus: bool, show_schema: bool, error: Option, } @@ -36,7 +34,6 @@ impl App { let mut app = Self { db, relations, - selection: ListState::default().with_selected(selected), active: None, columns: Vec::new(), page: Page { @@ -47,20 +44,15 @@ impl App { rows_state: TableState::default(), schema_state: TableState::default(), column_offset: 0, - sidebar_focus: true, show_schema: false, error: None, }; - app.open_selection(); + app.open_relation(selected); Ok(app) } pub(crate) fn handle_key(&mut self, key: KeyEvent) { match key.code { - KeyCode::Enter if self.sidebar_focus => { - self.open_selection(); - self.sidebar_focus = false; - } KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1), KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN), @@ -69,10 +61,10 @@ impl App { self.show_schema = !self.show_schema; self.column_offset = 0; } - KeyCode::Left | KeyCode::Char('h') if !self.sidebar_focus => { + KeyCode::Left | KeyCode::Char('h') => { self.column_offset = self.column_offset.saturating_sub(1); } - KeyCode::Right | KeyCode::Char('l') if !self.sidebar_focus => { + KeyCode::Right | KeyCode::Char('l') => { let count = if self.show_schema { 5 } else { @@ -94,10 +86,7 @@ impl App { } fn move_selection(&mut self, delta: isize) { - if self.sidebar_focus { - let next = moved_index(self.selection.selected(), self.relations.len(), delta); - self.selection.select(next); - } else if self.show_schema { + if self.show_schema { let next = moved_index(self.schema_state.selected(), self.columns.len(), delta); self.schema_state.select(next); } else { @@ -106,8 +95,8 @@ impl App { } } - fn open_selection(&mut self) { - self.active = self.selection.selected(); + pub(crate) fn open_relation(&mut self, index: Option) { + self.active = index; self.columns.clear(); self.page = Page { rows: Vec::new(), @@ -156,15 +145,14 @@ impl App { .position(|relation| Some(relation.id) == id) .or_else(|| (!relations.is_empty()).then_some(0)); self.relations = relations; - self.selection = ListState::default().with_selected(selected); - self.open_selection(); + self.open_relation(selected); } Err(error) => self.error = Some(error_text(error.as_ref())), } } pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) { - if area.width < 46 || area.height < 12 { + if area.width < 38 || area.height < 12 { frame.render_widget( Paragraph::new("Enlarge terminal to at least 70×12. q to quit."), area, @@ -187,32 +175,13 @@ impl App { .style(Style::default().fg(Color::Cyan)), header, ); - let [sidebar, content] = - Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body); - let sidebar_title = format!(" Relations ({}) ", self.relations.len()); - let sidebar_block = panel(&sidebar_title, focused && self.sidebar_focus); - if self.relations.is_empty() { - frame.render_widget( - Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block), - sidebar, - ); - } else { - let list = List::new( - self.relations - .iter() - .map(|relation| ListItem::new(relation.label.as_str())), - ) - .block(sidebar_block) - .highlight_style(selected_style()) - .highlight_symbol("› "); - frame.render_stateful_widget(list, sidebar, &mut self.selection); - } + let content = body; let title = if self.show_schema { " Schema · s for rows " } else { " Rows · s for schema " }; - let content_block = panel(title, focused && !self.sidebar_focus); + let content_block = panel(title, focused); let count = if self.show_schema { 5 } else { @@ -339,7 +308,7 @@ impl App { ); } frame.render_widget(Paragraph::new( - " Tab focus · Esc databases · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c add database · q quit" + " Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c add database · q quit" ).style(Style::default().fg(Color::DarkGray)), help); } } diff --git a/src/connection.rs b/src/connection.rs index 796fd2d..4a20a99 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -22,18 +22,35 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0)); let mut databases_focus = true; let mut active = None; + let mut expanded = true; let mut error: Option = None; let mut form = Form::default(); let mut show_form = false; loop { + let entries = explorer_entries( + profiles.entries.len(), + active, + browser.as_ref().map(|app| app.relations.len()), + expanded, + ); terminal.draw(|frame| { - let [sidebar, content] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]) + let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)]) .areas(frame.area()); - let items = profiles.entries.iter().enumerate().map(|(index, entry)| { - ListItem::new(format!("{}{}", if active == Some(index) { "● " } else { " " }, entry.label())) + let items = entries.iter().map(|entry| { + ListItem::new(match *entry { + ExplorerEntry::Database(index) => { + let connected = browser.is_some() && index == active; + let label = index.map_or_else(|| "Session database".into(), |index| profiles.entries[index].label()); + format!("{} {label}", if connected && expanded { "▼" } else { "▶" }) + } + ExplorerEntry::Relation(index) => { + let relation = &browser.as_ref().unwrap().relations[index]; + format!(" ├ {} [{}]", relation.label, relation.kind) + } + }) }); let list = List::new(items) - .block(Block::bordered().title(" Databases · c add ").border_style( + .block(Block::bordered().title(" Explorer · c add ").border_style( Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray }))) .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)); frame.render_stateful_widget(list, sidebar, &mut selection); @@ -65,12 +82,30 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< let result = if form.reconnect { form.connect().and_then(App::new).map(|connected| { browser = Some(connected); - active = selection.selected(); - databases_focus = false; + active = selection.selected().and_then(|index| { + match entries[index] { + ExplorerEntry::Database(index) => index, + _ => None, + } + }); + selection.select(active); + expanded = true; + databases_focus = true; }) } else { profiles.save(&form).map(|()| { - selection.select(Some(profiles.entries.len() - 1)); + let updated = explorer_entries( + profiles.entries.len(), + active, + browser.as_ref().map(|app| app.relations.len()), + expanded, + ); + selection.select(updated.iter().position(|entry| { + *entry + == ExplorerEntry::Database(Some( + profiles.entries.len() - 1, + )) + })); databases_focus = true; }) }; @@ -94,20 +129,29 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } KeyCode::Esc => databases_focus = true, KeyCode::Tab | KeyCode::BackTab => { - if let Some(browser) = &mut browser { - let backwards = key.code == KeyCode::BackTab; - if databases_focus { - databases_focus = false; - browser.sidebar_focus = !backwards; - } else if browser.sidebar_focus != backwards { - browser.sidebar_focus = backwards; - } else { - databases_focus = true; - } + if browser.is_some() { + databases_focus = !databases_focus; } } KeyCode::Enter if databases_focus => { - if let Some(index) = selection.selected() { + if let Some(entry) = + selection.selected().and_then(|index| entries.get(index)) + { + let index = match *entry { + ExplorerEntry::Relation(index) => { + browser.as_mut().unwrap().open_relation(Some(index)); + databases_focus = false; + continue; + } + ExplorerEntry::Database(index) + if browser.is_some() && index == active => + { + expanded = !expanded; + continue; + } + ExplorerEntry::Database(Some(index)) => index, + ExplorerEntry::Database(None) => continue, + }; form = profiles.entries[index].clone(); if form.provider == 0 { form.reconnect = true; @@ -118,7 +162,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< Ok(connected) => { browser = Some(connected); active = Some(index); - databases_focus = false; + selection.select(Some(index)); + expanded = true; + databases_focus = true; error = None; } Err(failure) => error = Some(error_text(failure.as_ref())), @@ -126,6 +172,33 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } } + KeyCode::Left | KeyCode::Char('h') if databases_focus => { + if matches!( + selection.selected().and_then(|index| entries.get(index)), + Some(ExplorerEntry::Relation(_)) + ) || selection.selected().and_then(|index| entries.get(index)) + == Some(&ExplorerEntry::Database(active)) + { + expanded = false; + selection.select( + entries.iter().position(|entry| { + *entry == ExplorerEntry::Database(active) + }), + ); + } + } + KeyCode::Right | KeyCode::Char('l') if databases_focus => { + if selection.selected().and_then(|index| entries.get(index)) + == Some(&ExplorerEntry::Database(active)) + { + expanded = true; + } + } + KeyCode::Char('r') if databases_focus => { + if let Some(browser) = &mut browser { + browser.handle_key(key); + } + } KeyCode::Down | KeyCode::Char('j') if databases_focus => { selection.select_next() } @@ -158,6 +231,33 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ExplorerEntry { + Database(Option), + Relation(usize), +} + +fn explorer_entries( + count: usize, + active: Option, + relations: Option, + expanded: bool, +) -> Vec { + let mut entries = Vec::new(); + let session = active.is_none() && relations.is_some(); + for database in session + .then_some(None) + .into_iter() + .chain((0..count).map(Some)) + { + entries.push(ExplorerEntry::Database(database)); + if database == active && expanded { + entries.extend((0..relations.unwrap_or(0)).map(ExplorerEntry::Relation)); + } + } + entries +} + fn config_path() -> Result { let root = std::env::var_os("XDG_CONFIG_HOME") .filter(|path| Path::new(path).is_absolute()) @@ -614,6 +714,28 @@ impl Input { mod tests { use super::*; + #[test] + fn explorer_keeps_relations_under_the_connected_database() { + use ExplorerEntry::{Database as Db, Relation as Rel}; + assert_eq!( + explorer_entries(3, Some(1), Some(2), true), + vec![Db(Some(0)), Db(Some(1)), Rel(0), Rel(1), Db(Some(2))] + ); + assert_eq!( + explorer_entries(3, Some(1), Some(2), false), + vec![Db(Some(0)), Db(Some(1)), Db(Some(2))] + ); + assert_eq!( + explorer_entries(1, None, Some(1), true), + vec![Db(None), Rel(0), Db(Some(0))] + ); + assert_eq!( + explorer_entries(1, Some(0), Some(0), true), + vec![Db(Some(0))] + ); + assert_eq!(explorer_entries(1, None, None, true), vec![Db(Some(0))]); + } + #[test] fn editing_preserves_unicode_and_filters_terminal_controls() { let mut input = Input::default(); diff --git a/src/main.rs b/src/main.rs index d6b2f99..587dd55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,14 +29,16 @@ Use sslmode=disable only for trusted local connections. The inspector needs 70×12 terminal cells; the PostgreSQL window needs 74×20. Navigation: - Tab/Shift-Tab Cycle focus: databases, relations, rows (Esc returns to databases) - Up/Down, j/k Move selection (Enter connects to a database or opens a relation) + Tab/Shift-Tab Switch between explorer and rows/schema (Esc returns to explorer) + Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation Home/End, g/G First / last item s Toggle rows and column schema - Left/Right Scroll columns (h/l also work) + Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too) n/p, PgDn/Up Next / previous 100-row page c Add a configured database in a floating window r Refresh relations and the active relation + Connected databases show their tables and views beneath them in the explorer. + Enter on the connected database collapses/expands it without reconnecting. q / Ctrl-C Quit New database window: Up/Down chooses a provider; Enter starts editing. Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.