Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions src-tauri/src/connection/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>, 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<Vec<String>, ConnectionError> {
state.list_databases_for_edit(&id, input).await
}

#[tauri::command]
pub async fn test_saved_connection(
state: tauri::State<'_, AppState>,
Expand Down Expand Up @@ -403,6 +424,24 @@ impl AppState {
self.pools.test(input).await
}

pub async fn list_databases(&self, input: TestInput) -> Result<Vec<String>, 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<Vec<String>, 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<TestResult, ConnectionError> {
let conn = self.store.lock().await.get_by_id(id)?;
let password = self.keychain.get(id)?;
Expand Down
73 changes: 73 additions & 0 deletions src-tauri/src/connection/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>, ConnectionError> {
let mut last_err: Option<String> = 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;
Expand Down Expand Up @@ -266,6 +298,47 @@ async fn fetch_version(client: &tokio_postgres::Client) -> Result<String, String
Ok(v)
}

/// Connect with the given config + TLS mode, then enumerate databases. Mirrors
/// `run_probe`'s connect handling but runs the `pg_database` listing query.
async fn probe_databases(cfg: PgConfig, mode: SslMode) -> Result<Vec<String>, 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<Vec<String>, 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
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
54 changes: 54 additions & 0 deletions src-tauri/tests/list_databases_integration.rs
Original file line number Diff line number Diff line change
@@ -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:?}");
}
24 changes: 24 additions & 0 deletions src/features/connections/ConnectionForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import("../../lib/tauri")>("../../lib/tauri");
Expand All @@ -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),
};
});

Expand Down Expand Up @@ -97,6 +101,8 @@ describe("ConnectionForm", () => {
testConnectionForEditMock.mockReset();
createConnectionMock.mockReset();
updateConnectionMock.mockReset();
listDatabasesMock.mockReset();
listDatabasesForEditMock.mockReset();
storeState = {
formMode: { type: "create" },
connections: [],
Expand Down Expand Up @@ -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"]);
});
});
67 changes: 67 additions & 0 deletions src/features/connections/ConnectionForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import {
type TestResult,
connectionRecordTestResult,
createConnection,
listDatabases,
listDatabasesForEdit,
testConnection,
testConnectionForEdit,
updateConnection,
Expand Down Expand Up @@ -189,6 +191,10 @@ export function ConnectionForm() {
const [testState, setTestState] = useState<TestUiState>({ status: "idle" });
const [submitting, setSubmitting] = useState(false);
const [showPassword, setShowPassword] = useState(false);
// #24 — databases fetched by the "browse databases" affordance, fed to a
// <datalist> so the Database field offers type-ahead from the real list.
const [databaseOptions, setDatabaseOptions] = useState<string[]>([]);
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
Expand Down Expand Up @@ -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 <datalist>. 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
Expand Down Expand Up @@ -598,8 +640,33 @@ export function ConnectionForm() {
inputProps={{
placeholder: t("connection.form.field.database.placeholder"),
readOnly: isInstantDbManaged,
list: "connection-database-options",
}}
/>
{isInstantDbManaged ? null : (
<div style={{ display: "flex", alignItems: "center", gap: 8, marginTop: -8 }}>
<button
type="button"
onClick={() => void handleBrowseDatabases()}
disabled={loadingDatabases}
className="btn btn-ghost"
data-testid="browse-databases"
style={{ fontSize: 11 }}
>
{loadingDatabases ? (
<Loader2 size={12} className="q-spin" aria-hidden="true" />
) : null}
{loadingDatabases
? t("connection.form.databases.loading")
: t("connection.form.action.browse_databases")}
</button>
</div>
)}
<datalist id="connection-database-options">
{databaseOptions.map((db) => (
<option key={db} value={db} />
))}
</datalist>
<Field
label={t("connection.form.field.username")}
name="username"
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@
"test": "Test connection",
"save": "Save",
"cancel": "Cancel",
"save_without_testing": "Save without testing"
"save_without_testing": "Save without testing",
"browse_databases": "Browse databases"
},
"databases": {
"loading": "Loading databases…",
"empty": "No databases reachable with these credentials.",
"loaded": "Found {{n}} database(s) — start typing in the field."
},
"discard": {
"title": "Discard changes?",
Expand Down
8 changes: 7 additions & 1 deletion src/i18n/locales/ru.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,13 @@
"test": "Проверить подключение",
"save": "Сохранить",
"cancel": "Отмена",
"save_without_testing": "Сохранить без проверки"
"save_without_testing": "Сохранить без проверки",
"browse_databases": "Показать базы"
},
"databases": {
"loading": "Загрузка баз…",
"empty": "С этими учётными данными доступных баз не найдено.",
"loaded": "Найдено баз: {{n}} — начните вводить в поле."
},
"discard": {
"title": "Отменить изменения?",
Expand Down
Loading
Loading