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.
|
- [ ] 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.
|
|
||||||
+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 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()
|
||||||
|
.and_then(App::new)
|
||||||
|
.and_then(|connected| {
|
||||||
|
profiles.save(&form)?;
|
||||||
|
Ok(connected)
|
||||||
|
})
|
||||||
|
.map(|connected| {
|
||||||
browser = Some(connected);
|
browser = Some(connected);
|
||||||
active = selection.selected().and_then(|index| {
|
active =
|
||||||
|
selection.selected().and_then(|index| {
|
||||||
match entries[index] {
|
match entries[index] {
|
||||||
ExplorerEntry::Database(index) => index,
|
ExplorerEntry::Database(index) => index,
|
||||||
_ => None,
|
_ => None,
|
||||||
@@ -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)
|
||||||
|
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);
|
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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user