feat: name saved connection configurations
This commit is contained in:
+77
-14
@@ -133,7 +133,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
})
|
||||
.map(|connected| {
|
||||
browser = Some(connected);
|
||||
connection_label = Some(form.label());
|
||||
connection_label = Some(form.connection_label());
|
||||
active =
|
||||
selection.selected().and_then(|index| {
|
||||
match entries[index] {
|
||||
@@ -147,6 +147,8 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
})
|
||||
} else {
|
||||
profiles.save(&form).map(|()| {
|
||||
let saved_index = profiles.entries.iter().position(|entry| entry.id == form.id)
|
||||
.unwrap_or(profiles.entries.len() - 1);
|
||||
let updated = explorer_entries(
|
||||
profiles.entries.len(),
|
||||
active,
|
||||
@@ -156,7 +158,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
selection.select(updated.iter().position(|entry| {
|
||||
*entry
|
||||
== ExplorerEntry::Database(Some(
|
||||
profiles.entries.len() - 1,
|
||||
saved_index,
|
||||
))
|
||||
}));
|
||||
focus = Focus::Explorer;
|
||||
@@ -205,6 +207,18 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
form = Form::default();
|
||||
show_form = true;
|
||||
}
|
||||
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),
|
||||
Some(ExplorerEntry::Relation(_)) => active,
|
||||
_ => None,
|
||||
});
|
||||
if let Some(index) = index {
|
||||
form = profiles.entries[index].clone();
|
||||
form.focus = form.name_focus();
|
||||
show_form = true;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('i') if focus == Focus::Explorer => {
|
||||
info =
|
||||
selection
|
||||
@@ -243,7 +257,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
||||
match form.connect().and_then(App::new) {
|
||||
Ok(connected) => {
|
||||
browser = Some(connected);
|
||||
connection_label = Some(form.label());
|
||||
connection_label = Some(form.connection_label());
|
||||
active = Some(index);
|
||||
selection.select(Some(index));
|
||||
expanded = true;
|
||||
@@ -542,10 +556,14 @@ impl Profiles {
|
||||
path TEXT NOT NULL
|
||||
)",
|
||||
)?;
|
||||
let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id FROM connections ORDER BY id")?
|
||||
if !storage.prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'name'")?.exists([])? {
|
||||
storage.execute_batch("ALTER TABLE connections ADD COLUMN name TEXT NOT NULL DEFAULT ''")?;
|
||||
}
|
||||
let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name FROM connections ORDER BY id")?
|
||||
.query_map([], |row| {
|
||||
let mut form = Form {
|
||||
id: Some(row.get(7)?),
|
||||
name: Input::from(row.get::<_, String>(8)?.as_str()),
|
||||
provider: usize::from(row.get::<_, u8>(0)?),
|
||||
tls: usize::from(row.get::<_, u8>(5)?),
|
||||
path: Input::from(row.get::<_, String>(6)?.as_str()),
|
||||
@@ -565,6 +583,7 @@ impl Profiles {
|
||||
saved.clear_password();
|
||||
saved.reconnect = false;
|
||||
saved.error = None;
|
||||
saved.name = Input::from(saved.name.value.trim());
|
||||
if form.provider == 0 {
|
||||
form.postgres_config()?;
|
||||
} else if form.path.value.is_empty() {
|
||||
@@ -579,12 +598,12 @@ 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, id)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
"INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
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",
|
||||
tls = excluded.tls, path = excluded.path, name = excluded.name",
|
||||
rusqlite::params![
|
||||
saved.provider as u8,
|
||||
saved.postgres[0].value,
|
||||
@@ -593,7 +612,8 @@ impl Profiles {
|
||||
saved.postgres[3].value,
|
||||
saved.tls as u8,
|
||||
saved.path.value,
|
||||
saved.id
|
||||
saved.id,
|
||||
saved.name.value
|
||||
],
|
||||
)?;
|
||||
saved.id.get_or_insert(self.storage.last_insert_rowid());
|
||||
@@ -609,6 +629,7 @@ impl Profiles {
|
||||
#[derive(Clone)]
|
||||
struct Form {
|
||||
id: Option<i64>,
|
||||
name: Input,
|
||||
provider: usize,
|
||||
focus: usize,
|
||||
postgres: [Input; 5],
|
||||
@@ -622,6 +643,7 @@ impl Default for Form {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: None,
|
||||
name: Input::default(),
|
||||
provider: 0,
|
||||
focus: 0,
|
||||
tls: 0,
|
||||
@@ -634,6 +656,10 @@ impl Default for Form {
|
||||
}
|
||||
|
||||
impl Form {
|
||||
fn name_focus(&self) -> usize {
|
||||
if self.provider == 1 { 2 } else { 6 + usize::from(self.reconnect) }
|
||||
}
|
||||
|
||||
fn connect(&self) -> Result<Database> {
|
||||
if self.provider == 1 {
|
||||
if self.path.value.is_empty() {
|
||||
@@ -674,6 +700,9 @@ impl Form {
|
||||
}
|
||||
|
||||
fn input(&self) -> Option<&Input> {
|
||||
if self.focus == self.name_focus() {
|
||||
return Some(&self.name);
|
||||
}
|
||||
if self.provider == 1 {
|
||||
(self.focus == 1).then_some(&self.path)
|
||||
} else {
|
||||
@@ -684,6 +713,9 @@ impl Form {
|
||||
}
|
||||
|
||||
fn input_mut(&mut self) -> Option<&mut Input> {
|
||||
if self.focus == self.name_focus() {
|
||||
return Some(&mut self.name);
|
||||
}
|
||||
if self.provider == 1 {
|
||||
(self.focus == 1).then_some(&mut self.path)
|
||||
} else {
|
||||
@@ -698,11 +730,7 @@ impl Form {
|
||||
}
|
||||
|
||||
fn edit(&mut self, key: KeyEvent) {
|
||||
let count = if self.provider == 0 {
|
||||
6 + usize::from(self.reconnect)
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let count = self.name_focus() + 1;
|
||||
match key.code {
|
||||
KeyCode::Tab => self.focus = (self.focus + 1) % count,
|
||||
KeyCode::BackTab => self.focus = (self.focus + count - 1) % count,
|
||||
@@ -724,6 +752,13 @@ impl Form {
|
||||
}
|
||||
|
||||
fn label(&self) -> String {
|
||||
if !self.name.value.trim().is_empty() {
|
||||
return crate::db::display_text(self.name.value.trim());
|
||||
}
|
||||
self.connection_label()
|
||||
}
|
||||
|
||||
fn connection_label(&self) -> String {
|
||||
crate::db::display_text(&if self.provider == 1 {
|
||||
let path = Path::new(&self.path.value);
|
||||
path.file_name()
|
||||
@@ -787,7 +822,7 @@ impl Form {
|
||||
}
|
||||
|
||||
fn draw(&self, frame: &mut Frame, screen: Rect) {
|
||||
let minimum_height = if self.provider == 0 { 20 } else { 16 };
|
||||
let minimum_height = if self.provider == 0 { 23 } else { 19 };
|
||||
if screen.width < 74 || screen.height < minimum_height {
|
||||
frame.render_widget(Clear, screen);
|
||||
frame.render_widget(
|
||||
@@ -829,6 +864,8 @@ impl Form {
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
header,
|
||||
);
|
||||
let [name_area, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(body);
|
||||
self.name.draw(frame, name_area, " Name (optional) ", false, self.focus == self.name_focus(), None);
|
||||
let [providers, details] =
|
||||
Layout::horizontal([Constraint::Length(24), Constraint::Min(1)]).areas(body);
|
||||
frame.render_widget(
|
||||
@@ -1526,6 +1563,8 @@ mod tests {
|
||||
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
assert!(form.input().is_none());
|
||||
form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE));
|
||||
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
form.input_mut().unwrap().paste("Warehouse");
|
||||
for value in ["db.example", "5440", "warehouse", "inspector"] {
|
||||
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
|
||||
if form.focus == 0 {
|
||||
@@ -1543,6 +1582,8 @@ mod tests {
|
||||
assert_eq!(profiles.entries.len(), 1);
|
||||
let restored = &profiles.entries[0];
|
||||
assert_eq!(restored.tls, 2);
|
||||
assert_eq!(restored.label(), "Warehouse");
|
||||
assert_eq!(restored.connection_label(), "warehouse · inspector@db.example:5440");
|
||||
for (input, expected) in
|
||||
restored
|
||||
.postgres
|
||||
@@ -1559,6 +1600,28 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_names_persist_and_blank_names_restore_the_default_label() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let path = directory.path().join("connections.sqlite3");
|
||||
let mut profiles = Profiles::open(&path)?;
|
||||
let mut form = Form { provider: 1, path: Input::from("/tmp/example.sqlite"), ..Form::default() };
|
||||
form.focus = form.name_focus();
|
||||
form.input_mut().unwrap().paste(" Local data ");
|
||||
profiles.save(&form)?;
|
||||
drop(profiles);
|
||||
let mut profiles = Profiles::open(&path)?;
|
||||
assert_eq!(profiles.entries[0].label(), "Local data");
|
||||
let mut form = profiles.entries[0].clone();
|
||||
form.name = Input::from(" \t ");
|
||||
profiles.save(&form)?;
|
||||
drop(profiles);
|
||||
let profiles = Profiles::open(&path)?;
|
||||
assert_eq!(profiles.entries.len(), 1);
|
||||
assert_eq!(profiles.entries[0].label(), "example.sqlite");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_ports_are_rejected_before_connecting() {
|
||||
for port in ["0", "65536", "not-a-port"] {
|
||||
|
||||
+4
-1
@@ -32,7 +32,7 @@ Expansion never runs shell commands. Quote CLI paths to let dbdash expand them.
|
||||
PostgreSQL URLs and libpq-style key=value strings are accepted.
|
||||
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
||||
Use sslmode=disable only for trusted local connections.
|
||||
The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×22/74×18.
|
||||
The inspector needs 70×17 terminal cells; PostgreSQL/SQLite windows need 74×25/74×21.
|
||||
|
||||
Navigation:
|
||||
? Show keybindings in Normal mode (Esc/Enter/? closes)
|
||||
@@ -79,6 +79,9 @@ 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.
|
||||
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.
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user