fix(sql): preserve file associations and list selection
This commit is contained in:
25 files changed
+558
-186
No files matched your search
@@ -10,13 +10,13 @@ import { useConnectionStore } from "@/stores/connectionStore";
|
||||
import { useToast } from "@/composables/useToast";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { translateBackendError } from "@/i18n/backend-errors";
|
||||
import { resolveDefaultDatabase } from "@/lib/database/defaultDatabase";
|
||||
import { copyToClipboard } from "@/lib/common/clipboard";
|
||||
import { resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { externalSqlFileOpenErrorMessage, formatSqlFileSize, isExternalSqlFileTooLargeError } from "@/lib/sql/sqlFileOpen";
|
||||
import * as api from "@/lib/backend/api";
|
||||
import type { SqlFileEntry } from "@/lib/backend/api";
|
||||
import { getSqlFileFolderPaths, saveSqlFileFolderPaths, notifySqlFileFoldersChanged } from "@/lib/sqlFile/sqlFileFolders";
|
||||
import { orderedListRangeAnchorIndex, orderedListSelectionIntent, type OrderedListSelectionItem } from "@/lib/selection/orderedListSelection";
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: [];
|
||||
@@ -44,12 +44,18 @@ type ContextTarget = { kind: "panel" } | { kind: "folderHeader"; folderPath: str
|
||||
|
||||
const contextTarget = ref<ContextTarget | null>(null);
|
||||
|
||||
// The currently highlighted tree row (file or folder path). Set on click or
|
||||
// right-click so the user sees which item an opened context menu refers to.
|
||||
const selectedPath = ref<string | null>(null);
|
||||
const selectedPaths = ref<Set<string>>(new Set());
|
||||
const activePath = ref<string | null>(null);
|
||||
const selectionAnchorIndex = ref<number | null>(null);
|
||||
|
||||
function selectPath(path: string | null) {
|
||||
selectedPath.value = path;
|
||||
function clearSelection() {
|
||||
selectedPaths.value = new Set();
|
||||
activePath.value = null;
|
||||
selectionAnchorIndex.value = null;
|
||||
}
|
||||
|
||||
function isPathHighlighted(path: string) {
|
||||
return activePath.value === path || selectedPaths.value.has(path);
|
||||
}
|
||||
|
||||
function loadSavedFolders(): string[] {
|
||||
@@ -209,11 +215,8 @@ async function openFile(path: string) {
|
||||
if (!isTauriRuntime()) return;
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(path);
|
||||
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = connection ? resolveDefaultDatabase(connection, []) : "";
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), { connectionId, database });
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version);
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, snapshot.content, snapshot.version, target.catalog);
|
||||
} catch (e: any) {
|
||||
if (isExternalSqlFileTooLargeError(e)) {
|
||||
executeFile(path);
|
||||
@@ -227,9 +230,10 @@ async function openFile(path: string) {
|
||||
// Open the App-level SQL file execution dialog with this file pre-selected so
|
||||
// the user can review its statements and pick a connection/database before run.
|
||||
function executeFile(path: string) {
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
connectionStore.sqlFileSource = {
|
||||
connectionId: connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "",
|
||||
database: "",
|
||||
connectionId: target.connectionId,
|
||||
database: target.database,
|
||||
filePath: path,
|
||||
};
|
||||
}
|
||||
@@ -262,6 +266,75 @@ function flatTree(entries: SqlFileEntry[], expanded: Set<string>): TreeEntry[] {
|
||||
return result;
|
||||
}
|
||||
|
||||
const visibleItems = computed<OrderedListSelectionItem[]>(() => {
|
||||
const items: OrderedListSelectionItem[] = [];
|
||||
for (const folder of folders.value) {
|
||||
items.push({ type: "folderHeader", id: folder.path });
|
||||
if (folder.collapsed) continue;
|
||||
for (const { entry } of flatTree(folder.entries, folder.expanded)) {
|
||||
items.push({ type: entry.is_dir ? "dir" : "file", id: entry.path });
|
||||
}
|
||||
}
|
||||
return items;
|
||||
});
|
||||
|
||||
function selectedRangeAnchorIndex() {
|
||||
const activeItem = activePath.value ? (visibleItems.value.find((item) => item.id === activePath.value) ?? null) : null;
|
||||
return orderedListRangeAnchorIndex(visibleItems.value, selectionAnchorIndex.value, activeItem);
|
||||
}
|
||||
|
||||
function selectRangeTo(currentIndex: number) {
|
||||
const anchorIndex = selectedRangeAnchorIndex();
|
||||
if (anchorIndex === null) {
|
||||
const current = visibleItems.value[currentIndex];
|
||||
selectedPaths.value = new Set(current ? [current.id] : []);
|
||||
selectionAnchorIndex.value = currentIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.min(anchorIndex, currentIndex);
|
||||
const end = Math.max(anchorIndex, currentIndex);
|
||||
const next = new Set(selectedPaths.value);
|
||||
for (let i = start; i <= end; i++) {
|
||||
const item = visibleItems.value[i];
|
||||
if (item) next.add(item.id);
|
||||
}
|
||||
selectedPaths.value = next;
|
||||
}
|
||||
|
||||
function handlePathClick(path: string, type: OrderedListSelectionItem["type"], event: MouseEvent, activate: () => void) {
|
||||
const currentIndex = visibleItems.value.findIndex((item) => item.id === path && item.type === type);
|
||||
if (currentIndex < 0) return;
|
||||
|
||||
const selectionIntent = orderedListSelectionIntent(event);
|
||||
if (selectionIntent === "range") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectRangeTo(currentIndex);
|
||||
return;
|
||||
}
|
||||
if (selectionIntent === "toggle") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const next = new Set(selectedPaths.value);
|
||||
if (next.has(path)) next.delete(path);
|
||||
else next.add(path);
|
||||
selectedPaths.value = next;
|
||||
selectionAnchorIndex.value = currentIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedPaths.value = new Set();
|
||||
activePath.value = path;
|
||||
selectionAnchorIndex.value = currentIndex;
|
||||
activate();
|
||||
}
|
||||
|
||||
function handlePanelClick(event: MouseEvent) {
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (!target?.closest("[data-sql-file-row='true']")) clearSelection();
|
||||
}
|
||||
|
||||
// ---- context menu ----
|
||||
const contextMenuItems = computed<ContextMenuItem[]>(() => {
|
||||
const target = contextTarget.value;
|
||||
@@ -365,12 +438,12 @@ function clearContextTarget() {
|
||||
<template #default="{ onContextMenu }">
|
||||
<div
|
||||
class="flex-1 overflow-y-auto"
|
||||
@click="handlePanelClick"
|
||||
@contextmenu.capture="contextTarget = { kind: 'panel' }"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = { kind: 'panel' };
|
||||
onContextMenu($event);
|
||||
"
|
||||
@click.self="selectPath(null)"
|
||||
>
|
||||
<div v-if="folders.length === 0" class="flex-1 flex flex-col items-center justify-center gap-2 p-4 text-xs text-muted-foreground">
|
||||
<FolderOpen class="h-8 w-8 text-muted-foreground/40" />
|
||||
@@ -381,19 +454,17 @@ function clearContextTarget() {
|
||||
<div v-else>
|
||||
<div v-for="(folder, fi) in folders" :key="folder.path" class="border-b last:border-b-0">
|
||||
<div
|
||||
data-sql-file-row="true"
|
||||
class="flex cursor-default items-center gap-1 px-2 py-1.5 text-[11px] font-medium text-muted-foreground bg-muted/10 sticky top-0 select-none"
|
||||
:class="selectedPath === folder.path ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'"
|
||||
@click="
|
||||
toggleFolderCollapse(folder);
|
||||
selectPath(folder.path);
|
||||
"
|
||||
:class="isPathHighlighted(folder.path) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40'"
|
||||
@click.stop="handlePathClick(folder.path, 'folderHeader', $event, () => toggleFolderCollapse(folder))"
|
||||
@contextmenu.capture="
|
||||
contextTarget = { kind: 'folderHeader', folderPath: folder.path };
|
||||
selectPath(folder.path);
|
||||
activePath = folder.path;
|
||||
"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = { kind: 'folderHeader', folderPath: folder.path };
|
||||
selectPath(folder.path);
|
||||
activePath = folder.path;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
@@ -427,22 +498,20 @@ function clearContextTarget() {
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
data-sql-file-row="true"
|
||||
v-for="{ entry, depth } in flatTree(folder.entries, folder.expanded)"
|
||||
:key="entry.path"
|
||||
class="flex cursor-default select-none items-center gap-1 px-2 py-1 text-sm"
|
||||
:class="[entry.is_dir ? 'rounded-sm' : 'rounded-none', selectedPath === entry.path ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40']"
|
||||
:class="[entry.is_dir ? 'rounded-sm' : 'rounded-none', isPathHighlighted(entry.path) ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/40']"
|
||||
:style="{ paddingLeft: depth * 16 + 8 + 'px' }"
|
||||
@click="
|
||||
selectPath(entry.path);
|
||||
entry.is_dir ? toggleExpand(folder, entry.path) : openFile(entry.path);
|
||||
"
|
||||
@click.stop="handlePathClick(entry.path, entry.is_dir ? 'dir' : 'file', $event, () => (entry.is_dir ? toggleExpand(folder, entry.path) : openFile(entry.path)))"
|
||||
@contextmenu.capture="
|
||||
contextTarget = entry.is_dir ? { kind: 'dir', folderPath: folder.path, entry } : { kind: 'file', folderPath: folder.path, entry };
|
||||
selectPath(entry.path);
|
||||
activePath = entry.path;
|
||||
"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = entry.is_dir ? { kind: 'dir', folderPath: folder.path, entry } : { kind: 'file', folderPath: folder.path, entry };
|
||||
selectPath(entry.path);
|
||||
activePath = entry.path;
|
||||
onContextMenu($event);
|
||||
"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,10 @@ import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus";
|
||||
import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts";
|
||||
import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport";
|
||||
import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName";
|
||||
import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget";
|
||||
import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
|
||||
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
|
||||
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import type { SavedSqlFile, SavedSqlFolder } from "@/types/database";
|
||||
|
||||
const { t } = useI18n();
|
||||
@@ -65,14 +68,6 @@ function folderPath(folder: SavedSqlFolder) {
|
||||
return parts.join(" / ");
|
||||
}
|
||||
|
||||
function activeImportConnectionId() {
|
||||
return connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
}
|
||||
|
||||
function importConnectionIdForFolder(folder?: SavedSqlFolder) {
|
||||
return folder?.connectionId || activeImportConnectionId();
|
||||
}
|
||||
|
||||
function sanitizeFileSystemSegment(name: string) {
|
||||
return name.replace(/[<>:"/\\|?*\p{Cc}]/gu, "_").trim() || "untitled";
|
||||
}
|
||||
@@ -223,12 +218,6 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
const connectionId = importConnectionIdForFolder(targetFolder);
|
||||
if (!connectionId) {
|
||||
toast(t("sqlLibrary.noConnection"), 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { open } = await import("@tauri-apps/plugin-dialog");
|
||||
const selected = await open({
|
||||
@@ -247,23 +236,27 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) {
|
||||
|
||||
const folderCache = new Map<string, SavedSqlFolder>();
|
||||
const takenNamesByFolder = new Map<string, Set<string>>();
|
||||
const folderConnectionId = targetFolder?.connectionId ?? "";
|
||||
|
||||
for (const file of importFiles) {
|
||||
const folderId = await resolveImportedFolder(connectionId, targetFolder?.id, file.folderNames, folderCache);
|
||||
const sourceTarget = resolveExternalSqlFileTarget(file.path, (connectionId) => !!connectionStore.getConfig(connectionId), unassociatedExternalSqlFileTarget());
|
||||
const importTarget = savedSqlImportTarget(sourceTarget, targetFolder);
|
||||
const folderId = await resolveImportedFolder(folderConnectionId, targetFolder?.id, file.folderNames, folderCache);
|
||||
const folderKey = folderId || "";
|
||||
let takenNames = takenNamesByFolder.get(folderKey);
|
||||
if (!takenNames) {
|
||||
takenNames = new Set(savedSqlStore.listFiles(connectionId, folderId).map((savedFile) => savedFile.name));
|
||||
takenNames = new Set((folderId ? savedSqlStore.filesInFolder(folderId) : savedSqlStore.filesWithoutFolder()).map((savedFile) => savedFile.name));
|
||||
takenNamesByFolder.set(folderKey, takenNames);
|
||||
}
|
||||
const path = file.path;
|
||||
const content = await api.readExternalSqlFile(path);
|
||||
const displayName = uniqueImportedName(file.name, takenNames);
|
||||
await savedSqlStore.saveFile({
|
||||
connectionId,
|
||||
connectionId: importTarget.connectionId,
|
||||
folderId,
|
||||
name: displayName,
|
||||
database: "",
|
||||
database: importTarget.database,
|
||||
catalog: importTarget.catalog,
|
||||
sql: content,
|
||||
});
|
||||
}
|
||||
@@ -496,6 +489,40 @@ function clearSelection() {
|
||||
lastClickedItemIndex.value = null;
|
||||
}
|
||||
|
||||
function clearPanelSelection() {
|
||||
clearSelection();
|
||||
activeItemId.value = null;
|
||||
activeItemType.value = null;
|
||||
}
|
||||
|
||||
function rangeAnchorIndex() {
|
||||
const activeItem = activeItemId.value && activeItemType.value ? { id: activeItemId.value, type: activeItemType.value } : null;
|
||||
return orderedListRangeAnchorIndex(allSelectableItems.value, lastClickedItemIndex.value, activeItem);
|
||||
}
|
||||
|
||||
function selectRangeTo(currentIndex: number) {
|
||||
const anchorIndex = rangeAnchorIndex();
|
||||
if (anchorIndex === null) {
|
||||
const current = allSelectableItems.value[currentIndex];
|
||||
selectedFileIds.value = new Set(current?.type === "file" ? [current.id] : []);
|
||||
selectedFolderIds.value = new Set(current?.type === "folder" ? [current.id] : []);
|
||||
lastClickedItemIndex.value = currentIndex;
|
||||
return;
|
||||
}
|
||||
|
||||
const start = Math.min(anchorIndex, currentIndex);
|
||||
const end = Math.max(anchorIndex, currentIndex);
|
||||
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?.type === "file") nextFiles.add(item.id);
|
||||
else if (item?.type === "folder") nextFolders.add(item.id);
|
||||
}
|
||||
selectedFileIds.value = nextFiles;
|
||||
selectedFolderIds.value = nextFolders;
|
||||
}
|
||||
|
||||
function setActiveItem(id: string, type: "file" | "folder") {
|
||||
activeItemId.value = id;
|
||||
activeItemType.value = type;
|
||||
@@ -671,21 +698,25 @@ async function openFile(file: SavedSqlFile, targetMode?: SavedSqlOpenTargetMode)
|
||||
const loadedFile = await savedSqlStore.ensureFileContent(file.id);
|
||||
if (!loadedFile) return;
|
||||
const tabId = queryStore.openSavedSql(loadedFile, { targetMode });
|
||||
connectionStore.activeConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? loadedFile.connectionId;
|
||||
const openedConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? loadedFile.connectionId;
|
||||
if (openedConnectionId) connectionStore.activeConnectionId = openedConnectionId;
|
||||
void savedSqlStore.recordFileUsage(loadedFile.id);
|
||||
}
|
||||
|
||||
function handleFileClick(file: SavedSqlFile, event: MouseEvent) {
|
||||
if (suppressNextRowClick.value) return;
|
||||
|
||||
const isMeta = event.metaKey || event.ctrlKey;
|
||||
const isShift = event.shiftKey;
|
||||
const selectionIntent = orderedListSelectionIntent(event);
|
||||
|
||||
// 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) {
|
||||
if (selectionIntent === "range") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectRangeTo(currentIndex);
|
||||
} else if (selectionIntent === "toggle") {
|
||||
// Toggle selection
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -697,55 +728,29 @@ function handleFileClick(file: SavedSqlFile, event: MouseEvent) {
|
||||
}
|
||||
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;
|
||||
// A plain click exits batch selection and makes this the single active item.
|
||||
clearSelection();
|
||||
lastClickedItemIndex.value = currentIndex;
|
||||
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;
|
||||
const selectionIntent = orderedListSelectionIntent(event);
|
||||
|
||||
// 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) {
|
||||
if (selectionIntent === "range") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
selectRangeTo(currentIndex);
|
||||
} else if (selectionIntent === "toggle") {
|
||||
// Toggle selection
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
@@ -757,41 +762,12 @@ function handleFolderClick(folder: SavedSqlFolder, event: MouseEvent) {
|
||||
}
|
||||
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;
|
||||
// A plain click exits batch selection and makes this the single active item.
|
||||
clearSelection();
|
||||
lastClickedItemIndex.value = currentIndex;
|
||||
setActiveItem(folder.id, "folder");
|
||||
toggleFolder(folder.id);
|
||||
// Set anchor for future shift-click even when not selecting
|
||||
if (!hadSelection) {
|
||||
lastClickedItemIndex.value = currentIndex;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1084,8 +1060,9 @@ 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;
|
||||
// Batch actions use the context menu. Starting a single-item drag here can
|
||||
// suppress the plain click that is supposed to exit batch selection.
|
||||
if (hasSelection.value || event.shiftKey || event.metaKey || event.ctrlKey) return;
|
||||
const target = event.target as HTMLElement | null;
|
||||
if (target?.closest("[data-no-drag='true']")) return;
|
||||
pendingDrag = {
|
||||
@@ -1194,6 +1171,7 @@ function showDropInside(targetId: string) {
|
||||
<template #default="{ onContextMenu }">
|
||||
<div
|
||||
class="h-full"
|
||||
@click.self="clearPanelSelection"
|
||||
@contextmenu.capture="contextTarget = 'panel'"
|
||||
@contextmenu.prevent="
|
||||
contextTarget = 'panel';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const panelSource = readFileSync(new URL("../SqlFilePanel.vue", import.meta.url), "utf8");
|
||||
|
||||
describe("SqlFilePanel selection", () => {
|
||||
it("uses the shared ordered-list selection behavior", () => {
|
||||
expect(panelSource).toContain("orderedListSelectionIntent(event)");
|
||||
expect(panelSource).toContain("orderedListRangeAnchorIndex(visibleItems.value");
|
||||
expect(panelSource).not.toContain("anchorIndex ?? 0");
|
||||
});
|
||||
|
||||
it("does not open or expand rows during modifier selection", () => {
|
||||
expect(panelSource).toMatch(/if \(selectionIntent === "range"\)[\s\S]*selectRangeTo\(currentIndex\);[\s\S]*return;/);
|
||||
expect(panelSource).toMatch(/if \(selectionIntent === "toggle"\)[\s\S]*selectionAnchorIndex\.value = currentIndex;[\s\S]*return;/);
|
||||
expect(panelSource).toMatch(/selectionAnchorIndex\.value = currentIndex;[\s\S]*activate\(\);/);
|
||||
});
|
||||
|
||||
it("clears selection when a non-row area is clicked", () => {
|
||||
expect(panelSource).toContain('@click="handlePanelClick"');
|
||||
expect(panelSource).toContain("[data-sql-file-row='true']");
|
||||
expect(panelSource).toContain('data-sql-file-row="true"');
|
||||
});
|
||||
});
|
||||
@@ -9,4 +9,21 @@ describe("SqlLibraryPanel selection contrast", () => {
|
||||
expect(panelSource).toMatch(/function fileMetaClass[\s\S]*\? "text-accent-foreground" : "text-muted-foreground";/);
|
||||
expect(panelSource).not.toContain('contextFile(fileId) ? "text-foreground/70"');
|
||||
});
|
||||
|
||||
it("lets a plain click reliably exit batch selection", () => {
|
||||
expect(panelSource).toMatch(/function handleDragMouseDown[\s\S]*if \(hasSelection\.value \|\| event\.shiftKey \|\| event\.metaKey \|\| event\.ctrlKey\) return;/);
|
||||
expect(panelSource).toMatch(/function handleFileClick[\s\S]*else \{[\s\S]*clearSelection\(\);[\s\S]*lastClickedItemIndex\.value = currentIndex;[\s\S]*setActiveItem\(file\.id, "file"\);/);
|
||||
expect(panelSource).toMatch(/function handleFolderClick[\s\S]*else \{[\s\S]*clearSelection\(\);[\s\S]*lastClickedItemIndex\.value = currentIndex;[\s\S]*setActiveItem\(folder\.id, "folder"\);/);
|
||||
});
|
||||
|
||||
it("clears row selection when the blank panel area is clicked", () => {
|
||||
expect(panelSource).toContain('@click.self="clearPanelSelection"');
|
||||
expect(panelSource).toMatch(/function clearPanelSelection[\s\S]*clearSelection\(\);[\s\S]*activeItemId\.value = null;[\s\S]*activeItemType\.value = null;/);
|
||||
});
|
||||
|
||||
it("does not use the first row as an implicit Shift selection anchor", () => {
|
||||
expect(panelSource).toContain("const anchorIndex = rangeAnchorIndex();");
|
||||
expect(panelSource).toMatch(/if \(anchorIndex === null\)[\s\S]*lastClickedItemIndex\.value = currentIndex;[\s\S]*return;/);
|
||||
expect(panelSource).not.toContain("lastClickedItemIndex.value ?? 0");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user