fix(sql-library): preserve folders during directory import

Closes #5877
This commit is contained in:
t8y2 committed 2026-08-11 17:03:10 +08:00
1 parent d71091f262
commit 8482ef25ec
4 files changed
+101 -18

No files matched your search

@@ -17,6 +17,7 @@ import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts"; import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import type { SavedSqlFile, SavedSqlFolder } from "@/types/database"; import type { SavedSqlFile, SavedSqlFolder } from "@/types/database";
@@ -76,14 +77,6 @@ function sanitizeFileSystemSegment(name: string) {
return name.replace(/[<>:"/\\|?*\p{Cc}]/gu, "_").trim() || "untitled"; 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<string>) { function uniqueImportedName(name: string, takenNames: Set<string>) {
const normalized = ensureSqlExtension(name); const normalized = ensureSqlExtension(name);
if (!takenNames.has(normalized)) { if (!takenNames.has(normalized)) {
@@ -201,10 +194,27 @@ async function exportFolderContents(folder?: SavedSqlFolder) {
} }
} }
async function collectSqlFilesRecursively(dir: string): Promise<string[]> { async function collectSqlFilesRecursively(dir: string) {
const collectPaths = (entries: Awaited<ReturnType<typeof api.listSqlFilesInFolder>>): string[] => entries.flatMap((entry) => (entry.is_dir ? collectPaths(entry.children) : [entry.path])); 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<string, SavedSqlFolder>) {
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) { async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
@@ -229,27 +239,36 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
}); });
if (!selected || Array.isArray(selected)) return; if (!selected || Array.isArray(selected)) return;
const sqlPaths = await collectSqlFilesRecursively(selected); const importFiles = await collectSqlFilesRecursively(selected);
if (sqlPaths.length === 0) { if (importFiles.length === 0) {
toast(t("sqlLibrary.importNone"), 3000); toast(t("sqlLibrary.importNone"), 3000);
return; return;
} }
const takenNames = new Set((targetFolder ? savedSqlStore.filesInFolder(targetFolder.id) : savedSqlStore.filesWithoutFolder()).map((file) => file.name)); const folderCache = new Map<string, SavedSqlFolder>();
const takenNamesByFolder = new Map<string, Set<string>>();
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 content = await api.readExternalSqlFile(path);
const displayName = uniqueImportedName(relativeImportName(selected, path), takenNames); const displayName = uniqueImportedName(file.name, takenNames);
await savedSqlStore.saveFile({ await savedSqlStore.saveFile({
connectionId, connectionId,
folderId: targetFolder?.id, folderId,
name: displayName, name: displayName,
database: "", database: "",
sql: content, sql: content,
}); });
} }
toast(t("sqlLibrary.imported", { count: sqlPaths.length }), 2500); toast(t("sqlLibrary.imported", { count: importFiles.length }), 2500);
} catch (e: any) { } catch (e: any) {
toast(t("sqlLibrary.importFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); toast(t("sqlLibrary.importFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
} }
@@ -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: [] },
]);
});
});
@@ -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;
}
+10
View File
@@ -22,6 +22,16 @@ test("SQL library directory imports use the native pruned folder scanner", () =>
assert.doesNotMatch(scanner, /@tauri-apps\/plugin-fs|readDir/); 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", () => { test("SQL library file menu can open a file with the current tab target", () => {
assert.match(source, /label: t\("sqlLibrary\.openInCurrentDatabase"\)/); assert.match(source, /label: t\("sqlLibrary\.openInCurrentDatabase"\)/);
assert.match(source, /action: \(\) => openFile\(target, "current"\)/); assert.match(source, /action: \(\) => openFile\(target, "current"\)/);