feat: remove saved connection configurations safely

This commit is contained in:
Valdemar Lorenzen
2026-10-20 11:22:07 +02:00
parent 75f569050c
commit e64ad2106a
2 changed files with 75 additions and 2 deletions
+73 -2
View File
@@ -31,6 +31,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
let mut show_form = false;
let mut info: Option<usize> = None;
let mut help: Option<u16> = None;
let mut remove: Option<usize> = None;
loop {
let entries = explorer_entries(
profiles.entries.len(),
@@ -40,7 +41,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
);
terminal.draw(|frame| {
let [top, workspace, bottom] = shell_areas(frame.area());
let position = if info.is_some() || help.is_some() {
let position = if info.is_some() || help.is_some() || remove.is_some() {
None
} else if show_form {
form.input().map(Input::position)
@@ -93,12 +94,45 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
if let Some(offset) = &mut help {
draw_help(frame, workspace, offset);
}
if let Some(index) = remove {
let width = workspace.width.min(70);
let height = workspace.height.min(7);
let area = Rect::new(workspace.x + (workspace.width - width) / 2,
workspace.y + (workspace.height - height) / 2, width, height);
frame.render_widget(Clear, area);
frame.render_widget(Paragraph::new(format!(
"Remove saved connection \"{}\"?\nOnly the configuration is removed. The database and active session are kept.\n\ny: remove · n/Esc: cancel",
profiles.entries[index].label()))
.wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ")), area);
}
})?;
match event::read()? {
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
if let Some(index) = remove {
match key.code {
KeyCode::Char('y') => {
match profiles.remove(index) {
Ok(()) => {
active = active.and_then(|active| {
if active == index { None } else { Some(active - usize::from(active > index)) }
});
let updated = explorer_entries(profiles.entries.len(), active,
browser.as_ref().map(|app| app.relations.len()), expanded);
selection.select((!updated.is_empty()).then(|| selection.selected().unwrap_or(0).min(updated.len() - 1)));
error = None;
}
Err(failure) => error = Some(error_text(failure.as_ref())),
}
remove = None;
}
KeyCode::Esc | KeyCode::Char('n') => remove = None,
_ => {}
}
continue;
}
if let Some(offset) = &mut help {
match key.code {
KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => help = None,
@@ -207,6 +241,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
form = Form::default();
show_form = true;
}
KeyCode::Char('d') | KeyCode::Delete if focus == Focus::Explorer => {
remove = selection.selected().and_then(|index| match entries.get(index) {
Some(ExplorerEntry::Database(Some(index))) => Some(*index),
_ => None,
});
}
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),
@@ -317,7 +357,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
}
}
Event::Paste(_) if info.is_some() || help.is_some() => {}
Event::Paste(_) if info.is_some() || help.is_some() || remove.is_some() => {}
Event::Paste(text) if show_form => {
form.error = None;
if let Some(input) = form.input_mut() {
@@ -624,6 +664,12 @@ impl Profiles {
}
Ok(())
}
fn remove(&mut self, index: usize) -> Result<()> {
self.storage.execute("DELETE FROM connections WHERE id = ?1", [self.entries[index].id])?;
self.entries.remove(index);
Ok(())
}
}
#[derive(Clone)]
@@ -1622,6 +1668,31 @@ mod tests {
Ok(())
}
#[test]
fn removing_a_profile_keeps_database_files_and_surviving_profiles() -> Result<()> {
let directory = tempfile::tempdir()?;
let database = directory.path().join("data.sqlite");
let contents = b"database file must not be touched";
std::fs::write(&database, contents)?;
let path = directory.path().join("profiles.sqlite");
let mut profiles = Profiles::open(&path)?;
let form = Form { provider: 1, path: Input::from(database.to_str().unwrap()), ..Form::default() };
profiles.save(&form)?;
profiles.save(&form)?;
let survivor = profiles.entries[1].id;
profiles.storage.execute_batch("PRAGMA query_only = ON")?;
assert!(profiles.remove(0).is_err());
assert_eq!(profiles.entries.len(), 2);
profiles.storage.execute_batch("PRAGMA query_only = OFF")?;
profiles.remove(0)?;
drop(profiles);
let profiles = Profiles::open(&path)?;
assert_eq!(profiles.entries.len(), 1);
assert_eq!(profiles.entries[0].id, survivor);
assert_eq!(std::fs::read(database)?, contents);
Ok(())
}
#[test]
fn invalid_ports_are_rejected_before_connecting() {
for port in ["0", "65536", "not-a-port"] {
+2
View File
@@ -82,6 +82,8 @@ Navigation:
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.
d / Delete on a saved database asks for y to remove its configuration (n/Esc cancels).
Database files are never deleted. Removing the active profile keeps its session open.
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