feat(sidebar): manage saved SQL by database

This commit is contained in:
zhangyc
2026-08-13 15:26:45 +08:00
committed by GitHub
parent 1efb3de113
commit 5f60fbe196
40 changed files with 1631 additions and 150 deletions
+41 -31
View File
@@ -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<boolean> {
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);
}
}
@@ -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<string>) {
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<string>) {
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<string>) {
}
}
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);
@@ -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<string | null>(null);
const pendingRenameNodeId = ref<string | null>(null);
const highlightedNodeId = ref<string | null>(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"
/>
</template>
@@ -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"
/>
</div>
@@ -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) {
@@ -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<HTMLInputElement>();
@@ -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) {
<div ref="trailingCommentLayoutRef" :class="hasTrailingMetadata() ? 'flex flex-1 min-w-0 items-center' : 'contents'">
<div ref="trailingCommentLeadingRef" :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'" :style="alignedCommentLeadingStyle()">
<input
v-if="isRenamingGroup"
v-if="isRenamingGroup || isRenamingSavedSql"
ref="renameInputRef"
v-model="renameInput"
class="min-w-0 flex-1 truncate bg-transparent border border-primary/50 rounded px-1 outline-none"
@blur="finishRenameGroup"
@keydown.enter.prevent="finishRenameGroup"
@keydown.escape.prevent="isRenamingGroup = false"
@blur="finishRename"
@keydown.enter.prevent="finishRename"
@keydown.escape.prevent="cancelRename"
@click.stop
/>
<span v-else ref="labelRef" :class="[labelWidthClass, { 'flex-1': node.type === 'connection' && !trailingComment }]">{{ visibleLabel(node) }}</span>
+10
View File
@@ -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",
+10
View File
@@ -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",
+10
View File
@@ -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",
+10
View File
@@ -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: "ロール",
+10
View File
@@ -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: "역할",
+10
View File
@@ -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",
+10
View File
@@ -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: "角色",
+10
View File
@@ -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: "角色",
@@ -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");
});
});
@@ -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<SavedSqlFile> & Pick<SavedSqlFile, "id" | "name" | "connectionId" | "database">): 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" }] });
});
});
@@ -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);
});
});
@@ -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",
@@ -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<TreeNode, "type" | "connectionId" | "catalog" | "database" | "schema">): 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>): 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`;
}
@@ -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<string, readonly SavedSqlFile[]>;
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<string, SavedSqlFile[]>();
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<TreeNode, "id" | "connectionId" | "catalog" | "database">, 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<TreeNode, "id" | "connectionId" | "catalog" | "database" | "children">, 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 }];
});
}
@@ -0,0 +1,18 @@
interface SavedSqlErrorTranslator {
(key: string, params?: Record<string, unknown>): 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);
}
@@ -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";
}
@@ -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;
@@ -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;
}
@@ -7,7 +7,7 @@ const TABLE_STRUCTURE_GROUP_TYPES = new Set<TreeNode["type"]>(["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;
}
@@ -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";
@@ -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,
};
@@ -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"],
]);
});
});
@@ -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);
+33 -3
View File
@@ -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<ConnectionConfig[]>([]);
const isDesktop = isTauriRuntime();
const activeConnectionId = ref<string | null>(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 {
+200 -38
View File
@@ -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<SavedSqlNameScope, "connectionId" | "catalog" | "database" | "folderId">): 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<string>();
@@ -106,16 +159,19 @@ export const useSavedSqlStore = defineStore("savedSql", () => {
const fileTargetRevisions = new Map<string, number>();
const pendingFileTargetSaves = new Map<string, Promise<SavedSqlFile | undefined>>();
const persistedFileTargets = new Map<string, SavedSqlExecutionTargetInput & { updatedAt: string }>();
const pendingNamesByScope = new Map<string, Map<string, PendingSavedSqlName>>();
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<string, PendingSavedSqlName>();
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<SavedSqlFile | undefined> {
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<SavedSqlFile[]> {
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,
+1
View File
@@ -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;
+24
View File
@@ -25,6 +25,7 @@ pub struct SavedSqlFile {
pub folder_id: Option<String>,
pub name: String,
pub database: String,
/// `None` is the built-in/default catalog and preserves legacy saved SQL scope.
#[serde(default)]
pub catalog: Option<String>,
pub schema: Option<String>,
@@ -46,3 +47,26 @@ pub struct SavedSqlLibrary {
pub folders: Vec<SavedSqlFolder>,
pub files: Vec<SavedSqlFile>,
}
#[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);
}
}
+65
View File
@@ -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::{
+1
View File
@@ -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",
});
+16
View File
@@ -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",
);
});
+10
View File
@@ -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 = [
+398
View File
@@ -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<SavedSqlFile>((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",
+29 -1
View File
@@ -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,
});
+33
View File
@@ -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");
+4
View File
@@ -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");