feat(sql-library): support nested folders

This commit is contained in:
zipg 2026-07-20 01:23:24 +08:00 committed by GitHub
parent 84745bb349
commit b43be528e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 101 additions and 4 deletions

View File

@ -421,13 +421,19 @@ function isFolderExpanded(folderId: string) {
async function openNewFolderInput(parentFolderId?: string) {
const parent = parentFolderId ? savedSqlStore.allFolders.find((folder) => folder.id === parentFolderId) : undefined;
if (parentFolderId && !parent) return;
const connectionId = parent?.connectionId || connectionStore.connections[0]?.id;
if (!connectionId) return;
if (parent?.id) {
collapsedFolders.value = new Set([...collapsedFolders.value].filter((id) => id !== parent.id));
}
const folder = await savedSqlStore.createFolder(connectionId, t("savedSql.newFolderDefault"), parent?.id);
startRenameFolder(folder);
try {
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) {
@ -892,7 +898,7 @@ const contextMenuItems = computed<CtxMenuItem[]>(() => {
];
}
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("sqlLibrary.importIntoFolder"), action: () => importDirectoryIntoLibrary(target), icon: Download },
{ label: t("sqlLibrary.exportFolder"), action: () => exportFolderContents(target), icon: Upload },
@ -1150,7 +1156,7 @@ function showDropInside(targetId: string) {
</Button>
</LightTooltip>
<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" />
</Button>
</LightTooltip>
@ -1225,6 +1231,18 @@ function showDropInside(targetId: string) {
{{ item.item.name }}
<span class="ml-1 text-muted-foreground">({{ folderFileCount(item.item.id) }})</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
@ -1298,6 +1316,18 @@ function showDropInside(targetId: string) {
{{ row.folder.name }}
<span class="ml-1 text-muted-foreground">({{ folderFileCount(row.folder.id) }})</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

View File

@ -112,6 +112,7 @@ export default {
rootFolder: "SQL Library",
newQuery: "New Query",
newFolder: "New SQL Folder",
newSubfolder: "New Subfolder",
newFolderDefault: "New Folder",
renameFolder: "Rename Folder",
deleteFolder: "Delete Folder",

View File

@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "Biblioteca SQL",
newQuery: "Nueva consulta",
newFolder: "Nueva carpeta SQL",
newSubfolder: "Nueva subcarpeta",
newFolderDefault: "Nueva carpeta",
renameFolder: "Renombrar carpeta",
deleteFolder: "Eliminar carpeta",

View File

@ -113,6 +113,7 @@ export default withEnglishFallback({
rootFolder: "Libreria SQL",
newQuery: "Nuova query",
newFolder: "Nuova Cartella SQL",
newSubfolder: "Nuova Sottocartella",
newFolderDefault: "Nuova Cartella",
renameFolder: "Rinomina Cartella",
deleteFolder: "Elimina Cartella",

View File

@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "SQLライブラリ",
newQuery: "新規クエリ",
newFolder: "新しいSQLフォルダ",
newSubfolder: "新しいサブフォルダ",
newFolderDefault: "新しいフォルダ",
renameFolder: "フォルダ名を変更",
deleteFolder: "フォルダを削除",

View File

@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "Biblioteca SQL",
newQuery: "Nova Consulta",
newFolder: "Nova Pasta SQL",
newSubfolder: "Nova Subpasta",
newFolderDefault: "Nova Pasta",
renameFolder: "Renomear Pasta",
deleteFolder: "Excluir Pasta",

View File

@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "SQL 库",
newQuery: "新建查询",
newFolder: "新建 SQL 文件夹",
newSubfolder: "新建子文件夹",
newFolderDefault: "新建文件夹",
renameFolder: "重命名文件夹",
deleteFolder: "删除文件夹",

View File

@ -114,6 +114,7 @@ export default withEnglishFallback({
rootFolder: "SQL 庫",
newQuery: "新建查詢",
newFolder: "建立 SQL 資料夾",
newSubfolder: "建立子資料夾",
newFolderDefault: "建立資料夾",
renameFolder: "重新命名資料夾",
deleteFolder: "刪除資料夾",

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

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);
});
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 () => {
const summaryFile: SavedSqlFile = {
id: "sql-1",