Files
gridline/src/hooks/useDbConnection.ts
T
adrianbonpin 0c74d77e75 v0.7.6: PostgreSQL object management, keychain toggle, tabbed Objects workspace (#12)
* [P1-T1] Change::Ddl Rust variant + execute_change arm

* [P1-T2] Frontend ddl change type + objectCrud capability

* [P1-T3] use_keychain data model + migration v8

* [P2-T1] object_crud skeleton + validators + build_ddl dispatch

* [P2-T2] Sequence builders

* [P2-T3] Enum builders (no value removal)

* [P2-T4] View / matview / extension builders

* [P2-T5] Index + constraint builders

* [P2-T6] Function / procedure + trigger builders

* [P2-T7] build_object_ddl + get_available_extensions commands + wrappers

* [P3-T1] conditional keychain + session passwords

* [P3-T2] keychain-off password prompt on connect

* [P3-T3] default-ON keychain opt-out + tooltip + modal conditional

* [P3-T4] ddl queue card + after-commit refetch

* [P4-T1] ObjectCrudDialog shell

* [P4-T2] SequenceForm

* [P4-T3] EnumForm with no-removal note

* [P4-T4] ExtensionForm with available-extensions picker

* [P4-T5] ViewForm (view + materialized view)

* [P5-T1] IndexForm with column picker

* [P5-T2] ConstraintForm (check/unique/pk/fk) + ColumnPicker

* [P5-T3] FunctionForm (function + procedure)

* [P5-T4] TriggerForm with trigger-function picker

* [P5-T5] ObjectContextMenu + Explorer/TableOverflowMenu CRUD wiring

* [P6-T1] object tab type + openObjectTab dedup

* [P6-T2] extract ObjectDetail for object tabs

* [P6-T3] Objects view two-pane sidebar + workspace

* [P6-T4] object-tab content + per-type tab icons

* [P7-T1] Version bump 0.7.5 -> 0.7.6

* [P7-T2] docs sync README/ROADMAP/AGENTS for v0.7.6

* [P7-T3] chore: Cargo.lock version sync 0.7.5 -> 0.7.6

* fix(ui): object tab icon stacks above name (preflight svg block)

* fix(ui): optically center object tab icon with name

* fix(ui): object tab icon matches query/table icon handling

* [UI-POLISH-1] objectForm tab type + openFormTab store action

* [UI-POLISH-2] ObjectFormTab + KindForm with Visual/SQL toggle

* [UI-POLISH-3] route create/edit through form tabs; remove modal

* docs: create/edit now open as form tabs (AGENTS sync)

* [FB-1] follow app styling patterns + schema dropdown in forms

* [FB-2] Monaco editor for function body + view definition

* [FB-3] form tabs styled like viewers + in-cell editing

* [FB-5] focus outline scoped to input area (label excluded)

* [FB-6] no amber focus outline on Monaco body/definition rows

* [FB-7] header dedupe + schema default + full edit prefill

* [FB-8] SQL view in read-only Monaco editor

* docs: roadmap — table create/edit + relationships (next)

* docs: roadmap — Admin follow-up is 0.7.7 (next after 0.7.6)
2026-08-06 22:56:49 +08:00

183 lines
7.0 KiB
TypeScript

import { useEffect, useCallback, useRef, useState } from "react";
import { useConnectionStore } from "../stores/connectionStore";
import { useDbViewerStore } from "../stores/dbViewerStore";
import { useNotificationStore } from "../stores/notificationStore";
import * as cmd from "../lib/commands";
import { pickDefaultSchema } from "../lib/utils";
import type { ConnectionInput, TableInfo } from "../lib/types";
export function useDbConnection(connectionId: string) {
const reset = useDbViewerStore((s) => s.reset);
const populate = useDbViewerStore((s) => s.populate);
const setSchemaTreeLoading = useDbViewerStore((s) => s.setSchemaTreeLoading);
const currentDatabase = useDbViewerStore((s) => s.currentDatabase);
const setCurrentDatabase = useDbViewerStore((s) => s.setCurrentDatabase);
const setCurrentSchema = useDbViewerStore((s) => s.setCurrentSchema);
const notify = useNotificationStore((s) => s.notify);
const [connectionError, setConnectionError] = useState<string | null>(null);
const [passwordPromptOpen, setPasswordPromptOpen] = useState(false);
const inputRef = useRef<ConnectionInput | null>(null);
// The database the pool is currently connected to. Unlike the selected
// `currentDatabase`, this lets us reconnect whenever the selection drifts
// from the live connection (including switching back to the first DB).
const connectedDbRef = useRef<string | null>(null);
const connect = useCallback(async () => {
const conn = useConnectionStore
.getState()
.connections.find((c) => c.id === connectionId);
if (!conn) {
setConnectionError("Connection not found");
return;
}
try {
const password = await useConnectionStore.getState().getConnectionPassword(conn.id).catch(() => null);
// Keychain-off + no session password: prompt the user instead of
// connecting with an empty password (early return, no Tauri call).
if (conn.use_keychain === false && !password) {
setPasswordPromptOpen(true);
return;
}
const sshPassword = conn.ssh_host
? await cmd.getConnectionSshPassword(conn.id).catch(() => null)
: null;
const sshPassphrase = conn.ssh_host
? await cmd.getConnectionSshPassphrase(conn.id).catch(() => null)
: null;
const input: ConnectionInput = {
name: conn.name,
db_type: conn.db_type,
host: conn.host,
port: conn.port,
username: conn.username,
password,
database: conn.database,
folder_id: conn.folder_id,
ssh_host: conn.ssh_host,
ssh_port: conn.ssh_port,
ssh_user: conn.ssh_user,
ssh_auth_method:
conn.ssh_auth_method as "password" | "key" | null | undefined,
ssh_private_key_path: conn.ssh_private_key_path,
ssh_password: sshPassword,
ssh_passphrase: sshPassphrase,
ssl_mode:
conn.ssl_mode as
| "disable"
| "require"
| "verify-ca"
| "verify-full"
| null
| undefined,
ssl_ca_path: conn.ssl_ca_path,
ssl_cert_path: conn.ssl_cert_path,
ssl_key_path: conn.ssl_key_path,
};
await cmd.dbConnect(connectionId, input);
setConnectionError(null);
inputRef.current = input;
connectedDbRef.current =
input.db_type === "sqlite"
? "main"
: (input.database ?? "postgres");
// Load initial data, smart-selecting the default schema (e.g. `public`)
setSchemaTreeLoading(true);
try {
const databases = await cmd
.getDatabases(connectionId)
.catch(() => [] as string[]);
const schemas = await cmd
.getSchemas(connectionId)
.catch(() => [] as string[]);
const defaultSchema = pickDefaultSchema(schemas);
const tables = await cmd.getTables(
connectionId,
defaultSchema ?? undefined,
);
populate(databases, schemas, tables);
if (databases.length > 0) {
// Prefer the connection's configured database, fall back to the first
// available one so the dropdown matches what the pool is connected to.
const preferred =
input.database && databases.includes(input.database)
? input.database
: databases[0];
setCurrentDatabase(preferred);
}
setCurrentSchema(defaultSchema);
} finally {
setSchemaTreeLoading(false);
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
setConnectionError(msg);
notify(`Failed to connect: ${msg}`, "error");
}
}, [connectionId, populate, setCurrentDatabase, setCurrentSchema, setSchemaTreeLoading, notify]);
useEffect(() => {
connect();
return () => {
cmd.dbDisconnect(connectionId).catch(() => {});
const hasPending = useDbViewerStore
.getState()
.changesQueue.some((c) => c.status === "pending");
if (!hasPending) reset();
};
}, [connectionId, connect, reset]);
// Database switch effect: whenever the selected database drifts from the
// pool's live connection, reconnect and refresh schemas/tables for that DB.
useEffect(() => {
if (!currentDatabase || !inputRef.current) return;
if (currentDatabase === connectedDbRef.current) return;
let cancelled = false;
const reconnect = async () => {
const input = { ...inputRef.current!, database: currentDatabase };
try {
setSchemaTreeLoading(true);
await cmd.dbConnect(connectionId, input);
if (cancelled) return;
connectedDbRef.current = currentDatabase;
const schemas = await cmd
.getSchemas(connectionId)
.catch(() => [] as string[]);
if (cancelled) return;
const newSchema = pickDefaultSchema(schemas);
const tables = await cmd
.getTables(connectionId, newSchema ?? undefined)
.catch(() => [] as TableInfo[]);
if (cancelled) return;
populate(useDbViewerStore.getState().databases, schemas, tables);
setCurrentSchema(newSchema);
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
// Revert the selection so the dropdown matches the live connection
setCurrentDatabase(connectedDbRef.current);
notify(`Failed to switch database: ${msg}`, "error");
} finally {
if (!cancelled) setSchemaTreeLoading(false);
}
};
reconnect();
return () => {
cancelled = true;
};
}, [currentDatabase, connectionId, populate, setCurrentSchema, setSchemaTreeLoading, notify]);
const submitPassword = useCallback(
(pw: string) => {
useConnectionStore.getState().setSessionPassword(connectionId, pw);
setPasswordPromptOpen(false);
void connect();
},
[connectionId, connect],
);
const cancelPassword = useCallback(() => setPasswordPromptOpen(false), []);
return { connectionError, connect, passwordPromptOpen, submitPassword, cancelPassword };
}