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

@@ -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.
const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value));
const queryRunningElapsed = ref(0);
const sourceLoadElapsed = ref(0);
function toggleResultsPane(): boolean {
if (props.activeTab.mode !== "query" || !hasQueryOutput.value) return false;
@@ -703,25 +704,36 @@ function onResultsResized(payload: { panes: { size: number }[] }) {
}
let queryRunningElapsedFrame: number | undefined;
function stopQueryRunningElapsedTimer() {
function stopRunningElapsedTimer() {
if (queryRunningElapsedFrame !== undefined) {
window.cancelAnimationFrame(queryRunningElapsedFrame);
queryRunningElapsedFrame = undefined;
}
}
function updateQueryRunningElapsed() {
const startedAt = props.activeTab.queryExecutionStartedAt;
queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0;
/** 源码仍在加载(未失败)时的开始时间;用于耗时显示与 rAF 循环的续期判断。 */
function pendingSourceLoadStartedAt(): number | undefined {
const load = props.activeTab.sourceLoad;
return load && !load.error ? load.startedAt : undefined;
}
function startQueryRunningElapsedTimer() {
stopQueryRunningElapsedTimer();
updateQueryRunningElapsed();
if (!props.activeTab.isExecuting || !props.activeTab.queryExecutionStartedAt) return;
// 一个 rAF 循环同时驱动「查询执行中」与「对象源码加载中」两个耗时显示:
// issue #9035 的核心体感就是「不知道要等多久」,所以源码加载也要显示已耗时。
function updateRunningElapsed() {
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 = () => {
updateQueryRunningElapsed();
if (props.activeTab.isExecuting && props.activeTab.queryExecutionStartedAt) {
updateRunningElapsed();
if (isTicking()) {
queryRunningElapsedFrame = window.requestAnimationFrame(updateOnNextFrame);
}
};
@@ -729,11 +741,12 @@ function startQueryRunningElapsedTimer() {
}
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(() => {
stopQueryRunningElapsedTimer();
stopRunningElapsedTimer();
standaloneResultToolbarResizeObserver?.disconnect();
window.removeEventListener("dbx-refresh-active-kv-browser", onRefreshActiveKvBrowser);
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">
<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>
<!-- 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
v-else
ref="queryEditorRef"
class="relative z-0 flex-1"
:auto-focus="autoFocus !== false"
@@ -6,26 +6,24 @@ import { queryExecutionLabelKey } from "@/lib/sql/queryExecutionState";
import type { QueryTab } from "@/types/database";
const props = defineProps<{
tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling">;
tab: Pick<QueryTab, "mode" | "isExecuting" | "isCancelling" | "sourceLoad">;
}>();
const { t } = useI18n();
const status = computed(() => {
if (props.tab.mode !== "query" || !props.tab.isExecuting) return undefined;
// 源码加载中(issue #9035)与查询执行中共用这一个 tab 栏转圈:两者都是
// 「这个 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";
});
const labelKey = computed(() => (status.value === "source-loading" ? "common.loading" : queryExecutionLabelKey(props.tab)));
</script>
<template>
<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(queryExecutionLabelKey(tab))"
:title="t(queryExecutionLabelKey(tab))"
>
<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)">
<Loader2 aria-hidden="true" class="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
</span>
<slot v-else />
@@ -10,7 +10,7 @@ vi.mock("vue-i18n", () => ({
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 root = document.createElement("div");
document.body.appendChild(root);
@@ -82,4 +82,31 @@ describe("TabExecutionStatus", () => {
expect(idle.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;
try {
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 { 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;
@@ -2067,18 +2067,11 @@ function openSidebarObjectSource(node: TreeNode, initialEditing: boolean) {
// 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;
const target = createSidebarActionTarget(node);
const requestGeneration = beginSidebarAction();
void store
.ensureConnected(target.connectionId!)
.then(() => {
if (requestGeneration !== sidebarActionGeneration) return;
store.activeConnectionId = target.connectionId!;
sidebarObjectSourceTarget.value = { node: target, initialEditing };
sidebarObjectSourceOpen.value = true;
})
.catch((error: any) => {
if (requestGeneration === sidebarActionGeneration) toast(error?.message || String(error), 5000);
});
beginSidebarAction();
// issue #9035:弹窗立即挂载。此前先 await ensureConnected 再开弹窗,这段时间
// 界面上没有任何反馈;现在连接与取源都在弹窗自身的加载态之内完成。
sidebarObjectSourceTarget.value = { node: target, initialEditing };
sidebarObjectSourceOpen.value = true;
}
function openSidebarSettings(initialTab: string) {
@@ -156,8 +156,7 @@ import {
type TableChildObjectType,
} from "@/lib/database/dbAdminSql";
import { buildRenameObjectSql, buildRenameDatabaseSql, buildRenameDatabasePreflightSql, databaseRenameMaintenanceDatabase, supportsDatabaseRename, supportsObjectRename, type RenameableObjectType } from "@/lib/table/objectRenameSql";
import { buildEditableObjectSource, buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { loadEditableObjectSourceForEditor } from "@/lib/table/objectSourceLoad";
import { buildRoutineRenameObjectSourceStatements, supportsSourceBackedRoutineRename } from "@/lib/table/objectSourceEditor";
import { buildViewDdl } from "@/lib/table/viewDdl";
import { formatSqlForDisplay, sqlFormatDialectForDbType } from "@/lib/sql/sqlFormatter";
import { omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay";
@@ -352,7 +351,7 @@ const settingsStore = useSettingsStore();
const savedSqlStore = useSavedSqlStore();
const { toast, message: toastMessage, dismissToast } = useToast();
const { toast } = useToast();
const installedPlugins = ref<InstalledPlugin[]>([]);
const sidebarPluginRegistry = computed(() => createFrontendPluginRegistry(installedPlugins.value, appLocale.value));
@@ -2526,70 +2525,22 @@ function openObjectSourceDialog(initialEditing: boolean, viewPackageBody = false
const database = node.database;
const sourceTarget = objectSourceTargetForTreeNode(sourceNode);
if (!sourceTarget) return;
const openMode = settingsStore.editorSettings.routineSourceOpenMode;
if (openMode === "query-tab") {
const loadingMessage = t("common.loading");
const loadingToastTimer = window.setTimeout(() => toast(loadingMessage, 30000), 350);
const finishLoadingToast = () => {
window.clearTimeout(loadingToastTimer);
if (toastMessage.value === loadingMessage) dismissToast();
};
void connectionStore
.ensureConnected(connectionId)
.then(async () => {
connectionStore.activeConnectionId = connectionId;
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);
const schema = sourceTarget.schema || database;
// issue #9035:两个分支都不再 await 连接与取源。先把容器挂出来(源码 tab /
// 源码弹窗),ensureConnected 与 getObjectSource 都发生在已挂载的 UI 之内,
// 加载中有状态、失败就地重试,而不是点击后一段时间毫无反应。
if (settingsStore.editorSettings.routineSourceOpenMode === "query-tab") {
queryStore.openObjectSourceTabPending({
connectionId,
database,
title: `Source - ${node.label}`,
schema,
catalog: node.catalog,
request: { name: sourceTarget.name, objectType: sourceTarget.objectType, signature: sourceNode.signature },
});
return;
}
void connectionStore
.ensureConnected(connectionId)
.then(() => {
connectionStore.activeConnectionId = connectionId;
emit("open-object-source", sourceNode, initialEditing);
})
.catch((e: any) => {
toast(e?.message || String(e), 5000);
});
emit("open-object-source", sourceNode, initialEditing);
}
function openProcedureExecution() {