From 5f60fbe19647360fc3f276865d242ac6b32b6b82 Mon Sep 17 00:00:00 2001 From: zhangyc Date: Thu, 13 Aug 2026 15:26:45 +0800 Subject: [PATCH] feat(sidebar): manage saved SQL by database --- apps/desktop/src/App.vue | 72 ++-- .../src/components/layout/SqlLibraryPanel.vue | 76 ++-- .../src/components/sidebar/ConnectionTree.vue | 87 +++- .../sidebar/SidebarTreeRuntimeHost.vue | 147 ++++++- .../src/components/sidebar/TreeItem.vue | 59 ++- apps/desktop/src/i18n/locales/en.ts | 10 + apps/desktop/src/i18n/locales/es.ts | 10 + apps/desktop/src/i18n/locales/it.ts | 10 + apps/desktop/src/i18n/locales/ja.ts | 10 + apps/desktop/src/i18n/locales/ko.ts | 10 + apps/desktop/src/i18n/locales/pt-BR.ts | 10 + apps/desktop/src/i18n/locales/zh-CN.ts | 10 + apps/desktop/src/i18n/locales/zh-TW.ts | 10 + .../savedSql/savedSqlClipboard.spec.ts | 32 ++ .../savedSql/savedSqlDatabaseTree.spec.ts | 98 +++++ .../sidebar/sidebarActiveTabTarget.spec.ts | 11 + .../sidebar/treeLoadedChildrenMarker.spec.ts | 15 + .../src/lib/savedSql/savedSqlClipboard.ts | 35 ++ .../src/lib/savedSql/savedSqlDatabaseTree.ts | 118 ++++++ .../src/lib/savedSql/savedSqlErrors.ts | 18 + .../src/lib/savedSql/savedSqlExport.ts | 34 ++ .../src/lib/savedSql/savedSqlHistory.ts | 3 + .../src/lib/sidebar/sidebarActiveTabTarget.ts | 3 + .../lib/sidebar/treeLoadedChildrenMarker.ts | 4 +- apps/desktop/src/lib/sidebar/treeNodeClick.ts | 3 +- .../src/lib/sql/queryCursorTableTarget.ts | 1 + .../connectionStore.dorisCatalog.spec.ts | 5 +- .../connectionStore.metadataLoading.spec.ts | 24 +- apps/desktop/src/stores/connectionStore.ts | 36 +- apps/desktop/src/stores/savedSqlStore.ts | 238 +++++++++-- apps/desktop/src/types/database.ts | 1 + crates/dbx-core/src/saved_sql.rs | 24 ++ crates/dbx-core/src/storage.rs | 65 +++ .../app-tests/queryCursorTableTarget.test.ts | 1 + packages/app-tests/savedSqlErrors.test.ts | 16 + packages/app-tests/savedSqlHistory.test.ts | 10 + packages/app-tests/savedSqlStore.test.ts | 398 ++++++++++++++++++ .../app-tests/sidebarActiveTabTarget.test.ts | 30 +- .../app-tests/sidebarContextMenuHost.test.ts | 33 ++ packages/app-tests/treeNodeClick.test.ts | 4 + 40 files changed, 1631 insertions(+), 150 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/savedSql/savedSqlClipboard.spec.ts create mode 100644 apps/desktop/src/lib/__tests__/savedSql/savedSqlDatabaseTree.spec.ts create mode 100644 apps/desktop/src/lib/savedSql/savedSqlClipboard.ts create mode 100644 apps/desktop/src/lib/savedSql/savedSqlDatabaseTree.ts create mode 100644 apps/desktop/src/lib/savedSql/savedSqlErrors.ts create mode 100644 apps/desktop/src/lib/savedSql/savedSqlExport.ts create mode 100644 packages/app-tests/savedSqlErrors.test.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index a668cdb488..ce8528e3e3 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -101,6 +101,7 @@ import { apiUrl, webPath } from "@/lib/common/webPath"; import { shouldBlockAppNativeSelectAll } from "@/lib/common/clipboard"; import { APP_FONT_SANS_CSS_VAR, DATA_GRID_FONT_FAMILY_CSS_VAR, DEFAULT_DATA_GRID_FONT_FAMILY, DEFAULT_UI_FONT_FAMILY } from "@/lib/app/appFonts"; import { rankSavedSqlHistory } from "@/lib/savedSql/savedSqlHistory"; +import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { savedSqlDefaultTargetForWrite } from "@/lib/savedSql/savedSqlExecutionTarget"; import { countActiveUpdateBlockingTasks } from "@/lib/app/appUpdateTaskGuard"; import { initSavedSqlEditorPositions } from "@/lib/app/savedSqlEditorPosition"; @@ -1088,7 +1089,7 @@ async function saveTabForCloseAll(tabId: string): Promise { queryStore.markTabClean(tab); return true; } catch (e: any) { - toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000); + toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(e, t) }), 5000); return false; } } @@ -1140,21 +1141,26 @@ async function handleSaveTab(tabId: string) { } const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { - const target = savedSqlTargetForSave(tab); - const updated = await savedSqlStore.saveFile({ - id: existing.id, - connectionId: target.connectionId, - folderId: existing.folderId, - name: existing.name, - database: target.database, - catalog: target.catalog, - schema: target.schema, - sql: tab.sql, - }); - queryStore.linkSavedSql(tab.id, updated.id, updated.name); - queryStore.markTabClean(tab); - notifySqlLibrarySaved(); - completePendingTabSave(tabId); + try { + const target = savedSqlTargetForSave(tab); + const updated = await savedSqlStore.saveFile({ + id: existing.id, + connectionId: target.connectionId, + folderId: existing.folderId, + name: existing.name, + database: target.database, + catalog: target.catalog, + schema: target.schema, + sql: tab.sql, + }); + queryStore.linkSavedSql(tab.id, updated.id, updated.name); + queryStore.markTabClean(tab); + notifySqlLibrarySaved(); + completePendingTabSave(tabId); + } catch (error) { + toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, t) }), 5000); + queryStore.resumeCloseConfirm(); + } return; } // No existing saved SQL — open save dialog, then close after save @@ -1177,20 +1183,24 @@ async function openSaveSqlDialog() { if (await saveExternalSqlPath(tab)) return; const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { - const target = savedSqlTargetForSave(tab); - const updated = await savedSqlStore.saveFile({ - id: existing.id, - connectionId: target.connectionId, - folderId: existing.folderId, - name: existing.name, - database: target.database, - catalog: target.catalog, - schema: target.schema, - sql: tab.sql, - }); - queryStore.linkSavedSql(tab.id, updated.id, updated.name); - queryStore.markTabClean(tab); - notifySqlLibrarySaved(); + try { + const target = savedSqlTargetForSave(tab); + const updated = await savedSqlStore.saveFile({ + id: existing.id, + connectionId: target.connectionId, + folderId: existing.folderId, + name: existing.name, + database: target.database, + catalog: target.catalog, + schema: target.schema, + sql: tab.sql, + }); + queryStore.linkSavedSql(tab.id, updated.id, updated.name); + queryStore.markTabClean(tab); + notifySqlLibrarySaved(); + } catch (error) { + toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, t) }), 5000); + } return; } @@ -1290,7 +1300,7 @@ async function confirmSaveSqlToLibrary() { closePendingSavedTab(); void notifyNewSqlLibrarySaved(flyOrigin); } catch (e: any) { - toast(t("savedSql.saveFailed", { message: e?.message || String(e) }), 5000); + toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(e, t) }), 5000); } } diff --git a/apps/desktop/src/components/layout/SqlLibraryPanel.vue b/apps/desktop/src/components/layout/SqlLibraryPanel.vue index 253b40be59..fed60aa971 100644 --- a/apps/desktop/src/components/layout/SqlLibraryPanel.vue +++ b/apps/desktop/src/components/layout/SqlLibraryPanel.vue @@ -18,9 +18,11 @@ import { useSettingsStore } from "@/stores/settingsStore"; import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; import { savedSqlFolderBranchFileCount } from "@/lib/savedSql/savedSqlFolderCounts"; import { collectSavedSqlDirectoryImportFiles } from "@/lib/savedSql/savedSqlDirectoryImport"; +import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; import { savedSqlImportTarget } from "@/lib/savedSql/savedSqlImportTarget"; import { savedSqlExecutionTargetFromTab, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; +import { exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport"; import { orderedListRangeAnchorIndex, orderedListSelectionIntent } from "@/lib/selection/orderedListSelection"; import { resolveExternalSqlFileTarget, unassociatedExternalSqlFileTarget } from "@/lib/sql/externalSqlFileTarget"; import type { SavedSqlFile, SavedSqlFolder } from "@/types/database"; @@ -74,7 +76,8 @@ function sanitizeFileSystemSegment(name: string) { function uniqueImportedName(name: string, takenNames: Set) { const normalized = ensureSqlExtension(name); - if (!takenNames.has(normalized)) { + const normalizedTakenNames = new Set([...takenNames].map((takenName) => ensureSqlExtension(takenName).toLocaleLowerCase())); + if (!normalizedTakenNames.has(normalized.toLocaleLowerCase())) { takenNames.add(normalized); return normalized; } @@ -83,7 +86,7 @@ function uniqueImportedName(name: string, takenNames: Set) { let counter = 2; while (true) { const candidate = `${base} (${counter}).sql`; - if (!takenNames.has(candidate)) { + if (!normalizedTakenNames.has(candidate.toLocaleLowerCase())) { takenNames.add(candidate); return candidate; } @@ -91,34 +94,12 @@ function uniqueImportedName(name: string, takenNames: Set) { } } -async function downloadText(content: string, fileName: string) { - const blob = new Blob([content], { type: "text/plain;charset=utf-8" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = fileName; - anchor.click(); - URL.revokeObjectURL(url); -} - async function exportSingleFile(file: SavedSqlFile) { try { const loadedFile = await savedSqlStore.ensureFileContent(file.id); if (!loadedFile) return; - const defaultFileName = sanitizeFileSystemSegment(ensureSqlExtension(file.name)); - if (isTauriRuntime()) { - const { save } = await import("@tauri-apps/plugin-dialog"); - const { writeTextFile } = await import("@tauri-apps/plugin-fs"); - const path = await save({ - defaultPath: defaultFileName, - filters: [{ name: "SQL", extensions: ["sql"] }], - }); - if (!path) return; - await writeTextFile(path, loadedFile.sql); - } else { - await downloadText(loadedFile.sql, defaultFileName); - } - toast(t("sqlLibrary.exported"), 2000); + const result = await exportSavedSqlFileContent(loadedFile.sql, file.name); + if (result === "saved") toast(t("sqlLibrary.exported"), 2000); } catch (e: any) { toast(t("sqlLibrary.exportFailed", { message: e?.message || String(e) }), 5000); } @@ -263,7 +244,8 @@ async function importDirectoryIntoLibrary(targetFolder?: SavedSqlFolder) { toast(t("sqlLibrary.imported", { count: importFiles.length }), 2500); } catch (e: any) { - toast(t("sqlLibrary.importFailed", { message: externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)) }), 5000); + const message = e?.code === "SAVED_SQL_NAME_CONFLICT" ? savedSqlErrorMessage(e, t) : externalSqlFileOpenErrorMessage(e, (key, params) => t(key, params)); + toast(t("sqlLibrary.importFailed", { message }), 5000); } } @@ -427,15 +409,19 @@ async function openNewQueryInFolder(folder?: SavedSqlFolder) { const takenNames = folder ? new Set(savedSqlStore.filesInFolder(folder.id).map((f) => f.name)) : new Set(savedSqlStore.filesWithoutFolder().map((f) => f.name)); const name = uniqueImportedName("new_query.sql", takenNames); - const file = await savedSqlStore.saveFile({ - connectionId, - folderId: folder?.id, - name, - database: "", - sql: "", - }); - const tabId = queryStore.openSavedSql(file); - connectionStore.activeConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? file.connectionId; + try { + const file = await savedSqlStore.saveFile({ + connectionId, + folderId: folder?.id, + name, + database: "", + sql: "", + }); + const tabId = queryStore.openSavedSql(file); + connectionStore.activeConnectionId = queryStore.tabs.find((tab) => tab.id === tabId)?.connectionId ?? file.connectionId; + } catch (error) { + toast(t("savedSql.saveFailed", { message: savedSqlErrorMessage(error, t) }), 5000); + } } // Batch selection state @@ -631,7 +617,11 @@ async function confirmRename() { if (type === "folder") { await savedSqlStore.renameFolder(id, name); } else { - await savedSqlStore.renameFile(id, ensureSqlExtension(name)); + try { + await savedSqlStore.renameFile(id, ensureSqlExtension(name)); + } catch (error) { + toast(t("savedSql.renameFailed", { message: savedSqlErrorMessage(error, t) }), 5000); + } } } @@ -688,9 +678,13 @@ async function executeBatchDelete() { async function moveFilesToFolder(fileIds: string[], folderId?: string) { const movableIds = [...new Set(fileIds)].filter((id) => savedSqlStore.getFile(id)); if (movableIds.length === 0) return; - await savedSqlStore.moveFilesToFolder(movableIds, folderId); - clearSelection(); - toast(t("sqlLibrary.moveSuccess", { count: movableIds.length }), 2000); + try { + await savedSqlStore.moveFilesToFolder(movableIds, folderId); + clearSelection(); + toast(t("sqlLibrary.moveSuccess", { count: movableIds.length }), 2000); + } catch (error) { + toast(t("sqlLibrary.moveFailed", { message: savedSqlErrorMessage(error, t) }), 5000); + } } async function openFile(file: SavedSqlFile, targetMode?: SavedSqlOpenTargetMode) { @@ -1045,7 +1039,7 @@ function onDocumentMouseUp() { const dropPromise = hadActiveDrag ? performDrop() : Promise.resolve(); if (hadActiveDrag) markSuppressedClick(); resetDragState(); - void dropPromise; + void dropPromise.catch((error) => toast(t("sqlLibrary.moveFailed", { message: savedSqlErrorMessage(error, t) }), 5000)); } document.addEventListener("mousemove", onDocumentMouseMove, true); diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index df7f60e8e6..b6fe513379 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -4,6 +4,7 @@ import { useI18n } from "vue-i18n"; import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, CircleDot, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw, Loader2, Unplug } from "@lucide/vue"; import { useConnectionStore } from "@/stores/connectionStore"; import { useQueryStore } from "@/stores/queryStore"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useToast } from "@/composables/useToast"; import type { ObjectSourceKind, TableInfo, TableNameFilter, TreeNode, TreeNodeType } from "@/types/database"; @@ -17,7 +18,7 @@ import { supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilit import { copyToClipboard } from "@/lib/common/clipboard"; import { connectionPasteTargetGroupId, copySelectedConnectionsToClipboards, selectedConnectionEditTarget } from "@/lib/sidebar/sidebarConnectionSelection"; import { isEditableSidebarTypeSearchTarget, sidebarTypeSearchNextQuery } from "@/lib/sidebar/sidebarTypeSearch"; -import { usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; +import { isInternalDorisCatalog, usesTreeSchemaMode } from "@/lib/database/databaseFeatureSupport"; import { connectionObjectTreeNodeSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect"; import { activeTabSidebarTarget, findSidebarNodeForActiveTab, findSidebarNodeForTarget, findNodePathForTarget, scrollTopForSidebarNode, shouldScrollActiveSidebarSelection, type ActiveTabSidebarTarget, type SidebarNodeScrollAlign } from "@/lib/sidebar/sidebarActiveTabTarget"; import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget"; @@ -57,6 +58,7 @@ import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDis const { t } = useI18n(); const store = useConnectionStore(); const queryStore = useQueryStore(); +const savedSqlStore = useSavedSqlStore(); const settingsStore = useSettingsStore(); const { toast } = useToast(); const searchQuery = ref(""); @@ -961,7 +963,7 @@ function bindSidebarTreeRuntimeHost(host: Element | ComponentPublicInstance | nu sidebarTreeRuntime.bindHost(runtimeHost); } -const pendingRenameGroupId = ref(null); +const pendingRenameNodeId = ref(null); const highlightedNodeId = ref(null); let highlightTimer: number | undefined; @@ -1026,7 +1028,7 @@ async function createNewGroup() { } async function startRenamingCreatedGroup(groupId: string) { - pendingRenameGroupId.value = groupId; + pendingRenameNodeId.value = groupId; store.selectedTreeNodeId = groupId; if (isRootListPartial.value) { searchQuery.value = ""; @@ -1039,11 +1041,22 @@ async function startRenamingCreatedGroup(groupId: string) { store.selectedTreeNodeId = groupId; } +async function startRenamingSavedSqlNode(nodeId: string) { + pendingRenameNodeId.value = nodeId; + store.selectedTreeNodeId = nodeId; + store.selectedTreeNodeIds = [nodeId]; + await scrollToSidebarNode(nodeId); + store.selectedTreeNodeId = nodeId; +} + async function locateActiveTabInSidebar() { const tab = activeTab.value; if (!tab) return; - const connId = tab.connectionId; + const tabTarget = activeTabSidebarTarget(tab); + const locatesSavedSql = tabTarget?.type === "saved-sql-file"; + const savedSqlFile = locatesSavedSql ? savedSqlStore.getFile(tabTarget.savedSqlId) : undefined; + const connId = savedSqlFile?.connectionId ?? tab.connectionId; // Reconnect if the connection was disconnected (children are cleared on disconnect) if (connId && !store.connectedIds.has(connId)) { @@ -1057,13 +1070,24 @@ async function locateActiveTabInSidebar() { } const config = connId ? store.getConfig(connId) : undefined; - const cursorCandidate = queryCursorTableCandidate(tab, effectiveDatabaseTypeForConnection(config)); - const fallbackTarget = queryContextTargetFromCandidate(tab, cursorCandidate) ?? activeTabSidebarTarget(tab); + const cursorCandidate = locatesSavedSql ? null : queryCursorTableCandidate(tab, effectiveDatabaseTypeForConnection(config)); + const fallbackTarget = locatesSavedSql ? tabTarget : (queryContextTargetFromCandidate(tab, cursorCandidate) ?? tabTarget); const initialTarget = cursorCandidate ? tableTargetFromCandidate(cursorCandidate) : fallbackTarget; if (!initialTarget) return; // Ensure the tree is loaded deep enough to contain the preferred target. - await ensureTreeLoadedForTarget(initialTarget); + // Saved SQL rows live below their database's runtime Queries node. Loading + // the database context first also makes explicit locate work after reconnect. + const treeLoadTarget: ActiveTabSidebarTarget = + locatesSavedSql && savedSqlFile?.connectionId && savedSqlFile.database + ? { + type: "query-context", + connectionId: savedSqlFile.connectionId, + catalog: savedSqlFile.catalog, + database: savedSqlFile.database, + } + : initialTarget; + await ensureTreeLoadedForTarget(treeLoadTarget); // Clear any active search filter so the node is visible if (isRootListPartial.value) { @@ -1075,12 +1099,12 @@ async function locateActiveTabInSidebar() { let target = resolveLoadedLocateTarget(initialTarget, cursorCandidate); let nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null; - if (!nodePath) { + if (!nodePath && !locatesSavedSql) { // The first load may have served a stale schema cache whose async refresh // replaced the database node before its tables finished loading, so the // table isn't in the tree yet. Force a synchronous reload and retry once so // locate reaches the table, not just the database (issue #715). - await ensureTreeLoadedForTarget(initialTarget, { force: true }); + await ensureTreeLoadedForTarget(treeLoadTarget, { force: true }); target = resolveLoadedLocateTarget(initialTarget, cursorCandidate); nodePath = target ? findNodePathForTarget(target, store.treeNodes) : null; } @@ -1180,8 +1204,21 @@ async function ensureTreeLoadedForTarget(target: ActiveTabSidebarTarget, opts?: if (config.db_type === "mq" || config.db_type === "nacos" || config.db_type === "consul") return; if (!("database" in target) || !target.database) return; + const usesExactCatalogScope = target.type === "query-context"; + const targetCatalog = usesExactCatalogScope ? target.catalog : undefined; + if (usesExactCatalogScope) { + const catalogNode = findDorisCatalogNode(store.treeNodes, connId, targetCatalog); + if (catalogNode && (force || !catalogNode.children || catalogNode.children.length === 0)) { + try { + await store.loadDorisCatalogDatabases(catalogNode, loadOptions); + } catch { + return; + } + } + } + // Find the database node - const dbNode = findDatabaseNode(store.treeNodes, connId, target.database); + const dbNode = findDatabaseNode(store.treeNodes, connId, target.database, targetCatalog, usesExactCatalogScope); if (!dbNode) return; const targetSchema = "schema" in target ? target.schema : undefined; const databaseChildrenLoaded = !!dbNode.children && dbNode.children.length > 0; @@ -1250,13 +1287,28 @@ function sameTreeName(left: string | undefined, right: string | undefined): bool return (left || "").toLowerCase() === (right || "").toLowerCase(); } -function findDatabaseNode(nodes: TreeNode[], connId: string, database: string): TreeNode | null { +function findDorisCatalogNode(nodes: TreeNode[], connId: string, catalog: string | undefined): TreeNode | null { for (const node of nodes) { - if (node.type === "database" && node.connectionId === connId && sameTreeName(node.database, database)) { + if (node.type === "doris-catalog" && node.connectionId === connId) { + const matches = catalog ? sameTreeName(node.catalog, catalog) : isInternalDorisCatalog(node.catalogType, node.catalog); + if (matches) return node; + } + if (node.children) { + const found = findDorisCatalogNode(node.children, connId, catalog); + if (found) return found; + } + } + return null; +} + +function findDatabaseNode(nodes: TreeNode[], connId: string, database: string, catalog?: string, exactCatalog = false): TreeNode | null { + for (const node of nodes) { + const catalogMatches = !exactCatalog || (catalog ? sameTreeName(node.catalog, catalog) : !node.catalog); + if (node.type === "database" && node.connectionId === connId && sameTreeName(node.database, database) && catalogMatches) { return node; } if (node.children) { - const found = findDatabaseNode(node.children, connId, database); + const found = findDatabaseNode(node.children, connId, database, catalog, exactCatalog); if (found) return found; } } @@ -1822,6 +1874,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); @open-visible-nacos-namespaces="openSidebarVisibleNacosNamespaces" @open-table-name-filters="openSidebarTableNameFilters" @request-group-rename="startRenamingCreatedGroup" + @request-saved-sql-rename="startRenamingSavedSqlNode" @open-danger-dialog="openSidebarDangerDialog" @open-dialog-controller="updateSidebarTreeItemDialogController" @open-install-extension="openSidebarInstallExtension" @@ -1933,11 +1986,11 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); :node="item.node" :depth="item.depth" :reorder-disabled="isRootListPartial || isConnectionListAlphabeticallySorted" - :pending-rename="pendingRenameGroupId === item.node.id" + :pending-rename="pendingRenameNodeId === item.node.id" :highlighted="highlightedNodeId === item.node.id" :comment-label-width="sidebarCommentLabelWidths.get(item.node.id)" @context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)" - @rename-started="pendingRenameGroupId = null" + @rename-started="pendingRenameNodeId = null" @group-created="startRenamingCreatedGroup" /> @@ -1980,11 +2033,11 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); :node="item.node" :depth="item.depth" :reorder-disabled="isRootListPartial || isConnectionListAlphabeticallySorted" - :pending-rename="pendingRenameGroupId === item.node.id" + :pending-rename="pendingRenameNodeId === item.node.id" :highlighted="highlightedNodeId === item.id" :comment-label-width="sidebarCommentLabelWidths.get(item.node.id)" @context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)" - @rename-started="pendingRenameGroupId = null" + @rename-started="pendingRenameNodeId = null" @group-created="startRenamingCreatedGroup" /> diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 69d5e9eb6a..0c4aca1d1c 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -62,6 +62,7 @@ import { CONNECTION_ATTEMPT_CANCELLED_MESSAGE, useConnectionStore } from "@/stor import { useQueryStore } from "@/stores/queryStore"; import { useSettingsStore } from "@/stores/settingsStore"; import { useSavedSqlStore } from "@/stores/savedSqlStore"; +import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; import { useToast } from "@/composables/useToast"; import { useDatabaseOptions } from "@/composables/useDatabaseOptions"; import type { ColumnInfo, DatabaseType, TreeNode, TreeNodeType } from "@/types/database"; @@ -157,6 +158,8 @@ import { sidebarStructureExportTargets } from "@/lib/sidebar/sidebarExportRuntim import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { copyToClipboard } from "@/lib/common/clipboard"; import { rankSavedSqlHistory, type SavedSqlHistoryScope } from "@/lib/savedSql/savedSqlHistory"; +import { savedSqlClipboardFileIds, savedSqlPasteTargetForNode } from "@/lib/savedSql/savedSqlClipboard"; +import { exportSavedSqlFileContent } from "@/lib/savedSql/savedSqlExport"; import { isSqlServerLinkedNode } from "@/lib/database/sqlServerLinkedServers"; import { flattenTree } from "@/composables/useFlatTree"; import { createDatabaseCollationOptionsForCharset, nextCreateDatabaseCollation, normalizeCreateDatabaseCharset, parseCreateDatabaseCharsetMetadata } from "@/lib/database/createDatabaseCharsetOptions"; @@ -333,6 +336,7 @@ const emit = defineEmits<{ "rename-started": []; "group-created": [groupId: string]; "request-group-rename": [groupId: string]; + "request-saved-sql-rename": [nodeId: string]; "node-toggled": [node: TreeNode, expanded: boolean]; "search-toggle": [node: TreeNode]; "context-menu": [event: MouseEvent, node: TreeNode, items: ContextMenuItem[]]; @@ -627,6 +631,12 @@ async function toggle() { return; } + if (node.type === "saved-sql-root" || node.type === "saved-sql-folder") { + node.isExpanded = !node.isExpanded; + emitNodeToggled(node, wasExpanded); + return; + } + if (node.type === "group-extensions" && connectionStore.canUseLoadedTreeNodeToggle(node)) { node.isExpanded = !node.isExpanded; if (wasExpanded && !connectionStore.sidebarSearchQuery) connectionStore.releaseCollapsedTreeNodeChildren(node.id); @@ -846,6 +856,8 @@ function runRowClickAction(clickDetail: number) { openObjectSourceDialog(false); } else if (action === "open-extension-details") { emit("open-extension-details", node); + } else if (action === "open-saved-sql") { + void openSavedSqlFile(); } else if (isDocumentBrowserTreeNode(node.type)) { openMongoTreeData(node); } else if (action === "toggle") { @@ -1014,11 +1026,23 @@ function openTransferFromTreeClipboard(): boolean { } function requestPasteTreeClipboard(): boolean { - if (currentDatabaseType() === "victoriametrics") return false; claimTreeItemDialogOwnership(); ensureDangerDialogRouting(); routeTreeItemDialogController(); const clipboard = connectionStore.treeClipboard; + if (clipboard?.kind === "saved-sql-copy") { + const target = savedSqlPasteTargetForNode(activeNode.value); + if (!target || clipboard.fileIds.length === 0) return false; + void savedSqlStore + .copyFilesToDatabase(clipboard.fileIds, target) + .then((files) => { + if (files.length > 0) toast(t("savedSql.pasted", { count: files.length }), 2000); + else toast(t("savedSql.nothingToPaste"), 3000); + }) + .catch((e: unknown) => toast(t("savedSql.pasteFailed", { message: savedSqlErrorMessage(e, t) }), 5000)); + return true; + } + if (currentDatabaseType() === "victoriametrics") return false; if (clipboard?.kind === "connection-copy") { const targetGroupId = connectionPasteTargetGroupId(activeNode.value, (connectionId) => connectionStore.groupIdForConnection(connectionId)); void connectionStore @@ -1079,6 +1103,10 @@ function requestRenameSelectedNode(): boolean { startRenameGroup(); return true; } + if (activeNode.value.type === "saved-sql-file" && activeNode.value.savedSqlId) { + emit("request-saved-sql-rename", activeNode.value.id); + return true; + } return false; } @@ -1117,6 +1145,10 @@ function requestDeleteSelectedNode(): boolean { claimTreeItemDialogOwnership(); ensureDangerDialogRouting(); routeTreeItemDialogController(); + if (activeNode.value.type === "saved-sql-file" && activeNode.value.savedSqlId) { + showDeleteSavedSqlConfirm.value = true; + return true; + } if (requestDropSelectedNodes()) return true; if (activeNode.value.type === "connection") { deleteConnection(); @@ -1230,6 +1262,59 @@ async function openSavedSqlFile() { void savedSqlStore.recordFileUsage(file.id); } +async function copySavedSqlFiles() { + const selected = selectedTreeNodesInVisibleOrder(); + const nodes = selected.length > 1 && selected.some((node) => node.id === activeNode.value.id) ? selected : [activeNode.value]; + const fileIds = savedSqlClipboardFileIds(nodes); + if (fileIds.length === 0) return; + connectionStore.treeClipboard = { kind: "saved-sql-copy", fileIds }; + try { + await copyToClipboard( + nodes + .filter((node) => node.type === "saved-sql-file" && !!node.savedSqlId) + .map(copyNameForTreeNode) + .join("\n"), + ); + } catch { + /* Internal SQL clipboard remains usable when the system clipboard is unavailable. */ + } + toast(t("savedSql.copied", { count: fileIds.length }), 2000); +} + +async function exportSavedSqlFile() { + const node = activeNode.value; + if (node.type !== "saved-sql-file" || !node.savedSqlId) return; + try { + const file = await savedSqlStore.ensureFileContent(node.savedSqlId); + if (!file) return; + const result = await exportSavedSqlFileContent(file.sql, file.name); + if (result === "saved") toast(t("sqlLibrary.exported"), 2000); + } catch (e: any) { + toast(t("sqlLibrary.exportFailed", { message: e?.message || String(e) }), 5000); + } +} + +function renameSavedSqlFile() { + const node = activeNode.value; + if (node.type === "saved-sql-file" && node.savedSqlId) emit("request-saved-sql-rename", node.id); +} + +function deleteSavedSqlFile() { + const node = activeNode.value; + if (node.type !== "saved-sql-file" || !node.savedSqlId) return; + claimTreeItemDialogOwnership(); + ensureDangerDialogRouting(); + showDeleteSavedSqlConfirm.value = true; +} + +async function confirmDeleteSavedSqlFile() { + const node = sidebarDangerTarget.value ?? activeNode.value; + if (node.type !== "saved-sql-file" || !node.savedSqlId) return; + await savedSqlStore.deleteFile(node.savedSqlId); + connectionStore.removeTreeNode(node.id); + releaseActiveNodeReference([node.id]); +} + async function openObjectBrowser() { const node = activeNode.value; if (!node.connectionId) return; @@ -1631,6 +1716,22 @@ async function copySelectedNames() { } return; } + const savedSqlFileIds = savedSqlClipboardFileIds(nodes); + if (savedSqlFileIds.length > 0) { + connectionStore.treeClipboard = { kind: "saved-sql-copy", fileIds: savedSqlFileIds }; + try { + await copyToClipboard( + nodes + .filter((node) => node.type === "saved-sql-file" && !!node.savedSqlId) + .map(copyNameForTreeNode) + .join("\n"), + ); + } catch { + /* Internal SQL clipboard remains usable when the system clipboard is unavailable. */ + } + toast(t("savedSql.copied", { count: savedSqlFileIds.length }), 2000); + return; + } updateTreeClipboardForNodes(nodes); try { await copyToClipboard(nodes.map(copyNameForTreeNode).join("\n")); @@ -3603,6 +3704,7 @@ const isMultiSelected = computed(() => connectionStore.selectedTreeNodeIdsSet.ha const dangerDialogRoutes: Array<{ flag: { value: boolean }; createRequest: () => SidebarDangerDialogRequest }> = []; const showHBaseDeleteTableConfirm = shallowRef(false); +const showDeleteSavedSqlConfirm = shallowRef(false); let stopDangerDialogRouting: (() => void) | null = null; @@ -3680,6 +3782,15 @@ routeDangerDialog(showHBaseDeleteTableConfirm, () => { }); }); +routeDangerDialog(showDeleteSavedSqlConfirm, () => + dangerRequest({ + title: t("savedSql.deleteFile"), + message: t("savedSql.deleteFileConfirm", { name: activeNode.value.label }), + confirmLabel: t("dangerDialog.confirm"), + confirm: confirmDeleteSavedSqlFile, + }), +); + routeDangerDialog(showEmptyTableConfirm, () => dangerRequest({ title: t("contextMenu.confirmEmptyTableTitle"), @@ -4065,6 +4176,8 @@ onBeforeUnmount(() => { const shortcutCopyName = computed(() => settingsStore.editorSettings.shortcuts.copySidebarSelection); +const shortcutPaste = computed(() => settingsStore.editorSettings.shortcuts.pasteSidebarSelection); + const shortcutOpenDataInNewTab = computed(() => settingsStore.editorSettings.shortcuts.openDataInNewTab); const shortcutEditConnection = computed(() => settingsStore.editorSettings.shortcuts.editSidebarConnection); @@ -4116,6 +4229,7 @@ function savedSqlHistoryScopeForNode(node: TreeNode): SavedSqlHistoryScope | nul if ((node.type === "database" || node.type === "schema") && hasTreeNodeDatabaseContext(node)) { return { connectionId: node.connectionId, + catalog: node.catalog ?? null, database: node.database, schema: node.type === "schema" ? node.schema : undefined, }; @@ -4123,6 +4237,7 @@ function savedSqlHistoryScopeForNode(node: TreeNode): SavedSqlHistoryScope | nul if ((node.type === "table" || node.type === "view") && hasTreeNodeDatabaseContext(node)) { return { connectionId: node.connectionId, + catalog: node.catalog ?? null, database: node.database, schema: node.schema, tableName: node.label, @@ -4474,6 +4589,36 @@ function buildDatabaseSidebarMenu(context: SidebarMenuFactoryContext): boolean { function buildSpecialSidebarMenu(context: SidebarMenuFactoryContext): boolean { const { node, items } = context; + + if (node.type === "saved-sql-root") { + items.push({ + label: t("savedSql.pasteFile"), + action: () => requestPasteTreeClipboard(), + icon: Clipboard, + shortcut: shortcutPaste.value, + disabled: connectionStore.treeClipboard?.kind !== "saved-sql-copy" || !savedSqlPasteTargetForNode(node), + }); + return true; + } + + if (node.type === "saved-sql-file") { + items.push({ label: t("savedSql.open"), action: openSavedSqlFile, icon: FileCode }); + items.push({ label: "", separator: true }); + items.push({ label: t("savedSql.copyFile"), action: copySavedSqlFiles, icon: Copy, shortcut: shortcutCopyName.value }); + items.push({ + label: t("savedSql.pasteFile"), + action: () => requestPasteTreeClipboard(), + icon: Clipboard, + shortcut: shortcutPaste.value, + disabled: connectionStore.treeClipboard?.kind !== "saved-sql-copy" || !savedSqlPasteTargetForNode(node), + }); + items.push({ label: t("sqlLibrary.exportFile"), action: exportSavedSqlFile, icon: Upload }); + items.push({ label: "", separator: true }); + items.push({ label: t("savedSql.renameFile"), action: renameSavedSqlFile, icon: Pencil, shortcut: shortcutRename }); + items.push({ label: "", separator: true }); + items.push({ label: t("savedSql.deleteFile"), action: deleteSavedSqlFile, icon: Trash2, shortcut: shortcutDelete, variant: "destructive" as const }); + return true; + } // 5. Redis DB / Mongo DB if (currentDatabaseType() === "hbase" && node.type === "group-tables") { if (canCreateTable.value) { diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 549de41de3..68ea54113f 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -68,6 +68,9 @@ import { isWindows } from "@/lib/backend/platform"; import { flattenTree } from "@/composables/useFlatTree"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import { focusSidebarRenameInput } from "@/lib/sidebar/sidebarRenameFocus"; +import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import { savedSqlErrorMessage } from "@/lib/savedSql/savedSqlErrors"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; // --- Drag and Drop --- import { useDragSort } from "@/composables/useDragSort"; import { sidebarTreeRuntimeKey } from "@/lib/sidebar/sidebarTreeRuntime"; @@ -261,6 +264,12 @@ function getIconInfo(node: TreeNode): { icon: any; colorClass: string } | null { return { icon: ShieldCheck, colorClass: "text-primary" }; case "dameng-job-admin": return { icon: CalendarClock, colorClass: "text-primary" }; + case "saved-sql-root": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-blue-500" }; + case "saved-sql-folder": + return { icon: node.isExpanded ? FolderOpen : FolderClosed, colorClass: "text-blue-400" }; + case "saved-sql-file": + return { icon: FileCode, colorClass: "text-blue-400" }; case "index": return { icon: Key, colorClass: "text-amber-400" }; case "fkey": @@ -355,6 +364,7 @@ function displayLabel(node: TreeNode): string { if (node.type === "object-browser") return t(node.label, { count: node.objectCount ?? 0 }); if (node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin") return t(node.label); if (node.type === "linked-server-root") return t(node.label); + if (node.type === "saved-sql-root") return t(node.label); if (node.type === "mqtt-topic" && node.id.endsWith(":mqtt-topic:__console__")) return t(node.label); if (node.label === "tree.defaultDatabase") return t(node.label); return isGroupLabel(node) ? t(node.label) : node.label; @@ -828,6 +838,8 @@ function clearTableSearchQuery() { // --- Connection Group Management --- const isRenamingGroup = ref(false); +const isRenamingSavedSql = ref(false); + const renameInput = ref(""); const renameInputRef = ref(); @@ -839,10 +851,20 @@ function startRenameGroup() { focusSidebarRenameInput(() => (isRenamingGroup.value ? renameInputRef.value : undefined)); } +function startRenameSavedSql() { + if (activeNode.value.type !== "saved-sql-file" || !activeNode.value.savedSqlId) return; + renameInput.value = stripSqlExtension(activeNode.value.label); + isRenamingSavedSql.value = true; + emit("rename-started"); + focusSidebarRenameInput(() => (isRenamingSavedSql.value ? renameInputRef.value : undefined)); +} + watch( () => props.pendingRename, (pending) => { - if (pending && activeNode.value.type === "connection-group") startRenameGroup(); + if (!pending) return; + if (activeNode.value.type === "connection-group") startRenameGroup(); + else if (activeNode.value.type === "saved-sql-file") startRenameSavedSql(); }, { immediate: true }, ); @@ -850,7 +872,7 @@ watch( function shouldMeasureLabelOverflow(): boolean { return shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: !!detailTooltip.value?.rows.length, - isRenaming: isRenamingGroup.value, + isRenaming: isRenamingGroup.value || isRenamingSavedSql.value, usesFullWidthLabel: usesFullWidthLabel.value, }); } @@ -870,6 +892,30 @@ function finishRenameGroup() { connectionStore.renameConnectionGroup(activeNode.value.id, trimmed); } +async function finishRenameSavedSql() { + if (!isRenamingSavedSql.value) return; + isRenamingSavedSql.value = false; + const fileId = activeNode.value.savedSqlId; + const trimmed = renameInput.value.trim(); + if (!fileId || !trimmed || ensureSqlExtension(trimmed) === activeNode.value.label) return; + try { + const savedSqlStore = useSavedSqlStore(); + await savedSqlStore.renameFile(fileId, ensureSqlExtension(trimmed)); + } catch (e: any) { + toast(t("savedSql.renameFailed", { message: savedSqlErrorMessage(e, t) }), 5000); + } +} + +function finishRename() { + if (isRenamingSavedSql.value) void finishRenameSavedSql(); + else finishRenameGroup(); +} + +function cancelRename() { + isRenamingGroup.value = false; + isRenamingSavedSql.value = false; +} + const PINNED_TREE_NODE_DRAG_TYPE = "__pinned-tree-node__"; function pinnedSortKey(): string { @@ -1081,6 +1127,7 @@ watch( // Virtual rows are recycled; transient DOM and pointer state must not leak // from the previously rendered node into the new row. isRenamingGroup.value = false; + isRenamingSavedSql.value = false; renameInput.value = ""; labelOverflowing.value = false; suppressNextTableReferenceClick = false; @@ -1244,13 +1291,13 @@ function onKeydown(event: KeyboardEvent) {
{{ visibleLabel(node) }} diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index 03b0cfcb20..751c570828 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -356,7 +356,15 @@ export default { deleteFolder: "Delete Folder", deleteFolderConfirm: "Delete “{name}” and the SQL files inside it?", open: "Open SQL", + copyFile: "Copy SQL", + pasteFile: "Paste SQL", + copied: "Copied {count} SQL file(s)", + pasted: "Pasted {count} SQL file(s)", + nothingToPaste: "No SQL files to paste", + pasteFailed: "Failed to paste SQL: {message}", renameFile: "Rename SQL", + renameFailed: "Failed to rename SQL: {message}", + nameConflict: 'An SQL named "{name}" already exists in this location', deleteFile: "Delete SQL", deleteFileConfirm: "Delete “{name}”?", createFolderOption: "Create folder “{name}”", @@ -398,6 +406,7 @@ export default { moveToFolder: "Move to Folder...", moveSelectedToFolder: "Move {count} SQL to Folder...", moveSuccess: "Moved {count} SQL file(s)", + moveFailed: "Failed to move SQL: {message}", clearSelection: "Clear Selection", sortByDate: "Sort by Date Modified", sortByFolder: "Sort by Folder Structure", @@ -2891,6 +2900,7 @@ export default { }, tree: { savedSql: "SQL Library", + queries: "Queries", userAdmin: "Users & Privileges", damengUsers: "Users", damengRoles: "Roles", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index d765f81577..b9af1e8ab1 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -357,7 +357,15 @@ export default withEnglishFallback({ deleteFolder: "Eliminar carpeta", deleteFolderConfirm: "¿Eliminar “{name}” y los archivos SQL que contiene?", open: "Abrir SQL", + copyFile: "Copiar SQL", + pasteFile: "Pegar SQL", + copied: "Se copiaron {count} archivo(s) SQL", + pasted: "Se pegaron {count} archivo(s) SQL", + nothingToPaste: "No hay archivos SQL para pegar", + pasteFailed: "Error al pegar SQL: {message}", renameFile: "Renombrar SQL", + renameFailed: "Error al renombrar SQL: {message}", + nameConflict: 'Ya existe un SQL llamado "{name}" en esta ubicación', deleteFile: "Eliminar SQL", deleteFileConfirm: "¿Eliminar “{name}”?", createFolderOption: "Crear carpeta “{name}”", @@ -399,6 +407,7 @@ export default withEnglishFallback({ moveToFolder: "Mover a carpeta...", moveSelectedToFolder: "Mover {count} SQL a carpeta...", moveSuccess: "Se movieron {count} SQL", + moveFailed: "Error al mover SQL: {message}", clearSelection: "Limpiar selección", sortByDate: "Ordenar por fecha de modificación", sortByFolder: "Ordenar por estructura de carpetas", @@ -2825,6 +2834,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "Biblioteca SQL", + queries: "Consultas", userAdmin: "Usuarios y Privilegios", damengUsers: "Usuarios", damengRoles: "Roles", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index 4d58c4c670..d77ece5f9e 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -356,7 +356,15 @@ export default withEnglishFallback({ deleteFolder: "Elimina Cartella", deleteFolderConfirm: "Eliminare “{name}” e tutti i file SQL al suo interno?", open: "Apri SQL", + copyFile: "Copia SQL", + pasteFile: "Incolla SQL", + copied: "Copiati {count} file SQL", + pasted: "Incollati {count} file SQL", + nothingToPaste: "Nessun file SQL da incollare", + pasteFailed: "Impossibile incollare SQL: {message}", renameFile: "Rinomina SQL", + renameFailed: "Impossibile rinominare SQL: {message}", + nameConflict: 'Esiste già un SQL denominato "{name}" in questa posizione', deleteFile: "Elimina SQL", deleteFileConfirm: "Eliminare “{name}”?", createFolderOption: "Crea cartella “{name}”", @@ -398,6 +406,7 @@ export default withEnglishFallback({ moveToFolder: "Sposta nella cartella...", moveSelectedToFolder: "Sposta {count} SQL nella cartella...", moveSuccess: "{count} SQL spostati", + moveFailed: "Impossibile spostare SQL: {message}", clearSelection: "Cancella selezione", sortByDate: "Ordina per data di modifica", sortByFolder: "Ordina per struttura cartelle", @@ -2823,6 +2832,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "Libreria SQL", + queries: "Query", userAdmin: "Utenti e Privilegi", damengUsers: "Utenti", damengRoles: "Ruoli", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 840230d224..8ab99680b9 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -357,7 +357,15 @@ export default withEnglishFallback({ deleteFolder: "フォルダを削除", deleteFolderConfirm: "「{name}」とその中のSQLファイルを削除しますか?", open: "SQLを開く", + copyFile: "SQLをコピー", + pasteFile: "SQLを貼り付け", + copied: "{count}個のSQLをコピーしました", + pasted: "{count}個のSQLを貼り付けました", + nothingToPaste: "貼り付けるSQLがありません", + pasteFailed: "SQLの貼り付けに失敗しました: {message}", renameFile: "SQLの名前を変更", + renameFailed: "SQLの名前変更に失敗しました: {message}", + nameConflict: "この場所には「{name}」という名前の SQL が既にあります", deleteFile: "SQLを削除", deleteFileConfirm: "「{name}」を削除しますか?", createFolderOption: "フォルダ「{name}」を作成", @@ -399,6 +407,7 @@ export default withEnglishFallback({ moveToFolder: "フォルダへ移動...", moveSelectedToFolder: "選択した {count} 個の SQL をフォルダへ移動...", moveSuccess: "{count} 個の SQL を移動しました", + moveFailed: "SQLの移動に失敗しました: {message}", clearSelection: "選択を解除", sortByDate: "更新日時で並べ替え", sortByFolder: "フォルダ構造で並べ替え", @@ -2850,6 +2859,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "SQLライブラリ", + queries: "クエリ", userAdmin: "ユーザーと権限", damengUsers: "ユーザー", damengRoles: "ロール", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index 177f0f46f5..380a10a389 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -351,7 +351,15 @@ export default withEnglishFallback({ deleteFolder: "폴더 삭제", deleteFolderConfirm: "“{name}”과(와) 그 안의 SQL 파일을 삭제하시겠습니까?", open: "SQL 열기", + copyFile: "SQL 복사", + pasteFile: "SQL 붙여넣기", + copied: "SQL {count}개를 복사했습니다", + pasted: "SQL {count}개를 붙여넣었습니다", + nothingToPaste: "붙여넣을 SQL이 없습니다", + pasteFailed: "SQL 붙여넣기 실패: {message}", renameFile: "SQL 이름 변경", + renameFailed: "SQL 이름 변경 실패: {message}", + nameConflict: '이 위치에 "{name}"이라는 SQL이 이미 있습니다', deleteFile: "SQL 삭제", deleteFileConfirm: "“{name}”을(를) 삭제하시겠습니까?", createFolderOption: "폴더 “{name}” 만들기", @@ -393,6 +401,7 @@ export default withEnglishFallback({ moveToFolder: "폴더로 이동...", moveSelectedToFolder: "SQL {count}개를 폴더로 이동...", moveSuccess: "SQL 파일 {count}개를 이동했습니다", + moveFailed: "SQL 이동 실패: {message}", clearSelection: "선택 해제", sortByDate: "수정 날짜순 정렬", sortByFolder: "폴더 구조순 정렬", @@ -2741,6 +2750,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "SQL 라이브러리", + queries: "쿼리", userAdmin: "사용자 및 권한", damengUsers: "사용자", damengRoles: "역할", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index 097898703b..da7ade6e8d 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -357,7 +357,15 @@ export default withEnglishFallback({ deleteFolder: "Excluir Pasta", deleteFolderConfirm: "Excluir “{name}” e os arquivos SQL dentro dela?", open: "Abrir SQL", + copyFile: "Copiar SQL", + pasteFile: "Colar SQL", + copied: "{count} arquivo(s) SQL copiado(s)", + pasted: "{count} arquivo(s) SQL colado(s)", + nothingToPaste: "Não há arquivos SQL para colar", + pasteFailed: "Falha ao colar SQL: {message}", renameFile: "Renomear SQL", + renameFailed: "Falha ao renomear SQL: {message}", + nameConflict: 'Já existe um SQL chamado "{name}" neste local', deleteFile: "Excluir SQL", deleteFileConfirm: "Excluir “{name}”?", createFolderOption: "Criar pasta “{name}”", @@ -399,6 +407,7 @@ export default withEnglishFallback({ moveToFolder: "Mover para pasta...", moveSelectedToFolder: "Mover {count} SQL para pasta...", moveSuccess: "{count} SQL movidos", + moveFailed: "Falha ao mover SQL: {message}", clearSelection: "Limpar seleção", sortByDate: "Ordenar por data de modificação", sortByFolder: "Ordenar por estrutura de pastas", @@ -2825,6 +2834,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "Biblioteca SQL", + queries: "Consultas", userAdmin: "Usuários e Privilégios", damengUsers: "Usuários", damengRoles: "Funções", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index baad13e503..f71c79e5a7 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -281,7 +281,15 @@ export default withEnglishFallback({ deleteFolder: "删除文件夹", deleteFolderConfirm: "确定删除“{name}”以及其中的 SQL 文件吗?", open: "打开 SQL", + copyFile: "复制 SQL", + pasteFile: "粘贴 SQL", + copied: "已复制 {count} 个 SQL", + pasted: "已粘贴 {count} 个 SQL", + nothingToPaste: "没有可粘贴的 SQL", + pasteFailed: "粘贴 SQL 失败:{message}", renameFile: "重命名 SQL", + renameFailed: "重命名 SQL 失败:{message}", + nameConflict: "当前位置已存在同名 SQL:“{name}”", deleteFile: "删除 SQL", deleteFileConfirm: "确定删除“{name}”吗?", createFolderOption: "创建文件夹“{name}”", @@ -323,6 +331,7 @@ export default withEnglishFallback({ moveToFolder: "分类至...", moveSelectedToFolder: "分类选中的 {count} 个 SQL 至...", moveSuccess: "已分类 {count} 个 SQL", + moveFailed: "分类 SQL 失败:{message}", clearSelection: "清除选择", sortByDate: "按修改日期排序", sortByFolder: "按文件夹结构排序", @@ -2815,6 +2824,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "SQL 库", + queries: "查询", userAdmin: "用户与权限", damengUsers: "用户", damengRoles: "角色", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index a163d7066e..be54033414 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -357,7 +357,15 @@ export default withEnglishFallback({ deleteFolder: "刪除資料夾", deleteFolderConfirm: "確定刪除「{name}」以及其中的 SQL 檔案嗎?", open: "開啟 SQL", + copyFile: "複製 SQL", + pasteFile: "貼上 SQL", + copied: "已複製 {count} 個 SQL", + pasted: "已貼上 {count} 個 SQL", + nothingToPaste: "沒有可貼上的 SQL", + pasteFailed: "貼上 SQL 失敗:{message}", renameFile: "重新命名 SQL", + renameFailed: "重新命名 SQL 失敗:{message}", + nameConflict: "目前位置已存在同名 SQL:「{name}」", deleteFile: "刪除 SQL", deleteFileConfirm: "確定刪除「{name}」嗎?", createFolderOption: "建立資料夾「{name}」", @@ -399,6 +407,7 @@ export default withEnglishFallback({ moveToFolder: "分類至...", moveSelectedToFolder: "分類選取的 {count} 個 SQL 至...", moveSuccess: "已分類 {count} 個 SQL", + moveFailed: "分類 SQL 失敗:{message}", clearSelection: "清除選取", sortByDate: "依修改日期排序", sortByFolder: "依資料夾結構排序", @@ -2824,6 +2833,7 @@ export default withEnglishFallback({ }, tree: { savedSql: "SQL 庫", + queries: "查詢", userAdmin: "使用者與權限", damengUsers: "使用者", damengRoles: "角色", diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlClipboard.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlClipboard.spec.ts new file mode 100644 index 0000000000..b1eeccef7e --- /dev/null +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlClipboard.spec.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { nextSavedSqlCopyName, savedSqlClipboardFileIds, savedSqlPasteTargetForNode } from "@/lib/savedSql/savedSqlClipboard"; +import { savedSqlExportFileName } from "@/lib/savedSql/savedSqlExport"; +import type { TreeNode } from "@/types/database"; + +describe("saved SQL tree clipboard", () => { + it("collects unique saved SQL ids from tree rows", () => { + const nodes: TreeNode[] = [ + { id: "file-1", label: "report.sql", type: "saved-sql-file", savedSqlId: "sql-1" }, + { id: "file-1-copy", label: "report.sql", type: "saved-sql-file", savedSqlId: "sql-1" }, + { id: "table-1", label: "report", type: "table" }, + ]; + + expect(savedSqlClipboardFileIds(nodes)).toEqual(["sql-1"]); + }); + + it("resolves database scope from a Queries node or saved SQL row", () => { + expect(savedSqlPasteTargetForNode({ type: "saved-sql-root", connectionId: "conn-1", catalog: "hive", database: "app" })).toEqual({ connectionId: "conn-1", catalog: "hive", database: "app", schema: undefined }); + expect(savedSqlPasteTargetForNode({ type: "saved-sql-file", connectionId: "conn-1", database: "app", schema: "public" })).toEqual({ connectionId: "conn-1", catalog: undefined, database: "app", schema: "public" }); + expect(savedSqlPasteTargetForNode({ type: "schema", connectionId: "conn-1", database: "app", schema: "public" })).toBeNull(); + }); + + it("uses Navicat-style incrementing copy suffixes", () => { + expect(nextSavedSqlCopyName("query.sql", new Set(["query.sql"]))).toBe("query_copy1.sql"); + expect(nextSavedSqlCopyName("query.sql", new Set(["query.sql", "query_copy1.sql", "QUERY_COPY2.SQL"]))).toBe("query_copy3.sql"); + expect(nextSavedSqlCopyName("query_copy1.sql", new Set(["query_copy1.sql"]))).toBe("query_copy2.sql"); + }); + + it("sanitizes exported SQL file names", () => { + expect(savedSqlExportFileName("report<2026>:daily")).toBe("report_2026__daily.sql"); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/savedSql/savedSqlDatabaseTree.spec.ts b/apps/desktop/src/lib/__tests__/savedSql/savedSqlDatabaseTree.spec.ts new file mode 100644 index 0000000000..19e6d4005a --- /dev/null +++ b/apps/desktop/src/lib/__tests__/savedSql/savedSqlDatabaseTree.spec.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "vitest"; +import { buildDatabaseSavedSqlRootNode, decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, savedSqlFilesForDatabase, stripDatabaseSavedSqlTreeNodes } from "@/lib/savedSql/savedSqlDatabaseTree"; +import type { SavedSqlFile, TreeNode } from "@/types/database"; + +function file(input: Partial & Pick): SavedSqlFile { + return { + sql: "select 1", + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + ...input, + }; +} + +const files = [ + file({ id: "orders-10", name: "orders-10.sql", connectionId: "conn-1", database: "app", folderId: "reports" }), + file({ id: "orders-2", name: "orders-2.sql", connectionId: "conn-1", database: "app" }), + file({ id: "analytics", name: "analytics.sql", connectionId: "conn-1", database: "analytics" }), + file({ id: "other-connection", name: "orders.sql", connectionId: "conn-2", database: "app" }), +]; + +describe("savedSqlFilesForDatabase", () => { + it("filters by the persisted connection and database association across all SQL library folders", () => { + expect(savedSqlFilesForDatabase(files, { connectionId: "conn-1", database: "app" }).map((item) => item.id)).toEqual(["orders-2", "orders-10"]); + }); + + it("keeps each original SQL before its naturally ordered copies", () => { + const copiedFiles = [ + file({ id: "query-5-copy-10", name: "query_5_copy10.sql", connectionId: "conn-1", database: "app" }), + file({ id: "query-10", name: "query_10.sql", connectionId: "conn-1", database: "app" }), + file({ id: "query-5-copy-1", name: "query_5_copy1.sql", connectionId: "conn-1", database: "app" }), + file({ id: "query-2", name: "query_2.sql", connectionId: "conn-1", database: "app" }), + file({ id: "query-5", name: "query_5.sql", connectionId: "conn-1", database: "app" }), + file({ id: "query-5-copy-2", name: "query_5_copy2.sql", connectionId: "conn-1", database: "app" }), + ]; + + expect(savedSqlFilesForDatabase(copiedFiles, { connectionId: "conn-1", database: "app" }).map((item) => item.name)).toEqual(["query_2.sql", "query_5.sql", "query_5_copy1.sql", "query_5_copy2.sql", "query_5_copy10.sql", "query_10.sql"]); + }); + + it("separates same-named databases by catalog and indexes all files once", () => { + const catalogFiles = [ + file({ id: "default", name: "default.sql", connectionId: "conn-1", database: "analytics" }), + file({ id: "hive", name: "hive.sql", connectionId: "conn-1", catalog: "hive", database: "analytics" }), + file({ id: "iceberg", name: "iceberg.sql", connectionId: "conn-1", catalog: "iceberg", database: "analytics" }), + ]; + const index = indexSavedSqlFilesByDatabase(catalogFiles); + + expect(savedSqlFilesForDatabase(index, { connectionId: "conn-1", database: "analytics" }).map((item) => item.id)).toEqual(["default"]); + expect(savedSqlFilesForDatabase(index, { connectionId: "conn-1", catalog: "hive", database: "analytics" }).map((item) => item.id)).toEqual(["hive"]); + expect(savedSqlFilesForDatabase(index, { connectionId: "conn-1", catalog: "iceberg", database: "analytics" }).map((item) => item.id)).toEqual(["iceberg"]); + }); +}); + +describe("database saved SQL tree", () => { + const database: TreeNode = { + id: "conn-1:app", + label: "app", + type: "database", + connectionId: "conn-1", + database: "app", + children: [], + }; + + it("keeps a visible Queries node even when the database has no saved SQL", () => { + const root = buildDatabaseSavedSqlRootNode(database, []); + expect(root).toMatchObject({ id: "conn-1:app:__queries", label: "tree.queries", type: "saved-sql-root", children: [] }); + }); + + it("adds Queries after database metadata and preserves its expansion state", () => { + const existingRoot = { ...buildDatabaseSavedSqlRootNode(database, files)!, isExpanded: false }; + const decorated = decorateDatabaseSavedSqlTreeNodes( + [ + { + ...database, + children: [{ id: "conn-1:app:tables", label: "tree.tables", type: "group-tables" }], + }, + ], + files, + [{ ...database, children: [existingRoot] }], + ); + + expect(decorated[0].children?.map((child) => child.type)).toEqual(["group-tables", "saved-sql-root"]); + expect(decorated[0].children?.at(-1)).toMatchObject({ isExpanded: false, children: [{ savedSqlId: "orders-2" }, { savedSqlId: "orders-10" }] }); + }); + + it("removes runtime saved SQL nodes before metadata is cached", () => { + const decorated = decorateDatabaseSavedSqlTreeNodes([database], files); + expect(stripDatabaseSavedSqlTreeNodes(decorated)).toEqual([{ ...database, children: [] }]); + }); + + it("renders a saved SQL only below its exact catalog database", () => { + const catalogFiles = [file({ id: "hive", name: "hive.sql", connectionId: "conn-1", catalog: "hive", database: "app" })]; + const defaultRoot = buildDatabaseSavedSqlRootNode(database, catalogFiles); + const hiveRoot = buildDatabaseSavedSqlRootNode({ ...database, id: "conn-1:hive:app", catalog: "hive" }, catalogFiles); + + expect(defaultRoot?.children).toEqual([]); + expect(hiveRoot).toMatchObject({ catalog: "hive", children: [{ catalog: "hive", savedSqlId: "hive" }] }); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts index 539403462c..d4c0ac176e 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/sidebarActiveTabTarget.spec.ts @@ -10,4 +10,15 @@ describe("sidebar active-tab targets", () => { expect(matchesTarget({ id: "connection:pg-1", label: "Postgres", type: "connection", connectionId: "pg-1" } as TreeNode, target!)).toBe(true); expect(matchesTarget({ id: "connection:pg-2", label: "Other", type: "connection", connectionId: "pg-2" } as TreeNode, target!)).toBe(false); }); + + it("locates a query context only in its exact catalog", () => { + const target = activeTabSidebarTarget({ mode: "query", connectionId: "doris-1", catalog: "hive", database: "analytics" } as QueryTab); + const hiveDatabase = { id: "hive:analytics", label: "analytics", type: "database", connectionId: "doris-1", catalog: "hive", database: "analytics" } as TreeNode; + const icebergDatabase = { ...hiveDatabase, id: "iceberg:analytics", catalog: "iceberg" }; + const defaultDatabase = { ...hiveDatabase, id: "default:analytics", catalog: undefined }; + + expect(matchesTarget(hiveDatabase, target!)).toBe(true); + expect(matchesTarget(icebergDatabase, target!)).toBe(false); + expect(matchesTarget(defaultDatabase, target!)).toBe(false); + }); }); diff --git a/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts b/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts index 85da197af8..888ef2aa9c 100644 --- a/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts +++ b/apps/desktop/src/lib/__tests__/sidebar/treeLoadedChildrenMarker.spec.ts @@ -38,6 +38,21 @@ describe("treeNodeLoadedChildrenContentPresent", () => { expect(treeNodeLoadedChildrenContentPresent(databaseNode(), "simple")).toBe(true); }); + it("does not mistake the runtime Queries node for loaded database metadata", () => { + const node = databaseNode([ + { + id: "conn:test1:__queries", + label: "tree.queries", + type: "saved-sql-root", + connectionId: "conn", + database: "test1", + children: [], + }, + ]); + expect(treeNodeLoadedChildrenContentPresent(node, "grouped")).toBe(false); + expect(simpleModeEmptyShellNeedsConfirmedLoad(node, "simple")).toBe(true); + }); + it("accepts loaded object groups with zero items", () => { const node: TreeNode = { id: "conn:test1:__tables", diff --git a/apps/desktop/src/lib/savedSql/savedSqlClipboard.ts b/apps/desktop/src/lib/savedSql/savedSqlClipboard.ts new file mode 100644 index 0000000000..b4d4659812 --- /dev/null +++ b/apps/desktop/src/lib/savedSql/savedSqlClipboard.ts @@ -0,0 +1,35 @@ +import { ensureSqlExtension, stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import type { TreeNode } from "@/types/database"; + +export interface SavedSqlPasteTarget { + connectionId: string; + catalog?: string; + database: string; + schema?: string; +} + +export function savedSqlClipboardFileIds(nodes: readonly TreeNode[]): string[] { + return [...new Set(nodes.filter((node) => node.type === "saved-sql-file" && !!node.savedSqlId).map((node) => node.savedSqlId!))]; +} + +export function savedSqlPasteTargetForNode(node: Pick): SavedSqlPasteTarget | null { + if (node.type !== "database" && node.type !== "saved-sql-root" && node.type !== "saved-sql-file") return null; + if (!node.connectionId || node.database === undefined) return null; + return { + connectionId: node.connectionId, + catalog: node.catalog, + database: node.database, + schema: node.schema, + }; +} + +export function nextSavedSqlCopyName(sourceName: string, takenNames: ReadonlySet): string { + const normalizedSource = ensureSqlExtension(sourceName); + const sourceBase = stripSqlExtension(normalizedSource); + const copyBase = sourceBase.replace(/_copy\d+$/i, "") || sourceBase; + const normalizedTakenNames = new Set([...takenNames].map((name) => ensureSqlExtension(name).toLocaleLowerCase())); + + let index = 1; + while (normalizedTakenNames.has(`${copyBase}_copy${index}.sql`.toLocaleLowerCase())) index++; + return `${copyBase}_copy${index}.sql`; +} diff --git a/apps/desktop/src/lib/savedSql/savedSqlDatabaseTree.ts b/apps/desktop/src/lib/savedSql/savedSqlDatabaseTree.ts new file mode 100644 index 0000000000..b26a85a1a1 --- /dev/null +++ b/apps/desktop/src/lib/savedSql/savedSqlDatabaseTree.ts @@ -0,0 +1,118 @@ +import type { SavedSqlFile, TreeNode } from "@/types/database"; +import { stripSqlExtension } from "@/lib/savedSql/savedSqlFileName"; + +const savedSqlNameCollator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + +function savedSqlCopySortKey(name: string): { base: string; copyIndex: number } { + const base = stripSqlExtension(name); + const copyMatch = base.match(/^(.*)_copy(\d+)$/i); + if (!copyMatch?.[1]) return { base, copyIndex: 0 }; + return { base: copyMatch[1], copyIndex: Number(copyMatch[2]) }; +} + +function compareSavedSqlFiles(left: SavedSqlFile, right: SavedSqlFile): number { + const leftKey = savedSqlCopySortKey(left.name); + const rightKey = savedSqlCopySortKey(right.name); + return savedSqlNameCollator.compare(leftKey.base, rightKey.base) || leftKey.copyIndex - rightKey.copyIndex || savedSqlNameCollator.compare(left.name, right.name) || left.id.localeCompare(right.id); +} + +export interface SavedSqlDatabaseScope { + connectionId: string; + catalog?: string; + database: string; +} + +export type SavedSqlDatabaseIndex = ReadonlyMap; +type SavedSqlDatabaseSource = readonly SavedSqlFile[] | SavedSqlDatabaseIndex; + +export function savedSqlDatabaseScopeKey(scope: SavedSqlDatabaseScope): string { + // Null is deliberately a concrete built-in/default catalog scope, not a wildcard. + return JSON.stringify([scope.connectionId, scope.catalog || null, scope.database]); +} + +export function indexSavedSqlFilesByDatabase(files: readonly SavedSqlFile[]): SavedSqlDatabaseIndex { + const index = new Map(); + for (const file of files) { + const key = savedSqlDatabaseScopeKey(file); + const scopedFiles = index.get(key); + if (scopedFiles) scopedFiles.push(file); + else index.set(key, [file]); + } + for (const scopedFiles of index.values()) scopedFiles.sort(compareSavedSqlFiles); + return index; +} + +export function savedSqlFilesForDatabase(source: SavedSqlDatabaseSource, scope: SavedSqlDatabaseScope): SavedSqlFile[] { + if (!Array.isArray(source)) return [...((source as SavedSqlDatabaseIndex).get(savedSqlDatabaseScopeKey(scope)) ?? [])]; + return source.filter((file) => savedSqlDatabaseScopeKey(file) === savedSqlDatabaseScopeKey(scope)).sort(compareSavedSqlFiles); +} + +function savedSqlFileNode(rootId: string, file: SavedSqlFile): TreeNode { + return { + id: `${rootId}:file:${file.id}`, + label: file.name, + type: "saved-sql-file", + connectionId: file.connectionId, + catalog: file.catalog, + database: file.database, + schema: file.schema, + savedSqlId: file.id, + }; +} + +export function buildDatabaseSavedSqlRootNode(databaseNode: Pick, source: SavedSqlDatabaseSource, existingRoot?: TreeNode): TreeNode | null { + if (!databaseNode.connectionId || databaseNode.database === undefined) return null; + + const id = `${databaseNode.id}:__queries`; + return { + id, + label: "tree.queries", + type: "saved-sql-root", + connectionId: databaseNode.connectionId, + catalog: databaseNode.catalog, + database: databaseNode.database, + isExpanded: existingRoot?.isExpanded ?? true, + children: savedSqlFilesForDatabase(source, { + connectionId: databaseNode.connectionId, + catalog: databaseNode.catalog, + database: databaseNode.database, + }).map((file) => savedSqlFileNode(id, file)), + }; +} + +export function withDatabaseSavedSqlRoot(databaseNode: Pick, children: readonly TreeNode[], source: SavedSqlDatabaseSource): TreeNode[] { + const existingRoot = databaseNode.children?.find((child) => child.type === "saved-sql-root"); + const root = buildDatabaseSavedSqlRootNode(databaseNode, source, existingRoot); + const metadataChildren = children.filter((child) => child.type !== "saved-sql-root"); + return root ? [...metadataChildren, root] : metadataChildren; +} + +export function decorateDatabaseSavedSqlTreeNodes(nodes: readonly TreeNode[], source: SavedSqlDatabaseSource, existingNodes: readonly TreeNode[] = []): TreeNode[] { + const existingById = new Map(existingNodes.map((node) => [node.id, node])); + return nodes.map((node) => { + const existing = existingById.get(node.id); + const children = decorateDatabaseSavedSqlTreeNodes(node.children ?? [], source, existing?.children ?? []); + if (node.type !== "database") { + return node.children === undefined ? node : { ...node, children }; + } + + const databaseNode = { + ...node, + children: existing?.children ?? node.children, + }; + return { + ...node, + children: withDatabaseSavedSqlRoot(databaseNode, children, source), + }; + }); +} + +export function stripDatabaseSavedSqlTreeNodes(nodes: readonly TreeNode[]): TreeNode[] { + return nodes.flatMap((node) => { + if (node.type === "saved-sql-root" || node.type === "saved-sql-file" || node.type === "saved-sql-folder") return []; + const children = node.children ? stripDatabaseSavedSqlTreeNodes(node.children) : undefined; + const hiddenChildren = node.hiddenChildren ? stripDatabaseSavedSqlTreeNodes(node.hiddenChildren) : undefined; + if (children === node.children && hiddenChildren === node.hiddenChildren) return [node]; + return [{ ...node, children, hiddenChildren }]; + }); +} diff --git a/apps/desktop/src/lib/savedSql/savedSqlErrors.ts b/apps/desktop/src/lib/savedSql/savedSqlErrors.ts new file mode 100644 index 0000000000..64b77c8814 --- /dev/null +++ b/apps/desktop/src/lib/savedSql/savedSqlErrors.ts @@ -0,0 +1,18 @@ +interface SavedSqlErrorTranslator { + (key: string, params?: Record): string; +} + +interface SavedSqlNameConflictLike { + code?: unknown; + fileName?: unknown; + message?: unknown; +} + +export function savedSqlErrorMessage(error: unknown, translate: SavedSqlErrorTranslator): string { + const candidate = error as SavedSqlNameConflictLike | null; + if (candidate?.code === "SAVED_SQL_NAME_CONFLICT" && typeof candidate.fileName === "string") { + return translate("savedSql.nameConflict", { name: candidate.fileName }); + } + if (typeof candidate?.message === "string") return candidate.message; + return String(error); +} diff --git a/apps/desktop/src/lib/savedSql/savedSqlExport.ts b/apps/desktop/src/lib/savedSql/savedSqlExport.ts new file mode 100644 index 0000000000..b07ed0029d --- /dev/null +++ b/apps/desktop/src/lib/savedSql/savedSqlExport.ts @@ -0,0 +1,34 @@ +import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; +import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName"; + +export function savedSqlExportFileName(name: string): string { + return ( + ensureSqlExtension(name) + .replace(/[<>:"/\\|?*\p{Cc}]/gu, "_") + .trim() || "untitled.sql" + ); +} + +export async function exportSavedSqlFileContent(sql: string, fileName: string): Promise<"saved" | "cancelled"> { + const defaultFileName = savedSqlExportFileName(fileName); + if (isTauriRuntime()) { + const { save } = await import("@tauri-apps/plugin-dialog"); + const { writeTextFile } = await import("@tauri-apps/plugin-fs"); + const path = await save({ + defaultPath: defaultFileName, + filters: [{ name: "SQL", extensions: ["sql"] }], + }); + if (!path) return "cancelled"; + await writeTextFile(path, sql); + return "saved"; + } + + const blob = new Blob([sql], { type: "text/plain;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = defaultFileName; + anchor.click(); + URL.revokeObjectURL(url); + return "saved"; +} diff --git a/apps/desktop/src/lib/savedSql/savedSqlHistory.ts b/apps/desktop/src/lib/savedSql/savedSqlHistory.ts index 66339bf92e..695276326c 100644 --- a/apps/desktop/src/lib/savedSql/savedSqlHistory.ts +++ b/apps/desktop/src/lib/savedSql/savedSqlHistory.ts @@ -5,6 +5,8 @@ const RECENCY_WINDOW_MS = 30 * DAY_MS; export interface SavedSqlHistoryScope { connectionId?: string; + /** Null explicitly selects the built-in/default catalog; undefined does not filter catalogs. */ + catalog?: string | null; database?: string; schema?: string; tableName?: string; @@ -51,6 +53,7 @@ export function savedSqlHistoryScore(file: SavedSqlFile, now = Date.now()) { export function savedSqlMatchesHistoryScope(file: SavedSqlFile, scope: SavedSqlHistoryScope) { if (scope.connectionId && file.connectionId !== scope.connectionId) return false; + if (scope.catalog !== undefined && (file.catalog || null) !== scope.catalog) return false; if (scope.database != null && file.database !== scope.database) return false; if (scope.schema && file.schema && file.schema !== scope.schema) return false; return true; diff --git a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts index 72ef022893..1e335dcf0e 100644 --- a/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts +++ b/apps/desktop/src/lib/sidebar/sidebarActiveTabTarget.ts @@ -73,6 +73,7 @@ export type ActiveTabSidebarTarget = | { type: "query-context"; connectionId: string; + catalog?: string; database: string; schema?: string; } @@ -187,6 +188,7 @@ export function activeTabSidebarTarget(tab: QueryTab | undefined | null): Active return { type: "query-context", connectionId: tab.connectionId, + catalog: tab.catalog, database: tab.database, schema: tab.schema, }; @@ -231,6 +233,7 @@ export function matchesTarget(node: TreeNode, target: ActiveTabSidebarTarget): b } if (target.type === "query-context") { + if ((node.catalog || undefined) !== (target.catalog || undefined)) return false; if (target.schema) { return node.type === "schema" && node.connectionId === target.connectionId && node.database === target.database && node.label === target.schema; } diff --git a/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts b/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts index 55b437e26d..7ce543f412 100644 --- a/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts +++ b/apps/desktop/src/lib/sidebar/treeLoadedChildrenMarker.ts @@ -7,7 +7,7 @@ const TABLE_STRUCTURE_GROUP_TYPES = new Set(["group-columns", /** Whether a node's in-memory children match a valid "loaded" marker (scheme A). */ export function treeNodeLoadedChildrenContentPresent(node: TreeNode, sidebarObjectDisplay: SidebarObjectDisplayMode): boolean { - const childCount = node.children?.length ?? 0; + const childCount = node.children?.filter((child) => child.type !== "saved-sql-root").length ?? 0; if (node.type === "database" || node.type === "schema" || node.type === "linked-server-schema") { if (childCount > 0) return true; // Grouped mode always materializes object-group placeholders; empty means a stale shell. @@ -23,5 +23,5 @@ export function treeNodeLoadedChildrenContentPresent(node: TreeNode, sidebarObje export function simpleModeEmptyShellNeedsConfirmedLoad(node: TreeNode, sidebarObjectDisplay: SidebarObjectDisplayMode): boolean { if (sidebarObjectDisplay !== "simple") return false; if (node.type !== "database" && node.type !== "schema" && node.type !== "linked-server-schema") return false; - return (node.children?.length ?? 0) === 0; + return (node.children?.filter((child) => child.type !== "saved-sql-root").length ?? 0) === 0; } diff --git a/apps/desktop/src/lib/sidebar/treeNodeClick.ts b/apps/desktop/src/lib/sidebar/treeNodeClick.ts index 26d3205db1..13b6b7a9ce 100644 --- a/apps/desktop/src/lib/sidebar/treeNodeClick.ts +++ b/apps/desktop/src/lib/sidebar/treeNodeClick.ts @@ -2,7 +2,7 @@ import type { DatabaseType, ObjectSourceKind, TreeNode, TreeNodeType } from "@/t import { customTypeCapabilities, supportsTypeObjectSource } from "@/lib/database/databaseObjectCapabilities"; import { matchesShortcut, type ShortcutLikeEvent } from "@/lib/editor/keyboardShortcuts"; -export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "toggle" | "none"; +export type TreeNodeRowAction = "open-data" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none"; export type TreeNodeRowDoubleClickAction = "open-data" | "activate-data" | "open-database-browser" | "open-object-browser" | "open-object-browser-and-expand" | "open-source" | "open-extension-details" | "open-saved-sql" | "toggle" | "none"; export type SidebarSelectionCopyAction = "copy-name" | "none"; export type SidebarActivation = "single" | "double"; @@ -104,6 +104,7 @@ function canOpenTreeNodeSource(type: TreeNodeType, dbType?: DatabaseType): boole export function treeNodeRowAction(type: TreeNodeType, canExpand: boolean, activation: SidebarActivation = "single", dbType?: DatabaseType): TreeNodeRowAction { if (!shouldActivateTreeNodeOnSingleClick(type, activation)) return "none"; if (type === "extension") return "open-extension-details"; + if (savedSqlNodeTypes.has(type)) return "open-saved-sql"; if (dataNodeTypes.has(type)) return "open-data"; // PostgreSQL-family custom types: open read-only details (toggle when expandable). if (type === "type" && customTypeCapabilities(dbType).details) return canExpand ? "toggle" : "none"; diff --git a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts index d0beccc3ad..3008956862 100644 --- a/apps/desktop/src/lib/sql/queryCursorTableTarget.ts +++ b/apps/desktop/src/lib/sql/queryCursorTableTarget.ts @@ -142,6 +142,7 @@ export function queryContextTargetFromCandidate(tab: QueryTab | undefined | null return { type: "query-context", connectionId: tab.connectionId, + catalog: tab.catalog, database: candidate?.database || tab.database, schema: candidate?.schema ?? tab.schema, }; diff --git a/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts index 9bcfba8e5e..9e88caf431 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.dorisCatalog.spec.ts @@ -121,6 +121,9 @@ describe("connectionStore Doris catalog tree", () => { expect(listTables).toHaveBeenCalledTimes(1); expect(listTables.mock.calls[0]?.[7]).toBe("iceberg"); expect(hiveDatabase.children).toEqual([]); - expect(icebergDatabase.children?.map((node) => [node.catalog, node.database, node.label])).toEqual([["iceberg", "sales", "orders"]]); + expect(icebergDatabase.children?.map((node) => [node.catalog, node.database, node.label])).toEqual([ + ["iceberg", "sales", "orders"], + ["iceberg", "sales", "tree.queries"], + ]); }); }); diff --git a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts index 6aa2586575..708412d728 100644 --- a/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts +++ b/apps/desktop/src/stores/__tests__/connectionStore.metadataLoading.spec.ts @@ -515,6 +515,7 @@ describe("connectionStore metadata loading", () => { expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([ ["schema", "app", "app"], ["schema", "reporting", "reporting"], + ["saved-sql-root", "tree.queries", undefined], ]); }); @@ -592,7 +593,10 @@ describe("connectionStore metadata loading", () => { expect(listSchemaInfos).toHaveBeenCalledWith(connection.id, "testdb"); expect(listTables).toHaveBeenCalled(); - expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([["table", "t", undefined]]); + expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([ + ["table", "t", undefined], + ["saved-sql-root", "tree.queries", undefined], + ]); }); it("keeps the flat object tree for GBase 8s databases that cannot qualify schemas in DML", async () => { @@ -626,7 +630,10 @@ describe("connectionStore metadata loading", () => { expect(listSchemaInfos).toHaveBeenCalledWith(connection.id, "dbx_test"); expect(listTables).toHaveBeenCalled(); - expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([["table", "connection_smoke", undefined]]); + expect(databaseNode.children?.map((node) => [node.type, node.label, node.schema])).toEqual([ + ["table", "connection_smoke", undefined], + ["saved-sql-root", "tree.queries", undefined], + ]); }); it("renders simple-mode table children without waiting for supplemental objects", async () => { @@ -1090,7 +1097,7 @@ describe("connectionStore metadata loading", () => { await store.loadSchemas(connection.id, "app", { force: true }); expect(store.connectionErrors[connection.id]).toBeUndefined(); - expect(store.treeNodes[0]?.children?.[0]?.children?.map((node) => node.label)).toEqual(["public", "tree.extensions"]); + expect(store.treeNodes[0]?.children?.[0]?.children?.map((node) => node.label)).toEqual(["public", "tree.extensions", "tree.queries"]); }); it("preserves the last successful tree snapshot when a forced metadata refresh fails", async () => { @@ -1207,7 +1214,7 @@ describe("connectionStore metadata loading", () => { await olderRefresh; expect(listSchemaInfos).toHaveBeenCalledTimes(2); - expect(databaseNode.children?.map((node) => node.label)).toEqual(["latest", "tree.extensions"]); + expect(databaseNode.children?.map((node) => node.label)).toEqual(["latest", "tree.extensions", "tree.queries"]); }); it("does not let an older refresh failure overwrite a newer successful refresh", async () => { @@ -1263,7 +1270,7 @@ describe("connectionStore metadata loading", () => { rejectOlderMetadata(new Error("connection closed")); await expect(olderRefresh).rejects.toThrow("connection closed"); - expect(databaseNode.children?.map((node) => node.label)).toEqual(["latest", "tree.extensions"]); + expect(databaseNode.children?.map((node) => node.label)).toEqual(["latest", "tree.extensions", "tree.queries"]); expect(store.connectionErrors[connection.id]).toBeUndefined(); expect(store.connectedIds.has(connection.id)).toBe(true); }); @@ -1396,7 +1403,7 @@ describe("connectionStore metadata loading", () => { const databaseNode = store.treeNodes[0]!.children![0]!; await store.loadSchemas(connection.id, "app"); - expect(databaseNode.children?.map((node) => node.label).filter((label) => label !== "tree.extensions")).toEqual(["public"]); + expect(databaseNode.children?.filter((node) => node.type === "schema").map((node) => node.label)).toEqual(["public"]); store.connections[0]!.show_system_schemas = true; databaseNode.children = []; @@ -1404,7 +1411,7 @@ describe("connectionStore metadata loading", () => { await store.loadSchemas(connection.id, "app"); expect(loadSchemaCache.mock.calls.map(([key]) => key)).toEqual([`${connection.id}:app:schemas-v3:hide-system`, `${connection.id}:app:schemas-v3:show-system`]); - expect(databaseNode.children?.map((node) => node.label).filter((label) => label !== "tree.extensions")).toEqual(["information_schema", "pg_catalog", "public"]); + expect(databaseNode.children?.filter((node) => node.type === "schema").map((node) => node.label)).toEqual(["information_schema", "pg_catalog", "public"]); }); it("clears a failed metadata warning when the driver hint finishes during retry", async () => { @@ -1838,7 +1845,8 @@ describe("connectionStore metadata loading", () => { await store.loadDatabases(connection.id, { force: true }); expect(listDatabases).toHaveBeenCalledTimes(1); - expect(connectionNode.children![0].children?.length ?? 0).toBe(0); + expect(connectionNode.children![0].children?.map((node) => node.type)).toEqual(["saved-sql-root"]); + expect(store.isTreeNodeChildrenLoaded(test1Id)).toBe(false); await store.loadTables(connection.id, "test1"); expect(connectionNode.children![0].children?.length).toBeGreaterThan(0); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 62c154374c..04849b9e03 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -103,6 +103,8 @@ import { connectionSupportsDatabaseUserAdmin } from "@/lib/database/databaseUser import { getTableMetadataCapabilities } from "@/lib/table/tableMetadataCapabilities"; import { mergeRedisCommandDocumentation, parseRedisCommandCatalog, parseRedisCommandDocumentation, type RedisCommandDocumentation } from "@/lib/redis/redisCommandDocs"; import { useSettingsStore } from "@/stores/settingsStore"; +import { useSavedSqlStore } from "@/stores/savedSqlStore"; +import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree"; import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers"; import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion"; import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities"; @@ -281,6 +283,10 @@ export type TreeClipboard = | { kind: "connection-copy"; connections: TreeClipboardConnectionEntry[]; + } + | { + kind: "saved-sql-copy"; + fileIds: string[]; }; interface LoadTreeOptions { @@ -314,6 +320,8 @@ function metadataDriverProfile(config?: ConnectionConfig): string | undefined { export const useConnectionStore = defineStore("connection", () => { const settingsStore = useSettingsStore(); const tunnelProfileStore = useTunnelProfileStore(); + const savedSqlStore = useSavedSqlStore(); + let savedSqlFilesByDatabase = indexSavedSqlFilesByDatabase(savedSqlStore.allFiles); const connections = ref([]); const isDesktop = isTauriRuntime(); const activeConnectionId = ref(localStorage.getItem(ACTIVE_CONNECTION_STORAGE_KEY)); @@ -1174,7 +1182,7 @@ export const useConnectionStore = defineStore("connection", () => { } function isConnectionUtilityNode(node: TreeNode): boolean { - return node.type === "user-admin" || node.type === "dameng-job-admin"; + return node.type === "user-admin" || node.type === "dameng-job-admin" || node.type === "saved-sql-root"; } function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] { @@ -1235,7 +1243,7 @@ export const useConnectionStore = defineStore("connection", () => { function syncConfirmedEmptyTreeNodeId(parent: TreeNode) { if (parent.type !== "database" && parent.type !== "schema" && parent.type !== "linked-server-schema") return; - const childCount = parent.children?.length ?? 0; + const childCount = parent.children?.filter((child) => child.type !== "saved-sql-root").length ?? 0; if (childCount === 0) confirmedEmptyTreeNodeIds.value.add(parent.id); else confirmedEmptyTreeNodeIds.value.delete(parent.id); } @@ -1273,6 +1281,10 @@ export const useConnectionStore = defineStore("connection", () => { function setChildren(parent: TreeNode, children: TreeNode[]) { // Compare markers against the resolved child list (after connection preserve), not the raw loader payload. children = preserveExistingConnectionMetadataChildren(parent, children); + children = decorateDatabaseSavedSqlTreeNodes(children, savedSqlFilesByDatabase, parent.children); + if (parent.type === "database") { + children = withDatabaseSavedSqlRoot(parent, children, savedSqlFilesByDatabase); + } if (shouldClearDescendantLoadedMarkers(parent, children)) { clearDescendantLoadedChildrenMarkers(parent.id); // Parent load may still be current; only supersede descendant generations. @@ -1441,6 +1453,24 @@ export const useConnectionStore = defineStore("connection", () => { return withConnectionUtilityNodes(connectionId, children, existingConnectionNode); } + function refreshDatabaseSavedSqlTrees(nodes: TreeNode[] = treeNodes.value) { + for (const node of nodes) { + if (node.type === "database") { + node.children = withDatabaseSavedSqlRoot(node, node.children || [], savedSqlFilesByDatabase); + } + if (node.children) refreshDatabaseSavedSqlTrees(node.children); + } + } + + watch( + () => savedSqlStore.treeVersion, + () => { + savedSqlFilesByDatabase = indexSavedSqlFilesByDatabase(savedSqlStore.allFiles); + refreshDatabaseSavedSqlTrees(); + }, + { flush: "sync" }, + ); + function schemaCacheKey(...parts: string[]): string { return parts.map((part) => encodeURIComponent(part)).join(":"); } @@ -2010,7 +2040,7 @@ export const useConnectionStore = defineStore("connection", () => { } async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) { - await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(children)).catch(() => undefined); + await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(stripDatabaseSavedSqlTreeNodes(children))).catch(() => undefined); } function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null { diff --git a/apps/desktop/src/stores/savedSqlStore.ts b/apps/desktop/src/stores/savedSqlStore.ts index be94b16d83..a1cea7b815 100644 --- a/apps/desktop/src/stores/savedSqlStore.ts +++ b/apps/desktop/src/stores/savedSqlStore.ts @@ -4,6 +4,8 @@ import { uuid } from "@/lib/common/utils"; import * as api from "@/lib/backend/api"; import { forgetSavedSqlEditorPosition } from "@/lib/app/savedSqlEditorPosition"; import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName"; +import { nextSavedSqlCopyName } from "@/lib/savedSql/savedSqlClipboard"; +import { savedSqlDatabaseScopeKey } from "@/lib/savedSql/savedSqlDatabaseTree"; import { isTauriRuntime } from "@/lib/backend/tauriRuntime"; import { useSettingsStore } from "@/stores/settingsStore"; import type { SavedSqlFile, SavedSqlFolder, SavedSqlLibrary } from "@/types/database"; @@ -33,6 +35,29 @@ interface SavedSqlExecutionTargetInput { schema?: string; } +interface SavedSqlNameScope { + id: string; + connectionId: string; + catalog?: string; + database: string; + folderId?: string; + name: string; +} + +interface PendingSavedSqlName { + ownerId: string; + count: number; +} + +export class SavedSqlNameConflictError extends Error { + readonly code = "SAVED_SQL_NAME_CONFLICT"; + + constructor(readonly fileName: string) { + super(`SQL "${fileName}" already exists in this location.`); + this.name = "SavedSqlNameConflictError"; + } +} + function nowIso() { return new Date().toISOString(); } @@ -69,6 +94,34 @@ function maxOrderIndex(values: Array<{ orderIndex?: number }>) { return values.reduce((max, item) => Math.max(max, item.orderIndex ?? -1), -1); } +function normalizedCatalog(catalog: string | null | undefined): string | undefined { + return catalog || undefined; +} + +function normalizeSavedSqlFile(file: SavedSqlFile): SavedSqlFile { + return { ...file, catalog: normalizedCatalog(file.catalog) }; +} + +function savedSqlNameKey(name: string): string { + return ensureSqlExtension(name).toLocaleLowerCase(); +} + +function savedSqlNameScopeKey(file: Pick): string { + // Database queries are displayed together below the database tree node, even + // when their SQL-library folders differ. Unassociated library queries retain + // normal folder semantics, so separate folders may use the same file name. + if (file.database) return JSON.stringify(["database", savedSqlDatabaseScopeKey(file)]); + return JSON.stringify(["library-folder", file.connectionId, file.folderId || null]); +} + +function savedSqlNameIdentity(file: SavedSqlNameScope): string { + return JSON.stringify([savedSqlNameScopeKey(file), savedSqlNameKey(file.name)]); +} + +function savedSqlTreeIdentity(file: SavedSqlFile): string { + return JSON.stringify([file.id, file.connectionId, normalizedCatalog(file.catalog) ?? null, file.database, file.schema ?? null, file.name]); +} + function folderDepth(items: SavedSqlFolder[], folderId: string) { const byId = new Map(items.map((folder) => [folder.id, folder])); const seen = new Set(); @@ -106,16 +159,19 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const fileTargetRevisions = new Map(); const pendingFileTargetSaves = new Map>(); const persistedFileTargets = new Map(); + const pendingNamesByScope = new Map>(); const version = ref(0); - function bumpVersion() { + const treeVersion = ref(0); + function bumpVersion(options: { tree?: boolean } = {}) { version.value++; + if (options.tree) treeVersion.value++; } function applyLibrary(library: SavedSqlLibrary) { folders.value = library.folders; - files.value = library.files.map((file) => ({ ...file, sqlLoaded: file.sqlLoaded ?? Boolean(file.sql) })); - bumpVersion(); + files.value = library.files.map((file) => ({ ...normalizeSavedSqlFile(file), sqlLoaded: file.sqlLoaded ?? Boolean(file.sql) })); + bumpVersion({ tree: true }); } async function migrateLegacyLocalStorage() { @@ -165,6 +221,39 @@ export const useSavedSqlStore = defineStore("savedSql", () => { return files.value.find((file) => file.id === id); } + function reserveFileName(file: SavedSqlNameScope): () => void { + const scopeKey = savedSqlNameScopeKey(file); + const nameKey = savedSqlNameKey(file.name); + const persistedConflict = files.value.some((candidate) => candidate.id !== file.id && savedSqlNameScopeKey(candidate) === scopeKey && savedSqlNameKey(candidate.name) === nameKey); + if (persistedConflict) throw new SavedSqlNameConflictError(file.name); + + let pendingNames = pendingNamesByScope.get(scopeKey); + if (!pendingNames) { + pendingNames = new Map(); + pendingNamesByScope.set(scopeKey, pendingNames); + } + const pending = pendingNames.get(nameKey); + if (pending && pending.ownerId !== file.id) throw new SavedSqlNameConflictError(file.name); + if (pending) pending.count++; + else pendingNames.set(nameKey, { ownerId: file.id, count: 1 }); + + let released = false; + return () => { + if (released) return; + released = true; + const currentNames = pendingNamesByScope.get(scopeKey); + const current = currentNames?.get(nameKey); + if (!current || current.ownerId !== file.id) return; + current.count--; + if (current.count <= 0) currentNames?.delete(nameKey); + if (currentNames?.size === 0) pendingNamesByScope.delete(scopeKey); + }; + } + + function pendingFileNames(scopeKey: string): string[] { + return [...(pendingNamesByScope.get(scopeKey)?.keys() ?? [])]; + } + async function ensureFileContent(id: string) { const existing = getFile(id); if (!existing) return undefined; @@ -172,7 +261,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const loaded = await api.loadSavedSqlFile(id); if (!loaded) return existing; - const hydrated = { ...loaded, sqlLoaded: true }; + const hydrated = { ...normalizeSavedSqlFile(loaded), sqlLoaded: true }; files.value = files.value.map((file) => (file.id === id ? hydrated : file)); bumpVersion(); return hydrated; @@ -222,16 +311,18 @@ export const useSavedSqlStore = defineStore("savedSql", () => { async function deleteFolder(id: string) { const removedIds = descendantFolderIds(id); + const removesFiles = files.value.some((file) => !!file.folderId && removedIds.has(file.folderId)); await api.deleteSavedSqlFolder(id); folders.value = folders.value.filter((folder) => !removedIds.has(folder.id)); files.value = files.value.filter((file) => !file.folderId || !removedIds.has(file.folderId)); - bumpVersion(); + bumpVersion({ tree: removesFiles }); await syncToLocalDirectory(); } async function saveFile(input: SaveFileInput) { const timestamp = nowIso(); const existing = input.id ? getFile(input.id) : undefined; + const catalog = normalizedCatalog(input.catalog); const hasFolderIdInput = Object.prototype.hasOwnProperty.call(input, "folderId"); const file: SavedSqlFile = existing ? { @@ -241,20 +332,20 @@ 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, connectionId: input.connectionId, + catalog, updatedAt: timestamp, } : { id: uuid(), connectionId: input.connectionId, + catalog, folderId: input.folderId || undefined, name: input.name, database: input.database, - catalog: input.catalog, schema: input.schema, sql: input.sql, sqlLoaded: true, @@ -262,33 +353,40 @@ export const useSavedSqlStore = defineStore("savedSql", () => { createdAt: timestamp, updatedAt: timestamp, }; - const saved = await api.saveSavedSqlFile(file); - files.value = [...files.value.filter((item) => item.id !== saved.id), { ...saved, sqlLoaded: true }]; - bumpVersion(); - await syncToLocalDirectory(); - return saved; + const nameIdentityChanged = !existing || savedSqlNameIdentity(existing) !== savedSqlNameIdentity(file); + const releaseName = nameIdentityChanged ? reserveFileName(file) : undefined; + try { + const saved = normalizeSavedSqlFile(await api.saveSavedSqlFile(file)); + files.value = [...files.value.filter((item) => item.id !== saved.id), { ...saved, sqlLoaded: true }]; + bumpVersion({ tree: !existing || savedSqlTreeIdentity(existing) !== savedSqlTreeIdentity(saved) }); + await syncToLocalDirectory(); + return saved; + } finally { + releaseName?.(); + } } function updateFileExecutionTarget(id: string, target: SavedSqlExecutionTargetInput): Promise { const existing = getFile(id); if (!existing) return Promise.resolve(undefined); - if (existing.connectionId === target.connectionId && existing.database === target.database && existing.catalog === target.catalog && existing.schema === target.schema) { + const normalizedTarget = { ...target, catalog: normalizedCatalog(target.catalog) }; + if (existing.connectionId === normalizedTarget.connectionId && normalizedCatalog(existing.catalog) === normalizedTarget.catalog && existing.database === normalizedTarget.database && existing.schema === normalizedTarget.schema) { return Promise.resolve(existing); } if (!persistedFileTargets.has(id)) { persistedFileTargets.set(id, { connectionId: existing.connectionId, + catalog: normalizedCatalog(existing.catalog), database: existing.database, - catalog: existing.catalog, schema: existing.schema, updatedAt: existing.updatedAt, }); } const revision = (fileTargetRevisions.get(id) ?? 0) + 1; fileTargetRevisions.set(id, revision); - files.value = files.value.map((file) => (file.id === id ? { ...file, ...target, updatedAt: nowIso() } : file)); - bumpVersion(); + files.value = files.value.map((file) => (file.id === id ? { ...file, ...normalizedTarget, updatedAt: nowIso() } : file)); + bumpVersion({ tree: true }); const previousSave = pendingFileTargetSaves.get(id) ?? Promise.resolve(undefined); const save = previousSave @@ -301,19 +399,30 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const candidate: SavedSqlFile = { ...loaded, - ...target, + ...normalizedTarget, sqlLoaded: true, updatedAt: nowIso(), }; files.value = files.value.map((file) => (file.id === id ? candidate : file)); bumpVersion(); + const persistedTarget = persistedFileTargets.get(id); + const persistedNameIdentity = persistedTarget + ? savedSqlNameIdentity({ + ...candidate, + connectionId: persistedTarget.connectionId, + catalog: persistedTarget.catalog, + database: persistedTarget.database, + }) + : savedSqlNameIdentity(candidate); + let releaseName: (() => void) | undefined; try { - const saved = await api.saveSavedSqlFile(candidate); + if (persistedNameIdentity !== savedSqlNameIdentity(candidate)) releaseName = reserveFileName(candidate); + const saved = normalizeSavedSqlFile(await api.saveSavedSqlFile(candidate)); persistedFileTargets.set(id, { connectionId: saved.connectionId, + catalog: normalizedCatalog(saved.catalog), database: saved.database, - catalog: saved.catalog, schema: saved.schema, updatedAt: saved.updatedAt, }); @@ -328,9 +437,11 @@ export const useSavedSqlStore = defineStore("savedSql", () => { if (fileTargetRevisions.get(id) === revision) { const persistedTarget = persistedFileTargets.get(id); if (persistedTarget) files.value = files.value.map((file) => (file.id === id ? { ...file, ...persistedTarget } : file)); - bumpVersion(); + bumpVersion({ tree: true }); } throw error; + } finally { + releaseName?.(); } }); @@ -348,20 +459,58 @@ export const useSavedSqlStore = defineStore("savedSql", () => { const existing = getFile(id); if (!existing) return; const normalizedName = ensureSqlExtension(name); - const saved = await api.saveSavedSqlFile({ ...existing, name: normalizedName, updatedAt: nowIso() }); - files.value = files.value.map((file) => (file.id === id ? { ...saved, sql: file.sql, sqlLoaded: file.sqlLoaded } : file)); - bumpVersion(); + if (normalizedName === existing.name) return existing; + const candidate = { ...existing, name: normalizedName, updatedAt: nowIso() }; + const releaseName = reserveFileName(candidate); + try { + const saved = normalizeSavedSqlFile(await api.saveSavedSqlFile(candidate)); + files.value = files.value.map((file) => (file.id === id ? { ...saved, sql: file.sql, sqlLoaded: file.sqlLoaded } : file)); + bumpVersion({ tree: true }); - const { useQueryStore } = await import("@/stores/queryStore"); - const queryStore = useQueryStore(); - for (const tab of queryStore.tabs) { - if (tab.savedSqlId === id) { - tab.title = saved.name; - tab.customTitle = true; + const { useQueryStore } = await import("@/stores/queryStore"); + const queryStore = useQueryStore(); + for (const tab of queryStore.tabs) { + if (tab.savedSqlId === id) { + tab.title = saved.name; + tab.customTitle = true; + } } + + await syncToLocalDirectory(); + return saved; + } finally { + releaseName(); + } + } + + async function copyFilesToDatabase(fileIds: readonly string[], target: SavedSqlExecutionTargetInput): Promise { + const uniqueFileIds = [...new Set(fileIds)]; + const normalizedTarget = { ...target, catalog: normalizedCatalog(target.catalog) }; + const copies: SavedSqlFile[] = []; + + for (const fileId of uniqueFileIds) { + const source = await ensureFileContent(fileId); + if (!source) continue; + const sourceFolder = source.folderId ? folders.value.find((folder) => folder.id === source.folderId) : undefined; + const folderId = sourceFolder?.connectionId === normalizedTarget.connectionId ? source.folderId : undefined; + const copyScope = { ...normalizedTarget, folderId }; + const scopeKey = savedSqlNameScopeKey(copyScope); + const takenNames = new Set([...files.value.filter((file) => savedSqlNameScopeKey(file) === scopeKey).map((file) => file.name), ...pendingFileNames(scopeKey)]); + const name = nextSavedSqlCopyName(source.name, takenNames); + const keepSourceScope = savedSqlDatabaseScopeKey(source) === savedSqlDatabaseScopeKey(normalizedTarget); + const saved = await saveFile({ + connectionId: normalizedTarget.connectionId, + catalog: normalizedTarget.catalog, + folderId, + name, + database: normalizedTarget.database, + schema: keepSourceScope ? source.schema : normalizedTarget.schema, + sql: source.sql, + }); + copies.push({ ...saved, sqlLoaded: true }); } - await syncToLocalDirectory(); + return copies; } async function recordFileUsage(id: string) { @@ -385,7 +534,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { async function deleteFile(id: string) { await api.deleteSavedSqlFile(id); files.value = files.value.filter((file) => file.id !== id); - bumpVersion(); + bumpVersion({ tree: true }); await syncToLocalDirectory(); // Close all tabs that reference this saved SQL file @@ -407,13 +556,24 @@ export const useSavedSqlStore = defineStore("savedSql", () => { } async function persistFiles(nextFiles: SavedSqlFile[]) { - const savedFiles = await Promise.all(nextFiles.map((file) => api.saveSavedSqlFile(file))); - files.value = savedFiles.map((saved) => { - const existing = files.value.find((file) => file.id === saved.id); - return { ...saved, sql: existing?.sql ?? saved.sql, sqlLoaded: existing?.sqlLoaded ?? saved.sqlLoaded }; - }); - bumpVersion(); - await syncToLocalDirectory(); + const previousTreeIdentities = new Map(files.value.map((file) => [file.id, savedSqlTreeIdentity(file)])); + const previousNameIdentities = new Map(files.value.map((file) => [file.id, savedSqlNameIdentity(file)])); + const releaseNames: Array<() => void> = []; + try { + for (const file of nextFiles) { + if (previousNameIdentities.get(file.id) !== savedSqlNameIdentity(file)) releaseNames.push(reserveFileName(file)); + } + const savedFiles = await Promise.all(nextFiles.map((file) => api.saveSavedSqlFile(file))); + files.value = savedFiles.map((saved) => { + const existing = files.value.find((file) => file.id === saved.id); + return { ...normalizeSavedSqlFile(saved), sql: existing?.sql ?? saved.sql, sqlLoaded: existing?.sqlLoaded ?? saved.sqlLoaded }; + }); + const treeChanged = files.value.length !== previousTreeIdentities.size || files.value.some((file) => previousTreeIdentities.get(file.id) !== savedSqlTreeIdentity(file)); + bumpVersion({ tree: treeChanged }); + await syncToLocalDirectory(); + } finally { + for (const releaseName of releaseNames) releaseName(); + } } async function syncEntries() { @@ -648,6 +808,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { files, isLoaded, version, + treeVersion, initFromStorage, listFolders, listChildFolders, @@ -660,6 +821,7 @@ export const useSavedSqlStore = defineStore("savedSql", () => { saveFile, updateFileExecutionTarget, renameFile, + copyFilesToDatabase, recordFileUsage, deleteFile, reorderFolders, diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 9f2511fff3..1bf0801032 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1302,6 +1302,7 @@ export interface SavedSqlFile { folderId?: string; name: string; database: string; + /** Undefined means the connection's built-in/default catalog. */ catalog?: string; schema?: string; sql: string; diff --git a/crates/dbx-core/src/saved_sql.rs b/crates/dbx-core/src/saved_sql.rs index f630d52a14..057b381f2d 100644 --- a/crates/dbx-core/src/saved_sql.rs +++ b/crates/dbx-core/src/saved_sql.rs @@ -25,6 +25,7 @@ pub struct SavedSqlFile { pub folder_id: Option, pub name: String, pub database: String, + /// `None` is the built-in/default catalog and preserves legacy saved SQL scope. #[serde(default)] pub catalog: Option, pub schema: Option, @@ -46,3 +47,26 @@ pub struct SavedSqlLibrary { pub folders: Vec, pub files: Vec, } + +#[cfg(test)] +mod tests { + use super::SavedSqlFile; + + #[test] + fn saved_sql_file_deserializes_legacy_json_without_catalog() { + let file: SavedSqlFile = serde_json::from_value(serde_json::json!({ + "id": "legacy", + "connectionId": "conn-1", + "folderId": null, + "name": "legacy.sql", + "database": "analytics", + "schema": null, + "sql": "SELECT 1;", + "createdAt": "2026-08-12", + "updatedAt": "2026-08-12" + })) + .unwrap(); + + assert_eq!(file.catalog, None); + } +} diff --git a/crates/dbx-core/src/storage.rs b/crates/dbx-core/src/storage.rs index f2a3a8b1ed..f38ce9f466 100644 --- a/crates/dbx-core/src/storage.rs +++ b/crates/dbx-core/src/storage.rs @@ -5680,6 +5680,71 @@ mod tests { assert_eq!(loaded.sql, "SELECT 1;"); } + #[tokio::test] + async fn saved_sql_catalog_migration_keeps_legacy_rows_in_default_scope_across_restart() { + let path = temp_db_path("saved-sql-catalog-restart-migration"); + { + let connection = Connection::open(&path).unwrap(); + connection + .execute( + "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 '' + )", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO saved_sql_files + (id, connection_id, name, database_name, sql_text, created_at, updated_at) + VALUES ('legacy', 'conn-1', 'legacy.sql', 'analytics', 'SELECT 1;', '2026-08-12', '2026-08-12')", + [], + ) + .unwrap(); + } + + let storage = Storage::open(&path).await.unwrap(); + let legacy = storage.load_saved_sql_file("legacy").await.unwrap().unwrap(); + assert_eq!(legacy.catalog, None); + + let external = SavedSqlFile { + id: "external".to_string(), + connection_id: "conn-1".to_string(), + catalog: Some("iceberg_catalog".to_string()), + folder_id: None, + name: "external.sql".to_string(), + database: "analytics".to_string(), + schema: None, + sql: "SELECT 2;".to_string(), + sql_loaded: true, + order_index: 1, + open_count: 0, + opened_at: None, + created_at: "2026-08-12".to_string(), + updated_at: "2026-08-12".to_string(), + }; + storage.save_saved_sql_file(&external).await.unwrap(); + drop(storage); + + let reopened = Storage::open(&path).await.unwrap(); + assert_eq!(reopened.load_saved_sql_file("legacy").await.unwrap().unwrap().catalog, None); + assert_eq!( + reopened.load_saved_sql_file("external").await.unwrap().unwrap().catalog.as_deref(), + Some("iceberg_catalog") + ); + } + // ---- AI Config tests ---- use crate::ai::{ diff --git a/packages/app-tests/queryCursorTableTarget.test.ts b/packages/app-tests/queryCursorTableTarget.test.ts index 9969a72ee7..9f10455d70 100644 --- a/packages/app-tests/queryCursorTableTarget.test.ts +++ b/packages/app-tests/queryCursorTableTarget.test.ts @@ -273,6 +273,7 @@ test("falls back to the candidate database and schema when no table is loaded", assert.deepEqual(queryContextTargetFromCandidate(tab, candidate), { type: "query-context", connectionId: "conn-1", + catalog: undefined, database: "app", schema: "reporting", }); diff --git a/packages/app-tests/savedSqlErrors.test.ts b/packages/app-tests/savedSqlErrors.test.ts new file mode 100644 index 0000000000..ba9dcbc040 --- /dev/null +++ b/packages/app-tests/savedSqlErrors.test.ts @@ -0,0 +1,16 @@ +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { savedSqlErrorMessage } from "../../apps/desktop/src/lib/savedSql/savedSqlErrors.ts"; + +test("saved SQL name conflicts use the localized message", () => { + const message = savedSqlErrorMessage({ code: "SAVED_SQL_NAME_CONFLICT", fileName: "report.sql" }, (key, params) => `${key}:${params?.name}`); + + assert.equal(message, "savedSql.nameConflict:report.sql"); +}); + +test("other saved SQL errors keep their original message", () => { + assert.equal( + savedSqlErrorMessage(new Error("disk full"), () => "translated"), + "disk full", + ); +}); diff --git a/packages/app-tests/savedSqlHistory.test.ts b/packages/app-tests/savedSqlHistory.test.ts index 5b5f1ba4e7..226e7826c2 100644 --- a/packages/app-tests/savedSqlHistory.test.ts +++ b/packages/app-tests/savedSqlHistory.test.ts @@ -27,6 +27,16 @@ test("SQL 库历史按连接和数据库过滤", () => { assert.equal(savedSqlMatchesHistoryScope(files[2], { connectionId: "conn-1" }), false); }); +test("SQL 库历史不会混入同名数据库的其他 catalog", () => { + const files = [ + file({ id: "default", name: "default.sql", connectionId: "conn-1", database: "app" }), + file({ id: "hive", name: "hive.sql", connectionId: "conn-1", catalog: "hive", database: "app" }), + ]; + + assert.deepEqual(rankSavedSqlHistory(files, { connectionId: "conn-1", catalog: null, database: "app" }).map((item) => item.id), ["default"]); + assert.deepEqual(rankSavedSqlHistory(files, { connectionId: "conn-1", catalog: "hive", database: "app" }).map((item) => item.id), ["hive"]); +}); + test("SQL 库历史综合打开次数和最近打开时间排序", () => { const now = Date.parse("2026-06-11T12:00:00.000Z"); const files = [ diff --git a/packages/app-tests/savedSqlStore.test.ts b/packages/app-tests/savedSqlStore.test.ts index 954141578d..0a06a1f80b 100644 --- a/packages/app-tests/savedSqlStore.test.ts +++ b/packages/app-tests/savedSqlStore.test.ts @@ -261,6 +261,42 @@ test("saved SQL target changes roll back when persistence fails", async () => { ); }); +test("moving a saved SQL target rejects a duplicate name in the destination database", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-target", + connectionId: "conn-1", + catalog: "hive", + name: "query.sql", + database: "db-1", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + { + id: "sql-existing", + connectionId: "conn-1", + catalog: "hive", + name: "QUERY.SQL", + database: "db-2", + sql: "SELECT 2;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await assert.rejects(store.updateFileExecutionTarget("sql-target", { connectionId: "conn-1", catalog: "hive", database: "db-2" }), /already exists/); + + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 0); + assert.equal(store.getFile("sql-target")?.database, "db-1"); +}); + test("empty and deleted-connection SQL files remain in the library", async () => { const files: SavedSqlFile[] = [ { @@ -356,6 +392,45 @@ test("saving an existing SQL file with root folder explicitly moves it to root", assert.equal(store.getFile("sql-1")?.folderId, undefined); }); +test("new SQL files use folder-scoped names until they are associated with a database", async () => { + const existing: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + folderId: "folder-1", + name: "query.sql", + database: "", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [existing] }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + const saved = await store.saveFile({ + connectionId: "conn-1", + folderId: "folder-2", + name: "QUERY.SQL", + database: "", + sql: "SELECT 2;", + }); + await assert.rejects( + store.saveFile({ + connectionId: "conn-1", + folderId: "folder-1", + name: "Query.sql", + database: "", + sql: "SELECT 3;", + }), + /already exists/, + ); + + assert.equal(saved.folderId, "folder-2"); + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1); +}); + test("moving multiple saved SQL files to a folder keeps existing target files", async () => { const files: SavedSqlFile[] = [ { @@ -448,6 +523,42 @@ test("moving selected files already in the target folder keeps them in place", a ); }); +test("moving an unassociated SQL file rejects a duplicate name in the destination folder", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-1", + connectionId: "conn-1", + folderId: "folder-1", + name: "report.sql", + database: "", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + { + id: "sql-2", + connectionId: "conn-1", + folderId: "folder-2", + name: "REPORT.SQL", + database: "", + sql: "SELECT 2;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await assert.rejects(store.moveFileToFolder("sql-1", "folder-2"), /already exists/); + + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 0); + assert.equal(store.getFile("sql-1")?.folderId, "folder-1"); +}); + test("renaming a saved SQL file syncs linked tab titles", async () => { const file: SavedSqlFile = { id: "sql-1", @@ -475,6 +586,293 @@ test("renaming a saved SQL file syncs linked tab titles", async () => { assert.equal(queryStore.tabs.find((item) => item.id === tabId)?.title, "revenue.sql"); }); +test("renaming a saved SQL file rejects a case-insensitive duplicate in the database", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-1", + connectionId: "conn-1", + catalog: "hive", + folderId: "folder-1", + name: "draft.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + { + id: "sql-2", + connectionId: "conn-1", + catalog: "hive", + folderId: "folder-2", + name: "Revenue.SQL", + database: "analytics", + sql: "SELECT 2;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await assert.rejects(store.renameFile("sql-1", "revenue.sql"), /already exists/); + + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 0); + assert.equal(store.getFile("sql-1")?.name, "draft.sql"); +}); + +test("saved SQL names remain independent across catalogs", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-hive", + connectionId: "conn-1", + catalog: "hive", + name: "draft.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + { + id: "sql-iceberg", + connectionId: "conn-1", + catalog: "iceberg", + name: "report.sql", + database: "analytics", + sql: "SELECT 2;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + await store.renameFile("sql-hive", "report.sql"); + + assert.equal(store.getFile("sql-hive")?.name, "report.sql"); + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1); +}); + +test("failed saved SQL rename releases the requested name for retry", async () => { + const file: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "draft.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [file] }); + apiMock.saveSavedSqlFile.mockRejectedValueOnce(new Error("disk full")).mockImplementation(async (value) => value); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + await assert.rejects(store.renameFile("sql-1", "revenue.sql"), /disk full/); + await store.renameFile("sql-1", "revenue.sql"); + + assert.equal(store.getFile("sql-1")?.name, "revenue.sql"); + assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 2); +}); + +test("copies saved SQL into the target database with a Navicat-style suffix", async () => { + const folder: SavedSqlFolder = { + id: "folder-1", + connectionId: "conn-1", + name: "Reports", + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + const source: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + folderId: "folder-1", + name: "report.sql", + database: "app", + schema: "public", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + const firstCopy: SavedSqlFile = { ...source, id: "sql-2", name: "report_copy1.sql" }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [folder], files: [source, firstCopy] }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + const copies = await store.copyFilesToDatabase([source.id], { + connectionId: "conn-1", + database: "app", + schema: "public", + }); + + assert.equal(copies.length, 1); + assert.equal(copies[0]?.name, "report_copy2.sql"); + assert.equal(copies[0]?.connectionId, "conn-1"); + assert.equal(copies[0]?.database, "app"); + assert.equal(copies[0]?.schema, "public"); + assert.equal(copies[0]?.folderId, "folder-1"); + assert.equal(copies[0]?.sql, "SELECT 1;"); +}); + +test("hydrates saved SQL before copying it to another database", async () => { + const summary: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "report.sql", + database: "app", + schema: "public", + sql: "", + sqlLoaded: false, + createdAt: "2026-06-27T00:00:00.000Z", + updatedAt: "2026-06-27T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [summary] }); + apiMock.loadSavedSqlFile.mockResolvedValue({ ...summary, sql: "SELECT * FROM reports;", sqlLoaded: true }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + const [copy] = await store.copyFilesToDatabase([summary.id], { + connectionId: "conn-2", + database: "analytics", + schema: "reporting", + }); + + assert.equal(copy?.name, "report_copy1.sql"); + assert.equal(copy?.connectionId, "conn-2"); + assert.equal(copy?.database, "analytics"); + assert.equal(copy?.schema, "reporting"); + assert.equal(copy?.sql, "SELECT * FROM reports;"); + assert.equal(apiMock.loadSavedSqlFile.mock.calls.length, 1); +}); + +test("concurrent saved SQL pastes reserve different copy names in the same database scope", async () => { + const source: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "report.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [source] }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + const [first, second] = await Promise.all([store.copyFilesToDatabase([source.id], { connectionId: "conn-1", catalog: "hive", database: "analytics" }), store.copyFilesToDatabase([source.id], { connectionId: "conn-1", catalog: "hive", database: "analytics" })]); + + assert.deepEqual([first[0]?.name, second[0]?.name].sort(), ["report_copy1.sql", "report_copy2.sql"]); + assert.equal(first[0]?.catalog, "hive"); + assert.equal(second[0]?.catalog, "hive"); +}); + +test("saved SQL paste skips a copy name reserved by a concurrent rename", async () => { + const files: SavedSqlFile[] = [ + { + id: "sql-source", + connectionId: "conn-1", + name: "report.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + { + id: "sql-rename", + connectionId: "conn-1", + name: "draft.sql", + database: "analytics", + sql: "SELECT 2;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }, + ]; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files }); + let finishRename: (() => void) | undefined; + apiMock.saveSavedSqlFile.mockImplementation((file) => + file.id === "sql-rename" + ? new Promise((resolve) => { + finishRename = () => resolve(file); + }) + : Promise.resolve(file), + ); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + + const rename = store.renameFile("sql-rename", "report_copy1.sql"); + await vi.waitFor(() => assert.equal(apiMock.saveSavedSqlFile.mock.calls.length, 1)); + const [copy] = await store.copyFilesToDatabase(["sql-source"], { + connectionId: "conn-1", + database: "analytics", + }); + finishRename?.(); + await rename; + + assert.equal(copy?.name, "report_copy2.sql"); + assert.equal(store.getFile("sql-rename")?.name, "report_copy1.sql"); +}); + +test("failed saved SQL paste releases its reserved copy name for retry", async () => { + const source: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "report.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [source] }); + apiMock.saveSavedSqlFile.mockRejectedValueOnce(new Error("disk full")).mockImplementation(async (file) => file); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + const target = { connectionId: "conn-1", catalog: "hive", database: "analytics" }; + + await assert.rejects(store.copyFilesToDatabase([source.id], target), /disk full/); + const [retry] = await store.copyFilesToDatabase([source.id], target); + + assert.equal(retry?.name, "report_copy1.sql"); +}); + +test("usage updates do not invalidate the saved SQL database tree", async () => { + const source: SavedSqlFile = { + id: "sql-1", + connectionId: "conn-1", + name: "report.sql", + database: "analytics", + sql: "SELECT 1;", + sqlLoaded: true, + createdAt: "2026-08-12T00:00:00.000Z", + updatedAt: "2026-08-12T00:00:00.000Z", + }; + apiMock.loadSavedSqlLibrary.mockResolvedValue({ folders: [], files: [source] }); + + const store = useSavedSqlStore(); + await store.initFromStorage(); + const treeVersion = store.treeVersion; + const contentVersion = store.version; + + await store.recordFileUsage(source.id); + + assert.equal(store.treeVersion, treeVersion); + assert.ok(store.version > contentVersion); +}); + test("renaming a saved SQL tab syncs the library file name", async () => { const file: SavedSqlFile = { id: "sql-1", diff --git a/packages/app-tests/sidebarActiveTabTarget.test.ts b/packages/app-tests/sidebarActiveTabTarget.test.ts index eb83ec79be..4a5b879f04 100644 --- a/packages/app-tests/sidebarActiveTabTarget.test.ts +++ b/packages/app-tests/sidebarActiveTabTarget.test.ts @@ -381,10 +381,37 @@ test("saved SQL tabs target the matching visible saved SQL file node", () => { isExecuting: false, mode: "query", }; - const file: TreeNode = { id: "file-node", label: "report.sql", type: "saved-sql-file", savedSqlId: "sql-1" }; + const file: TreeNode = { id: "file-node", label: "report.sql", type: "saved-sql-file", connectionId: "conn-1", database: "app", savedSqlId: "sql-1" }; + const queries: TreeNode = { + id: "queries-node", + label: "tree.queries", + type: "saved-sql-root", + connectionId: "conn-1", + database: "app", + children: [file], + }; + const database: TreeNode = { + id: "database-node", + label: "app", + type: "database", + connectionId: "conn-1", + database: "app", + children: [queries], + }; + const connection: TreeNode = { + id: "conn-1", + label: "Local", + type: "connection", + connectionId: "conn-1", + children: [database], + }; assert.deepEqual(activeTabSidebarTarget(tab), { type: "saved-sql-file", savedSqlId: "sql-1" }); assert.equal(findSidebarNodeForActiveTab(tab, [flat(file)])?.id, "file-node"); + assert.deepEqual( + findNodePathForTarget(activeTabSidebarTarget(tab)!, [connection])?.map((node) => node.id), + ["conn-1", "database-node", "queries-node", "file-node"], + ); }); test("query tabs target their database node in the sidebar", () => { @@ -401,6 +428,7 @@ test("query tabs target their database node in the sidebar", () => { assert.deepEqual(activeTabSidebarTarget(tab), { type: "query-context", connectionId: "conn-1", + catalog: undefined, database: "app", schema: undefined, }); diff --git a/packages/app-tests/sidebarContextMenuHost.test.ts b/packages/app-tests/sidebarContextMenuHost.test.ts index faaa8f618d..b3c6f2cc0d 100644 --- a/packages/app-tests/sidebarContextMenuHost.test.ts +++ b/packages/app-tests/sidebarContextMenuHost.test.ts @@ -130,6 +130,39 @@ test("sidebar keyboard table copy uses the same normalized schema as the context assert.match(copySelectedSidebarNamesBody, /schema: connectionObjectTreeNodeSchema\(store\.getConfig\(node\.connectionId!\), node\.database!, node\.schema\)/); }); +test("saved SQL tree rows expose copy, paste, export, rename, and confirmed deletion through the shared runtime host", () => { + const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); + const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); + const treeItem = readFileSync("apps/desktop/src/components/sidebar/TreeItem.vue", "utf8"); + const specialMenuBody = functionBody(runtimeHost, "buildSpecialSidebarMenu"); + const pasteBody = functionBody(runtimeHost, "requestPasteTreeClipboard"); + const savedSqlMenuStart = specialMenuBody.indexOf('if (node.type === "saved-sql-file")'); + const savedSqlMenuEnd = specialMenuBody.indexOf("// 5. Redis DB / Mongo DB", savedSqlMenuStart); + const savedSqlMenuBody = specialMenuBody.slice(savedSqlMenuStart, savedSqlMenuEnd); + + assert.match(specialMenuBody, /node\.type === "saved-sql-root"[\s\S]*?savedSql\.pasteFile/); + assert.match(savedSqlMenuBody, /savedSql\.copyFile[\s\S]*?savedSql\.pasteFile[\s\S]*?sqlLibrary\.exportFile[\s\S]*?savedSql\.renameFile[\s\S]*?savedSql\.deleteFile/); + assert.match(savedSqlMenuBody, /action: deleteSavedSqlFile[\s\S]*?variant: "destructive"/); + assert.doesNotMatch(savedSqlMenuBody, /contextMenu\.copyName/); + assert.match(pasteBody, /clipboard\?\.kind === "saved-sql-copy"[\s\S]*?copyFilesToDatabase/); + assert.match(runtimeHost, /activeNode\.value\.type === "saved-sql-file"[\s\S]*?request-saved-sql-rename/); + assert.match(connectionTree, /@request-saved-sql-rename="startRenamingSavedSqlNode"/); + assert.match(treeItem, /async function finishRenameSavedSql\(\)[\s\S]*?savedSqlStore\.renameFile/); + assert.match(runtimeHost, /routeDangerDialog\(showDeleteSavedSqlConfirm[\s\S]*?savedSql\.deleteFileConfirm[\s\S]*?confirmDeleteSavedSqlFile/); + assert.match(runtimeHost, /async function confirmDeleteSavedSqlFile\(\)[\s\S]*?savedSqlStore\.deleteFile[\s\S]*?connectionStore\.removeTreeNode/); + assert.match(functionBody(runtimeHost, "requestDeleteSelectedNode"), /saved-sql-file[\s\S]*?showDeleteSavedSqlConfirm\.value = true/); +}); + +test("explicit locate prioritizes the saved SQL row over SQL cursor table navigation", () => { + const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); + const locateBody = functionBody(connectionTree, "locateActiveTabInSidebar"); + + assert.match(locateBody, /const locatesSavedSql = tabTarget\?\.type === "saved-sql-file"/); + assert.match(locateBody, /const cursorCandidate = locatesSavedSql \? null : queryCursorTableCandidate/); + assert.match(locateBody, /locatesSavedSql && savedSqlFile\?\.connectionId && savedSqlFile\.database[\s\S]*?type: "query-context"/); + assert.match(locateBody, /findNodePathForTarget\(target, store\.treeNodes\)/); +}); + test("batch table paste refreshes each object list after all tables are processed", () => { const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const confirmPasteTableBody = functionBody(runtimeHost, "confirmPasteTable"); diff --git a/packages/app-tests/treeNodeClick.test.ts b/packages/app-tests/treeNodeClick.test.ts index 816f551c4b..662edf7264 100644 --- a/packages/app-tests/treeNodeClick.test.ts +++ b/packages/app-tests/treeNodeClick.test.ts @@ -16,6 +16,10 @@ test("single click navigation mode opens source-capable rows", () => { assert.equal(treeNodeRowAction("sequence", false), "open-source"); }); +test("single click navigation mode opens saved SQL rows", () => { + assert.equal(treeNodeRowAction("saved-sql-file", false, "single"), "open-saved-sql"); +}); + test("extension rows open their metadata details", () => { assert.equal(treeNodeRowAction("extension", false, "single"), "open-extension-details"); assert.equal(treeNodeRowAction("extension", false, "double"), "none");