From 8e41c27659c0ef470a2f85dc118a5cccbe16cbfb Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Mon, 19 Oct 2026 15:30:25 +0200 Subject: [PATCH] feat: add modal query editing and vamp-style status bars --- TODO.md | 34 ++--- src/app.rs | 297 +++++++++++++++++++++++++++++++++++------- src/connection.rs | 321 +++++++++++++++++++++++++++++++++++++++++++--- src/db.rs | 23 ++++ src/main.rs | 22 ++-- 5 files changed, 608 insertions(+), 89 deletions(-) diff --git a/TODO.md b/TODO.md index bd32ffb..4628698 100644 --- a/TODO.md +++ b/TODO.md @@ -1,17 +1,17 @@ -- [ ] adopt vamp-style top and bottom bars with a fixed layout, not vamp's configurable segment/tab system. - - [ ] render full-width bars in the outer shell (`src/connection.rs`) before splitting explorer/content; remove the old content-only header (`src/app.rs`, see above). - - [ ] top: `dbdash` badge with vamp-style `▓░` trailing edge; retain provider and READ ONLY information without tab numbering. - - [ ] bottom-left: current mode badge with `▓░` trailing edge, followed by the connected database name; bottom-right: focused input's one-based `line:column` with `░▓` leading edge. - - [ ] capture a safe database display name on successful saved-profile and CLI connections, including SQLite; use the connected database, not explorer selection or `Database::name()` (which returns the provider). Never expose raw connection strings/passwords; show a clear disconnected state and retain the previous identity after a failed switch. - - [ ] add Normal/Insert query editing with mode-aware key routing; define entry/navigation keys and make Esc leave Insert before returning to the explorer. Preserve query execution, multiline editing, paste, and focus cycling. - - [ ] keep connection/password forms direct-entry for now; make the mode indicator reflect the focused surface rather than a stale query mode. - - [ ] expose position from the existing `Input` cursor instead of duplicating state; follow the focused query/form field, hide position outside inputs, and define Unicode column semantics consistently. - - [ ] keep relation/page/column status in pane borders and accommodate the separate help pane; do not add tabs, title templates, custom segments, or editor-specific status. - - [ ] handle narrow/short terminals without overlapping content: truncate the database label before mode/position, and let bars yield to content when height is insufficient. - - [ ] verify mode transitions/key precedence, focused-input Unicode positions, connection identity on successful/failed switches, and constrained layouts; smoke-run the TUI and ask for a visual check. -- [ ] pressing `i` on a configured database connection should bring up a floating window with details/info on that connection. -- [ ] show a `*` to the right of the connected db in the sidebar. -- [ ] use the proper ending character in the family of `├` instead of that character all the way down. -- [ ] remove this top-most line above the panels on the right-hand side: `dbdash │ SQLite │ READ ONLY`. -- [ ] highlight the current cell isntead of just the current row in the results pane. -- [ ] put the help text inside it's own pane so it fits the design more cleanly. \ No newline at end of file +- [x] adopt vamp-style top and bottom bars with a fixed layout, not vamp's configurable segment/tab system. + - [x] render full-width bars in the outer shell (`src/connection.rs`) before splitting explorer/content; remove the old content-only header (`src/app.rs`, see above). + - [x] top: `dbdash` badge with vamp-style `▓░` trailing edge; retain provider and READ ONLY information without tab numbering. + - [x] bottom-left: current mode badge with `▓░` trailing edge, followed by the connected database name; bottom-right: focused input's one-based `line:column` with `░▓` leading edge. + - [x] capture a safe database display name on successful saved-profile and CLI connections, including SQLite; use the connected database, not explorer selection or `Database::name()` (which returns the provider). Never expose raw connection strings/passwords; show a clear disconnected state and retain the previous identity after a failed switch. + - [x] add Normal/Insert query editing with mode-aware key routing; define entry/navigation keys and make Esc leave Insert before returning to the explorer. Preserve query execution, multiline editing, paste, and focus cycling. + - [x] keep connection/password forms direct-entry for now; make the mode indicator reflect the focused surface rather than a stale query mode. + - [x] expose position from the existing `Input` cursor instead of duplicating state; follow the focused query/form field, hide position outside inputs, and define Unicode column semantics consistently. + - [x] keep relation/page/column status in pane borders and accommodate the separate help pane; do not add tabs, title templates, custom segments, or editor-specific status. + - [x] handle narrow/short terminals without overlapping content: truncate the database label before mode/position, and let bars yield to content when height is insufficient. + - [x] verify mode transitions/key precedence, focused-input Unicode positions, connection identity on successful/failed switches, and constrained layouts; smoke-run the TUI and ask for a visual check. +- [x] pressing `i` on a configured database connection should bring up a floating window with details/info on that connection. +- [x] show a `*` to the right of the connected db in the sidebar. +- [x] use the proper ending character in the family of `├` instead of that character all the way down. +- [x] remove this top-most line above the panels on the right-hand side: `dbdash │ SQLite │ READ ONLY`. +- [x] highlight the current cell isntead of just the current row in the results pane. +- [x] put the help text inside it's own pane so it fits the design more cleanly. \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index fad562d..28a1084 100644 --- a/src/app.rs +++ b/src/app.rs @@ -33,6 +33,7 @@ pub struct App { column_offset: usize, show_schema: bool, query: Input, + query_insert: bool, query_highlights: sql::Highlights, query_result: Option, error: Option, @@ -57,6 +58,7 @@ impl App { column_offset: 0, show_schema: false, query: Input::default(), + query_insert: false, query_highlights: Vec::new(), query_result: None, error: None, @@ -65,6 +67,26 @@ impl App { Ok(app) } + pub(crate) fn provider(&self) -> &'static str { + self.db.name() + } + + pub(crate) fn database_name(&self) -> &str { + self.db.display_name() + } + + pub(crate) fn query_insert_mode(&self) -> bool { + self.query_insert + } + + pub(crate) fn leave_query_insert(&mut self) -> bool { + std::mem::replace(&mut self.query_insert, false) + } + + pub(crate) fn query_position(&self) -> (usize, usize) { + self.query.position() + } + pub(crate) fn handle_key(&mut self, key: KeyEvent) { match key.code { KeyCode::Down | KeyCode::Char('j') => self.move_selection(1), @@ -75,21 +97,8 @@ impl App { self.show_schema = !self.show_schema; self.column_offset = 0; } - KeyCode::Left | KeyCode::Char('h') => { - self.column_offset = self.column_offset.saturating_sub(1); - } - KeyCode::Right | KeyCode::Char('l') => { - let count = if self.show_schema { - 5 - } else { - self.query_result - .as_ref() - .map_or(self.columns.len(), |result| result.headers.len()) - }; - if self.column_offset + 1 < count { - self.column_offset += 1; - } - } + KeyCode::Left | KeyCode::Char('h') => self.move_column(-1), + KeyCode::Right | KeyCode::Char('l') => self.move_column(1), KeyCode::Char('n') | KeyCode::PageDown if self.query_result.is_none() && !self.show_schema && self.page.has_next => { @@ -110,13 +119,54 @@ impl App { match key.code { KeyCode::F(5) => self.execute_query(), KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(), - KeyCode::Enter => self.query.paste_multiline("\n"), - _ => self.query.edit(key), + KeyCode::Esc => { + self.leave_query_insert(); + } + KeyCode::Enter if self.query_insert => self.query.paste_multiline("\n"), + _ if self.query_insert => self.query.edit(key), + KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::Home + | KeyCode::End => self.query.edit(key), + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => + { + let movement = match character { + 'h' => Some(KeyCode::Left), + 'j' => Some(KeyCode::Down), + 'k' => Some(KeyCode::Up), + 'l' => Some(KeyCode::Right), + 'a' => { + let (line, column) = self.query_position(); + self.query + .value + .split('\n') + .nth(line - 1) + .filter(|text| column <= text.chars().count()) + .map(|_| KeyCode::Right) + } + 'I' => Some(KeyCode::Home), + 'A' => Some(KeyCode::End), + _ => None, + }; + if let Some(code) = movement { + self.query.edit(KeyEvent::new(code, KeyModifiers::NONE)); + } + self.query_insert = matches!(character, 'i' | 'a' | 'I' | 'A'); + } + _ => {} } self.highlight_query(previous_len); } pub(crate) fn paste_query(&mut self, text: &str) { + if !self.query_insert { + return; + } let previous_len = self.query.value.len(); self.query.paste_multiline(text); self.highlight_query(previous_len); @@ -133,7 +183,8 @@ impl App { match self.db.query(&self.query.value) { Ok(result) => { self.rows_state = TableState::default() - .with_selected((!result.page.rows.is_empty()).then_some(0)); + .with_selected((!result.page.rows.is_empty()).then_some(0)) + .with_selected_column((!result.headers.is_empty()).then_some(0)); self.column_offset = 0; self.show_schema = false; self.query_result = Some(result); @@ -157,6 +208,26 @@ impl App { } } + fn column_count(&self) -> usize { + if self.show_schema { + 5 + } else { + self.query_result + .as_ref() + .map_or(self.columns.len(), |result| result.headers.len()) + } + } + + fn move_column(&mut self, delta: isize) { + let count = self.column_count(); + let state = if self.show_schema { + &mut self.schema_state + } else { + &mut self.rows_state + }; + state.select_column(moved_index(state.selected_column(), count, delta)); + } + pub(crate) fn open_relation(&mut self, index: Option) { self.active = index; self.query_result = None; @@ -176,6 +247,8 @@ impl App { self.columns = columns; self.schema_state .select((!self.columns.is_empty()).then_some(0)); + self.schema_state + .select_column((!self.columns.is_empty()).then_some(0)); self.load_page(0); } Err(error) => self.error = Some(error_text(error.as_ref())), @@ -188,8 +261,13 @@ impl App { // ponytail: synchronous reads block input; use a worker if responsiveness matters. match self.db.page(&self.relations[active], &self.columns, index) { Ok(page) => { - self.rows_state = - TableState::default().with_selected((!page.rows.is_empty()).then_some(0)); + self.rows_state = TableState::default() + .with_selected((!page.rows.is_empty()).then_some(0)) + .with_selected_column( + self.rows_state + .selected_column() + .or_else(|| (!self.columns.is_empty()).then_some(0)), + ); self.page = page; self.page_index = index; self.error = None; @@ -217,26 +295,16 @@ impl App { pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: Focus) { if area.width < 38 || area.height < 10 { frame.render_widget( - Paragraph::new("Enlarge terminal to at least 70×14. Ctrl-C to quit."), + Paragraph::new("Enlarge terminal to at least 70×17. Ctrl-C to quit."), area, ); return; } - let [header, error_area, body] = Layout::vertical([ - Constraint::Length(1), + let [error_area, body] = Layout::vertical([ Constraint::Length(u16::from(self.error.is_some())), Constraint::Min(4), ]) .areas(area); - frame.render_widget( - Paragraph::new(Line::from_iter([ - " dbdash │ ", - self.db.name(), - " │ READ ONLY", - ])) - .style(Style::default().fg(Color::Cyan)), - header, - ); let [query, content] = Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body); self.query.draw( @@ -254,16 +322,20 @@ impl App { } else { " Rows · s for schema " }; - let count = if self.show_schema { - 5 + let count = self.column_count(); + let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1); + let selected = if self.show_schema { + self.schema_state.selected_column() } else { - self.query_result - .as_ref() - .map_or(self.columns.len(), |result| result.headers.len()) + self.rows_state.selected_column() }; - let visible = usize::from(content.width.saturating_sub(4) / 21) - .max(1) - .min(count.saturating_sub(self.column_offset)); + if let Some(selected) = selected { + self.column_offset = self.column_offset.min(selected); + if selected >= self.column_offset + capacity { + self.column_offset = selected + 1 - capacity; + } + } + let visible = capacity.min(count.saturating_sub(self.column_offset)); let widths = (0..visible).map(|index| { if index + 1 == visible { Constraint::Min(1) @@ -349,9 +421,11 @@ impl App { .style(header_style()), ) .block(content_block) - .row_highlight_style(selected_style()) - .highlight_symbol("› "); + .cell_highlight_style(selected_style()); + self.schema_state + .select_column(selected.map(|column| column - self.column_offset)); frame.render_stateful_widget(table, content, &mut self.schema_state); + self.schema_state.select_column(selected); } else if page.rows.is_empty() { frame.render_widget( Paragraph::new(if self.error.is_some() { @@ -387,9 +461,11 @@ impl App { let table = Table::new(rows, widths) .header(Row::new(headers).style(header_style())) .block(content_block) - .row_highlight_style(selected_style()) - .highlight_symbol("› "); + .cell_highlight_style(selected_style()); + self.rows_state + .select_column(selected.map(|column| column - self.column_offset)); frame.render_stateful_widget(table, content, &mut self.rows_state); + self.rows_state.select_column(selected); } if let Some(error) = &self.error { frame.render_widget( @@ -446,6 +522,124 @@ mod tests { assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2)); } + #[test] + fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let file = tempfile::NamedTempFile::new()?; + let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?; + let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); + assert!(!app.query_insert_mode()); + app.paste_query("ignored"); + app.handle_query_key(key('q')); + assert!(app.query.value.is_empty()); + assert!(!app.leave_query_insert()); + app.handle_query_key(key('i')); + for character in ['q', 'i', 'h'] { + app.handle_query_key(key(character)); + } + app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); + app.paste_query("界é"); + assert_eq!(app.query.value, "qih\n界é"); + assert_eq!(app.query_position(), (2, 3)); + assert!(app.leave_query_insert()); + app.paste_query("ignored"); + app.handle_query_key(key('h')); + assert_eq!(app.query_position(), (2, 2)); + app.handle_query_key(key('k')); + assert_eq!(app.query_position(), (1, 2)); + app.handle_query_key(key('a')); + assert!(app.query_insert_mode()); + assert_eq!(app.query_position(), (1, 3)); + app.leave_query_insert(); + app.handle_query_key(key('I')); + assert_eq!(app.query_position(), (1, 1)); + assert!(app.query_insert_mode()); + app.leave_query_insert(); + app.handle_query_key(key('A')); + assert_eq!(app.query_position(), (1, 4)); + assert!(app.query_insert_mode()); + app.leave_query_insert(); + app.handle_query_key(key('a')); + assert_eq!(app.query_position(), (1, 4)); + app.handle_query_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); + assert!(!app.query_insert_mode()); + assert_eq!(app.query.value, "qih\n界é"); + Ok(()) + } + + #[test] + fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let file = tempfile::NamedTempFile::new()?; + let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?; + app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); + app.paste_query("SELECT\n42"); + for insert in [true, false] { + if !insert { + app.leave_query_insert(); + } + for key in [ + KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE), + KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL), + ] { + app.query_result = None; + app.handle_query_key(key); + assert_eq!(app.query_insert_mode(), insert); + assert_eq!( + app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(), + Some("42") + ); + } + } + Ok(()) + } + + #[test] + fn cell_selection_scrolls_and_survives_paging_and_schema_switches() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + use ratatui::{Terminal, backend::TestBackend, style::Color}; + let file = tempfile::NamedTempFile::new()?; + rusqlite::Connection::open(file.path())?.execute_batch( + "CREATE TABLE cells (a, b, c, d, e, f); + WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101) + INSERT INTO cells SELECT x, x, x, x, x, x FROM n;", + )?; + let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?; + let mut terminal = Terminal::new(TestBackend::new(60, 20))?; + let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); + assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); + terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; + assert_eq!(terminal.backend().buffer()[(1, 12)].bg, Color::Blue); + assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue); + app.handle_key(key('l')); + app.handle_key(key('l')); + app.handle_key(key('j')); + terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; + assert_eq!(app.rows_state.selected_cell(), Some((1, 2))); + assert_eq!(app.column_offset, 1); + assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue); + assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue); + assert_ne!(terminal.backend().buffer()[(22, 12)].bg, Color::Blue); + app.handle_key(key('n')); + assert_eq!(app.page_index, 1); + assert_eq!(app.rows_state.selected_cell(), Some((0, 2))); + app.handle_key(key('s')); + app.handle_key(key('l')); + app.handle_key(key('j')); + terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; + assert_eq!(app.schema_state.selected_cell(), Some((1, 1))); + assert_eq!(app.column_offset, 0); + assert_eq!(terminal.backend().buffer()[(22, 13)].bg, Color::Blue); + assert_ne!(terminal.backend().buffer()[(1, 13)].bg, Color::Blue); + app.handle_key(key('s')); + terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; + assert_eq!(app.rows_state.selected_cell(), Some((0, 2))); + assert_eq!(app.column_offset, 1); + app.handle_key(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(app.rows_state.selected_column(), Some(1)); + Ok(()) + } + #[test] fn query_colors_survive_editing_and_scrolling() -> crate::Result<()> { use super::{App, Focus}; @@ -454,17 +648,18 @@ mod tests { let file = tempfile::NamedTempFile::new()?; let mut app = App::new(crate::db::Database::open_sqlite(file.path())?)?; let mut terminal = Terminal::new(TestBackend::new(60, 14))?; + app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT '界', 42;\n-- comment"); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; let buffer = terminal.backend().buffer(); - assert_eq!(buffer[(1, 2)].fg, Color::Cyan); - assert_eq!(buffer[(8, 2)].fg, Color::Green); - assert_eq!(buffer[(1, 3)].fg, Color::DarkGray); + assert_eq!(buffer[(1, 1)].fg, Color::Cyan); + assert_eq!(buffer[(8, 1)].fg, Color::Green); + assert_eq!(buffer[(1, 2)].fg, Color::DarkGray); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.handle_query_key(KeyEvent::new(KeyCode::Char('\''), KeyModifiers::NONE)); app.paste_query("unfinished\nstring"); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; - assert_eq!(terminal.backend().buffer()[(1, 3)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(1, 2)].fg, Color::Green); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); app.paste_query(&format!("{}--{}", "-- line\n".repeat(12), "x".repeat(90))); terminal.draw(|frame| app.draw(frame, frame.area(), Focus::Query))?; @@ -484,11 +679,15 @@ mod tests { "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);", )?; let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?; + app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;"); app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); let result = app.query_result.as_ref().unwrap(); assert_eq!(result.headers, ["note", "missing"]); assert_eq!(result.page.rows[0], [Some("界é".into()), None]); + assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); + app.handle_key(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(app.rows_state.selected_cell(), Some((0, 1))); app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)); assert_eq!(app.rows_state.selected(), Some(1)); app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); @@ -529,7 +728,7 @@ mod tests { "rows 1–1" })); if let Some(error) = error { - let message: String = (0..100).map(|x| buffer[(x, 1)].symbol()).collect(); + let message: String = (0..100).map(|x| buffer[(x, 0)].symbol()).collect(); assert!(message.starts_with(error), "{message}"); } } diff --git a/src/connection.rs b/src/connection.rs index 585fd8a..0e24636 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -6,7 +6,7 @@ use ratatui::{ DefaultTerminal, Frame, layout::{Constraint, Layout, Rect}, style::{Color, Style}, - text::{Line, Text}, + text::{Line, Span, Text}, widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap}, }; @@ -28,6 +28,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< let mut error: Option = None; let mut form = Form::default(); let mut show_form = false; + let mut info: Option = None; loop { let entries = explorer_entries( profiles.entries.len(), @@ -36,14 +37,38 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< expanded, ); terminal.draw(|frame| { - let navigation = Paragraph::new(if focus == Focus::Query && !show_form { - " Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit" + let [top, workspace, bottom] = shell_areas(frame.area()); + let position = if info.is_some() { + None + } else if show_form { + form.input().map(Input::position) } else { - " Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit" + browser.as_ref().filter(|_| focus == Focus::Query).map(App::query_position) + }; + let insert = if show_form { + form.input().is_some() + } else { + info.is_none() && focus == Focus::Query + && browser.as_ref().is_some_and(App::query_insert_mode) + }; + let name = browser.as_ref().map_or("Not connected", App::database_name); + draw_bars(frame, top, bottom, browser.as_ref().map_or("—", App::provider), name, insert, position); + let navigation = Paragraph::new(if show_form { + "Tab/Shift-Tab fields · Enter save/connect · Esc cancel · Ctrl-C quit" + } else if focus == Focus::Query && insert { + "Esc normal · Tab/Shift-Tab focus · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit" + } else if focus == Focus::Query { + "i/a/I/A insert · hjkl/↑↓←→ move · Esc explorer · Tab/Shift-Tab focus · F5/Ctrl-Enter execute · Ctrl-C quit" + } else { + "Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · i database info · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit" }).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray)); - let help_height = navigation.line_count(frame.area().width) as u16; - let [main, help] = - Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area()); + let help_height = (navigation.line_count(workspace.width.saturating_sub(2)) as u16) + .saturating_add(2).min(workspace.height.saturating_sub(3)); + let help_height = if help_height >= 3 { help_height } else { 0 }; + let navigation = navigation.block(Block::bordered().title(" Navigation ")); + let [main, help] = Layout::vertical([ + Constraint::Min(1), Constraint::Length(help_height), + ]).areas(workspace); let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)]) .areas(main); let items = entries.iter().map(|entry| { @@ -51,11 +76,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< 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 { "▶" }) + database_label(&label, connected, expanded, sidebar.width.saturating_sub(2)) } ExplorerEntry::Relation(index) => { let relation = &browser.as_ref().unwrap().relations[index]; - format!(" ├ {} [{}]", relation.label, relation.kind) + let last = index + 1 == browser.as_ref().unwrap().relations.len(); + format!(" {} {} [{}]", if last { "└" } else { "├" }, relation.label, relation.kind) } }) }); @@ -74,7 +100,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } frame.render_widget(navigation, help); if show_form { - form.draw(frame); + form.draw(frame, workspace); + } + if let Some(index) = info { + profiles.entries[index].draw_info(frame, workspace); } })?; match event::read()? { @@ -82,6 +111,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return Ok(()); } + if info.is_some() { + if matches!(key.code, KeyCode::Esc | KeyCode::Enter) { + info = None; + } + continue; + } if show_form { match key.code { KeyCode::Esc => { @@ -133,7 +168,13 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } else { match key.code { - KeyCode::Esc => focus = Focus::Explorer, + KeyCode::Esc => { + if focus != Focus::Query + || !browser.as_mut().is_some_and(App::leave_query_insert) + { + focus = Focus::Explorer; + } + } KeyCode::Tab | KeyCode::BackTab if browser.is_some() => { focus = match (focus, key.code == KeyCode::BackTab) { (Focus::Explorer, false) | (Focus::Rows, true) => Focus::Query, @@ -151,6 +192,15 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< form = Form::default(); show_form = true; } + KeyCode::Char('i') if focus == Focus::Explorer => { + info = + selection + .selected() + .and_then(|index| match entries.get(index) { + Some(ExplorerEntry::Database(Some(index))) => Some(*index), + _ => None, + }); + } KeyCode::Enter if focus == Focus::Explorer => { if let Some(entry) = selection.selected().and_then(|index| entries.get(index)) @@ -238,6 +288,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } } + Event::Paste(_) if info.is_some() => {} Event::Paste(text) if show_form => { form.error = None; if let Some(input) = form.input_mut() { @@ -254,6 +305,101 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } +fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> String { + if width < 4 { + return if connected && width > 0 { + "*".into() + } else { + String::new() + }; + } + let mut result = String::from(if connected && expanded { + "▼ " + } else { + "▶ " + }); + let available = usize::from(width) - 2 - if connected { 2 } else { 0 }; + let span = Span::raw(label); + let mut used = 0; + for grapheme in span.styled_graphemes(Style::default()) { + let width = Span::raw(grapheme.symbol).width(); + if used + width > available { + break; + } + used += width; + result.push_str(grapheme.symbol); + } + if connected { + result.push_str(" *"); + } + result +} + +fn shell_areas(area: Rect) -> [Rect; 3] { + let bar = u16::from(area.height >= 6); + Layout::vertical([ + Constraint::Length(bar), + Constraint::Min(0), + Constraint::Length(bar), + ]) + .areas(area) +} + +fn draw_bars( + frame: &mut Frame, + top: Rect, + bottom: Rect, + provider: &str, + name: &str, + insert: bool, + position: Option<(usize, usize)>, +) { + let base = Style::default().bg(Color::DarkGray).fg(Color::White); + let badge = Style::default().bg(Color::Cyan).fg(Color::Black); + let edge = base.fg(Color::Cyan); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(" dbdash ", badge), + Span::styled("▓░", edge), + Span::raw(format!(" {provider} · READ ONLY")), + ])) + .style(base), + top, + ); + frame.render_widget(Paragraph::new("").style(base), bottom); + let position = position.map(|(line, column)| format!(" {line}:{column} ")); + let mode_width = bottom.width.min(10); + let position_width = position + .as_ref() + .map_or(0, |text| text.len() as u16 + 2) + .min(bottom.width.saturating_sub(mode_width)); + let [mode, name_area, cursor] = Layout::horizontal([ + Constraint::Length(mode_width), + Constraint::Min(0), + Constraint::Length(position_width), + ]) + .areas(bottom); + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(if insert { " INSERT " } else { " NORMAL " }, badge), + Span::styled("▓░", edge), + ])) + .style(base), + mode, + ); + frame.render_widget(Paragraph::new(format!(" {name}")).style(base), name_area); + if let Some(position) = position { + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("░▓", edge), + Span::styled(position, badge), + ])) + .style(base), + cursor, + ); + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] enum ExplorerEntry { Database(Option), @@ -414,6 +560,18 @@ impl Form { Ok(config) } + fn input(&self) -> Option<&Input> { + if self.reconnect { + Some(&self.postgres[4]) + } else if self.provider == 1 { + (self.focus == 1).then_some(&self.path) + } else { + self.postgres + .get(self.focus.checked_sub(1)?) + .filter(|_| self.focus < 5) + } + } + fn input_mut(&mut self) -> Option<&mut Input> { if self.reconnect { return Some(&mut self.postgres[4]); @@ -477,14 +635,59 @@ impl Form { }) } - fn draw(&self, frame: &mut Frame) { - let screen = frame.area(); + fn info_text(&self) -> String { + if self.provider == 1 { + format!( + "Provider: SQLite\nPath: {}\nAccess: READ ONLY", + crate::db::display_text(&self.path.value) + ) + } else { + format!( + "Provider: PostgreSQL\nHost: {}\nPort: {}\nDatabase: {}\nUser: {}\nTLS: {}\nAccess: READ ONLY", + crate::db::display_text(&self.postgres[0].value), + crate::db::display_text(&self.postgres[1].value), + crate::db::display_text(&self.postgres[2].value), + crate::db::display_text(&self.postgres[3].value), + ["Require", "Prefer", "Disable"][self.tls], + ) + } + } + + fn draw_info(&self, frame: &mut Frame, screen: Rect) { + let width = screen + .width + .saturating_sub(4) + .min(76) + .max(screen.width.min(4)); + let text = Paragraph::new(self.info_text()).wrap(Wrap { trim: false }); + let height = (text.line_count(width.saturating_sub(2)) as u16) + .saturating_add(4) + .min(screen.height); + let area = Rect::new( + screen.x + (screen.width - width) / 2, + screen.y + (screen.height - height) / 2, + width, + height, + ); + frame.render_widget(Clear, area); + frame.render_widget( + text.block( + Block::bordered() + .title(" Database info ") + .title_bottom(" Esc/Enter close "), + ), + area, + ); + } + + fn draw(&self, frame: &mut Frame, screen: Rect) { let minimum_height = if self.provider == 0 { 20 } else { 16 }; if screen.width < 74 || screen.height < minimum_height { frame.render_widget(Clear, screen); frame.render_widget( Paragraph::new(format!( - "Enlarge terminal to at least 74×{minimum_height}. Ctrl-C to quit." + "Enlarge terminal to at least 74×{}. Ctrl-C to quit.", + minimum_height + frame.area().height.saturating_sub(screen.height) )), screen, ); @@ -655,6 +858,14 @@ impl From<&str> for Input { } impl Input { + pub(crate) fn position(&self) -> (usize, usize) { + let prefix = &self.value[..self.cursor]; + ( + prefix.bytes().filter(|byte| *byte == b'\n').count() + 1, + prefix.rsplit('\n').next().unwrap_or("").chars().count() + 1, + ) + } + pub(crate) fn edit(&mut self, key: KeyEvent) { match key.code { KeyCode::Left => { @@ -797,6 +1008,83 @@ impl Input { mod tests { use super::*; + #[test] + fn connected_marker_survives_long_wide_profile_labels() -> Result<()> { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?; + terminal.draw(|frame| { + let label = database_label("界".repeat(40).as_str(), true, true, 30); + assert_eq!(Line::raw(&label).width(), 30); + frame.render_widget( + List::new([ListItem::new(label)]).block(Block::bordered()), + frame.area(), + ); + })?; + assert_eq!(terminal.backend().buffer()[(30, 1)].symbol(), "*"); + assert_eq!(database_label("long", true, true, 1), "*"); + Ok(()) + } + + #[test] + fn positions_count_logical_lines_and_unicode_scalars() { + let mut input = Input::from("é界\n🙂x"); + assert_eq!(input.position(), (2, 3)); + input.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(input.position(), (2, 2)); + input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + assert_eq!(input.position(), (2, 1)); + input.edit(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); + assert_eq!(input.position(), (1, 1)); + } + + #[test] + fn forms_report_insert_only_for_text_fields_and_info_excludes_secrets() { + let mut form = Form::default(); + assert!(form.input().is_none()); + form.focus = 1; + assert!(form.input().is_some()); + form.focus = 5; + assert!(form.input().is_none()); + form.reconnect = true; + form.postgres[4] = Input::from("secret-password"); + form.postgres[0] = Input::from("host\u{1b}[31m"); + assert!(form.input().is_some()); + let info = form.info_text(); + assert!(!info.contains("secret-password")); + assert!(!info.contains('\u{1b}')); + assert!(info.contains("\nHost:")); + form.provider = 1; + form.reconnect = false; + form.focus = 0; + assert!(form.input().is_none()); + form.focus = 1; + assert!(form.input().is_some()); + } + + #[test] + fn shell_keeps_position_when_name_is_long_and_yields_on_short_screens() -> Result<()> { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(24, 8))?; + terminal.draw(|frame| { + let [top, content, bottom] = shell_areas(frame.area()); + assert_eq!(content.height, 6); + draw_bars( + frame, + top, + bottom, + "SQLite", + "a very long database name", + true, + Some((12, 34)), + ); + })?; + let buffer = terminal.backend().buffer(); + let footer: String = (0..24).map(|x| buffer[(x, 7)].symbol()).collect(); + assert!(footer.starts_with(" INSERT ▓░")); + assert!(footer.ends_with("░▓ 12:34 ")); + let [top, content, bottom] = shell_areas(Rect::new(0, 0, 10, 3)); + assert_eq!((top.height, content.height, bottom.height), (0, 3, 0)); + Ok(()) + } + #[test] fn explorer_keeps_relations_under_the_connected_database() { use ExplorerEntry::{Database as Db, Relation as Rel}; @@ -868,7 +1156,10 @@ mod tests { }; form.postgres[4] = Input::from("test-secret-é"); let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 24))?; - terminal.draw(|frame| form.draw(frame))?; + terminal.draw(|frame| { + let area = frame.area(); + form.draw(frame, area); + })?; let rendered: String = terminal .backend() .buffer() diff --git a/src/db.rs b/src/db.rs index a4dedf3..2cd37a5 100644 --- a/src/db.rs +++ b/src/db.rs @@ -12,6 +12,7 @@ pub const PAGE_SIZE: usize = 100; pub struct Database { backend: Backend, + display_name: String, } #[allow( @@ -66,17 +67,29 @@ impl Database { client.batch_execute( "SET default_transaction_read_only = on; SET statement_timeout = '5s';", )?; + let display_name: String = client.query_one("SELECT current_database()", &[])?.get(0); Ok(Self { backend: Backend::Postgres(client), + display_name: display_text(&display_name), }) } pub fn open_sqlite(path: &Path) -> Result { Ok(Self { backend: Backend::Sqlite(sqlite::open(path)?), + display_name: display_text( + &path + .file_name() + .unwrap_or(path.as_os_str()) + .to_string_lossy(), + ), }) } + pub fn display_name(&self) -> &str { + &self.display_name + } + pub fn name(&self) -> &'static str { match self.backend { Backend::Postgres(_) => "PostgreSQL", @@ -290,6 +303,16 @@ pub fn display_text(value: &str) -> String { mod tests { use super::*; + #[test] + fn sqlite_identity_is_filename_without_terminal_controls() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("accounts\nprivate.sqlite"); + drop(rusqlite::Connection::open(&path)?); + let db = Database::open_sqlite(&path)?; + assert_eq!(db.display_name(), "accounts\\nprivate.sqlite"); + Ok(()) + } + #[test] fn identifiers_and_terminal_controls_are_safe() { assert_eq!( diff --git a/src/main.rs b/src/main.rs index 0e28e4f..630003e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,32 +27,38 @@ Expansion never runs shell commands. Quote CLI paths to let dbdash expand them. PostgreSQL URLs and libpq-style key=value strings are accepted. Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=disable only for trusted local connections. -The inspector needs 70×14 terminal cells; the PostgreSQL window needs 74×20. +The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×22/74×18. Navigation: - Tab/Shift-Tab Cycle explorer, query editor, and rows/schema (Esc returns to explorer) + Tab/Shift-Tab Cycle explorer, query editor, and rows/schema 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 Collapse/expand in explorer; scroll columns in rows/schema (h/l too) + Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too) n/p, PgDn/Up Next / previous 100-row relation page c Add a configured database in a floating window + i Show configured database info (Esc/Enter closes; passwords omitted) r Refresh relations and the active relation - Connected databases show their tables and views beneath them in the explorer. + Connected databases have a trailing * and show their tables/views beneath them. Enter on the connected database collapses/expands it without reconnecting. - Navigation help spans the bottom as one line, wrapping only when needed. + Navigation help has its own bordered pane and wraps when needed. + Full-width bars show dbdash/provider/READ ONLY above and mode/database below. + Focused inputs show one-based line:column (Unicode characters, not screen cells). The query editor sits above rows/results; the explorer spans both panes. Explorer and rows/schema bottom borders align; relation/page/column status appears in the rows/schema border. Read errors appear above the query editor. - In the query editor, type SQL directly; Enter inserts a newline. Arrow keys, - Home/End, Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported. + Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move. + In INSERT, type SQL; Enter inserts a newline. Arrow keys, Home/End, + Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported. + Esc leaves INSERT first; Esc in NORMAL returns to the explorer. + Connection/password fields remain direct-entry; query paste requires INSERT. SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect, including quoted identifiers, literals, and multiline comments/strings as you type. F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more. Failed queries keep the previous results. Opening a relation restores browsing without clearing the query text. Query text is not saved across connections. - q / Ctrl-C Quit (q is literal text in the query editor) + q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT) 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. Enter saves without connecting, including when the database is offline.