feat: Third pass aligning core experience with IntelliJ IDEA

Project & files
- Project view modes (Project Files / Open Files / All Changed Files /
  Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go,
  Maven/Gradle jars; read-only jar/zip browsing
- ⌘N New popup with type search and more file kinds; New Project, Rename
  Project, Reload All from Disk, Export/Import Settings, Power Save Mode
- File type associations, extra content roots and exclude patterns (indexed
  and watched without reopening the project)

Editor & navigation
- Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline,
  history; Find in Files: Module scope, custom pattern scopes, multiline
- Search Everywhere filters, Assign Shortcut, settings results; named bookmark
  lists, file/folder bookmarks; Structure filters and Show Inherited; Recent
  Locations timestamps and breadcrumbs; Code Vision author
- Completion options, editor tab placement/layout, inline completion (LSP 3.18)
- Spell checking (Typo inspection, ide-spell) and Inspections settings with
  custom profiles; Code Style schemes, formatter markers, Do not format

Run, VCS, services, database
- Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker,
  Compound), Modify options, Before launch, env dialog, Current File,
  Emulate terminal (PTY)
- Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the
  graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees
- Generic Services host (Docker connections/registries, run sessions); TODO
  scope/changes tabs with changelists; Coverage report import
- Data sources: wire-compatible vendors, URL import, SSL certs, Options,
  Schemas and Advanced tabs, color and comment

Workbench
- Run Anything, Customize Main Toolbar, named tool window layouts, menu
  completeness, Code Cleanup, macros, "Don't Show Again", editor banners
- Chinese translations for the new UI

Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
Цей коміт міститься в:
l0ng-ai committed 2026-10-25 21:24:00 +08:00
1 джерело 294198a727
коміт 1e00ea5ee2
426 files changed
+85241 -2886

No files matched your search

