mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(sidebar): manage saved SQL by database
This commit is contained in:
+41
-31
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "ロール",
|
||||
|
||||
@@ -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: "역할",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "角色",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user