feat: edit saved connection settings when connecting

This commit is contained in:
Valdemar Lorenzen
2026-10-20 10:00:36 +02:00
parent 24885b1a1f
commit e11fd0372d
3 changed files with 124 additions and 66 deletions
+2 -25
View File
@@ -1,25 +1,2 @@
- [x] adopt vamp-style top and bottom bars with a fixed layout, not vamp's configurable segment/tab system. - [ ] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar)
- [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). - [ ] feature: line numbers in gutter of sql editor
- [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.
+116 -38
View File
@@ -124,18 +124,25 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
KeyCode::Enter if form.focus == 0 => form.focus = 1, KeyCode::Enter if form.focus == 0 => form.focus = 1,
KeyCode::Enter => { KeyCode::Enter => {
let result = if form.reconnect { let result = if form.reconnect {
form.connect().and_then(App::new).map(|connected| { form.connect()
browser = Some(connected); .and_then(App::new)
active = selection.selected().and_then(|index| { .and_then(|connected| {
match entries[index] { profiles.save(&form)?;
ExplorerEntry::Database(index) => index, Ok(connected)
_ => None, })
} .map(|connected| {
}); browser = Some(connected);
selection.select(active); active =
expanded = true; selection.selected().and_then(|index| {
focus = Focus::Explorer; match entries[index] {
}) ExplorerEntry::Database(index) => index,
_ => None,
}
});
selection.select(active);
expanded = true;
focus = Focus::Explorer;
})
} else { } else {
profiles.save(&form).map(|()| { profiles.save(&form).map(|()| {
let updated = explorer_entries( let updated = explorer_entries(
@@ -350,9 +357,11 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Connection / password windows\n\ Connection / password windows\n\
Tab / Shift-Tab Change fields\n\ Tab / Shift-Tab Change fields\n\
Arrows Choose provider; Left/Right changes TLS\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\ 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 }); .wrap(Wrap { trim: false });
let lines = text.line_count(width.saturating_sub(2)) as u16; let lines = text.line_count(width.saturating_sub(2)) as u16;
@@ -530,9 +539,10 @@ impl Profiles {
path TEXT NOT NULL 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| { .query_map([], |row| {
let mut form = Form { let mut form = Form {
id: Some(row.get(7)?),
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()),
@@ -550,6 +560,8 @@ impl Profiles {
fn save(&mut self, form: &Form) -> Result<()> { fn save(&mut self, form: &Form) -> Result<()> {
let mut saved = form.clone(); let mut saved = form.clone();
saved.clear_password(); saved.clear_password();
saved.reconnect = false;
saved.error = None;
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() {
@@ -563,15 +575,37 @@ 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("INSERT INTO connections (provider, host, port, database_name, username, tls, path) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", self.storage.execute(
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])?; "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id)
self.entries.push(saved); 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(()) Ok(())
} }
} }
#[derive(Clone)] #[derive(Clone)]
struct Form { struct Form {
id: Option<i64>,
provider: usize, provider: usize,
focus: usize, focus: usize,
postgres: [Input; 5], postgres: [Input; 5],
@@ -584,6 +618,7 @@ struct Form {
impl Default for Form { impl Default for Form {
fn default() -> Self { fn default() -> Self {
Self { Self {
id: None,
provider: 0, provider: 0,
focus: 0, focus: 0,
tls: 0, tls: 0,
@@ -636,27 +671,22 @@ impl Form {
} }
fn input(&self) -> Option<&Input> { fn input(&self) -> Option<&Input> {
if self.reconnect { if self.provider == 1 {
Some(&self.postgres[4])
} else if self.provider == 1 {
(self.focus == 1).then_some(&self.path) (self.focus == 1).then_some(&self.path)
} else { } else {
self.postgres self.postgres
.get(self.focus.checked_sub(1)?) .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> { fn input_mut(&mut self) -> Option<&mut Input> {
if self.reconnect {
return Some(&mut self.postgres[4]);
}
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 {
self.postgres self.postgres
.get_mut(self.focus.checked_sub(1)?) .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) { fn edit(&mut self, key: KeyEvent) {
if self.reconnect { let count = if self.provider == 0 {
self.postgres[4].edit(key); 6 + usize::from(self.reconnect)
self.error = None; } else {
return; 2
} };
let count = if self.provider == 0 { 6 } 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,
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => { KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => {
self.provider = 1 - self.provider 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; self.tls = (self.tls + if key.code == KeyCode::Left { 2 } else { 1 }) % 3;
} }
_ => { _ => {
@@ -772,7 +803,7 @@ impl Form {
minimum_height, minimum_height,
); );
let window = Block::bordered().title(if self.reconnect { let window = Block::bordered().title(if self.reconnect {
" Connect · password only " " Connect · edit configuration "
} else { } else {
" New database " " New database "
}); });
@@ -861,14 +892,14 @@ impl Form {
pairs[index / 2][index % 2], pairs[index / 2][index % 2],
labels[index], labels[index],
index == 4, index == 4,
self.focus == index + 1 && (!self.reconnect || index == 4), self.focus == index + 1,
None, None,
); );
} }
frame.render_widget( frame.render_widget(
Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block( Paragraph::new(["Require TLS", "Prefer TLS", "Disable TLS"][self.tls]).block(
Block::bordered().title(" TLS · ←/→ change ").border_style( 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 Color::Cyan
} else { } else {
Color::DarkGray Color::DarkGray
@@ -892,7 +923,7 @@ impl Form {
); );
} }
let message = self.error.as_deref().unwrap_or(if self.reconnect { 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 { } else {
"Enter saves this configuration without connecting." "Enter saves this configuration without connecting."
}); });
@@ -907,7 +938,7 @@ impl Form {
status, status,
); );
frame.render_widget(Paragraph::new(if self.reconnect { 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 { } else {
" Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS · Ctrl-U clear · Esc cancel · Ctrl-C quit" " 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); }).style(Style::default().fg(Color::DarkGray)), help);
@@ -1478,6 +1509,53 @@ mod tests {
Ok(()) 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] #[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"] {
+6 -3
View File
@@ -77,9 +77,12 @@ 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.
Select a saved database and press Enter to connect. PostgreSQL prompts for a Select a saved database and press Enter to connect. PostgreSQL opens an editable
password; Esc cancels. Passwords are masked, never saved, and clear on success connection window, initially focused on the password. Tab/Shift-Tab changes fields;
or cancellation. Ctrl-U clears a field; pasting is supported. 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. Enter the database user explicitly; the operating-system username is not used.
Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling Configurations persist in $XDG_CONFIG_HOME/dbdash/connections.sqlite3, falling
back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute. back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute.