feat: support double-clicking .db/.sqlite/.duckdb files to open in IDE

Add OS file association for database files (.db, .sqlite, .sqlite3, .duckdb)
so double-clicking them in Finder/Explorer automatically creates a connection.
If the same file path is already connected, prompts the user to switch instead.

Also fix 42 pre-existing clippy warnings in dbx-core (needless_borrow,
too_many_arguments, derivable_impls, manual_div_ceil, type_complexity, etc.).

Closes #527
This commit is contained in:
t8y2 2026-05-29 17:25:52 +08:00
parent bbdb21c1e6
commit ef4baba57a
29 changed files with 344 additions and 105 deletions

View File

@ -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`);
});

View File

@ -5,6 +5,7 @@ import type { NavigationTarget } from "@/composables/useNavigationTargets";
export function useTauriEvents(deps: {
openTableTarget: (target: NavigationTarget) => Promise<void>;
openSqlFilePath: (path: string) => Promise<void>;
openDbFilePath: (path: string) => Promise<void>;
openConnectionDeepLink: (url: string) => Promise<void>;
}) {
const connectionStore = useConnectionStore();
@ -90,6 +91,17 @@ export function useTauriEvents(deps: {
}
}).then((unlisten) => unlistenHandles.push(unlisten));
listen<string[]>("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<string[]>("dbx-open-connection-links", async (event) => {
try {
for (const url of event.payload) {

View File

@ -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");

View File

@ -909,6 +909,10 @@ export async function pendingOpenSqlFiles(): Promise<string[]> {
return [];
}
export async function pendingOpenDbFiles(): Promise<string[]> {
return [];
}
export async function pendingOpenConnectionLinks(): Promise<string[]> {
return [];
}

View File

@ -317,6 +317,10 @@ export async function pendingOpenSqlFiles(): Promise<string[]> {
return invoke("pending_open_sql_files");
}
export async function pendingOpenDbFiles(): Promise<string[]> {
return invoke("pending_open_db_files");
}
export async function pendingOpenConnectionLinks(): Promise<string[]> {
return invoke("pending_open_connection_links");
}

View File

@ -218,6 +218,12 @@ pub struct AgentManager {
daemons: Mutex<std::collections::HashMap<String, AgentDriverClient>>,
}
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))]
{

View File

@ -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)

View File

@ -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<DatabaseType>) -> 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)

View File

@ -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

View File

@ -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::<u64>() {
if v >= 1 && v <= 300 {
if (1..=300).contains(&v) {
return Duration::from_secs(v);
}
}

View File

@ -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> {

View File

@ -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,

View File

@ -159,7 +159,7 @@ fn redis_sentinel_nodes(config: &ConnectionConfig) -> Result<Vec<ConnectionInfo>
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<serde_json::Value>
};
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))
}

View File

@ -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
}

View File

@ -168,6 +168,7 @@ async fn forward_loop(session: &Handle<SshClient>, 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<SshClient>,
ssh_host: String,
@ -237,6 +238,12 @@ pub struct TunnelManager {
tunnels: Mutex<HashMap<String, (JoinHandle<()>, u16)>>,
}
impl Default for TunnelManager {
fn default() -> Self {
Self::new()
}
}
impl TunnelManager {
pub fn new() -> Self {
Self { tunnels: Mutex::new(HashMap::new()) }

View File

@ -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
}
}

View File

@ -46,6 +46,10 @@ pub fn download_candidate_urls(github_url: &str, r2_path: &str) -> Vec<String> {
vec![format!("{R2_CDN_BASE}{r2_path}"), github_url.to_string()]
}
use std::pin::Pin;
type ResponseFuture = Pin<Box<dyn std::future::Future<Output = Result<reqwest::Response, String>> + 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<reqwest::Response, String> {
use futures::future::select_ok;
use std::pin::Pin;
let urls = download_candidate_urls(github_url, r2_path);
let mut futs: Vec<Pin<Box<dyn std::future::Future<Output = Result<reqwest::Response, String>> + Send>>> =
Vec::with_capacity(urls.len());
let mut futs: Vec<ResponseFuture> = 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<Box<dyn std::future::Future<Output = Result<reqwest::Response, String>> + Send>>);
}) as ResponseFuture);
}
match select_ok(futs).await {

View File

@ -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 {

View File

@ -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,

View File

@ -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]")

View File

@ -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<String> {
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

View File

@ -216,7 +216,7 @@ struct ExpressionAlias {
fn parse_expression_alias(column: &str) -> Option<ExpressionAlias> {
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;

View File

@ -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 {

View File

@ -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<DatabaseType, String> {
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<F>(
state: &AppState,
request: &TransferRequest,

View File

@ -105,7 +105,7 @@ fn cell_xml(value: Option<&Value>, row_index: usize, col_index: usize, style: Op
format!("<c r=\"{reference}\" t=\"b\"{style_attr}><v>{bool_v}</v></c>")
}
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!("<c r=\"{reference}\"{style_attr}><v>{}</v></c>", n)
} else {
format!(

View File

@ -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<Vec<String>>,
}
impl ExternalDbOpenState {
pub fn push(&self, paths: Vec<String>) {
if paths.is_empty() {
return;
}
if let Ok(mut pending) = self.pending.lock() {
pending.extend(paths);
}
}
fn drain(&self) -> Vec<String> {
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<String> {
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<I, S>(args: I, cwd: &Path) -> Vec<String>
where
I: IntoIterator<Item = S>,
S: AsRef<str>,
{
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<String> {
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<String>) -> Vec<String> {
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")));
}
}

View File

@ -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;

View File

@ -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::<commands::external_sql::ExternalSqlOpenState>() {
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::<commands::external_db::ExternalDbOpenState>() {
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<String> = 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::<commands::external_db::ExternalDbOpenState>() {
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")]

View File

@ -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": {