fix(source): open pending tab while loading object source

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-14 19:59:21 +08:00
1 parent eba6853480
commit 760d2c6409
14 files changed
+824 -149

No files matched your search

+14 -34
View File
@@ -2618,7 +2618,7 @@ function onEditTableStructure(table: SqlObjectNavigationTarget) {
queryStore.openTableStructure(target.connectionId, target.database, target.schema, target.tableName, undefined, undefined, target.catalog); queryStore.openTableStructure(target.connectionId, target.database, target.schema, target.tableName, undefined, undefined, target.catalog);
} }
async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: boolean) { function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditing: boolean) {
const provisionalTarget = tableTargetFromActiveTab(table); const provisionalTarget = tableTargetFromActiveTab(table);
if (!provisionalTarget) return; if (!provisionalTarget) return;
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(provisionalTarget.connectionId)); const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(provisionalTarget.connectionId));
@@ -2630,42 +2630,22 @@ async function onOpenObjectSource(table: SqlObjectNavigationTarget, initialEditi
const sourceName = sqlObjectNavigationSourceName(navigation); const sourceName = sqlObjectNavigationSourceName(navigation);
const sourceSchema = sqlObjectNavigationSourceSchema(navigation, target.schema || target.database); const sourceSchema = sqlObjectNavigationSourceSchema(navigation, target.schema || target.database);
try { try {
await connectionStore.ensureConnected(target.connectionId); // Keep the editor navigation path aligned with the sidebar: create a visible
connectionStore.activeConnectionId = target.connectionId; // pending tab first, then let the store own connection/source loading.
// Align Ctrl/Cmd+click with sidebar "view source" (dialog vs data tab).
if (settingsStore.editorSettings.routineSourceOpenMode === "query-tab") { if (settingsStore.editorSettings.routineSourceOpenMode === "query-tab") {
try { queryStore.openObjectSourceTabPending({
const resolvedDatabaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(target.connectionId)); connectionId: target.connectionId,
if (!resolvedDatabaseType) throw new Error("Connection type is unavailable."); database: target.database,
// Wrap Oracle bare ALL_SOURCE (`procedure name is ...`) as CREATE OR REPLACE for the editor. title: `Source - ${sourceName}`,
const { schema: sourceSchema || target.database,
raw, catalog: target.catalog,
editableSource, request: { name: sourceName, objectType, signature: navigation.signature },
objectType: resolvedType, });
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId: target.connectionId,
database: target.database,
schema: sourceSchema || target.database,
name: sourceName,
objectType,
databaseType: resolvedDatabaseType,
signature: navigation.signature,
});
const tabId = queryStore.createTab(target.connectionId, target.database, `Source - ${sourceName}`, "query", sourceSchema, editableSource, target.catalog, { forceNew: true, sourceView: true });
const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY"].includes(resolvedType);
if (sourceIsEditable) {
queryStore.setObjectSource(tabId, {
schema: sourceSchema || target.database,
name: sourceName,
objectType: resolvedType,
signature: navigation.signature,
});
}
} catch (e: any) {
toast(e?.message || String(e), 5000);
}
return; return;
} }
// The dialog owns ensureConnected so its existing loading/error/retry UI is
// mounted before a slow connection health check or Oracle metadata query.
queryEditorObjectSourceTarget.value = { queryEditorObjectSourceTarget.value = {
connectionId: target.connectionId, connectionId: target.connectionId,
database: target.database, database: target.database,
@@ -672,6 +672,7 @@ const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size"))
// re-normalize a single pane, and shrinking it would leave a blank dead zone. // re-normalize a single pane, and shrinking it would leave a blank dead zone.
const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value)); const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value));
const queryRunningElapsed = ref(0); const queryRunningElapsed = ref(0);
const sourceLoadElapsed = ref(0);
function toggleResultsPane(): boolean { function toggleResultsPane(): boolean {
if (props.activeTab.mode !== "query" || !hasQueryOutput.value) return false; if (props.activeTab.mode !== "query" || !hasQueryOutput.value) return false;
@@ -703,25 +704,36 @@ function onResultsResized(payload: { panes: { size: number }[] }) {
} }
let queryRunningElapsedFrame: number | undefined; let queryRunningElapsedFrame: number | undefined;
function stopQueryRunningElapsedTimer() { function stopRunningElapsedTimer() {
if (queryRunningElapsedFrame !== undefined) { if (queryRunningElapsedFrame !== undefined) {
window.cancelAnimationFrame(queryRunningElapsedFrame); window.cancelAnimationFrame(queryRunningElapsedFrame);
queryRunningElapsedFrame = undefined; queryRunningElapsedFrame = undefined;
} }
} }
function updateQueryRunningElapsed() { /** 源码仍在加载(未失败)时的开始时间;用于耗时显示与 rAF 循环的续期判断。 */
const startedAt = props.activeTab.queryExecutionStartedAt; function pendingSourceLoadStartedAt(): number | undefined {
queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0; const load = props.activeTab.sourceLoad;
return load && !load.error ? load.startedAt : undefined;
} }
function startQueryRunningElapsedTimer() { // 一个 rAF 循环同时驱动「查询执行中」与「对象源码加载中」两个耗时显示:
stopQueryRunningElapsedTimer(); // issue #9035 的核心体感就是「不知道要等多久」,所以源码加载也要显示已耗时。
updateQueryRunningElapsed(); function updateRunningElapsed() {
if (!props.activeTab.isExecuting || !props.activeTab.queryExecutionStartedAt) return; const startedAt = props.activeTab.queryExecutionStartedAt;
queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0;
const sourceStartedAt = pendingSourceLoadStartedAt();
sourceLoadElapsed.value = sourceStartedAt ? Math.max(0, Date.now() - sourceStartedAt) : 0;
}
function startRunningElapsedTimer() {
stopRunningElapsedTimer();
updateRunningElapsed();
const isTicking = () => (props.activeTab.isExecuting && !!props.activeTab.queryExecutionStartedAt) || pendingSourceLoadStartedAt() !== undefined;
if (!isTicking()) return;
const updateOnNextFrame = () => { const updateOnNextFrame = () => {
updateQueryRunningElapsed(); updateRunningElapsed();
if (props.activeTab.isExecuting && props.activeTab.queryExecutionStartedAt) { if (isTicking()) {
queryRunningElapsedFrame = window.requestAnimationFrame(updateOnNextFrame); queryRunningElapsedFrame = window.requestAnimationFrame(updateOnNextFrame);
} }
}; };
@@ -729,11 +741,12 @@ function startQueryRunningElapsedTimer() {
} }
const queryRunningElapsedSeconds = computed(() => formatElapsedSeconds(queryRunningElapsed.value)); const queryRunningElapsedSeconds = computed(() => formatElapsedSeconds(queryRunningElapsed.value));
const sourceLoadElapsedSeconds = computed(() => formatElapsedSeconds(sourceLoadElapsed.value));
watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt] as const, startQueryRunningElapsedTimer, { immediate: true }); watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt, props.activeTab.sourceLoad?.startedAt, props.activeTab.sourceLoad?.error] as const, startRunningElapsedTimer, { immediate: true });
onUnmounted(() => { onUnmounted(() => {
stopQueryRunningElapsedTimer(); stopRunningElapsedTimer();
standaloneResultToolbarResizeObserver?.disconnect(); standaloneResultToolbarResizeObserver?.disconnect();
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser); window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
window.removeEventListener("resize", updateStandaloneResultToolbarDimensions); window.removeEventListener("resize", updateStandaloneResultToolbarDimensions);
@@ -1320,7 +1333,18 @@ defineExpose({
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true"> <div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span> <span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
</div> </div>
<!-- issue #9035:源码 tab 先出现再加载。pending 期间不挂载编辑器
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。 -->
<QueryLoadingState v-if="activeTab.sourceLoad && !activeTab.sourceLoad.error" class="relative z-0 flex-1" :elapsed-seconds="sourceLoadElapsedSeconds" />
<div v-else-if="activeTab.sourceLoad?.error" class="relative z-0 flex flex-1 min-h-0 flex-col items-center justify-center gap-3 px-6 text-sm" data-object-source-load-error>
<p class="max-w-[80%] text-center break-words text-destructive">{{ activeTab.sourceLoad.error }}</p>
<Button variant="outline" size="sm" class="gap-1.5" @click="queryStore.retryObjectSourceTab(activeTab.id)">
<RotateCcw class="h-4 w-4" />
{{ t("common.retry") }}
</Button>
</div>
<QueryEditor <QueryEditor
v-else
ref="queryEditorRef" ref="queryEditorRef"
class="relative z-0 flex-1" class="relative z-0 flex-1"
:auto-focus="autoFocus !== false" :auto-focus="autoFocus !== false"
@@ -6,26 +6,24 @@ import { queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import type { QueryTab } from "@/types/database"; import type { QueryTab } from "@/types/database";
const props = defineProps<{ const props = defineProps<{
tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling">; tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling" | "sourceLoad">;
}>(); }>();
const { t } = useI18n(); const { t } = useI18n();
const status = computed(() => { // 源码加载中(issue #9035)与查询执行中共用这一个 tab 栏转圈:两者都是
if (props.tab.mode !== "query" || !props.tab.isExecuting) return undefined; // 「这个 tab 正在等后端」,用户需要的反馈是同一种。
const status = computed<"running" | "cancelling" | "source-loading" | undefined>(() => {
if (props.tab.mode !== "query") return undefined;
if (props.tab.sourceLoad && !props.tab.sourceLoad.error) return "source-loading";
if (!props.tab.isExecuting) return undefined;
return props.tab.isCancelling ? "cancelling" : "running"; return props.tab.isCancelling ? "cancelling" : "running";
}); });
const labelKey = computed(() => (status.value === "source-loading" ? "common.loading" : queryExecutionLabelKey(props.tab)));
</script> </script>
<template> <template>
<span <span v-if="status" data-tab-execution-status role="status" class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center" :class="status === 'cancelling' ? 'text-amber-600 dark:text-amber-400' : 'text-blue-600 dark:text-blue-400'" :aria-label="t(labelKey)" :title="t(labelKey)">
v-if="status"
data-tab-execution-status
role="status"
class="inline-flex h-3.5 w-3.5 shrink-0 items-center justify-center"
:class="status === 'cancelling' ? 'text-amber-600 dark:text-amber-400' : 'text-blue-600 dark:text-blue-400'"
:aria-label="t(queryExecutionLabelKey(tab))"
:title="t(queryExecutionLabelKey(tab))"
>
<Loader2 aria-hidden="true" class="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" /> <Loader2 aria-hidden="true" class="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
</span> </span>
<slot v-else /> <slot v-else />
@@ -10,7 +10,7 @@ vi.mock("vue-i18n", () => ({
const mountedApps: ReturnType<typeof createApp>[] = []; const mountedApps: ReturnType<typeof createApp>[] = [];
function mountStatus(initial: { mode: "query" | "data"; isExecuting: boolean; isCancelling?: boolean }, withFallback = false) { function mountStatus(initial: { mode: "query" | "data"; isExecuting: boolean; isCancelling?: boolean; sourceLoad?: { startedAt: number; error?: string; request: { name: string; objectType: string } } }, withFallback = false) {
const state = reactive(initial); const state = reactive(initial);
const root = document.createElement("div"); const root = document.createElement("div");
document.body.appendChild(root); document.body.appendChild(root);
@@ -82,4 +82,31 @@ describe("TabExecutionStatus", () => {
expect(idle.root.querySelector("[data-tab-execution-status]")).toBeNull(); expect(idle.root.querySelector("[data-tab-execution-status]")).toBeNull();
expect(data.root.querySelector("[data-tab-execution-status]")).toBeNull(); expect(data.root.querySelector("[data-tab-execution-status]")).toBeNull();
}); });
// issue #9035:源码 tab 先出现再加载,加载中的等待必须有反馈。
it("shows the loading indicator while an object source tab is still loading", async () => {
const { root, state } = mountStatus({ mode: "query", isExecuting: false, sourceLoad: { startedAt: Date.now(), request: { name: "v_orders", objectType: "VIEW" } } }, true);
await nextTick();
const status = root.querySelector<HTMLElement>("[data-tab-execution-status]");
expect(status).not.toBeNull();
expect(status?.getAttribute("aria-label")).toBe("common.loading");
expect(status?.className).toContain("text-blue-600");
// 加载态占用图标位,与查询执行中的表现一致
expect(root.querySelector("[data-tab-icon]")).toBeNull();
state.sourceLoad = undefined;
await nextTick();
expect(root.querySelector("[data-tab-execution-status]")).toBeNull();
expect(root.querySelector("[data-tab-icon]")).not.toBeNull();
});
it("stops the loading indicator once the object source load failed", async () => {
const { root } = mountStatus({ mode: "query", isExecuting: false, sourceLoad: { startedAt: Date.now(), error: "ORA-00942", request: { name: "v_orders", objectType: "VIEW" } } }, true);
await nextTick();
// 失败态由 tab 内容区的错误 + Retry 表达,tab 栏不应继续转圈
expect(root.querySelector("[data-tab-execution-status]")).toBeNull();
expect(root.querySelector("[data-tab-icon]")).not.toBeNull();
});
}); });
@@ -87,6 +87,10 @@ async function loadSource(nextEditing = props.initialEditing && canEdit.value) {
loading.value = true; loading.value = true;
try { try {
if (!props.databaseType) throw new Error("Connection type is unavailable."); if (!props.databaseType) throw new Error("Connection type is unavailable.");
// issue #9035:连接建立也纳入弹窗的加载态。调用方不再先 await ensureConnected
// 才打开弹窗,否则弹窗挂载前会有一段没有任何反馈的等待。
await connectionStore.ensureConnected(props.connectionId);
connectionStore.activeConnectionId = props.connectionId;
const schema = props.schema || props.database; const schema = props.schema || props.database;
const { source: result, objectType: resolvedType } = await loadObjectSourceWithRoutineFallback(api.getObjectSource, props.connectionId, props.database, schema, props.name, props.objectType, props.signature, props.relationName); const { source: result, objectType: resolvedType } = await loadObjectSourceWithRoutineFallback(api.getObjectSource, props.connectionId, props.database, schema, props.name, props.objectType, props.signature, props.relationName);
const editableAllowed = result.editable !== false; const editableAllowed = result.editable !== false;
@@ -2067,18 +2067,11 @@ function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
// connections list user-defined types without a CREATE TYPE getter this cycle. // connections list user-defined types without a CREATE TYPE getter this cycle.
if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return; if ((node.type === "type" || node.type === "type-body") && !supportsTypeObjectSource(store.getConfig(node.connectionId)?.db_type)) return;
const target = createSidebarActionTarget(node); const target = createSidebarActionTarget(node);
const requestGeneration = beginSidebarAction(); beginSidebarAction();
void store // issue #9035:弹窗立即挂载。此前先 await ensureConnected 再开弹窗,这段时间
.ensureConnected(target.connectionId!) // 界面上没有任何反馈;现在连接与取源都在弹窗自身的加载态之内完成。
.then(() => { sidebarObjectSourceTarget.value = { node: target, initialEditing };
if (requestGeneration !== sidebarActionGeneration) return; sidebarObjectSourceOpen.value = true;
store.activeConnectionId = target.connectionId!;
sidebarObjectSourceTarget.value = { node: target, initialEditing };
sidebarObjectSourceOpen.value = true;
})
.catch((error: any) => {
if (requestGeneration === sidebarActionGeneration) toast(error?.message || String(error), 5000);
});
} }
function openSidebarSettings(initialTab: string) { function openSidebarSettings(initialTab: string) {
@@ -156,8 +156,7 @@ import {
type TableChildObjectType, type TableChildObjectType,
} from "@/lib/database/dbAdminSql"; } from "@/lib/database/dbAdminSql";
import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePreflightSql, databaseRenameMaintenanceDatabase, supportsDatabaseRename, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql"; import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePreflightSql, databaseRenameMaintenanceDatabase, supportsDatabaseRename, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql";
import { buildEditableObjectSource, buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor"; import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
import { buildViewDdl } from "@/lib/table/viewDdl"; import { buildViewDdl } from "@/lib/table/viewDdl";
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter"; import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
@@ -352,7 +351,7 @@ const settingsStore = useSettingsStore();
const savedSqlStore = useSavedSqlStore(); const savedSqlStore = useSavedSqlStore();
const { toast, message: toastMessage, dismissToast } = useToast(); const { toast } = useToast();
const installedPlugins = ref<InstalledPlugin[]>([]); const installedPlugins = ref<InstalledPlugin[]>([]);
const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value)); const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
@@ -2526,70 +2525,22 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false
const database = node.database; const database = node.database;
const sourceTarget = objectSourceTargetForTreeNode(sourceNode); const sourceTarget = objectSourceTargetForTreeNode(sourceNode);
if (!sourceTarget) return; if (!sourceTarget) return;
const openMode = settingsStore.editorSettings.routineSourceOpenMode; const schema = sourceTarget.schema || database;
if (openMode === "query-tab") { // issue #9035:两个分支都不再 await 连接与取源。先把容器挂出来(源码 tab /
const loadingMessage = t("common.loading"); // 源码弹窗),ensureConnected 与 getObjectSource 都发生在已挂载的 UI 之内,
const loadingToastTimer = window.setTimeout(() => toast(loadingMessage, 30000), 350); // 加载中有状态、失败就地重试,而不是点击后一段时间毫无反应。
const finishLoadingToast = () => { if (settingsStore.editorSettings.routineSourceOpenMode === "query-tab") {
window.clearTimeout(loadingToastTimer); queryStore.openObjectSourceTabPending({
if (toastMessage.value === loadingMessage) dismissToast(); connectionId,
}; database,
void connectionStore title: `Source - ${node.label}`,
.ensureConnected(connectionId) schema,
.then(async () => { catalog: node.catalog,
connectionStore.activeConnectionId = connectionId; request: { name: sourceTarget.name, objectType: sourceTarget.objectType, signature: sourceNode.signature },
const schema = sourceTarget.schema || database; });
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
if (!databaseType) throw new Error("Connection type is unavailable.");
const objectName = sourceTarget.name;
const {
raw,
editableSource,
objectType: resolvedType,
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId,
database,
schema,
name: objectName,
objectType: sourceTarget.objectType as any,
databaseType,
signature: sourceNode.signature,
});
const sourceIsEditable = raw.editable !== false && !["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"].includes(resolvedType);
if (sourceIsEditable) {
queryStore.openObjectSourceTab({
connectionId,
database,
title: `Source - ${node.label}`,
schema,
catalog: node.catalog,
sql: editableSource,
objectSource: {
schema,
name: objectName,
objectType: resolvedType,
signature: node.signature,
},
});
} else {
queryStore.createTab(connectionId, database, `Source - ${node.label}`, "query", schema, editableSource, node.catalog, { forceNew: true, sourceView: true });
}
})
.catch((e: any) => {
toast(e?.message || String(e), 5000);
})
.finally(finishLoadingToast);
return; return;
} }
void connectionStore emit("open-object-source", sourceNode, initialEditing);
.ensureConnected(connectionId)
.then(() => {
connectionStore.activeConnectionId = connectionId;
emit("open-object-source", sourceNode, initialEditing);
})
.catch((e: any) => {
toast(e?.message || String(e), 5000);
});
} }
function openProcedureExecution() { function openProcedureExecution() {
@@ -62,6 +62,20 @@ describe("openTabsPersistence originalSql round-trip", () => {
expect(restored.objectSource).toBeUndefined(); expect(restored.objectSource).toBeUndefined();
}); });
it("does not persist a pending object-source tab without its in-flight request", () => {
const pending = queryTab({
id: "pending-source",
title: "Source - v_orders",
sourceView: true,
sourceLoad: {
startedAt: Date.now(),
request: { name: "v_orders", objectType: "VIEW" },
},
});
expect(serializeOpenTabs([pending])).toEqual([]);
});
it("keeps legacy query tabs without source intent compatible", () => { it("keeps legacy query tabs without source intent compatible", () => {
const [restored] = roundTrip([queryTab({ sql: "SELECT 1" })]); const [restored] = roundTrip([queryTab({ sql: "SELECT 1" })]);
expect(restored.sourceView).toBeUndefined(); expect(restored.sourceView).toBeUndefined();
@@ -104,6 +104,13 @@ function shouldPersistTabSql(tab: QueryTab) {
return tab.originalSql !== undefined && tab.sql !== tab.originalSql; return tab.originalSql !== undefined && tab.sql !== tab.originalSql;
} }
// Pending object-source tabs are transient work surfaces. Persisting one while
// its request is in flight would restore an empty source tab after restart,
// because the request itself is intentionally not durable.
function shouldPersistOpenTab(tab: QueryTab): boolean {
return !tab.sourceLoad;
}
function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) { function restoredOriginalSql(tab: SavedOpenTab, mode: QueryTab["mode"], sql: string) {
if (mode !== "query") return undefined; if (mode !== "query") return undefined;
if (tab.externalSqlPath) return tab.originalSql ?? sql; if (tab.externalSqlPath) return tab.originalSql ?? sql;
@@ -149,7 +156,7 @@ function restoredTabUiState(tab: SavedOpenTab): QueryTab["uiState"] {
} }
export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] {
return tabs.map((tab) => ({ return tabs.filter(shouldPersistOpenTab).map((tab) => ({
id: tab.id, id: tab.id,
...(typeof tab.createdAt === "number" ? { createdAt: tab.createdAt } : {}), ...(typeof tab.createdAt === "number" ? { createdAt: tab.createdAt } : {}),
title: tab.title, title: tab.title,
@@ -259,6 +266,9 @@ function restoreOpenTabsArray(parsed: unknown, rawActiveTabId: string | null, op
redisMonitorActive: false, redisMonitorActive: false,
isCancelling: false, isCancelling: false,
queryExecutionStartedAt: undefined, queryExecutionStartedAt: undefined,
// sourceLoad 是纯运行期态(serializeOpenTabs 不落盘)。这里显式清空,
// 让「恢复后的 tab 不会停在加载中」成为不变量,而不是依赖白名单的副作用。
sourceLoad: undefined,
executingResultRunId: undefined, executingResultRunId: undefined,
editorViewport: restoredEditorViewport(tab), editorViewport: restoredEditorViewport(tab),
editorSelection: restoredEditorSelection(tab, typeof tab.sql === "string" ? tab.sql.length : 0), editorSelection: restoredEditorSelection(tab, typeof tab.sql === "string" ? tab.sql.length : 0),
@@ -0,0 +1,272 @@
import { createPinia, setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
// issue #9035:源码 tab 必须先出现再加载。这批用例锁定 pending 占位 tab 的
// 行为契约:立即出现、原地填充、就地重试、加载中被关闭则丢弃、重复点击去重。
const mocks = vi.hoisted(() => {
const connectionStore = {
activeConnectionId: null as string | null,
ensureConnected: vi.fn(async () => {}),
getConfig: (connectionId: string) => (connectionId ? { id: connectionId, name: connectionId, db_type: "oracle" } : undefined),
};
return {
connectionStore,
ensureConnected: connectionStore.ensureConnected,
getObjectSource: vi.fn(),
buildEditableObjectSource: vi.fn(async (input: { source: string }) => input.source),
closeQuerySession: vi.fn(async () => {}),
closeClientConnectionSession: vi.fn(async () => {}),
saveOpenTabsState: vi.fn(async () => {}),
};
});
vi.mock("@/lib/backend/api", () => ({
getObjectSource: (...args: unknown[]) => mocks.getObjectSource(...args),
buildEditableObjectSource: (input: { source: string }) => mocks.buildEditableObjectSource(input),
closeQuerySession: mocks.closeQuerySession,
closeClientConnectionSession: mocks.closeClientConnectionSession,
saveOpenTabsState: mocks.saveOpenTabsState,
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => mocks.connectionStore,
}));
const CONNECTION_ID = "ora-1";
const DATABASE = "ORCL";
const SCHEMA = "APP";
const pendingOptions = (request: { name: string; objectType: "VIEW" | "PROCEDURE" | "FUNCTION"; signature?: string }) => ({
connectionId: CONNECTION_ID,
database: DATABASE,
title: `Source - ${request.name}`,
schema: SCHEMA,
request,
});
const objectSource = (source: string, editable?: boolean) => ({
name: "ignored",
object_type: "VIEW" as const,
source,
...(editable === undefined ? {} : { editable }),
});
/** 让 loader 的 await 链跑完(ensureConnected → getObjectSource → 落地)。 */
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("queryStore pending object source tab", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn() });
setActivePinia(createPinia());
});
it("creates a visible loading tab before the source arrives", async () => {
let resolveSource: ((value: unknown) => void) | undefined;
mocks.getObjectSource.mockReturnValue(new Promise((resolve) => (resolveSource = resolve)));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const id = store.openObjectSourceTabPending(pendingOptions({ name: "v_orders", objectType: "VIEW" }));
// 请求还没回来,tab 已经在界面上:这正是 #9035 缺的那一步
expect(store.tabs).toHaveLength(1);
const tab = store.tabs[0]!;
expect(tab.id).toBe(id);
expect(tab.title).toBe("Source - v_orders");
expect(tab.sourceView).toBe(true);
expect(tab.sql).toBe("");
expect(tab.objectSource).toBeUndefined();
expect(tab.sourceLoad?.error).toBeUndefined();
expect(tab.sourceLoad?.request).toEqual({ name: "v_orders", objectType: "VIEW", signature: undefined });
expect(tab.sourceLoad?.startedAt).toBeTypeOf("number");
resolveSource?.(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual"));
await settle();
});
it("fills the same tab in place once the source resolves", async () => {
mocks.getObjectSource.mockResolvedValue(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const id = store.openObjectSourceTabPending(pendingOptions({ name: "v_orders", objectType: "VIEW" }));
await settle();
expect(store.tabs).toHaveLength(1);
const tab = store.tabs[0]!;
expect(tab.id).toBe(id);
expect(tab.sql).toBe("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual");
expect(tab.sourceView).toBe(true);
expect(tab.sourceLoad).toBeUndefined();
expect(tab.objectSource).toMatchObject({ schema: SCHEMA, name: "v_orders", objectType: "VIEW" });
});
it("keeps the resolved (fallback) object type on the tab, not the requested one", async () => {
// 请求 PROCEDURE,后端返回空 → routine fallback 回落到 FUNCTION
mocks.getObjectSource.mockImplementation(async (_connectionId, _database, _schema, _name, objectType: string) => objectSource(objectType === "FUNCTION" ? "CREATE OR REPLACE FUNCTION app.foo RETURN NUMBER IS BEGIN RETURN 1; END;" : ""));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
store.openObjectSourceTabPending(pendingOptions({ name: "foo", objectType: "PROCEDURE" }));
await settle();
expect(store.tabs[0]?.objectSource).toMatchObject({ name: "foo", objectType: "FUNCTION" });
});
it("surfaces a failure inside the tab and retries in place", async () => {
mocks.getObjectSource.mockRejectedValueOnce(new Error("ORA-00942: table or view does not exist"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const id = store.openObjectSourceTabPending(pendingOptions({ name: "v_orders", objectType: "VIEW" }));
await settle();
const failed = store.tabs[0]!;
expect(failed.id).toBe(id);
expect(failed.sourceLoad?.error).toContain("ORA-00942");
expect(failed.sql).toBe("");
// 失败不吞掉 tab:用户就在这个 tab 里重试,而不是只收到一个 toast
mocks.getObjectSource.mockResolvedValueOnce(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 2 FROM dual"));
store.retryObjectSourceTab(id);
// 重试立即回到加载态(错误清空 → tab 栏重新转圈)
expect(store.tabs[0]?.sourceLoad?.error).toBeUndefined();
await settle();
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0]?.sql).toBe("CREATE OR REPLACE VIEW app.v_orders AS SELECT 2 FROM dual");
expect(store.tabs[0]?.sourceLoad).toBeUndefined();
});
it("drops the result when the tab was closed while loading", async () => {
let resolveSource: ((value: unknown) => void) | undefined;
mocks.getObjectSource.mockReturnValue(new Promise((resolve) => (resolveSource = resolve)));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const id = store.openObjectSourceTabPending(pendingOptions({ name: "v_orders", objectType: "VIEW" }));
store.closeTab(id);
expect(store.tabs).toHaveLength(0);
resolveSource?.(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual"));
await settle();
// 关掉就是放弃:不重建 tab、不写入已关闭的容器
expect(store.tabs).toHaveLength(0);
});
it("reuses the pending tab and the in-flight request on a repeat click", async () => {
let resolveSource: ((value: unknown) => void) | undefined;
mocks.getObjectSource.mockReturnValue(new Promise((resolve) => (resolveSource = resolve)));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const options = pendingOptions({ name: "v_orders", objectType: "VIEW" });
const first = store.openObjectSourceTabPending(options);
const second = store.openObjectSourceTabPending(options);
expect(second).toBe(first);
expect(store.tabs).toHaveLength(1);
// 第二次点击发生在请求真正发出之前(loader 先 await ensureConnected),
// 所以这里要等微任务落定后再确认只发了一次请求
await settle();
expect(mocks.getObjectSource).toHaveBeenCalledTimes(1);
resolveSource?.(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual"));
await settle();
expect(store.tabs).toHaveLength(1);
});
it("reuses an already loaded tab instantly and revalidates it in the background", async () => {
mocks.getObjectSource.mockResolvedValue(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const options = pendingOptions({ name: "v_orders", objectType: "VIEW" });
const first = store.openObjectSourceTabPending(options);
await settle();
expect(mocks.getObjectSource).toHaveBeenCalledTimes(1);
expect(store.activeTabId).toBe(first);
// 重复打开:不出现第二个转圈 tab,立刻回到同一个 tab,也不进加载态
mocks.getObjectSource.mockResolvedValueOnce(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 2 FROM dual"));
expect(store.openObjectSourceTabPending(options)).toBe(first);
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0]?.sourceLoad).toBeUndefined();
// 复用路径不经过 ensureConnected,但树上动作的惯例仍是把该连接设为当前连接
expect(mocks.connectionStore.activeConnectionId).toBe(CONNECTION_ID);
await settle();
// 后台重新校验:重开拿到的是最新 DDL(改动前每次打开都会重新取源,
// 而源码 tab 没有别的刷新入口,所以复用不能省掉这次校验)
expect(mocks.getObjectSource).toHaveBeenCalledTimes(2);
expect(store.tabs[0]?.sql).toBe("CREATE OR REPLACE VIEW app.v_orders AS SELECT 2 FROM dual");
expect(store.tabs[0]?.sourceLoad).toBeUndefined();
});
it("never clobbers unsaved edits when revalidating in the background", async () => {
mocks.getObjectSource.mockResolvedValue(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 1 FROM dual"));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const options = pendingOptions({ name: "v_orders", objectType: "VIEW" });
const id = store.openObjectSourceTabPending(options);
await settle();
// 用户在源码 tab 里改了内容(未保存)
store.updateSql(id, "CREATE OR REPLACE VIEW app.v_orders AS SELECT 999 FROM dual");
mocks.getObjectSource.mockResolvedValueOnce(objectSource("CREATE OR REPLACE VIEW app.v_orders AS SELECT 2 FROM dual"));
store.openObjectSourceTabPending(options);
await settle();
expect(store.tabs[0]?.sql).toContain("SELECT 999");
expect(store.tabs[0]?.originalSql).not.toBe(store.tabs[0]?.sql);
});
it("hands the loaded source to an existing tab that already matches the resolved identity", async () => {
// 已有一个解析为 FUNCTION 的 tab;本次请求的是 PROCEDURE(树的类型可能是错的),
// 前置复用检查匹配不上,只能先开 pending,等 fallback 解析出 FUNCTION 后再交接。
mocks.getObjectSource.mockImplementation(async (_connectionId, _database, _schema, _name, objectType: string) => objectSource(objectType === "FUNCTION" ? "CREATE OR REPLACE FUNCTION app.foo RETURN NUMBER IS BEGIN RETURN 1; END;" : ""));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
const existingId = store.openObjectSourceTab({
connectionId: CONNECTION_ID,
database: DATABASE,
title: "Source - foo",
schema: SCHEMA,
sql: "CREATE OR REPLACE FUNCTION app.foo RETURN NUMBER IS BEGIN RETURN 0; END;",
objectSource: { schema: SCHEMA, name: "foo", objectType: "FUNCTION" },
});
const pendingId = store.openObjectSourceTabPending(pendingOptions({ name: "foo", objectType: "PROCEDURE" }));
expect(store.tabs).toHaveLength(2);
expect(pendingId).not.toBe(existingId);
await settle();
// pending 占位交接给既有 tab,不留下同一个对象的第二个 tab
expect(store.tabs).toHaveLength(1);
expect(store.tabs[0]?.id).toBe(existingId);
expect(store.tabs[0]?.sql).toBe("CREATE OR REPLACE FUNCTION app.foo RETURN NUMBER IS BEGIN RETURN 1; END;");
expect(store.tabs[0]?.sourceLoad).toBeUndefined();
});
it("honors backend editability: read-only sources load as a plain source tab", async () => {
mocks.getObjectSource.mockResolvedValue(objectSource("CREATE SEQUENCE app.seq_users", false));
const { useQueryStore } = await import("@/stores/queryStore");
const store = useQueryStore();
store.openObjectSourceTabPending(pendingOptions({ name: "seq_users", objectType: "VIEW" }));
await settle();
const tab = store.tabs[0]!;
expect(tab.sql).toBe("CREATE SEQUENCE app.seq_users");
expect(tab.sourceView).toBe(true);
expect(tab.objectSource).toBeUndefined();
expect(tab.sourceLoad).toBeUndefined();
});
});
+251 -3
View File
@@ -5,7 +5,7 @@ import { uuid } from "@/lib/common/utils";
import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue"; import { computed, markRaw, nextTick, onScopeDispose, reactive, ref, watch } from "vue";
import { useI18n } from "vue-i18n"; import { useI18n } from "vue-i18n";
import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState"; import { sanitizeTabPageUiState } from "@/lib/tabs/tabUiState";
import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database"; import type { BatchSqlExecution, ConnectionConfig, DatabaseType, IndexInfo, NacosConfigEditorViewport, ObjectBrowserFilter, ObjectBrowserViewport, ObjectSource, ObjectSourceKind, QueryResult, QueryResultSourceColumnRef, QueryTab, TableInfoTab, TableStructureEditorTarget } from "@/types/database";
import { orderPinnedFirst } from "@/lib/app/pinnedItems"; import { orderPinnedFirst } from "@/lib/app/pinnedItems";
import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState"; import { canCancelQueryExecution } from "@/lib/sql/queryExecutionState";
import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan"; import { buildExplainSql, parseExplainResult, parseDamengExplainText, parseOracleExplainText, sqlServerExplainResult, type BuildExplainSqlResult, type ExplainPlanDatabaseType } from "@/lib/diagram/explainPlan";
@@ -92,6 +92,8 @@ import { ensureSqlExtension } from "@/lib/savedSql/savedSqlFileName";
import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget"; import { resolveSavedSqlExecutionTarget, savedSqlExecutionTargetFromTab, type SavedSqlExecutionTarget, type SavedSqlOpenTargetMode } from "@/lib/savedSql/savedSqlExecutionTarget";
import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage"; import { safeLocalStorageGet, safeLocalStorageRemove } from "@/lib/backend/safeStorage";
import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint"; import { sqlTextFingerprint } from "@/lib/sql/sqlTextFingerprint";
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
import { buildEditableObjectSource } from "@/lib/table/objectSourceEditor";
import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime"; import { disposeAllSqlServerActivityTraces, disposeSqlServerActivityTrace } from "@/lib/sqlserver/sqlServerActivityTraceRuntime";
import type { SavedSqlFile } from "@/types/database"; import type { SavedSqlFile } from "@/types/database";
import i18n, { currentLocale } from "@/i18n"; import i18n, { currentLocale } from "@/i18n";
@@ -176,6 +178,32 @@ interface OpenObjectSourceTabOptions {
objectSource: NonNullable<QueryTab["objectSource"]>; objectSource: NonNullable<QueryTab["objectSource"]>;
} }
/**
* 请求身份:`objectSource.objectType` 要等 routine fallback 跑完才知道,所以
* pending 去重只能按请求时的身份判定。请求身份必须含 objectType —— PACKAGE 与
* PACKAGE_BODY 同名同 schema,仅靠 name+schema 会错误合并。
*/
interface ObjectSourceRequestIdentity {
name: string;
objectType: ObjectSourceKind;
signature?: string;
}
interface OpenPendingObjectSourceTabOptions {
connectionId: string;
database: string;
title: string;
schema?: string;
catalog?: string;
request: ObjectSourceRequestIdentity;
}
/**
* 拿到源码也没有可编辑形态的对象类型:只填内容,不挂 objectSource。
* (`App.vue` 的 Ctrl+click 路径有一份少了 `JOB` 的旧副本,是既有不一致。)
*/
const OBJECT_SOURCE_READ_ONLY_TYPES: readonly ObjectSourceKind[] = ["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"];
interface UpdateExecutionTargetOptions { interface UpdateExecutionTargetOptions {
persistSavedSqlTarget?: boolean; persistSavedSqlTarget?: boolean;
} }
@@ -2560,9 +2588,17 @@ export const useQueryStore = defineStore("query", () => {
}); });
} }
function openObjectSourceTab(options: OpenObjectSourceTabOptions) { /**
const existing = tabs.value.find( * 对象源码 tab 的判重键。裁决点是**解析后**的 objectSource.objectType:
* routine fallback 会把 PROCEDURE↔FUNCTION、PACKAGE↔PACKAGE_BODY 归一,
* 因此「PROCEDURE foo」与「FUNCTION foo」应当共用一个 tab。pending 阶段
* 拿不到解析结果,只能做请求身份去重(见 openObjectSourceTabPending),
* 解析完成后再回到这里落定,避免改变既有语义。
*/
function findMatchingObjectSourceTab(options: OpenObjectSourceTabOptions, excludeTabId?: string): QueryTab | undefined {
return tabs.value.find(
(tab) => (tab) =>
tab.id !== excludeTabId &&
tab.mode === "query" && tab.mode === "query" &&
tab.connectionId === options.connectionId && tab.connectionId === options.connectionId &&
tab.database === options.database && tab.database === options.database &&
@@ -2573,6 +2609,10 @@ export const useQueryStore = defineStore("query", () => {
(tab.objectSource.schema || "") === (options.objectSource.schema || "") && (tab.objectSource.schema || "") === (options.objectSource.schema || "") &&
(tab.objectSource.signature || "") === (options.objectSource.signature || ""), (tab.objectSource.signature || "") === (options.objectSource.signature || ""),
); );
}
function openObjectSourceTab(options: OpenObjectSourceTabOptions) {
const existing = findMatchingObjectSourceTab(options);
if (existing) { if (existing) {
existing.sourceView = true; existing.sourceView = true;
switchTab(existing.id); switchTab(existing.id);
@@ -2588,6 +2628,212 @@ export const useQueryStore = defineStore("query", () => {
return id; return id;
} }
/**
* 正在后台重新校验源码的 tab。非响应式:仅用于避免同一个 tab 上叠起多次
* 取源请求(Oracle 的 GET_DDL 正是慢的那一步)。
*/
const sourceRevalidateInFlight = new Set<string>();
function findPendingObjectSourceTab(options: OpenPendingObjectSourceTabOptions): QueryTab | undefined {
return tabs.value.find(
(tab) =>
!!tab.sourceLoad &&
tab.connectionId === options.connectionId &&
tab.database === options.database &&
(tab.schema || "") === (options.schema || "") &&
(tab.catalog || "") === (options.catalog || "") &&
tab.sourceLoad.request.name === options.request.name &&
tab.sourceLoad.request.objectType === options.request.objectType &&
(tab.sourceLoad.request.signature || "") === (options.request.signature || ""),
);
}
/**
* 立即建出源码 tab 并挂上加载态,再异步取源码(issue #9035)。
* 此前是「等连接 + 等源码都完成才建 tab」,等待期间没有任何可见 UI,
* 用户看到的是点击后毫无反应。
*/
function openObjectSourceTabPending(options: OpenPendingObjectSourceTabOptions): string {
// 这个对象已经打开过:立刻切过去,再在后台重新校验源码。
// 两条弯路都要避开 —— 再建一个 pending tab 会让界面上多出一个转圈 tab,
// 随后又被交接逻辑关掉;而只切过去不校验,会让重开看到的是旧 DDL
// (改动前每次打开都会重新取源,源码 tab 没有其它刷新入口)。
const loaded = findMatchingObjectSourceTab({
connectionId: options.connectionId,
database: options.database,
title: options.title,
schema: options.schema,
catalog: options.catalog,
sql: "",
objectSource: { schema: options.schema, name: options.request.name, objectType: options.request.objectType, signature: options.request.signature },
});
if (loaded) {
loaded.sourceView = true;
switchTab(loaded.id);
// 这条路径不经过 ensureConnected,但树上的动作会把该连接设为当前连接
useConnectionStore().activeConnectionId = options.connectionId;
void revalidateObjectSourceTab(loaded.id);
return loaded.id;
}
const existing = findPendingObjectSourceTab(options);
if (existing) {
switchTab(existing.id);
// 再次点击同一对象 = 再试一次,不新开 tab、不重复占用一个 tab 位
if (existing.sourceLoad?.error) retryObjectSourceTab(existing.id);
return existing.id;
}
const id = createTab(options.connectionId, options.database, options.title, "query", options.schema, "", options.catalog, { forceNew: true, sourceView: true });
const tab = tabs.value.find((candidate) => candidate.id === id);
if (tab) tab.sourceLoad = { startedAt: Date.now(), request: { ...options.request } };
void loadObjectSourceIntoTab(id);
return id;
}
/**
* 后台重新校验一个已加载源码 tab 的 DDL:不占用加载态、不打断编辑,
* 失败就保持原内容(用户并没有在等这次请求)。
*/
async function revalidateObjectSourceTab(id: string) {
if (sourceRevalidateInFlight.has(id)) return;
const tab = tabs.value.find((candidate) => candidate.id === id);
const objectSource = tab?.objectSource;
if (!tab || !objectSource) return;
const { connectionId } = tab;
const { database } = tab;
const schema = objectSource.schema || tab.schema || database;
sourceRevalidateInFlight.add(id);
try {
const connectionStore = useConnectionStore();
await connectionStore.ensureConnected(connectionId);
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
if (!databaseType) return;
const {
raw,
editableSource,
objectType: resolvedType,
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId,
database,
schema,
name: objectSource.name,
objectType: objectSource.objectType,
databaseType,
signature: objectSource.signature,
});
// 期间 tab 可能被关闭、被复用或已被编辑:身份没变且用户没改过内容时才回填
const current = tabs.value.find((candidate) => candidate.id === id);
if (current?.objectSource !== objectSource || resolvedType !== objectSource.objectType) return;
if (raw.editable === false || OBJECT_SOURCE_READ_ONLY_TYPES.includes(resolvedType)) return;
if (isTabDirty(current)) return;
updateSql(id, editableSource);
markTabClean(current);
} catch {
// 已有的源码依然可用,静默保留
} finally {
sourceRevalidateInFlight.delete(id);
}
}
function retryObjectSourceTab(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad) return;
tab.sourceLoad.error = undefined;
tab.sourceLoad.startedAt = Date.now();
void loadObjectSourceIntoTab(id);
}
function clearObjectSourceLoad(tab: QueryTab) {
tab.sourceLoad = undefined;
}
async function loadObjectSourceIntoTab(id: string) {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad || !tab.connectionId) return;
const { connectionId } = tab;
const { database } = tab;
const schema = tab.schema || database;
const { request } = tab.sourceLoad;
try {
const connectionStore = useConnectionStore();
await connectionStore.ensureConnected(connectionId);
connectionStore.activeConnectionId = connectionId;
const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId));
if (!databaseType) throw new Error("Connection type is unavailable.");
const {
raw,
editableSource,
objectType: resolvedType,
} = await loadEditableObjectSourceForEditor(api.getObjectSource, buildEditableObjectSource, {
connectionId,
database,
schema,
name: request.name,
objectType: request.objectType,
databaseType,
signature: request.signature,
});
applyLoadedObjectSource(id, { connectionId, database, schema, catalog: tab.catalog, title: tab.title, request, editableSource, raw, resolvedType });
} catch (e: any) {
// 就地显示错误 + Retry:用户此刻正看着这个 tab,比 toast 更可发现
const failed = tabs.value.find((candidate) => candidate.id === id);
if (failed?.sourceLoad) failed.sourceLoad.error = e?.message || String(e);
}
}
function applyLoadedObjectSource(
id: string,
loaded: {
connectionId: string;
database: string;
schema?: string;
catalog?: string;
title: string;
request: ObjectSourceRequestIdentity;
editableSource: string;
raw: ObjectSource;
resolvedType: ObjectSourceKind;
},
) {
// 加载期间 tab 被关掉(用户放弃)或连接被断开:静默丢弃,不重建、不写库
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab?.sourceLoad) return;
const sourceIsEditable = loaded.raw.editable !== false && !OBJECT_SOURCE_READ_ONLY_TYPES.includes(loaded.resolvedType);
if (sourceIsEditable) {
const options: OpenObjectSourceTabOptions = {
connectionId: loaded.connectionId,
database: loaded.database,
title: loaded.title,
schema: loaded.schema,
catalog: loaded.catalog,
sql: loaded.editableSource,
objectSource: { schema: loaded.schema, name: loaded.request.name, objectType: loaded.resolvedType, signature: loaded.request.signature },
};
// 解析后的身份可能命中已存在的 tab(例:先按 FUNCTION 打开过,这次请求的是 PROCEDURE)。
// 有则交接给它并关掉 pending 占位,避免同一个对象出现两个 tab。
const existing = findMatchingObjectSourceTab(options, id);
if (existing) {
clearObjectSourceLoad(tab);
closeTab(id);
existing.sourceView = true;
switchTab(existing.id);
if (!isTabDirty(existing)) {
updateSql(existing.id, loaded.editableSource);
markTabClean(existing);
}
return;
}
updateSql(id, loaded.editableSource);
setObjectSource(id, options.objectSource);
} else {
updateSql(id, loaded.editableSource);
}
tab.sourceView = true;
markTabClean(tab);
clearObjectSourceLoad(tab);
}
function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) { function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) {
const id = createTab(connectionId, database, undefined, "query", undefined, sql); const id = createTab(connectionId, database, undefined, "query", undefined, sql);
const tab = tabs.value.find((item) => item.id === id); const tab = tabs.value.find((item) => item.id === id);
@@ -8155,6 +8401,8 @@ export const useQueryStore = defineStore("query", () => {
isConfirmingAppClose, isConfirmingAppClose,
createTab, createTab,
openObjectSourceTab, openObjectSourceTab,
openObjectSourceTabPending,
retryObjectSourceTab,
showExecutedQueryResults, showExecutedQueryResults,
focusGroup, focusGroup,
activateTab, activateTab,
+23
View File
@@ -1667,6 +1667,29 @@ export interface QueryTab {
objectType: ObjectSourceKind; objectType: ObjectSourceKind;
signature?: string; signature?: string;
}; };
/**
* 「先出 UI 再加载」的中间态:源码 tab 已经可见,但源码还在路上
* (ensureConnected + getObjectSource)。让 tab 栏与编辑区在等待期间就有反馈,
* 失败时就地显示错误 + Retry,而不是等到加载完才建 tab、失败只弹 toast。
*
* 纯运行期字段,刻意不进 openTabsPersistence 的落盘白名单:重启后恢复出的
* tab 只是普通空 tab,不会永久停在「加载中」。
*/
sourceLoad?: {
startedAt: number;
/** 加载失败时写入;保留 request 以便就地重试 */
error?: string;
/**
* 重试所需的请求身份。与 `objectSource` 分开保存:objectType 在这里是
* **请求时**的类型,而 `objectSource.objectType` 是 routine fallback
* 解析后的类型(PROCEDURE↔FUNCTION、PACKAGE↔PACKAGE_BODY 会被改写)。
*/
request: {
name: string;
objectType: ObjectSourceKind;
signature?: string;
};
};
tableComment?: string | null; tableComment?: string | null;
tableMeta?: { tableMeta?: {
schema?: string; schema?: string;
+89
View File
@@ -0,0 +1,89 @@
import { strict as assert } from "node:assert";
import { readFileSync } from "node:fs";
import { test } from "vitest";
function read(relativePath: string): string {
return readFileSync(relativePath, "utf8");
}
function functionBody(source: string, name: string): string {
const signature = `function ${name}(`;
const asyncSignature = `async ${signature}`;
const signatureIndex = source.indexOf(asyncSignature) >= 0 ? source.indexOf(asyncSignature) : source.indexOf(signature);
assert.notEqual(signatureIndex, -1, `Could not find function ${name}`);
const bodyStart = source.indexOf("{", signatureIndex);
assert.notEqual(bodyStart, -1, `Could not find body for ${name}`);
let depth = 0;
for (let index = bodyStart; index < source.length; index += 1) {
const char = source[index];
if (char === "{") depth += 1;
if (char === "}") {
depth -= 1;
if (depth === 0) return source.slice(bodyStart + 1, index);
}
}
throw new Error(`Could not parse body for ${name}`);
}
// issue #9035:打开对象源码必须「先出 UI 再加载」。加载态要有可见落点
// (tab 栏 + tab 内容区)、失败要能就地重试、连接建立也要罩在弹窗的加载态之内。
test("object source loading feedback is visible in the tab bar and the tab body", () => {
const contentArea = read("apps/desktop/src/components/layout/ContentArea.vue");
const tabStatus = read("apps/desktop/src/components/layout/TabExecutionStatus.vue");
assert.match(contentArea, /activeTab\.sourceLoad && !activeTab\.sourceLoad\.error/);
assert.match(contentArea, /:elapsed-seconds="sourceLoadElapsedSeconds"/);
assert.match(contentArea, /data-object-source-load-error/);
assert.match(contentArea, /queryStore\.retryObjectSourceTab\(activeTab\.id\)/);
assert.match(tabStatus, /props\.tab\.sourceLoad && !props\.tab\.sourceLoad\.error/);
// 失败态由内容区的错误 + Retry 表达,tab 栏不应继续转圈
assert.match(tabStatus, /labelKey/);
});
test("the object source dialog owns the connection it waits for", () => {
const dialog = read("apps/desktop/src/components/objects/ObjectSourceDialog.vue");
const loadBody = functionBody(dialog, "loadSource");
// 调用方不再先 await ensureConnected 才开弹窗,连接必须发生在弹窗的 loading 之内
const connectIndex = loadBody.indexOf("ensureConnected");
const fetchIndex = loadBody.indexOf("loadObjectSourceWithRoutineFallback");
assert.ok(connectIndex >= 0, "ObjectSourceDialog.loadSource must own ensureConnected");
assert.ok(fetchIndex >= 0, "ObjectSourceDialog.loadSource must fetch the source");
assert.ok(connectIndex < fetchIndex, "connect must happen before the source fetch");
});
test("query-tab entry no longer blocks the sidebar on connect", () => {
const connectionTree = read("apps/desktop/src/components/sidebar/ConnectionTree.vue");
const openBody = functionBody(connectionTree, "openSidebarObjectSource");
// 弹窗立即挂载;此前的 await ensureConnected 制造了没有反馈的死等窗口
// (断言调用形态,注释里提到这个名字不算)
assert.doesNotMatch(openBody, /ensureConnected\(/);
assert.match(openBody, /sidebarObjectSourceOpen\.value = true/);
});
test("query-editor object source entry uses the same visible loading path", () => {
const app = read("apps/desktop/src/App.vue");
const openBody = functionBody(app, "onOpenObjectSource");
assert.match(openBody, /queryStore\.openObjectSourceTabPending\(\{/);
assert.match(openBody, /queryEditorObjectSourceTarget\.value =/);
assert.match(openBody, /showQueryEditorObjectSourceDialog\.value = true/);
assert.doesNotMatch(openBody, /ensureConnected\(/);
assert.doesNotMatch(openBody, /api\.getObjectSource\(/);
});
test("object source load state is runtime-only and never restored from disk", () => {
const persistence = read("apps/desktop/src/lib/app/openTabsPersistence.ts");
const types = read("apps/desktop/src/types/database.ts");
const serializeBody = functionBody(persistence, "serializeOpenTabs");
// tab 类型上带着重试所需的请求身份
assert.match(types, /sourceLoad\?: \{/);
assert.match(types, /request: \{\n\s+name: string;\n\s+objectType: ObjectSourceKind;/);
// 不落盘:否则重启后 tab 会永久停在「加载中」
assert.doesNotMatch(serializeBody, /sourceLoad/);
assert.match(persistence, /sourceLoad: undefined/);
});
+51 -9
View File
@@ -22,6 +22,20 @@ function functionBody(source: string, name: string): string {
throw new Error(`Could not parse body for ${name}`); throw new Error(`Could not parse body for ${name}`);
} }
/**
* Region of one top-level function in a store/component module. Unlike
* `functionBody`, this survives inline object type literals in the parameter
* list (the first `{` there is not the body).
*/
function functionRegion(source: string, name: string): string {
const candidates = [` async function ${name}(`, ` function ${name}(`].map((marker) => source.indexOf(marker)).filter((index) => index >= 0);
assert.notEqual(candidates.length, 0, `Could not find function ${name}`);
const start = Math.min(...candidates);
const rest = source.slice(start + 1);
const next = /\n (?:async )?function /.exec(rest);
return next ? rest.slice(0, next.index) : rest;
}
test("tree-level context menu opens with the current row items atomically", () => { test("tree-level context menu opens with the current row items atomically", () => {
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8"); const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.vue", "utf8");
const contextMenu = readFileSync("apps/desktop/src/components/ui/CustomContextMenu.vue", "utf8"); const contextMenu = readFileSync("apps/desktop/src/components/ui/CustomContextMenu.vue", "utf8");
@@ -60,18 +74,46 @@ test("tree host owns sidebar data-open generations", () => {
assert.match(connectionTree, /createSidebarActionTarget\(node\)/); assert.match(connectionTree, /createSidebarActionTarget\(node\)/);
}); });
test("query-tab object source uses canonical identity and honors backend editability", () => { test("query-tab object source opens the tab before connecting or loading", () => {
const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8"); const runtimeHost = readFileSync("apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue", "utf8");
const openObjectSourceBody = functionBody(runtimeHost, "openObjectSourceDialog"); const openObjectSourceBody = functionBody(runtimeHost, "openObjectSourceDialog");
assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTab\(\{/); // #9035:点击后立刻建出带加载态的 tab/弹窗,ensureConnected 与 getObjectSource
assert.match(openObjectSourceBody, /raw\.editable !== false/); // 都必须发生在已挂载的 UI 之内,而不是先 await 完再挂载(那样界面没有任何反馈)。
assert.match(openObjectSourceBody, /!\["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"\]\.includes\(resolvedType\)/); assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTabPending\(\{/);
assert.match(openObjectSourceBody, /objectType: resolvedType/); assert.match(openObjectSourceBody, /objectType: sourceTarget\.objectType/);
assert.match(openObjectSourceBody, /signature: node\.signature/); assert.match(openObjectSourceBody, /signature: sourceNode\.signature/);
assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true, sourceView: true \}\)/); assert.match(openObjectSourceBody, /emit\("open-object-source", sourceNode, initialEditing\)/);
assert.doesNotMatch(openObjectSourceBody, /queryStore\.updateSql/); // 断言「调用形态」而不是词本身:这段代码里注释会提到这两个名字
assert.doesNotMatch(openObjectSourceBody, /queryStore\.markTabClean/); assert.doesNotMatch(openObjectSourceBody, /ensureConnected\(/);
assert.doesNotMatch(openObjectSourceBody, /api\.getObjectSource\(/);
assert.doesNotMatch(openObjectSourceBody, /toast\(/);
});
test("object source identity and editability are enforced in queryStore", () => {
const queryStore = readFileSync("apps/desktop/src/stores/queryStore.ts", "utf8");
const findBody = functionRegion(queryStore, "findMatchingObjectSourceTab");
const pendingBody = functionRegion(queryStore, "openObjectSourceTabPending");
const applyBody = functionRegion(queryStore, "applyLoadedObjectSource");
// canonical identity:连接 + 库 + schema + catalog + 解析后的对象身份共同决定复用哪个 tab
assert.match(findBody, /tab\.objectSource\?\.name === options\.objectSource\.name/);
assert.match(findBody, /tab\.objectSource\.objectType === options\.objectSource\.objectType/);
assert.match(findBody, /\(tab\.objectSource\.schema \|\| ""\) === \(options\.objectSource\.schema \|\| ""\)/);
assert.match(findBody, /\(tab\.objectSource\.signature \|\| ""\) === \(options\.objectSource\.signature \|\| ""\)/);
// honor backend editability:只读源码不挂 objectSource,但仍是一个 sourceView tab
assert.match(applyBody, /raw\.editable !== false/);
assert.match(applyBody, /OBJECT_SOURCE_READ_ONLY_TYPES\.includes\(loaded\.resolvedType\)/);
assert.match(applyBody, /tab\.sourceView = true/);
assert.match(queryStore, /const OBJECT_SOURCE_READ_ONLY_TYPES: readonly ObjectSourceKind\[\] = \["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"\]/);
// pending 占位:同步返回(不 await),tab 已可见并带着可重试的请求身份
assert.doesNotMatch(pendingBody, /await /);
assert.match(pendingBody, /tab\.sourceLoad = \{ startedAt: Date\.now\(\), request: \{ \.\.\.options\.request \} \}/);
assert.match(pendingBody, /void loadObjectSourceIntoTab\(id\)/);
// 落地时清掉加载态,否则 tab 会永远停在转圈
assert.match(applyBody, /clearObjectSourceLoad\(tab\)/);
}); });
test("table copy menu uses the shared single and multi-selection clipboard path", () => { test("table copy menu uses the shared single and multi-selection clipboard path", () => {