diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 0626823ea..eb01518bb 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -51,6 +51,19 @@ function getConnectionLabel(connectionId: string) { return conn?.name || connectionId; } +function folderPath(folder: SavedSqlFolder) { + const folderById = new Map(savedSqlStore.allFolders.map((item) => [item.id, item])); + const parts: string[] = []; + const seen = new Set(); + let current: SavedSqlFolder | undefined = folder; + while (current && !seen.has(current.id)) { + seen.add(current.id); + parts.unshift(current.name); + current = current.parentFolderId ? folderById.get(current.parentFolderId) : undefined; + } + return parts.join(" / "); +} + function activeImportConnectionId() { return connectionStore.activeConnectionId || connectionStore.connections[0]?.id || ""; } @@ -586,6 +599,14 @@ async function executeBatchDelete() { toast(t("sqlLibrary.batchDeleteSuccess", { count: fileIds.length + folderIds.length }), 2000); } +async function moveFilesToFolder(fileIds: string[], folderId?: string) { + const movableIds = [...new Set(fileIds)].filter((id) => savedSqlStore.getFile(id)); + if (movableIds.length === 0) return; + await savedSqlStore.moveFilesToFolder(movableIds, folderId); + clearSelection(); + toast(t("sqlLibrary.moveSuccess", { count: movableIds.length }), 2000); +} + async function openFile(file: SavedSqlFile) { if (suppressNextRowClick.value) return; const loadedFile = await savedSqlStore.ensureFileContent(file.id); @@ -717,13 +738,52 @@ function handleFolderClick(folder: SavedSqlFolder, event: MouseEvent) { const contextTarget = ref(null); +function folderMoveMenuItems(fileIds: string[]): CtxMenuItem[] { + const files = [...new Set(fileIds)].map((id) => savedSqlStore.getFile(id)).filter((file): file is SavedSqlFile => Boolean(file)); + const allInUnfiled = files.length > 0 && files.every((file) => !file.folderId); + const folderItems = savedSqlStore.allFoldersTreeOrder + .filter((folder) => isConnectionVisible(folder.connectionId)) + .map((folder) => ({ + label: folderPath(folder), + action: () => + moveFilesToFolder( + files.map((file) => file.id), + folder.id, + ), + disabled: files.every((file) => file.folderId === folder.id), + icon: FolderClosed, + })); + + return [ + { + label: t("sqlLibrary.unfiled"), + action: () => + moveFilesToFolder( + files.map((file) => file.id), + undefined, + ), + disabled: files.length === 0 || allInUnfiled, + icon: FolderOpen, + }, + ...(folderItems.length > 0 ? [{ label: "", separator: true }, ...folderItems] : []), + ]; +} + const contextMenuItems = computed(() => { const target = contextTarget.value; if (!target) return []; // If there's selection, show batch delete option if (hasSelection.value) { + const selectedFiles = Array.from(selectedFileIds.value); return [ + { + label: t("sqlLibrary.moveSelectedToFolder", { count: selectedFiles.length }), + icon: FolderClosed, + children: folderMoveMenuItems(selectedFiles), + visible: selectedFiles.length > 0, + }, + { label: "", separator: true, visible: selectedFiles.length > 0 }, { label: t("sqlLibrary.batchDelete", { count: selectedCount.value }), action: confirmBatchDelete, @@ -756,6 +816,7 @@ const contextMenuItems = computed(() => { return [ { label: t("savedSql.open"), action: () => openFile(target), icon: FileText }, { label: t("sqlLibrary.exportFile"), action: () => exportSingleFile(target), icon: FileInput }, + { label: t("sqlLibrary.moveToFolder"), icon: FolderClosed, children: folderMoveMenuItems([target.id]) }, { label: "", separator: true }, { label: t("savedSql.renameFile"), action: () => startRenameFile(target), icon: Pencil }, { label: "", separator: true }, diff --git a/apps/desktop/src/components/ui/CustomContextMenu.vue b/apps/desktop/src/components/ui/CustomContextMenu.vue index d33f4c8c3..c4b75951e 100644 --- a/apps/desktop/src/components/ui/CustomContextMenu.vue +++ b/apps/desktop/src/components/ui/CustomContextMenu.vue @@ -52,9 +52,11 @@ const subRef = ref(); const subX = ref(0); const subY = ref(0); let subCloseTimer: ReturnType | null = null; +let subAnchorRect: { left: number; right: number; top: number; bottom: number } | null = null; function close() { activeSubIndex.value = null; + subAnchorRect = null; show.value = false; } @@ -147,6 +149,7 @@ function onItemMouseEnter(index: number, event: MouseEvent) { } const trigger = event.currentTarget as HTMLElement; const rect = trigger.getBoundingClientRect(); + subAnchorRect = { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom }; subX.value = rect.right + 4; subY.value = rect.top; activeSubIndex.value = index; @@ -194,11 +197,25 @@ function adjustSubPosition() { const rect = subRef.value.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; - if (rect.right > vw) { - subX.value = Math.max(0, vw - rect.width - 8); + const margin = 8; + const gap = 4; + if (subAnchorRect) { + const rightX = subAnchorRect.right + gap; + const leftX = subAnchorRect.left - rect.width - gap; + if (rightX + rect.width <= vw - margin) { + subX.value = rightX; + } else if (leftX >= margin) { + subX.value = leftX; + } else { + subX.value = Math.max(margin, Math.min(rightX, vw - rect.width - margin)); + } + } else if (rect.right > vw - margin) { + subX.value = Math.max(margin, vw - rect.width - margin); } - if (rect.bottom > vh) { - subY.value = Math.max(0, vh - rect.height - 8); + if (rect.bottom > vh - margin) { + subY.value = Math.max(margin, vh - rect.height - margin); + } else if (rect.top < margin) { + subY.value = margin; } // When the submenu flips left due to right-edge overflow, it may land // under the mouse cursor. Since the mouse didn't move, mouseenter won't @@ -272,8 +289,8 @@ onBeforeUnmount(() => {
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index af605d6a1..751223414 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -146,6 +146,9 @@ export default { batchDelete: "Batch Delete", batchDeleteConfirm: "Are you sure you want to delete {count} selected items? This action cannot be undone.", batchDeleteSuccess: "Successfully deleted {count} items", + moveToFolder: "Move to Folder...", + moveSelectedToFolder: "Move {count} SQL to Folder...", + moveSuccess: "Moved {count} SQL file(s)", clearSelection: "Clear Selection", sortByDate: "Sort by Date Modified", sortByFolder: "Sort by Folder Structure", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index 4e732ac18..f777d2296 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -148,6 +148,9 @@ export default withEnglishFallback({ batchDelete: "Eliminación por lotes", batchDeleteConfirm: "¿Está seguro de que desea eliminar {count} elementos seleccionados? Esta acción no se puede deshacer.", batchDeleteSuccess: "Se eliminaron {count} elementos con éxito", + moveToFolder: "Mover a carpeta...", + moveSelectedToFolder: "Mover {count} SQL a carpeta...", + moveSuccess: "Se movieron {count} SQL", clearSelection: "Limpiar selección", sortByDate: "Ordenar por fecha de modificación", sortByFolder: "Ordenar por estructura de carpetas", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index a45fb64ba..6334b77ac 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -147,6 +147,9 @@ export default withEnglishFallback({ batchDelete: "Eliminazione in blocco", batchDeleteConfirm: "Sei sicuro di voler eliminare {count} elementi selezionati? Questa azione non può essere annullata.", batchDeleteSuccess: "{count} elementi eliminati con successo", + moveToFolder: "Sposta nella cartella...", + moveSelectedToFolder: "Sposta {count} SQL nella cartella...", + moveSuccess: "{count} SQL spostati", clearSelection: "Cancella selezione", sortByDate: "Ordina per data di modifica", sortByFolder: "Ordina per struttura cartelle", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 77d21b587..de6a7c67f 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -148,6 +148,9 @@ export default withEnglishFallback({ batchDelete: "一括削除", batchDeleteConfirm: "選択した {count} 項目を削除してもよろしいですか?この操作は元に戻せません。", batchDeleteSuccess: "{count} 項目を削除しました", + moveToFolder: "フォルダへ移動...", + moveSelectedToFolder: "選択した {count} 個の SQL をフォルダへ移動...", + moveSuccess: "{count} 個の SQL を移動しました", clearSelection: "選択を解除", sortByDate: "更新日時で並べ替え", sortByFolder: "フォルダ構造で並べ替え", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 975d57bc5..a77bb16ef 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -148,6 +148,9 @@ export default withEnglishFallback({ batchDelete: "Exclusão em lote", batchDeleteConfirm: "Tem certeza de que deseja excluir {count} itens selecionados? Esta ação não pode ser desfeita.", batchDeleteSuccess: "{count} itens excluídos com sucesso", + moveToFolder: "Mover para pasta...", + moveSelectedToFolder: "Mover {count} SQL para pasta...", + moveSuccess: "{count} SQL movidos", clearSelection: "Limpar seleção", sortByDate: "Ordenar por data de modificação", sortByFolder: "Ordenar por estrutura de pastas", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4bd271c6a..30466f13b 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -148,6 +148,9 @@ export default withEnglishFallback({ batchDelete: "批量删除", batchDeleteConfirm: "确定要删除选中的 {count} 项吗?此操作无法撤销。", batchDeleteSuccess: "成功删除 {count} 项", + moveToFolder: "分类至...", + moveSelectedToFolder: "分类选中的 {count} 个 SQL 至...", + moveSuccess: "已分类 {count} 个 SQL", clearSelection: "清除选择", sortByDate: "按修改日期排序", sortByFolder: "按文件夹结构排序", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 2e5fd5d91..fffe4d03e 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -148,6 +148,9 @@ export default withEnglishFallback({ batchDelete: "批次刪除", batchDeleteConfirm: "確定要刪除選取的 {count} 項嗎?此操作無法復原。", batchDeleteSuccess: "成功刪除 {count} 項", + moveToFolder: "分類至...", + moveSelectedToFolder: "分類選取的 {count} 個 SQL 至...", + moveSuccess: "已分類 {count} 個 SQL", clearSelection: "清除選取", sortByDate: "依修改日期排序", sortByFolder: "依資料夾結構排序", diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index a08be5db8..2dc8e38a5 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -453,6 +453,42 @@ export const useSavedSqlStore = defineStore("savedSql", () => { await persistFiles([...untouched, ...nextSource, ...nextDestination]); } + async function moveFilesToFolder(fileIds: string[], folderId?: string) { + const uniqueIds = [...new Set(fileIds)]; + if (uniqueIds.length === 0) return; + + const targetFolderId = folderId || undefined; + const movingFiles = uniqueIds.map((id) => files.value.find((file) => file.id === id)).filter((file): file is SavedSqlFile => Boolean(file)); + const filesToMove = movingFiles.filter((file) => (file.folderId || undefined) !== targetFolderId); + if (filesToMove.length === 0) return; + const moveIdSet = new Set(filesToMove.map((file) => file.id)); + + const timestamp = nowIso(); + const affectedFolderIds = new Set(filesToMove.map((file) => file.folderId || "")); + affectedFolderIds.add(targetFolderId || ""); + + const movedFiles = filesToMove.map((file) => ({ + ...file, + folderId: targetFolderId, + updatedAt: timestamp, + })); + + // Reindex each touched folder separately so moving a batch out of one + // folder never rewrites unrelated siblings into the destination folder. + const nextAffectedFiles = Array.from(affectedFolderIds).flatMap((groupId) => { + const normalizedGroupId = groupId || undefined; + const remaining = sortFilesByOrder(files.value.filter((file) => (file.folderId || "") === groupId && !moveIdSet.has(file.id))); + const group = groupId === (targetFolderId || "") ? [...remaining, ...movedFiles] : remaining; + return reindexFiles(group, normalizedGroupId).map((file) => ({ + ...file, + updatedAt: timestamp, + })); + }); + const untouched = files.value.filter((file) => !affectedFolderIds.has(file.folderId || "") && !moveIdSet.has(file.id)); + + await persistFiles([...untouched, ...nextAffectedFiles]); + } + async function reorderFiles(draggedId: string, targetId: string, position: "before" | "after") { const dragged = files.value.find((file) => file.id === draggedId); const target = files.value.find((file) => file.id === targetId); @@ -531,6 +567,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { moveFolderToFolder, reorderFiles, moveFileToFolder, + moveFilesToFolder, syncToLocalDirectory, allFolders, allFoldersTreeOrder, diff --git a/packages/app-tests/savedSqlStore.test.ts b/packages/app-tests/savedSqlStore.test.ts index eb547bd8a..212a53d8a 100644 --- a/packages/app-tests/savedSqlStore.test.ts +++ b/packages/app-tests/savedSqlStore.test.ts @@ -135,3 +135,95 @@ test("saving an existing SQL file with root folder explicitly moves it to root", assert.equal(apiMock.saveSavedSqlFile.mock.calls[0]?.[0].folderId, undefined); assert.equal(store.getFile("sql-1")?.folderId, undefined); }); + +test("moving multiple saved SQL files to a folder keeps existing target files", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-1", + connectionId: "conn-1", + name: "one.sql", + database: "db", + sql: "SELECT 1;", + orderIndex: 0, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }, + { + id: "sql-2", + connectionId: "conn-1", + name: "two.sql", + database: "db", + sql: "SELECT 2;", + orderIndex: 1, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }, + { + id: "sql-3", + connectionId: "conn-1", + folderId: "folder-1", + name: "three.sql", + database: "db", + sql: "SELECT 3;", + orderIndex: 0, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await store.moveFilesToFolder(["sql-1", "sql-2"], "folder-1"); + + assert.deepEqual( + store.filesInFolder("folder-1").map((file) => [file.id, file.folderId, file.orderIndex]), + [ + ["sql-3", "folder-1", 0], + ["sql-1", "folder-1", 1], + ["sql-2", "folder-1", 2], + ], + ); + assert.deepEqual( + store.filesWithoutFolder().map((file) => file.id), + [], + ); +}); + +test("moving selected files already in the target folder keeps them in place", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-1", + connectionId: "conn-1", + name: "one.sql", + database: "db", + sql: "SELECT 1;", + orderIndex: 0, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }, + { + id: "sql-2", + connectionId: "conn-1", + folderId: "folder-1", + name: "two.sql", + database: "db", + sql: "SELECT 2;", + orderIndex: 0, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await store.moveFilesToFolder(["sql-1", "sql-2"], "folder-1"); + + assert.deepEqual( + store.filesInFolder("folder-1").map((file) => file.id), + ["sql-2", "sql-1"], + ); +});