/** * Data source properties (IDEA's Data Sources and Drivers page): name, colour and * comment above General / Options / SSH/SSL / Schemas / Advanced tabs, Test Connection. */ import { useState, type ReactNode } from "react"; import { call, commands, IpcError, type DataSource, type DbColor, type DbIntrospection, type DbOptions, type DbProperty, type DbSsh, type SshAuth, type DbSslMode, type DbTxMode, } from "@/platform/ipc"; import { executeAction } from "@/platform/actions"; import { tx, useTx, useTxTemplate } from "@/platform/i18n"; import { sshLabel as configLabel } from "@/platform/remoteSettings"; import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui"; import { colorVar, connectRemedy, effectiveSsh, fileSourceName, newSourceId, parseSourceUrl, sshConfigOf, sshLabel, sshTunnelReady, } from "./model"; import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store"; import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors"; import styles from "./Database.module.css"; import { isImeKey } from "@/platform/ime"; type Tab = "general" | "options" | "ssh" | "schemas" | "advanced"; const TYPES: SelectOption[] = VENDORS.map((v) => ({ value: v.id, label: v.label, icon: v.icon })); const COLORS: Array> = [ { value: "none", label: "No color" }, { value: "red", label: "Red" }, { value: "orange", label: "Orange" }, { value: "yellow", label: "Yellow" }, { value: "green", label: "Green" }, { value: "blue", label: "Blue" }, { value: "violet", label: "Violet" }, ]; const SSL: Array> = [ { value: "disable", label: "Disable" }, { value: "prefer", label: "Prefer" }, { value: "require", label: "Require" }, { value: "verifyCa", label: "Verify CA" }, { value: "verifyFull", label: "Verify Full" }, ]; const TX: Array> = [ { value: "auto", label: "Auto" }, { value: "manual", label: "Manual" }, ]; const SSH_AUTH: Array> = [ { value: "openSsh", label: "OpenSSH config and authentication agent" }, { value: "password", label: "Password" }, { value: "keyPair", label: "Key pair" }, ]; /** Driver property names the Advanced tab suggests, and what the backend does with them. */ const PROPERTIES: Record = { postgres: { names: [ "statement_timeout", "lock_timeout", "idle_in_transaction_session_timeout", "search_path", "work_mem", "application_name", "connect_timeout", ], hint: "Sent as run-time parameters when connecting (any server setting, e.g. statement_timeout). application_name names the session; connect_timeout is in seconds.", }, mysql: { names: ["sql_mode", "max_execution_time", "wait_timeout", "charset", "collation", "connect_timeout"], hint: "Set as session variables after connecting (SET SESSION name = value). charset and collation pick the connection character set; connect_timeout is in seconds.", }, sqlite: { names: ["foreign_keys", "busy_timeout", "cache_size", "journal_mode", "synchronous"], hint: "Applied as pragmas when opening the file (PRAGMA name = value).", }, sqlServer: { names: ["connect_timeout"], hint: "Connection settings for SQL Server.", }, }; const blankSsh = (): DbSsh => ({ enabled: false, host: "", port: null, user: "", auth: "openSsh", keyFile: "", saveSecret: true, }); const blankOptions = (): DbOptions => ({ transaction: "auto", defaultSchema: "", timeZone: "", startupScript: "", keepAliveSecs: null, autoDisconnectSecs: null, singleDatabase: false, }); const blankIntrospection = (): DbIntrospection => ({ allDatabases: true, databases: [], allSchemas: true, schemas: [], showSystem: false, showTemplates: false, objectFilter: "", }); const parsePort = (v: string) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 && n < 65536 ? n : null; }; const parseSecs = (v: string) => { const n = parseInt(v, 10); return Number.isFinite(n) && n > 0 ? n : null; }; function blank(): DataSource { return { id: newSourceId(), name: "", driver: "postgres", vendor: "", host: "localhost", port: null, database: "", user: "", file: null, ssl: "prefer", savePassword: true, readOnly: false, color: null, comment: "", urlOnly: false, url: "", sslCaFile: "", sslCertFile: "", sslKeyFile: "", options: blankOptions(), introspection: blankIntrospection(), properties: [], }; } async function pickFile(title: string): Promise { const { open } = await import("@tauri-apps/plugin-dialog"); const file = await open({ multiple: false, directory: false, title }); return typeof file === "string" ? file : null; } /** A path field with Browse…. */ function FileField({ id, label, value, placeholder, disabled, title, onChange, }: { id: string; label: string; value: string; placeholder?: string; disabled?: boolean; title: string; onChange: (v: string) => void; }) { return ( <>
onChange(e.target.value)} />
); } /** Seconds after a checkbox (Run keep-alive query each N sec.). */ function SecondsOption({ label, value, fallback, onChange, }: { label: string; value: number | null | undefined; fallback: number; onChange: (v: number | null) => void; }) { const [draft, setDraft] = useState(String(value ?? fallback)); const on = value != null; return ( <>
onChange(c ? (parseSecs(draft) ?? fallback) : null)} label={label} /> { setDraft(e.target.value); const n = parseSecs(e.target.value); if (n) onChange(n); }} /> sec.
); } /** Schemas tab: "All …" or a choice of names and `*` / `?` patterns. */ function NamePicker({ allLabel, addLabel, all, chosen, known, onAll, onChosen, }: { /** "All databases" */ allLabel: string; /** Accessible name of the pattern input. */ addLabel: string; all: boolean; chosen: string[]; known: string[]; onAll: (all: boolean) => void; onChosen: (chosen: string[]) => void; }) { const [pattern, setPattern] = useState(""); const names = [...new Set([...known, ...chosen])]; const add = () => { const p = pattern.trim(); if (p && !chosen.includes(p)) onChosen([...chosen, p]); setPattern(""); }; return (
{!all && (
{names.map((n) => ( onChosen(c ? [...chosen, n] : chosen.filter((x) => x !== n))} label={n} /> ))}
setPattern(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !isImeKey(e.nativeEvent)) { e.preventDefault(); add(); } }} />
)}
); } function Section({ title }: { title: string }) { return (
{title}
); } export function SourceDialog({ initial, preset, presetPassword, }: { initial: DataSource | null; preset?: Partial; presetPassword?: string | null; }) { const tx = useTx(); const txTemplate = useTxTemplate(); const [s, setS] = useState(() => ({ ...blank(), ...preset, ...initial })); // `undefined`: unchanged (editing keeps the stored password). const [password, setPassword] = useState(presetPassword ?? undefined); // Same convention for the SSH password / passphrase. const [sshSecret, setSshSecret] = useState(undefined); const [tab, setTab] = useState("general"); const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null); const [busy, setBusy] = useState(false); const [showComment, setShowComment] = useState(!!s.comment); // The URL being typed in default mode (null: show the fields' URL). const [urlDraft, setUrlDraft] = useState(null); const loadedDbs = useDatabase((st) => (initial?.id ? st.databases[initial.id] : undefined)); const loadedSchemas = useDatabase((st) => (initial?.id ? st.schemas[initial.id] : undefined)); const set = (patch: Partial) => setS((x) => ({ ...x, ...patch })); const ssh: DbSsh = { ...blankSsh(), ...s.ssh }; const sshConfigs = useWorkflowSettings((w) => w.ssh.configs); const sshConfigId = ssh.config?.trim() ?? ""; const sshConfig = sshConfigOf(ssh, sshConfigs); // The host / user / authentication in effect (the chosen SSH configuration's). const sshUsed: DbSsh = effectiveSsh(ssh, sshConfigs) ?? ssh; const setSsh = (patch: Partial) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } })); const options: DbOptions = { ...blankOptions(), ...s.options }; const setOptions = (patch: Partial) => setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } })); const intro: DbIntrospection = { ...blankIntrospection(), ...s.introspection }; const setIntro = (patch: Partial) => setS((x) => ({ ...x, introspection: { ...blankIntrospection(), ...x.introspection, ...patch } })); const properties = s.properties ?? []; const setProperty = (i: number, patch: Partial) => set({ properties: properties.map((p, j) => (j === i ? { ...p, ...patch } : p)) }); const vendor = vendorOf(s); const sqlite = s.driver === "sqlite"; const postgres = s.driver === "postgres"; const urlOnly = !sqlite && !!s.urlOnly; const parsedUrl = urlOnly ? parseSourceUrl(s.url ?? "") : null; const urlValid = !!parsedUrl && parsedUrl.source.driver === s.driver; const host = urlOnly ? (parsedUrl?.source.host ?? "") : (s.host ?? ""); const database = urlOnly ? (parsedUrl?.source.database ?? "") : (s.database ?? ""); const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`); const sshOn = !sqlite && !!ssh.enabled; const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || sshTunnelReady(ssh, sshConfigs)); const sslOn = (s.ssl ?? "prefer") !== "disable"; /** What is saved / tested: a preset's port when the field was left empty. */ const effective = (): DataSource => ({ ...s, name, port: s.port ?? (s.vendor ? vendor.port : null) }); const setType = (id: string) => { const v = vendorById(id); if (!v) return; setS((x) => { const old = vendorOf(x); const port = x.port == null || x.port === old.port ? v.port : x.port; return { ...x, driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port, ssl: v.ssl ?? x.ssl }; }); }; /** Typing a URL in default mode fills the fields it names. */ const typeUrl = (text: string) => { setUrlDraft(text); const p = parseSourceUrl(text); if (!p || p.source.driver !== s.driver) return; const { host: h, port, database: db, user, ssl, sslCaFile, sslCertFile, sslKeyFile, vendor: v } = p.source; set({ host: h, port, database: db, ...(user ? { user } : {}), ...(ssl ? { ssl } : {}), ...(sslCaFile ? { sslCaFile } : {}), ...(sslCertFile ? { sslCertFile } : {}), ...(sslKeyFile ? { sslKeyFile } : {}), ...(v ? { vendor: v } : {}), }); if (p.password) setPassword(p.password); }; const setUrlOnly = (on: boolean) => set(on && !s.url?.trim() ? { urlOnly: on, url: sourceUrl(s) } : { urlOnly: on }); const runTest = async () => { setBusy(true); setTest(null); try { const v = await call(commands.dbTest(effective(), password ?? null, sshOn ? (sshSecret ?? null) : null)); setTest({ ok: true, text: tx`Succeeded: ${v}` }); } catch (e) { const text = e instanceof Error ? e.message : String(e); const trust = e instanceof IpcError && connectRemedy(e.kind, e.message, { ssh: sshUsed }) === "trustHostKey"; setTest({ ok: false, text, trust }); } finally { setBusy(false); } }; const trustAndRetry = async () => { // A shared SSH configuration records the failed host key under its own id. await call(sshConfigId ? commands.sshTrustHost(sshConfigId) : commands.dbTrustSshHost(s.id ?? "")); await runTest(); }; const save = async () => { setBusy(true); try { await saveSource( { ...effective(), properties: properties.filter((p) => p.name?.trim()) }, sqlite ? undefined : password === undefined ? undefined : password || null, !sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null, ); closeDialog(); } catch (e) { setTest({ ok: false, text: e instanceof Error ? e.message : String(e) }); } finally { setBusy(false); } }; const loadNames = async () => { const id = initial?.id; if (!id) return; await Promise.all([postgres ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]); }; const tabs: Array<{ id: Tab; label: string }> = [ { id: "general", label: "General" }, { id: "options", label: "Options" }, ...(sqlite ? [] : [ { id: "ssh" as const, label: "SSH/SSL" }, { id: "schemas" as const, label: "Schemas" }, ]), { id: "advanced", label: "Advanced" }, ]; const current = tabs.some((t) => t.id === tab) ? tab : "general"; const props = PROPERTIES[s.driver ?? "sqlite"]!; const general = ( <> Driver: set({ host: e.target.value })} /> set({ port: parsePort(e.target.value) })} /> )} {sshOn && ( <> {tx`Reached from the SSH host ${sshLabel({ ssh: sshUsed })}.`} )} set({ user: e.target.value })} />
" : ""} onChange={(e) => setPassword(e.target.value)} /> set({ savePassword })} label="Save password" />
{!urlOnly && ( <> set({ database: e.target.value })} /> )} (urlOnly ? set({ url: e.target.value }) : typeUrl(e.target.value))} onBlur={() => setUrlDraft(null)} /> {txTemplate( urlOnly ? urlValid || !s.url?.trim() ? "Connects with the URL alone: host, port, database, SSL and parameters come from it." : `Not a ${vendor.label} URL` : "Editing the URL fills in the settings above.", )} )} ); const optionsTab = ( <>
set({ readOnly })} label="Read-only" /> Transaction control: setOptions({ defaultSchema: e.target.value })} /> )} {!sqlite && ( <> setOptions({ timeZone: e.target.value })} /> setOptions({ keepAliveSecs })} /> )} setOptions({ autoDisconnectSecs })} /> {postgres && ( <> setOptions({ singleDatabase })} label="Single database mode (show only the configured database)" /> )}