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:
parent
bbdb21c1e6
commit
ef4baba57a
|
|
@ -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`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))]
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
Loading…
Reference in New Issue