diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index bdc9502ad..daa91c5e5 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -33,8 +33,10 @@ import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; import { findTreeNodeById, resolveNewQueryTarget } from "@/lib/newQueryContext"; import { buildExecutableObjectSourceStatements, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; import { resolveExecutableSql, resolveExecutableSqlWithBackend } from "@/lib/sqlExecutionTarget"; +import { uuid } from "@/lib/utils"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { sqlFileTitleFromPath } from "@/lib/sqlFileOpen"; +import type { ConnectionConfig } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink"; import { isBrowserReloadShortcut, @@ -210,6 +212,7 @@ const { onExecuteSql, onReloadData, onPaginate, onSort } = useDataGridActions(ac const { setupTauriListeners, cleanupTauriListeners } = useTauriEvents({ openTableTarget, openSqlFilePath, + openDbFilePath, openConnectionDeepLink, }); useVisibilityChange(); @@ -488,6 +491,73 @@ async function openPendingSqlFiles() { } } +const DB_EXTENSIONS = [".db", ".sqlite", ".sqlite3", ".duckdb"]; + +function getDbTypeFromPath(path: string): "sqlite" | "duckdb" | null { + const lower = path.toLowerCase(); + if (lower.endsWith(".duckdb")) return "duckdb"; + if (DB_EXTENSIONS.some((ext) => lower.endsWith(ext))) return "sqlite"; + return null; +} + +async function openDbFilePath(path: string) { + if (!isTauriRuntime()) return; + try { + const name = path.split("/").pop()?.split("\\").pop() || path; + const dbType = getDbTypeFromPath(path); + if (!dbType) return; + + // Check for existing connection with the same file path + const existing = connectionStore.connections.find((c) => c.host === path); + if (existing) { + const { ask } = await import("@tauri-apps/plugin-dialog"); + const switchTo = await ask(`A connection to "${path}" already exists. Switch to it?`, { + title: "Database Already Open", + kind: "info", + }); + if (switchTo) { + connectionStore.activeConnectionId = existing.id; + connectionStore.ensureConnected(existing.id).catch(() => {}); + const node = connectionStore.treeNodes.find((n) => n.id === existing.id); + if (node && !node.isExpanded) { + connectionStore.loadDatabases(existing.id); + } + } + return; + } + + const config: ConnectionConfig = { + id: uuid(), + name, + db_type: dbType, + driver_profile: dbType, + driver_label: dbType === "duckdb" ? "DuckDB" : "SQLite", + url_params: "", + host: path, + port: 0, + username: "", + password: "", + }; + await connectionStore.addConnection(config); + void connectionStore.connect(config); + toast(t("welcome.fileOpened", { name })); + } catch (e: any) { + toast(t("toolbar.sqlOpenFailed", { message: e?.message || String(e) }), 5000); + } +} + +async function openPendingDbFiles() { + if (!isTauriRuntime()) return; + try { + const paths = await api.pendingOpenDbFiles(); + for (const path of paths) { + await openDbFilePath(path); + } + } catch { + /* ignore startup file-open probing errors */ + } +} + async function openConnectionDeepLink(url: string) { try { const draft = parseConnectionDeepLink(url); @@ -856,6 +926,7 @@ onMounted(async () => { .catch(() => {}); setupTauriListeners(); void openPendingSqlFiles(); + void openPendingDbFiles(); void openPendingConnectionLinks(); console.log(`[STARTUP] onMounted sync done: ${(performance.now() - mountStart).toFixed(0)}ms`); }); diff --git a/apps/desktop/src/composables/useTauriEvents.ts b/apps/desktop/src/composables/useTauriEvents.ts index 1a013d27c..423a2f2dd 100644 --- a/apps/desktop/src/composables/useTauriEvents.ts +++ b/apps/desktop/src/composables/useTauriEvents.ts @@ -5,6 +5,7 @@ import type { NavigationTarget } from "@/composables/useNavigationTargets"; export function useTauriEvents(deps: { openTableTarget: (target: NavigationTarget) => Promise; openSqlFilePath: (path: string) => Promise; + openDbFilePath: (path: string) => Promise; openConnectionDeepLink: (url: string) => Promise; }) { const connectionStore = useConnectionStore(); @@ -90,6 +91,17 @@ export function useTauriEvents(deps: { } }).then((unlisten) => unlistenHandles.push(unlisten)); + listen("dbx-open-db-files", async (event) => { + try { + for (const path of event.payload) { + await deps.openDbFilePath(path); + } + focusCurrentWindow(); + } catch (e) { + console.error("[DBX] dbx-open-db-files error:", e); + } + }).then((unlisten) => unlistenHandles.push(unlisten)); + listen("dbx-open-connection-links", async (event) => { try { for (const url of event.payload) { diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index f9a604d29..66ba18324 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -164,6 +164,7 @@ export const executeSqlFile = forward("executeSqlFile"); export const cancelSqlFileExecution = forward("cancelSqlFileExecution"); export const listenSqlFileProgress = forward("listenSqlFileProgress"); export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles"); +export const pendingOpenDbFiles = forward("pendingOpenDbFiles"); export const pendingOpenConnectionLinks = forward("pendingOpenConnectionLinks"); export const readExternalSqlFile = forward("readExternalSqlFile"); diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 61273581b..aadef2304 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -909,6 +909,10 @@ export async function pendingOpenSqlFiles(): Promise { return []; } +export async function pendingOpenDbFiles(): Promise { + return []; +} + export async function pendingOpenConnectionLinks(): Promise { return []; } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index 15e643767..f16ce007d 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -317,6 +317,10 @@ export async function pendingOpenSqlFiles(): Promise { return invoke("pending_open_sql_files"); } +export async function pendingOpenDbFiles(): Promise { + return invoke("pending_open_db_files"); +} + export async function pendingOpenConnectionLinks(): Promise { return invoke("pending_open_connection_links"); } diff --git a/crates/dbx-core/src/agent_manager.rs b/crates/dbx-core/src/agent_manager.rs index 6270b67f0..12ec8b3a5 100644 --- a/crates/dbx-core/src/agent_manager.rs +++ b/crates/dbx-core/src/agent_manager.rs @@ -218,6 +218,12 @@ pub struct AgentManager { daemons: Mutex>, } +impl Default for AgentManager { + fn default() -> Self { + Self::new() + } +} + impl AgentManager { pub fn new() -> Self { let home = @@ -571,7 +577,7 @@ fn is_executable_file(path: &Path) -> bool { { use std::os::unix::fs::PermissionsExt; - return path.metadata().map(|meta| meta.permissions().mode() & 0o111 != 0).unwrap_or(false); + path.metadata().map(|meta| meta.permissions().mode() & 0o111 != 0).unwrap_or(false) } #[cfg(not(unix))] { diff --git a/crates/dbx-core/src/ai.rs b/crates/dbx-core/src/ai.rs index 7ac438931..d78363049 100644 --- a/crates/dbx-core/src/ai.rs +++ b/crates/dbx-core/src/ai.rs @@ -51,19 +51,14 @@ pub enum AiProvider { Custom, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum AiApiStyle { + #[default] Completions, Responses, } -impl Default for AiApiStyle { - fn default() -> Self { - Self::Completions - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AiConfig { @@ -430,7 +425,7 @@ pub async fn call_claude(client: &reqwest::Client, request: AiCompletionRequest) }); let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .headers(claude_headers(&request.config)?) .json(&body) .send() @@ -469,7 +464,7 @@ pub async fn call_openai_compatible(client: &reqwest::Client, request: AiComplet } let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .headers(headers) .json(&body_obj) .send() @@ -496,7 +491,7 @@ pub async fn call_responses_api(client: &reqwest::Client, request: AiCompletionR }); let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .headers(headers) .json(&body) .send() @@ -542,7 +537,7 @@ pub async fn call_gemini(client: &reqwest::Client, request: AiCompletionRequest) }); let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .query(&[("key", request.config.api_key.as_str())]) .header(CONTENT_TYPE, "application/json") .json(&body) @@ -668,7 +663,7 @@ async fn stream_claude( }); let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .headers(claude_headers(&request.config)?) .json(&body) .send() @@ -755,7 +750,7 @@ async fn stream_openai( } let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .headers(headers) .json(&body_obj) .send() @@ -842,7 +837,7 @@ async fn stream_responses_api( }); let res = client - .post(&resolve_endpoint(&request.config)) + .post(resolve_endpoint(&request.config)) .headers(headers) .json(&body) .send() @@ -931,7 +926,7 @@ async fn stream_gemini( }); let res = client - .post(&resolve_gemini_stream_endpoint(&request.config)) + .post(resolve_gemini_stream_endpoint(&request.config)) .query(&[("key", request.config.api_key.as_str()), ("alt", "sse")]) .header(CONTENT_TYPE, "application/json") .json(&body) diff --git a/crates/dbx-core/src/data_compare.rs b/crates/dbx-core/src/data_compare.rs index 4a3bdea34..441b97189 100644 --- a/crates/dbx-core/src/data_compare.rs +++ b/crates/dbx-core/src/data_compare.rs @@ -510,6 +510,7 @@ fn build_data_compare_select_sql( format!("SELECT {select_columns} FROM {table}{order_by} LIMIT {row_limit}{offset_sql};") } +#[allow(clippy::too_many_arguments)] async fn fetch_compare_rows( state: &AppState, connection_id: &str, @@ -618,7 +619,7 @@ fn literal_text(text: &str, database_type: Option) -> String { fn format_tdengine_timestamp_literal_text(text: &str) -> String { // Keep non-timestamp text unchanged; TDengine timestamp normalization is UI-parity best effort in Rust. - if text.len() < 19 || !text.as_bytes().get(10).is_some_and(|ch| *ch == b' ') { + if text.len() < 19 || text.as_bytes().get(10).is_none_or(|ch| *ch != b' ') { return text.to_string(); } text.replacen(' ', "T", 1) diff --git a/crates/dbx-core/src/database_export.rs b/crates/dbx-core/src/database_export.rs index a1bb2417e..5bf9d3a5d 100644 --- a/crates/dbx-core/src/database_export.rs +++ b/crates/dbx-core/src/database_export.rs @@ -265,7 +265,7 @@ pub async fn export_database_sql_core( .read() .await .get(&request.connection_id) - .map(|c| c.db_type.clone()) + .map(|c| c.db_type) .ok_or_else(|| format!("Connection config not found: {}", request.connection_id))?; // 2. Get pool diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index 4d58a8f12..a8563b5e9 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -30,7 +30,7 @@ pub fn connection_timeout() -> Duration { const JS_MAX_SAFE_INTEGER: i64 = 9_007_199_254_740_991; pub fn safe_i64_to_json(v: i64) -> serde_json::Value { - if v > JS_MAX_SAFE_INTEGER || v < -JS_MAX_SAFE_INTEGER { + if !(-JS_MAX_SAFE_INTEGER..=JS_MAX_SAFE_INTEGER).contains(&v) { serde_json::Value::String(v.to_string()) } else { serde_json::Value::Number(v.into()) @@ -86,7 +86,7 @@ pub fn parse_connect_timeout_with_fallback(url: &str, fallback: Duration) -> Dur || key.eq_ignore_ascii_case("connectionTimeout") { if let Ok(v) = value.parse::() { - if v >= 1 && v <= 300 { + if (1..=300).contains(&v) { return Duration::from_secs(v); } } diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 49afde33b..21ab83962 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -578,49 +578,49 @@ fn mysql_url_verifies_identity(url: &str) -> bool { } fn is_jdbc_param(key: &str) -> bool { - match key.to_ascii_lowercase().as_str() { + matches!( + key.to_ascii_lowercase().as_str(), "useunicode" - | "characterencoding" - | "zerodatetimebehavior" - | "usessl" - | "servertimezone" - | "allowpublickeyretrieval" - | "autoreconnect" - | "maxreconnects" - | "uselegacydatetimecode" - | "usecompression" - | "cacheprepstmts" - | "useserverprepstmts" - | "useconfigs" - | "usecursorfetch" - | "defaultfetchsize" - | "usejdbccomplianttimezoneshift" - | "usesspscompatibletimezoneshift" - | "failoverreadonly" - | "maxallowedpacket" - | "tinyint1isbit" - | "transformedbitisboolean" - | "yearisdatetype" - | "createdatabaseifnotexist" - | "noaccesstoprocedurebodies" - | "nullcatalogmeanscurrent" - | "nullnamepatternmatchesall" - | "dumponqueriesexception" - | "enablequerytimeouts" - | "useinformationschema" - | "gatherperfmetrics" - | "reportmetricsintervalmillis" - | "maxquerysizetolog" - | "packetdebugbuffersize" - | "usenanosforelapsedtime" - | "slowquerythresholdmillis" - | "autoslowlog" - | "explainslowqueries" - | "resultsetsizethreshold" - | "nettimeoutforstreamingresults" - | "useusageadvisor" => true, - _ => false, - } + | "characterencoding" + | "zerodatetimebehavior" + | "usessl" + | "servertimezone" + | "allowpublickeyretrieval" + | "autoreconnect" + | "maxreconnects" + | "uselegacydatetimecode" + | "usecompression" + | "cacheprepstmts" + | "useserverprepstmts" + | "useconfigs" + | "usecursorfetch" + | "defaultfetchsize" + | "usejdbccomplianttimezoneshift" + | "usesspscompatibletimezoneshift" + | "failoverreadonly" + | "maxallowedpacket" + | "tinyint1isbit" + | "transformedbitisboolean" + | "yearisdatetype" + | "createdatabaseifnotexist" + | "noaccesstoprocedurebodies" + | "nullcatalogmeanscurrent" + | "nullnamepatternmatchesall" + | "dumponqueriesexception" + | "enablequerytimeouts" + | "useinformationschema" + | "gatherperfmetrics" + | "reportmetricsintervalmillis" + | "maxquerysizetolog" + | "packetdebugbuffersize" + | "usenanosforelapsedtime" + | "slowquerythresholdmillis" + | "autoslowlog" + | "explainslowqueries" + | "resultsetsizethreshold" + | "nettimeoutforstreamingresults" + | "useusageadvisor" + ) } fn mysql_async_url(url: &str) -> Cow<'_, str> { diff --git a/crates/dbx-core/src/db/proxy_tunnel.rs b/crates/dbx-core/src/db/proxy_tunnel.rs index 2328f7305..1d84f8160 100644 --- a/crates/dbx-core/src/db/proxy_tunnel.rs +++ b/crates/dbx-core/src/db/proxy_tunnel.rs @@ -19,6 +19,7 @@ impl ProxyTunnelManager { Self { tunnels: tokio::sync::Mutex::new(HashMap::new()) } } + #[allow(clippy::too_many_arguments)] pub async fn start_tunnel( &self, connection_id: &str, diff --git a/crates/dbx-core/src/db/redis_driver.rs b/crates/dbx-core/src/db/redis_driver.rs index 2b3581404..fc1c03b21 100644 --- a/crates/dbx-core/src/db/redis_driver.rs +++ b/crates/dbx-core/src/db/redis_driver.rs @@ -159,7 +159,7 @@ fn redis_sentinel_nodes(config: &ConnectionConfig) -> Result vec![format!("{}:{}", config.host.trim(), config.port)] } else { raw_nodes - .split(|ch: char| ch == ',' || ch == ';' || ch == '\n' || ch == '\r') + .split([',', ';', '\n', '\r']) .map(str::trim) .filter(|node| !node.is_empty()) .map(ToOwned::to_owned) @@ -206,7 +206,7 @@ fn redis_node_endpoints( vec![format!("{fallback_host}:{}", if fallback_port == 0 { default_port } else { fallback_port })] } else { raw_nodes - .split(|ch: char| ch == ',' || ch == ';' || ch == '\n' || ch == '\r') + .split([',', ';', '\n', '\r']) .map(str::trim) .filter(|node| !node.is_empty()) .map(ToOwned::to_owned) @@ -1395,7 +1395,7 @@ fn parse_scan_members(raw: RedisRawValue) -> Result<(u64, Vec }; let items = - entries.iter().filter_map(|v| redis_value_to_string(v.clone())).map(|s| serde_json::Value::String(s)).collect(); + entries.iter().filter_map(|v| redis_value_to_string(v.clone())).map(serde_json::Value::String).collect(); Ok((cursor, items)) } diff --git a/crates/dbx-core/src/db/sqlserver.rs b/crates/dbx-core/src/db/sqlserver.rs index 8435467d9..870969293 100644 --- a/crates/dbx-core/src/db/sqlserver.rs +++ b/crates/dbx-core/src/db/sqlserver.rs @@ -650,9 +650,7 @@ fn is_transaction_control(sql: &str) -> bool { return true; } if first.eq_ignore_ascii_case("BEGIN") { - return tokens - .get(1) - .map_or(false, |t| t.eq_ignore_ascii_case("TRANSACTION") || t.eq_ignore_ascii_case("TRAN")); + return tokens.get(1).is_some_and(|t| t.eq_ignore_ascii_case("TRANSACTION") || t.eq_ignore_ascii_case("TRAN")); } false } diff --git a/crates/dbx-core/src/db/ssh_tunnel.rs b/crates/dbx-core/src/db/ssh_tunnel.rs index 4e3c5ad34..4e5b970f4 100644 --- a/crates/dbx-core/src/db/ssh_tunnel.rs +++ b/crates/dbx-core/src/db/ssh_tunnel.rs @@ -168,6 +168,7 @@ async fn forward_loop(session: &Handle, listener: &TcpListener, remot /// reconnections so the tunnel appears continuously available to clients. /// Uses exponential backoff for reconnect attempts and gives up after /// MAX_RECONNECT_ATTEMPTS to avoid log storms from permanent failures. +#[allow(clippy::too_many_arguments)] async fn tunnel_reconnect_loop( mut session: Handle, ssh_host: String, @@ -237,6 +238,12 @@ pub struct TunnelManager { tunnels: Mutex, u16)>>, } +impl Default for TunnelManager { + fn default() -> Self { + Self::new() + } +} + impl TunnelManager { pub fn new() -> Self { Self { tunnels: Mutex::new(HashMap::new()) } diff --git a/crates/dbx-core/src/external/types.rs b/crates/dbx-core/src/external/types.rs index 75161ae4e..a90c4f600 100644 --- a/crates/dbx-core/src/external/types.rs +++ b/crates/dbx-core/src/external/types.rs @@ -41,17 +41,12 @@ pub struct ExternalCapabilities { } /// Cache state tracking for external source snapshots. -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub enum CacheState { + #[default] Empty, Fresh, Stale, Loading, Error(String), } - -impl Default for CacheState { - fn default() -> Self { - Self::Empty - } -} diff --git a/crates/dbx-core/src/lib.rs b/crates/dbx-core/src/lib.rs index 8c7f6eb6f..6b95d0c23 100644 --- a/crates/dbx-core/src/lib.rs +++ b/crates/dbx-core/src/lib.rs @@ -46,6 +46,10 @@ pub fn download_candidate_urls(github_url: &str, r2_path: &str) -> Vec { vec![format!("{R2_CDN_BASE}{r2_path}"), github_url.to_string()] } +use std::pin::Pin; + +type ResponseFuture = Pin> + Send>>; + pub async fn race_download( client: &reqwest::Client, github_url: &str, @@ -53,11 +57,9 @@ pub async fn race_download( user_agent: &str, ) -> Result { use futures::future::select_ok; - use std::pin::Pin; let urls = download_candidate_urls(github_url, r2_path); - let mut futs: Vec> + Send>>> = - Vec::with_capacity(urls.len()); + let mut futs: Vec = Vec::with_capacity(urls.len()); for url in urls { let client = client.clone(); @@ -70,7 +72,7 @@ pub async fn race_download( .await .and_then(|r| r.error_for_status()) .map_err(|e| format!("{e}")) - }) as Pin> + Send>>); + }) as ResponseFuture); } match select_ok(futs).await { diff --git a/crates/dbx-core/src/models/connection.rs b/crates/dbx-core/src/models/connection.rs index ea3be1e1d..11d707644 100644 --- a/crates/dbx-core/src/models/connection.rs +++ b/crates/dbx-core/src/models/connection.rs @@ -124,17 +124,13 @@ fn is_false(value: &bool) -> bool { #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] +#[derive(Default)] pub enum ProxyType { + #[default] Socks5, Http, } -impl Default for ProxyType { - fn default() -> Self { - Self::Socks5 - } -} - #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] #[serde(rename_all = "lowercase")] pub enum DatabaseType { diff --git a/crates/dbx-core/src/mongo_ops.rs b/crates/dbx-core/src/mongo_ops.rs index 0b03e0cda..7be55ecf0 100644 --- a/crates/dbx-core/src/mongo_ops.rs +++ b/crates/dbx-core/src/mongo_ops.rs @@ -34,6 +34,7 @@ pub async fn mongo_list_collections_core( } } +#[allow(clippy::too_many_arguments)] pub async fn mongo_find_documents_core( state: &AppState, connection_id: &str, diff --git a/crates/dbx-core/src/query_result_sql.rs b/crates/dbx-core/src/query_result_sql.rs index 01a41c844..57f2d4fad 100644 --- a/crates/dbx-core/src/query_result_sql.rs +++ b/crates/dbx-core/src/query_result_sql.rs @@ -331,7 +331,7 @@ fn add_sql_server_top(sql: &str, limit: usize) -> String { if has_top_level_select_top(sql) { return sql.to_string(); } - if sql.len() >= 6 && sql[..6].to_ascii_uppercase() == "SELECT" { + if sql.len() >= 6 && sql[..6].eq_ignore_ascii_case("SELECT") { format!("SELECT TOP ({limit}){}", &sql[6..]) } else { format!("SELECT TOP ({limit}) * FROM ({sql}) [dbx_page]") diff --git a/crates/dbx-core/src/sql.rs b/crates/dbx-core/src/sql.rs index c0b0068ef..25025aea0 100644 --- a/crates/dbx-core/src/sql.rs +++ b/crates/dbx-core/src/sql.rs @@ -284,7 +284,7 @@ impl SqlStatementSplitter { let trimmed = self.buffer.trim(); let last_line = trimmed.rsplit('\n').next().unwrap_or(trimmed).trim(); if parse_delimiter_command(last_line).is_some() { - let before = trimmed.rsplitn(2, '\n').nth(1).unwrap_or("").trim(); + let before = trimmed.rsplit_once('\n').map(|x| x.0).unwrap_or("").trim(); if has_executable_sql_with_options(before, self.options) { statements.push(before.to_string()); } @@ -634,9 +634,9 @@ pub fn split_sql_batches(sql: &str) -> Vec { fn parse_delimiter_command(line: &str) -> Option<&str> { let bytes = line.as_bytes(); - let rest = if bytes.len() > 10 && bytes[..10].eq_ignore_ascii_case(b"delimiter ") { - Some(&line[10..]) - } else if bytes.len() > 10 && bytes[..10].eq_ignore_ascii_case(b"delimiter\t") { + let rest = if bytes.len() > 10 + && (bytes[..10].eq_ignore_ascii_case(b"delimiter ") || bytes[..10].eq_ignore_ascii_case(b"delimiter\t")) + { Some(&line[10..]) } else { None diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 6d108a15d..7f8ad0cd6 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -216,7 +216,7 @@ struct ExpressionAlias { fn parse_expression_alias(column: &str) -> Option { let trimmed_end = column.trim_end(); - for (index, _) in trimmed_end.match_indices(|c: char| c == 'A' || c == 'a') { + for (index, _) in trimmed_end.match_indices(['A', 'a']) { let candidate = &trimmed_end[index..]; if !candidate.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("AS")) { continue; diff --git a/crates/dbx-core/src/table_structure_sql.rs b/crates/dbx-core/src/table_structure_sql.rs index 04cb75545..ff3679247 100644 --- a/crates/dbx-core/src/table_structure_sql.rs +++ b/crates/dbx-core/src/table_structure_sql.rs @@ -353,7 +353,7 @@ fn build_table_comment_sql(options: &TableStructureSqlOptions, warnings: &mut Ve StructureDialect::SqlServer => { build_sqlserver_table_comment_sql(&table, options.schema.as_deref(), &options.table_name, new_comment) } - StructureDialect::Sqlite | StructureDialect::DuckDb | _ => { + _ => { if !clean(new_comment).is_empty() { warnings .push(format!("Table comments are not supported for {} from this editor.", dialect_label(dialect))); @@ -757,7 +757,7 @@ fn build_primary_key_sql( if !old_pk_names.is_empty() { match dialect { StructureDialect::Postgres => { - let raw_table = options.table_name.split('.').last().unwrap_or(&options.table_name); + let raw_table = options.table_name.split('.').next_back().unwrap_or(&options.table_name); let pk_name = format!("{}_pkey", clean(raw_table)); statements.push(format!("ALTER TABLE {table} DROP CONSTRAINT {};", quote_ident(dialect, &pk_name))); } @@ -1051,7 +1051,8 @@ fn build_sqlserver_existing_column_sql( )); } if !default_value.is_empty() { - let short_table = table.split('.').last().unwrap_or(table).trim_matches(|c: char| c == '[' || c == ']'); + let short_table = + table.split('.').next_back().unwrap_or(table).trim_matches(|c: char| c == '[' || c == ']'); let constraint_name = format!( "DF_{short_table}_{col_name}", short_table = short_table, @@ -1503,7 +1504,7 @@ fn column_position_clause(dialect: StructureDialect, columns: &[&EditableStructu if index == 0 { return " FIRST".to_string(); } - format!(" AFTER {}", quote_ident(dialect, &columns.get(index - 1).map(|column| column.name.as_str()).unwrap_or(""))) + format!(" AFTER {}", quote_ident(dialect, columns.get(index - 1).map(|column| column.name.as_str()).unwrap_or(""))) } fn mysql_column_position_changed(columns: &[&EditableStructureColumn], index: usize) -> bool { diff --git a/crates/dbx-core/src/transfer.rs b/crates/dbx-core/src/transfer.rs index 70fdf0017..b0743dfbf 100644 --- a/crates/dbx-core/src/transfer.rs +++ b/crates/dbx-core/src/transfer.rs @@ -805,8 +805,7 @@ pub fn generate_create_table_ddl( }); } - let mut pks = Vec::new(); - pks.reserve(columns.iter().filter(|c| c.is_primary_key).count()); + let mut pks = Vec::with_capacity(columns.iter().filter(|c| c.is_primary_key).count()); for c in columns { if c.is_primary_key { let qname = quote_identifier(&c.name, target_db); @@ -843,7 +842,7 @@ pub fn generate_create_table_ddl( ddl.push_str("\n)"); if is_mysql_family { - if let Some(ref comment) = table_comment { + if let Some(comment) = table_comment { let trimmed = comment.trim(); if !trimmed.is_empty() { ddl.push_str(&format!(" COMMENT='{}'", trimmed.replace('\'', "''"))); @@ -877,7 +876,7 @@ pub fn generate_comment_ddl( // Table-level comment first (PostgreSQL/Oracle only; ClickHouse doesn't support COMMENT ON TABLE) if matches!(target_db, DatabaseType::Postgres | DatabaseType::Oracle) { - if let Some(ref comment) = table_comment { + if let Some(comment) = table_comment { let trimmed = comment.trim(); if !trimmed.is_empty() { let escaped = trimmed.replace('\'', "''"); @@ -1301,10 +1300,7 @@ fn database_from_pool_key(pool_key: &str) -> Option<&str> { pub async fn get_db_type(state: &AppState, connection_id: &str) -> Result { let configs = state.configs.read().await; - configs - .get(connection_id) - .map(|c| c.db_type.clone()) - .ok_or_else(|| format!("Connection config not found: {connection_id}")) + configs.get(connection_id).map(|c| c.db_type).ok_or_else(|| format!("Connection config not found: {connection_id}")) } pub async fn get_columns_for_transfer( @@ -1811,6 +1807,7 @@ pub async fn clear_cancelled(transfer_id: &str) { /// Transfer a single table. Returns rows transferred. /// `progress_callback` is invoked for progress updates. +#[allow(clippy::too_many_arguments)] pub async fn transfer_table( state: &AppState, request: &TransferRequest, diff --git a/crates/dbx-core/src/xlsx_export.rs b/crates/dbx-core/src/xlsx_export.rs index 08dcf8cb0..8a972e500 100644 --- a/crates/dbx-core/src/xlsx_export.rs +++ b/crates/dbx-core/src/xlsx_export.rs @@ -105,7 +105,7 @@ fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Op format!("{bool_v}") } Some(Value::Number(n)) => { - if n.as_f64().map_or(false, |f| f.is_finite()) { + if n.as_f64().is_some_and(|f| f.is_finite()) { format!("{}", n) } else { format!( diff --git a/src-tauri/src/commands/external_db.rs b/src-tauri/src/commands/external_db.rs new file mode 100644 index 000000000..8808b6121 --- /dev/null +++ b/src-tauri/src/commands/external_db.rs @@ -0,0 +1,113 @@ +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +const DB_EXTENSIONS: &[&str] = &["db", "sqlite", "sqlite3", "duckdb"]; + +#[derive(Default)] +pub struct ExternalDbOpenState { + pending: Mutex>, +} + +impl ExternalDbOpenState { + pub fn push(&self, paths: Vec) { + if paths.is_empty() { + return; + } + if let Ok(mut pending) = self.pending.lock() { + pending.extend(paths); + } + } + + fn drain(&self) -> Vec { + self.pending.lock().map(|mut pending| pending.drain(..).collect()).unwrap_or_default() + } +} + +#[tauri::command] +pub fn pending_open_db_files(state: tauri::State<'_, ExternalDbOpenState>) -> Vec { + let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let mut paths = db_file_paths_from_args(std::env::args().skip(1), &cwd); + paths.extend(state.drain()); + dedupe_paths(paths) +} + +pub fn db_file_paths_from_args(args: I, cwd: &Path) -> Vec +where + I: IntoIterator, + S: AsRef, +{ + args.into_iter().filter_map(|arg| db_file_path_from_arg(arg.as_ref(), cwd)).collect() +} + +fn db_file_path_from_arg(arg: &str, cwd: &Path) -> Option { + if arg.starts_with('-') { + return None; + } + + let path = PathBuf::from(arg); + if !is_db_file_path(&path) { + return None; + } + + let resolved = if path.is_absolute() { path } else { cwd.join(path) }; + Some(resolved.to_string_lossy().to_string()) +} + +pub fn is_db_file_path(path: &Path) -> bool { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| DB_EXTENSIONS.iter().any(|e| e.eq_ignore_ascii_case(ext))) + .unwrap_or(false) +} + +fn dedupe_paths(paths: Vec) -> Vec { + let mut unique = Vec::new(); + for path in paths { + if !unique.contains(&path) { + unique.push(path); + } + } + unique +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filters_db_file_args_case_insensitively() { + let paths = db_file_paths_from_args( + ["/tmp/a.db", "--flag", "/tmp/b.SQLITE", "/tmp/c.sqlite3", "/tmp/d.duckdb", "/tmp/e.txt"], + Path::new("/work"), + ); + + assert_eq!(paths, vec!["/tmp/a.db", "/tmp/b.SQLITE", "/tmp/c.sqlite3", "/tmp/d.duckdb"]); + } + + #[test] + fn resolves_relative_db_file_args_against_cwd() { + let paths = db_file_paths_from_args(["data/mydb.db"], Path::new("/work")); + + assert_eq!(paths, vec!["/work/data/mydb.db"]); + } + + #[test] + fn drains_pending_db_file_paths_once() { + let state = ExternalDbOpenState::default(); + state.push(vec!["/tmp/a.db".to_string()]); + + assert_eq!(state.drain(), vec!["/tmp/a.db"]); + assert!(state.drain().is_empty()); + } + + #[test] + fn is_db_file_path_recognizes_extensions() { + assert!(is_db_file_path(Path::new("test.db"))); + assert!(is_db_file_path(Path::new("test.sqlite"))); + assert!(is_db_file_path(Path::new("test.sqlite3"))); + assert!(is_db_file_path(Path::new("test.duckdb"))); + assert!(is_db_file_path(Path::new("test.DB"))); + assert!(!is_db_file_path(Path::new("test.sql"))); + assert!(!is_db_file_path(Path::new("test.txt"))); + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e221271ac..dea43b105 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -9,6 +9,7 @@ pub mod csv_export; pub mod data_compare; pub mod database_export; pub mod deep_link; +pub mod external_db; pub mod external_sql; pub mod history; pub mod mcp_bridge; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b53912cba..aadb10f66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -154,13 +154,21 @@ pub fn run() { let links = commands::deep_link::connection_deep_links_from_args(args.clone()); open_connection_deep_links(app, links); - let paths = commands::external_sql::sql_file_paths_from_args(args, std::path::Path::new(&cwd)); + let paths = commands::external_sql::sql_file_paths_from_args(args.clone(), std::path::Path::new(&cwd)); if !paths.is_empty() { if let Some(state) = app.try_state::() { state.push(paths.clone()); } let _ = app.emit("dbx-open-sql-files", paths); } + + let db_paths = commands::external_db::db_file_paths_from_args(args, std::path::Path::new(&cwd)); + if !db_paths.is_empty() { + if let Some(state) = app.try_state::() { + state.push(db_paths.clone()); + } + let _ = app.emit("dbx-open-db-files", db_paths); + } show_main_window(app); })) .plugin(tauri_plugin_shell::init()) @@ -200,6 +208,7 @@ pub fn run() { )); app.manage(state.clone()); app.manage(commands::external_sql::ExternalSqlOpenState::default()); + app.manage(commands::external_db::ExternalDbOpenState::default()); app.manage(commands::deep_link::DeepLinkOpenState::default()); let startup_links = commands::deep_link::connection_deep_links_from_args(std::env::args().skip(1)); open_connection_deep_links(app.handle(), startup_links); @@ -340,6 +349,7 @@ pub fn run() { commands::sql_file::cancel_sql_file_execution, commands::external_sql::pending_open_sql_files, commands::external_sql::read_external_sql_file, + commands::external_db::pending_open_db_files, commands::deep_link::pending_open_connection_links, commands::table_import::preview_table_import_file, commands::table_import::import_table_file, @@ -437,6 +447,23 @@ pub fn run() { let _ = window.set_focus(); } } + + let db_paths: Vec = urls + .iter() + .filter_map(|url| url.to_file_path().ok()) + .filter(|path| commands::external_db::is_db_file_path(path)) + .map(|path| path.to_string_lossy().to_string()) + .collect(); + if !db_paths.is_empty() { + if let Some(state) = app_handle.try_state::() { + state.push(db_paths.clone()); + } + let _ = app_handle.emit("dbx-open-db-files", db_paths); + if let Some(window) = app_handle.get_webview_window("main") { + let _ = window.show(); + let _ = window.set_focus(); + } + } } #[cfg(target_os = "macos")] diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 04bf7fb37..7a53952cb 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -43,6 +43,12 @@ "description": "SQL script", "role": "Editor", "mimeType": "application/sql" + }, + { + "ext": ["db", "sqlite", "sqlite3", "duckdb"], + "name": "Database File", + "description": "Database file", + "role": "Editor" } ], "macOS": {