feat(app): handle SQL export and tray updates

This commit is contained in:
t8y2 2026-05-18 18:39:07 +08:00
parent b01c17e709
commit b2be49367c
12 changed files with 153 additions and 11 deletions

View File

@ -365,8 +365,7 @@ async function openSqlFile() {
async function openSqlFilePath(path: string) {
if (!isTauriRuntime()) return;
try {
const { readTextFile } = await import("@tauri-apps/plugin-fs");
const content = await readTextFile(path);
const content = await api.readExternalSqlFile(path);
const connectionId =
connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || "";
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;

View File

@ -43,6 +43,7 @@ const tableError = ref<string | null>(null);
const includeStructure = ref(true);
const includeData = ref(true);
const includeObjects = ref(true);
const dropTableIfExists = ref(false);
// Export state
const isExporting = ref(false);
@ -197,6 +198,7 @@ async function startExport() {
includeStructure: includeStructure.value,
includeData: includeData.value,
includeObjects: includeObjects.value,
dropTableIfExists: dropTableIfExists.value,
batchSize: 1000,
};
@ -240,6 +242,7 @@ function resetState() {
includeStructure.value = true;
includeData.value = true;
includeObjects.value = true;
dropTableIfExists.value = false;
isExporting.value = false;
exportProgress.value = null;
exportDone.value = false;
@ -415,6 +418,15 @@ watch(open, async (val) => {
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.includeStructure") }}
</div>
<div
class="flex items-center gap-2 text-xs"
:class="includeStructure ? 'cursor-pointer' : 'cursor-not-allowed text-muted-foreground/50'"
@click="includeStructure && (dropTableIfExists = !dropTableIfExists)"
>
<CheckSquare v-if="dropTableIfExists && includeStructure" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />
{{ t("databaseExport.dropTableIfExists") }}
</div>
<div class="flex items-center gap-2 cursor-pointer text-xs" @click="includeData = !includeData">
<CheckSquare v-if="includeData" class="w-3.5 h-3.5 text-primary shrink-0" />
<Square v-else class="w-3.5 h-3.5 text-muted-foreground/40 shrink-0" />

View File

@ -1173,6 +1173,7 @@ export default {
databaseExport: {
title: "Export Database",
includeStructure: "Table structure (DDL)",
dropTableIfExists: "Add DROP TABLE IF EXISTS before DDL",
includeData: "Table data (INSERT)",
includeObjects: "Views / Procedures / Functions",
tableSelection: "Tables",

View File

@ -1078,6 +1078,7 @@ export default {
databaseExport: {
title: "Exportar base de datos",
includeStructure: "Estructura de tablas (DDL)",
dropTableIfExists: "Agregar DROP TABLE IF EXISTS antes del DDL",
includeData: "Datos de tablas (INSERT)",
includeObjects: "Vistas / Procedimientos / Funciones",
tableSelection: "Tablas",

View File

@ -1150,6 +1150,7 @@ export default {
databaseExport: {
title: "导出数据库",
includeStructure: "表结构 (DDL)",
dropTableIfExists: "导出前添加 DROP TABLE IF EXISTS",
includeData: "表数据 (INSERT)",
includeObjects: "视图 / 存储过程 / 函数",
tableSelection: "选择表",

View File

@ -102,6 +102,7 @@ export const executeSqlFile = forward("executeSqlFile");
export const cancelSqlFileExecution = forward("cancelSqlFileExecution");
export const listenSqlFileProgress = forward("listenSqlFileProgress");
export const pendingOpenSqlFiles = forward("pendingOpenSqlFiles");
export const readExternalSqlFile = forward("readExternalSqlFile");
// Data Transfer
export const startTransfer = forward("startTransfer");

View File

@ -482,6 +482,10 @@ export async function pendingOpenSqlFiles(): Promise<string[]> {
return [];
}
export async function readExternalSqlFile(_path: string): Promise<string> {
throw new Error("Opening external SQL file paths is only available in the desktop app");
}
// ---------------------------------------------------------------------------
// Data Transfer
// ---------------------------------------------------------------------------

View File

@ -112,6 +112,10 @@ export async function pendingOpenSqlFiles(): Promise<string[]> {
return invoke("pending_open_sql_files");
}
export async function readExternalSqlFile(path: string): Promise<string> {
return invoke("read_external_sql_file", { path });
}
// --- AI Conversations ---
export interface AiChatMessage {
@ -868,6 +872,7 @@ export interface DatabaseExportRequest {
includeStructure: boolean;
includeData: boolean;
includeObjects: boolean;
dropTableIfExists?: boolean;
batchSize: number;
}

View File

@ -21,6 +21,8 @@ pub struct DatabaseExportRequest {
pub include_structure: bool,
pub include_data: bool,
pub include_objects: bool,
#[serde(default)]
pub drop_table_if_exists: bool,
pub batch_size: usize,
}
@ -159,6 +161,10 @@ pub async fn export_database_sql_core(
// Export structure
if request.include_structure {
if request.drop_table_if_exists {
writeln!(file, "{}\n", drop_table_if_exists_sql(table_name, &request.schema, &db_type))
.map_err(|e| format!("Failed to write file: {e}"))?;
}
match crate::schema::get_table_ddl_core(
state,
&request.connection_id,
@ -448,9 +454,14 @@ fn filter_selected_table_infos(
tables.into_iter().filter(|table| selected.contains(table.name.as_str())).collect()
}
fn drop_table_if_exists_sql(table_name: &str, schema: &str, db_type: &DatabaseType) -> String {
format!("DROP TABLE IF EXISTS {};", crate::transfer::qualified_table(table_name, schema, db_type))
}
#[cfg(test)]
mod tests {
use super::filter_selected_table_infos;
use super::{drop_table_if_exists_sql, filter_selected_table_infos};
use crate::models::connection::DatabaseType;
use crate::types::TableInfo;
fn table(name: &str, table_type: &str) -> TableInfo {
@ -474,4 +485,18 @@ mod tests {
assert_eq!(filtered.iter().map(|table| table.name.as_str()).collect::<Vec<_>>(), vec!["users", "orders"]);
}
#[test]
fn builds_drop_table_if_exists_with_qualified_mysql_name() {
let sql = drop_table_if_exists_sql("users", "app", &DatabaseType::Mysql);
assert_eq!(sql, "DROP TABLE IF EXISTS `app`.`users`;");
}
#[test]
fn builds_drop_table_if_exists_without_empty_schema() {
let sql = drop_table_if_exists_sql("users", "", &DatabaseType::Postgres);
assert_eq!(sql, "DROP TABLE IF EXISTS \"users\";");
}
}

View File

@ -19,7 +19,7 @@ tauri-build = { version = "2.5.6", features = [] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
tauri = { version = "2.10.3", features = [] }
tauri = { version = "2.10.3", features = ["tray-icon"] }
tauri-plugin-log = "2"
sqlx = { version = "0.8", features = ["runtime-tokio", "tls-native-tls", "mysql", "postgres", "sqlite", "json", "chrono", "uuid", "rust_decimal"] }
rust_decimal = { version = "1", features = ["serde"] }

View File

@ -9,6 +9,11 @@ pub fn pending_open_sql_files(state: tauri::State<'_, ExternalSqlOpenState>) ->
dedupe_paths(paths)
}
#[tauri::command]
pub fn read_external_sql_file(path: String) -> Result<String, String> {
read_external_sql_file_content(Path::new(&path))
}
#[derive(Default)]
pub struct ExternalSqlOpenState {
pending: Mutex<Vec<String>>,
@ -55,6 +60,13 @@ pub fn is_sql_file_path(path: &Path) -> bool {
path.extension().and_then(|ext| ext.to_str()).map(|ext| ext.eq_ignore_ascii_case("sql")).unwrap_or(false)
}
pub fn read_external_sql_file_content(path: &Path) -> Result<String, String> {
if !is_sql_file_path(path) {
return Err("Only .sql files can be opened this way".to_string());
}
std::fs::read_to_string(path).map_err(|e| format!("Failed to read SQL file: {e}"))
}
fn dedupe_paths(paths: Vec<String>) -> Vec<String> {
let mut unique = Vec::new();
for path in paths {
@ -91,4 +103,26 @@ mod tests {
assert_eq!(state.drain(), vec!["/tmp/a.sql"]);
assert!(state.drain().is_empty());
}
#[test]
fn reads_external_sql_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
assert_eq!(result.unwrap(), "select 1;");
}
#[test]
fn rejects_external_non_sql_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
assert!(result.unwrap_err().contains(".sql"));
}
}

View File

@ -8,8 +8,66 @@ use commands::connection::AppState;
use dbx_core::storage::Storage;
use std::sync::Arc;
use std::time::Instant;
use tauri::{
menu::MenuBuilder,
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
};
use tauri::{Emitter, Manager, RunEvent};
fn should_hide_window_on_close(target_os: &str) -> bool {
matches!(target_os, "macos" | "windows")
}
fn show_main_window(app: &tauri::AppHandle) {
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
fn setup_windows_tray(app: &mut tauri::App) -> tauri::Result<()> {
let menu = MenuBuilder::new(app).text("show", "Show DBX").separator().text("quit", "Quit DBX").build()?;
let mut tray = TrayIconBuilder::with_id("main-tray").tooltip("DBX").menu(&menu).show_menu_on_left_click(false);
if let Some(icon) = app.default_window_icon().cloned() {
tray = tray.icon(icon);
}
tray.on_menu_event(|app, event| {
if event.id() == "show" {
show_main_window(app);
} else if event.id() == "quit" {
app.exit(0);
}
})
.on_tray_icon_event(|tray, event| match event {
TrayIconEvent::Click { button: MouseButton::Left, button_state: MouseButtonState::Up, .. }
| TrayIconEvent::DoubleClick { button: MouseButton::Left, .. } => show_main_window(tray.app_handle()),
_ => {}
})
.build(app)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::should_hide_window_on_close;
#[test]
fn hides_window_on_close_for_windows_and_macos() {
assert!(should_hide_window_on_close("windows"));
assert!(should_hide_window_on_close("macos"));
}
#[test]
fn does_not_hide_window_on_close_for_other_platforms() {
assert!(!should_hide_window_on_close("linux"));
}
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
rustls::crypto::aws_lc_rs::default_provider().install_default().expect("Failed to install rustls crypto provider");
@ -75,15 +133,18 @@ pub fn run() {
let _ = window.set_decorations(false);
}
}
#[cfg(target_os = "windows")]
setup_windows_tray(app)?;
window_state_guard::enforce_main_window_bounds(app.handle());
Ok(())
})
.on_window_event(|window, event| {
#[cfg(target_os = "macos")]
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
window.hide().unwrap();
api.prevent_close();
if should_hide_window_on_close(std::env::consts::OS) {
let _ = window.hide();
api.prevent_close();
}
}
})
.invoke_handler(tauri::generate_handler![
@ -135,6 +196,7 @@ pub fn run() {
commands::sql_file::execute_sql_file,
commands::sql_file::cancel_sql_file_execution,
commands::external_sql::pending_open_sql_files,
commands::external_sql::read_external_sql_file,
commands::table_import::preview_table_import_file,
commands::table_import::import_table_file,
commands::table_import::cancel_table_import,
@ -215,10 +277,7 @@ pub fn run() {
#[cfg(target_os = "macos")]
if let RunEvent::Reopen { has_visible_windows, .. } = &event {
if !has_visible_windows {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
}
show_main_window(app_handle);
}
}
});