+129
Переглянути файл
@@ -245,6 +245,13 @@
gap: 6px;
min-width: 0;
}
.tinted {
flex: 1;
margin: 0 -4px;
padding: 0 4px;
border-radius: var(--radius-control, 4px);
background: color-mix(in srgb, var(--db-tint) var(--tint-strength), transparent);
}
.nodeLabel {
overflow: hidden;
text-overflow: ellipsis;
@@ -334,6 +341,128 @@
gap: 8px;
align-items: center;
}
/* Data source dialog. */
.formSection {
grid-column: 1 / -1;
display: flex;
align-items: center;
gap: 8px;
color: var(--fg-strong);
}
.formSection::after {
content: "";
flex: 1;
height: 1px;
background: var(--border);
}
.formWide {
grid-column: 1 / -1;
display: flex;
flex-direction: column;
gap: 10px;
min-width: 0;
}
.portInput {
width: 80px;
}
.secondsInput {
width: 72px;
}
.narrowSelect {
min-width: 140px;
}
.segmented {
display: inline-flex;
justify-self: start;
border-radius: var(--radius-control, 4px);
box-shadow: inset 0 0 0 1px var(--border-button);
}
.segmented button {
height: var(--h-field, 28px);
padding: 0 14px;
border: 0;
border-radius: inherit;
background: transparent;
color: var(--fg);
font: inherit;
}
.segmented button[aria-pressed="true"] {
background: var(--bg-tab-selected);
box-shadow: inset 0 0 0 1px var(--border-tab-selected);
color: var(--fg-strong);
}
.segmented button:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: -2px;
}
.textArea {
width: 100%;
min-height: 64px;
resize: vertical;
padding: 6px 8px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-control, 4px);
background: var(--bg-input);
color: var(--fg-editor, var(--fg-strong));
font-family: var(--font-mono);
font-size: var(--fs-code);
line-height: 1.5;
}
.textArea:focus {
outline: none;
border-color: var(--accent);
}
.linkButton {
justify-self: start;
padding: 0;
border: 0;
background: none;
color: var(--link);
font: inherit;
cursor: pointer;
}
.linkButton:hover {
text-decoration: underline;
}
.colorValue {
display: inline-flex;
align-items: center;
gap: 6px;
}
.colorSwatch {
width: 12px;
height: 12px;
border-radius: 3px;
}
.namePicker {
display: flex;
flex-direction: column;
gap: 6px;
}
.nameList {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 180px;
overflow: auto;
padding-left: 22px;
}
.propertyTable {
display: flex;
flex-direction: column;
gap: 4px;
}
.propertyHead,
.propertyRow {
display: grid;
grid-template-columns: 1fr 1fr 24px;
align-items: center;
gap: 6px;
}
.propertyHead {
color: var(--fg-secondary);
font-size: var(--fs-small);
}
.testResult {
grid-column: 1 / -1;
font-size: var(--fs-small);
+28 -10
Переглянути файл
@@ -4,8 +4,8 @@
* with counts and column types, loaded lazily. Double-click a table to open its data, a data
* source to open its query console.
*/
import { useEffect, useMemo, useRef } from "react";
import type { DbTableDetails } from "@/platform/ipc";
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
import type { DbDatabases, DbTableDetails } from "@/platform/ipc";
import { setContext } from "@/platform/context";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
@@ -21,7 +21,7 @@ import {
type TreeHandle,
T,
} from "@/ui";
import { databaseKey, driverIcon, splitTarget, targetOf } from "./model";
import { colorVar, databaseKey, shownNames, sourceIcon, splitTarget, targetOf } from "./model";
import { nodeTarget, type DbNode as Node, type Group } from "./nodes";
import {
connect,
@@ -105,9 +105,20 @@ export function DatabaseView() {
return d?.status === "ok" ? d.value : null;
};
const sourceOf = (target: string) => st.sources.find((s) => s.id === splitTarget(target).id);
/** The Schemas tab's choice. */
const shownSchemas = (target: string, names: string[]) => {
const i = sourceOf(target)?.introspection;
return shownNames(names, i?.allSchemas, i?.schemas);
};
const shownDatabases = (id: string, v: DbDatabases) => {
const i = sourceOf(id)?.introspection;
return shownNames(v.names, i?.allDatabases, i?.databases, v.current);
};
const schemasOf = (key: string, id: string) =>
status(key, st.schemas[id], (v) =>
v.map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
shownSchemas(id, v).map((schema) => ({ kind: "schema", key: `${id}/${schema}`, id, schema }) as Node),
);
const children = (n: Node): readonly Node[] | undefined => {
@@ -116,7 +127,7 @@ export function DatabaseView() {
const id = n.source.id ?? "";
if (listsDatabases(n.source))
return status(n.key, st.databases[id], (v) =>
v.names.map((name) => ({
shownDatabases(id, v).map((name) => ({
kind: "database",
key: databaseKey(id, name),
id: targetOf(id, name, v.current),
@@ -226,14 +237,14 @@ export function DatabaseView() {
const id = n.source.id ?? "";
if (listsDatabases(n.source)) {
const d = st.databases[id];
return d?.status === "ok" ? String(d.value.names.length) : undefined;
return d?.status === "ok" ? String(shownDatabases(id, d.value).length) : undefined;
}
const s = st.schemas[id];
return s?.status === "ok" ? String(s.value.length) : undefined;
return s?.status === "ok" ? String(shownSchemas(id, s.value).length) : undefined;
}
case "database": {
const s = st.schemas[n.id];
return s?.status === "ok" ? String(s.value.length) : undefined;
return s?.status === "ok" ? String(shownSchemas(n.id, s.value).length) : undefined;
}
case "folder":
return String(
@@ -259,7 +270,7 @@ export function DatabaseView() {
const icon = (n: Node): string | undefined => {
switch (n.kind) {
case "source":
return driverIcon(n.source.driver);
return sourceIcon(n.source);
case "database":
return "database";
case "schema":
@@ -334,8 +345,15 @@ export function DatabaseView() {
? styles.keyLabel
: styles.nodeLabel;
const live = n.kind === "source" && [...st.connected].some((t) => splitTarget(t).id === n.source.id);
const target = nodeTarget(n);
// IDEA tints every row of a coloured data source.
const tint = colorVar(target ? sourceOf(target)?.color : null);
return (
<span className={styles.nodeRow}>
<span
className={tint ? `${styles.nodeRow} ${styles.tinted}` : styles.nodeRow}
style={tint ? ({ "--db-tint": tint } as CSSProperties) : undefined}
title={n.kind === "source" && n.source.comment ? n.source.comment : undefined}
>
{i && (
<span className={styles.nodeIcon}>
<Icon name={i} size={16} />
+5 -395
Переглянути файл
@@ -1,404 +1,14 @@
/** Data source (add / edit / test connection) and password dialogs. */
import { Fragment, useState } from "react";
import {
call,
commands,
IpcError,
type DataSource,
type DbDriver,
type DbSsh,
type DbSshAuth,
type DbSslMode,
} from "@/platform/ipc";
import { Button, Checkbox, Dialog, Input, Select, Tabs, lazyComponent, T } from "@/ui";
import { connectRemedy, fileSourceName, newSourceId, sshLabel } from "./model";
import { closeDialog, saveSource, useDatabase } from "./store";
import { call, commands, type DataSource } from "@/platform/ipc";
import { Checkbox, Dialog, Input, lazyComponent, T } from "@/ui";
import { sshLabel } from "./model";
import { SourceDialog } from "./SourceDialog";
import { closeDialog, useDatabase } from "./store";
import styles from "./Database.module.css";
const ObjectDialogs = lazyComponent(() => import("./ObjectDialogs").then((m) => m.ObjectDialogs));
const DRIVERS: Array<{ value: DbDriver; label: string }> = [
{ value: "postgres", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL / MariaDB" },
{ value: "sqlite", label: "SQLite" },
];
const SSL: Array<{ value: DbSslMode; label: string }> = [
{ value: "prefer", label: "Prefer" },
{ value: "require", label: "Require" },
{ value: "disable", label: "Disable" },
];
const SSH_AUTH: Array<{ value: DbSshAuth; label: string }> = [
{ value: "openSsh", label: "OpenSSH config and authentication agent" },
{ value: "password", label: "Password" },
{ value: "keyPair", label: "Key pair" },
];
const blankSsh = (): DbSsh => ({
enabled: false,
host: "",
port: null,
user: "",
auth: "openSsh",
keyFile: "",
saveSecret: true,
});
const parsePort = (v: string) => {
const n = parseInt(v, 10);
return Number.isFinite(n) && n > 0 && n < 65536 ? n : null;
};
function blank(): DataSource {
return {
id: newSourceId(),
name: "",
driver: "postgres",
host: "localhost",
port: null,
database: "",
user: "",
file: null,
ssl: "prefer",
savePassword: true,
readOnly: false,
};
}
function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<"general" | "ssh">("general");
const [test, setTest] = useState<{ ok: boolean; text: string; trust?: boolean } | null>(null);
const [busy, setBusy] = useState(false);
const set = (patch: Partial<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const sqlite = s.driver === "sqlite";
const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${s.database || s.user || "db"}@${s.host}`);
const sshOn = !sqlite && !!ssh.enabled;
const valid = sqlite ? !!s.file?.trim() : !!s.host?.trim() && (!sshOn || !!ssh.host?.trim());
const runTest = async () => {
setBusy(true);
setTest(null);
try {
const v = await call(commands.dbTest({ ...s, name }, password ?? null, sshOn ? (sshSecret ?? null) : null));
setTest({ ok: true, text: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(commands.dbTrustSshHost(s.id ?? ""));
await runTest();
};
const save = async () => {
setBusy(true);
try {
await saveSource(
{ ...s, name },
sqlite ? undefined : password === undefined ? undefined : password || null,
!sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null,
);
closeDialog();
} catch (e) {
setTest({ ok: false, text: e instanceof Error ? e.message : String(e) });
} finally {
setBusy(false);
}
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") set({ file });
};
const browseKey = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const file = await open({ multiple: false, directory: false, title: "Private Key File" });
if (typeof file === "string") setSsh({ keyFile: file });
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={560}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
<T>Test Connection</T>
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
{!sqlite && (
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={tab}
onSelect={(id) => setTab(id as "general" | "ssh")}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={[
{ id: "general", label: "General" },
{ id: "ssh", label: "SSH/SSL" },
]}
/>
)}
<div
className={styles.form}
role={sqlite ? undefined : "tabpanel"}
id={sqlite ? undefined : `db-source-panel-${tab}`}
>
{sqlite || tab === "general" ? (
<>
<label className={styles.formLabel} htmlFor="db-name">
<T>Name:</T>
</label>
<Input
id="db-name"
value={s.name ?? ""}
placeholder={name}
onChange={(e) => set({ name: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Driver:</T>
</span>
<Select
aria-label="Driver"
value={s.driver ?? "postgres"}
options={DRIVERS}
onChange={(driver) => set({ driver })}
/>
{sqlite ? (
<>
<label className={styles.formLabel} htmlFor="db-file">
<T>File:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-file"
className={styles.fill}
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
onChange={(e) => set({ file: e.target.value || null })}
/>
<Button onClick={() => void browse()}>
<T>Browse…</T>
</Button>
</div>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-host"
className={styles.fill}
value={s.host ?? ""}
onChange={(e) => set({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
<Input
id="db-port"
style={{ width: 80 }}
inputMode="numeric"
value={s.port ?? ""}
placeholder={s.driver === "mysql" ? "3306" : "5432"}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
<T>User:</T>
</label>
<Input id="db-user" value={s.user ?? ""} onChange={(e) => set({ user: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-password">
<T>Password:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox
checked={!!s.savePassword}
onChange={(savePassword) => set({ savePassword })}
label="Save password"
/>
</div>
<label className={styles.formLabel} htmlFor="db-database">
<T>Database:</T>
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
</>
) : (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
style={{ width: 80 }}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<>
<label className={styles.formLabel} htmlFor="db-ssh-key">
<T>Private key:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-key"
className={styles.fill}
disabled={!sshOn}
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
onChange={(e) => setSsh({ keyFile: e.target.value })}
/>
<Button disabled={!sshOn} onClick={() => void browseKey()}>
<T>Browse…</T>
</Button>
</div>
</>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
<T>
{
"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."
}
</T>
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{ssh.auth === "password" ? "Password:" : "Passphrase:"}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<span className={styles.formLabel}>
<T>SSL:</T>
</span>
<Select aria-label="SSL mode" value={s.ssl ?? "prefer"} options={SSL} onChange={(ssl) => set({ ssl })} />
</>
)}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? "Connecting…" : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
<T>Trust Host and Retry</T>
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
function PasswordDialog({ source, resolve }: { source: DataSource; resolve: (ok: boolean) => void }) {
const [password, setPassword] = useState("");
const [save, setSave] = useState(!!source.savePassword);
+3 -2
Переглянути файл
@@ -515,8 +515,8 @@ function SourceUrlDialog() {
/>
<span className={styles.formHint}>
{url.trim() && !parsed
? "Not a PostgreSQL, MySQL / MariaDB or SQLite URL"
: "postgresql://, mysql://, mariadb://, jdbc:…, sqlite:path"}
? "Not a URL of PostgreSQL, MySQL, SQLite or a compatible database"
: "postgresql://, mysql://, mariadb://, redshift://, tidb://…, jdbc:…, sqlite:path"}
</span>
</div>
</Dialog>
@@ -550,6 +550,7 @@ function QuickDoc({ node, anchor }: { node: DbNode; anchor: { x: number; y: numb
switch (node.kind) {
case "source":
rows.push(["Driver", sourceLabel(node.source).replace(/^.*\((.*)\)$/, "$1")]);
if (node.source.comment?.trim()) rows.push(["Comment", node.source.comment.trim()]);
rows.push(
node.source.driver === "sqlite"
? ["File", node.source.file ?? ""]
+935
Переглянути файл
@@ -0,0 +1,935 @@
/**
* 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 DbSshAuth,
type DbSslMode,
type DbTxMode,
} from "@/platform/ipc";
import { useTx, useTxTemplate } from "@/platform/i18n";
import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui";
import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model";
import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors";
import styles from "./Database.module.css";
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<SelectOption<DbColor | "none">> = [
{ 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<SelectOption<DbSslMode>> = [
{ 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<SelectOption<DbTxMode>> = [
{ value: "auto", label: "Auto" },
{ value: "manual", label: "Manual" },
];
const SSH_AUTH: Array<SelectOption<DbSshAuth>> = [
{ 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<string, { names: string[]; hint: string }> = {
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).",
},
};
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,
});
const blankIntrospection = (): DbIntrospection => ({
allDatabases: true,
databases: [],
allSchemas: true,
schemas: [],
showSystem: false,
});
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<string | null> {
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 (
<>
<label className={styles.formLabel} htmlFor={id}>
<T>{label}</T>
</label>
<div className={styles.formRow}>
<Input
id={id}
className={styles.fill}
disabled={disabled}
value={value}
placeholder={placeholder}
onChange={(e) => onChange(e.target.value)}
/>
<Button
disabled={disabled}
onClick={() =>
void pickFile(title).then((f) => {
if (f) onChange(f);
})
}
>
<T>Browse…</T>
</Button>
</div>
</>
);
}
/** 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 (
<>
<span />
<div className={styles.formRow}>
<Checkbox checked={on} onChange={(c) => onChange(c ? (parseSecs(draft) ?? fallback) : null)} label={label} />
<Input
className={styles.secondsInput}
inputMode="numeric"
aria-label={`${label} (seconds)`}
disabled={!on}
value={draft}
onChange={(e) => {
setDraft(e.target.value);
const n = parseSecs(e.target.value);
if (n) onChange(n);
}}
/>
<span>
<T>sec.</T>
</span>
</div>
</>
);
}
/** Schemas tab: "All …" or a choice of names and `*` / `?` patterns. */
function NamePicker({
title,
all,
chosen,
known,
onAll,
onChosen,
}: {
title: 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 (
<div className={styles.namePicker}>
<Checkbox checked={all} onChange={onAll} label={`All ${title}`} />
{!all && (
<div className={styles.nameList}>
{names.map((n) => (
<Checkbox
key={n}
checked={chosen.includes(n)}
onChange={(c) => onChosen(c ? [...chosen, n] : chosen.filter((x) => x !== n))}
label={n}
/>
))}
<div className={styles.formRow}>
<Input
className={styles.fill}
value={pattern}
placeholder="Name or pattern, e.g. app_*"
aria-label={`Add ${title} pattern`}
onChange={(e) => setPattern(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.nativeEvent.isComposing) {
e.preventDefault();
add();
}
}}
/>
<Button disabled={!pattern.trim()} onClick={add}>
<T>Add Pattern</T>
</Button>
</div>
</div>
)}
</div>
);
}
function Section({ title }: { title: string }) {
return (
<div className={styles.formSection}>
<T>{title}</T>
</div>
);
}
export function SourceDialog({
initial,
preset,
presetPassword,
}: {
initial: DataSource | null;
preset?: Partial<DataSource>;
presetPassword?: string | null;
}) {
const tx = useTx();
const txTemplate = useTxTemplate();
const [s, setS] = useState<DataSource>(() => ({ ...blank(), ...preset, ...initial }));
// `undefined`: unchanged (editing keeps the stored password).
const [password, setPassword] = useState<string | undefined>(presetPassword ?? undefined);
// Same convention for the SSH password / passphrase.
const [sshSecret, setSshSecret] = useState<string | undefined>(undefined);
const [tab, setTab] = useState<Tab>("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<string | null>(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<DataSource>) => setS((x) => ({ ...x, ...patch }));
const ssh: DbSsh = { ...blankSsh(), ...s.ssh };
const setSsh = (patch: Partial<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
const options: DbOptions = { ...blankOptions(), ...s.options };
const setOptions = (patch: Partial<DbOptions>) => setS((x) => ({ ...x, options: { ...blankOptions(), ...x.options, ...patch } }));
const intro: DbIntrospection = { ...blankIntrospection(), ...s.introspection };
const setIntro = (patch: Partial<DbIntrospection>) =>
setS((x) => ({ ...x, introspection: { ...blankIntrospection(), ...x.introspection, ...patch } }));
const properties = s.properties ?? [];
const setProperty = (i: number, patch: Partial<DbProperty>) =>
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 || !!ssh.host?.trim());
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: `Succeeded: ${v}` });
} catch (e) {
const text = e instanceof Error ? e.message : String(e);
const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey";
setTest({ ok: false, text, trust });
} finally {
setBusy(false);
}
};
const trustAndRetry = async () => {
await call(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 || ssh.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 = (
<>
<span className={styles.formLabel}>
<T>Driver:</T>
</span>
<Select aria-label="Data source type" value={vendor.id} options={TYPES} onChange={setType} />
{sqlite ? (
<FileField
id="db-file"
label="File:"
value={s.file ?? ""}
placeholder="path/to/database.db (absolute or project-relative)"
title="SQLite Database File"
onChange={(file) => set({ file: file || null })}
/>
) : (
<>
<span className={styles.formLabel}>
<T>Connection type:</T>
</span>
<div className={styles.segmented} role="group" aria-label={tx("Connection type")}>
<button type="button" aria-pressed={!urlOnly} onClick={() => setUrlOnly(false)}>
<T>default</T>
</button>
<button type="button" aria-pressed={urlOnly} onClick={() => setUrlOnly(true)}>
<T>URL only</T>
</button>
</div>
{!urlOnly && (
<>
<label className={styles.formLabel} htmlFor="db-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input id="db-host" className={styles.fill} value={s.host ?? ""} onChange={(e) => set({ host: e.target.value })} />
<label className={styles.formLabel} htmlFor="db-port">
<T>Port:</T>
</label>
<Input
id="db-port"
className={styles.portInput}
inputMode="numeric"
value={s.port ?? ""}
placeholder={String(vendor.port ?? "")}
onChange={(e) => set({ port: parsePort(e.target.value) })}
/>
</div>
</>
)}
{sshOn && (
<>
<span />
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
</>
)}
<label className={styles.formLabel} htmlFor="db-user">
<T>User:</T>
</label>
<Input
id="db-user"
value={s.user ?? ""}
placeholder={urlOnly ? "from the URL" : ""}
onChange={(e) => set({ user: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-password">
<T>Password:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-password"
className={styles.fill}
type="password"
value={password ?? ""}
placeholder={initial && password === undefined ? "<unchanged>" : ""}
onChange={(e) => setPassword(e.target.value)}
/>
<Checkbox checked={!!s.savePassword} onChange={(savePassword) => set({ savePassword })} label="Save password" />
</div>
{!urlOnly && (
<>
<label className={styles.formLabel} htmlFor="db-database">
<T>Database:</T>
</label>
<Input id="db-database" value={s.database ?? ""} onChange={(e) => set({ database: e.target.value })} />
</>
)}
<label className={styles.formLabel} htmlFor="db-url">
<T>URL:</T>
</label>
<Input
id="db-url"
mono
value={urlOnly ? (s.url ?? "") : (urlDraft ?? sourceUrl(s))}
invalid={urlOnly ? !urlValid : urlDraft !== null && parseSourceUrl(urlDraft)?.source.driver !== s.driver}
placeholder={sourceUrl(s)}
onChange={(e) => (urlOnly ? set({ url: e.target.value }) : typeUrl(e.target.value))}
onBlur={() => setUrlDraft(null)}
/>
<span />
<span className={styles.formHint}>
{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.",
)}
</span>
</>
)}
</>
);
const optionsTab = (
<>
<Section title="Connection" />
<span />
<Checkbox checked={!!s.readOnly} onChange={(readOnly) => set({ readOnly })} label="Read-only" />
<span className={styles.formLabel}>
<T>Transaction control:</T>
</span>
<Select
aria-label="Transaction control"
className={styles.narrowSelect}
value={options.transaction ?? "auto"}
options={TX}
onChange={(transaction) => setOptions({ transaction })}
/>
{postgres && (
<>
<label className={styles.formLabel} htmlFor="db-default-schema">
<T>Default schema:</T>
</label>
<Input
id="db-default-schema"
value={options.defaultSchema ?? ""}
placeholder="public (search_path, e.g. app, public)"
onChange={(e) => setOptions({ defaultSchema: e.target.value })}
/>
</>
)}
{!sqlite && (
<>
<label className={styles.formLabel} htmlFor="db-time-zone">
<T>Time zone:</T>
</label>
<Input
id="db-time-zone"
value={options.timeZone ?? ""}
placeholder={postgres ? "Server default, e.g. UTC or Europe/Berlin" : "UTC (+00:00), e.g. Europe/Berlin or +08:00"}
onChange={(e) => setOptions({ timeZone: e.target.value })}
/>
<SecondsOption
label="Run keep-alive query each"
value={options.keepAliveSecs}
fallback={60}
onChange={(keepAliveSecs) => setOptions({ keepAliveSecs })}
/>
</>
)}
<SecondsOption
label="Auto-disconnect after"
value={options.autoDisconnectSecs}
fallback={300}
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
/>
<label className={styles.formLabel} htmlFor="db-startup">
<T>Startup script:</T>
</label>
<textarea
id="db-startup"
className={styles.textArea}
spellCheck={false}
value={options.startupScript ?? ""}
placeholder={postgres ? "SET statement_timeout = '30s';" : sqlite ? "PRAGMA foreign_keys = ON;" : "SET SESSION sql_mode = 'ANSI';"}
onChange={(e) => setOptions({ startupScript: e.target.value })}
/>
<span />
<span className={styles.formHint}>
<T>Runs on every new connection, before any other statement.</T>
</span>
</>
);
const sshTab = (
<>
<span />
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
<label className={styles.formLabel} htmlFor="db-ssh-host">
<T>Host:</T>
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-host"
className={styles.fill}
disabled={!sshOn}
value={ssh.host ?? ""}
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
onChange={(e) => setSsh({ host: e.target.value })}
/>
<label className={styles.formLabel} htmlFor="db-ssh-port">
<T>Port:</T>
</label>
<Input
id="db-ssh-port"
className={styles.portInput}
inputMode="numeric"
disabled={!sshOn}
value={ssh.port ?? ""}
placeholder="22"
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
/>
</div>
<label className={styles.formLabel} htmlFor="db-ssh-user">
<T>User:</T>
</label>
<Input
id="db-ssh-user"
disabled={!sshOn}
value={ssh.user ?? ""}
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
onChange={(e) => setSsh({ user: e.target.value })}
/>
<span className={styles.formLabel}>
<T>Authentication:</T>
</span>
<Select
aria-label="SSH authentication type"
disabled={!sshOn}
value={ssh.auth ?? "openSsh"}
options={SSH_AUTH}
onChange={(auth) => setSsh({ auth })}
/>
{ssh.auth === "keyPair" && (
<FileField
id="db-ssh-key"
label="Private key:"
value={ssh.keyFile ?? ""}
placeholder="~/.ssh/id_ed25519"
disabled={!sshOn}
title="Private Key File"
onChange={(keyFile) => setSsh({ keyFile })}
/>
)}
{ssh.auth === "openSsh" ? (
<>
<span />
<span className={styles.formHint}>
<T>{"Uses HostName, User, Port, IdentityFile and ProxyJump from ~/.ssh/config, and keys of the SSH agent."}</T>
</span>
</>
) : (
<>
<label className={styles.formLabel} htmlFor="db-ssh-secret">
{tx(ssh.auth === "password" ? "Password:" : "Passphrase:")}
</label>
<div className={styles.formRow}>
<Input
id="db-ssh-secret"
className={styles.fill}
type="password"
disabled={!sshOn}
value={sshSecret ?? ""}
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
onChange={(e) => setSshSecret(e.target.value)}
/>
<Checkbox
checked={!!ssh.saveSecret}
disabled={!sshOn}
onChange={(saveSecret) => setSsh({ saveSecret })}
label="Save password"
/>
</div>
</>
)}
<Section title="SSL" />
<span className={styles.formLabel}>
<T>Mode:</T>
</span>
<Select
aria-label="SSL mode"
className={styles.narrowSelect}
value={s.ssl ?? "prefer"}
options={SSL}
onChange={(ssl) => set({ ssl })}
/>
<FileField
id="db-ssl-ca"
label="CA file:"
value={s.sslCaFile ?? ""}
placeholder={s.ssl === "verifyCa" || s.ssl === "verifyFull" ? "System roots when empty" : ""}
disabled={!sslOn}
title="CA Certificate File"
onChange={(sslCaFile) => set({ sslCaFile })}
/>
<FileField
id="db-ssl-cert"
label="Client certificate file:"
value={s.sslCertFile ?? ""}
disabled={!sslOn}
title="Client Certificate File"
onChange={(sslCertFile) => set({ sslCertFile })}
/>
<FileField
id="db-ssl-key"
label="Client key file:"
value={s.sslKeyFile ?? ""}
disabled={!sslOn}
title="Client Key File"
onChange={(sslKeyFile) => set({ sslKeyFile })}
/>
</>
);
const knownDbs = loadedDbs?.status === "ok" ? loadedDbs.value.names : [];
const knownSchemas = loadedSchemas?.status === "ok" ? loadedSchemas.value : [];
const schemasTab = (
<>
<div className={styles.formWide}>
{postgres && (
<NamePicker
title="databases"
all={intro.allDatabases !== false}
chosen={intro.databases ?? []}
known={knownDbs}
onAll={(allDatabases) => setIntro({ allDatabases })}
onChosen={(databases) => setIntro({ databases })}
/>
)}
<NamePicker
title={s.driver === "mysql" ? "schemas (databases)" : "schemas"}
all={intro.allSchemas !== false}
chosen={intro.schemas ?? []}
known={knownSchemas}
onAll={(allSchemas) => setIntro({ allSchemas })}
onChosen={(schemas) => setIntro({ schemas })}
/>
<Checkbox
checked={!!intro.showSystem}
onChange={(showSystem) => setIntro({ showSystem })}
label={postgres ? "Show system schemas and template databases" : "Show system schemas"}
/>
<div className={styles.formRow}>
<Button disabled={!initial?.id || busy} onClick={() => void loadNames()}>
<T>Load Names from Server</T>
</Button>
<span className={styles.formHint}>
{initial?.id
? "The Database tree shows only the checked names and patterns (* and ?); the connected database always shows."
: "Save the data source to pick from the server's names; patterns (* and ?) work any time."}
</span>
</div>
</div>
</>
);
const advancedTab = (
<div className={styles.formWide}>
<div className={styles.propertyTable} role="table" aria-label={tx("Driver properties")}>
<div role="row" className={styles.propertyHead}>
<span role="columnheader">
<T>Name</T>
</span>
<span role="columnheader">
<T>Value</T>
</span>
<span />
</div>
{properties.map((p, i) => (
<div role="row" key={i} className={styles.propertyRow}>
<Input
aria-label="Property name"
list="db-property-names"
value={p.name ?? ""}
placeholder="<user defined>"
onChange={(e) => setProperty(i, { name: e.target.value })}
/>
<Input aria-label="Property value" value={p.value ?? ""} placeholder="<value>" onChange={(e) => setProperty(i, { value: e.target.value })} />
<IconButton
icon="close"
size="small"
aria-label="Remove property"
title="Remove"
onClick={() => set({ properties: properties.filter((_, j) => j !== i) })}
/>
</div>
))}
</div>
<datalist id="db-property-names">
{props.names.map((n) => (
<option key={n} value={n} />
))}
</datalist>
<div className={styles.formRow}>
<Button icon="plus" onClick={() => set({ properties: [...properties, { name: "", value: "" }] })}>
<T>Add Property</T>
</Button>
</div>
<span className={styles.formHint}>
<T>{props.hint}</T>
</span>
</div>
);
const body: Record<Tab, ReactNode> = {
general,
options: optionsTab,
ssh: sshTab,
schemas: schemasTab,
advanced: advancedTab,
};
return (
<Dialog
open
onClose={closeDialog}
title={initial ? `Data Source: ${initial.name}` : "New Data Source"}
width={640}
footerStart={
<Button onClick={() => void runTest()} disabled={busy || !valid}>
<T>Test Connection</T>
</Button>
}
buttons={[
{ label: "Cancel", role: "cancel" },
{ label: "OK", role: "default", disabled: busy || !valid, onClick: () => void save() },
]}
>
<div className={styles.form}>
<label className={styles.formLabel} htmlFor="db-name">
<T>Name:</T>
</label>
<div className={styles.formRow}>
<Input id="db-name" className={styles.fill} value={s.name ?? ""} placeholder={name} onChange={(e) => set({ name: e.target.value })} />
<span className={styles.formLabel}>
<T>Color:</T>
</span>
<Select
aria-label="Color"
className={styles.narrowSelect}
value={s.color ?? "none"}
options={COLORS}
onChange={(c) => set({ color: c === "none" ? null : c })}
renderValue={(o) => (
<span className={styles.colorValue}>
{o && o.value !== "none" && <span className={styles.colorSwatch} style={{ background: colorVar(o.value as DbColor) }} />}
<T>{o?.label ?? "No color"}</T>
</span>
)}
/>
</div>
<span />
{showComment ? (
<textarea
className={styles.textArea}
aria-label={tx("Comment")}
autoFocus={!s.comment}
rows={2}
value={s.comment ?? ""}
placeholder={tx("Comment")}
onChange={(e) => set({ comment: e.target.value })}
/>
) : (
<button type="button" className={styles.linkButton} onClick={() => setShowComment(true)}>
<T>Add comment</T>
</button>
)}
</div>
<Tabs
variant="compact"
className={styles.dialogTabs}
activeId={current}
onSelect={(id) => setTab(id as Tab)}
aria-label="Data source settings"
panelIdPrefix="db-source"
items={tabs}
/>
<div className={styles.form} role="tabpanel" id={`db-source-panel-${current}`}>
{body[current]}
<div className={`${styles.testResult} ${test?.ok ? styles.ok : styles.bad}`} role="status">
{busy && !test ? tx("Connecting…") : test?.text}
{test?.trust && (
<>
{" "}
<Button size="small" onClick={() => void trustAndRetry()} disabled={busy}>
<T>Trust Host and Retry</T>
</Button>
</>
)}
</div>
</div>
</Dialog>
);
}
+103 -1
Переглянути файл
@@ -1,6 +1,23 @@
import { describe, expect, it } from "vitest";
import { exportData, type ExportData } from "./export";
import { buildRowEdits, connectRemedy, emptyEdits, hasEdits, parseSourceUrl, sourceLabel, splitTarget, sshLabel, targetOf, toCsv, toTsv } from "./model";
import {
buildRowEdits,
colorVar,
connectRemedy,
emptyEdits,
hasEdits,
idleTargets,
matchesPattern,
parseSourceUrl,
shownNames,
sourceLabel,
splitTarget,
sshLabel,
targetOf,
toCsv,
toTsv,
} from "./model";
import { VENDORS, sourceUrl, vendorById, vendorOf, vendorPreset } from "./vendors";
describe("clipboard formats", () => {
it("copies TSV with NULL as empty and whitespace flattened", () => {
@@ -131,3 +148,88 @@ describe("parseSourceUrl", () => {
expect(parseSourceUrl("oracle://x")).toBeNull();
});
});
describe("data source types", () => {
it("presets wire-compatible products on the existing drivers", () => {
expect(vendorPreset(vendorById("cockroachdb")!)).toEqual({ driver: "postgres", vendor: "cockroachdb", port: 26257 });
expect(vendorPreset(vendorById("postgres")!)).toEqual({ driver: "postgres", vendor: "", port: 5432 });
expect(vendorPreset(vendorById("supabase")!).ssl).toBe("require");
expect(VENDORS.every((v) => ["postgres", "mysql", "sqlite"].includes(v.driver))).toBe(true);
const labels = VENDORS.map((v) => v.label);
expect(labels).toEqual([...labels].sort((a, b) => a.localeCompare(b)));
});
it("falls back to the driver for unknown or mismatched presets", () => {
expect(vendorOf({ driver: "mysql", vendor: "tidb" }).label).toBe("TiDB");
expect(vendorOf({ driver: "postgres", vendor: "tidb" }).id).toBe("postgres");
expect(vendorOf({ driver: "mysql" }).id).toBe("mysql");
expect(sourceLabel({ name: "tidb", driver: "mysql", vendor: "tidb" })).toBe("tidb (TiDB)");
});
it("previews the JDBC URL of the fields", () => {
expect(sourceUrl({ driver: "postgres", host: "localhost", port: 5432, database: "postgres" })).toBe(
"jdbc:postgresql://localhost:5432/postgres",
);
expect(sourceUrl({ driver: "postgres", vendor: "redshift", host: "r", port: null, database: "" })).toBe("jdbc:redshift://r:5439");
expect(sourceUrl({ driver: "mysql", vendor: "mariadb", host: "::1", port: 3307, database: "a b" })).toBe(
"jdbc:mariadb://[::1]:3307/a%20b",
);
expect(sourceUrl({ driver: "sqlite", file: "app.db" })).toBe("jdbc:sqlite:app.db");
});
it("reads URLs of compatible products, SSL settings and driver properties", () => {
const tidb = parseSourceUrl("jdbc:tidb://[email protected]:4000/test?sslmode=verify-full&sslrootcert=/ca.pem&sql_mode=ANSI");
expect(tidb?.source).toEqual({
driver: "mysql",
vendor: "tidb",
host: "tidb.local",
port: 4000,
user: "root",
database: "test",
ssl: "verifyFull",
sslCaFile: "/ca.pem",
properties: [{ name: "sql_mode", value: "ANSI" }],
});
expect(parseSourceUrl("redshift://r.example:5439/dev")?.source.vendor).toBe("redshift");
expect(parseSourceUrl("postgresql://h:70000/db")).toBeNull();
});
});
describe("Schemas tab", () => {
it("matches names and * / ? patterns", () => {
expect(matchesPattern("app_1", "app_*")).toBe(true);
expect(matchesPattern("app.1", "app?1")).toBe(true);
expect(matchesPattern("apps", "app")).toBe(false);
expect(matchesPattern("a.b", "a.b")).toBe(true);
});
it("shows all names, or the chosen ones and the connected database", () => {
const names = ["app", "app_test", "billing", "postgres"];
expect(shownNames(names, undefined, undefined)).toEqual(names);
expect(shownNames(names, true, ["billing"])).toEqual(names);
expect(shownNames(names, false, ["app*"], "postgres")).toEqual(["app", "app_test", "postgres"]);
expect(shownNames(names, false, [])).toEqual([]);
});
});
describe("Options tab", () => {
it("finds connections idle past their auto-disconnect", () => {
const sources = [
{ id: "a", options: { autoDisconnectSecs: 60 } },
{ id: "b", options: { autoDisconnectSecs: null } },
];
const used = new Map([
["a", 0],
["a:other", 50_000],
["b", 0],
]);
const idle = (busy: string[]) => idleTargets(["a", "a:other", "b", "c"], sources, used, (t) => busy.includes(t), 60_000);
expect(idle([])).toEqual(["a"]);
expect(idle(["a"])).toEqual([]);
});
it("names the colour tokens", () => {
expect(colorVar("red")).toBe("var(--db-color-red)");
expect(colorVar(null)).toBeUndefined();
});
});
+16 -1
Переглянути файл
@@ -8,7 +8,14 @@ import { lazyComponent } from "@/ui";
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { useProject } from "@/platform/project";
import { registerEditorKind, registerOverlay, registerServiceView, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import {
registerEditorKind,
registerOverlay,
registerServiceView,
registerToolWindow,
setFileDecorations,
toolWindowService,
} from "@/workbench/contributions";
import { SQL_LANGUAGE, consoleExtension, executeInView } from "./console";
import { DatabaseDialogs } from "./Dialogs";
import { TABLE_KIND, tableTitle } from "./tableInput";
@@ -18,10 +25,12 @@ import {
DB_TOOL_WINDOW,
RESULTS_VIEW,
consoleSource,
consoleTints,
loadSources,
openConsole,
openSourceDialog,
useDatabase,
watchIdleConnections,
} from "./store";
export function activate(): () => void {
@@ -72,6 +81,12 @@ export function activate(): () => void {
newMenuOpen: false,
});
}),
// Consoles of coloured data sources get a tinted tab.
useDatabase.subscribe((s, prev) => {
if (s.consoles !== prev.consoles || s.sources !== prev.sources) setFileDecorations("database", consoleTints(s));
}),
() => setFileDecorations("database", new Map()),
watchIdleConnections(),
registerDatabaseTreeActions(),
registerActions([
{
+110 -20
Переглянути файл
@@ -1,5 +1,6 @@
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
import type { DbCellValue, DbDriver, DbRowEdit, DataSource } from "@/platform/ipc";
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSslMode, DataSource } from "@/platform/ipc";
import { vendorOf, vendorOfScheme } from "./vendors";
export type Cell = string | null;
@@ -62,14 +63,55 @@ export function fileSourceName(file: string): string {
return base.replace(/\.[^.]+$/, "") || base;
}
/** The tree / menu icon of a driver (IDEA shows the vendor's mark). */
export function driverIcon(driver: DbDriver | null | undefined): string {
return driver === "postgres" ? "db-postgres" : driver === "mysql" ? "db-mysql" : "db-sqlite";
/** The tree / menu icon of a data source (IDEA shows the vendor's mark). */
export function sourceIcon(s: Pick<DataSource, "vendor" | "driver">): string {
return vendorOf(s).icon;
}
export function sourceLabel(s: Pick<DataSource, "name" | "driver">): string {
const kind = s.driver === "postgres" ? "PostgreSQL" : s.driver === "mysql" ? "MySQL" : "SQLite";
return `${s.name} (${kind})`;
export function sourceLabel(s: Pick<DataSource, "name" | "driver" | "vendor">): string {
return `${s.name} (${vendorOf(s).label})`;
}
/** The token of a data source colour (tree rows, console tab). */
export function colorVar(color: DbColor | null | undefined): string | undefined {
return color ? `var(--db-color-${color})` : undefined;
}
/** `*` / `?` wildcard match of the Schemas tab (case-sensitive, like the server's names). */
export function matchesPattern(name: string, pattern: string): boolean {
if (!/[*?]/.test(pattern)) return name === pattern;
const re = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*").replace(/\?/g, ".");
return new RegExp(`^${re}$`).test(name);
}
/**
* The databases / schemas the tree shows (Schemas tab): all of them, or the
* ones matching a chosen name or pattern; `keep` (the connected database) always stays.
*/
export function shownNames(names: readonly string[], all: boolean | undefined, chosen: readonly string[] | undefined, keep?: string | null): string[] {
if (all !== false) return [...names];
const picks = (chosen ?? []).map((p) => p.trim()).filter(Boolean);
return names.filter((n) => n === keep || picks.some((p) => matchesPattern(n, p)));
}
/**
* Connected targets idle for longer than their data source's Auto-disconnect
* (`lastUsed`: ms of the last statement / introspection; `busy`: running or holding a transaction).
*/
export function idleTargets(
connected: Iterable<string>,
sources: readonly DataSource[],
lastUsed: ReadonlyMap<string, number>,
busy: (target: string) => boolean,
now: number,
): string[] {
const out: string[] = [];
for (const t of connected) {
const secs = sources.find((s) => s.id === splitTarget(t).id)?.options?.autoDisconnectSecs;
if (!secs || busy(t)) continue;
if (now - (lastUsed.get(t) ?? now) >= secs * 1000) out.push(t);
}
return out;
}
export function newSourceId(): string {
@@ -120,20 +162,36 @@ export function targetOf(id: string, database: string | null, current: string |
/** Key of the Database tree node of database `name` of data source `id`. */
export const databaseKey = (id: string, name: string) => `${id}/@${name}`;
const SSL_MODES: Record<string, DbSslMode> = {
disable: "disable",
disabled: "disable",
prefer: "prefer",
preferred: "prefer",
require: "require",
required: "require",
"verify-ca": "verifyCa",
verify_ca: "verifyCa",
"verify-full": "verifyFull",
verify_identity: "verifyFull",
"verify-identity": "verifyFull",
};
/**
* Data Source from URL: `postgres://user:pass@host:5432/db`, `mysql://…`,
* `mariadb://…`, the `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`.
* Null when the URL names no supported driver.
* Data Source from URL (and the General tab's URL field): `postgres://user:pass@host:5432/db`,
* `mysql://…`, `mariadb://…`, wire-compatible products (`redshift://`, `tidb://`…), the
* `jdbc:` forms of those, `jdbc:sqlite:path` / `sqlite:path`. SSL parameters fill the
* SSL tab, other parameters become driver properties. Null when the URL names no supported driver.
*/
export function parseSourceUrl(raw: string): { source: Partial<DataSource>; password: string | null } | null {
const url = raw.trim().replace(/^jdbc:/i, "");
const sqlite = /^sqlite:(?:\/\/)?(.+)$/i.exec(url);
const sqlite = /^sqlite3?:(?:\/\/)?(.+)$/i.exec(url);
if (sqlite) {
const file = sqlite[1]!.replace(/^\/([A-Za-z]:)/, "$1");
return { source: { driver: "sqlite", file, name: fileSourceName(file) }, password: null };
}
const m = /^(postgres(?:ql)?|mysql|mariadb):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?]+|\[[^\]]+\])(?::(\d+))?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
if (!m) return null;
const m = /^([a-z][a-z0-9]*):\/\/(?:([^:@/]*)(?::([^@/]*))?@)?([^:/?,]+|\[[^\]]+\])(?::(\d+))?(?:,[^/?]*)?(?:\/([^?]*))?(?:\?(.*))?$/i.exec(url);
const vendor = m ? vendorOfScheme(m[1]!) : undefined;
if (!m || !vendor || vendor.driver === "sqlite") return null;
const dec = (s: string | undefined) => {
try {
return s ? decodeURIComponent(s) : "";
@@ -141,13 +199,45 @@ export function parseSourceUrl(raw: string): { source: Partial<DataSource>; pass
return s ?? "";
}
};
const driver = m[1]!.toLowerCase().startsWith("postgres") ? "postgres" : "mysql";
const params = new URLSearchParams(m[7] ?? "");
const user = dec(m[2]) || params.get("user") || "";
const password = m[3] !== undefined ? dec(m[3]) : params.get("password");
const port = m[5] ? parseInt(m[5], 10) : null;
const source: Partial<DataSource> = { driver, host: m[4]!, port, user, database: dec(m[6]) };
const ssl = params.get("sslmode")?.toLowerCase();
if (ssl === "disable" || ssl === "require" || ssl === "prefer") source.ssl = ssl;
if (port !== null && !(port > 0 && port < 65536)) return null;
let user = dec(m[2]);
let password = m[3] !== undefined ? dec(m[3]) : null;
const host = m[4]!.replace(/^\[(.*)\]$/, "$1");
const source: Partial<DataSource> = { driver: vendor.driver, host, port, user, database: dec(m[6]).replace(/\/$/, "") };
if (vendor.id !== vendor.driver) source.vendor = vendor.id;
const properties: DbProperty[] = [];
for (const [k, v] of new URLSearchParams(m[7] ?? "")) {
switch (k.toLowerCase()) {
case "user":
user ||= v;
break;
case "password":
password ??= v;
break;
case "sslmode":
case "ssl-mode": {
const mode = SSL_MODES[v.toLowerCase()];
if (mode) source.ssl = mode;
break;
}
case "sslrootcert":
case "ssl-ca":
source.sslCaFile = v;
break;
case "sslcert":
case "ssl-cert":
source.sslCertFile = v;
break;
case "sslkey":
case "ssl-key":
source.sslKeyFile = v;
break;
default:
properties.push({ name: k, value: v });
}
}
source.user = user;
if (properties.length) source.properties = properties;
return { source, password };
}
+40 -3
Переглянути файл
@@ -20,9 +20,9 @@ import {
type SqlStatement,
} from "@/platform/ipc";
import { editorsFor } from "@/editor/api";
import { editorService, notifications, showServiceView, toolWindowService } from "@/workbench/contributions";
import { editorService, notifications, showServiceView, toolWindowService, type FileDecoration } from "@/workbench/contributions";
import { ask, confirm } from "@/workbench/messages";
import { connectRemedy, databaseKey, newSourceId, splitTarget } from "./model";
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
import type { DbNode } from "./nodes";
import type { DbObjectRef } from "./objects";
import { formatted } from "./sql/format";
@@ -321,13 +321,25 @@ export async function resolveConnectError(e: unknown, source: DataSource): Promi
}
}
/** Target → time of its last use (Options › Auto-disconnect). */
const lastUsed = new Map<string, number>();
/** Options › Transaction control: a manual data source's consoles start in manual commit. */
function applyConnectDefaults(id: string) {
if (sourceById(id)?.options?.transaction === "manual" && !useDatabase.getState().manualCommit.has(id))
void setManualCommit(id, true).catch(() => {});
}
/** Runs `op`, asking for passwords / host key trust and retrying when the connection needs them. */
export async function withConnection<T>(id: string, op: () => Promise<T>): Promise<T> {
for (;;) {
try {
const r = await op();
if (!useDatabase.getState().connected.has(id))
lastUsed.set(id, Date.now());
if (!useDatabase.getState().connected.has(id)) {
useDatabase.setState((s) => ({ connected: new Set(s.connected).add(id) }));
applyConnectDefaults(id);
}
return r;
} catch (e) {
const source = sourceById(id);
@@ -360,6 +372,31 @@ export async function disconnect(id: string): Promise<void> {
});
}
/** Disconnects targets idle past their data source's Auto-disconnect; returns the stopper. */
export function watchIdleConnections(everyMs = 5000): () => void {
const timer = setInterval(() => {
const s = useDatabase.getState();
const running = new Set(Object.keys(s.running).map((path) => s.consoles[path]));
const busy = (t: string) => running.has(t) || s.pendingTx.has(t);
for (const t of idleTargets(s.connected, s.sources, lastUsed, busy, Date.now())) {
lastUsed.delete(t);
void disconnect(t).then(() => log("info", `${targetLabel(t)}: disconnected after being idle`));
}
}, everyMs);
return () => clearInterval(timer);
}
/** Console files of coloured data sources, tinted like IDEA's (file decorations). */
export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Map<string, FileDecoration> {
const out = new Map<string, FileDecoration>();
for (const [path, target] of Object.entries(s.consoles)) {
const id = splitTarget(target).id;
const tint = colorVar(s.sources.find((x) => x.id === id)?.color);
if (tint) out.set(path, { tint });
}
return out;
}
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return source?.driver === "postgres";
+4 -9
Переглянути файл
@@ -1,7 +1,7 @@
/** Menus of the Database tool window, laid out like IDEA's (New ▸, Copy/Paste ▸, SQL Scripts ▸ …). */
import type { DbDriver } from "@/platform/ipc";
import type { MenuItem } from "@/ui";
import { driverIcon, fileSourceName } from "./model";
import { fileSourceName } from "./model";
import { VENDORS, vendorPreset } from "./vendors";
import { nodeTarget, objectOf, tableOf, type DbNode } from "./nodes";
import { SCRIPT_LABELS, canDrop, canRename, type ScriptKind } from "./objects";
import { connectedTargets, openDialog, openSourceDialog, sourceById, useDatabase } from "./store";
@@ -21,18 +21,13 @@ async function sqliteFromFile() {
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
/** New ▸ Data Source ▸ — the drivers the backend speaks. */
/** New ▸ Data Source ▸ — the drivers the backend speaks and wire-compatible products, A–Z like IDEA's. */
function dataSourceItems(): MenuItem[] {
const preset = (label: string, driver: DbDriver): MenuItem => ({
label,
icon: driverIcon(driver),
run: () => openSourceDialog(null, { driver }),
});
return [
{
label: "Data Source",
icon: "database",
submenu: [preset("MariaDB", "mysql"), preset("MySQL", "mysql"), preset("PostgreSQL", "postgres"), preset("SQLite", "sqlite")],
submenu: VENDORS.map((v) => ({ label: v.label, icon: v.icon, run: () => openSourceDialog(null, vendorPreset(v)) })),
},
{ label: "Data Source from File…", icon: "folder", run: () => void sqliteFromFile() },
{ label: "Data Source from URL…", icon: "external-link", run: () => openDialog({ kind: "sourceUrl" }) },
+86
Переглянути файл
@@ -0,0 +1,86 @@
/**
* Data source types of New ▸ Data Source: the three drivers the backend speaks,
* and products that speak their wire protocol (a preset of port, icon and URL scheme).
*/
import type { DataSource, DbDriver, DbSslMode } from "@/platform/ipc";
export interface DbVendor {
/** Stored in `DataSource.vendor`; a driver's own entry has the driver's id. */
id: string;
label: string;
driver: DbDriver;
port: number | null;
icon: string;
/** JDBC URL scheme (`jdbc:<scheme>://…`). */
scheme: string;
/** Managed services that only accept encrypted connections. */
ssl?: DbSslMode;
}
export const VENDORS: readonly DbVendor[] = [
{ id: "aurora-mysql", label: "Amazon Aurora MySQL", driver: "mysql", port: 3306, icon: "db-aurora", scheme: "mysql" },
{ id: "redshift", label: "Amazon Redshift", driver: "postgres", port: 5439, icon: "db-redshift", scheme: "redshift", ssl: "require" },
{ id: "cockroachdb", label: "CockroachDB", driver: "postgres", port: 26257, icon: "db-cockroach", scheme: "postgresql" },
{ id: "greenplum", label: "Greenplum", driver: "postgres", port: 5432, icon: "db-greenplum", scheme: "postgresql" },
{ id: "mariadb", label: "MariaDB", driver: "mysql", port: 3306, icon: "db-mariadb", scheme: "mariadb" },
{ id: "mysql", label: "MySQL", driver: "mysql", port: 3306, icon: "db-mysql", scheme: "mysql" },
{ id: "neon", label: "Neon", driver: "postgres", port: 5432, icon: "db-neon", scheme: "postgresql", ssl: "require" },
{ id: "oceanbase", label: "OceanBase (MySQL mode)", driver: "mysql", port: 2881, icon: "db-oceanbase", scheme: "oceanbase" },
{ id: "postgres", label: "PostgreSQL", driver: "postgres", port: 5432, icon: "db-postgres", scheme: "postgresql" },
{ id: "singlestore", label: "SingleStore", driver: "mysql", port: 3306, icon: "db-singlestore", scheme: "singlestore" },
{ id: "sqlite", label: "SQLite", driver: "sqlite", port: null, icon: "db-sqlite", scheme: "sqlite" },
{ id: "supabase", label: "Supabase", driver: "postgres", port: 5432, icon: "db-supabase", scheme: "postgresql", ssl: "require" },
{ id: "tidb", label: "TiDB", driver: "mysql", port: 4000, icon: "db-tidb", scheme: "mysql" },
{ id: "timescaledb", label: "TimescaleDB", driver: "postgres", port: 5432, icon: "db-timescale", scheme: "postgresql" },
{ id: "yugabytedb", label: "YugabyteDB", driver: "postgres", port: 5433, icon: "db-yugabyte", scheme: "postgresql" },
];
const BY_ID = new Map(VENDORS.map((v) => [v.id, v]));
/** The type of a data source: its preset, else its driver's own entry. */
export function vendorOf(s: Pick<DataSource, "vendor" | "driver">): DbVendor {
const v = s.vendor ? BY_ID.get(s.vendor) : undefined;
return v && v.driver === (s.driver ?? "sqlite") ? v : BY_ID.get(s.driver ?? "sqlite")!;
}
export const vendorById = (id: string): DbVendor | undefined => BY_ID.get(id);
/** The fields a new data source of type `v` starts with. */
export function vendorPreset(v: DbVendor): Partial<DataSource> {
const preset: Partial<DataSource> = { driver: v.driver, vendor: v.id === v.driver ? "" : v.id, port: v.port };
if (v.ssl) preset.ssl = v.ssl;
return preset;
}
/** URL schemes (besides the JDBC ones) that name a type. */
const SCHEMES: Record<string, string> = {
postgres: "postgres",
postgresql: "postgres",
pgsql: "postgres",
redshift: "redshift",
cockroachdb: "cockroachdb",
cockroach: "cockroachdb",
yugabytedb: "yugabytedb",
timescaledb: "timescaledb",
greenplum: "greenplum",
mysql: "mysql",
mariadb: "mariadb",
tidb: "tidb",
singlestore: "singlestore",
memsql: "singlestore",
oceanbase: "oceanbase",
};
export const vendorOfScheme = (scheme: string): DbVendor | undefined => BY_ID.get(SCHEMES[scheme.toLowerCase()] ?? "");
const bracketed = (host: string) => (host.includes(":") && !host.startsWith("[") ? `[${host}]` : host);
/** The JDBC URL of a data source's fields (the General tab's URL preview). */
export function sourceUrl(s: DataSource): string {
const v = vendorOf(s);
if (v.driver === "sqlite") return `jdbc:sqlite:${s.file ?? ""}`;
const host = bracketed(s.host?.trim() || "localhost");
const port = s.port ?? v.port;
const db = s.database ? `/${encodeURIComponent(s.database)}` : "";
return `jdbc:${v.scheme}://${host}${port ? `:${port}` : ""}${db}`;
}