diff --git a/TODO.md b/TODO.md index 8f5ad1a..721e41e 100644 --- a/TODO.md +++ b/TODO.md @@ -1,25 +1,2 @@ -- [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 READ ONLY information without tab numbering. - - [x] bottom-left: current mode badge with `▓░` trailing edge, followed by the connected database name; bottom-right: database provider followed by the focused input's one-based `line:column` (or N/A) 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, show N/A 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. -- [x] move the table name in the results pane from the bottom-left border title to top-right border title instead -- [x] remove the bottom navigation help panel and have a keybinding overview appear in a floating window when pressing `?` in normal mode -- [x] remove the path from the database entry in the sidebar (can be viewed in the info panel) -- [x] info panel should also pop up when pressing `i` on a child entry of that database entry in the sidebar (instead of only when the parent/database connection is hovered) -- [x] `line:col` indicator in the bottom-right status bar should never disappear but instead display "N/A" in that case. -- [x] database type indicator should be moved down just left of the "line:col" indicator like in the attached image -- [x] introduce vim navigation inside the query input field (`hjkl`, `w/b/e`, `0/^/$`, `gg/G`; bindings listed in `?`). -- [x] bugfix: ctrl+enter current doesn't work for executing sql, only F5 does. \ No newline at end of file +- [ ] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar) +- [ ] feature: line numbers in gutter of sql editor \ No newline at end of file diff --git a/src/connection.rs b/src/connection.rs index 28cb5af..7106a23 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -124,18 +124,25 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< KeyCode::Enter if form.focus == 0 => form.focus = 1, KeyCode::Enter => { let result = if form.reconnect { - form.connect().and_then(App::new).map(|connected| { - browser = Some(connected); - active = selection.selected().and_then(|index| { - match entries[index] { - ExplorerEntry::Database(index) => index, - _ => None, - } - }); - selection.select(active); - expanded = true; - focus = Focus::Explorer; - }) + form.connect() + .and_then(App::new) + .and_then(|connected| { + profiles.save(&form)?; + Ok(connected) + }) + .map(|connected| { + browser = Some(connected); + active = + selection.selected().and_then(|index| { + match entries[index] { + ExplorerEntry::Database(index) => index, + _ => None, + } + }); + selection.select(active); + expanded = true; + focus = Focus::Explorer; + }) } else { profiles.save(&form).map(|()| { let updated = explorer_entries( @@ -350,9 +357,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 / save configuration / submit password\n\ + Enter Edit provider / save new / connect saved\n\ Ctrl-U / paste Clear / enter text in a focused field\n\ - Esc Cancel", + Esc Cancel and discard edits\n\ + Saved settings update only after a successful connection.\n\ + Passwords are never saved.", ) .wrap(Wrap { trim: false }); let lines = text.line_count(width.saturating_sub(2)) as u16; @@ -530,9 +539,10 @@ impl Profiles { path TEXT NOT NULL )", )?; - let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path FROM connections ORDER BY id")? + let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id FROM connections ORDER BY id")? .query_map([], |row| { let mut form = Form { + id: Some(row.get(7)?), provider: usize::from(row.get::<_, u8>(0)?), tls: usize::from(row.get::<_, u8>(5)?), path: Input::from(row.get::<_, String>(6)?.as_str()), @@ -550,6 +560,8 @@ impl Profiles { fn save(&mut self, form: &Form) -> Result<()> { let mut saved = form.clone(); saved.clear_password(); + saved.reconnect = false; + saved.error = None; if form.provider == 0 { form.postgres_config()?; } else if form.path.value.is_empty() { @@ -563,15 +575,37 @@ impl Profiles { }; saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?); } - self.storage.execute("INSERT INTO connections (provider, host, port, database_name, username, tls, path) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", - rusqlite::params![saved.provider as u8, saved.postgres[0].value, saved.postgres[1].value, saved.postgres[2].value, saved.postgres[3].value, saved.tls as u8, saved.path.value])?; - self.entries.push(saved); + self.storage.execute( + "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + ON CONFLICT(id) DO UPDATE SET + provider = excluded.provider, host = excluded.host, port = excluded.port, + database_name = excluded.database_name, username = excluded.username, + tls = excluded.tls, path = excluded.path", + rusqlite::params![ + saved.provider as u8, + saved.postgres[0].value, + saved.postgres[1].value, + saved.postgres[2].value, + saved.postgres[3].value, + saved.tls as u8, + saved.path.value, + saved.id + ], + )?; + saved.id.get_or_insert(self.storage.last_insert_rowid()); + if let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == saved.id) { + *entry = saved; + } else { + self.entries.push(saved); + } Ok(()) } } #[derive(Clone)] struct Form { + id: Option, provider: usize, focus: usize, postgres: [Input; 5], @@ -584,6 +618,7 @@ struct Form { impl Default for Form { fn default() -> Self { Self { + id: None, provider: 0, focus: 0, tls: 0, @@ -636,27 +671,22 @@ impl Form { } fn input(&self) -> Option<&Input> { - if self.reconnect { - Some(&self.postgres[4]) - } else if self.provider == 1 { + if self.provider == 1 { (self.focus == 1).then_some(&self.path) } else { self.postgres .get(self.focus.checked_sub(1)?) - .filter(|_| self.focus < 5) + .filter(|_| self.focus < 5 + usize::from(self.reconnect)) } } fn input_mut(&mut self) -> Option<&mut Input> { - if self.reconnect { - return Some(&mut self.postgres[4]); - } if self.provider == 1 { (self.focus == 1).then_some(&mut self.path) } else { self.postgres .get_mut(self.focus.checked_sub(1)?) - .filter(|_| self.focus < 5) + .filter(|_| self.focus < 5 + usize::from(self.reconnect)) } } @@ -665,19 +695,20 @@ impl Form { } fn edit(&mut self, key: KeyEvent) { - if self.reconnect { - self.postgres[4].edit(key); - self.error = None; - return; - } - let count = if self.provider == 0 { 6 } else { 2 }; + let count = if self.provider == 0 { + 6 + usize::from(self.reconnect) + } else { + 2 + }; match key.code { KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => { self.provider = 1 - self.provider } - KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 5 => { + KeyCode::Left | KeyCode::Right + if self.provider == 0 && self.focus == 5 + usize::from(self.reconnect) => + { self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3; } _ => { @@ -772,7 +803,7 @@ impl Form { minimum_height, ); let window = Block::bordered().title(if self.reconnect { - " Connect · password only " + " Connect · edit configuration " } else { " New database " }); @@ -861,14 +892,14 @@ impl Form { pairs[index / 2][index % 2], labels[index], index == 4, - self.focus == index + 1 && (!self.reconnect || index == 4), + self.focus == index + 1, None, ); } frame.render_widget( Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block( Block::bordered().title(" TLS · ←/→ change ").border_style( - Style::default().fg(if !self.reconnect && self.focus == 5 { + Style::default().fg(if self.focus == 5 + usize::from(self.reconnect) { Color::Cyan } else { Color::DarkGray @@ -892,7 +923,7 @@ impl Form { ); } let message = self.error.as_deref().unwrap_or(if self.reconnect { - "Enter a password if needed. The current database stays open until this succeeds." + "Edit settings and enter a password if needed. Changes are saved only after connecting." } else { "Enter saves this configuration without connecting." }); @@ -907,7 +938,7 @@ impl Form { status, ); frame.render_widget(Paragraph::new(if self.reconnect { - " Enter connect · Ctrl-U clear password · Esc cancel · Ctrl-C quit" + " Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit" } else { " Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit" }).style(Style::default().fg(Color::DarkGray)), help); @@ -1478,6 +1509,53 @@ mod tests { Ok(()) } + #[test] + fn saved_connection_fields_can_be_edited_without_duplicating_profiles() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("connections.sqlite3"); + let mut profiles = Profiles::open(&path)?; + let mut form = Form::default(); + form.postgres[3] = Input::from("reader"); + profiles.save(&form)?; + let mut form = profiles.entries[0].clone(); + form.reconnect = true; + form.focus = 5; + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert!(form.input().is_none()); + form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + for value in ["db.example", "5440", "warehouse", "inspector"] { + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + if form.focus == 0 { + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + } + form.edit(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + form.input_mut().unwrap().paste(value); + } + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + form.input_mut().unwrap().paste("never-save-this-password"); + profiles.save(&form)?; + drop(profiles); + + let profiles = Profiles::open(&path)?; + assert_eq!(profiles.entries.len(), 1); + let restored = &profiles.entries[0]; + assert_eq!(restored.tls, 2); + for (input, expected) in + restored + .postgres + .iter() + .zip(["db.example", "5440", "warehouse", "inspector", ""]) + { + assert_eq!(input.value, expected); + } + assert!( + !std::fs::read(path)? + .windows(b"never-save-this-password".len()) + .any(|bytes| bytes == b"never-save-this-password") + ); + Ok(()) + } + #[test] fn invalid_ports_are_rejected_before_connecting() { for port in ["0", "65536", "not-a-port"] { diff --git a/src/main.rs b/src/main.rs index 2ed1105..f5fa3fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -77,9 +77,12 @@ Navigation: 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. - Select a saved database and press Enter to connect. PostgreSQL prompts for a - password; Esc cancels. Passwords are masked, never saved, and clear on success - or cancellation. Ctrl-U clears a field; pasting is supported. + Select a saved database and press Enter to connect. PostgreSQL opens an editable + connection window, initially focused on the password. Tab/Shift-Tab changes fields; + Left/Right changes TLS when focused. Enter connects and updates that saved entry + only on success; failures keep the previous settings and connection. Esc discards edits. + Passwords are masked, never saved, and clear on success or cancellation. + Ctrl-U clears a field; pasting is supported. Enter the database user explicitly; the operating-system username is not used. Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute.