feat(sidebar): manage saved SQL by database

This commit is contained in:
zhangyc authored and GitHub committed 2026-08-13 15:26:45 +08:00
1 parent 1efb3de113
commit 5f60fbe196
40 files changed
+1631 -150

No files matched your search

@@ -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>