From e64ad2106a402ceffdbca59c4e14781392645a3b Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 20 Oct 2026 11:22:07 +0200 Subject: [PATCH] feat: remove saved connection configurations safely --- src/connection.rs | 75 +++++++++++++++++++++++++++++++++++++++++++++-- src/main.rs | 2 ++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/connection.rs b/src/connection.rs index 0e93f8f..0e8b14f 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -31,6 +31,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< let mut show_form = false; let mut info: Option = None; let mut help: Option = None; + let mut remove: Option = None; loop { let entries = explorer_entries( profiles.entries.len(), @@ -40,7 +41,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> 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) -> 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) -> 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) -> 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"] { diff --git a/src/main.rs b/src/main.rs index b68fb3e..1ea542c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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