diff --git a/src/app.rs b/src/app.rs index f2027c9..2105cf6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,6 +1,6 @@ use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::{ - DefaultTerminal, Frame, + Frame, layout::{Constraint, Layout}, style::{Color, Modifier, Style}, text::Line, @@ -55,19 +55,7 @@ impl App { Ok(app) } - pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> { - loop { - terminal.draw(|frame| self.draw(frame))?; - if let Event::Key(key) = event::read()? - && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) - && self.handle_key(key) - { - return Ok(()); - } - } - } - - fn handle_key(&mut self, key: KeyEvent) -> bool { + pub(crate) fn handle_key(&mut self, key: KeyEvent) -> bool { if key.code == KeyCode::Char('q') || (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL)) { @@ -183,7 +171,7 @@ impl App { } } - fn draw(&mut self, frame: &mut Frame) { + pub(crate) fn draw(&mut self, frame: &mut Frame) { let area = frame.area(); if area.width < 70 || area.height < 12 { frame.render_widget( @@ -360,7 +348,7 @@ impl App { ); } frame.render_widget(Paragraph::new( - " Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · q/Ctrl-C quit" + " Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · c connect · q quit" ).style(Style::default().fg(Color::DarkGray)), help); } } diff --git a/src/connection.rs b/src/connection.rs new file mode 100644 index 0000000..e7a9b75 --- /dev/null +++ b/src/connection.rs @@ -0,0 +1,251 @@ +use std::{borrow::Cow, path::Path}; + +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::{ + DefaultTerminal, Frame, + layout::{Constraint, Layout, Rect}, + style::{Color, Style}, + text::Line, + widgets::{Block, List, ListItem, Paragraph, Wrap}, +}; + +use crate::{Result, app::App, db::Database, error_text}; + +pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result<()> { + let mut browser = initial.map(App::new).transpose()?; + let mut form = Form::default(); + let mut show_form = browser.is_none(); + loop { + terminal.draw(|frame| { + if show_form { + form.draw(frame, false); + } else if let Some(browser) = &mut browser { + browser.draw(frame); + } + })?; + match event::read()? { + Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { + return Ok(()); + } + if show_form { + match key.code { + KeyCode::Esc => { + if browser.is_none() { + return Ok(()); + } + show_form = false; + } + KeyCode::Enter => { + terminal.draw(|frame| form.draw(frame, true))?; + match form.connect().and_then(App::new) { + Ok(connected) => { + browser = Some(connected); + show_form = false; + } + Err(error) => form.error = Some(error_text(error.as_ref())), + } + } + _ => form.path.edit(key), + } + } else if key.code == KeyCode::Char('c') { + form.error = None; + show_form = true; + } else if browser + .as_mut() + .is_some_and(|browser| browser.handle_key(key)) + { + return Ok(()); + } + } + Event::Paste(text) if show_form => form.path.paste(&text), + _ => {} + } + } +} + +#[derive(Default)] +struct Form { + path: Input, + error: Option, +} + +impl Form { + fn connect(&self) -> Result { + if self.path.value.is_empty() { + return Err("Enter an existing SQLite database file path.".into()); + } + Database::open_sqlite(Path::new(&self.path.value)) + } + + fn draw(&self, frame: &mut Frame, connecting: bool) { + let area = frame.area(); + if area.width < 70 || area.height < 12 { + frame.render_widget( + Paragraph::new("Enlarge terminal to at least 70×12. Ctrl-C to quit."), + area, + ); + return; + } + let [header, body, status, help] = Layout::vertical([ + Constraint::Length(2), + Constraint::Min(4), + Constraint::Length(3), + Constraint::Length(2), + ]) + .areas(area); + frame.render_widget( + Paragraph::new(" dbdash │ New connection │ READ ONLY") + .style(Style::default().fg(Color::Cyan)), + header, + ); + let [providers, details] = + Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); + frame.render_widget( + List::new([ListItem::new(" SQLite · local file") + .style(Style::default().bg(Color::Blue).fg(Color::White))]) + .block(Block::bordered().title(" Database type ")), + providers, + ); + let [path, description] = + Layout::vertical([Constraint::Length(3), Constraint::Min(1)]).areas(details); + self.path + .draw(frame, path, " Database file path ", false, true); + frame.render_widget(Paragraph::new("Open an existing SQLite file.\nFiles are never created or modified.\n\nConnection details stay in this session.").wrap(Wrap { trim: false }), description); + let message = if connecting { + "Connecting…" + } else { + self.error.as_deref().unwrap_or("Not connected to a new database. The current connection stays open until this succeeds.") + }; + frame.render_widget( + Paragraph::new(message) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(if self.error.is_some() && !connecting { + Color::Red + } else { + Color::Cyan + })), + status, + ); + frame.render_widget(Paragraph::new(" Enter connect · Esc cancel/quit · Ctrl-C quit\n ←→ Home/End edit · Backspace/Delete erase · Ctrl-U clear").style(Style::default().fg(Color::DarkGray)), help); + } +} + +#[derive(Default)] +struct Input { + value: String, + cursor: usize, +} + +impl Input { + fn edit(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Left => { + self.cursor = self.value[..self.cursor] + .char_indices() + .next_back() + .map_or(0, |(index, _)| index) + } + KeyCode::Right => { + self.cursor += self.value[self.cursor..] + .chars() + .next() + .map_or(0, char::len_utf8) + } + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.value.len(), + KeyCode::Backspace if self.cursor > 0 => { + let previous = self.value[..self.cursor] + .char_indices() + .next_back() + .unwrap() + .0; + self.value.drain(previous..self.cursor); + self.cursor = previous; + } + KeyCode::Delete if self.cursor < self.value.len() => { + self.value.remove(self.cursor); + } + KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => { + self.value.clear(); + self.cursor = 0; + } + KeyCode::Char(character) + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + && !character.is_control() => + { + self.value.insert(self.cursor, character); + self.cursor += character.len_utf8(); + } + _ => {} + } + } + + fn paste(&mut self, value: &str) { + let text: String = value + .chars() + .filter(|character| !character.is_control()) + .collect(); + self.value.insert_str(self.cursor, &text); + self.cursor += text.len(); + } + + fn draw(&self, frame: &mut Frame, area: Rect, label: &str, secret: bool, focused: bool) { + let text: Cow<'_, str> = if secret { + Cow::Owned("•".repeat(self.value.chars().count())) + } else { + Cow::Borrowed(&self.value) + }; + let before_cursor = if secret { + self.value[..self.cursor].chars().count() + } else { + Line::raw(&self.value[..self.cursor]).width() + }; + let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX); + let offset = cursor.saturating_sub(area.width.saturating_sub(3)); + frame.render_widget( + Paragraph::new(text).scroll((0, offset)).block( + Block::bordered() + .title(label) + .border_style(Style::default().fg(if focused { + Color::Cyan + } else { + Color::DarkGray + })), + ), + area, + ); + if focused && area.width > 2 && area.height > 2 { + frame.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editing_preserves_unicode_and_filters_terminal_controls() { + let mut input = Input::default(); + input.paste("aé🙂z\n\u{1b}"); + input.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + assert_eq!(input.value, "aéz"); + input.edit(KeyEvent::new(KeyCode::Char('界'), KeyModifiers::NONE)); + input.edit(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)); + assert_eq!(input.value, "aé界"); + input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE)); + input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)); + input.paste("/tmp/"); + assert_eq!(input.value, "/tmp/aé界"); + input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE)); + input.edit(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(input.cursor, input.value.len()); + input.edit(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + assert!(input.value.is_empty()); + assert_eq!(input.cursor, 0); + } +} diff --git a/src/main.rs b/src/main.rs index 83299b7..6a93bc1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ mod app; +mod connection; mod db; use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode}; @@ -12,7 +13,7 @@ Usage: dbdash --sqlite PATH dbdash --help -With no argument, the connection is read from DATABASE_URL. +With no argument or DATABASE_URL, the in-app connection screen opens. Prefer the environment variable to keep passwords out of process arguments: export DATABASE_URL='postgresql://user@localhost:5432/database' cargo run @@ -33,6 +34,7 @@ Navigation: s Toggle rows and column schema Left/Right Scroll columns (h/l also work) n/p, PgDn/Up Next / previous 100-row page + c Open the connection screen (Esc keeps the current database) r Refresh relations and the active relation q / Ctrl-C Quit @@ -66,11 +68,12 @@ fn run() -> Result<()> { } let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite"); let connection = if sqlite { - args.next() - .ok_or("--sqlite requires a database file path; see dbdash --help")? + Some( + args.next() + .ok_or("--sqlite requires a database file path; see dbdash --help")?, + ) } else { argument.or_else(|| std::env::var_os("DATABASE_URL")) - .ok_or("set DATABASE_URL, pass a PostgreSQL connection string, or use --sqlite PATH; see dbdash --help")? }; if args.next().is_some() { return Err("unexpected extra arguments; see dbdash --help".into()); @@ -78,22 +81,30 @@ fn run() -> Result<()> { if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() { return Err("an interactive terminal is required; see dbdash --help".into()); } - let database = if sqlite { - db::Database::open_sqlite(Path::new(&connection))? - } else { - db::Database::connect( - connection - .to_str() - .ok_or("connection string must be UTF-8")?, - )? - }; - let mut app = app::App::new(database)?; + let database = connection + .map(|connection| { + if sqlite { + db::Database::open_sqlite(Path::new(&connection)) + } else { + db::Database::connect( + connection + .to_str() + .ok_or("connection string must be UTF-8")?, + ) + } + }) + .transpose()?; let result = (|| { let mut terminal = ratatui::try_init()?; - app.run(&mut terminal) + crossterm::execute!(std::io::stdout(), crossterm::event::EnableBracketedPaste)?; + connection::run(&mut terminal, database) })(); + let paste_result = + crossterm::execute!(std::io::stdout(), crossterm::event::DisableBracketedPaste); ratatui::restore(); - result + result?; + paste_result?; + Ok(()) } fn error_text(error: &(dyn Error + 'static)) -> String {