diff --git a/src-tauri/src/connection/commands.rs b/src-tauri/src/connection/commands.rs index d5646d3..34c2798 100644 --- a/src-tauri/src/connection/commands.rs +++ b/src-tauri/src/connection/commands.rs @@ -124,6 +124,27 @@ pub async fn test_connection_for_edit( state.test_connection_for_edit(&id, input).await } +/// List the databases reachable with the form's free-form credentials, for the +/// "browse databases" affordance in the connection form. +#[tauri::command] +pub async fn list_databases( + state: tauri::State<'_, AppState>, + input: TestInput, +) -> Result, ConnectionError> { + state.list_databases(input).await +} + +/// Edit-form variant of `list_databases`: if `input.password` is `None`, fills +/// it from the keychain entry for `id` (mirrors `test_connection_for_edit`). +#[tauri::command] +pub async fn list_databases_for_edit( + state: tauri::State<'_, AppState>, + id: String, + input: TestInput, +) -> Result, ConnectionError> { + state.list_databases_for_edit(&id, input).await +} + #[tauri::command] pub async fn test_saved_connection( state: tauri::State<'_, AppState>, @@ -403,6 +424,24 @@ impl AppState { self.pools.test(input).await } + pub async fn list_databases(&self, input: TestInput) -> Result, ConnectionError> { + self.pools.list_databases(input).await + } + + /// See the `list_databases_for_edit` command. Fills the password from the + /// keychain when blank, mirroring `test_connection_for_edit`. + pub async fn list_databases_for_edit( + &self, + id: &str, + mut input: TestInput, + ) -> Result, ConnectionError> { + if input.password.is_none() { + self.store.lock().await.get_by_id(id)?; + input.password = self.keychain.get(id)?; + } + self.pools.list_databases(input).await + } + pub async fn test_saved_connection(&self, id: &str) -> Result { let conn = self.store.lock().await.get_by_id(id)?; let password = self.keychain.get(id)?; diff --git a/src-tauri/src/connection/pool.rs b/src-tauri/src/connection/pool.rs index 8339634..504f6b6 100644 --- a/src-tauri/src/connection/pool.rs +++ b/src-tauri/src/connection/pool.rs @@ -153,6 +153,38 @@ impl PoolRegistry { } } + /// One-shot enumeration of databases reachable with the given free-form + /// credentials. Connects to a maintenance DB (`postgres`, falling back to + /// `template1`) rather than the typed `database`, so it works even when the + /// user mistyped the target DB name — the whole point of the "browse + /// databases" affordance. Bypasses the pool. + pub async fn list_databases(&self, input: TestInput) -> Result, ConnectionError> { + let mut last_err: Option = None; + for maint_db in ["postgres", "template1"] { + let cfg = Self::pg_config( + &input.host, + input.port, + maint_db, + &input.username, + input.password.as_deref(), + input.ssl_mode, + ); + match probe_databases(cfg, input.ssl_mode).await { + Ok(dbs) => return Ok(dbs), + Err(err) => last_err = Some(err), + } + } + // Mirror `test()`'s password-missing rewrite so a blank password on a + // password-required server surfaces an actionable message. + let err = last_err.unwrap_or_else(|| "could not list databases".to_string()); + let user_facing = if input.password.is_none() && err.contains("password missing") { + "password not set — enter the password and retry".to_string() + } else { + err + }; + Err(ConnectionError::Postgres(user_facing)) + } + /// Idempotent — closing an absent pool is a no-op so retries are safe. pub async fn evict(&self, connection_id: &str) { let mut guard = self.pools.lock().await; @@ -266,6 +298,47 @@ async fn fetch_version(client: &tokio_postgres::Client) -> Result Result, String> { + match TlsFlavor::from_ssl_mode(mode) { + TlsFlavor::None => { + let (client, conn) = cfg + .connect(tokio_postgres::NoTls) + .await + .map_err(|e| format_error_chain(&e))?; + tokio::spawn(conn); + fetch_databases(&client).await + } + flavor => { + let tls = build_tls_connector(flavor).map_err(|e| e.to_string())?; + let (client, conn) = cfg.connect(tls).await.map_err(|e| format_error_chain(&e))?; + tokio::spawn(conn); + fetch_databases(&client).await + } + } +} + +async fn fetch_databases(client: &tokio_postgres::Client) -> Result, String> { + // Exclude template databases and any the role can't CONNECT to, so the list + // matches what the user could actually open. Ordered for a stable UI. + let rows = client + .query( + "SELECT datname FROM pg_database \ + WHERE datistemplate = false AND has_database_privilege(datname, 'CONNECT') \ + ORDER BY datname", + &[], + ) + .await + .map_err(|e| format_error_chain(&e))?; + rows.iter() + .map(|r| { + r.try_get::<_, String>(0) + .map_err(|e| format_error_chain(&e)) + }) + .collect() +} + /// Walk an `std::error::Error` source chain and join Display messages with /// `: ` separators. Without this, callers see the outermost wrapper only — /// e.g. `tokio_postgres::Error{Kind::Config}` displays as `invalid diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83320e0..70d181c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -241,6 +241,8 @@ pub fn run() { connection::commands::delete_connection, connection::commands::test_connection, connection::commands::test_connection_for_edit, + connection::commands::list_databases, + connection::commands::list_databases_for_edit, connection::commands::test_saved_connection, connection::commands::parse_connection_uri, connection::commands::connection_connect, diff --git a/src-tauri/tests/list_databases_integration.rs b/src-tauri/tests/list_databases_integration.rs new file mode 100644 index 0000000..035422d --- /dev/null +++ b/src-tauri/tests/list_databases_integration.rs @@ -0,0 +1,54 @@ +//! Integration test for #24 `list_databases` against a real Postgres +//! testcontainer. Requires Docker; skips (with a note) if the container fails +//! to start, matching the pattern in `connection_integration.rs`. + +use ide99::connection::types::{SslMode, TestInput}; +use ide99::connection::PoolRegistry; +use testcontainers::core::ImageExt; +use testcontainers::runners::AsyncRunner; +use testcontainers_modules::postgres::Postgres; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn list_databases_works_with_wrong_typed_db_and_excludes_templates() { + let container = match Postgres::default().with_tag("17-alpine").start().await { + Ok(c) => c, + Err(err) => { + eprintln!("skipping: docker unavailable ({err})"); + return; + } + }; + let host_port = container + .get_host_port_ipv4(5432) + .await + .expect("get host port"); + + let pools = PoolRegistry::new(); + + // The typed `database` is deliberately a nonexistent name: the whole point + // of #24 is that listing succeeds anyway, because it connects to a + // maintenance DB (postgres/template1), not the typed one. + let dbs = pools + .list_databases(TestInput { + host: "127.0.0.1".into(), + port: host_port, + database: "this_database_does_not_exist".into(), + username: "postgres".into(), + password: Some("postgres".into()), + ssl_mode: SslMode::Disable, + }) + .await + .expect("list_databases should succeed via the maintenance DB despite a wrong typed db"); + + assert!( + dbs.contains(&"postgres".to_string()), + "expected 'postgres' in {dbs:?}", + ); + assert!( + !dbs.iter().any(|d| d == "template0" || d == "template1"), + "template databases must be excluded, got {dbs:?}", + ); + // Results are ORDER BY datname — assert ascending order. + let mut sorted = dbs.clone(); + sorted.sort(); + assert_eq!(dbs, sorted, "database list must be sorted, got {dbs:?}"); +} diff --git a/src/features/connections/ConnectionForm.test.tsx b/src/features/connections/ConnectionForm.test.tsx index f2aaf1d..e388078 100644 --- a/src/features/connections/ConnectionForm.test.tsx +++ b/src/features/connections/ConnectionForm.test.tsx @@ -31,6 +31,8 @@ const testConnectionMock = vi.fn(); const testConnectionForEditMock = vi.fn(); const createConnectionMock = vi.fn(); const updateConnectionMock = vi.fn(); +const listDatabasesMock = vi.fn(); +const listDatabasesForEditMock = vi.fn(); vi.mock("../../lib/tauri", async () => { const actual = await vi.importActual("../../lib/tauri"); @@ -40,6 +42,8 @@ vi.mock("../../lib/tauri", async () => { testConnectionForEdit: (id: string, input: unknown) => testConnectionForEditMock(id, input), createConnection: (input: unknown) => createConnectionMock(input), updateConnection: (id: string, input: unknown) => updateConnectionMock(id, input), + listDatabases: (input: unknown) => listDatabasesMock(input), + listDatabasesForEdit: (id: string, input: unknown) => listDatabasesForEditMock(id, input), }; }); @@ -97,6 +101,8 @@ describe("ConnectionForm", () => { testConnectionForEditMock.mockReset(); createConnectionMock.mockReset(); updateConnectionMock.mockReset(); + listDatabasesMock.mockReset(); + listDatabasesForEditMock.mockReset(); storeState = { formMode: { type: "create" }, connections: [], @@ -534,4 +540,22 @@ describe("ConnectionForm", () => { }); expect(saveButton).not.toBeDisabled(); }); + + // --- Issue #24 — browse available databases --- + + it("Browse databases fetches the list and populates the database datalist", async () => { + listDatabasesMock.mockResolvedValueOnce(["app_db", "postgres"]); + renderForm(); + // username is required for the browse trigger; host/port carry defaults. + await userEvent.type(screen.getByLabelText(/connection.form.field.username/), "u"); + await userEvent.click(screen.getByTestId("browse-databases")); + + await waitFor(() => { + expect(listDatabasesMock).toHaveBeenCalled(); + }); + const options = Array.from( + document.querySelectorAll("#connection-database-options option"), + ).map((o) => (o as HTMLOptionElement).value); + expect(options).toEqual(["app_db", "postgres"]); + }); }); diff --git a/src/features/connections/ConnectionForm.tsx b/src/features/connections/ConnectionForm.tsx index 2b58739..36657db 100644 --- a/src/features/connections/ConnectionForm.tsx +++ b/src/features/connections/ConnectionForm.tsx @@ -19,6 +19,8 @@ import { type TestResult, connectionRecordTestResult, createConnection, + listDatabases, + listDatabasesForEdit, testConnection, testConnectionForEdit, updateConnection, @@ -189,6 +191,10 @@ export function ConnectionForm() { const [testState, setTestState] = useState({ status: "idle" }); const [submitting, setSubmitting] = useState(false); const [showPassword, setShowPassword] = useState(false); + // #24 — databases fetched by the "browse databases" affordance, fed to a + // so the Database field offers type-ahead from the real list. + const [databaseOptions, setDatabaseOptions] = useState([]); + const [loadingDatabases, setLoadingDatabases] = useState(false); const [showDiscardConfirm, setShowDiscardConfirm] = useState(false); // Orphan-Instant-DB tracker: snapshotted from prefill at open time. While // this is non-null, abandoning the form (cancel/discard/X) drops the @@ -289,6 +295,42 @@ export function ConnectionForm() { } }, [methods, t, toast, formMode.type, editingConnection]); + // #24 — fetch the databases reachable with the current credentials and feed + // them to the Database field's . Connects to a maintenance DB, so + // it works even when the typed database name is wrong. Mirrors handleTest's + // edit/blank-password branching. + const handleBrowseDatabases = useCallback(async () => { + const valid = await methods.trigger(["host", "port", "username"]); + if (!valid) return; + const values = methods.getValues(); + const blankPassword = values.password === "" || values.password === undefined; + const input = { + host: values.host, + port: values.port, + database: values.database, + username: values.username, + password: blankPassword ? undefined : values.password, + sslMode: values.sslMode, + }; + setLoadingDatabases(true); + try { + const names = + formMode.type === "edit" && editingConnection && blankPassword + ? await listDatabasesForEdit(editingConnection.id, input) + : await listDatabases(input); + setDatabaseOptions(names); + if (names.length === 0) { + toast.info(t("connection.form.databases.empty")); + } else { + toast.success(t("connection.form.databases.loaded", { n: names.length })); + } + } catch (err) { + toast.error(localizeConnectionError(err, t)); + } finally { + setLoadingDatabases(false); + } + }, [methods, t, toast, formMode.type, editingConnection]); + // when the user just ran a successful test in the form, mirror // that result onto the persisted row so ConnectionDetails doesn't go from // "Connection successful" straight to "Never tested". Failure-after-save is @@ -598,8 +640,33 @@ export function ConnectionForm() { inputProps={{ placeholder: t("connection.form.field.database.placeholder"), readOnly: isInstantDbManaged, + list: "connection-database-options", }} /> + {isInstantDbManaged ? null : ( +
+ +
+ )} + + {databaseOptions.map((db) => ( + { return invokeChecked("test_saved_connection", { id }, testResultSchema); } +/** + * List the databases reachable with the supplied free-form credentials. + * Connects to a maintenance DB, so it works even if `input.database` is a typo. + */ +export function listDatabases(input: TestInput): Promise { + return invokeChecked("list_databases", { input }, z.array(z.string())); +} + +/** Edit-form variant — backend fills the password from the keychain when blank. */ +export function listDatabasesForEdit(id: string, input: TestInput): Promise { + return invokeChecked("list_databases_for_edit", { id, input }, z.array(z.string())); +} + /** * Persist a test result the form already obtained, without re-hitting * Postgres. Used right after Save to populate `lastTestedAt` /