From 75f569050c5666fc416752734e2cebeec3c90c0b Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 20 Oct 2026 11:21:28 +0200 Subject: [PATCH] feat: name saved connection configurations --- src/connection.rs | 91 +++++++++++++++++++++++++++++++++++++++-------- src/main.rs | 5 ++- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/src/connection.rs b/src/connection.rs index 4b2ebe2..0e93f8f 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -133,7 +133,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< }) .map(|connected| { browser = Some(connected); - connection_label = Some(form.label()); + connection_label = Some(form.connection_label()); active = selection.selected().and_then(|index| { match entries[index] { @@ -147,6 +147,8 @@ 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) + .unwrap_or(profiles.entries.len() - 1); let updated = explorer_entries( profiles.entries.len(), active, @@ -156,7 +158,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< selection.select(updated.iter().position(|entry| { *entry == ExplorerEntry::Database(Some( - profiles.entries.len() - 1, + saved_index, )) })); focus = Focus::Explorer; @@ -205,6 +207,18 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< form = Form::default(); show_form = true; } + 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, + }); + if let Some(index) = index { + form = profiles.entries[index].clone(); + form.focus = form.name_focus(); + show_form = true; + } + } KeyCode::Char('i') if focus == Focus::Explorer => { info = selection @@ -243,7 +257,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< match form.connect().and_then(App::new) { Ok(connected) => { browser = Some(connected); - connection_label = Some(form.label()); + connection_label = Some(form.connection_label()); active = Some(index); selection.select(Some(index)); expanded = true; @@ -542,10 +556,14 @@ impl Profiles { path TEXT NOT NULL )", )?; - let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id FROM connections ORDER BY id")? + 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| { let mut form = Form { id: Some(row.get(7)?), + name: Input::from(row.get::<_, String>(8)?.as_str()), provider: usize::from(row.get::<_, u8>(0)?), tls: usize::from(row.get::<_, u8>(5)?), path: Input::from(row.get::<_, String>(6)?.as_str()), @@ -565,6 +583,7 @@ impl Profiles { saved.clear_password(); saved.reconnect = false; saved.error = None; + saved.name = Input::from(saved.name.value.trim()); if form.provider == 0 { form.postgres_config()?; } else if form.path.value.is_empty() { @@ -579,12 +598,12 @@ 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, id) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) + "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) 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", + tls = excluded.tls, path = excluded.path, name = excluded.name", rusqlite::params![ saved.provider as u8, saved.postgres[0].value, @@ -593,7 +612,8 @@ impl Profiles { saved.postgres[3].value, saved.tls as u8, saved.path.value, - saved.id + saved.id, + saved.name.value ], )?; saved.id.get_or_insert(self.storage.last_insert_rowid()); @@ -609,6 +629,7 @@ impl Profiles { #[derive(Clone)] struct Form { id: Option, + name: Input, provider: usize, focus: usize, postgres: [Input; 5], @@ -622,6 +643,7 @@ impl Default for Form { fn default() -> Self { Self { id: None, + name: Input::default(), provider: 0, focus: 0, tls: 0, @@ -634,6 +656,10 @@ impl Default for Form { } impl Form { + fn name_focus(&self) -> usize { + if self.provider == 1 { 2 } else { 6 + usize::from(self.reconnect) } + } + fn connect(&self) -> Result { if self.provider == 1 { if self.path.value.is_empty() { @@ -674,6 +700,9 @@ impl Form { } fn input(&self) -> Option<&Input> { + if self.focus == self.name_focus() { + return Some(&self.name); + } if self.provider == 1 { (self.focus == 1).then_some(&self.path) } else { @@ -684,6 +713,9 @@ impl Form { } fn input_mut(&mut self) -> Option<&mut Input> { + if self.focus == self.name_focus() { + return Some(&mut self.name); + } if self.provider == 1 { (self.focus == 1).then_some(&mut self.path) } else { @@ -698,11 +730,7 @@ impl Form { } fn edit(&mut self, key: KeyEvent) { - let count = if self.provider == 0 { - 6 + usize::from(self.reconnect) - } else { - 2 - }; + let count = self.name_focus() + 1; match key.code { KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, @@ -724,6 +752,13 @@ impl Form { } fn label(&self) -> String { + if !self.name.value.trim().is_empty() { + return crate::db::display_text(self.name.value.trim()); + } + self.connection_label() + } + + fn connection_label(&self) -> String { crate::db::display_text(&if self.provider == 1 { let path = Path::new(&self.path.value); path.file_name() @@ -787,7 +822,7 @@ impl Form { } fn draw(&self, frame: &mut Frame, screen: Rect) { - let minimum_height = if self.provider == 0 { 20 } else { 16 }; + let minimum_height = if self.provider == 0 { 23 } else { 19 }; if screen.width < 74 || screen.height < minimum_height { frame.render_widget(Clear, screen); frame.render_widget( @@ -829,6 +864,8 @@ 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 [providers, details] = Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); frame.render_widget( @@ -1526,6 +1563,8 @@ mod tests { form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert!(form.input().is_none()); form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + form.input_mut().unwrap().paste("Warehouse"); for value in ["db.example", "5440", "warehouse", "inspector"] { form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); if form.focus == 0 { @@ -1543,6 +1582,8 @@ mod tests { assert_eq!(profiles.entries.len(), 1); 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"); for (input, expected) in restored .postgres @@ -1559,6 +1600,28 @@ mod tests { Ok(()) } + #[test] + fn custom_names_persist_and_blank_names_restore_the_default_label() -> Result<()> { + 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() }; + form.focus = form.name_focus(); + form.input_mut().unwrap().paste(" Local data "); + profiles.save(&form)?; + drop(profiles); + let mut profiles = Profiles::open(&path)?; + assert_eq!(profiles.entries[0].label(), "Local data"); + let mut form = profiles.entries[0].clone(); + form.name = Input::from(" \t "); + profiles.save(&form)?; + drop(profiles); + let profiles = Profiles::open(&path)?; + assert_eq!(profiles.entries.len(), 1); + assert_eq!(profiles.entries[0].label(), "example.sqlite"); + 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 c244f6c..b68fb3e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -32,7 +32,7 @@ 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×17 terminal cells; PostgreSQL/SQLite windows need 74×22/74×18. +The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×25/74×21. Navigation: ? Show keybindings in Normal mode (Esc/Enter/? closes) @@ -79,6 +79,9 @@ 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. + Optional Name controls the sidebar label; leave it blank for the default label. + e on a saved database or its child edits the configuration without connecting. + Enter saves edits; Esc discards them. Connected sessions keep their actual identity. 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