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
This commit is contained in:
t8y2 committed 2026-06-22 10:43:26 +08:00
1 parent 4e07f73157
commit 0a652d1b2c
8 files changed
+264 -12

No files matched your search

@@ -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<SqlLibraryRow[]>(() => {
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<Set<string>>(new Set());
const selectedFolderIds = ref<Set<string>>(new Set());
const lastClickedItemIndex = ref<number | null>(null); // Unified index for both folders and files
// Active item state (single selection highlight, like left sidebar)
const activeItemId = ref<string | null>(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<HTMLInputElement | null>(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<SavedSqlFolder | SavedSqlFile | "panel" | null>(null);
const contextMenuItems = computed<CtxMenuItem[]>(() => {
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<DragItemType, "folder" | "file">) {
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) {
<div class="h-full flex flex-col overflow-hidden border-l bg-background select-none">
<div class="h-9 flex items-center gap-1 px-2 border-b shrink-0 bg-muted/20">
<span class="text-[13px] font-medium">{{ t("sqlLibrary.title") }}</span>
<span v-if="hasSelection" class="text-[12px] text-muted-foreground ml-1">({{ selectedCount }})</span>
<span class="flex-1" />
<Button variant="ghost" size="icon" class="h-5 w-5" :title="t('savedSql.newFolder')" @click="openNewFolderInput">
<FolderPlus class="h-3 w-3" />
@@ -810,16 +1018,16 @@ function showDropInside(targetId: string) {
onContextMenu($event);
"
>
<div v-for="row in visibleFolderRows" :key="row.type === 'folder' ? row.folder.id : row.file.id" class="mb-0.5">
<div v-for="row in visibleFolderRows" :key="row.type === 'folder' ? row.folder.id : row.file.id">
<div
v-if="row.type === 'folder'"
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer transition-colors group"
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[showDropInside(row.folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : 'hover:bg-accent', isDraggingItem(row.folder.id) ? 'opacity-50' : '']"
:class="[showDropInside(row.folder.id) ? 'ring-1 ring-primary/50 bg-primary/5' : isFolderSelected(row.folder.id) ? 'bg-primary/10' : isFolderActive(row.folder.id) ? 'bg-accent' : 'hover:bg-accent', isDraggingItem(row.folder.id) ? 'opacity-50' : '']"
@mousedown="handleDragMouseDown($event, row.folder.id, 'folder')"
@mousemove="updateDropTarget($event, row.folder.id, 'folder')"
@mouseleave="clearDropTarget(row.folder.id)"
@click="toggleFolder(row.folder.id)"
@click="handleFolderClick(row.folder, $event)"
@contextmenu.capture="contextTarget = row.folder"
@contextmenu.prevent="
contextTarget = row.folder;
@@ -849,13 +1057,13 @@ function showDropInside(targetId: string) {
<div
v-else
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer transition-colors group"
class="relative flex items-center gap-1 rounded py-1.5 pr-2 text-[13px] cursor-pointer group"
:style="{ paddingLeft: `${8 + row.depth * 16}px` }"
:class="[isDraggingItem(row.file.id) ? 'opacity-50' : 'hover:bg-accent']"
:class="[isDraggingItem(row.file.id) ? 'opacity-50' : isFileSelected(row.file.id) ? 'bg-primary/10' : isFileActive(row.file.id) ? 'bg-accent' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, row.file.id, 'file')"
@mousemove="updateDropTarget($event, row.file.id, 'file')"
@mouseleave="clearDropTarget(row.file.id)"
@click="openFile(row.file)"
@click="handleFileClick(row.file, $event)"
@contextmenu.capture="contextTarget = row.file"
@contextmenu.prevent="
contextTarget = row.file;
@@ -894,12 +1102,12 @@ function showDropInside(targetId: string) {
<div
v-for="file in visibleFiles"
:key="file.id"
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-[13px] cursor-pointer transition-colors group"
:class="[isDraggingItem(file.id) ? 'opacity-50' : 'hover:bg-accent']"
class="relative flex items-center gap-1 rounded px-2 py-1.5 text-[13px] cursor-pointer group"
:class="[isDraggingItem(file.id) ? 'opacity-50' : isFileSelected(file.id) ? 'bg-primary/10' : 'hover:bg-accent']"
@mousedown="handleDragMouseDown($event, file.id, 'file')"
@mousemove="updateDropTarget($event, file.id, 'file')"
@mouseleave="clearDropTarget(file.id)"
@click="openFile(file)"
@click="handleFileClick(file, $event)"
@contextmenu.capture="contextTarget = file"
@contextmenu.prevent="
contextTarget = file;
@@ -953,5 +1161,21 @@ function showDropInside(targetId: string) {
</DialogFooter>
</DialogContent>
</Dialog>
<!-- Batch Delete Confirmation Dialog -->
<Dialog :open="showBatchDeleteConfirm" @update:open="(open) => (showBatchDeleteConfirm = open)">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ t("sqlLibrary.batchDelete") }}</DialogTitle>
<DialogDescription>
{{ t("sqlLibrary.batchDeleteConfirm", { count: selectedCount }) }}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" size="sm" @click="showBatchDeleteConfirm = false">{{ t("dangerDialog.cancel") }}</Button>
<Button variant="destructive" size="sm" @click="executeBatchDelete">{{ t("dangerDialog.confirm") }}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</template>