mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-21 05:20:02 +00:00
feat(sidebar): reveal local db file in OS file manager
This commit is contained in:
@@ -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(() => {
|
||||
|
||||
@@ -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<string | null>(() => {
|
||||
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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1009,6 +1009,7 @@
|
||||
confirmDeleteMessage: "确定要删除「{name}」吗?此操作不可撤销。",
|
||||
confirmDeleteSelectedMessage: "确定要删除选中的{count}个连接吗?",
|
||||
editConnection: "编辑连接",
|
||||
revealDatabaseFile: "在文件管理器中显示",
|
||||
selectVisibleDatabases: "选择显示数据库",
|
||||
duplicateConnection: "复制连接",
|
||||
userAdmin: "用户与权限",
|
||||
|
||||
@@ -947,6 +947,7 @@
|
||||
confirmDeleteMessage: "確定要刪除「{name}」嗎?此操作無法復原。",
|
||||
confirmDeleteSelectedMessage: "確定要刪除選取的{count}個連線嗎?",
|
||||
editConnection: "編輯連線",
|
||||
revealDatabaseFile: "在檔案管理員中顯示",
|
||||
selectVisibleDatabases: "選擇顯示資料庫",
|
||||
duplicateConnection: "複製連線",
|
||||
newQuery: "建立查詢",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<base>.mv.db` (default) or `<base>.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<ConnectionConfig, "db_type" | "host" | "connection_string">): 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<ConnectionConfig, "db_type" | "host" | "connection_string">): boolean {
|
||||
return connectionFilePath(config) !== null;
|
||||
}
|
||||
@@ -372,6 +372,10 @@ export async function openSavedSqlStorageDir(_dir?: string | null): Promise<void
|
||||
throw new Error("SQL storage directory is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function revealPathInFileManager(_path: string): Promise<void> {
|
||||
throw new Error("Reveal in file manager is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function syncSavedSqlDirectory(_request: SavedSqlSyncRequest): Promise<void> {
|
||||
throw new Error("SQL directory sync is only available in the desktop app.");
|
||||
}
|
||||
|
||||
@@ -960,6 +960,10 @@ export async function openSavedSqlStorageDir(dir?: string | null): Promise<void>
|
||||
return invoke("open_saved_sql_storage_dir", { dir });
|
||||
}
|
||||
|
||||
export async function revealPathInFileManager(path: string): Promise<void> {
|
||||
return invoke("reveal_path_in_file_manager", { path });
|
||||
}
|
||||
|
||||
export async function syncSavedSqlDirectory(request: SavedSqlSyncRequest): Promise<void> {
|
||||
return invoke("sync_saved_sql_directory", { request });
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <path>` highlights the file in Finder.
|
||||
/// - Windows: `explorer.exe /select,<path>` 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,<path>` 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<PathBuf, String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user