diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index b7266a3930..7af7209271 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -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 { 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 { 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)), diff --git a/apps/desktop/src/components/layout/SqlFilePanel.vue b/apps/desktop/src/components/layout/SqlFilePanel.vue index 981bd01a0a..e8d924f5bd 100644 --- a/apps/desktop/src/components/layout/SqlFilePanel.vue +++ b/apps/desktop/src/components/layout/SqlFilePanel.vue @@ -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(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(null); +const selectedPaths = ref>(new Set()); +const activePath = ref(null); +const selectionAnchorIndex = ref(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): TreeEntry[] { return result; } +const visibleItems = computed(() => { + 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(() => { const target = contextTarget.value; @@ -365,12 +438,12 @@ function clearContextTarget() {