fix(sql-library): preserve folders during directory import
Closes #5877
This commit is contained in:
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;
|
||||||
|
}
|
||||||
@@ -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"\)/);
|
||||||
|
|||||||
Reference in new issue
Block a user