From d091b0db85a6bdce89f1a3a7202b2219006094ac Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Sat, 13 Jun 2026 16:00:47 +0800 Subject: [PATCH] feat(sidebar): reveal local db file in OS file manager --- .../connection/ConnectionDialog.vue | 3 +- .../src/components/sidebar/TreeItem.vue | 27 ++++ apps/desktop/src/i18n/locales/en.ts | 1 + apps/desktop/src/i18n/locales/es.ts | 1 + apps/desktop/src/i18n/locales/it.ts | 1 + apps/desktop/src/i18n/locales/pt-BR.ts | 1 + apps/desktop/src/i18n/locales/zh-CN.ts | 1 + apps/desktop/src/i18n/locales/zh-TW.ts | 1 + apps/desktop/src/lib/api.ts | 1 + apps/desktop/src/lib/connectionFile.ts | 50 +++++++ apps/desktop/src/lib/http.ts | 4 + apps/desktop/src/lib/tauri.ts | 4 + crates/dbx-core/src/database_capabilities.rs | 31 ++++ src-tauri/src/commands/fs_open.rs | 138 ++++++++++++++++++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 1 + 16 files changed, 265 insertions(+), 1 deletion(-) create mode 100644 apps/desktop/src/lib/connectionFile.ts create mode 100644 src-tauri/src/commands/fs_open.rs diff --git a/apps/desktop/src/components/connection/ConnectionDialog.vue b/apps/desktop/src/components/connection/ConnectionDialog.vue index fd3cd54840..e7af392606 100644 --- a/apps/desktop/src/components/connection/ConnectionDialog.vue +++ b/apps/desktop/src/components/connection/ConnectionDialog.vue @@ -23,6 +23,7 @@ import { applyParsedConnectionUrl, normalizeMongoConnectionString, parseConnecti import type { ConnectionDeepLinkDraft } from "@/lib/connectionDeepLink"; import { connectionUrlPlaceholder as getUrlPlaceholder } from "@/lib/connectionPresentation"; import { h2ConnectionModeForConfig, h2FileJdbcUrl, h2FilePathFromJdbcUrl, type H2ConnectionMode } from "@/lib/h2Connection"; +import { isLocalFileTypeDb } from "@/lib/connectionFile"; import { mongodbAuthFailureHint, mongoUrlParam, setMongoUrlParam } from "@/lib/mongoConnectionOptions"; import { copyToClipboard } from "@/lib/clipboard"; import { showAgentDriverInstallHint, type AgentDriverInstallState } from "@/lib/agentDriverInstallHint"; @@ -847,7 +848,7 @@ const hasDbPickerResults = computed(() => filteredDbCategories.value.some((categ const selectedDbIcon = computed(() => iconTypeMap[selectedType.value] || selectedProfile().icon || selectedType.value); const isJdbcConnection = computed(() => form.value.db_type === "jdbc"); const isH2FileMode = computed(() => form.value.db_type === "h2" && h2ConnectionMode.value === "file"); -const usesLocalFilePathInput = computed(() => form.value.db_type === "sqlite" || form.value.db_type === "duckdb" || form.value.db_type === "access" || isH2FileMode.value); +const usesLocalFilePathInput = computed(() => isLocalFileTypeDb(form.value.db_type) && (form.value.db_type !== "h2" || isH2FileMode.value)); const connectionUrlPlaceholder = computed(() => getUrlPlaceholder(form.value.db_type)); const filePathPlaceholder = computed(() => { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 94ddb6ef2c..97a62df9e9 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -62,6 +62,8 @@ import { uuid } from "@/lib/utils"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; import { canTreeNodeShowExpander, treeItemPaddingLeft, usesFullWidthTreeLabel } from "@/lib/sidebarTreeItemLayout"; import { buildTableSelectSql } from "@/lib/tableSelectSql"; +import { connectionFilePath } from "@/lib/connectionFile"; +import { revealPathInFileManager } from "@/lib/tauri"; import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/queryEditorTableDrop"; import { editablePrimaryKeys, usesSyntheticRowIdKey } from "@/lib/tableEditing"; import { supportsDatabaseCreation, supportsDatabaseSearch, supportsFieldLineage, supportsObjectBrowserTreeNode, supportsSchemaDiagram, supportsSqlFileExecution, supportsTableImport, supportsTableTruncate, supportsTableStructureEditing, usesTreeSchemaMode } from "@/lib/databaseCapabilities"; @@ -2118,6 +2120,24 @@ function editConnection() { } } +const revealConnectionFilePath = computed(() => { + if (props.node.type !== "connection" || !props.node.connectionId) return null; + const config = connectionStore.getConfig(props.node.connectionId); + if (!config) return null; + return connectionFilePath(config); +}); + +async function revealDatabaseFile() { + const path = revealConnectionFilePath.value; + if (!path) return; + try { + await revealPathInFileManager(path); + } catch (e: any) { + const message = typeof e === "string" ? e : e?.message || String(e); + toast(message, 5000); + } +} + async function disconnectConnection() { if (props.node.connectionId) { try { @@ -2713,6 +2733,13 @@ function treeItemMenuItems(): ContextMenuItem[] { }); } items.push({ label: t("contextMenu.editConnection"), action: editConnection, icon: Pencil }); + if (revealConnectionFilePath.value) { + items.push({ + label: t("contextMenu.revealDatabaseFile"), + action: revealDatabaseFile, + icon: FolderOpen, + }); + } items.push({ label: t("contextMenu.duplicateConnection"), action: duplicateConnection, icon: CopyPlus }); items.push({ label: "", separator: true }); items.push({ diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 410e38e195..a14c996b70 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -1010,6 +1010,7 @@ confirmDeleteMessage: 'Are you sure you want to delete "{name}"? This cannot be undone.', confirmDeleteSelectedMessage: "Are you sure you want to delete the selected {count} connections? This cannot be undone.", editConnection: "Edit Connection", + revealDatabaseFile: "Reveal in File Manager", selectVisibleDatabases: "Select Visible Databases", userAdmin: "Users & Privileges", openUserAdmin: "Open Users & Privileges", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 98953bcb59..1ef46851fe 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -867,6 +867,7 @@ confirmDeleteMessage: '¿Estás seguro de que deseas eliminar "{name}"? Esta acción no se puede deshacer.', confirmDeleteSelectedMessage: "¿Eliminar las {count} conexiones seleccionadas? Esta acción no se puede deshacer.", editConnection: "Editar conexión", + revealDatabaseFile: "Mostrar en el explorador", selectVisibleDatabases: "Seleccionar bases visibles", duplicateConnection: "Duplicar conexión", newQuery: "Nueva consulta", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 40cd036aab..e2b884bb4a 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -968,6 +968,7 @@ confirmDeleteMessage: 'Sei sicuro di voler eliminare "{name}"? Questa azione non può essere annullata.', confirmDeleteSelectedMessage: "Eliminare le {count} connessioni selezionate? Questa azione non può essere annullata.", editConnection: "Modifica Connessione", + revealDatabaseFile: "Mostra nel file manager", selectVisibleDatabases: "Seleziona Database Visibili", duplicateConnection: "Duplica Connessione", newQuery: "Nuova Query", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 72830e204d..d6e1364748 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -968,6 +968,7 @@ confirmDeleteMessage: 'Tem certeza de que deseja excluir "{name}"? Esta ação não pode ser desfeita.', confirmDeleteSelectedMessage: "Excluir as {count} conexões selecionadas? Esta ação não pode ser desfeita.", editConnection: "Editar Conexão", + revealDatabaseFile: "Mostrar no gerenciador de arquivos", selectVisibleDatabases: "Selecionar Bancos de Dados Visíveis", duplicateConnection: "Duplicar Conexão", newQuery: "Nova Consulta", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index de38f91f16..ac3aa17de1 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -1009,6 +1009,7 @@ confirmDeleteMessage: "确定要删除「{name}」吗?此操作不可撤销。", confirmDeleteSelectedMessage: "确定要删除选中的{count}个连接吗?", editConnection: "编辑连接", + revealDatabaseFile: "在文件管理器中显示", selectVisibleDatabases: "选择显示数据库", duplicateConnection: "复制连接", userAdmin: "用户与权限", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 5e992af740..9f417224f3 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -947,6 +947,7 @@ confirmDeleteMessage: "確定要刪除「{name}」嗎?此操作無法復原。", confirmDeleteSelectedMessage: "確定要刪除選取的{count}個連線嗎?", editConnection: "編輯連線", + revealDatabaseFile: "在檔案管理員中顯示", selectVisibleDatabases: "選擇顯示資料庫", duplicateConnection: "複製連線", newQuery: "建立查詢", diff --git a/apps/desktop/src/lib/api.ts b/apps/desktop/src/lib/api.ts index 3b0066b9c4..4bc1e0dbe4 100644 --- a/apps/desktop/src/lib/api.ts +++ b/apps/desktop/src/lib/api.ts @@ -96,6 +96,7 @@ export const saveSavedSqlFile = forward("saveSavedSqlFile"); export const deleteSavedSqlFile = forward("deleteSavedSqlFile"); export const savedSqlStorageDir = forward("savedSqlStorageDir"); export const openSavedSqlStorageDir = forward("openSavedSqlStorageDir"); +export const revealPathInFileManager = forward("revealPathInFileManager"); export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory"); // Schema diff --git a/apps/desktop/src/lib/connectionFile.ts b/apps/desktop/src/lib/connectionFile.ts new file mode 100644 index 0000000000..634cbb27c1 --- /dev/null +++ b/apps/desktop/src/lib/connectionFile.ts @@ -0,0 +1,50 @@ +import type { ConnectionConfig, DatabaseType } from "@/types/database"; +import { h2FilePathFromJdbcUrl } from "@/lib/h2Connection"; + +/** + * Whether the database type is one that (potentially) backs onto a local file. + * This is a type-level check only — it does not verify that a valid path is + * configured. Use this to decide whether to show file-path UI; use + * `isLocalFileDb` / `connectionFilePath` to decide whether a reveal action is + * available. + */ +export function isLocalFileTypeDb(dbType: DatabaseType | string): boolean { + return dbType === "sqlite" || dbType === "duckdb" || dbType === "access" || dbType === "h2"; +} + +/** + * Extract the on-disk file path of a connection if (and only if) the + * connection backs onto a single local file. + * + * - SQLite / DuckDB / Access: `host` holds the file path. + * - H2 file mode: path is parsed out of the JDBC URL via `h2FilePathFromJdbcUrl`. + * H2 stores its data in `.mv.db` (default) or `.h2.db`; we hand + * back the base path the user actually configured so the Tauri side can try + * reasonable variants when the literal path isn't a file. + * + * Returns `null` for in-memory (`:memory:`), empty paths, H2 server/mem mode, + * and any DB type that isn't local-file. + */ +export function connectionFilePath(config: Pick): string | null { + const dbType = config.db_type; + if (dbType === "sqlite" || dbType === "duckdb" || dbType === "access") { + const host = (config.host ?? "").trim(); + if (!host || host === ":memory:") return null; + return host; + } + if (dbType === "h2") { + const fromUrl = h2FilePathFromJdbcUrl(config.connection_string); + const trimmed = (fromUrl ?? "").trim(); + if (!trimmed) return null; + return trimmed; + } + return null; +} + +/** + * Whether the connection has a meaningful local file path that can be revealed + * in the OS file manager. True iff `connectionFilePath` returns non-null. + */ +export function isLocalFileDb(config: Pick): boolean { + return connectionFilePath(config) !== null; +} diff --git a/apps/desktop/src/lib/http.ts b/apps/desktop/src/lib/http.ts index 4b4bdcaf0d..fda7893a2a 100644 --- a/apps/desktop/src/lib/http.ts +++ b/apps/desktop/src/lib/http.ts @@ -372,6 +372,10 @@ export async function openSavedSqlStorageDir(_dir?: string | null): Promise { + throw new Error("Reveal in file manager is only available in the desktop app."); +} + export async function syncSavedSqlDirectory(_request: SavedSqlSyncRequest): Promise { throw new Error("SQL directory sync is only available in the desktop app."); } diff --git a/apps/desktop/src/lib/tauri.ts b/apps/desktop/src/lib/tauri.ts index f01b2721f4..f9621ee0b2 100644 --- a/apps/desktop/src/lib/tauri.ts +++ b/apps/desktop/src/lib/tauri.ts @@ -960,6 +960,10 @@ export async function openSavedSqlStorageDir(dir?: string | null): Promise return invoke("open_saved_sql_storage_dir", { dir }); } +export async function revealPathInFileManager(path: string): Promise { + return invoke("reveal_path_in_file_manager", { path }); +} + export async function syncSavedSqlDirectory(request: SavedSqlSyncRequest): Promise { return invoke("sync_saved_sql_directory", { request }); } diff --git a/crates/dbx-core/src/database_capabilities.rs b/crates/dbx-core/src/database_capabilities.rs index 03a2a87281..0db895f3be 100644 --- a/crates/dbx-core/src/database_capabilities.rs +++ b/crates/dbx-core/src/database_capabilities.rs @@ -40,3 +40,34 @@ pub fn skips_tcp_probe(db_type: &DatabaseType) -> bool { matches!(db_type, DatabaseType::Sqlite | DatabaseType::DuckDb | DatabaseType::Turso | DatabaseType::Jdbc) || is_agent_type(db_type) } + +/// Database types whose connection backs onto a single local file (or may, in the +/// case of H2 file mode). Used to decide whether to expose a "reveal in file +/// manager" affordance. Whether the H2 connection is actually in file mode must +/// be determined separately by parsing the JDBC URL. +pub fn is_local_file_db_type(db_type: &DatabaseType) -> bool { + matches!(db_type, DatabaseType::Sqlite | DatabaseType::DuckDb | DatabaseType::Access | DatabaseType::H2) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_file_db_types_match_expected_set() { + assert!(is_local_file_db_type(&DatabaseType::Sqlite)); + assert!(is_local_file_db_type(&DatabaseType::DuckDb)); + assert!(is_local_file_db_type(&DatabaseType::Access)); + assert!(is_local_file_db_type(&DatabaseType::H2)); + } + + #[test] + fn non_local_file_db_types_rejected() { + assert!(!is_local_file_db_type(&DatabaseType::Mysql)); + assert!(!is_local_file_db_type(&DatabaseType::Postgres)); + assert!(!is_local_file_db_type(&DatabaseType::Redis)); + assert!(!is_local_file_db_type(&DatabaseType::MongoDb)); + assert!(!is_local_file_db_type(&DatabaseType::Turso)); + assert!(!is_local_file_db_type(&DatabaseType::Rqlite)); + } +} diff --git a/src-tauri/src/commands/fs_open.rs b/src-tauri/src/commands/fs_open.rs new file mode 100644 index 0000000000..8dcd29d2bd --- /dev/null +++ b/src-tauri/src/commands/fs_open.rs @@ -0,0 +1,138 @@ +use std::path::{Path, PathBuf}; + +use dbx_core::path_utils::expand_tilde; + +/// Reveal a file in the platform's file manager. +/// +/// - macOS: `open -R ` highlights the file in Finder. +/// - Windows: `explorer.exe /select,` opens Explorer with the file selected. +/// - Linux: opens the parent directory with `xdg-open`. (DBus +/// `org.freedesktop.FileManager1.ShowItems` would be a higher-fidelity +/// alternative; deferred to a follow-up to avoid a new dependency.) +/// +/// The caller MUST pass an absolute path that already exists. Path expansion +/// (`~`) and existence checks happen one layer up in the Tauri command. +pub fn reveal_in_file_manager(path: &Path) -> Result<(), String> { + #[cfg(target_os = "macos")] + { + std::process::Command::new("open") + .arg("-R") + .arg(path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to launch Finder: {e}")) + } + + #[cfg(target_os = "windows")] + { + // `/select,` must be passed as a single argument with no space + // after the comma. `Command::arg` does not invoke a shell, so the path + // is forwarded as-is — spaces and non-ASCII characters survive. + let arg = format!("/select,{}", path.display()); + std::process::Command::new("explorer") + .arg(arg) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to launch Explorer: {e}")) + } + + #[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] + { + let target: PathBuf = path.parent().map(|p| p.to_path_buf()).unwrap_or_else(|| path.to_path_buf()); + std::process::Command::new("xdg-open") + .arg(&target) + .spawn() + .map(|_| ()) + .map_err(|e| format!("failed to launch xdg-open: {e}")) + } +} + +fn validate_path(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("path is empty".to_string()); + } + if trimmed == ":memory:" { + return Err("in-memory database has no file to reveal".to_string()); + } + + let expanded = expand_tilde(trimmed); + let path = PathBuf::from(&expanded); + if !path.is_absolute() { + return Err(format!("path is not absolute: {expanded}")); + } + if !path.exists() { + return Err(format!("文件不存在: {expanded}")); + } + Ok(path) +} + +/// Reveal an absolute file path in the OS file manager. The path may use a +/// leading `~` which is expanded via `dbx_core::path_utils::expand_tilde`. +#[tauri::command] +pub async fn reveal_path_in_file_manager(path: String) -> Result<(), String> { + let resolved = validate_path(&path)?; + reveal_in_file_manager(&resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn empty_path_is_rejected() { + assert!(validate_path("").is_err()); + assert!(validate_path(" ").is_err()); + } + + #[test] + fn memory_path_is_rejected() { + let err = validate_path(":memory:").unwrap_err(); + assert!(err.contains("in-memory")); + } + + #[test] + fn relative_path_is_rejected() { + let err = validate_path("relative/path/foo.sqlite").unwrap_err(); + assert!(err.contains("not absolute")); + } + + #[test] + fn nonexistent_absolute_path_is_rejected() { + // /this/path/should/not/exist on Unix; on Windows the same check fires + // because the literal also won't exist. + let probe = if cfg!(windows) { + "C:/__dbx_definitely_missing__/foo.sqlite".to_string() + } else { + "/__dbx_definitely_missing__/foo.sqlite".to_string() + }; + let err = validate_path(&probe).unwrap_err(); + assert!(err.contains("文件不存在")); + } + + #[test] + fn existing_absolute_path_is_accepted() { + // The OS temp dir is always an existing absolute path. + let dir = std::env::temp_dir(); + assert!(dir.is_absolute()); + let dir_str = dir.to_string_lossy().to_string(); + let resolved = validate_path(&dir_str).expect("temp dir should validate"); + assert_eq!(resolved, dir); + } + + #[test] + fn tilde_is_expanded_when_home_set() { + // Only run when HOME (or USERPROFILE) actually points somewhere we can + // use to validate. We do not require any specific file under it; we + // only assert that expansion happens (so the absolute-path check + // passes). + let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")); + if let Ok(h) = home { + let p = std::path::Path::new(&h); + if p.is_absolute() && p.exists() { + let resolved = validate_path("~").expect("tilde should expand"); + assert!(resolved.is_absolute()); + } + } + } +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 11ba965487..63ddd6ad68 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -12,6 +12,7 @@ pub mod deep_link; pub mod etcd_cmd; pub mod external_db; pub mod external_sql; +pub mod fs_open; pub mod history; pub mod keychain; pub mod mcp; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0d128aeeaf..09442b484b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -482,6 +482,7 @@ pub fn run() { commands::saved_sql::saved_sql_storage_dir, commands::saved_sql::open_saved_sql_storage_dir, commands::saved_sql::sync_saved_sql_directory, + commands::fs_open::reveal_path_in_file_manager, commands::mongo_cmd::mongo_list_databases, commands::mongo_cmd::mongo_list_collections, commands::mongo_cmd::document_find_documents,