дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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
Цей коміт міститься в:
1 джерело
294198a727
коміт
1e00ea5ee2
426 files changed
+85241
-2886
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? ""]
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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" }) },
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача