feat: add SQL library panel with folder/file management, rename, and sync support
This commit is contained in:
1 parent
d7a8f5d285
commit
9ffa4c41d9
26 files changed
+711
-352
No files matched your search
@@ -1,9 +1,38 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tauri::State;
|
||||
|
||||
use dbx_core::connection::AppState;
|
||||
use dbx_core::saved_sql::{SavedSqlFile, SavedSqlFolder, SavedSqlLibrary};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct SavedSqlStorageState {
|
||||
pub data_dir: PathBuf,
|
||||
}
|
||||
|
||||
const SYNC_MANIFEST_FILE: &str = ".dbx-sql-library-sync.json";
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedSqlSyncEntry {
|
||||
pub folder_name: Option<String>,
|
||||
pub file_name: String,
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedSqlSyncRequest {
|
||||
pub target_dir: String,
|
||||
pub entries: Vec<SavedSqlSyncEntry>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SavedSqlSyncManifest {
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_saved_sql_library(state: State<'_, Arc<AppState>>) -> Result<SavedSqlLibrary, String> {
|
||||
state.storage.load_saved_sql_library().await
|
||||
@@ -33,3 +62,157 @@ pub async fn save_saved_sql_file(state: State<'_, Arc<AppState>>, file: SavedSql
|
||||
pub async fn delete_saved_sql_file(state: State<'_, Arc<AppState>>, id: String) -> Result<(), String> {
|
||||
state.storage.delete_saved_sql_file(&id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn saved_sql_storage_dir(state: State<'_, SavedSqlStorageState>) -> Result<String, String> {
|
||||
Ok(state.data_dir.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_saved_sql_storage_dir(
|
||||
state: State<'_, SavedSqlStorageState>,
|
||||
dir: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let target_dir = dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| state.data_dir.clone());
|
||||
std::fs::create_dir_all(&target_dir).map_err(|e| e.to_string())?;
|
||||
open_path(&target_dir)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn sync_saved_sql_directory(request: SavedSqlSyncRequest) -> Result<(), String> {
|
||||
let target_dir = PathBuf::from(request.target_dir.trim());
|
||||
if target_dir.as_os_str().is_empty() {
|
||||
return Err("Target directory is empty".to_string());
|
||||
}
|
||||
tokio::task::spawn_blocking(move || sync_saved_sql_directory_blocking(&target_dir, &request.entries))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn sync_saved_sql_directory_blocking(target_dir: &Path, entries: &[SavedSqlSyncEntry]) -> Result<(), String> {
|
||||
std::fs::create_dir_all(target_dir).map_err(|e| e.to_string())?;
|
||||
remove_previous_sync_files(target_dir)?;
|
||||
|
||||
let mut written_files = Vec::new();
|
||||
for entry in entries {
|
||||
let mut file_dir = target_dir.to_path_buf();
|
||||
if let Some(folder_name) = entry.folder_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
|
||||
file_dir.push(sanitize_file_segment(folder_name));
|
||||
}
|
||||
std::fs::create_dir_all(&file_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let file_name = ensure_sql_extension(&sanitize_file_segment(&entry.file_name));
|
||||
let file_path = unique_file_path(&file_dir, &file_name);
|
||||
std::fs::write(&file_path, &entry.sql).map_err(|e| e.to_string())?;
|
||||
if let Ok(relative) = file_path.strip_prefix(target_dir) {
|
||||
written_files.push(relative.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = SavedSqlSyncManifest { files: written_files };
|
||||
let manifest_json = serde_json::to_string_pretty(&manifest).map_err(|e| e.to_string())?;
|
||||
std::fs::write(target_dir.join(SYNC_MANIFEST_FILE), manifest_json).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_previous_sync_files(target_dir: &Path) -> Result<(), String> {
|
||||
let manifest_path = target_dir.join(SYNC_MANIFEST_FILE);
|
||||
let Ok(raw) = std::fs::read_to_string(&manifest_path) else {
|
||||
return Ok(());
|
||||
};
|
||||
let manifest = serde_json::from_str::<SavedSqlSyncManifest>(&raw).unwrap_or_default();
|
||||
for relative in manifest.files {
|
||||
let file_path = target_dir.join(relative);
|
||||
if file_path.is_file() {
|
||||
std::fs::remove_file(&file_path).map_err(|e| e.to_string())?;
|
||||
remove_empty_parent_dirs(target_dir, file_path.parent());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_empty_parent_dirs(root: &Path, parent: Option<&Path>) {
|
||||
let Some(dir) = parent else {
|
||||
return;
|
||||
};
|
||||
if dir == root {
|
||||
return;
|
||||
}
|
||||
if std::fs::remove_dir(dir).is_ok() {
|
||||
remove_empty_parent_dirs(root, dir.parent());
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_file_segment(value: &str) -> String {
|
||||
let sanitized = value
|
||||
.chars()
|
||||
.map(|ch| match ch {
|
||||
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||
ch if ch.is_control() => '_',
|
||||
ch => ch,
|
||||
})
|
||||
.collect::<String>()
|
||||
.trim()
|
||||
.trim_matches('.')
|
||||
.to_string();
|
||||
if sanitized.is_empty() {
|
||||
"untitled".to_string()
|
||||
} else {
|
||||
sanitized
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_sql_extension(name: &str) -> String {
|
||||
if name.to_lowercase().ends_with(".sql") {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{name}.sql")
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_file_path(dir: &Path, file_name: &str) -> PathBuf {
|
||||
let mut candidate = dir.join(file_name);
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
|
||||
let base = file_name.strip_suffix(".sql").unwrap_or(file_name);
|
||||
let mut counter = 2;
|
||||
loop {
|
||||
candidate = dir.join(format!("{base} ({counter}).sql"));
|
||||
if !candidate.exists() {
|
||||
return candidate;
|
||||
}
|
||||
counter += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn open_path(path: &Path) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut command = {
|
||||
let mut command = std::process::Command::new("open");
|
||||
command.arg(path);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut command = {
|
||||
let mut command = std::process::Command::new("explorer");
|
||||
command.arg(path);
|
||||
command
|
||||
};
|
||||
|
||||
#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))]
|
||||
let mut command = {
|
||||
let mut command = std::process::Command::new("xdg-open");
|
||||
command.arg(path);
|
||||
command
|
||||
};
|
||||
|
||||
command.spawn().map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
Reference in new issue
Block a user