feat: name saved connection configurations

This commit is contained in:
Valdemar Lorenzen
2026-10-20 11:21:28 +02:00
parent e940c41e34
commit 75f569050c
2 changed files with 81 additions and 15 deletions
+77 -14
View File
@@ -133,7 +133,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}) })
.map(|connected| { .map(|connected| {
browser = Some(connected); browser = Some(connected);
connection_label = Some(form.label()); connection_label = Some(form.connection_label());
active = active =
selection.selected().and_then(|index| { selection.selected().and_then(|index| {
match entries[index] { match entries[index] {
@@ -147,6 +147,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}) })
} else { } else {
profiles.save(&form).map(|()| { 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( let updated = explorer_entries(
profiles.entries.len(), profiles.entries.len(),
active, active,
@@ -156,7 +158,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
selection.select(updated.iter().position(|entry| { selection.select(updated.iter().position(|entry| {
*entry *entry
== ExplorerEntry::Database(Some( == ExplorerEntry::Database(Some(
profiles.entries.len() - 1, saved_index,
)) ))
})); }));
focus = Focus::Explorer; focus = Focus::Explorer;
@@ -205,6 +207,18 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
form = Form::default(); form = Form::default();
show_form = true; 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 => { KeyCode::Char('i') if focus == Focus::Explorer => {
info = info =
selection selection
@@ -243,7 +257,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
match form.connect().and_then(App::new) { match form.connect().and_then(App::new) {
Ok(connected) => { Ok(connected) => {
browser = Some(connected); browser = Some(connected);
connection_label = Some(form.label()); connection_label = Some(form.connection_label());
active = Some(index); active = Some(index);
selection.select(Some(index)); selection.select(Some(index));
expanded = true; expanded = true;
@@ -542,10 +556,14 @@ impl Profiles {
path TEXT NOT NULL 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| { .query_map([], |row| {
let mut form = Form { let mut form = Form {
id: Some(row.get(7)?), id: Some(row.get(7)?),
name: Input::from(row.get::<_, String>(8)?.as_str()),
provider: usize::from(row.get::<_, u8>(0)?), provider: usize::from(row.get::<_, u8>(0)?),
tls: usize::from(row.get::<_, u8>(5)?), tls: usize::from(row.get::<_, u8>(5)?),
path: Input::from(row.get::<_, String>(6)?.as_str()), path: Input::from(row.get::<_, String>(6)?.as_str()),
@@ -565,6 +583,7 @@ impl Profiles {
saved.clear_password(); saved.clear_password();
saved.reconnect = false; saved.reconnect = false;
saved.error = None; saved.error = None;
saved.name = Input::from(saved.name.value.trim());
if form.provider == 0 { if form.provider == 0 {
form.postgres_config()?; form.postgres_config()?;
} else if form.path.value.is_empty() { } 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")?); saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?);
} }
self.storage.execute( self.storage.execute(
"INSERT INTO connections (provider, host, port, database_name, username, tls, path, id) "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
provider = excluded.provider, host = excluded.host, port = excluded.port, provider = excluded.provider, host = excluded.host, port = excluded.port,
database_name = excluded.database_name, username = excluded.username, 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![ rusqlite::params![
saved.provider as u8, saved.provider as u8,
saved.postgres[0].value, saved.postgres[0].value,
@@ -593,7 +612,8 @@ impl Profiles {
saved.postgres[3].value, saved.postgres[3].value,
saved.tls as u8, saved.tls as u8,
saved.path.value, saved.path.value,
saved.id saved.id,
saved.name.value
], ],
)?; )?;
saved.id.get_or_insert(self.storage.last_insert_rowid()); saved.id.get_or_insert(self.storage.last_insert_rowid());
@@ -609,6 +629,7 @@ impl Profiles {
#[derive(Clone)] #[derive(Clone)]
struct Form { struct Form {
id: Option<i64>, id: Option<i64>,
name: Input,
provider: usize, provider: usize,
focus: usize, focus: usize,
postgres: [Input; 5], postgres: [Input; 5],
@@ -622,6 +643,7 @@ impl Default for Form {
fn default() -> Self { fn default() -> Self {
Self { Self {
id: None, id: None,
name: Input::default(),
provider: 0, provider: 0,
focus: 0, focus: 0,
tls: 0, tls: 0,
@@ -634,6 +656,10 @@ impl Default for Form {
} }
impl Form { impl Form {
fn name_focus(&self) -> usize {
if self.provider == 1 { 2 } else { 6 + usize::from(self.reconnect) }
}
fn connect(&self) -> Result<Database> { fn connect(&self) -> Result<Database> {
if self.provider == 1 { if self.provider == 1 {
if self.path.value.is_empty() { if self.path.value.is_empty() {
@@ -674,6 +700,9 @@ impl Form {
} }
fn input(&self) -> Option<&Input> { fn input(&self) -> Option<&Input> {
if self.focus == self.name_focus() {
return Some(&self.name);
}
if self.provider == 1 { if self.provider == 1 {
(self.focus == 1).then_some(&self.path) (self.focus == 1).then_some(&self.path)
} else { } else {
@@ -684,6 +713,9 @@ impl Form {
} }
fn input_mut(&mut self) -> Option<&mut Input> { fn input_mut(&mut self) -> Option<&mut Input> {
if self.focus == self.name_focus() {
return Some(&mut self.name);
}
if self.provider == 1 { if self.provider == 1 {
(self.focus == 1).then_some(&mut self.path) (self.focus == 1).then_some(&mut self.path)
} else { } else {
@@ -698,11 +730,7 @@ impl Form {
} }
fn edit(&mut self, key: KeyEvent) { fn edit(&mut self, key: KeyEvent) {
let count = if self.provider == 0 { let count = self.name_focus() + 1;
6 + usize::from(self.reconnect)
} else {
2
};
match key.code { match key.code {
KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::Tab => self.focus = (self.focus + 1) % count,
KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, KeyCode::BackTab => self.focus = (self.focus + count - 1) % count,
@@ -724,6 +752,13 @@ impl Form {
} }
fn label(&self) -> String { 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 { crate::db::display_text(&if self.provider == 1 {
let path = Path::new(&self.path.value); let path = Path::new(&self.path.value);
path.file_name() path.file_name()
@@ -787,7 +822,7 @@ impl Form {
} }
fn draw(&self, frame: &mut Frame, screen: Rect) { 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 { if screen.width < 74 || screen.height < minimum_height {
frame.render_widget(Clear, screen); frame.render_widget(Clear, screen);
frame.render_widget( frame.render_widget(
@@ -829,6 +864,8 @@ impl Form {
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(Color::Cyan)),
header, 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] = let [providers, details] =
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body); Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
frame.render_widget( frame.render_widget(
@@ -1526,6 +1563,8 @@ mod tests {
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert!(form.input().is_none()); assert!(form.input().is_none());
form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::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"] { for value in ["db.example", "5440", "warehouse", "inspector"] {
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
if form.focus == 0 { if form.focus == 0 {
@@ -1543,6 +1582,8 @@ mod tests {
assert_eq!(profiles.entries.len(), 1); assert_eq!(profiles.entries.len(), 1);
let restored = &profiles.entries[0]; let restored = &profiles.entries[0];
assert_eq!(restored.tls, 2); assert_eq!(restored.tls, 2);
assert_eq!(restored.label(), "Warehouse");
assert_eq!(restored.connection_label(), "warehouse · inspector@db.example:5440");
for (input, expected) in for (input, expected) in
restored restored
.postgres .postgres
@@ -1559,6 +1600,28 @@ mod tests {
Ok(()) 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] #[test]
fn invalid_ports_are_rejected_before_connecting() { fn invalid_ports_are_rejected_before_connecting() {
for port in ["0", "65536", "not-a-port"] { for port in ["0", "65536", "not-a-port"] {
+4 -1
View File
@@ -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. PostgreSQL URLs and libpq-style key=value strings are accepted.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections. 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: Navigation:
? Show keybindings in Normal mode (Esc/Enter/? closes) ? Show keybindings in Normal mode (Esc/Enter/? closes)
@@ -79,6 +79,9 @@ Navigation:
New database window: Up/Down chooses a provider; Enter starts editing. 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. Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
Enter saves without connecting, including when the database is offline. 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 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; 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 Left/Right changes TLS when focused. Enter connects and updates that saved entry