diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 5640738638..f92c19db65 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -62,7 +62,7 @@ import { isMacOS, isWindows } from "@/lib/backend/platform"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile"; import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; -import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; +import { externalSqlFileOpenErrorMessage, isSqlFilePath, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen"; import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab, TreeNode } from "@/types/database"; import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink"; import { parseAiConfigDeepLink, type AiConfigDeepLinkDraft } from "@/lib/ai/aiConfigDeepLink"; @@ -1311,6 +1311,7 @@ function savedSqlTargetForSave(tab: QueryTab) { */ async function formattedSqlForSave(tab: QueryTab): Promise { if (!settingsStore.editorSettings.formatSqlOnSqlFileSave) return tab.sql; + if (tab.externalSqlPath && !isSqlFilePath(tab.externalSqlPath)) return tab.sql; const sqlSnapshot = tab.sql; if (!sqlSnapshot.trim()) return sqlSnapshot; const connection = connectionStore.getConfig(tab.connectionId); @@ -1590,7 +1591,11 @@ async function confirmSaveSqlToLibrary() { async function saveExternalSqlTabAs(tab: QueryTab): Promise { if (!canSaveSqlTab(tab) || !isTauriRuntime()) return false; try { - const saved = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), await formattedSqlForSave(tab)); + // Non-SQL external tabs (custom-filtered text files) keep their own file + // name and extension when saving a copy instead of being forced to .sql. + const currentFileName = tab.externalSqlPath?.split(/[\\/]/).pop()?.trim() ?? ""; + const filterExtension = currentFileName.includes(".") ? currentFileName.split(".").pop()?.toLowerCase() : undefined; + const saved = await api.saveExternalSqlFile(currentFileName || defaultSavedSqlName(tab.title), await formattedSqlForSave(tab), filterExtension); if (!saved) return false; queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version); rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog, schema: tab.schema }); diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index 253c8a1704..9a912f4c29 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -15,7 +15,7 @@ import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { translateBackendError } from "@/i18n/backend-errors"; import { copyToClipboard } from "@/lib/common/clipboard"; import { forgetExternalSqlFileTarget, moveExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; -import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen"; +import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError, isSqlFilePath } from "@/lib/sql/sqlFileOpen"; import * as api from "@/lib/backend/api"; import type { SqlFileEntry } from "@/lib/backend/api"; import { getSqlFileFilter, getSqlFileFolderPaths, saveSqlFileFilter, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders"; @@ -257,7 +257,7 @@ async function openFile(path: string) { const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget()); queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog, target.schema); } catch (e: any) { - if (isExternalSqlFileTooLargeError(e)) { + if (isExternalSqlFileTooLargeError(e) && isSqlFilePath(path)) { executeFile(path); toast(t("sqlFile.largeFileExecutionOpened", { size: formatSqlFileSize(e.sizeBytes) }), 6000); return; @@ -376,7 +376,12 @@ function handlePanelClick(event: MouseEvent) { function normalizedSqlFileName(name: string) { const trimmed = name.trim(); - return /\.sql$/i.test(trimmed) ? trimmed : `${trimmed}.sql`; + return isSqlFilePath(trimmed) ? trimmed : `${trimmed}.sql`; +} + +function normalizedRenamedFileName(name: string, currentName: string) { + const trimmed = name.trim(); + return isSqlFilePath(currentName) ? normalizedSqlFileName(trimmed) : trimmed; } function openCreateDialog(rootPath: string, directoryPath: string) { @@ -409,7 +414,7 @@ async function renameSqlFile() { const target = renameTarget.value; if (!target || !fileNameInput.value.trim()) return; try { - const nextPath = await api.renameSqlFileInFolder(target.folderPath, target.entry.path, normalizedSqlFileName(fileNameInput.value)); + const nextPath = await api.renameSqlFileInFolder(target.folderPath, target.entry.path, normalizedRenamedFileName(fileNameInput.value, target.entry.name)); // Renaming does not change content, and re-reading here would make a // successful rename appear to fail for files too large for the editor. queryStore.relocateExternalSqlFilePath(target.entry.path, nextPath); @@ -487,9 +492,11 @@ const contextMenuItems = computed(() => { } // file - return [ - { label: t("sqlFileTree.openFile"), action: () => openFile(target.entry.path), icon: FileCode }, - { label: t("sqlFileTree.executeSqlFile"), action: () => executeFile(target.entry.path), icon: Play }, + const items: ContextMenuItem[] = [{ label: t("sqlFileTree.openFile"), action: () => openFile(target.entry.path), icon: FileCode }]; + if (isSqlFilePath(target.entry.name)) { + items.push({ label: t("sqlFileTree.executeSqlFile"), action: () => executeFile(target.entry.path), icon: Play }); + } + items.push( { label: "", separator: true }, { label: t("sqlFileTree.revealInFileManager"), action: () => revealInFileManager(target.entry.path), icon: FolderSearch }, { label: t("sqlFileTree.copyPath"), action: () => copyPath(target.entry.path), icon: Copy }, @@ -497,7 +504,8 @@ const contextMenuItems = computed(() => { { label: t("sqlFileTree.renameFile"), action: () => openRenameDialog(target), icon: Pencil }, { label: "", separator: true }, { label: t("sqlFileTree.deleteFile"), action: () => openDeleteDialog(target), icon: Trash2, variant: "destructive" }, - ]; + ); + return items; }); function expandSubtree(target: Extract) { diff --git a/apps/desktop/src/components/layout/__tests__/SqlFilePanel.spec.ts b/apps/desktop/src/components/layout/__tests__/SqlFilePanel.spec.ts index d8c8a69ed9..8a038e7b5c 100644 --- a/apps/desktop/src/components/layout/__tests__/SqlFilePanel.spec.ts +++ b/apps/desktop/src/components/layout/__tests__/SqlFilePanel.spec.ts @@ -39,6 +39,11 @@ describe("SqlFilePanel file renaming", () => { expect(renameFunction).toContain("queryStore.relocateExternalSqlFilePath(target.entry.path, nextPath)"); expect(renameFunction).not.toContain("readExternalSqlFileSnapshot(nextPath)"); }); + + it("preserves non-SQL extensions while keeping SQL rename convenience", () => { + expect(panelSource).toContain("normalizedRenamedFileName(fileNameInput.value, target.entry.name)"); + expect(panelSource).toContain("isSqlFilePath(currentName) ? normalizedSqlFileName(trimmed) : trimmed"); + }); }); describe("SqlFilePanel directory actions", () => { @@ -58,4 +63,9 @@ describe("SqlFilePanel file filter", () => { expect(panelSource).toContain("t('sqlFileTree.fileFilterPlaceholder')"); expect(panelSource).not.toContain("const fileFilterPlaceholder ="); }); + + it("offers SQL execution only for SQL files", () => { + expect(panelSource).toContain("if (isSqlFilePath(target.entry.name))"); + expect(panelSource).toContain("isExternalSqlFileTooLargeError(e) && isSqlFilePath(path)"); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts index 92a5bf0288..ea51469548 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlFileOpen.spec.ts @@ -1,13 +1,18 @@ // @vitest-environment happy-dom import { describe, expect, it } from "vitest"; -import { ExternalSqlFileTooLargeError, externalSqlFileDisplayTitles, externalSqlFileOpenErrorMessage, formatSqlFileSize, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, normalizeExternalSqlPath, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen"; +import { ExternalSqlFileTooLargeError, externalSqlFileDisplayTitles, externalSqlFileOpenErrorMessage, formatSqlFileSize, isSqlFilePath, MAX_EXTERNAL_SQL_EDITOR_FILE_BYTES, normalizeExternalSqlPath, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen"; describe("external SQL file paths", () => { it("normalizes Windows separators for identity checks", () => { expect(normalizeExternalSqlPath(" C:\\work\\demo.sql ")).toBe("C:/work/demo.sql"); }); + it("distinguishes SQL files from other filtered text files", () => { + expect(isSqlFilePath("C:\\work\\demo.SQL")).toBe(true); + expect(isSqlFilePath("/work/script.py")).toBe(false); + }); + it("uses the shortest unique parent path for duplicate filenames", () => { expect(externalSqlFileDisplayTitles(["/work/demo/create.sql", "/work/learn/create.sql", "/work/query.sql"])).toEqual(["demo/create.sql", "learn/create.sql", "query.sql"]); }); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 7ace725fe2..e4c199a769 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -2246,7 +2246,7 @@ export async function writeExternalSqlFile(_path: string, _content: string, _opt throw new Error("Saving external SQL file paths is only available in the desktop app"); } -export async function saveExternalSqlFile(_defaultFileName: string, _content: string): Promise<{ path: string; version: import("@/types/database").ExternalSqlFileVersion } | null> { +export async function saveExternalSqlFile(_defaultFileName: string, _content: string, _filterExtension?: string): Promise<{ path: string; version: import("@/types/database").ExternalSqlFileVersion } | null> { throw new Error("Saving SQL files locally is only available in the desktop app"); } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index 7a1ca9fc0e..92c8337b8c 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -937,8 +937,8 @@ export async function writeExternalSqlFile(path: string, content: string, option }); } -export async function saveExternalSqlFile(defaultFileName: string, content: string): Promise<{ path: string; version: ExternalSqlFileVersion } | null> { - return invoke("save_external_sql_file", { defaultFileName, content }); +export async function saveExternalSqlFile(defaultFileName: string, content: string, filterExtension?: string): Promise<{ path: string; version: ExternalSqlFileVersion } | null> { + return invoke("save_external_sql_file", { defaultFileName, content, filterExtension }); } export interface SqlFileEntry { diff --git a/src-tauri/src/commands/external_sql.rs b/src-tauri/src/commands/external_sql.rs index 50720ab83e..d3fd53537e 100644 --- a/src-tauri/src/commands/external_sql.rs +++ b/src-tauri/src/commands/external_sql.rs @@ -85,9 +85,19 @@ pub async fn save_external_sql_file( window: tauri::Window, default_file_name: String, content: String, + filter_extension: Option, ) -> Result, 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 { - 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 Result { - 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 Result { - 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 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 { - 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); } } diff --git a/src-tauri/src/commands/list_sql_files.rs b/src-tauri/src/commands/list_sql_files.rs index 2b0dc58cf1..45e2468015 100644 --- a/src-tauri/src/commands/list_sql_files.rs +++ b/src-tauri/src/commands/list_sql_files.rs @@ -35,11 +35,16 @@ pub struct SqlFileEntry { pub children: Vec, } -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 Result { +fn managed_file(root_path: &str, file_path: &str) -> Result { 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 { 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()));