feat(sql-library): support nested folders

This commit is contained in:
zipg authored and GitHub committed 2026-07-20 01:23:24 +08:00
1 parent 84745bb349
commit b43be528e1
10 files changed
+101 -4

No files matched your search

@@ -421,13 +421,19 @@ function isFolderExpanded(folderId: string) {
async function openNewFolderInput(parentFolderId?: string) { async function openNewFolderInput(parentFolderId?: string) {
const parent = parentFolderId ? savedSqlStore.allFolders.find((folder) => folder.id === parentFolderId) : undefined; const parent = parentFolderId ? savedSqlStore.allFolders.find((folder) => folder.id === parentFolderId) : undefined;
if (parentFolderId && !parent) return;
const connectionId = parent?.connectionId || connectionStore.connections[0]?.id; const connectionId = parent?.connectionId || connectionStore.connections[0]?.id;
if (!connectionId) return; if (!connectionId) return;
if (parent?.id) { if (parent?.id) {
collapsedFolders.value = new Set([...collapsedFolders.value].filter((id) => id !== parent.id)); collapsedFolders.value = new Set([...collapsedFolders.value].filter((id) => id !== parent.id));
} }
const folder = await savedSqlStore.createFolder(connectionId, t("savedSql.newFolderDefault"), parent?.id); try {
startRenameFolder(folder); const folder = await savedSqlStore.createFolder(connectionId, t("savedSql.newFolderDefault"), parent?.id);
searchText.value = "";
startRenameFolder(folder);
} catch (e: any) {
toast(t("savedSql.createFolderFailed", { message: e?.message || String(e) }), 5000);
}
} }
async function openNewQueryInFolder(folder?: SavedSqlFolder) { async function openNewQueryInFolder(folder?: SavedSqlFolder) {
@@ -892,7 +898,7 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
]; ];
} }
return [ return [
{ label: t("savedSql.newFolder"), action: () => openNewFolderInput(target.id), icon: FolderPlus }, { label: t("savedSql.newSubfolder"), action: () => openNewFolderInput(target.id), icon: FolderPlus },
{ label: t("savedSql.newQuery"), action: () => openNewQueryInFolder(target), icon: FilePlus }, { label: t("savedSql.newQuery"), action: () => openNewQueryInFolder(target), icon: FilePlus },
{ label: t("sqlLibrary.importIntoFolder"), action: () => importDirectoryIntoLibrary(target), icon: Download }, { label: t("sqlLibrary.importIntoFolder"), action: () => importDirectoryIntoLibrary(target), icon: Download },
{ label: t("sqlLibrary.exportFolder"), action: () => exportFolderContents(target), icon: Upload }, { label: t("sqlLibrary.exportFolder"), action: () => exportFolderContents(target), icon: Upload },
@@ -1150,7 +1156,7 @@ function showDropInside(targetId: string) {
</Button> </Button>
</LightTooltip> </LightTooltip>
<LightTooltip :text="t('savedSql.newFolder')" side="bottom" :delay="0" :close-delay="0" nowrap> <LightTooltip :text="t('savedSql.newFolder')" side="bottom" :delay="0" :close-delay="0" nowrap>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="openNewFolderInput"> <Button variant="ghost" size="icon" class="h-5 w-5" @click="openNewFolderInput()">
<FolderPlus class="h-3 w-3" /> <FolderPlus class="h-3 w-3" />
</Button> </Button>
</LightTooltip> </LightTooltip>
@@ -1225,6 +1231,18 @@ function showDropInside(targetId: string) {
{{ item.item.name }} {{ item.item.name }}
<span class="ml-1 text-muted-foreground">({{ folderFileCount(item.item.id) }})</span> <span class="ml-1 text-muted-foreground">({{ folderFileCount(item.item.id) }})</span>
</span> </span>
<LightTooltip v-if="!isRenamingFolder(item.item.id)" :text="t('savedSql.newSubfolder')" side="left" :delay="0" :close-delay="0" nowrap>
<button
type="button"
data-no-drag="true"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:aria-label="t('savedSql.newSubfolder')"
@mousedown.stop
@click.stop="openNewFolderInput(item.item.id)"
>
<FolderPlus class="h-3.5 w-3.5" />
</button>
</LightTooltip>
</div> </div>
<div <div
@@ -1298,6 +1316,18 @@ function showDropInside(targetId: string) {
{{ row.folder.name }} {{ row.folder.name }}
<span class="ml-1 text-muted-foreground">({{ folderFileCount(row.folder.id) }})</span> <span class="ml-1 text-muted-foreground">({{ folderFileCount(row.folder.id) }})</span>
</span> </span>
<LightTooltip v-if="!isRenamingFolder(row.folder.id)" :text="t('savedSql.newSubfolder')" side="left" :delay="0" :close-delay="0" nowrap>
<button
type="button"
data-no-drag="true"
class="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground/70 hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
:aria-label="t('savedSql.newSubfolder')"
@mousedown.stop
@click.stop="openNewFolderInput(row.folder.id)"
>
<FolderPlus class="h-3.5 w-3.5" />
</button>
</LightTooltip>
</div> </div>
<div <div
+1
View File
@@ -112,6 +112,7 @@ export default {
rootFolder: "SQL Library", rootFolder: "SQL Library",
newQuery: "New Query", newQuery: "New Query",
newFolder: "New SQL Folder", newFolder: "New SQL Folder",
newSubfolder: "New Subfolder",
newFolderDefault: "New Folder", newFolderDefault: "New Folder",
renameFolder: "Rename Folder", renameFolder: "Rename Folder",
deleteFolder: "Delete Folder", deleteFolder: "Delete Folder",
+1
View File
@@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "Biblioteca SQL", rootFolder: "Biblioteca SQL",
newQuery: "Nueva consulta", newQuery: "Nueva consulta",
newFolder: "Nueva carpeta SQL", newFolder: "Nueva carpeta SQL",
newSubfolder: "Nueva subcarpeta",
newFolderDefault: "Nueva carpeta", newFolderDefault: "Nueva carpeta",
renameFolder: "Renombrar carpeta", renameFolder: "Renombrar carpeta",
deleteFolder: "Eliminar carpeta", deleteFolder: "Eliminar carpeta",
+1
View File
@@ -113,6 +113,7 @@ export default withEnglishFallback({
rootFolder: "Libreria SQL", rootFolder: "Libreria SQL",
newQuery: "Nuova query", newQuery: "Nuova query",
newFolder: "Nuova Cartella SQL", newFolder: "Nuova Cartella SQL",
newSubfolder: "Nuova Sottocartella",
newFolderDefault: "Nuova Cartella", newFolderDefault: "Nuova Cartella",
renameFolder: "Rinomina Cartella", renameFolder: "Rinomina Cartella",
deleteFolder: "Elimina Cartella", deleteFolder: "Elimina Cartella",
+1
View File
@@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "SQLライブラリ", rootFolder: "SQLライブラリ",
newQuery: "新規クエリ", newQuery: "新規クエリ",
newFolder: "新しいSQLフォルダ", newFolder: "新しいSQLフォルダ",
newSubfolder: "新しいサブフォルダ",
newFolderDefault: "新しいフォルダ", newFolderDefault: "新しいフォルダ",
renameFolder: "フォルダ名を変更", renameFolder: "フォルダ名を変更",
deleteFolder: "フォルダを削除", deleteFolder: "フォルダを削除",
+1
View File
@@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "Biblioteca SQL", rootFolder: "Biblioteca SQL",
newQuery: "Nova Consulta", newQuery: "Nova Consulta",
newFolder: "Nova Pasta SQL", newFolder: "Nova Pasta SQL",
newSubfolder: "Nova Subpasta",
newFolderDefault: "Nova Pasta", newFolderDefault: "Nova Pasta",
renameFolder: "Renomear Pasta", renameFolder: "Renomear Pasta",
deleteFolder: "Excluir Pasta", deleteFolder: "Excluir Pasta",
+1
View File
@@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "SQL 库", rootFolder: "SQL 库",
newQuery: "新建查询", newQuery: "新建查询",
newFolder: "新建 SQL 文件夹", newFolder: "新建 SQL 文件夹",
newSubfolder: "新建子文件夹",
newFolderDefault: "新建文件夹", newFolderDefault: "新建文件夹",
renameFolder: "重命名文件夹", renameFolder: "重命名文件夹",
deleteFolder: "删除文件夹", deleteFolder: "删除文件夹",
+1
View File
@@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "SQL 庫", rootFolder: "SQL 庫",
newQuery: "新建查詢", newQuery: "新建查詢",
newFolder: "建立 SQL 資料夾", newFolder: "建立 SQL 資料夾",
newSubfolder: "建立子資料夾",
newFolderDefault: "建立資料夾", newFolderDefault: "建立資料夾",
renameFolder: "重新命名資料夾", renameFolder: "重新命名資料夾",
deleteFolder: "刪除資料夾", deleteFolder: "刪除資料夾",
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+60
View File
@@ -48,6 +48,66 @@ test("concurrent saved SQL folder creates reuse the same pending folder", async
assert.equal(store.folders[0]?.id, firstFolder.id); assert.equal(store.folders[0]?.id, firstFolder.id);
}); });
test("creates a nested SQL folder under the requested parent", async () => {
const root: SavedSqlFolder = {
id: "root",
connectionId: "conn-1",
name: "Root",
orderIndex: 0,
createdAt: "2026-07-19T00:00:00.000Z",
updatedAt: "2026-07-19T00:00:00.000Z",
};
const sibling: SavedSqlFolder = {
id: "child-1",
connectionId: "conn-1",
parentFolderId: "root",
name: "Existing child",
orderIndex: 0,
createdAt: "2026-07-19T00:00:00.000Z",
updatedAt: "2026-07-19T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [root, sibling], files: [] });
const store = useSavedSqlStore();
await store.initFromStorage();
const child = await store.createFolder("conn-1", "Nested child", "root");
assert.equal(child.parentFolderId, "root");
assert.equal(child.connectionId, "conn-1");
assert.equal(child.orderIndex, 1);
assert.equal(apiMock.saveSavedSqlFolder.mock.calls.at(-1)?.[0].parentFolderId, "root");
assert.deepEqual(
store.listChildFolders("conn-1", "root").map((folder) => folder.id),
["child-1", child.id],
);
});
test("does not move a SQL folder into its own descendant", async () => {
const root: SavedSqlFolder = {
id: "root",
connectionId: "conn-1",
name: "Root",
createdAt: "2026-07-19T00:00:00.000Z",
updatedAt: "2026-07-19T00:00:00.000Z",
};
const child: SavedSqlFolder = {
id: "child",
connectionId: "conn-1",
parentFolderId: "root",
name: "Child",
createdAt: "2026-07-19T00:00:00.000Z",
updatedAt: "2026-07-19T00:00:00.000Z",
};
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [root, child], files: [] });
const store = useSavedSqlStore();
await store.initFromStorage();
await store.moveFolderToFolder("root", "child");
assert.equal(store.allFolders.find((folder) => folder.id === "root")?.parentFolderId, undefined);
assert.equal(apiMock.saveSavedSqlFolder.mock.calls.length, 0);
});
test("saved SQL summaries load file content on demand", async () => { test("saved SQL summaries load file content on demand", async () => {
const summaryFile: SavedSqlFile = { const summaryFile: SavedSqlFile = {
id: "sql-1", id: "sql-1",