feat: edit saved connection settings when connecting
This commit is contained in:
@@ -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.
|
||||
- [ ] 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
|
||||
+105
-27
@@ -124,9 +124,16 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> 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| {
|
||||
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| {
|
||||
active =
|
||||
selection.selected().and_then(|index| {
|
||||
match entries[index] {
|
||||
ExplorerEntry::Database(index) => index,
|
||||
_ => None,
|
||||
@@ -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.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<i64>,
|
||||
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"] {
|
||||
|
||||
+6
-3
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user