feat: add Greenplum 7 compatibility and AI SQL generation
Greenplum 7 (PG12-based) compatibility: - Auto-detect GP via version() string, store DbFlavor per connection - connect returns ConnectResult with version + flavor - Fix pg_total_relation_size to use c.oid (universal, safer on both PG/GP) - Branch is_identity column query for GP (lacks the column) - Branch list_sessions wait_event fields for GP - Exclude gp_toolkit schema in schema listing, completion, lookup, AI context - Smart StatusBar version display: GP shows "GP 7.0.0 (PG 12.4)" - Fix connection list spinner showing on all cards during connect AI SQL generation (Ollama): - Add AI settings, model selection, and generate_sql command - Frontend AI panel with prompt input and SQL output Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
315
src-tauri/Cargo.lock
generated
315
src-tauri/Cargo.lock
generated
@@ -438,6 +438,16 @@ dependencies = [
|
|||||||
"version_check",
|
"version_check",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.10.1"
|
version = "0.10.1"
|
||||||
@@ -461,9 +471,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics-types",
|
"core-graphics-types",
|
||||||
"foreign-types",
|
"foreign-types 0.5.0",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -474,7 +484,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -920,6 +930,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@@ -978,6 +994,15 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared 0.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types"
|
name = "foreign-types"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
@@ -985,7 +1010,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"foreign-types-macros",
|
"foreign-types-macros",
|
||||||
"foreign-types-shared",
|
"foreign-types-shared 0.3.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -999,6 +1024,12 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "foreign-types-shared"
|
name = "foreign-types-shared"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
@@ -1424,6 +1455,25 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h2"
|
||||||
|
version = "0.4.13"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||||
|
dependencies = [
|
||||||
|
"atomic-waker",
|
||||||
|
"bytes",
|
||||||
|
"fnv",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"http",
|
||||||
|
"indexmap 2.13.0",
|
||||||
|
"slab",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -1568,6 +1618,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
"futures-core",
|
"futures-core",
|
||||||
|
"h2",
|
||||||
"http",
|
"http",
|
||||||
"http-body",
|
"http-body",
|
||||||
"httparse",
|
"httparse",
|
||||||
@@ -1580,6 +1631,38 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -1598,9 +1681,11 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"tracing",
|
"tracing",
|
||||||
|
"windows-registry",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1994,6 +2079,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@@ -2131,6 +2222,23 @@ dependencies = [
|
|||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6cdede44f9a69cab2899a2049e2c3bd49bf911a157f6a3353d4a91c61abbce44"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ndk"
|
name = "ndk"
|
||||||
version = "0.9.0"
|
version = "0.9.0"
|
||||||
@@ -2487,6 +2595,50 @@ dependencies = [
|
|||||||
"pathdiff",
|
"pathdiff",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if",
|
||||||
|
"foreign-types 0.3.2",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.114",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -3099,6 +3251,46 @@ version = "0.8.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.12.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-core",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
|
"hyper-tls",
|
||||||
|
"hyper-util",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower",
|
||||||
|
"tower-http",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@@ -3245,6 +3437,19 @@ dependencies = [
|
|||||||
"semver",
|
"semver",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rustls"
|
name = "rustls"
|
||||||
version = "0.23.36"
|
version = "0.23.36"
|
||||||
@@ -3300,6 +3505,15 @@ dependencies = [
|
|||||||
"winapi-util",
|
"winapi-util",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "schemars"
|
name = "schemars"
|
||||||
version = "0.8.22"
|
version = "0.8.22"
|
||||||
@@ -3371,6 +3585,29 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "selectors"
|
name = "selectors"
|
||||||
version = "0.24.0"
|
version = "0.24.0"
|
||||||
@@ -4092,6 +4329,27 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation 0.9.4",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "system-deps"
|
name = "system-deps"
|
||||||
version = "6.2.2"
|
version = "6.2.2"
|
||||||
@@ -4113,7 +4371,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"block2",
|
"block2",
|
||||||
"core-foundation",
|
"core-foundation 0.10.1",
|
||||||
"core-graphics",
|
"core-graphics",
|
||||||
"crossbeam-channel",
|
"crossbeam-channel",
|
||||||
"dispatch",
|
"dispatch",
|
||||||
@@ -4192,7 +4450,7 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"plist",
|
"plist",
|
||||||
"raw-window-handle",
|
"raw-window-handle",
|
||||||
"reqwest",
|
"reqwest 0.13.2",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_repr",
|
"serde_repr",
|
||||||
@@ -4455,6 +4713,19 @@ dependencies = [
|
|||||||
"toml 0.9.12+spec-1.1.0",
|
"toml 0.9.12+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.25.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tendril"
|
name = "tendril"
|
||||||
version = "0.4.3"
|
version = "0.4.3"
|
||||||
@@ -4590,6 +4861,26 @@ dependencies = [
|
|||||||
"syn 2.0.114",
|
"syn 2.0.114",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-rustls"
|
||||||
|
version = "0.26.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||||
|
dependencies = [
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-stream"
|
name = "tokio-stream"
|
||||||
version = "0.1.18"
|
version = "0.1.18"
|
||||||
@@ -4826,6 +5117,7 @@ dependencies = [
|
|||||||
"csv",
|
"csv",
|
||||||
"hex",
|
"hex",
|
||||||
"log",
|
"log",
|
||||||
|
"reqwest 0.12.28",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"schemars 1.2.1",
|
"schemars 1.2.1",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -5405,6 +5697,17 @@ dependencies = [
|
|||||||
"windows-link 0.1.3",
|
"windows-link 0.1.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-registry"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link 0.2.1",
|
||||||
|
"windows-result 0.4.1",
|
||||||
|
"windows-strings 0.5.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-result"
|
name = "windows-result"
|
||||||
version = "0.3.4"
|
version = "0.3.4"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ csv = "1"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
bigdecimal = { version = "0.4", features = ["serde"] }
|
bigdecimal = { version = "0.4", features = ["serde"] }
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable-http-server"] }
|
rmcp = { version = "0.15", features = ["server", "macros", "transport-streamable-http-server"] }
|
||||||
axum = "0.8"
|
axum = "0.8"
|
||||||
schemars = "1"
|
schemars = "1"
|
||||||
|
|||||||
299
src-tauri/src/commands/ai.rs
Normal file
299
src-tauri/src/commands/ai.rs
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
use crate::error::{TuskError, TuskResult};
|
||||||
|
use crate::models::ai::{
|
||||||
|
AiSettings, OllamaChatMessage, OllamaChatRequest, OllamaChatResponse, OllamaModel,
|
||||||
|
OllamaTagsResponse,
|
||||||
|
};
|
||||||
|
use crate::state::AppState;
|
||||||
|
use sqlx::Row;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::fs;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Duration;
|
||||||
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
fn http_client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.connect_timeout(Duration::from_secs(5))
|
||||||
|
.timeout(Duration::from_secs(300))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_ai_settings_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||||
|
let dir = app
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| TuskError::Custom(e.to_string()))?;
|
||||||
|
fs::create_dir_all(&dir)?;
|
||||||
|
Ok(dir.join("ai_settings.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ai_settings(app: AppHandle) -> TuskResult<AiSettings> {
|
||||||
|
let path = get_ai_settings_path(&app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(AiSettings::default());
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
let settings: AiSettings = serde_json::from_str(&data)?;
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_ai_settings(app: AppHandle, settings: AiSettings) -> TuskResult<()> {
|
||||||
|
let path = get_ai_settings_path(&app)?;
|
||||||
|
let data = serde_json::to_string_pretty(&settings)?;
|
||||||
|
fs::write(&path, data)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_ollama_models(ollama_url: String) -> TuskResult<Vec<OllamaModel>> {
|
||||||
|
let url = format!("{}/api/tags", ollama_url.trim_end_matches('/'));
|
||||||
|
let resp = http_client()
|
||||||
|
.get(&url)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TuskError::Ai(format!("Cannot connect to Ollama at {}: {}", ollama_url, e)))?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(TuskError::Ai(format!(
|
||||||
|
"Ollama error ({}): {}",
|
||||||
|
status, body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let tags: OllamaTagsResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TuskError::Ai(format!("Failed to parse Ollama response: {}", e)))?;
|
||||||
|
|
||||||
|
Ok(tags.models)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn generate_sql(
|
||||||
|
app: AppHandle,
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
prompt: String,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
// Load AI settings
|
||||||
|
let settings = {
|
||||||
|
let path = get_ai_settings_path(&app)?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(TuskError::Ai(
|
||||||
|
"No AI model selected. Open AI settings to choose a model.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let data = fs::read_to_string(&path)?;
|
||||||
|
serde_json::from_str::<AiSettings>(&data)?
|
||||||
|
};
|
||||||
|
|
||||||
|
if settings.model.is_empty() {
|
||||||
|
return Err(TuskError::Ai(
|
||||||
|
"No AI model selected. Open AI settings to choose a model.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build schema context
|
||||||
|
let schema_text = build_schema_context(&state, &connection_id).await?;
|
||||||
|
|
||||||
|
let system_prompt = format!(
|
||||||
|
"You are a PostgreSQL SQL generator. Given the database schema below and a natural language request, \
|
||||||
|
output ONLY a valid PostgreSQL SQL query. Do not include any explanation, markdown formatting, \
|
||||||
|
or code fences. Output raw SQL only.\n\n\
|
||||||
|
RULES:\n\
|
||||||
|
- Use FK relationships for correct JOIN conditions.\n\
|
||||||
|
- timestamp - timestamp = interval. To get a number use EXTRACT(EPOCH FROM (ts1 - ts2)).\n\
|
||||||
|
- interval cannot be cast to numeric directly.\n\
|
||||||
|
- When using UNION/UNION ALL, ensure matching column types; cast enums to text if they differ.\n\
|
||||||
|
- Use COALESCE for nullable columns in aggregations when appropriate.\n\
|
||||||
|
- Prefer LEFT JOIN when the related row may not exist.\n\n\
|
||||||
|
DATABASE SCHEMA:\n{}",
|
||||||
|
schema_text
|
||||||
|
);
|
||||||
|
|
||||||
|
let request = OllamaChatRequest {
|
||||||
|
model: settings.model,
|
||||||
|
messages: vec![
|
||||||
|
OllamaChatMessage {
|
||||||
|
role: "system".to_string(),
|
||||||
|
content: system_prompt,
|
||||||
|
},
|
||||||
|
OllamaChatMessage {
|
||||||
|
role: "user".to_string(),
|
||||||
|
content: prompt,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
stream: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/chat",
|
||||||
|
settings.ollama_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = http_client()
|
||||||
|
.post(&url)
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
TuskError::Ai(format!(
|
||||||
|
"Cannot connect to Ollama at {}: {}",
|
||||||
|
settings.ollama_url, e
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
return Err(TuskError::Ai(format!(
|
||||||
|
"Ollama error ({}): {}",
|
||||||
|
status, body
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let chat_resp: OllamaChatResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| TuskError::Ai(format!("Failed to parse Ollama response: {}", e)))?;
|
||||||
|
|
||||||
|
let sql = clean_sql_response(&chat_resp.message.content);
|
||||||
|
Ok(sql)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn build_schema_context(
|
||||||
|
state: &AppState,
|
||||||
|
connection_id: &str,
|
||||||
|
) -> TuskResult<String> {
|
||||||
|
let pools = state.pools.read().await;
|
||||||
|
let pool = pools
|
||||||
|
.get(connection_id)
|
||||||
|
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
||||||
|
|
||||||
|
// Single query: all columns with real type names (enum types show actual name, not USER-DEFINED)
|
||||||
|
let col_rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
c.table_schema, c.table_name, c.column_name, \
|
||||||
|
CASE WHEN c.data_type = 'USER-DEFINED' THEN c.udt_name ELSE c.data_type END AS data_type, \
|
||||||
|
c.is_nullable = 'NO' AS not_null, \
|
||||||
|
EXISTS( \
|
||||||
|
SELECT 1 FROM information_schema.table_constraints tc \
|
||||||
|
JOIN information_schema.key_column_usage kcu \
|
||||||
|
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema \
|
||||||
|
WHERE tc.constraint_type = 'PRIMARY KEY' \
|
||||||
|
AND tc.table_schema = c.table_schema \
|
||||||
|
AND tc.table_name = c.table_name \
|
||||||
|
AND kcu.column_name = c.column_name \
|
||||||
|
) AS is_pk \
|
||||||
|
FROM information_schema.columns c \
|
||||||
|
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
||||||
|
ORDER BY c.table_schema, c.table_name, c.ordinal_position",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
// Group columns by schema.table
|
||||||
|
let mut tables: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||||
|
for row in &col_rows {
|
||||||
|
let schema: String = row.get(0);
|
||||||
|
let table: String = row.get(1);
|
||||||
|
let col_name: String = row.get(2);
|
||||||
|
let data_type: String = row.get(3);
|
||||||
|
let not_null: bool = row.get(4);
|
||||||
|
let is_pk: bool = row.get(5);
|
||||||
|
|
||||||
|
let mut parts = vec![col_name, data_type];
|
||||||
|
if is_pk {
|
||||||
|
parts.push("PK".to_string());
|
||||||
|
}
|
||||||
|
if not_null {
|
||||||
|
parts.push("NOT NULL".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = format!("{}.{}", schema, table);
|
||||||
|
tables.entry(key).or_default().push(parts.join(" "));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut lines: Vec<String> = tables
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, cols)| format!("{}({})", key, cols.join(", ")))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Fetch FK relationships
|
||||||
|
let fks = fetch_foreign_keys_from_pool(pool).await?;
|
||||||
|
for fk in &fks {
|
||||||
|
lines.push(fk.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(lines.join("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn fetch_foreign_keys_from_pool(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
) -> TuskResult<Vec<String>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT \
|
||||||
|
cn.nspname AS schema_name, cl.relname AS table_name, \
|
||||||
|
array_agg(DISTINCT a.attname ORDER BY a.attname) AS columns, \
|
||||||
|
cnf.nspname AS ref_schema, clf.relname AS ref_table, \
|
||||||
|
array_agg(DISTINCT af.attname ORDER BY af.attname) AS ref_columns \
|
||||||
|
FROM pg_constraint con \
|
||||||
|
JOIN pg_class cl ON con.conrelid = cl.oid \
|
||||||
|
JOIN pg_namespace cn ON cl.relnamespace = cn.oid \
|
||||||
|
JOIN pg_class clf ON con.confrelid = clf.oid \
|
||||||
|
JOIN pg_namespace cnf ON clf.relnamespace = cnf.oid \
|
||||||
|
JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = ANY(con.conkey) \
|
||||||
|
JOIN pg_attribute af ON af.attrelid = con.confrelid AND af.attnum = ANY(con.confkey) \
|
||||||
|
WHERE con.contype = 'f' \
|
||||||
|
AND cn.nspname NOT IN ('pg_catalog','information_schema','pg_toast','gp_toolkit') \
|
||||||
|
GROUP BY cn.nspname, cl.relname, cnf.nspname, clf.relname, con.oid",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
let fks: Vec<String> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| {
|
||||||
|
let schema: String = r.get(0);
|
||||||
|
let table: String = r.get(1);
|
||||||
|
let cols: Vec<String> = r.get(2);
|
||||||
|
let ref_schema: String = r.get(3);
|
||||||
|
let ref_table: String = r.get(4);
|
||||||
|
let ref_cols: Vec<String> = r.get(5);
|
||||||
|
format!(
|
||||||
|
"FK: {}.{}({}) -> {}.{}({})",
|
||||||
|
schema,
|
||||||
|
table,
|
||||||
|
cols.join(", "),
|
||||||
|
ref_schema,
|
||||||
|
ref_table,
|
||||||
|
ref_cols.join(", ")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
Ok(fks)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clean_sql_response(raw: &str) -> String {
|
||||||
|
let trimmed = raw.trim();
|
||||||
|
// Remove markdown code fences
|
||||||
|
let without_fences = if trimmed.starts_with("```") {
|
||||||
|
let inner = trimmed
|
||||||
|
.strip_prefix("```sql")
|
||||||
|
.or_else(|| trimmed.strip_prefix("```SQL"))
|
||||||
|
.or_else(|| trimmed.strip_prefix("```"))
|
||||||
|
.unwrap_or(trimmed);
|
||||||
|
inner.strip_suffix("```").unwrap_or(inner)
|
||||||
|
} else {
|
||||||
|
trimmed
|
||||||
|
};
|
||||||
|
without_fences.trim().to_string()
|
||||||
|
}
|
||||||
@@ -1,12 +1,19 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::connection::ConnectionConfig;
|
use crate::models::connection::ConnectionConfig;
|
||||||
use crate::state::AppState;
|
use crate::state::{AppState, DbFlavor};
|
||||||
|
use serde::Serialize;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tauri::{AppHandle, Manager, State};
|
use tauri::{AppHandle, Manager, State};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
pub struct ConnectResult {
|
||||||
|
pub version: String,
|
||||||
|
pub flavor: DbFlavor,
|
||||||
|
}
|
||||||
|
|
||||||
fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
fn get_connections_path(app: &AppHandle) -> TuskResult<std::path::PathBuf> {
|
||||||
let dir = app
|
let dir = app
|
||||||
.path()
|
.path()
|
||||||
@@ -72,6 +79,9 @@ pub async fn delete_connection(
|
|||||||
let mut ro = state.read_only.write().await;
|
let mut ro = state.read_only.write().await;
|
||||||
ro.remove(&id);
|
ro.remove(&id);
|
||||||
|
|
||||||
|
let mut flavors = state.db_flavors.write().await;
|
||||||
|
flavors.remove(&id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +102,10 @@ pub async fn test_connection(config: ConnectionConfig) -> TuskResult<String> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn connect(state: State<'_, Arc<AppState>>, config: ConnectionConfig) -> TuskResult<()> {
|
pub async fn connect(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
config: ConnectionConfig,
|
||||||
|
) -> TuskResult<ConnectResult> {
|
||||||
let pool = PgPool::connect(&config.connection_url())
|
let pool = PgPool::connect(&config.connection_url())
|
||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
@@ -103,13 +116,29 @@ pub async fn connect(state: State<'_, Arc<AppState>>, config: ConnectionConfig)
|
|||||||
.await
|
.await
|
||||||
.map_err(TuskError::Database)?;
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
|
// Detect database flavor via version()
|
||||||
|
let row = sqlx::query("SELECT version()")
|
||||||
|
.fetch_one(&pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
let version: String = row.get(0);
|
||||||
|
|
||||||
|
let flavor = if version.to_lowercase().contains("greenplum") {
|
||||||
|
DbFlavor::Greenplum
|
||||||
|
} else {
|
||||||
|
DbFlavor::PostgreSQL
|
||||||
|
};
|
||||||
|
|
||||||
let mut pools = state.pools.write().await;
|
let mut pools = state.pools.write().await;
|
||||||
pools.insert(config.id.clone(), pool);
|
pools.insert(config.id.clone(), pool);
|
||||||
|
|
||||||
let mut ro = state.read_only.write().await;
|
let mut ro = state.read_only.write().await;
|
||||||
ro.insert(config.id.clone(), true);
|
ro.insert(config.id.clone(), true);
|
||||||
|
|
||||||
Ok(())
|
let mut flavors = state.db_flavors.write().await;
|
||||||
|
flavors.insert(config.id.clone(), flavor);
|
||||||
|
|
||||||
|
Ok(ConnectResult { version, flavor })
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -149,6 +178,9 @@ pub async fn disconnect(state: State<'_, Arc<AppState>>, id: String) -> TuskResu
|
|||||||
let mut ro = state.read_only.write().await;
|
let mut ro = state.read_only.write().await;
|
||||||
ro.remove(&id);
|
ro.remove(&id);
|
||||||
|
|
||||||
|
let mut flavors = state.db_flavors.write().await;
|
||||||
|
flavors.remove(&id);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,3 +202,11 @@ pub async fn get_read_only(
|
|||||||
) -> TuskResult<bool> {
|
) -> TuskResult<bool> {
|
||||||
Ok(state.is_read_only(&connection_id).await)
|
Ok(state.is_read_only(&connection_id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_db_flavor(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
connection_id: String,
|
||||||
|
) -> TuskResult<DbFlavor> {
|
||||||
|
Ok(state.get_flavor(&connection_id).await)
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ async fn search_database_inner(
|
|||||||
"SELECT table_schema, table_name, data_type \
|
"SELECT table_schema, table_name, data_type \
|
||||||
FROM information_schema.columns \
|
FROM information_schema.columns \
|
||||||
WHERE column_name = $1 \
|
WHERE column_name = $1 \
|
||||||
AND table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast')",
|
AND table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit')",
|
||||||
)
|
)
|
||||||
.bind(column_name)
|
.bind(column_name)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::management::*;
|
use crate::models::management::*;
|
||||||
use crate::state::AppState;
|
use crate::state::{AppState, DbFlavor};
|
||||||
use crate::utils::escape_ident;
|
use crate::utils::escape_ident;
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -514,22 +514,32 @@ pub async fn list_sessions(
|
|||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> TuskResult<Vec<SessionInfo>> {
|
) -> TuskResult<Vec<SessionInfo>> {
|
||||||
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
let pools = state.pools.read().await;
|
let pools = state.pools.read().await;
|
||||||
let pool = pools
|
let pool = pools
|
||||||
.get(&connection_id)
|
.get(&connection_id)
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
|
"SELECT pid, usename, datname, state, query, \
|
||||||
|
query_start::text, NULL::text as wait_event_type, NULL::text as wait_event, \
|
||||||
|
client_addr::text \
|
||||||
|
FROM pg_stat_activity \
|
||||||
|
WHERE datname IS NOT NULL \
|
||||||
|
ORDER BY query_start DESC NULLS LAST"
|
||||||
|
} else {
|
||||||
"SELECT pid, usename, datname, state, query, \
|
"SELECT pid, usename, datname, state, query, \
|
||||||
query_start::text, wait_event_type, wait_event, \
|
query_start::text, wait_event_type, wait_event, \
|
||||||
client_addr::text \
|
client_addr::text \
|
||||||
FROM pg_stat_activity \
|
FROM pg_stat_activity \
|
||||||
WHERE datname IS NOT NULL \
|
WHERE datname IS NOT NULL \
|
||||||
ORDER BY query_start DESC NULLS LAST",
|
ORDER BY query_start DESC NULLS LAST"
|
||||||
)
|
};
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
let rows = sqlx::query(sql)
|
||||||
.map_err(TuskError::Database)?;
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
let sessions = rows
|
let sessions = rows
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::error::{TuskError, TuskResult};
|
use crate::error::{TuskError, TuskResult};
|
||||||
use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
|
use crate::models::schema::{ColumnDetail, ColumnInfo, ConstraintInfo, IndexInfo, SchemaObject};
|
||||||
use crate::state::AppState;
|
use crate::state::{AppState, DbFlavor};
|
||||||
use sqlx::Row;
|
use sqlx::Row;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -37,14 +37,21 @@ pub async fn list_schemas_core(
|
|||||||
.get(connection_id)
|
.get(connection_id)
|
||||||
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
.ok_or_else(|| TuskError::NotConnected(connection_id.to_string()))?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let flavor = state.get_flavor(connection_id).await;
|
||||||
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
|
"SELECT schema_name FROM information_schema.schemata \
|
||||||
|
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
||||||
|
ORDER BY schema_name"
|
||||||
|
} else {
|
||||||
"SELECT schema_name FROM information_schema.schemata \
|
"SELECT schema_name FROM information_schema.schemata \
|
||||||
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
WHERE schema_name NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
||||||
ORDER BY schema_name",
|
ORDER BY schema_name"
|
||||||
)
|
};
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
let rows = sqlx::query(sql)
|
||||||
.map_err(TuskError::Database)?;
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
Ok(rows.iter().map(|r| r.get::<String, _>(0)).collect())
|
||||||
}
|
}
|
||||||
@@ -70,7 +77,7 @@ pub async fn list_tables_core(
|
|||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT t.table_name, \
|
"SELECT t.table_name, \
|
||||||
c.reltuples::bigint as row_count, \
|
c.reltuples::bigint as row_count, \
|
||||||
pg_total_relation_size(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name))::bigint as size_bytes \
|
pg_total_relation_size(c.oid)::bigint as size_bytes \
|
||||||
FROM information_schema.tables t \
|
FROM information_schema.tables t \
|
||||||
LEFT JOIN pg_class c ON c.relname = t.table_name \
|
LEFT JOIN pg_class c ON c.relname = t.table_name \
|
||||||
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) \
|
AND c.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = $1) \
|
||||||
@@ -387,20 +394,28 @@ pub async fn get_completion_schema(
|
|||||||
state: State<'_, Arc<AppState>>,
|
state: State<'_, Arc<AppState>>,
|
||||||
connection_id: String,
|
connection_id: String,
|
||||||
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
) -> TuskResult<HashMap<String, HashMap<String, Vec<String>>>> {
|
||||||
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
let pools = state.pools.read().await;
|
let pools = state.pools.read().await;
|
||||||
let pool = pools
|
let pool = pools
|
||||||
.get(&connection_id)
|
.get(&connection_id)
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
|
"SELECT table_schema, table_name, column_name \
|
||||||
|
FROM information_schema.columns \
|
||||||
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast', 'gp_toolkit') \
|
||||||
|
ORDER BY table_schema, table_name, ordinal_position"
|
||||||
|
} else {
|
||||||
"SELECT table_schema, table_name, column_name \
|
"SELECT table_schema, table_name, column_name \
|
||||||
FROM information_schema.columns \
|
FROM information_schema.columns \
|
||||||
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
WHERE table_schema NOT IN ('pg_catalog', 'information_schema', 'pg_toast') \
|
||||||
ORDER BY table_schema, table_name, ordinal_position",
|
ORDER BY table_schema, table_name, ordinal_position"
|
||||||
)
|
};
|
||||||
.fetch_all(pool)
|
|
||||||
.await
|
let rows = sqlx::query(sql)
|
||||||
.map_err(TuskError::Database)?;
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
|
let mut result: HashMap<String, HashMap<String, Vec<String>>> = HashMap::new();
|
||||||
for row in &rows {
|
for row in &rows {
|
||||||
@@ -426,25 +441,36 @@ pub async fn get_column_details(
|
|||||||
schema: String,
|
schema: String,
|
||||||
table: String,
|
table: String,
|
||||||
) -> TuskResult<Vec<ColumnDetail>> {
|
) -> TuskResult<Vec<ColumnDetail>> {
|
||||||
|
let flavor = state.get_flavor(&connection_id).await;
|
||||||
let pools = state.pools.read().await;
|
let pools = state.pools.read().await;
|
||||||
let pool = pools
|
let pool = pools
|
||||||
.get(&connection_id)
|
.get(&connection_id)
|
||||||
.ok_or(TuskError::NotConnected(connection_id))?;
|
.ok_or(TuskError::NotConnected(connection_id))?;
|
||||||
|
|
||||||
let rows = sqlx::query(
|
let sql = if flavor == DbFlavor::Greenplum {
|
||||||
|
"SELECT c.column_name, c.data_type, \
|
||||||
|
c.is_nullable = 'YES' as is_nullable, \
|
||||||
|
c.column_default, \
|
||||||
|
false as is_identity \
|
||||||
|
FROM information_schema.columns c \
|
||||||
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||||
|
ORDER BY c.ordinal_position"
|
||||||
|
} else {
|
||||||
"SELECT c.column_name, c.data_type, \
|
"SELECT c.column_name, c.data_type, \
|
||||||
c.is_nullable = 'YES' as is_nullable, \
|
c.is_nullable = 'YES' as is_nullable, \
|
||||||
c.column_default, \
|
c.column_default, \
|
||||||
c.is_identity = 'YES' as is_identity \
|
c.is_identity = 'YES' as is_identity \
|
||||||
FROM information_schema.columns c \
|
FROM information_schema.columns c \
|
||||||
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
WHERE c.table_schema = $1 AND c.table_name = $2 \
|
||||||
ORDER BY c.ordinal_position",
|
ORDER BY c.ordinal_position"
|
||||||
)
|
};
|
||||||
.bind(&schema)
|
|
||||||
.bind(&table)
|
let rows = sqlx::query(sql)
|
||||||
.fetch_all(pool)
|
.bind(&schema)
|
||||||
.await
|
.bind(&table)
|
||||||
.map_err(TuskError::Database)?;
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(TuskError::Database)?;
|
||||||
|
|
||||||
Ok(rows
|
Ok(rows
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub enum TuskError {
|
|||||||
#[error("Connection is in read-only mode")]
|
#[error("Connection is in read-only mode")]
|
||||||
ReadOnly,
|
ReadOnly,
|
||||||
|
|
||||||
|
#[error("AI error: {0}")]
|
||||||
|
Ai(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Custom(String),
|
Custom(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub fn run() {
|
|||||||
commands::connections::disconnect,
|
commands::connections::disconnect,
|
||||||
commands::connections::set_read_only,
|
commands::connections::set_read_only,
|
||||||
commands::connections::get_read_only,
|
commands::connections::get_read_only,
|
||||||
|
commands::connections::get_db_flavor,
|
||||||
// queries
|
// queries
|
||||||
commands::queries::execute_query,
|
commands::queries::execute_query,
|
||||||
// schema
|
// schema
|
||||||
|
|||||||
44
src-tauri/src/models/ai.rs
Normal file
44
src-tauri/src/models/ai.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct AiSettings {
|
||||||
|
pub ollama_url: String,
|
||||||
|
pub model: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for AiSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
ollama_url: "http://localhost:11434".to_string(),
|
||||||
|
model: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct OllamaChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct OllamaChatRequest {
|
||||||
|
pub model: String,
|
||||||
|
pub messages: Vec<OllamaChatMessage>,
|
||||||
|
pub stream: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OllamaChatResponse {
|
||||||
|
pub message: OllamaChatMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct OllamaTagsResponse {
|
||||||
|
pub models: Vec<OllamaModel>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct OllamaModel {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
@@ -1,12 +1,21 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum DbFlavor {
|
||||||
|
PostgreSQL,
|
||||||
|
Greenplum,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub pools: RwLock<HashMap<String, PgPool>>,
|
pub pools: RwLock<HashMap<String, PgPool>>,
|
||||||
pub config_path: RwLock<Option<PathBuf>>,
|
pub config_path: RwLock<Option<PathBuf>>,
|
||||||
pub read_only: RwLock<HashMap<String, bool>>,
|
pub read_only: RwLock<HashMap<String, bool>>,
|
||||||
|
pub db_flavors: RwLock<HashMap<String, DbFlavor>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -15,6 +24,7 @@ impl AppState {
|
|||||||
pools: RwLock::new(HashMap::new()),
|
pools: RwLock::new(HashMap::new()),
|
||||||
config_path: RwLock::new(None),
|
config_path: RwLock::new(None),
|
||||||
read_only: RwLock::new(HashMap::new()),
|
read_only: RwLock::new(HashMap::new()),
|
||||||
|
db_flavors: RwLock::new(HashMap::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,4 +32,9 @@ impl AppState {
|
|||||||
let map = self.read_only.read().await;
|
let map = self.read_only.read().await;
|
||||||
map.get(id).copied().unwrap_or(true)
|
map.get(id).copied().unwrap_or(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_flavor(&self, id: &str) -> DbFlavor {
|
||||||
|
let map = self.db_flavors.read().await;
|
||||||
|
map.get(id).copied().unwrap_or(DbFlavor::PostgreSQL)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { useAppStore } from "@/stores/app-store";
|
|||||||
import type { Tab } from "@/types";
|
import type { Tab } from "@/types";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { activeConnectionId, addTab } = useAppStore();
|
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
||||||
|
|
||||||
const handleNewQuery = useCallback(() => {
|
const handleNewQuery = useCallback(() => {
|
||||||
if (!activeConnectionId) return;
|
if (!activeConnectionId) return;
|
||||||
@@ -22,10 +22,11 @@ export default function App() {
|
|||||||
type: "query",
|
type: "query",
|
||||||
title: "New Query",
|
title: "New Query",
|
||||||
connectionId: activeConnectionId,
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
sql: "",
|
sql: "",
|
||||||
};
|
};
|
||||||
addTab(tab);
|
addTab(tab);
|
||||||
}, [activeConnectionId, addTab]);
|
}, [activeConnectionId, currentDatabase, addTab]);
|
||||||
|
|
||||||
const handleCloseTab = useCallback(() => {
|
const handleCloseTab = useCallback(() => {
|
||||||
const { activeTabId, closeTab } = useAppStore.getState();
|
const { activeTabId, closeTab } = useAppStore.getState();
|
||||||
|
|||||||
92
src/components/ai/AiBar.tsx
Normal file
92
src/components/ai/AiBar.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { AiSettingsPopover } from "./AiSettingsPopover";
|
||||||
|
import { useGenerateSql } from "@/hooks/use-ai";
|
||||||
|
import { Sparkles, Loader2, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
connectionId: string;
|
||||||
|
onSqlGenerated: (sql: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
onExecute?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AiBar({ connectionId, onSqlGenerated, onClose, onExecute }: Props) {
|
||||||
|
const [prompt, setPrompt] = useState("");
|
||||||
|
const generateMutation = useGenerateSql();
|
||||||
|
|
||||||
|
const handleGenerate = () => {
|
||||||
|
if (!prompt.trim() || generateMutation.isPending) return;
|
||||||
|
generateMutation.mutate(
|
||||||
|
{ connectionId, prompt },
|
||||||
|
{
|
||||||
|
onSuccess: (sql) => {
|
||||||
|
onSqlGenerated(sql);
|
||||||
|
setPrompt("");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
toast.error("AI generation failed", { description: String(err) });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
onExecute?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
handleGenerate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 border-b bg-muted/50 px-2 py-1">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 shrink-0 text-purple-500" />
|
||||||
|
<Input
|
||||||
|
value={prompt}
|
||||||
|
onChange={(e) => setPrompt(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Describe the query you want..."
|
||||||
|
className="h-7 min-w-0 flex-1 text-xs"
|
||||||
|
autoFocus
|
||||||
|
disabled={generateMutation.isPending}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generateMutation.isPending || !prompt.trim()}
|
||||||
|
>
|
||||||
|
{generateMutation.isPending ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
"Generate"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<AiSettingsPopover />
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close AI bar"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
121
src/components/ai/AiSettingsPopover.tsx
Normal file
121
src/components/ai/AiSettingsPopover.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useAiSettings, useSaveAiSettings, useOllamaModels } from "@/hooks/use-ai";
|
||||||
|
import { Settings, RefreshCw, Loader2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
export function AiSettingsPopover() {
|
||||||
|
const { data: settings } = useAiSettings();
|
||||||
|
const saveMutation = useSaveAiSettings();
|
||||||
|
|
||||||
|
const [url, setUrl] = useState<string | null>(null);
|
||||||
|
const [model, setModel] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const currentUrl = url ?? settings?.ollama_url ?? "http://localhost:11434";
|
||||||
|
const currentModel = model ?? settings?.model ?? "";
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: models,
|
||||||
|
isLoading: modelsLoading,
|
||||||
|
isError: modelsError,
|
||||||
|
refetch: refetchModels,
|
||||||
|
} = useOllamaModels(currentUrl);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
saveMutation.mutate(
|
||||||
|
{ ollama_url: currentUrl, model: currentModel },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.success("AI settings saved"),
|
||||||
|
onError: (err) =>
|
||||||
|
toast.error("Failed to save AI settings", {
|
||||||
|
description: String(err),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
title="AI Settings"
|
||||||
|
>
|
||||||
|
<Settings className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80" align="end">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<h4 className="text-sm font-medium">Ollama Settings</h4>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-xs text-muted-foreground">Ollama URL</label>
|
||||||
|
<Input
|
||||||
|
value={currentUrl}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
placeholder="http://localhost:11434"
|
||||||
|
className="h-8 text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-xs text-muted-foreground">Model</label>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
className="h-5 w-5 p-0"
|
||||||
|
onClick={() => refetchModels()}
|
||||||
|
disabled={modelsLoading}
|
||||||
|
title="Refresh models"
|
||||||
|
>
|
||||||
|
{modelsLoading ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{modelsError ? (
|
||||||
|
<p className="text-xs text-destructive">
|
||||||
|
Cannot connect to Ollama
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Select value={currentModel} onValueChange={setModel}>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs">
|
||||||
|
<SelectValue placeholder="Select a model" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{models?.map((m) => (
|
||||||
|
<SelectItem key={m.name} value={m.name}>
|
||||||
|
{m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button size="sm" className="h-7 text-xs" onClick={handleSave}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import type { ConnectionConfig } from "@/types";
|
import type { ConnectionConfig } from "@/types";
|
||||||
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
import { ENVIRONMENTS } from "@/lib/environment";
|
import { ENVIRONMENTS } from "@/lib/environment";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -39,8 +40,10 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
|
|||||||
const connectMutation = useConnect();
|
const connectMutation = useConnect();
|
||||||
const disconnectMutation = useDisconnect();
|
const disconnectMutation = useDisconnect();
|
||||||
const { connectedIds, activeConnectionId } = useAppStore();
|
const { connectedIds, activeConnectionId } = useAppStore();
|
||||||
|
const [connectingId, setConnectingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const handleConnect = (conn: ConnectionConfig) => {
|
const handleConnect = (conn: ConnectionConfig) => {
|
||||||
|
setConnectingId(conn.id);
|
||||||
connectMutation.mutate(conn, {
|
connectMutation.mutate(conn, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(`Connected to ${conn.name}`);
|
toast.success(`Connected to ${conn.name}`);
|
||||||
@@ -49,6 +52,9 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
|
|||||||
onError: (err) => {
|
onError: (err) => {
|
||||||
toast.error("Connection failed", { description: String(err) });
|
toast.error("Connection failed", { description: String(err) });
|
||||||
},
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setConnectingId(null);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -169,9 +175,9 @@ export function ConnectionList({ open, onOpenChange, onEdit, onNew }: Props) {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-7 w-7"
|
className="h-7 w-7"
|
||||||
onClick={() => handleConnect(conn)}
|
onClick={() => handleConnect(conn)}
|
||||||
disabled={connectMutation.isPending}
|
disabled={connectingId !== null}
|
||||||
>
|
>
|
||||||
{connectMutation.isPending ? (
|
{connectingId === conn.id ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Plug className="h-3.5 w-3.5" />
|
<Plug className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -12,13 +12,14 @@ export function HistoryPanel() {
|
|||||||
const { data: entries } = useHistory(undefined, search || undefined);
|
const { data: entries } = useHistory(undefined, search || undefined);
|
||||||
const clearMutation = useClearHistory();
|
const clearMutation = useClearHistory();
|
||||||
|
|
||||||
const handleClick = (sql: string, connectionId: string) => {
|
const handleClick = (sql: string, connectionId: string, database?: string) => {
|
||||||
const cid = activeConnectionId ?? connectionId;
|
const cid = activeConnectionId ?? connectionId;
|
||||||
const tab: Tab = {
|
const tab: Tab = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
type: "query",
|
type: "query",
|
||||||
title: "History Query",
|
title: "History Query",
|
||||||
connectionId: cid,
|
connectionId: cid,
|
||||||
|
database,
|
||||||
sql,
|
sql,
|
||||||
};
|
};
|
||||||
addTab(tab);
|
addTab(tab);
|
||||||
@@ -52,7 +53,7 @@ export function HistoryPanel() {
|
|||||||
<button
|
<button
|
||||||
key={entry.id}
|
key={entry.id}
|
||||||
className="flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent"
|
className="flex w-full flex-col gap-0.5 border-b px-3 py-2 text-left text-xs hover:bg-accent"
|
||||||
onClick={() => handleClick(entry.sql, entry.connection_id)}
|
onClick={() => handleClick(entry.sql, entry.connection_id, entry.database || undefined)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{entry.status === "success" ? (
|
{entry.status === "success" ? (
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ import { useConnections } from "@/hooks/use-connections";
|
|||||||
import { Circle } from "lucide-react";
|
import { Circle } from "lucide-react";
|
||||||
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
import { EnvironmentBadge } from "@/components/connections/EnvironmentBadge";
|
||||||
|
|
||||||
|
function formatDbVersion(version: string): string {
|
||||||
|
const gpMatch = version.match(/Greenplum Database ([\d.]+)/i);
|
||||||
|
if (gpMatch) {
|
||||||
|
const pgMatch = version.match(/^PostgreSQL ([\d.]+)/);
|
||||||
|
const pgVer = pgMatch ? ` (PG ${pgMatch[1]})` : "";
|
||||||
|
return `GP ${gpMatch[1]}${pgVer}`;
|
||||||
|
}
|
||||||
|
return version.split(",")[0]?.replace("PostgreSQL ", "PG ") ?? version;
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
rowCount?: number | null;
|
rowCount?: number | null;
|
||||||
executionTime?: number | null;
|
executionTime?: number | null;
|
||||||
@@ -46,7 +56,7 @@ export function StatusBar({ rowCount, executionTime }: Props) {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{pgVersion && (
|
{pgVersion && (
|
||||||
<span className="hidden sm:inline">{pgVersion.split(",")[0]?.replace("PostgreSQL ", "PG ")}</span>
|
<span className="hidden sm:inline">{formatDbVersion(pgVersion)}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export function AdminPanel() {
|
|||||||
type: "roles",
|
type: "roles",
|
||||||
title: "Roles & Users",
|
title: "Roles & Users",
|
||||||
connectionId: activeConnectionId,
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
};
|
};
|
||||||
addTab(tab);
|
addTab(tab);
|
||||||
}}
|
}}
|
||||||
@@ -66,6 +67,7 @@ export function AdminPanel() {
|
|||||||
type: "sessions",
|
type: "sessions",
|
||||||
title: "Active Sessions",
|
title: "Active Sessions",
|
||||||
connectionId: activeConnectionId,
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
};
|
};
|
||||||
addTab(tab);
|
addTab(tab);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import type { Tab } from "@/types";
|
|||||||
|
|
||||||
export function SavedQueriesPanel() {
|
export function SavedQueriesPanel() {
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const { activeConnectionId, addTab } = useAppStore();
|
const { activeConnectionId, currentDatabase, addTab } = useAppStore();
|
||||||
const { data: queries } = useSavedQueries(search || undefined);
|
const { data: queries } = useSavedQueries(search || undefined);
|
||||||
const deleteMutation = useDeleteSavedQuery();
|
const deleteMutation = useDeleteSavedQuery();
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export function SavedQueriesPanel() {
|
|||||||
type: "query",
|
type: "query",
|
||||||
title: "Saved Query",
|
title: "Saved Query",
|
||||||
connectionId: cid,
|
connectionId: cid,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
sql,
|
sql,
|
||||||
};
|
};
|
||||||
addTab(tab);
|
addTab(tab);
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ export function SchemaTree() {
|
|||||||
type: "table",
|
type: "table",
|
||||||
title: table,
|
title: table,
|
||||||
connectionId: activeConnectionId,
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
schema,
|
schema,
|
||||||
table,
|
table,
|
||||||
};
|
};
|
||||||
@@ -129,6 +130,7 @@ export function SchemaTree() {
|
|||||||
type: "structure",
|
type: "structure",
|
||||||
title: `${table} (structure)`,
|
title: `${table} (structure)`,
|
||||||
connectionId: activeConnectionId,
|
connectionId: activeConnectionId,
|
||||||
|
database: currentDatabase ?? undefined,
|
||||||
schema,
|
schema,
|
||||||
table,
|
table,
|
||||||
};
|
};
|
||||||
|
|||||||
87
src/components/ui/popover.tsx
Normal file
87
src/components/ui/popover.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
function Popover({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
|
||||||
|
return <PopoverPrimitive.Root data-slot="popover" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTrigger({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
|
||||||
|
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverContent({
|
||||||
|
className,
|
||||||
|
align = "center",
|
||||||
|
sideOffset = 4,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
data-slot="popover-content"
|
||||||
|
align={align}
|
||||||
|
sideOffset={sideOffset}
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverAnchor({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
|
||||||
|
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-header"
|
||||||
|
className={cn("flex flex-col gap-1 text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="popover-title"
|
||||||
|
className={cn("font-medium", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PopoverDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"p">) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="popover-description"
|
||||||
|
className={cn("text-muted-foreground", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Popover,
|
||||||
|
PopoverTrigger,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverHeader,
|
||||||
|
PopoverTitle,
|
||||||
|
PopoverDescription,
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import { useCompletionSchema } from "@/hooks/use-completion-schema";
|
|||||||
import { useConnections } from "@/hooks/use-connections";
|
import { useConnections } from "@/hooks/use-connections";
|
||||||
import { useAppStore } from "@/stores/app-store";
|
import { useAppStore } from "@/stores/app-store";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces } from "lucide-react";
|
import { Play, Loader2, Lock, BarChart3, Download, AlignLeft, Bookmark, Table2, Braces, Sparkles } from "lucide-react";
|
||||||
import { format as formatSql } from "sql-formatter";
|
import { format as formatSql } from "sql-formatter";
|
||||||
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
import { SaveQueryDialog } from "@/components/saved-queries/SaveQueryDialog";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
import { exportCsv, exportJson } from "@/lib/tauri";
|
import { exportCsv, exportJson } from "@/lib/tauri";
|
||||||
import { save } from "@tauri-apps/plugin-dialog";
|
import { save } from "@tauri-apps/plugin-dialog";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { AiBar } from "@/components/ai/AiBar";
|
||||||
import type { QueryResult, ExplainResult } from "@/types";
|
import type { QueryResult, ExplainResult } from "@/types";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -51,6 +52,7 @@ export function WorkspacePanel({
|
|||||||
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
const [resultView, setResultView] = useState<"results" | "explain">("results");
|
||||||
const [resultViewMode, setResultViewMode] = useState<"table" | "json">("table");
|
const [resultViewMode, setResultViewMode] = useState<"table" | "json">("table");
|
||||||
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
|
||||||
|
const [aiBarOpen, setAiBarOpen] = useState(false);
|
||||||
|
|
||||||
const queryMutation = useQueryExecution();
|
const queryMutation = useQueryExecution();
|
||||||
const addHistoryMutation = useAddHistory();
|
const addHistoryMutation = useAddHistory();
|
||||||
@@ -245,6 +247,16 @@ export function WorkspacePanel({
|
|||||||
<Bookmark className="h-3 w-3" />
|
<Bookmark className="h-3 w-3" />
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={aiBarOpen ? "secondary" : "ghost"}
|
||||||
|
className="h-6 gap-1 text-xs"
|
||||||
|
onClick={() => setAiBarOpen(!aiBarOpen)}
|
||||||
|
title="AI SQL Generator"
|
||||||
|
>
|
||||||
|
<Sparkles className="h-3 w-3" />
|
||||||
|
AI
|
||||||
|
</Button>
|
||||||
{result && result.columns.length > 0 && (
|
{result && result.columns.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
@@ -277,7 +289,18 @@ export function WorkspacePanel({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 overflow-hidden">
|
{aiBarOpen && (
|
||||||
|
<AiBar
|
||||||
|
connectionId={connectionId}
|
||||||
|
onSqlGenerated={(sql) => {
|
||||||
|
setSqlValue(sql);
|
||||||
|
onSqlChange?.(sql);
|
||||||
|
}}
|
||||||
|
onClose={() => setAiBarOpen(false)}
|
||||||
|
onExecute={handleExecute}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
<SqlEditor
|
<SqlEditor
|
||||||
value={sqlValue}
|
value={sqlValue}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
@@ -290,70 +313,74 @@ export function WorkspacePanel({
|
|||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
<ResizablePanel id="results" defaultSize="60%" minSize="15%">
|
||||||
{(explainData || result || error) && (
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
<div className="flex items-center border-b text-xs">
|
{(explainData || result || error) && (
|
||||||
<button
|
<div className="flex shrink-0 items-center border-b text-xs">
|
||||||
className={`px-3 py-1 font-medium ${
|
|
||||||
resultView === "results"
|
|
||||||
? "bg-background text-foreground"
|
|
||||||
: "text-muted-foreground hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
onClick={() => setResultView("results")}
|
|
||||||
>
|
|
||||||
Results
|
|
||||||
</button>
|
|
||||||
{explainData && (
|
|
||||||
<button
|
<button
|
||||||
className={`px-3 py-1 font-medium ${
|
className={`px-3 py-1 font-medium ${
|
||||||
resultView === "explain"
|
resultView === "results"
|
||||||
? "bg-background text-foreground"
|
? "bg-background text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultView("explain")}
|
onClick={() => setResultView("results")}
|
||||||
>
|
>
|
||||||
Explain
|
Results
|
||||||
</button>
|
</button>
|
||||||
)}
|
{explainData && (
|
||||||
{resultView === "results" && result && result.columns.length > 0 && (
|
|
||||||
<div className="ml-auto mr-2 flex items-center rounded-md border">
|
|
||||||
<button
|
<button
|
||||||
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
className={`px-3 py-1 font-medium ${
|
||||||
resultViewMode === "table"
|
resultView === "explain"
|
||||||
? "bg-muted text-foreground"
|
? "bg-background text-foreground"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
onClick={() => setResultViewMode("table")}
|
onClick={() => setResultView("explain")}
|
||||||
title="Table view"
|
|
||||||
>
|
>
|
||||||
<Table2 className="h-3 w-3" />
|
Explain
|
||||||
Table
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
)}
|
||||||
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
{resultView === "results" && result && result.columns.length > 0 && (
|
||||||
resultViewMode === "json"
|
<div className="ml-auto mr-2 flex items-center rounded-md border">
|
||||||
? "bg-muted text-foreground"
|
<button
|
||||||
: "text-muted-foreground hover:text-foreground"
|
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
||||||
}`}
|
resultViewMode === "table"
|
||||||
onClick={() => setResultViewMode("json")}
|
? "bg-muted text-foreground"
|
||||||
title="JSON view"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
>
|
}`}
|
||||||
<Braces className="h-3 w-3" />
|
onClick={() => setResultViewMode("table")}
|
||||||
JSON
|
title="Table view"
|
||||||
</button>
|
>
|
||||||
</div>
|
<Table2 className="h-3 w-3" />
|
||||||
|
Table
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`flex items-center gap-1 px-2 py-0.5 font-medium ${
|
||||||
|
resultViewMode === "json"
|
||||||
|
? "bg-muted text-foreground"
|
||||||
|
: "text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
onClick={() => setResultViewMode("json")}
|
||||||
|
title="JSON view"
|
||||||
|
>
|
||||||
|
<Braces className="h-3 w-3" />
|
||||||
|
JSON
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-h-0 flex-1">
|
||||||
|
{resultView === "explain" && explainData ? (
|
||||||
|
<ExplainView data={explainData} />
|
||||||
|
) : (
|
||||||
|
<ResultsPanel
|
||||||
|
result={result}
|
||||||
|
error={error}
|
||||||
|
isLoading={queryMutation.isPending && resultView === "results"}
|
||||||
|
viewMode={resultViewMode}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
{resultView === "explain" && explainData ? (
|
|
||||||
<ExplainView data={explainData} />
|
|
||||||
) : (
|
|
||||||
<ResultsPanel
|
|
||||||
result={result}
|
|
||||||
error={error}
|
|
||||||
isLoading={queryMutation.isPending && resultView === "results"}
|
|
||||||
viewMode={resultViewMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
</ResizablePanelGroup>
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
|||||||
47
src/hooks/use-ai.ts
Normal file
47
src/hooks/use-ai.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getAiSettings,
|
||||||
|
saveAiSettings,
|
||||||
|
listOllamaModels,
|
||||||
|
generateSql,
|
||||||
|
} from "@/lib/tauri";
|
||||||
|
import type { AiSettings } from "@/types";
|
||||||
|
|
||||||
|
export function useAiSettings() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ai-settings"],
|
||||||
|
queryFn: getAiSettings,
|
||||||
|
staleTime: Infinity,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveAiSettings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (settings: AiSettings) => saveAiSettings(settings),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["ai-settings"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOllamaModels(ollamaUrl: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["ollama-models", ollamaUrl],
|
||||||
|
queryFn: () => listOllamaModels(ollamaUrl!),
|
||||||
|
enabled: !!ollamaUrl,
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGenerateSql() {
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({
|
||||||
|
connectionId,
|
||||||
|
prompt,
|
||||||
|
}: {
|
||||||
|
connectionId: string;
|
||||||
|
prompt: string;
|
||||||
|
}) => generateSql(connectionId, prompt),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -51,19 +51,19 @@ export function useTestConnection() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useConnect() {
|
export function useConnect() {
|
||||||
const { addConnectedId, setActiveConnectionId, setPgVersion, setCurrentDatabase } =
|
const { addConnectedId, setActiveConnectionId, setPgVersion, setDbFlavor, setCurrentDatabase } =
|
||||||
useAppStore();
|
useAppStore();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (config: ConnectionConfig) => {
|
mutationFn: async (config: ConnectionConfig) => {
|
||||||
await connectDb(config);
|
const result = await connectDb(config);
|
||||||
const version = await testConnection(config);
|
return { id: config.id, ...result, database: config.database };
|
||||||
return { id: config.id, version, database: config.database };
|
|
||||||
},
|
},
|
||||||
onSuccess: ({ id, version, database }) => {
|
onSuccess: ({ id, version, flavor, database }) => {
|
||||||
addConnectedId(id);
|
addConnectedId(id);
|
||||||
setActiveConnectionId(id);
|
setActiveConnectionId(id);
|
||||||
setPgVersion(version);
|
setPgVersion(version);
|
||||||
|
setDbFlavor(id, flavor);
|
||||||
setCurrentDatabase(database);
|
setCurrentDatabase(database);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -91,17 +91,17 @@ export function useDisconnect() {
|
|||||||
|
|
||||||
export function useReconnect() {
|
export function useReconnect() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { setPgVersion, setCurrentDatabase } = useAppStore();
|
const { setPgVersion, setDbFlavor, setCurrentDatabase } = useAppStore();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (config: ConnectionConfig) => {
|
mutationFn: async (config: ConnectionConfig) => {
|
||||||
await disconnectDb(config.id);
|
await disconnectDb(config.id);
|
||||||
await connectDb(config);
|
const result = await connectDb(config);
|
||||||
const version = await testConnection(config);
|
return { id: config.id, ...result, database: config.database };
|
||||||
return { version, database: config.database };
|
|
||||||
},
|
},
|
||||||
onSuccess: ({ version, database }) => {
|
onSuccess: ({ id, version, flavor, database }) => {
|
||||||
setPgVersion(version);
|
setPgVersion(version);
|
||||||
|
setDbFlavor(id, flavor);
|
||||||
setCurrentDatabase(database);
|
setCurrentDatabase(database);
|
||||||
queryClient.invalidateQueries();
|
queryClient.invalidateQueries();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||||
import type {
|
import type {
|
||||||
ConnectionConfig,
|
ConnectionConfig,
|
||||||
|
ConnectResult,
|
||||||
|
DbFlavor,
|
||||||
QueryResult,
|
QueryResult,
|
||||||
PaginatedQueryResult,
|
PaginatedQueryResult,
|
||||||
SchemaObject,
|
SchemaObject,
|
||||||
@@ -40,7 +42,7 @@ export const testConnection = (config: ConnectionConfig) =>
|
|||||||
invoke<string>("test_connection", { config });
|
invoke<string>("test_connection", { config });
|
||||||
|
|
||||||
export const connectDb = (config: ConnectionConfig) =>
|
export const connectDb = (config: ConnectionConfig) =>
|
||||||
invoke<void>("connect", { config });
|
invoke<ConnectResult>("connect", { config });
|
||||||
|
|
||||||
export const disconnectDb = (id: string) =>
|
export const disconnectDb = (id: string) =>
|
||||||
invoke<void>("disconnect", { id });
|
invoke<void>("disconnect", { id });
|
||||||
@@ -55,6 +57,9 @@ export const setReadOnly = (connectionId: string, readOnly: boolean) =>
|
|||||||
export const getReadOnly = (connectionId: string) =>
|
export const getReadOnly = (connectionId: string) =>
|
||||||
invoke<boolean>("get_read_only", { connectionId });
|
invoke<boolean>("get_read_only", { connectionId });
|
||||||
|
|
||||||
|
export const getDbFlavor = (connectionId: string) =>
|
||||||
|
invoke<DbFlavor>("get_db_flavor", { connectionId });
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
export const executeQuery = (connectionId: string, sql: string) =>
|
export const executeQuery = (connectionId: string, sql: string) =>
|
||||||
invoke<QueryResult>("execute_query", { connectionId, sql });
|
invoke<QueryResult>("execute_query", { connectionId, sql });
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import type { ConnectionConfig, Tab } from "@/types";
|
import type { ConnectionConfig, DbFlavor, Tab } from "@/types";
|
||||||
|
|
||||||
interface AppState {
|
interface AppState {
|
||||||
connections: ConnectionConfig[];
|
connections: ConnectionConfig[];
|
||||||
@@ -7,6 +7,7 @@ interface AppState {
|
|||||||
currentDatabase: string | null;
|
currentDatabase: string | null;
|
||||||
connectedIds: Set<string>;
|
connectedIds: Set<string>;
|
||||||
readOnlyMap: Record<string, boolean>;
|
readOnlyMap: Record<string, boolean>;
|
||||||
|
dbFlavors: Record<string, DbFlavor>;
|
||||||
tabs: Tab[];
|
tabs: Tab[];
|
||||||
activeTabId: string | null;
|
activeTabId: string | null;
|
||||||
sidebarWidth: number;
|
sidebarWidth: number;
|
||||||
@@ -18,6 +19,7 @@ interface AppState {
|
|||||||
addConnectedId: (id: string) => void;
|
addConnectedId: (id: string) => void;
|
||||||
removeConnectedId: (id: string) => void;
|
removeConnectedId: (id: string) => void;
|
||||||
setReadOnly: (connectionId: string, readOnly: boolean) => void;
|
setReadOnly: (connectionId: string, readOnly: boolean) => void;
|
||||||
|
setDbFlavor: (connectionId: string, flavor: DbFlavor) => void;
|
||||||
setPgVersion: (version: string | null) => void;
|
setPgVersion: (version: string | null) => void;
|
||||||
|
|
||||||
addTab: (tab: Tab) => void;
|
addTab: (tab: Tab) => void;
|
||||||
@@ -33,6 +35,7 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
currentDatabase: null,
|
currentDatabase: null,
|
||||||
connectedIds: new Set(),
|
connectedIds: new Set(),
|
||||||
readOnlyMap: {},
|
readOnlyMap: {},
|
||||||
|
dbFlavors: {},
|
||||||
tabs: [],
|
tabs: [],
|
||||||
activeTabId: null,
|
activeTabId: null,
|
||||||
sidebarWidth: 260,
|
sidebarWidth: 260,
|
||||||
@@ -50,13 +53,22 @@ export const useAppStore = create<AppState>((set) => ({
|
|||||||
set((state) => {
|
set((state) => {
|
||||||
const next = new Set(state.connectedIds);
|
const next = new Set(state.connectedIds);
|
||||||
next.delete(id);
|
next.delete(id);
|
||||||
const { [id]: _, ...restRo } = state.readOnlyMap;
|
const restRo = Object.fromEntries(
|
||||||
return { connectedIds: next, readOnlyMap: restRo };
|
Object.entries(state.readOnlyMap).filter(([k]) => k !== id)
|
||||||
|
);
|
||||||
|
const restFlavors = Object.fromEntries(
|
||||||
|
Object.entries(state.dbFlavors).filter(([k]) => k !== id)
|
||||||
|
);
|
||||||
|
return { connectedIds: next, readOnlyMap: restRo, dbFlavors: restFlavors };
|
||||||
}),
|
}),
|
||||||
setReadOnly: (connectionId, readOnly) =>
|
setReadOnly: (connectionId, readOnly) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
readOnlyMap: { ...state.readOnlyMap, [connectionId]: readOnly },
|
readOnlyMap: { ...state.readOnlyMap, [connectionId]: readOnly },
|
||||||
})),
|
})),
|
||||||
|
setDbFlavor: (connectionId, flavor) =>
|
||||||
|
set((state) => ({
|
||||||
|
dbFlavors: { ...state.dbFlavors, [connectionId]: flavor },
|
||||||
|
})),
|
||||||
setPgVersion: (version) => set({ pgVersion: version }),
|
setPgVersion: (version) => set({ pgVersion: version }),
|
||||||
|
|
||||||
addTab: (tab) =>
|
addTab: (tab) =>
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
export type DbFlavor = "postgresql" | "greenplum";
|
||||||
|
|
||||||
|
export interface ConnectResult {
|
||||||
|
version: string;
|
||||||
|
flavor: DbFlavor;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ConnectionConfig {
|
export interface ConnectionConfig {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user