diff --git a/TODO.md b/TODO.md index 65c0280..122aad4 100644 --- a/TODO.md +++ b/TODO.md @@ -1,3 +1,4 @@ -- [ ] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar) -- [ ] feature: ability to remove a database connection -- [ ] feature: line numbers in gutter of sql editor \ No newline at end of file +- [x] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar) +- [x] feature: ability to remove a database connection +- [x] feature: line numbers in gutter of sql editor +- [ ] bug: database type should display nothing instead of `-` when no database is connected. (it should only show the type when it's available) \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 6ab9d8b..2f16e9d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -723,14 +723,14 @@ mod tests { 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, 1)].fg, Color::Cyan); - assert_eq!(buffer[(8, 1)].fg, Color::Green); - assert_eq!(buffer[(1, 2)].fg, Color::DarkGray); + assert_eq!(buffer[(3, 1)].fg, Color::Cyan); + assert_eq!(buffer[(10, 1)].fg, Color::Green); + assert_eq!(buffer[(3, 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, 2)].fg, Color::Green); + assert_eq!(terminal.backend().buffer()[(3, 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))?; @@ -738,7 +738,7 @@ mod tests { let buffer = terminal.backend().buffer(); assert_eq!(cursor.x, 58); assert_eq!(buffer[(cursor.x - 1, cursor.y)].symbol(), "x"); - assert_eq!(buffer[(1, cursor.y)].fg, Color::DarkGray); + assert_eq!(buffer[(4, cursor.y)].fg, Color::DarkGray); Ok(()) } diff --git a/src/connection.rs b/src/connection.rs index 0e8b14f..d7a0698 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -101,9 +101,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< workspace.y + (workspace.height - height) / 2, width, height); frame.render_widget(Clear, area); frame.render_widget(Paragraph::new(format!( - "Remove saved connection \"{}\"?\nOnly the configuration is removed. The database and active session are kept.\n\ny: remove · n/Esc: cancel", + "Remove saved connection \"{}\"?\nOnly the configuration is removed. The database and active session are kept.", profiles.entries[index].label())) - .wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ")), area); + .wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ").title_bottom(" y: remove · n/Esc: cancel ")), area); } })?; match event::read()? { @@ -117,11 +117,21 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< match profiles.remove(index) { Ok(()) => { active = active.and_then(|active| { - if active == index { None } else { Some(active - usize::from(active > index)) } + if active == index { + None + } else { + Some(active - usize::from(active > index)) + } }); - let updated = explorer_entries(profiles.entries.len(), active, - browser.as_ref().map(|app| app.relations.len()), expanded); - selection.select((!updated.is_empty()).then(|| selection.selected().unwrap_or(0).min(updated.len() - 1))); + let updated = explorer_entries( + profiles.entries.len(), + active, + browser.as_ref().map(|app| app.relations.len()), + expanded, + ); + selection.select((!updated.is_empty()).then(|| { + selection.selected().unwrap_or(0).min(updated.len() - 1) + })); error = None; } Err(failure) => error = Some(error_text(failure.as_ref())), @@ -181,7 +191,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< }) } else { profiles.save(&form).map(|()| { - let saved_index = profiles.entries.iter().position(|entry| entry.id == form.id) + let saved_index = profiles + .entries + .iter() + .position(|entry| entry.id == form.id) .unwrap_or(profiles.entries.len() - 1); let updated = explorer_entries( profiles.entries.len(), @@ -190,10 +203,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< expanded, ); selection.select(updated.iter().position(|entry| { - *entry - == ExplorerEntry::Database(Some( - saved_index, - )) + *entry == ExplorerEntry::Database(Some(saved_index)) })); focus = Focus::Explorer; }) @@ -242,17 +252,23 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< show_form = true; } KeyCode::Char('d') | KeyCode::Delete if focus == Focus::Explorer => { - remove = selection.selected().and_then(|index| match entries.get(index) { - Some(ExplorerEntry::Database(Some(index))) => Some(*index), - _ => None, - }); + remove = + selection + .selected() + .and_then(|index| match entries.get(index) { + Some(ExplorerEntry::Database(Some(index))) => Some(*index), + _ => None, + }); } KeyCode::Char('e') if focus == Focus::Explorer => { - let index = selection.selected().and_then(|index| match entries.get(index) { - Some(ExplorerEntry::Database(Some(index))) => Some(*index), - Some(ExplorerEntry::Relation(_)) => active, - _ => None, - }); + let index = + selection + .selected() + .and_then(|index| match entries.get(index) { + Some(ExplorerEntry::Database(Some(index))) => Some(*index), + Some(ExplorerEntry::Relation(_)) => active, + _ => None, + }); if let Some(index) = index { form = profiles.entries[index].clone(); form.focus = form.name_focus(); @@ -394,6 +410,8 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Left/Right, h/l Collapse/expand explorer or select cells\n\ Enter Connect/expand database or open relation\n\ i Saved connection info on database or child\n\ + e Edit saved configuration/name on database or child\n\ + d / Delete Remove saved database (y confirms, n/Esc cancels)\n\ s Toggle rows / column schema\n\ n/p, PgDn/PgUp Next / previous 100-row relation page\n\ r Refresh relations and active relation\n\n\ @@ -414,10 +432,11 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Connection / password windows\n\ Tab / Shift-Tab Change fields\n\ Arrows Choose provider; Left/Right changes TLS\n\ - Enter Edit provider / save new / connect saved\n\ + Enter Edit provider / save settings / connect\n\ Ctrl-U / paste Clear / enter text in a focused field\n\ Esc Cancel and discard edits\n\ - Saved settings update only after a successful connection.\n\ + Optional Name sets the sidebar label; blank uses the default.\n\ + Connect saves settings only on success; e edits save without connecting.\n\ Passwords are never saved.", ) .wrap(Wrap { trim: false }); @@ -596,8 +615,13 @@ impl Profiles { path TEXT NOT NULL )", )?; - if !storage.prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'name'")?.exists([])? { - storage.execute_batch("ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''")?; + if !storage + .prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'name'")? + .exists([])? + { + storage.execute_batch( + "ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''", + )?; } let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name FROM connections ORDER BY id")? .query_map([], |row| { @@ -666,7 +690,10 @@ impl Profiles { } fn remove(&mut self, index: usize) -> Result<()> { - self.storage.execute("DELETE FROM connections WHERE id = ?1", [self.entries[index].id])?; + self.storage.execute( + "DELETE FROM connections WHERE id = ?1", + [self.entries[index].id], + )?; self.entries.remove(index); Ok(()) } @@ -703,7 +730,11 @@ impl Default for Form { impl Form { fn name_focus(&self) -> usize { - if self.provider == 1 { 2 } else { 6 + usize::from(self.reconnect) } + if self.provider == 1 { + 2 + } else { + 6 + usize::from(self.reconnect) + } } fn connect(&self) -> Result { @@ -910,8 +941,16 @@ impl Form { .style(Style::default().fg(Color::Cyan)), header, ); - let [name_area, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(body); - self.name.draw(frame, name_area, " Name (optional) ", false, self.focus == self.name_focus(), None); + let [name_area, body] = + Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(body); + self.name.draw( + frame, + name_area, + " Name (optional) ", + false, + self.focus == self.name_focus(), + None, + ); let [providers, details] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); frame.render_widget( @@ -1245,36 +1284,62 @@ impl Input { focused: bool, highlighted: Option>, ) { + let numbered = highlighted.is_some(); let text = if secret { Text::raw("•".repeat(self.value.chars().count())) } else { highlighted.unwrap_or_else(|| Text::raw(&self.value)) }; + let line_count = text.lines.len().max(1); + let block = Block::bordered() + .title(label) + .border_style(Style::default().fg(if focused { + Color::Cyan + } else { + Color::DarkGray + })); + let inner = block.inner(area); + frame.render_widget(block, area); + let gutter_width = if numbered { + (line_count.ilog10() as u16 + 2).min(inner.width.saturating_sub(1)) + } else { + 0 + }; + let content = Rect::new( + inner.x + gutter_width, + inner.y, + inner.width - gutter_width, + inner.height, + ); let prefix = &self.value[..self.cursor]; let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX); - let row_offset = row.saturating_sub(area.height.saturating_sub(3)); + let row_offset = row.saturating_sub(content.height.saturating_sub(1)); let before_cursor = if secret { self.value[..self.cursor].chars().count() } else { Line::raw(prefix.rsplit('\n').next().unwrap_or("")).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((row_offset, 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 + row - row_offset)); + let offset = cursor.saturating_sub(content.width.saturating_sub(1)); + frame.render_widget(Paragraph::new(text).scroll((row_offset, offset)), content); + if gutter_width > 0 { + let lines = (usize::from(row_offset)..line_count) + .take(usize::from(inner.height)) + .map(|line| { + Line::raw(format!( + "{:>width$} ", + line + 1, + width = usize::from(gutter_width - 1) + )) + }) + .collect::>(); + frame.render_widget( + Paragraph::new(lines).style(Style::default().fg(Color::DarkGray)), + Rect::new(inner.x, inner.y, gutter_width, inner.height), + ); + } + if focused && content.width > 0 && content.height > 0 { + frame.set_cursor_position((content.x + cursor - offset, content.y + row - row_offset)); } } } @@ -1282,6 +1347,71 @@ impl Input { #[cfg(test)] mod tests { use super::*; + #[test] + fn sql_gutter_tracks_scrolling_digits_and_cursor_without_entering_sql() -> Result<()> { + use ratatui::{Terminal, backend::TestBackend}; + let source = (1..=12) + .map(|line| format!("SELECT {line}, '界abcdefghijklmnop';")) + .collect::>() + .join("\n"); + let mut input = Input::from(source.as_str()); + let mut terminal = Terminal::new(TestBackend::new(22, 5))?; + terminal.draw(|frame| { + input.draw( + frame, + frame.area(), + "Query", + false, + true, + Some(crate::sql::text( + &input.value, + &crate::sql::highlight(&input.value, true), + )), + ) + })?; + let buffer = terminal.backend().buffer(); + for (row, number) in [(1, "10"), (2, "11"), (3, "12")] { + let gutter: String = (1..=2).map(|x| buffer[(x, row)].symbol()).collect(); + assert_eq!(gutter, number); + } + assert_eq!(terminal.get_cursor_position()?.x, 20); + input.paste_multiline("\n"); + terminal.draw(|frame| { + input.draw( + frame, + frame.area(), + "Query", + false, + true, + Some(crate::sql::text( + &input.value, + &crate::sql::highlight(&input.value, true), + )), + ) + })?; + assert_eq!(terminal.backend().buffer()[(1, 3)].symbol(), "1"); + assert_eq!(terminal.backend().buffer()[(2, 3)].symbol(), "3"); + assert_eq!(terminal.get_cursor_position()?, (4, 3).into()); + input.vim_motion('g'); + terminal.draw(|frame| { + input.draw( + frame, + frame.area(), + "Query", + false, + true, + Some(crate::sql::text( + &input.value, + &crate::sql::highlight(&input.value, true), + )), + ) + })?; + assert_eq!(terminal.backend().buffer()[(2, 1)].symbol(), "1"); + assert_eq!(terminal.backend().buffer()[(4, 1)].symbol(), "S"); + assert_eq!(terminal.get_cursor_position()?, (4, 1).into()); + assert_eq!(input.value, format!("{source}\n")); + Ok(()) + } #[test] fn keybindings_scroll_to_the_end_and_clamp_after_resize() -> Result<()> { @@ -1629,7 +1759,10 @@ mod tests { let restored = &profiles.entries[0]; assert_eq!(restored.tls, 2); assert_eq!(restored.label(), "Warehouse"); - assert_eq!(restored.connection_label(), "warehouse · inspector@db.example:5440"); + assert_eq!( + restored.connection_label(), + "warehouse · inspector@db.example:5440" + ); for (input, expected) in restored .postgres @@ -1651,7 +1784,11 @@ mod tests { let directory = tempfile::tempdir()?; let path = directory.path().join("connections.sqlite3"); let mut profiles = Profiles::open(&path)?; - let mut form = Form { provider: 1, path: Input::from("/tmp/example.sqlite"), ..Form::default() }; + let mut form = Form { + provider: 1, + path: Input::from("/tmp/example.sqlite"), + ..Form::default() + }; form.focus = form.name_focus(); form.input_mut().unwrap().paste(" Local data "); profiles.save(&form)?; @@ -1676,7 +1813,11 @@ mod tests { std::fs::write(&database, contents)?; let path = directory.path().join("profiles.sqlite"); let mut profiles = Profiles::open(&path)?; - let form = Form { provider: 1, path: Input::from(database.to_str().unwrap()), ..Form::default() }; + let form = Form { + provider: 1, + path: Input::from(database.to_str().unwrap()), + ..Form::default() + }; profiles.save(&form)?; profiles.save(&form)?; let survivor = profiles.entries[1].id; diff --git a/src/main.rs b/src/main.rs index 1ea542c..f6ce11f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -47,18 +47,20 @@ Navigation: Works on a saved database entry or any of its table/view children. r Refresh relations and the active relation Connected databases have a trailing * and show their tables/views beneath them. - SQLite sidebar entries show only the filename; the info window shows the full path. + Unnamed SQLite entries show only the filename; the info window shows the full path. Enter on the connected database collapses/expands it without reconnecting. Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump. Query Insert mode and connection/password fields keep ? as literal text. Full-width bars show dbdash/READ ONLY above and mode/database below. Saved PostgreSQL connections show database · user@host:port beside the mode, - matching the sidebar; passwords are never included. + regardless of custom sidebar names; passwords are never included. The database provider sits immediately left of the bottom-right position badge. Only badges have colored backgrounds; the remaining bar area uses terminal defaults. Focused inputs show one-based line:column (Unicode characters, not screen cells). The position badge shows N/A outside focused inputs, including in info/help popups. The query editor sits above rows/results; the explorer spans both panes. + The SQL editor has a one-based line-number gutter that follows vertical scrolling; + horizontal scrolling moves only SQL, not the numbers or logical cursor position. Explorer and rows/schema bottom borders align; table names appear in the top-right border, with page/column status below. Read errors appear above the query editor. Query editor starts in NORMAL: i/a/I/A enters INSERT; hjkl/arrows move.