fix(source): open pending tab while loading object source
This commit is contained in:
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() {
|
||||
|
||||
Reference in new issue
Block a user