From 6a14b11efc6fbcd6a4e84accf4679b652ec00524 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 9 Jun 2026 16:20:50 +0800 Subject: [PATCH 01/42] fix(mongodb): skip SQL semantic diagnostics for MongoDB connections MongoDB shell syntax like db.collection.find({}) was incorrectly parsed as SQL, causing a "sql parser error" diagnostic in the editor. Added MongoDB guards in shouldRunSqlSemanticDiagnostics and refreshSemanticDiagnostics, matching existing Elasticsearch logic. Also fix a pre-existing TiDB cloud URL param ordering test failure. --- .../src/components/editor/QueryEditor.vue | 2 +- .../desktop/src/lib/sqlSemanticDiagnostics.ts | 2 +- crates/dbx-core/src/models/connection.rs | 2 +- .../app-tests/sqlSemanticDiagnostics.test.ts | 21 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 7395ef23b1..aa31d33ad6 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -741,7 +741,7 @@ async function refreshSemanticDiagnostics() { setSemanticDiagnostics([]); return; } - if (props.databaseType === "elasticsearch") { + if (props.databaseType === "mongodb" || props.databaseType === "elasticsearch") { setSemanticDiagnostics([]); return; } diff --git a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts index c0dfa00648..a4fd2a553b 100644 --- a/apps/desktop/src/lib/sqlSemanticDiagnostics.ts +++ b/apps/desktop/src/lib/sqlSemanticDiagnostics.ts @@ -104,7 +104,7 @@ export function shouldRunSqlSemanticDiagnostics( cursor: number, options: { databaseType?: DatabaseType } = {}, ): boolean { - if (options.databaseType === "elasticsearch") return false; + if (options.databaseType === "mongodb" || options.databaseType === "elasticsearch") return false; const context = getSqlCompletionContext(sql, cursor); if (context.suggestTables || context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions) return false; if (context.qualifier) return false; diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index 6d4327e45e..9d7c9ff068 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -1685,7 +1685,7 @@ mod tests { assert_eq!( config.connection_url(), - "mysql://root:secret@gateway01.us-west-2.prod.aws.tidbcloud.com:4000/test?require_ssl=true&verify_ca=false&verify_identity=false&charset=utf8mb4" + "mysql://root:secret@gateway01.us-west-2.prod.aws.tidbcloud.com:4000/test?require_ssl=true&charset=utf8mb4&verify_ca=false&verify_identity=false" ); } diff --git a/packages/app-tests/sqlSemanticDiagnostics.test.ts b/packages/app-tests/sqlSemanticDiagnostics.test.ts index 5d314b00c6..62a58ee499 100644 --- a/packages/app-tests/sqlSemanticDiagnostics.test.ts +++ b/packages/app-tests/sqlSemanticDiagnostics.test.ts @@ -93,3 +93,24 @@ test("defers diagnostics while the cursor is in table completion context", () => assert.equal(shouldRunSqlSemanticDiagnostics("select u.", "select u.".length), false); assert.equal(shouldRunSqlSemanticDiagnostics("select * from users where missing = 1", 42), true); }); + +test("skips diagnostics for MongoDB connections", () => { + assert.equal( + shouldRunSqlSemanticDiagnostics("db.my_collection.find({})", 0, { databaseType: "mongodb" }), + false, + ); +}); + +test("skips diagnostics for Elasticsearch connections", () => { + assert.equal( + shouldRunSqlSemanticDiagnostics("db.my_collection.find({})", 0, { databaseType: "elasticsearch" }), + false, + ); +}); + +test("still runs diagnostics for SQL connections", () => { + assert.equal( + shouldRunSqlSemanticDiagnostics("SELECT * FROM users WHERE id = 1", 42, { databaseType: "mysql" }), + true, + ); +}); From efb0dba0ae5152d42b395c1e3878196763f9cae7 Mon Sep 17 00:00:00 2001 From: Oleksandr Liakhevych Date: Tue, 9 Jun 2026 11:22:28 +0300 Subject: [PATCH 02/42] fix(ai): do not provide temperature for OpenAI reasoning models --- crates/dbx-core/src/ai.rs | 62 ++++++++++++++++++++++++++++++++++----- 1 file changed, 54 insertions(+), 8 deletions(-) diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index a2c40af7e8..2297ca07a1 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -290,6 +290,25 @@ fn responses_max_output_tokens(max_tokens: Option) -> u32 { max_tokens.unwrap_or(2048).max(16) } +fn is_openai_api_config(config: &AiConfig) -> bool { + matches!(config.provider, AiProvider::Openai) || config.endpoint.to_ascii_lowercase().contains("api.openai.com") +} + +fn is_openai_reasoning_model(model: &str) -> bool { + let model = model.trim().to_ascii_lowercase(); + model.starts_with("gpt-5") || model.starts_with("o1") || model.starts_with("o3") || model.starts_with("o4") +} + +pub fn supports_temperature(config: &AiConfig) -> bool { + !(is_openai_api_config(config) && is_openai_reasoning_model(&config.model)) +} + +fn add_temperature_if_supported(body: &mut serde_json::Value, request: &AiCompletionRequest) { + if supports_temperature(&request.config) { + body["temperature"] = json!(request.temperature.unwrap_or(0.2)); + } +} + fn responses_text(data: &serde_json::Value) -> String { if let Some(text) = data["output_text"].as_str().filter(|text| !text.is_empty()) { return text.to_string(); @@ -538,8 +557,8 @@ pub async fn call_openai_compatible(client: &reqwest::Client, request: AiComplet "model": request.config.model, "messages": messages, "max_tokens": request.max_tokens.unwrap_or(2048), - "temperature": request.temperature.unwrap_or(0.2), }); + add_temperature_if_supported(&mut body_obj, &request); if !request.config.enable_thinking { body_obj["extra_body"] = json!({ "chat_template_kwargs": { "enable_thinking": false } @@ -566,12 +585,12 @@ pub async fn call_openai_compatible(client: &reqwest::Client, request: AiComplet pub async fn call_responses_api(client: &reqwest::Client, request: AiCompletionRequest) -> Result { let headers = maybe_bearer_headers(&request.config)?; - let body = json!({ + let mut body = json!({ "model": request.config.model, "input": build_responses_input(&request.system_prompt, &request.messages), "max_output_tokens": responses_max_output_tokens(request.max_tokens), - "temperature": request.temperature.unwrap_or(0.2), }); + add_temperature_if_supported(&mut body, &request); let res = client .post(resolve_endpoint(&request.config)) @@ -815,9 +834,9 @@ async fn stream_openai( "model": request.config.model, "messages": messages, "max_tokens": request.max_tokens.unwrap_or(2048), - "temperature": request.temperature.unwrap_or(0.2), "stream": true, }); + add_temperature_if_supported(&mut body_obj, request); if !request.config.enable_thinking { body_obj["extra_body"] = json!({ "chat_template_kwargs": { "enable_thinking": false } @@ -903,13 +922,13 @@ async fn stream_responses_api( ) -> Result<(), String> { let headers = maybe_bearer_headers(&request.config)?; - let body = json!({ + let mut body = json!({ "model": request.config.model, "input": build_responses_input(&request.system_prompt, &request.messages), "max_output_tokens": responses_max_output_tokens(request.max_tokens), - "temperature": request.temperature.unwrap_or(0.2), "stream": true, }); + add_temperature_if_supported(&mut body, request); let res = client .post(resolve_endpoint(&request.config)) @@ -1112,8 +1131,8 @@ pub fn load_config(path: &Path) -> Result, String> { mod tests { use super::{ build_ai_http_client, gemini_text, openai_response_text, openai_stream_text, parse_model_list_response, - resolve_endpoint, resolve_model_list_endpoint, responses_max_output_tokens, responses_text, validate_config, - AiApiStyle, AiConfig, AiModelInfo, AiProvider, + resolve_endpoint, resolve_model_list_endpoint, responses_max_output_tokens, responses_text, + supports_temperature, validate_config, AiApiStyle, AiConfig, AiModelInfo, AiProvider, }; #[test] @@ -1273,6 +1292,33 @@ mod tests { assert_eq!(responses_max_output_tokens(None), 2048); } + #[test] + fn omits_temperature_for_openai_reasoning_models() { + let mut config = AiConfig { + provider: AiProvider::Openai, + api_key: "key".to_string(), + endpoint: "https://api.openai.com/v1/chat/completions".to_string(), + model: "gpt-5.5".to_string(), + api_style: AiApiStyle::Completions, + proxy_enabled: false, + proxy_url: String::new(), + enable_thinking: true, + }; + + assert!(!supports_temperature(&config)); + + config.model = "o4-mini".to_string(); + assert!(!supports_temperature(&config)); + + config.model = "gpt-4o".to_string(); + assert!(supports_temperature(&config)); + + config.provider = AiProvider::OpenaiCompatible; + config.endpoint = "http://localhost:11434/v1".to_string(); + config.model = "gpt-5-local".to_string(); + assert!(supports_temperature(&config)); + } + #[test] fn parses_responses_text_from_current_and_nested_shapes() { assert_eq!( From 20f4a4a5ce4744a2be186129c37885b1d8de0f6b Mon Sep 17 00:00:00 2001 From: lexmin0412 Date: Tue, 9 Jun 2026 16:22:52 +0800 Subject: [PATCH 03/42] fix(ui): prevent Ctrl+right-click from closing context menu immediately on macOS Root cause: macOS emits a synthetic click event when the right mouse button is released while holding Control. The outside-dismiss handler was listening for click events in capture phase, which caught this synthetic event and closed the menu right after it opened. Fix: switch from click to pointerdown (with event.button === 0 guard) for outside-dismiss detection. pointerdown fires on press before contextmenu, so the right-button event (button:2) is filtered out before the menu even opens, and the synthetic click is never seen. --- .../desktop/src/components/ui/CustomContextMenu.vue | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/components/ui/CustomContextMenu.vue b/apps/desktop/src/components/ui/CustomContextMenu.vue index c43bd30a8e..53a1acc67d 100644 --- a/apps/desktop/src/components/ui/CustomContextMenu.vue +++ b/apps/desktop/src/components/ui/CustomContextMenu.vue @@ -54,7 +54,12 @@ function close() { show.value = false; } -function onClickOutside(e: MouseEvent) { +function onPointerDownOutside(e: PointerEvent) { + // Only respond to primary (left) button presses. This avoids a macOS + // issue where Ctrl+right-click generates a synthetic click event on + // mouseup. By using pointerdown (which fires on press, before + // contextmenu) instead of click, we never see that synthetic event. + if (e.button !== 0) return; const target = e.target as Node; const inMenu = menuRef.value?.contains(target); const inSub = subRef.value?.contains(target); @@ -78,13 +83,13 @@ function onResize() { watch(show, (val) => { if (val) { openMenus.add(close); - document.addEventListener("click", onClickOutside, true); + document.addEventListener("pointerdown", onPointerDownOutside, true); document.addEventListener("keydown", onKeydown); document.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onResize); } else { openMenus.delete(close); - document.removeEventListener("click", onClickOutside, true); + document.removeEventListener("pointerdown", onPointerDownOutside, true); document.removeEventListener("keydown", onKeydown); document.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); @@ -229,7 +234,7 @@ function shortcutKeys(shortcut?: string): string[] { onBeforeUnmount(() => { openMenus.delete(close); - document.removeEventListener("click", onClickOutside, true); + document.removeEventListener("pointerdown", onPointerDownOutside, true); document.removeEventListener("keydown", onKeydown); document.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onResize); From 46eaaed8f14eac6d9ab1fc5c8064d0c38ffbdbcc Mon Sep 17 00:00:00 2001 From: srako Date: Tue, 9 Jun 2026 16:23:13 +0800 Subject: [PATCH 04/42] fix(clickhouse): bypass system proxy to prevent connection failure --- crates/dbx-core/src/db/clickhouse_driver.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index 8ed936d88f..dbd77bc1be 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -17,7 +17,8 @@ pub struct ChClient { impl ChClient { pub fn new(url: &str, username: Option, password: Option, timeout: Duration) -> Self { - let http = HttpClient::builder().connect_timeout(timeout).build().unwrap_or_else(|_| HttpClient::new()); + let http = + HttpClient::builder().connect_timeout(timeout).no_proxy().build().unwrap_or_else(|_| HttpClient::new()); Self { http, base_url: url.trim_end_matches('/').to_string(), username, password } } @@ -28,7 +29,7 @@ impl ChClient { ca_cert_path: Option<&str>, timeout: Duration, ) -> Result { - let mut builder = HttpClient::builder().connect_timeout(timeout); + let mut builder = HttpClient::builder().connect_timeout(timeout).no_proxy(); if let Some(path) = ca_cert_path.map(str::trim).filter(|path| !path.is_empty()) { let path = expand_cert_path(path); let cert_bytes = From ee65fa660d0ad9a602a4ed08dc181f17819f8181 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 9 Jun 2026 16:42:04 +0800 Subject: [PATCH 05/42] fix(db): always bypass system proxy for all HTTP-based database drivers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace per-driver proxy bypass logic with a shared http_client_builder() that always calls .no_proxy(). Previously ES and rqlite only bypassed for localhost, causing remote connections to be routed through system proxy (like Clash Verge) and failing — the same issue #922 fixed for ClickHouse. --- crates/dbx-core/src/db/clickhouse_driver.rs | 7 ++--- .../dbx-core/src/db/elasticsearch_driver.rs | 30 ++----------------- crates/dbx-core/src/db/mod.rs | 5 ++++ crates/dbx-core/src/db/rqlite_driver.rs | 18 ++--------- 4 files changed, 13 insertions(+), 47 deletions(-) diff --git a/crates/dbx-core/src/db/clickhouse_driver.rs b/crates/dbx-core/src/db/clickhouse_driver.rs index dbd77bc1be..aa6f91da75 100644 --- a/crates/dbx-core/src/db/clickhouse_driver.rs +++ b/crates/dbx-core/src/db/clickhouse_driver.rs @@ -3,7 +3,7 @@ use serde::Deserialize; use std::fs; use std::time::{Duration, Instant}; -use super::with_connection_timeout; +use super::{http_client_builder, with_connection_timeout}; use crate::query::MAX_ROWS; use crate::sql::starts_with_executable_sql_keyword; use crate::types::{ColumnInfo, DatabaseInfo, QueryResult, TableInfo}; @@ -17,8 +17,7 @@ pub struct ChClient { impl ChClient { pub fn new(url: &str, username: Option, password: Option, timeout: Duration) -> Self { - let http = - HttpClient::builder().connect_timeout(timeout).no_proxy().build().unwrap_or_else(|_| HttpClient::new()); + let http = http_client_builder(timeout).build().unwrap_or_else(|_| HttpClient::new()); Self { http, base_url: url.trim_end_matches('/').to_string(), username, password } } @@ -29,7 +28,7 @@ impl ChClient { ca_cert_path: Option<&str>, timeout: Duration, ) -> Result { - let mut builder = HttpClient::builder().connect_timeout(timeout).no_proxy(); + let mut builder = http_client_builder(timeout); if let Some(path) = ca_cert_path.map(str::trim).filter(|path| !path.is_empty()) { let path = expand_cert_path(path); let cert_bytes = diff --git a/crates/dbx-core/src/db/elasticsearch_driver.rs b/crates/dbx-core/src/db/elasticsearch_driver.rs index 3aeb5e4f5d..4f2086ae6b 100644 --- a/crates/dbx-core/src/db/elasticsearch_driver.rs +++ b/crates/dbx-core/src/db/elasticsearch_driver.rs @@ -5,7 +5,7 @@ use std::collections::HashSet; use std::error::Error; use std::time::Duration; -use super::with_connection_timeout; +use super::{http_client_builder, with_connection_timeout}; use crate::db::mongo_driver::MongoDocumentResult; pub struct EsClient { @@ -28,11 +28,7 @@ impl EsClient { (Some(u), Some(p)) if !u.is_empty() => Some((u.to_string(), p.to_string())), _ => None, }; - let mut builder = - HttpClient::builder().connect_timeout(timeout).danger_accept_invalid_certs(accept_invalid_certs); - if elasticsearch_should_bypass_system_proxy(&base_url) { - builder = builder.no_proxy(); - } + let builder = http_client_builder(timeout).danger_accept_invalid_certs(accept_invalid_certs); let http = builder.build().unwrap_or_else(|_| HttpClient::new()); let fallback_base_urls = elasticsearch_base_url_fallbacks(&base_url); Self { http, base_url, fallback_base_urls, auth } @@ -166,17 +162,6 @@ fn elasticsearch_base_url_fallbacks(base_url: &str) -> Vec { } } -fn elasticsearch_should_bypass_system_proxy(base_url: &str) -> bool { - let Ok(parsed) = reqwest::Url::parse(base_url) else { - return false; - }; - let Some(host) = parsed.host_str() else { - return false; - }; - let host = host.trim_matches(['[', ']']); - host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|ip| ip.is_loopback()) -} - fn redact_elasticsearch_url(url: &str) -> String { let Ok(mut parsed) = reqwest::Url::parse(url) else { return url.to_string(); @@ -1149,8 +1134,7 @@ fn parse_aggregations(aggs: &serde_json::Map) -> (Vec #[cfg(test)] mod tests { use super::{ - elasticsearch_accept_invalid_certs, elasticsearch_base_url_fallbacks, elasticsearch_should_bypass_system_proxy, - redact_elasticsearch_url, EsClient, + elasticsearch_accept_invalid_certs, elasticsearch_base_url_fallbacks, redact_elasticsearch_url, EsClient, }; use std::time::Duration; @@ -1200,12 +1184,4 @@ mod tests { "https://user:password@localhost:9200" ); } - - #[test] - fn elasticsearch_only_bypasses_system_proxy_for_loopback_hosts() { - assert!(elasticsearch_should_bypass_system_proxy("https://localhost:9200")); - assert!(elasticsearch_should_bypass_system_proxy("https://127.0.0.1:9200")); - assert!(elasticsearch_should_bypass_system_proxy("https://[::1]:9200")); - assert!(!elasticsearch_should_bypass_system_proxy("https://search.example.com:9200")); - } } diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index f6e32f295f..91aa497724 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -16,6 +16,7 @@ pub mod sqlserver; pub mod ssh_tunnel; pub mod transport_layer_tunnel; +use reqwest::ClientBuilder; use std::future::Future; use std::time::Duration; @@ -30,6 +31,10 @@ pub fn connection_timeout() -> Duration { Duration::from_secs(CONNECTION_TIMEOUT_SECS) } +pub fn http_client_builder(timeout: Duration) -> ClientBuilder { + reqwest::Client::builder().connect_timeout(timeout).no_proxy() +} + const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; pub fn safe_i64_to_json(v: i64) -> serde_json::Value { diff --git a/crates/dbx-core/src/db/rqlite_driver.rs b/crates/dbx-core/src/db/rqlite_driver.rs index 316fbee3ba..53651317db 100644 --- a/crates/dbx-core/src/db/rqlite_driver.rs +++ b/crates/dbx-core/src/db/rqlite_driver.rs @@ -2,7 +2,7 @@ use reqwest::Client as HttpClient; use serde::Deserialize; use std::time::{Duration, Instant}; -use super::with_connection_timeout; +use super::{http_client_builder, with_connection_timeout}; use crate::sql::starts_with_executable_sql_keyword; use crate::types::{ ColumnInfo, DatabaseInfo, ForeignKeyInfo, IndexInfo, ObjectSource, ObjectSourceKind, QueryResult, TableInfo, @@ -26,13 +26,10 @@ impl RqliteClient { tls_enabled: bool, timeout: Duration, ) -> Result { - let mut builder = HttpClient::builder().connect_timeout(timeout); + let mut builder = http_client_builder(timeout); if rqlite_accept_invalid_certs(tls_enabled, url_params) { builder = builder.danger_accept_invalid_certs(true); } - if rqlite_should_bypass_system_proxy(url) { - builder = builder.no_proxy(); - } let http = builder.build().map_err(|e| format!("Failed to configure rqlite HTTP client: {e}"))?; let auth = if username.trim().is_empty() { None } else { Some((username.to_string(), password.to_string())) }; Ok(Self { @@ -411,17 +408,6 @@ fn rqlite_accept_invalid_certs(tls_enabled: bool, url_params: Option<&str>) -> b }) } -fn rqlite_should_bypass_system_proxy(base_url: &str) -> bool { - let Ok(parsed) = reqwest::Url::parse(base_url) else { - return false; - }; - let Some(host) = parsed.host_str() else { - return false; - }; - let host = host.trim_matches(['[', ']']); - host.eq_ignore_ascii_case("localhost") || host.parse::().is_ok_and(|ip| ip.is_loopback()) -} - #[cfg(test)] mod tests { use super::*; From 96e11935c233355ae0b0e5a14130f06fc3af0e1c Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 9 Jun 2026 16:42:36 +0800 Subject: [PATCH 06/42] fix(mongodb): support ObjectId filters by _id --- crates/dbx-core/src/db/mongo_driver.rs | 144 ++++++++++++++++++++++++- 1 file changed, 141 insertions(+), 3 deletions(-) diff --git a/crates/dbx-core/src/db/mongo_driver.rs b/crates/dbx-core/src/db/mongo_driver.rs index 5f6ecf633f..cf030b60d4 100644 --- a/crates/dbx-core/src/db/mongo_driver.rs +++ b/crates/dbx-core/src/db/mongo_driver.rs @@ -117,7 +117,7 @@ pub async fn find_documents( let filter_doc: Document = match filter { Some(f) if !f.trim().is_empty() => { let json: serde_json::Value = serde_json::from_str(f).map_err(|e| format!("Invalid filter JSON: {e}"))?; - json_object_to_document(&json)? + json_filter_to_document(&json)? } _ => doc! {}, }; @@ -240,7 +240,7 @@ pub async fn update_documents( serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?; let update_value: serde_json::Value = serde_json::from_str(update_json).map_err(|e| format!("Invalid update JSON: {e}"))?; - let filter = json_object_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?; + let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?; let update = json_object_to_document(&update_value).map_err(|e| format!("Invalid update: {e}"))?; let col = client.database(database).collection::(collection); let result = if many { @@ -279,7 +279,7 @@ pub async fn delete_documents( ) -> Result { let filter_value: serde_json::Value = serde_json::from_str(filter_json).map_err(|e| format!("Invalid filter JSON: {e}"))?; - let filter = json_object_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?; + let filter = json_filter_to_document(&filter_value).map_err(|e| format!("Invalid filter: {e}"))?; let col = client.database(database).collection::(collection); let result = if many { col.delete_many(filter).await.map_err(|e| e.to_string())? @@ -320,6 +320,13 @@ pub fn json_object_to_document(value: &serde_json::Value) -> Result Result { + match json_filter_value_to_bson(value, None) { + Bson::Document(doc) => Ok(doc), + other => Err(format!("Expected a JSON object, got {other:?}")), + } +} + fn json_value_to_bson(value: &serde_json::Value) -> Bson { match value { serde_json::Value::Null => Bson::Null, @@ -350,6 +357,94 @@ fn json_value_to_bson(value: &serde_json::Value) -> Bson { } } +fn json_filter_value_to_bson(value: &serde_json::Value, field_name: Option<&str>) -> Bson { + if field_name == Some("_id") { + if let Some(id) = value.as_str() { + return id_equality_bson(id); + } + } + + match value { + serde_json::Value::Array(arr) => { + Bson::Array(arr.iter().map(|item| json_filter_value_to_bson(item, None)).collect()) + } + serde_json::Value::Object(obj) => { + if obj.len() == 1 { + if let Some(serde_json::Value::String(hex)) = obj.get("$oid") { + if let Ok(oid) = ObjectId::parse_str(hex) { + return Bson::ObjectId(oid); + } + } + } + + if field_name == Some("_id") && obj.keys().all(|key| key.starts_with('$')) { + let mut doc = Document::new(); + for (key, item) in obj { + match key.as_str() { + "$eq" => { + if let Some(id) = item.as_str() { + doc.insert("$in", object_id_string_variants(id)); + } else { + doc.insert(key, json_filter_value_to_bson(item, None)); + } + } + "$ne" => { + if let Some(id) = item.as_str() { + doc.insert("$nin", object_id_string_variants(id)); + } else { + doc.insert(key, json_filter_value_to_bson(item, None)); + } + } + "$in" | "$nin" => { + if let Some(items) = item.as_array() { + doc.insert(key, expand_object_id_string_array(items)); + } else { + doc.insert(key, json_filter_value_to_bson(item, None)); + } + } + _ => { + doc.insert(key, json_filter_value_to_bson(item, None)); + } + } + } + return Bson::Document(doc); + } + + let doc: Document = obj.iter().map(|(k, v)| (k.clone(), json_filter_value_to_bson(v, Some(k)))).collect(); + Bson::Document(doc) + } + _ => json_value_to_bson(value), + } +} + +fn id_equality_bson(id: &str) -> Bson { + let variants = object_id_string_variants(id); + if variants.len() == 1 { + variants.into_iter().next().unwrap_or(Bson::String(id.to_string())) + } else { + Bson::Document(doc! { "$in": variants }) + } +} + +fn object_id_string_variants(id: &str) -> Vec { + match ObjectId::parse_str(id) { + Ok(oid) => vec![Bson::ObjectId(oid), Bson::String(id.to_string())], + Err(_) => vec![Bson::String(id.to_string())], + } +} + +fn expand_object_id_string_array(items: &[serde_json::Value]) -> Bson { + let mut values = Vec::new(); + for item in items { + if let Some(id) = item.as_str() { + values.extend(object_id_string_variants(id)); + } else { + values.push(json_filter_value_to_bson(item, None)); + } + } + Bson::Array(values) +} + #[cfg(test)] mod tests { use super::*; @@ -372,4 +467,47 @@ mod tests { assert_eq!(filters.len(), 1); assert!(matches!(filters[0].get("_id"), Some(Bson::String(value)) if value == id)); } + + #[test] + fn json_filter_to_document_matches_object_id_and_string_for_id_hex() { + let id = "507f1f77bcf86cd799439011"; + let filter = serde_json::json!({ "_id": id }); + let doc = json_filter_to_document(&filter).unwrap(); + + let Some(Bson::Document(id_filter)) = doc.get("_id") else { + panic!("expected _id operator document"); + }; + let Some(Bson::Array(values)) = id_filter.get("$in") else { + panic!("expected _id $in variants"); + }; + assert!(matches!(values.first(), Some(Bson::ObjectId(_)))); + assert!(matches!(values.get(1), Some(Bson::String(value)) if value == id)); + } + + #[test] + fn json_filter_to_document_expands_id_operator_variants() { + let id = "507f1f77bcf86cd799439011"; + let filter = serde_json::json!({ "$and": [{ "_id": { "$eq": id } }] }); + let doc = json_filter_to_document(&filter).unwrap(); + + let Some(Bson::Array(and_items)) = doc.get("$and") else { + panic!("expected $and array"); + }; + let Some(Bson::Document(first)) = and_items.first() else { + panic!("expected first $and document"); + }; + let Some(Bson::Document(id_filter)) = first.get("_id") else { + panic!("expected _id operator document"); + }; + assert!(matches!(id_filter.get("$in"), Some(Bson::Array(values)) if values.len() == 2)); + } + + #[test] + fn json_filter_to_document_leaves_non_id_hex_strings_alone() { + let id = "507f1f77bcf86cd799439011"; + let filter = serde_json::json!({ "owner_id": id }); + let doc = json_filter_to_document(&filter).unwrap(); + + assert!(matches!(doc.get("owner_id"), Some(Bson::String(value)) if value == id)); + } } From 354d21d0866671df2257411d74b5ed7e0139076b Mon Sep 17 00:00:00 2001 From: John Jin Date: Tue, 9 Jun 2026 16:43:53 +0800 Subject: [PATCH 07/42] =?UTF-8?q?feat(grid):=20=E5=88=97=E6=98=AF=E5=90=A6?= =?UTF-8?q?=E5=8F=AF=E6=8E=92=E5=BA=8F=20(#935)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/components/grid/DataGrid.vue | 6 ++++++ apps/desktop/src/types/database.ts | 5 +++++ crates/dbx-core/src/db/clickhouse_driver.rs | 2 ++ crates/dbx-core/src/db/elasticsearch_driver.rs | 5 +++++ crates/dbx-core/src/db/mysql.rs | 3 +++ crates/dbx-core/src/db/postgres.rs | 4 ++++ crates/dbx-core/src/db/rqlite_driver.rs | 2 ++ crates/dbx-core/src/db/sqlite.rs | 2 ++ crates/dbx-core/src/db/sqlserver.rs | 5 +++++ crates/dbx-core/src/query.rs | 15 +++++++++++++++ crates/dbx-core/src/transfer.rs | 2 ++ crates/dbx-core/src/types.rs | 4 ++++ 12 files changed, 55 insertions(+) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index fefa92f12d..0ca8289f8c 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -381,6 +381,11 @@ function shortTypeName(t: string): string { return t; } +function headerColumnSortable(actualColIdx: number): boolean { + const resolved = props.result.column_sortables?.[actualColIdx]; + return resolved !== undefined ? resolved : true; +} + function typeColorClass(t: string): string { // Strip precision/scale suffix like (20,6) const base = t.replace(/\(.*\)$/, "").toLowerCase(); @@ -6757,6 +6762,7 @@ const gridContextMenuItems = computed(() => { - + + +import { ref } from "vue"; +import type { HTMLAttributes } from "vue"; +import LightTooltip from "@/components/ui/LightTooltip.vue"; +import { cn } from "@/lib/utils"; + +const props = withDefaults( + defineProps<{ + text: string; + class?: HTMLAttributes["class"]; + side?: "top" | "right" | "bottom" | "left"; + sideOffset?: number; + delay?: number; + openOnFocus?: boolean; + }>(), + { + side: "top", + sideOffset: 8, + delay: 0, + openOnFocus: true, + }, +); + +const textRef = ref(); + +function isTooltipDisabled(): boolean { + const el = textRef.value; + if (!el) return true; + return el.scrollWidth - el.clientWidth <= 2; +} + + + From 348f39ff35a26e050802dd2c5b0e1b78e5a9e8b9 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 9 Jun 2026 17:53:07 +0800 Subject: [PATCH 11/42] perf(tauri): move blocking command work off async runtime --- crates/dbx-core/src/jdbc.rs | 19 ++++++++--- src-tauri/src/commands/app_settings.rs | 10 ++++-- src-tauri/src/commands/csv_export.rs | 10 ++++-- src-tauri/src/commands/external_sql.rs | 15 +++++++-- src-tauri/src/commands/keychain.rs | 46 ++++++++++++++------------ src-tauri/src/commands/mcp.rs | 20 +++++++---- src-tauri/src/commands/plugins.rs | 25 +++++++++++--- src-tauri/src/commands/system_fonts.rs | 6 ++-- src-tauri/src/commands/text_export.rs | 26 ++++++++++----- src-tauri/src/commands/update.rs | 4 +-- src-tauri/src/commands/xlsx_export.rs | 18 ++++++---- 11 files changed, 135 insertions(+), 64 deletions(-) diff --git a/crates/dbx-core/src/jdbc.rs b/crates/dbx-core/src/jdbc.rs index b73d50d9c2..c01021d2cd 100644 --- a/crates/dbx-core/src/jdbc.rs +++ b/crates/dbx-core/src/jdbc.rs @@ -79,15 +79,24 @@ pub async fn get_jdbc_plugin_status(plugins_root: &Path) -> Result Result { let bytes = download_jdbc_plugin_zip().await?; let plugin_dir = plugins_root.join("jdbc"); - install_jdbc_plugin_zip(&bytes, &plugin_dir)?; - jdbc_plugin_status_from_dir(&plugin_dir).await + let status_dir = plugin_dir.clone(); + tokio::task::spawn_blocking(move || install_jdbc_plugin_zip(&bytes, &plugin_dir)) + .await + .map_err(|err| err.to_string())??; + jdbc_plugin_status_from_dir(&status_dir).await } pub async fn install_jdbc_plugin_from_file(plugins_root: &Path, file_path: &str) -> Result { - let bytes = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?; let plugin_dir = plugins_root.join("jdbc"); - install_jdbc_plugin_zip(&bytes, &plugin_dir)?; - jdbc_plugin_status_from_dir(&plugin_dir).await + let status_dir = plugin_dir.clone(); + let file_path = file_path.to_string(); + tokio::task::spawn_blocking(move || { + let bytes = std::fs::read(file_path).map_err(|e| format!("Failed to read file: {e}"))?; + install_jdbc_plugin_zip(&bytes, &plugin_dir) + }) + .await + .map_err(|err| err.to_string())??; + jdbc_plugin_status_from_dir(&status_dir).await } pub fn uninstall_jdbc_plugin(plugins_root: &Path) -> Result { diff --git a/src-tauri/src/commands/app_settings.rs b/src-tauri/src/commands/app_settings.rs index d1b0e63b15..7b645b18ea 100644 --- a/src-tauri/src/commands/app_settings.rs +++ b/src-tauri/src/commands/app_settings.rs @@ -1,4 +1,4 @@ -use std::sync::Arc; +use std::{path::PathBuf, sync::Arc}; use dbx_core::storage::DesktopSettings; use tauri::{AppHandle, Manager, State}; @@ -37,9 +37,15 @@ pub async fn save_pinned_tree_node_ids(state: State<'_, Arc>, ids: Vec #[tauri::command] pub async fn load_native_debug_logs(app: AppHandle) -> Result { + let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?; + tauri::async_runtime::spawn_blocking(move || load_native_debug_logs_from_dir(log_dir)) + .await + .map_err(|err| err.to_string())? +} + +fn load_native_debug_logs_from_dir(log_dir: PathBuf) -> Result { const MAX_FILES: usize = 6; const MAX_FILE_BYTES: u64 = 512 * 1024; - let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?; if !log_dir.exists() { return Ok(format!("Native log dir does not exist yet: {}", log_dir.display())); } diff --git a/src-tauri/src/commands/csv_export.rs b/src-tauri/src/commands/csv_export.rs index 3ed996b5b5..1381aab7e8 100644 --- a/src-tauri/src/commands/csv_export.rs +++ b/src-tauri/src/commands/csv_export.rs @@ -15,9 +15,13 @@ pub struct QueryResultCsvExportRequest { } #[tauri::command] -pub fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> { - let csv = format_csv(&request.columns, &request.rows); - std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string()) +pub async fn export_query_result_csv(request: QueryResultCsvExportRequest) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let csv = format_csv(&request.columns, &request.rows); + std::fs::write(&request.file_path, format!("\u{FEFF}{csv}")).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string())? } #[tauri::command] diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index a162d8f187..64b1f477e1 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -12,8 +12,8 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) -> } #[tauri::command] -pub fn read_external_sql_file(path: String) -> Result { - read_external_sql_file_content(Path::new(&path)) +pub async fn read_external_sql_file(path: String) -> Result { + read_external_sql_file_content_async(PathBuf::from(path)).await } #[derive(Default)] @@ -62,7 +62,8 @@ pub fn is_sql_file_path(path: &Path) -> bool { path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false) } -pub fn read_external_sql_file_content(path: &Path) -> Result { +#[cfg(test)] +fn read_external_sql_file_content(path: &Path) -> Result { if !is_sql_file_path(path) { return Err("Only .sql files can be opened this way".to_string()); } @@ -70,6 +71,14 @@ pub fn read_external_sql_file_content(path: &Path) -> Result { decode_sql_file_bytes(&bytes) } +async fn read_external_sql_file_content_async(path: PathBuf) -> Result { + if !is_sql_file_path(&path) { + return Err("Only .sql files can be opened this way".to_string()); + } + let bytes = tokio::fs::read(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?; + decode_sql_file_bytes(&bytes) +} + fn dedupe_paths(paths: Vec) -> Vec { let mut unique = Vec::new(); for path in paths { diff --git a/src-tauri/src/commands/keychain.rs b/src-tauri/src/commands/keychain.rs index d196d5f920..020a311fb0 100644 --- a/src-tauri/src/commands/keychain.rs +++ b/src-tauri/src/commands/keychain.rs @@ -1,5 +1,3 @@ -use std::process::Command; - /// Read a macOS Keychain generic password by service name. /// Triggers a system authorization dialog (Touch ID / password) for each unique service. #[tauri::command] @@ -12,28 +10,34 @@ pub async fn read_keychain_password(service: String, account: Option) -> #[cfg(target_os = "macos")] { - let mut cmd = Command::new("security"); - cmd.args(["find-generic-password", "-s", &service, "-w"]); - if let Some(ref acct) = account { - cmd.args(["-a", acct]); - } + tauri::async_runtime::spawn_blocking(move || read_keychain_password_blocking(service, account)) + .await + .map_err(|err| err.to_string())? + } +} - let output = cmd.output().map_err(|e| format!("Failed to run security command: {e}"))?; +#[cfg(target_os = "macos")] +fn read_keychain_password_blocking(service: String, account: Option) -> Result { + let mut cmd = std::process::Command::new("security"); + cmd.args(["find-generic-password", "-s", &service, "-w"]); + if let Some(ref acct) = account { + cmd.args(["-a", acct]); + } - if output.status.success() { - let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); - Ok(password) + let output = cmd.output().map_err(|e| format!("Failed to run security command: {e}"))?; + + if output.status.success() { + let password = String::from_utf8_lossy(&output.stdout).trim().to_string(); + Ok(password) + } else { + // Exit code 44 = user cancelled the authorization dialog + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + if output.status.code() == Some(44) || stderr.contains("User canceled") { + Ok(String::new()) + } else if stderr.contains("could not be found") || stderr.contains("The specified item could not be found") { + Ok(String::new()) } else { - // Exit code 44 = user cancelled the authorization dialog - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - if output.status.code() == Some(44) || stderr.contains("User canceled") { - Ok(String::new()) // Return empty instead of error for cancelled - } else if stderr.contains("could not be found") || stderr.contains("The specified item could not be found") - { - Ok(String::new()) // No entry found — not an error - } else { - Err(format!("Keychain read failed: {}", stderr.trim())) - } + Err(format!("Keychain read failed: {}", stderr.trim())) } } } diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index f6cd04bc50..50993b2870 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -31,11 +31,17 @@ struct NpmLatestPackage { #[tauri::command] pub async fn check_mcp_server_status() -> Result { - let npm_available = command_success("npm", &["--version"]); - let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line); - let current_version = if npm_available { installed_mcp_version() } else { None }; - let bin_path = locate_mcp_bin(); - let latest_version = fetch_latest_mcp_version().await.ok(); + let local_status = tauri::async_runtime::spawn_blocking(|| { + let npm_available = command_success("npm", &["--version"]); + let node_version = command_stdout("node", &["--version"]).ok().and_then(first_non_empty_line); + let current_version = if npm_available { installed_mcp_version() } else { None }; + let bin_path = locate_mcp_bin(); + (npm_available, node_version, current_version, bin_path) + }); + let latest_version = fetch_latest_mcp_version(); + let (local_status, latest_version) = tokio::join!(local_status, latest_version); + let (npm_available, node_version, current_version, bin_path) = local_status.map_err(|err| err.to_string())?; + let latest_version = latest_version.ok(); let update_available = current_version .as_deref() .zip(latest_version.as_deref()) @@ -58,7 +64,9 @@ pub async fn check_mcp_server_status() -> Result { async fn fetch_latest_mcp_version() -> Result { let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker"); - if let Some(proxy_url) = dbx_core::update::system_proxy_url() { + let proxy_url = + tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.map_err(|e| e.to_string())?; + if let Some(proxy_url) = proxy_url { let proxy = reqwest::Proxy::all(&proxy_url).map_err(|e| format!("Invalid system proxy URL: {e}"))?; builder = builder.proxy(proxy); } diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs index a5644c683e..6488acd77d 100644 --- a/src-tauri/src/commands/plugins.rs +++ b/src-tauri/src/commands/plugins.rs @@ -8,7 +8,10 @@ use super::connection::AppState; #[tauri::command] pub async fn list_plugins(state: State<'_, Arc>) -> Result, String> { - state.plugins.list_installed() + let root_dir = state.plugins.root_dir().to_path_buf(); + tauri::async_runtime::spawn_blocking(move || dbx_core::plugins::PluginRegistry::new(root_dir).list_installed()) + .await + .map_err(|err| err.to_string())? } #[tauri::command] @@ -31,12 +34,18 @@ pub async fn install_jdbc_plugin_local( #[tauri::command] pub async fn uninstall_jdbc_plugin(state: State<'_, Arc>) -> Result { - jdbc::uninstall_jdbc_plugin(state.plugins.root_dir()) + let root_dir = state.plugins.root_dir().to_path_buf(); + tauri::async_runtime::spawn_blocking(move || jdbc::uninstall_jdbc_plugin(&root_dir)) + .await + .map_err(|err| err.to_string())? } #[tauri::command] pub async fn list_jdbc_drivers(state: State<'_, Arc>) -> Result, String> { - jdbc::list_jdbc_drivers(state.plugins.root_dir()) + let root_dir = state.plugins.root_dir().to_path_buf(); + tauri::async_runtime::spawn_blocking(move || jdbc::list_jdbc_drivers(&root_dir)) + .await + .map_err(|err| err.to_string())? } #[tauri::command] @@ -44,10 +53,16 @@ pub async fn import_jdbc_drivers( state: State<'_, Arc>, paths: Vec, ) -> Result, String> { - jdbc::import_jdbc_drivers(state.plugins.root_dir(), &paths) + let root_dir = state.plugins.root_dir().to_path_buf(); + tauri::async_runtime::spawn_blocking(move || jdbc::import_jdbc_drivers(&root_dir, &paths)) + .await + .map_err(|err| err.to_string())? } #[tauri::command] pub async fn delete_jdbc_driver(state: State<'_, Arc>, path: String) -> Result, String> { - jdbc::delete_jdbc_driver(state.plugins.root_dir(), &path) + let root_dir = state.plugins.root_dir().to_path_buf(); + tauri::async_runtime::spawn_blocking(move || jdbc::delete_jdbc_driver(&root_dir, &path)) + .await + .map_err(|err| err.to_string())? } diff --git a/src-tauri/src/commands/system_fonts.rs b/src-tauri/src/commands/system_fonts.rs index 3b09678831..051d2acd1e 100644 --- a/src-tauri/src/commands/system_fonts.rs +++ b/src-tauri/src/commands/system_fonts.rs @@ -1,4 +1,6 @@ #[tauri::command] -pub fn list_system_fonts() -> Result, String> { - Ok(dbx_core::jdbc::list_system_fonts()) +pub async fn list_system_fonts() -> Result, String> { + tauri::async_runtime::spawn_blocking(|| Ok(dbx_core::jdbc::list_system_fonts())) + .await + .map_err(|err| err.to_string())? } diff --git a/src-tauri/src/commands/text_export.rs b/src-tauri/src/commands/text_export.rs index 7077f0a348..7e3267ea33 100644 --- a/src-tauri/src/commands/text_export.rs +++ b/src-tauri/src/commands/text_export.rs @@ -11,19 +11,29 @@ pub struct QueryResultTextExportRequest { } impl QueryResultTextExportRequest { - fn data(&self) -> QueryResultTextExportData { - QueryResultTextExportData { columns: self.columns.clone(), rows: self.rows.clone() } + fn into_data(self) -> QueryResultTextExportData { + QueryResultTextExportData { columns: self.columns, rows: self.rows } } } #[tauri::command] -pub fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> { - let content = format_json(&request.data())?; - std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string()) +pub async fn export_query_result_json(request: QueryResultTextExportRequest) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let file_path = request.file_path.clone(); + let content = format_json(&request.into_data())?; + std::fs::write(file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string())? } #[tauri::command] -pub fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> { - let content = format_markdown(&request.data()); - std::fs::write(&request.file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string()) +pub async fn export_query_result_markdown(request: QueryResultTextExportRequest) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let file_path = request.file_path.clone(); + let content = format_markdown(&request.into_data()); + std::fs::write(file_path, format!("\u{FEFF}{content}")).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string())? } diff --git a/src-tauri/src/commands/update.rs b/src-tauri/src/commands/update.rs index 768949a019..412099b655 100644 --- a/src-tauri/src/commands/update.rs +++ b/src-tauri/src/commands/update.rs @@ -10,6 +10,6 @@ pub async fn check_for_updates() -> Result { } #[tauri::command] -pub fn get_system_proxy_url() -> Option { - dbx_core::update::system_proxy_url() +pub async fn get_system_proxy_url() -> Option { + tauri::async_runtime::spawn_blocking(dbx_core::update::system_proxy_url).await.ok().flatten() } diff --git a/src-tauri/src/commands/xlsx_export.rs b/src-tauri/src/commands/xlsx_export.rs index f6ca0cf997..78684f1b2b 100644 --- a/src-tauri/src/commands/xlsx_export.rs +++ b/src-tauri/src/commands/xlsx_export.rs @@ -12,11 +12,15 @@ pub struct QueryResultXlsxExportRequest { } #[tauri::command] -pub fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> { - let workbook = build_xlsx_workbook(&XlsxWorksheetData { - sheet_name: request.sheet_name, - columns: request.columns, - rows: request.rows, - })?; - std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) +pub async fn export_query_result_xlsx(request: QueryResultXlsxExportRequest) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || { + let workbook = build_xlsx_workbook(&XlsxWorksheetData { + sheet_name: request.sheet_name, + columns: request.columns, + rows: request.rows, + })?; + std::fs::write(&request.file_path, workbook).map_err(|err| err.to_string()) + }) + .await + .map_err(|err| err.to_string())? } From 2050bee75cbcacd22736c2afa34cf1d0cf05a49a Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 9 Jun 2026 17:57:25 +0800 Subject: [PATCH 12/42] feat(query): show elapsed time while executing --- .../src/components/layout/ContentArea.vue | 33 ++++++++++++++++++- apps/desktop/src/lib/openTabsPersistence.ts | 1 + apps/desktop/src/stores/queryStore.ts | 5 +++ apps/desktop/src/types/database.ts | 1 + 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index ab1fe5d7ba..2bb8b8afdb 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -1,5 +1,5 @@ + + diff --git a/crates/dbx-core/assets/database-drivers.manifest.json b/crates/dbx-core/assets/database-drivers.manifest.json index 3724014c6f..4a7261778a 100644 --- a/crates/dbx-core/assets/database-drivers.manifest.json +++ b/crates/dbx-core/assets/database-drivers.manifest.json @@ -495,6 +495,17 @@ "skipTcpProbe": true, "defaultPort": 5138 }, + { + "dbType": "etcd", + "label": "etcd", + "runtimeMode": "agent", + "mcpMode": "bridge", + "agentKey": "etcd", + "singleConnectionPool": false, + "metadataConnectionScoped": false, + "skipTcpProbe": true, + "defaultPort": 2379 + }, { "dbType": "iotdb", "label": "Apache IoTDB", diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index a5efd88730..1fcda9f50c 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -34,11 +34,18 @@ pub struct DesktopSettings { pub icon_theme: DesktopIconTheme, #[serde(default)] pub debug_logging_enabled: bool, + #[serde(default)] + pub saved_sql_sync_dir: Option, } impl Default for DesktopSettings { fn default() -> Self { - Self { show_tray_icon: true, icon_theme: DesktopIconTheme::Default, debug_logging_enabled: false } + Self { + show_tray_icon: true, + icon_theme: DesktopIconTheme::Default, + debug_logging_enabled: false, + saved_sql_sync_dir: None, + } } } @@ -124,6 +131,7 @@ const SCHEMA_STATEMENTS: &[&str] = &[ id TEXT PRIMARY KEY, connection_id TEXT NOT NULL, name TEXT NOT NULL DEFAULT '', + order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", @@ -135,6 +143,7 @@ const SCHEMA_STATEMENTS: &[&str] = &[ database_name TEXT NOT NULL DEFAULT '', schema_name TEXT, sql_text TEXT NOT NULL DEFAULT '', + order_index INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL DEFAULT '' )", @@ -155,6 +164,7 @@ impl Storage { conn.execute(statement, []).map_err(|e| e.to_string())?; } ensure_history_columns_sync(conn)?; + ensure_saved_sql_columns_sync(conn)?; Ok(()) }) } @@ -196,6 +206,34 @@ fn ensure_history_columns_sync(conn: &Connection) -> Result<(), String> { Ok(()) } +fn ensure_saved_sql_columns_sync(conn: &Connection) -> Result<(), String> { + const FOLDER_COLUMNS: &[(&str, &str)] = &[("order_index", "INTEGER NOT NULL DEFAULT 0")]; + const FILE_COLUMNS: &[(&str, &str)] = &[("order_index", "INTEGER NOT NULL DEFAULT 0")]; + + ensure_table_columns(conn, "saved_sql_folders", FOLDER_COLUMNS)?; + ensure_table_columns(conn, "saved_sql_files", FILE_COLUMNS)?; + Ok(()) +} + +fn ensure_table_columns(conn: &Connection, table_name: &str, columns: &[(&str, &str)]) -> Result<(), String> { + let mut stmt = + conn.prepare(&format!("SELECT name FROM pragma_table_info('{table_name}')")).map_err(|e| e.to_string())?; + let existing = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())? + .collect::, _>>() + .map_err(|e| e.to_string())?; + + for (name, definition) in columns { + if existing.contains(*name) { + continue; + } + conn.execute(&format!("ALTER TABLE {table_name} ADD COLUMN {name} {definition}"), []) + .map_err(|e| e.to_string())?; + } + Ok(()) +} + fn ssh_tunnel_secret_segment(index: usize, hop: &crate::models::connection::SshTunnelConfig) -> String { if hop.id.trim().is_empty() { index.to_string() @@ -436,6 +474,14 @@ impl Storage { "debug_logging_enabled".to_string(), serde_json::Value::Bool(desktop_settings.debug_logging_enabled), ); + match desktop_settings.saved_sql_sync_dir.as_ref().filter(|path| !path.trim().is_empty()) { + Some(path) => { + settings.insert("saved_sql_sync_dir".to_string(), serde_json::Value::String(path.clone())); + } + None => { + settings.remove("saved_sql_sync_dir"); + } + } self.save_app_settings_json(&settings).await } @@ -452,6 +498,12 @@ impl Storage { .get("debug_logging_enabled") .and_then(|value| value.as_bool()) .unwrap_or_else(|| DesktopSettings::default().debug_logging_enabled), + saved_sql_sync_dir: settings + .get("saved_sql_sync_dir") + .and_then(|value| value.as_str()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToString::to_string), }) } @@ -785,9 +837,16 @@ impl Storage { for folder in &library.folders { tx.execute( - "INSERT INTO saved_sql_folders (id, connection_id, name, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?)", - params![folder.id, folder.connection_id, folder.name, folder.created_at, folder.updated_at], + "INSERT INTO saved_sql_folders (id, connection_id, name, order_index, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?)", + params![ + folder.id, + folder.connection_id, + folder.name, + folder.order_index, + folder.created_at, + folder.updated_at + ], ) .map_err(|e| e.to_string())?; } @@ -795,8 +854,8 @@ impl Storage { for file in &library.files { tx.execute( "INSERT INTO saved_sql_files \ - (id, connection_id, folder_id, name, database_name, schema_name, sql_text, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", + (id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", params![ file.id, file.connection_id, @@ -805,6 +864,7 @@ impl Storage { file.database, file.schema, file.sql, + file.order_index, file.created_at, file.updated_at ], @@ -821,8 +881,8 @@ impl Storage { self.with_conn(|conn| { let mut folder_stmt = conn .prepare( - "SELECT id, connection_id, name, created_at, updated_at \ - FROM saved_sql_folders ORDER BY connection_id, name COLLATE NOCASE", + "SELECT id, connection_id, name, order_index, created_at, updated_at \ + FROM saved_sql_folders ORDER BY order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let folders = folder_stmt @@ -831,8 +891,9 @@ impl Storage { id: row.get(0)?, connection_id: row.get(1)?, name: row.get(2)?, - created_at: row.get(3)?, - updated_at: row.get(4)?, + order_index: row.get(3)?, + created_at: row.get(4)?, + updated_at: row.get(5)?, }) }) .map_err(|e| e.to_string())? @@ -841,8 +902,8 @@ impl Storage { let mut file_stmt = conn .prepare( - "SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, created_at, updated_at \ - FROM saved_sql_files ORDER BY connection_id, folder_id, name COLLATE NOCASE", + "SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at \ + FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE", ) .map_err(|e| e.to_string())?; let files = file_stmt @@ -855,8 +916,9 @@ impl Storage { database: row.get(4)?, schema: row.get(5)?, sql: row.get(6)?, - created_at: row.get(7)?, - updated_at: row.get(8)?, + order_index: row.get(7)?, + created_at: row.get(8)?, + updated_at: row.get(9)?, }) }) .map_err(|e| e.to_string())? @@ -872,13 +934,21 @@ impl Storage { let folder = folder.clone(); self.with_conn(move |conn| { conn.execute( - "INSERT INTO saved_sql_folders (id, connection_id, name, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?) \ + "INSERT INTO saved_sql_folders (id, connection_id, name, order_index, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ connection_id = excluded.connection_id, \ name = excluded.name, \ + order_index = excluded.order_index, \ updated_at = excluded.updated_at", - params![folder.id, folder.connection_id, folder.name, folder.created_at, folder.updated_at], + params![ + folder.id, + folder.connection_id, + folder.name, + folder.order_index, + folder.created_at, + folder.updated_at + ], ) .map(|_| ()) .map_err(|e| e.to_string()) @@ -902,8 +972,8 @@ impl Storage { self.with_conn(move |conn| { conn.execute( "INSERT INTO saved_sql_files \ - (id, connection_id, folder_id, name, database_name, schema_name, sql_text, created_at, updated_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) \ + (id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, created_at, updated_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT(id) DO UPDATE SET \ connection_id = excluded.connection_id, \ folder_id = excluded.folder_id, \ @@ -911,6 +981,7 @@ impl Storage { database_name = excluded.database_name, \ schema_name = excluded.schema_name, \ sql_text = excluded.sql_text, \ + order_index = excluded.order_index, \ updated_at = excluded.updated_at", params![ file.id, @@ -920,6 +991,7 @@ impl Storage { file.database, file.schema, file.sql, + file.order_index, file.created_at, file.updated_at ], @@ -1348,6 +1420,7 @@ mod tests { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, debug_logging_enabled: true, + saved_sql_sync_dir: None, }) .await .unwrap(); @@ -1355,7 +1428,12 @@ mod tests { assert_eq!(storage.load_password_hash().await.unwrap(), Some("hash-1".to_string())); assert_eq!( storage.load_desktop_settings().await.unwrap(), - DesktopSettings { show_tray_icon: false, icon_theme: DesktopIconTheme::Black, debug_logging_enabled: true } + DesktopSettings { + show_tray_icon: false, + icon_theme: DesktopIconTheme::Black, + debug_logging_enabled: true, + saved_sql_sync_dir: None + } ); } From 9ffa4c41d95b97e75f8a4fa538c47e2b190d2391 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 10 Jun 2026 02:46:10 +0800 Subject: [PATCH 24/42] feat: add SQL library panel with folder/file management, rename, and sync support --- apps/desktop/src/App.vue | 53 +++- .../src/components/editor/QueryHistory.vue | 27 +-- .../src/components/layout/AppToolbar.vue | 18 ++ .../src/components/sidebar/TreeItem.vue | 224 +---------------- .../desktop/src/composables/usePanelResize.ts | 4 + apps/desktop/src/i18n/locales/en.ts | 26 ++ apps/desktop/src/i18n/locales/es.ts | 26 ++ apps/desktop/src/i18n/locales/it.ts | 26 ++ apps/desktop/src/i18n/locales/pt-BR.ts | 26 ++ apps/desktop/src/i18n/locales/zh-CN.ts | 27 +++ apps/desktop/src/i18n/locales/zh-TW.ts | 26 ++ apps/desktop/src/lib/api.ts | 3 + apps/desktop/src/lib/http.ts | 15 +- .../desktop/src/lib/sidebarActiveTabTarget.ts | 12 - apps/desktop/src/lib/sidebarNodeOrdering.ts | 7 +- apps/desktop/src/lib/sidebarTreeItemLayout.ts | 12 +- apps/desktop/src/lib/tauri.ts | 24 ++ apps/desktop/src/lib/treeNodeClick.ts | 1 - apps/desktop/src/stores/connectionStore.ts | 80 +----- apps/desktop/src/stores/queryStore.ts | 2 +- apps/desktop/src/stores/savedSqlStore.ts | 228 +++++++++++++++++- apps/desktop/src/stores/settingsStore.ts | 3 + apps/desktop/src/types/database.ts | 2 + crates/dbx-core/src/saved_sql.rs | 4 + src-tauri/src/commands/saved_sql.rs | 183 ++++++++++++++ src-tauri/src/lib.rs | 4 + 26 files changed, 711 insertions(+), 352 deletions(-) diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index c8e4db963c..b56c3aae60 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -70,6 +70,7 @@ import type { AiAction } from "@/lib/ai"; const AiAssistant = defineAsyncComponent(() => import("@/components/editor/AiAssistant.vue")); const QueryHistory = defineAsyncComponent(() => import("@/components/editor/QueryHistory.vue")); +const SqlLibraryPanel = defineAsyncComponent(() => import("@/components/layout/SqlLibraryPanel.vue")); const DriverStorePage = defineAsyncComponent(() => import("@/components/config/DriverStoreDialog.vue")); const UpdateDialog = defineAsyncComponent(() => import("@/components/layout/UpdateDialog.vue")); const LoginPage = defineAsyncComponent(() => import("@/components/auth/LoginPage.vue")); @@ -116,10 +117,19 @@ const showDriverStore = ref(false); const agentDriverUpdateCount = ref(0); const showHistory = ref(false); const showAiPanel = ref(safeLocalStorageGet("dbx-ai-panel-open") === "true"); +const showSqlLibraryPanel = ref(safeLocalStorageGet("dbx-sql-library-open") === "true"); const sidebarOpen = ref(safeLocalStorageGet("dbx-sidebar-open") !== "false"); const aiPanelReady = ref(false); -const { sidebarWidth, aiPanelWidth, historyWidth, startSidebarResize, startAiPanelResize, startHistoryResize } = - usePanelResize(); +const { + sidebarWidth, + aiPanelWidth, + historyWidth, + sqlLibraryWidth, + startSidebarResize, + startAiPanelResize, + startHistoryResize, + startSqlLibraryResize, +} = usePanelResize(); const aiAssistantRef = ref(null); const appSidebarRef = ref | null>(null); const contentAreaRef = ref | null>(null); @@ -245,8 +255,7 @@ const connectionStats = computed(() => ({ })); const recentConnections = computed(() => connectionStore.connections.slice(0, 5)); const saveSqlFolders = computed(() => { - const tab = activeTab.value; - return tab ? savedSqlStore.listFolders(tab.connectionId) : []; + return savedSqlStore.allFolders; }); async function applyUiScale(scale: number) { @@ -325,6 +334,11 @@ function toggleAiPanel() { safeLocalStorageSet("dbx-ai-panel-open", String(showAiPanel.value)); } +function toggleSqlLibrary() { + showSqlLibraryPanel.value = !showSqlLibraryPanel.value; + safeLocalStorageSet("dbx-sql-library-open", String(showSqlLibraryPanel.value)); +} + function fixWithAi(errorMessage: string) { if (!showAiPanel.value) { showAiPanel.value = true; @@ -371,8 +385,9 @@ function formatActiveSql() { } function defaultSavedSqlName(title: string) { - const trimmed = title.trim() || "Query"; - return trimmed.endsWith(".sql") ? trimmed : `${trimmed}.sql`; + const trimmed = title.trim() || "query"; + const normalized = trimmed.replace(/\s+/g, "_"); + return normalized.endsWith(".sql") ? normalized : `${normalized}.sql`; } async function openSaveSqlDialog() { @@ -394,7 +409,6 @@ async function openSaveSqlDialog() { sql: tab.sql, }); queryStore.linkSavedSql(tab.id, updated.id, updated.name); - connectionStore.refreshSavedSqlTree(tab.connectionId); toast(t("savedSql.saved"), 2000); return; } @@ -445,7 +459,6 @@ async function confirmSaveSqlToLibrary() { sql: tab.sql, }); queryStore.linkSavedSql(tab.id, saved.id, saved.name); - connectionStore.refreshSavedSqlTree(tab.connectionId); showSaveSqlDialog.value = false; toast(t("savedSql.saved"), 2000); } catch (e: any) { @@ -868,9 +881,10 @@ function onLoginSuccess() { function initApp() { const t0 = performance.now(); console.log("[STARTUP] initApp begin"); - settingsStore.initDesktopSettings().catch(() => {}); - savedSqlStore - .initFromStorage() + settingsStore + .initDesktopSettings() + .catch(() => {}) + .then(() => savedSqlStore.initFromStorage()) .then(() => { console.log(`[STARTUP] savedSqlStore.initFromStorage: ${(performance.now() - t0).toFixed(0)}ms`); return connectionStore.initFromDisk(); @@ -1010,6 +1024,7 @@ onUnmounted(() => { :theme-mode="themeMode" :show-ai-panel="showAiPanel" :show-history="showHistory" + :show-sql-library="showSqlLibraryPanel" :show-driver-store="showDriverStore" :checking-updates="checkingUpdates" :has-update-available="toolbarHasUpdateAvailable" @@ -1021,6 +1036,7 @@ onUnmounted(() => { @set-theme-mode="setThemeMode" @toggle-ai="toggleAiPanel" @toggle-history="showHistory = !showHistory" + @toggle-sql-library="toggleSqlLibrary" @open-github="openGitHub" @open-settings="showSettingsDialog = true" @open-driver-store="showDriverStore = !showDriverStore" @@ -1235,6 +1251,21 @@ onUnmounted(() => { @close="showHistory = false" /> + +
+
+
+ +
+
store.load());