diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index d5a82b3c76..44988274ff 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -17,6 +17,7 @@ import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts"; +import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport"; import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import type { SavedSqlFile, SavedSqlFolder } from "@/types/database"; @@ -76,14 +77,6 @@ function sanitizeFileSystemSegment(name: string) { return name.replace(/[<>:"/\\|?*\p{Cc}]/gu, "_").trim() || "untitled"; } -function relativeImportName(baseDir: string, filePath: string) { - const normalizedBase = baseDir.replace(/\\/g, "/").replace(/\/+$/, ""); - const normalizedFile = filePath.replace(/\\/g, "/"); - const relative = normalizedFile.startsWith(`${normalizedBase}/`) ? normalizedFile.slice(normalizedBase.length + 1) : normalizedFile.split("/").pop() || "import.sql"; - const pretty = relative.replace(/\//g, " - "); - return ensureSqlExtension(pretty); -} - function uniqueImportedName(name: string, takenNames: Set) { const normalized = ensureSqlExtension(name); if (!takenNames.has(normalized)) { @@ -201,10 +194,27 @@ async function exportFolderContents(folder?: SavedSqlFolder) { } } -async function collectSqlFilesRecursively(dir: string): Promise { - const collectPaths = (entries: Awaited>): string[] => entries.flatMap((entry) => (entry.is_dir ? collectPaths(entry.children) : [entry.path])); +async function collectSqlFilesRecursively(dir: string) { + return collectSavedSqlDirectoryImportFiles(await api.listSqlFilesInFolder(dir)); +} - return collectPaths(await api.listSqlFilesInFolder(dir)); +function importedFolderCacheKey(parentFolderId: string | undefined, name: string) { + return JSON.stringify([parentFolderId || "", name]); +} + +async function resolveImportedFolder(connectionId: string, rootFolderId: string | undefined, folderNames: string[], folderCache: Map) { + let parentFolderId = rootFolderId; + for (const name of folderNames) { + const cacheKey = importedFolderCacheKey(parentFolderId, name); + let folder = folderCache.get(cacheKey); + if (!folder) { + folder = savedSqlStore.listChildFolders(connectionId, parentFolderId).find((candidate) => candidate.name === name); + if (!folder) folder = await savedSqlStore.createFolder(connectionId, name, parentFolderId); + folderCache.set(cacheKey, folder); + } + parentFolderId = folder.id; + } + return parentFolderId; } async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) { @@ -229,27 +239,36 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) { }); if (!selected || Array.isArray(selected)) return; - const sqlPaths = await collectSqlFilesRecursively(selected); - if (sqlPaths.length === 0) { + const importFiles = await collectSqlFilesRecursively(selected); + if (importFiles.length === 0) { toast(t("sqlLibrary.importNone"), 3000); return; } - const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).map((file) => file.name)); + const folderCache = new Map(); + const takenNamesByFolder = new Map>(); - for (const path of sqlPaths) { + for (const file of importFiles) { + const folderId = await resolveImportedFolder(connectionId, targetFolder?.id, file.folderNames, folderCache); + const folderKey = folderId || ""; + let takenNames = takenNamesByFolder.get(folderKey); + if (!takenNames) { + takenNames = new Set(savedSqlStore.listFiles(connectionId, folderId).map((savedFile) => savedFile.name)); + takenNamesByFolder.set(folderKey, takenNames); + } + const path = file.path; const content = await api.readExternalSqlFile(path); - const displayName = uniqueImportedName(relativeImportName(selected, path), takenNames); + const displayName = uniqueImportedName(file.name, takenNames); await savedSqlStore.saveFile({ connectionId, - folderId: targetFolder?.id, + folderId, name: displayName, database: "", sql: content, }); } - toast(t("sqlLibrary.imported", { count: sqlPaths.length }), 2500); + toast(t("sqlLibrary.imported", { count: importFiles.length }), 2500); } catch (e: any) { toast(t("sqlLibrary.importFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); } diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlDirectoryImport.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlDirectoryImport.spec.ts new file mode 100644 index 0000000000..6b9ad78b81 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlDirectoryImport.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport"; + +describe("saved SQL directory import", () => { + it("preserves nested directory paths without flattening file names", () => { + const files = collectSavedSqlDirectoryImportFiles([ + { name: "database", path: "/scripts/database", is_dir: true, children: [{ name: "daily.sql", path: "/scripts/database/daily.sql", is_dir: false, children: [] }] }, + { + name: "incidents", + path: "/scripts/incidents", + is_dir: true, + children: [ + { + name: "postgres", + path: "/scripts/incidents/postgres", + is_dir: true, + children: [{ name: "repair.sql", path: "/scripts/incidents/postgres/repair.sql", is_dir: false, children: [] }], + }, + ], + }, + { name: "root.sql", path: "/scripts/root.sql", is_dir: false, children: [] }, + ]); + + expect(files).toEqual([ + { name: "daily.sql", path: "/scripts/database/daily.sql", folderNames: ["database"] }, + { name: "repair.sql", path: "/scripts/incidents/postgres/repair.sql", folderNames: ["incidents", "postgres"] }, + { name: "root.sql", path: "/scripts/root.sql", folderNames: [] }, + ]); + }); +}); diff --git a/apps/desktop/src/lib/savedSql/savedSqlDirectoryImport.ts b/apps/desktop/src/lib/savedSql/savedSqlDirectoryImport.ts new file mode 100644 index 0000000000..ff6d23740b --- /dev/null +++ b/apps/desktop/src/lib/savedSql/savedSqlDirectoryImport.ts @@ -0,0 +1,24 @@ +export interface SavedSqlDirectoryImportEntry { + name: string; + path: string; + is_dir: boolean; + children: SavedSqlDirectoryImportEntry[]; +} + +export interface SavedSqlDirectoryImportFile { + name: string; + path: string; + folderNames: string[]; +} + +export function collectSavedSqlDirectoryImportFiles(entries: SavedSqlDirectoryImportEntry[], folderNames: string[] = []): SavedSqlDirectoryImportFile[] { + const files: SavedSqlDirectoryImportFile[] = []; + for (const entry of entries) { + if (entry.is_dir) { + files.push(...collectSavedSqlDirectoryImportFiles(entry.children, [...folderNames, entry.name])); + } else { + files.push({ name: entry.name, path: entry.path, folderNames }); + } + } + return files; +} diff --git a/packages/app-tests/sqlLibraryImportEncoding.test.ts b/packages/app-tests/sqlLibraryImportEncoding.test.ts index 57eb36b1fd..922bdc9859 100644 --- a/packages/app-tests/sqlLibraryImportEncoding.test.ts +++ b/packages/app-tests/sqlLibraryImportEncoding.test.ts @@ -22,6 +22,16 @@ test("SQL library directory imports use the native pruned folder scanner", () => assert.doesNotMatch(scanner, /@tauri-apps\/plugin-fs|readDir/); }); +test("SQL library directory imports preserve nested folder paths", () => { + const start = source.indexOf("async function importDirectoryIntoLibrary"); + const end = source.indexOf("async function chooseSyncDirectory", start); + const handler = start >= 0 && end > start ? source.slice(start, end) : ""; + + assert.match(handler, /resolveImportedFolder\(connectionId, targetFolder\?\.id, file\.folderNames, folderCache\)/); + assert.match(handler, /uniqueImportedName\(file\.name, takenNames\)/); + assert.doesNotMatch(handler, /relativeImportName/); +}); + test("SQL library file menu can open a file with the current tab target", () => { assert.match(source, /label: t\("sqlLibrary\.openInCurrentDatabase"\)/); assert.match(source, /action: \(\) => openFile\(target, "current"\)/);