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

+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}`);
}
/**
* 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", () => {
const connectionTree = readFileSync("apps/desktop/src/components/sidebar/ConnectionTree.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\)/);
});
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 openObjectSourceBody = functionBody(runtimeHost, "openObjectSourceDialog");
assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTab\(\{/);
assert.match(openObjectSourceBody, /raw\.editable !== false/);
assert.match(openObjectSourceBody, /!\["SEQUENCE", "TRIGGER", "TYPE", "TYPE_BODY", "JOB"\]\.includes\(resolvedType\)/);
assert.match(openObjectSourceBody, /objectType: resolvedType/);
assert.match(openObjectSourceBody, /signature: node\.signature/);
assert.match(openObjectSourceBody, /createTab\(connectionId, database, `Source - \$\{node\.label\}`, "query", schema, editableSource, node\.catalog, \{ forceNew: true, sourceView: true \}\)/);
assert.doesNotMatch(openObjectSourceBody, /queryStore\.updateSql/);
assert.doesNotMatch(openObjectSourceBody, /queryStore\.markTabClean/);
// #9035:点击后立刻建出带加载态的 tab/弹窗,ensureConnected 与 getObjectSource
// 都必须发生在已挂载的 UI 之内,而不是先 await 完再挂载(那样界面没有任何反馈)。
assert.match(openObjectSourceBody, /queryStore\.openObjectSourceTabPending\(\{/);
assert.match(openObjectSourceBody, /objectType: sourceTarget\.objectType/);
assert.match(openObjectSourceBody, /signature: sourceNode\.signature/);
assert.match(openObjectSourceBody, /emit\("open-object-source", sourceNode, initialEditing\)/);
// 断言「调用形态」而不是词本身:这段代码里注释会提到这两个名字
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", () => {