fix(sql-file): open and save custom-filtered text files

This commit is contained in:
zipg authored and GitHub committed 2026-09-02 11:55:00 +08:00
1 parent d3a6a69796
commit 9b6f3c48e4
8 files changed
+113 -58

No files matched your search

+34 -29
View File
@@ -85,9 +85,19 @@ pub async fn save_external_sql_file(
window: tauri::Window,
default_file_name: String,
content: String,
filter_extension: Option<String>,
) -> Result<Option<ExternalSqlFileSaveResult>, String> {
let (sender, receiver) = tokio::sync::oneshot::channel();
window.dialog().file().set_file_name(default_file_name).add_filter("SQL", &["sql"]).save_file(move |file_path| {
// Non-SQL external tabs (custom-filtered text files) keep their own
// extension when saving a copy; without a usable extension leave the
// dialog unfiltered instead of forcing the SQL filter.
let dialog = window.dialog().file().set_file_name(default_file_name);
let dialog = match filter_extension.as_deref().map(str::trim).filter(|extension| !extension.is_empty()) {
Some("sql") => dialog.add_filter("SQL", &["sql"]),
Some(extension) => dialog.add_filter(extension.to_uppercase(), &[extension]),
None => dialog,
};
dialog.save_file(move |file_path| {
let _ = sender.send(file_path);
});
let path = receiver
@@ -171,10 +181,10 @@ fn external_sql_file_version(metadata: &std::fs::Metadata, bytes: &[u8]) -> Exte
#[cfg(test)]
fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResult, String> {
if !is_sql_file_path(path) {
return Err("Only .sql files can be opened this way".to_string());
}
let metadata = std::fs::metadata(path).map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
if !metadata.is_file() {
return Err("Only files can be opened this way".to_string());
}
if exceeds_external_sql_editor_limit(metadata.len()) {
return Ok(ExternalSqlFileReadResult::TooLarge {
size_bytes: metadata.len(),
@@ -187,11 +197,11 @@ fn read_external_sql_file_content(path: &Path) -> Result<ExternalSqlFileReadResu
}
async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalSqlFileReadResult, String> {
if !is_sql_file_path(&path) {
return Err("Only .sql files can be opened this way".to_string());
}
let file = tokio::fs::File::open(&path).await.map_err(|e| format!("Failed to read SQL file: {e}"))?;
let metadata = file.metadata().await.map_err(|e| format!("Failed to inspect SQL file: {e}"))?;
if !metadata.is_file() {
return Err("Only files can be opened this way".to_string());
}
if exceeds_external_sql_editor_limit(metadata.len()) {
return Ok(ExternalSqlFileReadResult::TooLarge {
size_bytes: metadata.len(),
@@ -214,13 +224,11 @@ async fn read_external_sql_file_content_async(path: PathBuf) -> Result<ExternalS
}
async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFileStatus, String> {
if !is_sql_file_path(&path) {
return Err("Only .sql files can be inspected this way".to_string());
}
match tokio::fs::metadata(&path).await {
Ok(metadata) => {
Ok(metadata) if metadata.is_file() => {
Ok(ExternalSqlFileStatus::Present { size_bytes: metadata.len(), modified_ns: modified_ns(&metadata) })
}
Ok(_) => Err("Only files can be inspected this way".to_string()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(ExternalSqlFileStatus::Missing),
Err(error) => Err(format!("Failed to inspect SQL file: {error}")),
}
@@ -228,9 +236,6 @@ async fn inspect_external_sql_file_async(path: PathBuf) -> Result<ExternalSqlFil
#[cfg(test)]
fn write_external_sql_file_content(path: &Path, content: &str) -> Result<(), String> {
if !is_sql_file_path(path) {
return Err("Only .sql files can be saved this way".to_string());
}
std::fs::write(path, content).map_err(|e| format!("Failed to save SQL file: {e}"))
}
@@ -266,10 +271,6 @@ async fn write_external_sql_file_checked_async(
expected_missing: bool,
force: bool,
) -> Result<ExternalSqlFileWriteResult, String> {
if !is_sql_file_path(&path) {
return Err("Only .sql files can be saved this way".to_string());
}
if !force {
match external_sql_file_version_async(&path).await? {
Some(current_version) => {
@@ -397,14 +398,17 @@ mod tests {
}
#[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();
fn reads_external_filtered_text_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.py", uuid::Uuid::new_v4()));
std::fs::write(&path, "print('hello')").unwrap();
let result = read_external_sql_file_content(&path);
let _ = std::fs::remove_file(&path);
assert!(result.unwrap_err().contains(".sql"));
let ExternalSqlFileReadResult::Content { content, .. } = result.unwrap() else {
panic!("expected text file content");
};
assert_eq!(content, "print('hello')");
}
#[test]
@@ -474,9 +478,9 @@ mod tests {
}
#[tokio::test]
async fn inspects_present_and_missing_external_sql_files() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sql", uuid::Uuid::new_v4()));
std::fs::write(&path, "select 1;").unwrap();
async fn inspects_present_and_missing_external_text_files() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.sh", uuid::Uuid::new_v4()));
std::fs::write(&path, "echo test").unwrap();
let present = inspect_external_sql_file_async(path.clone()).await.unwrap();
assert!(matches!(present, ExternalSqlFileStatus::Present { size_bytes: 9, .. }));
@@ -583,12 +587,13 @@ mod tests {
}
#[test]
fn rejects_external_non_sql_file_save() {
fn writes_external_filtered_text_file_content() {
let path = std::env::temp_dir().join(format!("dbx-test-{}.txt", uuid::Uuid::new_v4()));
let result = write_external_sql_file_content(&path, "select 2;");
let result = write_external_sql_file_content(&path, "plain text");
assert!(result.unwrap_err().contains(".sql"));
assert!(!path.exists());
assert!(result.is_ok());
assert_eq!(std::fs::read_to_string(&path).unwrap(), "plain text");
let _ = std::fs::remove_file(&path);
}
}
+37 -15
View File
@@ -35,11 +35,16 @@ pub struct SqlFileEntry {
pub children: Vec<SqlFileEntry>,
}
fn validate_sql_file_name(file_name: &str) -> Result<&str, String> {
fn validate_file_name(file_name: &str) -> Result<&str, String> {
let file_name = file_name.trim();
if file_name.is_empty() || file_name == "." || file_name == ".." || file_name.contains(['/', '\\']) {
return Err("SQL file name must not contain a path".to_string());
return Err("File name must not contain a path".to_string());
}
Ok(file_name)
}
fn validate_sql_file_name(file_name: &str) -> Result<&str, String> {
let file_name = validate_file_name(file_name)?;
if !file_name.to_ascii_lowercase().ends_with(".sql") || file_name.len() <= 4 {
return Err("Only .sql files can be managed here".to_string());
}
@@ -72,19 +77,14 @@ fn managed_directory(root_path: &str, directory_path: &str) -> Result<PathBuf, S
Ok(directory)
}
fn managed_sql_file(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
fn managed_file(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
let root = canonical_directory(Path::new(root_path), "SQL root folder")?;
let file = std::fs::canonicalize(file_path).map_err(|error| format!("Failed to resolve SQL file: {error}"))?;
let file = std::fs::canonicalize(file_path).map_err(|error| format!("Failed to resolve file: {error}"))?;
if !file.starts_with(&root) {
return Err("SQL file is outside the opened root folder".to_string());
return Err("File is outside the opened root folder".to_string());
}
if !file.is_file()
|| !file
.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("sql"))
{
return Err("Only .sql files can be managed here".to_string());
if !file.is_file() {
return Err("Only files can be managed here".to_string());
}
Ok(file)
}
@@ -248,9 +248,9 @@ pub async fn rename_sql_file_in_folder(
file_name: String,
) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || {
let file_name = validate_sql_file_name(&file_name)?.to_owned();
let file_name = validate_file_name(&file_name)?.to_owned();
let display_path = renamed_sql_file_display_path(&file_path, &file_name)?;
let file = managed_sql_file(&root_path, &file_path)?;
let file = managed_file(&root_path, &file_path)?;
let target =
file.parent().ok_or_else(|| "Failed to resolve SQL file parent folder".to_string())?.join(&file_name);
if target.exists() {
@@ -270,7 +270,7 @@ pub async fn rename_sql_file_in_folder(
#[tauri::command]
pub async fn delete_sql_file_in_folder(root_path: String, file_path: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
let file = managed_sql_file(&root_path, &file_path)?;
let file = managed_file(&root_path, &file_path)?;
std::fs::remove_file(file).map_err(|error| format!("Failed to delete SQL file: {error}"))
})
.await
@@ -367,9 +367,31 @@ mod tests {
assert!(validate_sql_file_name("query.txt").is_err());
assert!(validate_sql_file_name("nested/query.sql").is_err());
assert!(validate_sql_file_name("nested\\query.sql").is_err());
assert_eq!(validate_file_name("script.py").unwrap(), "script.py");
assert_eq!(renamed_sql_file_display_path("draft.sql", "report.sql").unwrap(), "report.sql");
}
#[tokio::test]
async fn renames_and_deletes_filtered_text_files_within_the_opened_root() {
let root = std::env::temp_dir().join(format!("dbx-text-file-manage-{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&root).unwrap();
let source = root.join("script.sh");
std::fs::write(&source, "echo hello").unwrap();
let renamed = rename_sql_file_in_folder(
root.to_string_lossy().into_owned(),
source.to_string_lossy().into_owned(),
"script.py".to_string(),
)
.await
.unwrap();
assert!(Path::new(&renamed).is_file());
delete_sql_file_in_folder(root.to_string_lossy().into_owned(), renamed.clone()).await.unwrap();
assert!(!Path::new(&renamed).exists());
std::fs::remove_dir_all(root).unwrap();
}
#[tokio::test]
async fn manages_sql_files_within_the_opened_root() {
let root = std::env::temp_dir().join(format!("dbx-sql-file-manage-{}", uuid::Uuid::new_v4()));