From 0a652d1b2c2681c968853d4e8a36b9326e20807b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 22 Jun 2026 10:43:26 +0800 Subject: [PATCH] feat(sql-library): add batch selection and deletion for SQL Library panel - Support Ctrl/Cmd+click for multi-select toggle - Support Shift+click for additive range selection across folders and files - Add batch delete via right-click context menu with confirmation - Add active/inactive visual states matching connection sidebar behavior - Remove transition-colors for instant selection feedback - Unified selection index system for cross-type range selection --- .../src/components/layout/SqlLibraryPanel.vue | 248 +++++++++++++++++- apps/desktop/src/i18n/locales/en.ts | 4 + apps/desktop/src/i18n/locales/es.ts | 4 + apps/desktop/src/i18n/locales/it.ts | 4 + apps/desktop/src/i18n/locales/ja.ts | 4 + apps/desktop/src/i18n/locales/pt-BR.ts | 4 + apps/desktop/src/i18n/locales/zh-CN.ts | 4 + apps/desktop/src/i18n/locales/zh-TW.ts | 4 + 8 files changed, 264 insertions(+), 12 deletions(-) diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 63a4b8e177..ba9e155f83 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -339,13 +339,14 @@ function filesInFolder(folderId: string) { .filter((file) => includeAllFilesForMatchedFolder || fileMatchesQuery(file)); } -type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number } | { type: "file"; file: SavedSqlFile; depth: number }; +type SqlLibraryRow = { type: "folder"; folder: SavedSqlFolder; depth: number; folderIndex: number } | { type: "file"; file: SavedSqlFile; depth: number }; const visibleFolderRows = computed(() => { const rows: SqlLibraryRow[] = []; + let folderIndex = 0; const appendFolder = (folder: SavedSqlFolder, depth: number) => { if (!folderBranchMatchesQuery(folder)) return; - rows.push({ type: "folder", folder, depth }); + rows.push({ type: "folder", folder, depth, folderIndex: folderIndex++ }); if (!isFolderExpanded(folder.id)) return; for (const child of childFolders(folder.id)) { appendFolder(child, depth + 1); @@ -417,6 +418,52 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) { queryStore.openSavedSql(file); } +// Batch selection state +const selectedFileIds = ref>(new Set()); +const selectedFolderIds = ref>(new Set()); +const lastClickedItemIndex = ref(null); // Unified index for both folders and files + +// Active item state (single selection highlight, like left sidebar) +const activeItemId = ref(null); +const activeItemType = ref<"file" | "folder" | null>(null); + +// Unified item list for selection (folders first, then unfiled files) +const allSelectableItems = computed(() => { + const folders = visibleFolderRows.value.filter((r) => r.type === "folder").map((r) => ({ type: "folder" as const, id: r.folder.id })); + const files = visibleFiles.value.map((f) => ({ type: "file" as const, id: f.id })); + return [...folders, ...files]; +}); + +const hasSelection = computed(() => selectedFileIds.value.size > 0 || selectedFolderIds.value.size > 0); +const selectedCount = computed(() => selectedFileIds.value.size + selectedFolderIds.value.size); + +function clearSelection() { + selectedFileIds.value = new Set(); + selectedFolderIds.value = new Set(); + lastClickedItemIndex.value = null; +} + +function setActiveItem(id: string, type: "file" | "folder") { + activeItemId.value = id; + activeItemType.value = type; +} + +function isFileSelected(fileId: string): boolean { + return selectedFileIds.value.has(fileId); +} + +function isFolderSelected(folderId: string): boolean { + return selectedFolderIds.value.has(folderId); +} + +function isFileActive(fileId: string): boolean { + return activeItemType.value === "file" && activeItemId.value === fileId; +} + +function isFolderActive(folderId: string): boolean { + return activeItemType.value === "folder" && activeItemId.value === folderId; +} + const renamingTarget = ref<{ type: "folder" | "file"; id: string } | null>(null); const renameValue = ref(""); const renameInputRef = ref(null); @@ -461,6 +508,7 @@ function cancelRename() { const deleteTarget = ref<{ type: "folder" | "file"; id: string; name: string } | null>(null); const showDeleteConfirm = ref(false); +const showBatchDeleteConfirm = ref(false); function confirmDeleteFolder(folder: SavedSqlFolder) { deleteTarget.value = { type: "folder", id: folder.id, name: folder.name }; @@ -472,6 +520,11 @@ function confirmDeleteFile(file: SavedSqlFile) { showDeleteConfirm.value = true; } +function confirmBatchDelete() { + if (!hasSelection.value) return; + showBatchDeleteConfirm.value = true; +} + async function executeDelete() { if (!deleteTarget.value) return; const { type, id } = deleteTarget.value; @@ -481,6 +534,23 @@ async function executeDelete() { deleteTarget.value = null; } +async function executeBatchDelete() { + const fileIds = Array.from(selectedFileIds.value); + const folderIds = Array.from(selectedFolderIds.value); + + // Delete files first, then folders + for (const fileId of fileIds) { + await savedSqlStore.deleteFile(fileId); + } + for (const folderId of folderIds) { + await savedSqlStore.deleteFolder(folderId); + } + + showBatchDeleteConfirm.value = false; + clearSelection(); + toast(t("sqlLibrary.batchDeleteSuccess", { count: fileIds.length + folderIds.length }), 2000); +} + function openFile(file: SavedSqlFile) { if (suppressNextRowClick.value) return; queryStore.openSavedSql(file); @@ -488,11 +558,146 @@ function openFile(file: SavedSqlFile) { void savedSqlStore.recordFileUsage(file.id); } +function handleFileClick(file: SavedSqlFile, event: MouseEvent) { + if (suppressNextRowClick.value) return; + + const isMeta = event.metaKey || event.ctrlKey; + const isShift = event.shiftKey; + + // Find current file index in unified list + const currentIndex = allSelectableItems.value.findIndex((item) => item.type === "file" && item.id === file.id); + if (currentIndex < 0) return; + + if (isMeta) { + // Toggle selection + event.preventDefault(); + event.stopPropagation(); + const next = new Set(selectedFileIds.value); + if (next.has(file.id)) { + next.delete(file.id); + } else { + next.add(file.id); + } + selectedFileIds.value = next; + lastClickedItemIndex.value = currentIndex; + } else if (isShift) { + // Range selection - additive mode (add to existing selection) + event.preventDefault(); + event.stopPropagation(); + + const startIndex = lastClickedItemIndex.value ?? 0; + const start = Math.min(startIndex, currentIndex); + const end = Math.max(startIndex, currentIndex); + + // Add to existing selection (additive mode) + const nextFiles = new Set(selectedFileIds.value); + const nextFolders = new Set(selectedFolderIds.value); + for (let i = start; i <= end; i++) { + const item = allSelectableItems.value[i]; + if (item) { + if (item.type === "file") { + nextFiles.add(item.id); + } else { + nextFolders.add(item.id); + } + } + } + selectedFileIds.value = nextFiles; + selectedFolderIds.value = nextFolders; + lastClickedItemIndex.value = currentIndex; + } else { + // Normal click - open file, clear selection but keep anchor, and set active + const hadSelection = hasSelection.value; + clearSelection(); + setActiveItem(file.id, "file"); + openFile(file); + // Set anchor for future shift-click even when not selecting + if (!hadSelection) { + lastClickedItemIndex.value = currentIndex; + } + } +} + +function handleFolderClick(folder: SavedSqlFolder, event: MouseEvent) { + if (suppressNextRowClick.value) return; + + const isMeta = event.metaKey || event.ctrlKey; + const isShift = event.shiftKey; + + // Find current folder index in unified list + const currentIndex = allSelectableItems.value.findIndex((item) => item.type === "folder" && item.id === folder.id); + if (currentIndex < 0) return; + + if (isMeta) { + // Toggle selection + event.preventDefault(); + event.stopPropagation(); + const next = new Set(selectedFolderIds.value); + if (next.has(folder.id)) { + next.delete(folder.id); + } else { + next.add(folder.id); + } + selectedFolderIds.value = next; + lastClickedItemIndex.value = currentIndex; + } else if (isShift) { + // Range selection - additive mode (add to existing selection) + event.preventDefault(); + event.stopPropagation(); + + const startIndex = lastClickedItemIndex.value ?? 0; + const start = Math.min(startIndex, currentIndex); + const end = Math.max(startIndex, currentIndex); + + // Add to existing selection (additive mode) + const nextFiles = new Set(selectedFileIds.value); + const nextFolders = new Set(selectedFolderIds.value); + for (let i = start; i <= end; i++) { + const item = allSelectableItems.value[i]; + if (item) { + if (item.type === "file") { + nextFiles.add(item.id); + } else { + nextFolders.add(item.id); + } + } + } + selectedFileIds.value = nextFiles; + selectedFolderIds.value = nextFolders; + lastClickedItemIndex.value = currentIndex; + } else { + // Normal click - toggle folder expansion, clear selection but keep anchor, and set active + const hadSelection = hasSelection.value; + clearSelection(); + setActiveItem(folder.id, "folder"); + toggleFolder(folder.id); + // Set anchor for future shift-click even when not selecting + if (!hadSelection) { + lastClickedItemIndex.value = currentIndex; + } + } +} + const contextTarget = ref(null); const contextMenuItems = computed(() => { const target = contextTarget.value; if (!target) return []; + + // If there's selection, show batch delete option + if (hasSelection.value) { + return [ + { + label: t("sqlLibrary.batchDelete", { count: selectedCount.value }), + action: confirmBatchDelete, + icon: Trash2, + variant: "destructive", + }, + { label: "", separator: true }, + { label: t("sqlLibrary.clearSelection"), action: clearSelection, icon: X }, + ]; + } + if (target === "panel") { return [ { label: t("savedSql.newFolder"), action: openNewFolderInput, icon: FolderPlus }, @@ -710,6 +915,8 @@ onBeforeUnmount(() => { function handleDragMouseDown(event: MouseEvent, id: string, type: Extract) { if (event.button !== 0) return; + // Skip drag when modifier keys are pressed (for selection) + if (event.shiftKey || event.metaKey || event.ctrlKey) return; const target = event.target as HTMLElement | null; if (target?.closest("[data-no-drag='true']")) return; pendingDrag = { @@ -774,6 +981,7 @@ function showDropInside(targetId: string) {
{{ t("sqlLibrary.title") }} + ({{ selectedCount }}) + + + +
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 7348fc7b02..57d3da70f1 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -134,6 +134,10 @@ export default { imported: "Imported {count} SQL files", desktopOnly: "This feature is only available on desktop", noConnection: "No connection is available for import", + batchDelete: "Batch Delete", + batchDeleteConfirm: "Are you sure you want to delete {count} selected items? This action cannot be undone.", + batchDeleteSuccess: "Successfully deleted {count} items", + clearSelection: "Clear Selection", }, connection: { diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index fb5770bb99..921b342e04 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -132,6 +132,10 @@ export default { imported: "Se importaron {count} archivos SQL", desktopOnly: "Esta función solo está disponible en escritorio", noConnection: "No hay conexión disponible para importar", + 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", + clearSelection: "Limpiar selección", }, connection: { diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 25de04d766..4aa905b232 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -132,6 +132,10 @@ export default { imported: "Importati {count} file SQL", desktopOnly: "Questa funzione è disponibile solo su desktop", noConnection: "Nessuna connessione disponibile per l'importazione", + 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", + clearSelection: "Cancella selezione", }, connection: { diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 69ceff1c0b..41157004bd 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -134,6 +134,10 @@ export default { imported: "{count}件のSQLファイルをインポートしました", desktopOnly: "この機能はデスクトップアプリでのみ利用可能です", noConnection: "インポートに利用可能な接続がありません", + batchDelete: "一括削除", + batchDeleteConfirm: "選択した {count} 項目を削除してもよろしいですか?この操作は元に戻せません。", + batchDeleteSuccess: "{count} 項目を削除しました", + clearSelection: "選択を解除", }, connection: { title: "新しい接続", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index e7580e30ba..a10b63df08 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -132,6 +132,10 @@ export default { imported: "Importados {count} arquivos SQL", desktopOnly: "Este recurso está disponível apenas no desktop", noConnection: "Nenhuma conexão disponível para importar", + 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", + clearSelection: "Limpar seleção", }, connection: { diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 4f92c2f296..b09b1aad31 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -135,6 +135,10 @@ export default { imported: "已导入 {count} 个 SQL 文件", desktopOnly: "该功能仅桌面端可用", noConnection: "当前没有可用于导入的连接", + batchDelete: "批量删除", + batchDeleteConfirm: "确定要删除选中的 {count} 项吗?此操作无法撤销。", + batchDeleteSuccess: "成功删除 {count} 项", + clearSelection: "清除选择", }, connection: { diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index b17e8c3d5c..424172a7ab 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -132,6 +132,10 @@ export default { imported: "已匯入 {count} 個 SQL 檔案", desktopOnly: "此功能僅桌面版可用", noConnection: "目前沒有可用於匯入的連線", + batchDelete: "批次刪除", + batchDeleteConfirm: "確定要刪除選取的 {count} 項嗎?此操作無法復原。", + batchDeleteSuccess: "成功刪除 {count} 項", + clearSelection: "清除選取", }, connection: {