mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 08:18:52 +00:00
fix(sql): preserve file associations and list selection
This commit is contained in:
+27
-21
@@ -58,7 +58,7 @@ import { uuid } from "@/lib/common/utils";
|
||||
import { isMacOS, isWindows } from "@/lib/backend/platform";
|
||||
import { isTauriRuntime } from "@/lib/backend/tauriRuntime";
|
||||
import { openQueryResultArchiveFile } from "@/lib/query/queryResultArchiveFile";
|
||||
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { externalSqlFileOpenErrorMessage, readBrowserSqlFile, sqlFileTitleFromPath } from "@/lib/sql/sqlFileOpen";
|
||||
import type { ConnectionConfig, DatabaseType, ObjectSourceKind, QueryTab } from "@/types/database";
|
||||
import { parseConnectionDeepLink, type ConnectionDeepLinkDraft } from "@/lib/connection/connectionDeepLink";
|
||||
@@ -1015,7 +1015,7 @@ async function writeExternalSqlTab(tab: QueryTab, options: { closeAfterSave?: bo
|
||||
expectedMissing: options.expectedMissing,
|
||||
});
|
||||
if (result.kind !== "written") return "retry";
|
||||
rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database });
|
||||
rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog });
|
||||
queryStore.markExternalSqlFileSaved(tab.id, result.version);
|
||||
toast(t("savedSql.saved"), 2000);
|
||||
if (options.closeAfterSave) queryStore.closeTab(tab.id, { force: true });
|
||||
@@ -1080,6 +1080,7 @@ async function saveTabForCloseAll(tabId: string): Promise<boolean> {
|
||||
folderId: existing?.folderId,
|
||||
name: existing?.name || defaultSavedSqlName(tab.title),
|
||||
database: target.database,
|
||||
catalog: target.catalog,
|
||||
schema: target.schema,
|
||||
sql: tab.sql,
|
||||
});
|
||||
@@ -1146,6 +1147,7 @@ async function handleSaveTab(tabId: string) {
|
||||
folderId: existing.folderId,
|
||||
name: existing.name,
|
||||
database: target.database,
|
||||
catalog: target.catalog,
|
||||
schema: target.schema,
|
||||
sql: tab.sql,
|
||||
});
|
||||
@@ -1182,6 +1184,7 @@ async function openSaveSqlDialog() {
|
||||
folderId: existing.folderId,
|
||||
name: existing.name,
|
||||
database: target.database,
|
||||
catalog: target.catalog,
|
||||
schema: target.schema,
|
||||
sql: tab.sql,
|
||||
});
|
||||
@@ -1277,6 +1280,7 @@ async function confirmSaveSqlToLibrary() {
|
||||
folderId: saveSqlFolderId.value === ROOT_SAVED_SQL_FOLDER ? undefined : saveSqlFolderId.value,
|
||||
name: defaultSavedSqlName(name),
|
||||
database: target.database,
|
||||
catalog: target.catalog,
|
||||
schema: target.schema,
|
||||
sql: tab.sql,
|
||||
});
|
||||
@@ -1296,7 +1300,7 @@ async function saveExternalSqlTabAs(tab: QueryTab): Promise<boolean> {
|
||||
const saved = await api.saveExternalSqlFile(defaultSavedSqlName(tab.title), tab.sql);
|
||||
if (!saved) return false;
|
||||
queryStore.linkExternalSqlPath(tab.id, saved.path, sqlFileTitleFromPath(saved.path), saved.version);
|
||||
rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database });
|
||||
rememberExternalSqlFileTarget(saved.path, { connectionId: tab.connectionId, database: tab.database, catalog: tab.catalog });
|
||||
invalidateSaveSqlFolderSelection();
|
||||
showSaveSqlDialog.value = false;
|
||||
closePendingSavedTab();
|
||||
@@ -1314,14 +1318,13 @@ async function saveActiveSqlAsLocalFile() {
|
||||
}
|
||||
|
||||
function applyExternalSqlFileTarget(tab: QueryTab, path: string) {
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), {
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
});
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
if (target.connectionId !== tab.connectionId) {
|
||||
queryStore.updateConnection(tab.id, target.connectionId, target.database);
|
||||
} else if (target.database !== tab.database) {
|
||||
queryStore.updateDatabase(tab.id, target.database);
|
||||
}
|
||||
if (target.catalog !== tab.catalog || target.database !== tab.database) {
|
||||
if (target.catalog !== undefined || tab.catalog !== undefined) queryStore.updateCatalog(tab.id, target.catalog, target.database);
|
||||
else queryStore.updateDatabase(tab.id, target.database);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1392,11 +1395,8 @@ async function openSqlFilePath(path: string) {
|
||||
try {
|
||||
await desktopOpenTabsRestorationBarrier?.settled;
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(path);
|
||||
const connectionId = connectionStore.activeConnectionId || activeTab.value?.connectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = activeTab.value?.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) {
|
||||
toast(t("toolbar.sqlOpenFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000);
|
||||
}
|
||||
@@ -1647,7 +1647,8 @@ async function openSavedSqlFromWelcome(fileId: string) {
|
||||
const file = await savedSqlStore.ensureFileContent(fileId);
|
||||
if (!file) return;
|
||||
const tabId = queryStore.openSavedSql(file);
|
||||
connectionStore.activeConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? file.connectionId;
|
||||
const openedConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? file.connectionId;
|
||||
if (openedConnectionId) connectionStore.activeConnectionId = openedConnectionId;
|
||||
void savedSqlStore.recordFileUsage(file.id);
|
||||
toast(t("welcome.fileOpened", { name: file.name }), 2000);
|
||||
}
|
||||
@@ -1817,13 +1818,16 @@ async function changeActiveConnection(connectionId: string) {
|
||||
if (!tab) return;
|
||||
const connection = connectionStore.getConfig(connectionId);
|
||||
if (!connection) return;
|
||||
queryStore.updateConnection(tab.id, connectionId, resolveDefaultDatabase(connection, []));
|
||||
const initialDatabase = resolveDefaultDatabase(connection, []);
|
||||
queryStore.updateConnection(tab.id, connectionId, initialDatabase);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database: initialDatabase, catalog: undefined });
|
||||
connectionStore.activeConnectionId = connectionId;
|
||||
try {
|
||||
await connectionStore.ensureConnected(connectionId);
|
||||
const options = await getDatabaseOptions(connectionId);
|
||||
const database = resolveDefaultDatabase(connection, options);
|
||||
queryStore.updateDatabase(tab.id, database);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId, database, catalog: undefined });
|
||||
if (connection.default_schema || connection.db_type === "oracle") {
|
||||
try {
|
||||
// A configured default wins. Otherwise Oracle returns the session's current schema first.
|
||||
@@ -1850,6 +1854,7 @@ function changeActiveDatabase(database: string) {
|
||||
const tab = activeTab.value;
|
||||
if (tab) {
|
||||
queryStore.updateDatabase(tab.id, database);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog: tab.catalog });
|
||||
if (databaseRequiredTabId.value === tab.id && database) {
|
||||
databaseRequiredTabId.value = null;
|
||||
}
|
||||
@@ -1858,7 +1863,10 @@ function changeActiveDatabase(database: string) {
|
||||
|
||||
function changeActiveCatalog(catalog: string | undefined, database: string) {
|
||||
const tab = activeTab.value;
|
||||
if (tab) queryStore.updateCatalog(tab.id, catalog, database);
|
||||
if (tab) {
|
||||
queryStore.updateCatalog(tab.id, catalog, database);
|
||||
if (tab.externalSqlPath) rememberExternalSqlFileTarget(tab.externalSqlPath, { connectionId: tab.connectionId, database, catalog });
|
||||
}
|
||||
}
|
||||
|
||||
async function setActiveDatabaseAsDefault() {
|
||||
@@ -1987,10 +1995,8 @@ async function handleQuickOpenSelect(item: any) {
|
||||
if (item.type === "sql_file" && item.filePath) {
|
||||
try {
|
||||
const snapshot = await api.readExternalSqlFileSnapshot(item.filePath);
|
||||
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = connection ? resolveDefaultDatabase(connection, []) : "";
|
||||
queryStore.openExternalSqlFile(connectionId, database, item.filePath, snapshot.content, snapshot.version);
|
||||
const target = resolveExternalSqlFileTarget(item.filePath, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, item.filePath, snapshot.content, snapshot.version, target.catalog);
|
||||
} catch (e: any) {
|
||||
toast(
|
||||
externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)),
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,6 +179,18 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SqlFileExecutionDialog retries", () => {
|
||||
it("does not auto-select the first connection for an unassociated external file", async () => {
|
||||
root = document.createElement("div");
|
||||
document.body.append(root);
|
||||
app = createApp(SqlFileExecutionDialog, { open: true, prefillFilePath: "/tmp/unassociated.sql" });
|
||||
app.mount(root);
|
||||
|
||||
await vi.waitFor(() => expect(mocks.previewSqlFile).toHaveBeenCalledWith("/tmp/unassociated.sql"));
|
||||
expect(mocks.ensureConnected).not.toHaveBeenCalled();
|
||||
expect(mocks.fetchSqlFileTargetOptions).not.toHaveBeenCalled();
|
||||
expect(findButton("sqlFile.execute").disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("does not restore a completed run's file summary after an early retry failure", async () => {
|
||||
await mountReadyDialog();
|
||||
await completeFirstExecution();
|
||||
|
||||
@@ -196,6 +196,7 @@ function resolveInitialConnectionId() {
|
||||
if (props.prefillConnectionId && sqlConnections.value.some((c) => c.id === props.prefillConnectionId)) {
|
||||
return props.prefillConnectionId;
|
||||
}
|
||||
if (props.prefillFilePath) return "";
|
||||
return sqlConnections.value[0]?.id ?? "";
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as api from "@/lib/backend/api";
|
||||
import type { ConnectionConfig, ExternalSqlFileVersion } from "@/types/database";
|
||||
import { detectDatabaseFileType } from "@/lib/database/databaseFileDetection";
|
||||
import { externalSqlFileOpenErrorMessage, readBrowserSqlFile } from "@/lib/sql/sqlFileOpen";
|
||||
import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
|
||||
function isSqlFilePath(path: string): boolean {
|
||||
return /\.sql$/i.test(path);
|
||||
@@ -24,12 +25,13 @@ export function useFileDrop() {
|
||||
const { toast } = useToast();
|
||||
|
||||
async function openDroppedSqlFile(name: string, content: string, path?: string, version?: ExternalSqlFileVersion) {
|
||||
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = connection?.database || "";
|
||||
if (path) {
|
||||
queryStore.openExternalSqlFile(connectionId, database, path, content, version);
|
||||
const target = resolveExternalSqlFileTarget(path, (savedConnectionId) => !!connectionStore.getConfig(savedConnectionId), unassociatedExternalSqlFileTarget());
|
||||
queryStore.openExternalSqlFile(target.connectionId, target.database, path, content, version, target.catalog);
|
||||
} else {
|
||||
const connectionId = connectionStore.activeConnectionId || connectionStore.connections[0]?.id || "";
|
||||
const connection = connectionId ? connectionStore.getConfig(connectionId) : undefined;
|
||||
const database = connection?.database || "";
|
||||
const tabId = queryStore.createTab(connectionId, database, name, "query");
|
||||
queryStore.updateSql(tabId, content);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ const savedTarget = {
|
||||
connectionId: "saved-connection",
|
||||
database: "saved_database",
|
||||
schema: "saved_schema",
|
||||
catalog: "saved_catalog",
|
||||
};
|
||||
|
||||
describe("saved SQL execution targets", () => {
|
||||
@@ -46,11 +47,13 @@ describe("saved SQL execution targets", () => {
|
||||
connectionId: "runtime-connection",
|
||||
database: "runtime_database",
|
||||
schema: "runtime_schema",
|
||||
catalog: "runtime_catalog",
|
||||
}),
|
||||
).toEqual({
|
||||
connectionId: "runtime-connection",
|
||||
database: "runtime_database",
|
||||
schema: "runtime_schema",
|
||||
catalog: "runtime_catalog",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget";
|
||||
|
||||
describe("savedSqlImportTarget", () => {
|
||||
it("inherits an explicitly associated local file target at the library root", () => {
|
||||
expect(savedSqlImportTarget({ connectionId: "conn-1", database: "sales", catalog: "hive" })).toEqual({
|
||||
connectionId: "conn-1",
|
||||
database: "sales",
|
||||
catalog: "hive",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps an unassociated local file unassociated at the library root", () => {
|
||||
expect(savedSqlImportTarget({ connectionId: "", database: "" })).toEqual({ connectionId: "", database: "" });
|
||||
});
|
||||
|
||||
it("keeps the destination folder connection and inherits a matching database", () => {
|
||||
expect(savedSqlImportTarget({ connectionId: "conn-1", database: "sales", catalog: "iceberg" }, { connectionId: "conn-1" })).toEqual({
|
||||
connectionId: "conn-1",
|
||||
database: "sales",
|
||||
catalog: "iceberg",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not apply a database from a different connection to the destination folder", () => {
|
||||
expect(savedSqlImportTarget({ connectionId: "conn-2", database: "analytics" }, { connectionId: "conn-1" })).toEqual({
|
||||
connectionId: "conn-1",
|
||||
database: "",
|
||||
catalog: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the destination folder for an unassociated source", () => {
|
||||
expect(savedSqlImportTarget({ connectionId: "", database: "" }, { connectionId: "conn-1" })).toEqual({
|
||||
connectionId: "conn-1",
|
||||
database: "",
|
||||
catalog: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection";
|
||||
|
||||
describe("orderedListSelectionIntent", () => {
|
||||
it("uses Shift for range selection even when another modifier is also reported", () => {
|
||||
expect(orderedListSelectionIntent({ shiftKey: true, metaKey: false, ctrlKey: false })).toBe("range");
|
||||
expect(orderedListSelectionIntent({ shiftKey: true, metaKey: true, ctrlKey: false })).toBe("range");
|
||||
expect(orderedListSelectionIntent({ shiftKey: true, metaKey: false, ctrlKey: true })).toBe("range");
|
||||
});
|
||||
|
||||
it("uses Cmd or Ctrl for toggle selection", () => {
|
||||
expect(orderedListSelectionIntent({ shiftKey: false, metaKey: true, ctrlKey: false })).toBe("toggle");
|
||||
expect(orderedListSelectionIntent({ shiftKey: false, metaKey: false, ctrlKey: true })).toBe("toggle");
|
||||
});
|
||||
|
||||
it("uses an unmodified click for single selection", () => {
|
||||
expect(orderedListSelectionIntent({ shiftKey: false, metaKey: false, ctrlKey: false })).toBe("single");
|
||||
});
|
||||
});
|
||||
|
||||
describe("orderedListRangeAnchorIndex", () => {
|
||||
const items = [
|
||||
{ type: "file", id: "first" },
|
||||
{ type: "folder", id: "folder" },
|
||||
{ type: "file", id: "last" },
|
||||
];
|
||||
|
||||
it("keeps the explicit selection anchor", () => {
|
||||
expect(orderedListRangeAnchorIndex(items, 2, { type: "file", id: "first" })).toBe(2);
|
||||
});
|
||||
|
||||
it("uses the active item when no explicit anchor exists", () => {
|
||||
expect(orderedListRangeAnchorIndex(items, null, { type: "folder", id: "folder" })).toBe(1);
|
||||
});
|
||||
|
||||
it("does not fall back to the first item without an anchor", () => {
|
||||
expect(orderedListRangeAnchorIndex(items, null, null)).toBeNull();
|
||||
expect(orderedListRangeAnchorIndex(items, null, { type: "file", id: "missing" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, MAX_EXTERNAL_SQL_FILE_TARGETS, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
import { EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, MAX_EXTERNAL_SQL_FILE_TARGETS, rememberExternalSqlFileTarget, resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
|
||||
describe("external SQL file targets", () => {
|
||||
beforeEach(() => {
|
||||
@@ -9,20 +9,48 @@ describe("external SQL file targets", () => {
|
||||
});
|
||||
|
||||
it("restores the saved data source across normalized file paths", () => {
|
||||
rememberExternalSqlFileTarget(" C:\\work\\report.sql ", { connectionId: "saved-connection", database: "analytics" });
|
||||
rememberExternalSqlFileTarget(" C:\\work\\report.sql ", { connectionId: "saved-connection", database: "sales", catalog: "hive" });
|
||||
|
||||
expect(resolveExternalSqlFileTarget("C:/work/report.sql", () => true, { connectionId: "fallback", database: "default" })).toEqual({
|
||||
connectionId: "saved-connection",
|
||||
database: "analytics",
|
||||
database: "sales",
|
||||
catalog: "hive",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps same-named databases in different catalogs distinct", () => {
|
||||
rememberExternalSqlFileTarget("/work/hive.sql", { connectionId: "saved-connection", database: "sales", catalog: "hive" });
|
||||
rememberExternalSqlFileTarget("/work/iceberg.sql", { connectionId: "saved-connection", database: "sales", catalog: "iceberg" });
|
||||
|
||||
expect(resolveExternalSqlFileTarget("/work/hive.sql", () => true, unassociatedExternalSqlFileTarget()).catalog).toBe("hive");
|
||||
expect(resolveExternalSqlFileTarget("/work/iceberg.sql", () => true, unassociatedExternalSqlFileTarget()).catalog).toBe("iceberg");
|
||||
});
|
||||
|
||||
it("treats historical records without catalog as the default catalog", () => {
|
||||
localStorage.setItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY, JSON.stringify([{ path: "/work/legacy.sql", connectionId: "saved-connection", database: "sales", updatedAt: 1 }]));
|
||||
|
||||
expect(resolveExternalSqlFileTarget("/work/legacy.sql", () => true, unassociatedExternalSqlFileTarget())).toEqual({
|
||||
connectionId: "saved-connection",
|
||||
database: "sales",
|
||||
catalog: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a new file path unassociated", () => {
|
||||
expect(resolveExternalSqlFileTarget("/work/new.sql", () => true, unassociatedExternalSqlFileTarget())).toEqual({
|
||||
connectionId: "",
|
||||
database: "",
|
||||
catalog: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back when the saved connection no longer exists", () => {
|
||||
rememberExternalSqlFileTarget("/work/report.sql", { connectionId: "deleted-connection", database: "analytics" });
|
||||
|
||||
expect(resolveExternalSqlFileTarget("/work/report.sql", () => false, { connectionId: "fallback", database: "default" })).toEqual({
|
||||
connectionId: "fallback",
|
||||
database: "default",
|
||||
expect(resolveExternalSqlFileTarget("/work/report.sql", () => false, unassociatedExternalSqlFileTarget())).toEqual({
|
||||
connectionId: "",
|
||||
database: "",
|
||||
catalog: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,6 +75,7 @@ describe("external SQL file targets", () => {
|
||||
expect(resolveExternalSqlFileTarget(`/work/report-${MAX_EXTERNAL_SQL_FILE_TARGETS}.sql`, () => true, { connectionId: "fallback", database: "default" })).toEqual({
|
||||
connectionId: `connection-${MAX_EXTERNAL_SQL_FILE_TARGETS}`,
|
||||
database: `database-${MAX_EXTERNAL_SQL_FILE_TARGETS}`,
|
||||
catalog: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface SavedSqlExecutionTarget {
|
||||
catalog?: string;
|
||||
}
|
||||
|
||||
type SavedSqlFileTarget = Pick<SavedSqlFile, "connectionId" | "database" | "schema">;
|
||||
type SavedSqlFileTarget = Pick<SavedSqlFile, "connectionId" | "database" | "schema" | "catalog">;
|
||||
type QueryTabTarget = Pick<QueryTab, "connectionId" | "database" | "schema" | "catalog">;
|
||||
|
||||
export function savedSqlExecutionTargetFromFile(file: SavedSqlFileTarget): SavedSqlExecutionTarget {
|
||||
@@ -17,6 +17,7 @@ export function savedSqlExecutionTargetFromFile(file: SavedSqlFileTarget): Saved
|
||||
connectionId: file.connectionId,
|
||||
database: file.database,
|
||||
schema: file.schema,
|
||||
catalog: file.catalog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -40,5 +41,6 @@ export function savedSqlDefaultTargetForWrite(currentTarget: SavedSqlExecutionTa
|
||||
connectionId: currentTarget.connectionId,
|
||||
database: currentTarget.database,
|
||||
schema: currentTarget.schema,
|
||||
catalog: currentTarget.catalog,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SavedSqlFolder } from "@/types/database";
|
||||
import type { ExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget";
|
||||
|
||||
export function savedSqlImportTarget(sourceTarget: ExternalSqlFileTarget, folder?: Pick<SavedSqlFolder, "connectionId">): ExternalSqlFileTarget {
|
||||
if (!folder) return sourceTarget;
|
||||
return {
|
||||
connectionId: folder.connectionId,
|
||||
database: sourceTarget.connectionId === folder.connectionId ? sourceTarget.database : "",
|
||||
catalog: sourceTarget.connectionId === folder.connectionId ? sourceTarget.catalog : undefined,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export type OrderedListSelectionIntent = "range" | "toggle" | "single";
|
||||
|
||||
export interface OrderedListSelectionItem {
|
||||
type: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function orderedListSelectionIntent(event: Pick<MouseEvent, "shiftKey" | "metaKey" | "ctrlKey">): OrderedListSelectionIntent {
|
||||
if (event.shiftKey) return "range";
|
||||
if (event.metaKey || event.ctrlKey) return "toggle";
|
||||
return "single";
|
||||
}
|
||||
|
||||
export function orderedListRangeAnchorIndex(items: OrderedListSelectionItem[], anchorIndex: number | null, activeItem: OrderedListSelectionItem | null): number | null {
|
||||
if (anchorIndex !== null && anchorIndex >= 0 && anchorIndex < items.length) return anchorIndex;
|
||||
if (!activeItem) return null;
|
||||
const index = items.findIndex((item) => item.type === activeItem.type && item.id === activeItem.id);
|
||||
return index >= 0 ? index : null;
|
||||
}
|
||||
@@ -6,6 +6,11 @@ export const MAX_EXTERNAL_SQL_FILE_TARGETS = 200;
|
||||
export interface ExternalSqlFileTarget {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
catalog?: string;
|
||||
}
|
||||
|
||||
export function unassociatedExternalSqlFileTarget(): ExternalSqlFileTarget {
|
||||
return { connectionId: "", database: "", catalog: undefined };
|
||||
}
|
||||
|
||||
interface StoredExternalSqlFileTarget extends ExternalSqlFileTarget {
|
||||
@@ -17,7 +22,9 @@ function loadExternalSqlFileTargets(): StoredExternalSqlFileTarget[] {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(EXTERNAL_SQL_FILE_TARGETS_STORAGE_KEY) || "[]");
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.filter((item): item is StoredExternalSqlFileTarget => typeof item?.path === "string" && typeof item?.connectionId === "string" && typeof item?.database === "string" && typeof item?.updatedAt === "number");
|
||||
return parsed
|
||||
.filter((item) => typeof item?.path === "string" && typeof item?.connectionId === "string" && typeof item?.database === "string" && (item?.catalog === undefined || typeof item.catalog === "string") && typeof item?.updatedAt === "number")
|
||||
.map((item) => ({ ...item, catalog: item.catalog })) as StoredExternalSqlFileTarget[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -44,5 +51,5 @@ export function resolveExternalSqlFileTarget(path: string, connectionExists: (co
|
||||
const normalizedPath = normalizeExternalSqlPath(path);
|
||||
const saved = loadExternalSqlFileTargets().find((item) => item.path === normalizedPath);
|
||||
if (!saved || !connectionExists(saved.connectionId)) return fallback;
|
||||
return { connectionId: saved.connectionId, database: saved.database };
|
||||
return { connectionId: saved.connectionId, database: saved.database, catalog: saved.catalog };
|
||||
}
|
||||
|
||||
@@ -1692,7 +1692,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
});
|
||||
}
|
||||
|
||||
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"]) {
|
||||
function openExternalSqlFile(connectionId: string, database: string, path: string, sql: string, version?: QueryTab["externalSqlFileVersion"], catalog?: string) {
|
||||
const normalizedPath = normalizeExternalSqlPath(path);
|
||||
const existing = tabs.value.find((tab) => tab.mode === "query" && tab.externalSqlPath && normalizeExternalSqlPath(tab.externalSqlPath) === normalizedPath);
|
||||
if (existing) {
|
||||
@@ -1709,6 +1709,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
customTitle: true,
|
||||
connectionId,
|
||||
database,
|
||||
catalog,
|
||||
sql,
|
||||
originalSql: sql,
|
||||
externalSqlPath: path,
|
||||
@@ -3081,6 +3082,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
.updateFileExecutionTarget(tab.savedSqlId, {
|
||||
connectionId: tab.connectionId,
|
||||
database: tab.database,
|
||||
catalog: tab.catalog,
|
||||
schema: tab.schema,
|
||||
})
|
||||
.catch((error) => console.warn("[DBX][saved-sql:target:error]", error));
|
||||
|
||||
@@ -21,6 +21,7 @@ interface SaveFileInput {
|
||||
folderId?: string;
|
||||
name: string;
|
||||
database: string;
|
||||
catalog?: string;
|
||||
schema?: string;
|
||||
sql: string;
|
||||
}
|
||||
@@ -28,6 +29,7 @@ interface SaveFileInput {
|
||||
interface SavedSqlExecutionTargetInput {
|
||||
connectionId: string;
|
||||
database: string;
|
||||
catalog?: string;
|
||||
schema?: string;
|
||||
}
|
||||
|
||||
@@ -239,6 +241,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
folderId: hasFolderIdInput ? input.folderId || undefined : existing.folderId,
|
||||
name: input.name,
|
||||
database: input.database,
|
||||
catalog: input.catalog,
|
||||
schema: input.schema,
|
||||
sql: input.sql,
|
||||
sqlLoaded: true,
|
||||
@@ -251,6 +254,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
folderId: input.folderId || undefined,
|
||||
name: input.name,
|
||||
database: input.database,
|
||||
catalog: input.catalog,
|
||||
schema: input.schema,
|
||||
sql: input.sql,
|
||||
sqlLoaded: true,
|
||||
@@ -268,7 +272,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise<SavedSqlFile | undefined> {
|
||||
const existing = getFile(id);
|
||||
if (!existing) return Promise.resolve(undefined);
|
||||
if (existing.connectionId === target.connectionId && existing.database === target.database && existing.schema === target.schema) {
|
||||
if (existing.connectionId === target.connectionId && existing.database === target.database && existing.catalog === target.catalog && existing.schema === target.schema) {
|
||||
return Promise.resolve(existing);
|
||||
}
|
||||
|
||||
@@ -276,6 +280,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
persistedFileTargets.set(id, {
|
||||
connectionId: existing.connectionId,
|
||||
database: existing.database,
|
||||
catalog: existing.catalog,
|
||||
schema: existing.schema,
|
||||
updatedAt: existing.updatedAt,
|
||||
});
|
||||
@@ -308,6 +313,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
|
||||
persistedFileTargets.set(id, {
|
||||
connectionId: saved.connectionId,
|
||||
database: saved.database,
|
||||
catalog: saved.catalog,
|
||||
schema: saved.schema,
|
||||
updatedAt: saved.updatedAt,
|
||||
});
|
||||
|
||||
@@ -1299,6 +1299,7 @@ export interface SavedSqlFile {
|
||||
folderId?: string;
|
||||
name: string;
|
||||
database: string;
|
||||
catalog?: string;
|
||||
schema?: string;
|
||||
sql: string;
|
||||
sqlLoaded?: boolean;
|
||||
|
||||
@@ -25,6 +25,8 @@ pub struct SavedSqlFile {
|
||||
pub folder_id: Option<String>,
|
||||
pub name: String,
|
||||
pub database: String,
|
||||
#[serde(default)]
|
||||
pub catalog: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
pub sql: String,
|
||||
#[serde(default = "default_sql_loaded")]
|
||||
|
||||
@@ -394,6 +394,7 @@ const SCHEMA_STATEMENTS: &[&str] = &[
|
||||
folder_id TEXT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
database_name TEXT NOT NULL DEFAULT '',
|
||||
catalog_name TEXT,
|
||||
schema_name TEXT,
|
||||
sql_text TEXT NOT NULL DEFAULT '',
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -555,6 +556,7 @@ fn ensure_saved_sql_columns_sync(conn: &Connection) -> Result<(), String> {
|
||||
const FOLDER_COLUMNS: &[(&str, &str)] =
|
||||
&[("parent_folder_id", "TEXT"), ("order_index", "INTEGER NOT NULL DEFAULT 0")];
|
||||
const FILE_COLUMNS: &[(&str, &str)] = &[
|
||||
("catalog_name", "TEXT"),
|
||||
("order_index", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("open_count", "INTEGER NOT NULL DEFAULT 0"),
|
||||
("opened_at", "TEXT"),
|
||||
@@ -2863,14 +2865,15 @@ impl Storage {
|
||||
for file in &library.files {
|
||||
tx.execute(
|
||||
"INSERT INTO saved_sql_files \
|
||||
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
params![
|
||||
file.id,
|
||||
file.connection_id,
|
||||
file.folder_id,
|
||||
file.name,
|
||||
file.database,
|
||||
file.catalog,
|
||||
file.schema,
|
||||
file.sql,
|
||||
file.order_index,
|
||||
@@ -2914,7 +2917,7 @@ impl Storage {
|
||||
|
||||
let mut file_stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \
|
||||
"SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \
|
||||
FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -2926,14 +2929,15 @@ impl Storage {
|
||||
folder_id: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
database: row.get(4)?,
|
||||
schema: row.get(5)?,
|
||||
sql: row.get(6)?,
|
||||
catalog: row.get(5)?,
|
||||
schema: row.get(6)?,
|
||||
sql: row.get(7)?,
|
||||
sql_loaded: true,
|
||||
order_index: row.get(7)?,
|
||||
open_count: row.get(8)?,
|
||||
opened_at: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
order_index: row.get(8)?,
|
||||
open_count: row.get(9)?,
|
||||
opened_at: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -2971,7 +2975,7 @@ impl Storage {
|
||||
|
||||
let mut file_stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, connection_id, folder_id, name, database_name, schema_name, order_index, open_count, opened_at, created_at, updated_at \
|
||||
"SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, order_index, open_count, opened_at, created_at, updated_at \
|
||||
FROM saved_sql_files ORDER BY COALESCE(folder_id, ''), order_index, connection_id, name COLLATE NOCASE",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -2983,14 +2987,15 @@ impl Storage {
|
||||
folder_id: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
database: row.get(4)?,
|
||||
schema: row.get(5)?,
|
||||
catalog: row.get(5)?,
|
||||
schema: row.get(6)?,
|
||||
sql: String::new(),
|
||||
sql_loaded: false,
|
||||
order_index: row.get(6)?,
|
||||
open_count: row.get(7)?,
|
||||
opened_at: row.get(8)?,
|
||||
created_at: row.get(9)?,
|
||||
updated_at: row.get(10)?,
|
||||
order_index: row.get(7)?,
|
||||
open_count: row.get(8)?,
|
||||
opened_at: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
@@ -3007,7 +3012,7 @@ impl Storage {
|
||||
self.with_conn(move |conn| {
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \
|
||||
"SELECT id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at \
|
||||
FROM saved_sql_files WHERE id = ?1",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
@@ -3018,14 +3023,15 @@ impl Storage {
|
||||
folder_id: row.get(2)?,
|
||||
name: row.get(3)?,
|
||||
database: row.get(4)?,
|
||||
schema: row.get(5)?,
|
||||
sql: row.get(6)?,
|
||||
catalog: row.get(5)?,
|
||||
schema: row.get(6)?,
|
||||
sql: row.get(7)?,
|
||||
sql_loaded: true,
|
||||
order_index: row.get(7)?,
|
||||
open_count: row.get(8)?,
|
||||
opened_at: row.get(9)?,
|
||||
created_at: row.get(10)?,
|
||||
updated_at: row.get(11)?,
|
||||
order_index: row.get(8)?,
|
||||
open_count: row.get(9)?,
|
||||
opened_at: row.get(10)?,
|
||||
created_at: row.get(11)?,
|
||||
updated_at: row.get(12)?,
|
||||
})
|
||||
}) {
|
||||
Ok(file) => Ok(Some(file)),
|
||||
@@ -3099,15 +3105,16 @@ impl Storage {
|
||||
self.with_conn(move |conn| {
|
||||
conn.execute(
|
||||
"INSERT INTO saved_sql_files \
|
||||
(id, connection_id, folder_id, name, database_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
(id, connection_id, folder_id, name, database_name, catalog_name, schema_name, sql_text, order_index, open_count, opened_at, created_at, updated_at) \
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \
|
||||
ON CONFLICT(id) DO UPDATE SET \
|
||||
connection_id = excluded.connection_id, \
|
||||
folder_id = excluded.folder_id, \
|
||||
name = excluded.name, \
|
||||
database_name = excluded.database_name, \
|
||||
catalog_name = excluded.catalog_name, \
|
||||
schema_name = excluded.schema_name, \
|
||||
sql_text = CASE WHEN ?13 THEN excluded.sql_text ELSE saved_sql_files.sql_text END, \
|
||||
sql_text = CASE WHEN ?14 THEN excluded.sql_text ELSE saved_sql_files.sql_text END, \
|
||||
order_index = excluded.order_index, \
|
||||
open_count = excluded.open_count, \
|
||||
opened_at = excluded.opened_at, \
|
||||
@@ -3118,6 +3125,7 @@ impl Storage {
|
||||
file.folder_id,
|
||||
file.name,
|
||||
file.database,
|
||||
file.catalog,
|
||||
file.schema,
|
||||
file.sql,
|
||||
file.order_index,
|
||||
@@ -5567,6 +5575,42 @@ mod tests {
|
||||
assert!(entry.last_accessed_at >= entry.created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_sql_catalog_column_migrates_legacy_database() {
|
||||
let path = temp_db_path("saved-sql-catalog-migration");
|
||||
{
|
||||
let connection = Connection::open(&path).unwrap();
|
||||
connection
|
||||
.execute_batch(
|
||||
"CREATE TABLE saved_sql_files (
|
||||
id TEXT PRIMARY KEY,
|
||||
connection_id TEXT NOT NULL,
|
||||
folder_id TEXT,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
database_name TEXT NOT NULL DEFAULT '',
|
||||
schema_name TEXT,
|
||||
sql_text TEXT NOT NULL DEFAULT '',
|
||||
order_index INTEGER NOT NULL DEFAULT 0,
|
||||
open_count INTEGER NOT NULL DEFAULT 0,
|
||||
opened_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
INSERT INTO saved_sql_files
|
||||
(id, connection_id, name, database_name, sql_text, created_at, updated_at)
|
||||
VALUES
|
||||
('legacy-sql', 'conn-1', 'legacy.sql', 'sales', 'SELECT 1;', '2026-01-01', '2026-01-01');",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let storage = Storage::open(&path).await.unwrap();
|
||||
let loaded = storage.load_saved_sql_file("legacy-sql").await.unwrap().unwrap();
|
||||
|
||||
assert_eq!(loaded.database, "sales");
|
||||
assert_eq!(loaded.catalog, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn saved_sql_summary_omits_sql_text_and_loads_file_on_demand() {
|
||||
let path = temp_db_path("saved-sql-summary");
|
||||
@@ -5577,6 +5621,7 @@ mod tests {
|
||||
folder_id: None,
|
||||
name: "large.sql".to_string(),
|
||||
database: "main".to_string(),
|
||||
catalog: Some("hive".to_string()),
|
||||
schema: None,
|
||||
sql: "SELECT * FROM very_large_table;".repeat(100),
|
||||
sql_loaded: true,
|
||||
@@ -5592,10 +5637,12 @@ mod tests {
|
||||
let summary = storage.load_saved_sql_library_summary().await.unwrap();
|
||||
assert_eq!(summary.files.len(), 1);
|
||||
assert_eq!(summary.files[0].sql, "");
|
||||
assert_eq!(summary.files[0].catalog.as_deref(), Some("hive"));
|
||||
assert!(!summary.files[0].sql_loaded);
|
||||
|
||||
let loaded = storage.load_saved_sql_file("sql-1").await.unwrap().unwrap();
|
||||
assert_eq!(loaded.sql, file.sql);
|
||||
assert_eq!(loaded.catalog.as_deref(), Some("hive"));
|
||||
assert!(loaded.sql_loaded);
|
||||
}
|
||||
|
||||
@@ -5609,6 +5656,7 @@ mod tests {
|
||||
folder_id: None,
|
||||
name: "query.sql".to_string(),
|
||||
database: "main".to_string(),
|
||||
catalog: None,
|
||||
schema: None,
|
||||
sql: "SELECT 1;".to_string(),
|
||||
sql_loaded: true,
|
||||
|
||||
@@ -22,6 +22,7 @@ test("external SQL saves persist their selected data source before closing", ()
|
||||
assert.ok(write >= 0);
|
||||
assert.ok(write < remember);
|
||||
assert.ok(remember < close);
|
||||
assert.ok(source.includes("catalog: tab.catalog"));
|
||||
});
|
||||
|
||||
test("external SQL open entry points restore a saved data source", () => {
|
||||
@@ -30,17 +31,26 @@ test("external SQL open entry points restore a saved data source", () => {
|
||||
const startupTabOpen = startupOpen.indexOf("queryStore.openExternalSqlFile");
|
||||
assert.ok(startupResolve >= 0);
|
||||
assert.ok(startupResolve < startupTabOpen);
|
||||
assert.ok(startupOpen.includes("snapshot.version, target.catalog"));
|
||||
|
||||
const panelOpen = functionSource(sqlFilePanelSource, "async function openFile", "function executeFile");
|
||||
const panelResolve = panelOpen.indexOf("resolveExternalSqlFileTarget");
|
||||
const panelTabOpen = panelOpen.indexOf("queryStore.openExternalSqlFile");
|
||||
assert.ok(panelResolve >= 0);
|
||||
assert.ok(panelResolve < panelTabOpen);
|
||||
assert.ok(panelOpen.includes("snapshot.version, target.catalog"));
|
||||
|
||||
const pickerOpen = functionSource(appSource, "async function openSqlFile()", "async function importResultArchive");
|
||||
assert.ok(pickerOpen.includes("applyExternalSqlFileTarget(tab, sqlPath)"));
|
||||
});
|
||||
|
||||
test("external SQL catalog changes persist their complete target", () => {
|
||||
const source = functionSource(appSource, "function changeActiveCatalog", "async function setActiveDatabaseAsDefault");
|
||||
assert.ok(source.includes("queryStore.updateCatalog(tab.id, catalog, database)"));
|
||||
assert.ok(source.includes("rememberExternalSqlFileTarget"));
|
||||
assert.ok(source.includes("{ connectionId: tab.connectionId, database, catalog }"));
|
||||
});
|
||||
|
||||
test("external SQL overwrite and recreate actions keep checked-write preconditions", () => {
|
||||
const prepareSave = functionSource(externalSqlFileChangesSource, "async function prepareSave", "\n watch(");
|
||||
assert.ok(prepareSave.includes("expectedContentHash: change.snapshot.version.contentHash"));
|
||||
|
||||
+15
-2
@@ -225,18 +225,30 @@ test("external SQL files use full paths as tab identity", () => {
|
||||
assert.equal(store.tabs.find((tab) => tab.id === learnId)?.sql, "select 'learn';");
|
||||
});
|
||||
|
||||
test("external SQL files restore same-named databases in different catalogs", () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
|
||||
const hiveId = store.openExternalSqlFile("conn-1", "sales", "/work/hive.sql", "select 1;", undefined, "hive");
|
||||
const icebergId = store.openExternalSqlFile("conn-1", "sales", "/work/iceberg.sql", "select 2;", undefined, "iceberg");
|
||||
|
||||
assert.equal(store.tabs.find((tab) => tab.id === hiveId)?.catalog, "hive");
|
||||
assert.equal(store.tabs.find((tab) => tab.id === icebergId)?.catalog, "iceberg");
|
||||
});
|
||||
|
||||
test("reopening an external SQL path preserves unsaved editor content", () => {
|
||||
setActivePinia(createPinia());
|
||||
const store = useQueryStore();
|
||||
const tabId = store.openExternalSqlFile("conn-1", "db", "C:\\work\\draft.sql", "select 1;");
|
||||
store.updateSql(tabId, "select 2;");
|
||||
|
||||
const reopenedId = store.openExternalSqlFile("conn-2", "other", "C:/work/draft.sql", "select 3;");
|
||||
const reopenedId = store.openExternalSqlFile("conn-2", "other", "C:/work/draft.sql", "select 3;", undefined, "iceberg");
|
||||
|
||||
assert.equal(reopenedId, tabId);
|
||||
assert.equal(store.tabs.length, 1);
|
||||
assert.equal(store.tabs[0].sql, "select 2;");
|
||||
assert.equal(store.tabs[0].connectionId, "conn-1");
|
||||
assert.equal(store.tabs[0].catalog, undefined);
|
||||
});
|
||||
|
||||
test("external SQL titles collapse after a duplicate filename tab closes", () => {
|
||||
@@ -324,6 +336,7 @@ test("saved SQL opens with its saved execution target by default", async () => {
|
||||
name: "saved.sql",
|
||||
database: "saved_database",
|
||||
schema: "saved_schema",
|
||||
catalog: "saved_catalog",
|
||||
sql: "SELECT 1;",
|
||||
sqlLoaded: true,
|
||||
createdAt: "2026-07-30T00:00:00.000Z",
|
||||
@@ -334,7 +347,7 @@ test("saved SQL opens with its saved execution target by default", async () => {
|
||||
assert.equal(tab?.connectionId, "saved-connection");
|
||||
assert.equal(tab?.database, "saved_database");
|
||||
assert.equal(tab?.schema, "saved_schema");
|
||||
assert.equal(tab?.catalog, undefined);
|
||||
assert.equal(tab?.catalog, "saved_catalog");
|
||||
} finally {
|
||||
await nextTick();
|
||||
restoreStorage();
|
||||
|
||||
+31
-1
@@ -142,6 +142,7 @@ test("changing a saved SQL execution target persists the latest target", async (
|
||||
connectionId: "conn-1",
|
||||
name: "query.sql",
|
||||
database: "db-1",
|
||||
catalog: "hive",
|
||||
schema: "public",
|
||||
sql: "SELECT 1;",
|
||||
sqlLoaded: true,
|
||||
@@ -156,12 +157,14 @@ test("changing a saved SQL execution target persists the latest target", async (
|
||||
await store.updateFileExecutionTarget("sql-target", {
|
||||
connectionId: "conn-2",
|
||||
database: "db-2",
|
||||
catalog: "iceberg",
|
||||
schema: "app",
|
||||
});
|
||||
|
||||
const saved = apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0];
|
||||
assert.equal(saved?.connectionId, "conn-2");
|
||||
assert.equal(saved?.database, "db-2");
|
||||
assert.equal(saved?.catalog, "iceberg");
|
||||
assert.equal(saved?.schema, "app");
|
||||
assert.equal(saved?.sql, file.sql);
|
||||
assert.equal(saved?.name, file.name);
|
||||
@@ -169,9 +172,10 @@ test("changing a saved SQL execution target persists the latest target", async (
|
||||
{
|
||||
connectionId: store.getFile("sql-target")?.connectionId,
|
||||
database: store.getFile("sql-target")?.database,
|
||||
catalog: store.getFile("sql-target")?.catalog,
|
||||
schema: store.getFile("sql-target")?.schema,
|
||||
},
|
||||
{ connectionId: "conn-2", database: "db-2", schema: "app" },
|
||||
{ connectionId: "conn-2", database: "db-2", catalog: "iceberg", schema: "app" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -202,6 +206,32 @@ test("rapid saved SQL target changes persist only the latest target", async () =
|
||||
assert.equal(store.getFile("sql-target")?.connectionId, "conn-3");
|
||||
});
|
||||
|
||||
test("changing only the saved SQL catalog persists the new catalog", async () => {
|
||||
const file: SavedSqlFile = {
|
||||
id: "sql-catalog-target",
|
||||
connectionId: "conn-1",
|
||||
name: "query.sql",
|
||||
database: "sales",
|
||||
catalog: "hive",
|
||||
sql: "SELECT 1;",
|
||||
sqlLoaded: true,
|
||||
createdAt: "2026-06-27T00:00:00.000Z",
|
||||
updatedAt: "2026-06-27T00:00:00.000Z",
|
||||
};
|
||||
apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] });
|
||||
|
||||
const store = useSavedSqlStore();
|
||||
await store.initFromStorage();
|
||||
await store.updateFileExecutionTarget("sql-catalog-target", {
|
||||
connectionId: "conn-1",
|
||||
database: "sales",
|
||||
catalog: "iceberg",
|
||||
});
|
||||
|
||||
assert.equal(apiMock.saveSavedSqlFile.mock.calls.at(-1)?.[0].catalog, "iceberg");
|
||||
assert.equal(store.getFile("sql-catalog-target")?.catalog, "iceberg");
|
||||
});
|
||||
|
||||
test("saved SQL target changes roll back when persistence fails", async () => {
|
||||
const file: SavedSqlFile = {
|
||||
id: "sql-target",
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ test("SQL library directory imports preserve nested folder paths", () => {
|
||||
const end = source.indexOf("async function chooseSyncDirectory", start);
|
||||
const handler = start >= 0 && end > start ? source.slice(start, end) : "";
|
||||
|
||||
assert.match(handler, /resolveImportedFolder\(connectionId, targetFolder\?\.id, file\.folderNames, folderCache\)/);
|
||||
assert.match(handler, /resolveImportedFolder\(folderConnectionId, targetFolder\?\.id, file\.folderNames, folderCache\)/);
|
||||
assert.match(handler, /uniqueImportedName\(file\.name, takenNames\)/);
|
||||
assert.doesNotMatch(handler, /relativeImportName/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user