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| {
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<Database>) -> 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<Database>) -> 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<Database>) -> 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<Database>) -> 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<i64>,
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<Database> {
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"] {
+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.
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