From 508369ee3907caa46561d26a213138055d9acd44 Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Sun, 30 Aug 2026 00:47:21 +0800 Subject: [PATCH] fix(mysql): open editor when creating a new event --- .../src/components/layout/ContentArea.vue | 1 + .../src/components/objects/ObjectBrowser.vue | 36 +++-- .../ObjectBrowser.eventCreate.spec.ts | 62 +++++++++ .../sidebar/SidebarTreeRuntimeHost.vue | 20 ++- .../src/lib/table/eventEditorRequest.spec.ts | 63 +++++++++ .../src/lib/table/eventEditorRequest.ts | 50 +++++++ .../__tests__/queryStore.eventCreate.spec.ts | 129 ++++++++++++++++++ apps/desktop/src/stores/queryStore.ts | 23 +++- apps/desktop/src/types/database.ts | 2 + 9 files changed, 368 insertions(+), 18 deletions(-) create mode 100644 apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.spec.ts create mode 100644 apps/desktop/src/lib/table/eventEditorRequest.spec.ts create mode 100644 apps/desktop/src/lib/table/eventEditorRequest.ts create mode 100644 apps/desktop/src/stores/__tests__/queryStore.eventCreate.spec.ts diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index e66e2d858d..8b1ddb0a9e 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2343,6 +2343,7 @@ defineExpose({ :initial-event-name="activeTab.objectBrowser?.eventName" :initial-event-read-only="activeTab.objectBrowser?.eventReadOnly" :initial-event-open-request-id="activeTab.objectBrowser?.eventOpenRequestId" + :initial-event-create-request-id="activeTab.objectBrowser?.eventCreateRequestId" :initial-object-filter="activeTab.objectBrowser?.initialObjectFilter" :viewport="activeTab.objectBrowser?.viewport" @open-table="emit('openObjectTable', $event)" diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 77ba221461..d4e24cd0cd 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -155,6 +155,7 @@ import { loadObjectMetadataFacet } from "@/lib/metadata/objectMetadataCache"; import { invalidateObjectMetadataCache } from "@/lib/metadata/objectMetadataCache"; import { invalidateObjectDdl } from "@/lib/metadata/objectDdlCache"; import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; +import { resolveInitialEventEditorRequest } from "@/lib/table/eventEditorRequest"; type ObjectFilter = ObjectBrowserFilter; type ObjectBrowserColumnKey = "select" | "name" | "type" | "estimatedRows" | "totalBytes" | "created_at" | "updated_at" | "comment"; @@ -167,6 +168,8 @@ const props = defineProps<{ initialEventName?: string; initialEventReadOnly?: boolean; initialEventOpenRequestId?: number; + /** 显式"新建事件"请求号:每次菜单点击递增,用于打开/重新进入 CREATE 编辑器 */ + initialEventCreateRequestId?: number; initialObjectFilter?: "tables" | "events"; viewport?: ObjectBrowserViewport; }>(); @@ -2711,18 +2714,33 @@ function applyObjectBrowserRows(nextRows: ObjectBrowserRow[]) { } function openInitialEventIfNeeded() { - const name = props.initialEventName?.trim(); - const requestKey = `${props.initialEventOpenRequestId ?? 0}:${name}`; - if (!name || openedInitialEvent.value === requestKey || loadingObjects.value) return; + const name = props.initialEventName?.trim() ?? ""; + const decision = resolveInitialEventEditorRequest({ + eventCreateRequestId: props.initialEventCreateRequestId, + eventName: props.initialEventName, + eventOpenRequestId: props.initialEventOpenRequestId, + openedRequestKey: openedInitialEvent.value, + hasEventRow: rows.value.some((candidate) => candidate.type === "EVENT" && candidate.name === name), + loadingObjects: loadingObjects.value, + }); + if (decision.type === "ignore") return; + openedInitialEvent.value = decision.requestKey; + if (decision.type === "create") { + // 新建事件:不依赖对象列表中的 EVENT row,直接进入 CREATE 编辑器。 + // MySqlEventEditor 收到空 name 时会以 CREATE 模式渲染。 + sidePanelGuard.start(); + sidePanelRow.value = null; + sourceRow.value = null; + sidePanelMode.value = "event-editor"; + return; + } const row = rows.value.find((candidate) => candidate.type === "EVENT" && candidate.name === name); - if (!row) return; - openedInitialEvent.value = requestKey; - openEventEditor(row); + if (row) openEventEditor(row); } function finishObjectBrowserRowsLoad() { loadingObjects.value = false; - const preferredFilter = props.initialObjectFilter ?? (props.initialEventName ? "events" : "tables"); + const preferredFilter = props.initialObjectFilter ?? (props.initialEventName || props.initialEventCreateRequestId !== undefined ? "events" : "tables"); if (!userHasSelectedFilter.value && objectCounts.value[preferredFilter] > 0) { // The default table filter is a presentation choice, not a user query // change, so preserve the tab's saved scroll offset across remounts. @@ -2733,8 +2751,8 @@ function finishObjectBrowserRowsLoad() { restoreObjectBrowserViewport(); } -watch([() => props.initialEventName, () => props.initialEventOpenRequestId], ([name, requestId], [previousName, previousRequestId]) => { - if (name !== previousName || requestId !== previousRequestId) openedInitialEvent.value = ""; +watch([() => props.initialEventName, () => props.initialEventOpenRequestId, () => props.initialEventCreateRequestId], ([name, requestId, createRequestId], [previousName, previousRequestId, previousCreateRequestId]) => { + if (name !== previousName || requestId !== previousRequestId || createRequestId !== previousCreateRequestId) openedInitialEvent.value = ""; openInitialEventIfNeeded(); }); diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.spec.ts new file mode 100644 index 0000000000..eb091b53c9 --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.eventCreate.spec.ts @@ -0,0 +1,62 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; + +const objectBrowserSource = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8"); +const contentAreaSource = readFileSync(new URL("../../layout/ContentArea.vue", import.meta.url), "utf8"); + +function functionBody(name: string, source: string): string { + const signature = new RegExp(`(?:async\\s+)?function\\s+${name}\\s*\\([^)]*\\)\\s*(?::\\s*[^\\{]+)?\\{`, "m").exec(source); + if (!signature) throw new Error(`Missing function ${name}`); + const bodyStart = signature.index + signature[0].length; + let depth = 1; + for (let index = bodyStart; index < source.length; index += 1) { + if (source[index] === "{") depth += 1; + else if (source[index] === "}") depth -= 1; + if (depth === 0) return source.slice(bodyStart, index); + } + throw new Error(`Unclosed function ${name}`); +} + +describe("ObjectBrowser MySQL Event CREATE navigation", () => { + it("accepts an explicit create request prop next to the edit props", () => { + expect(objectBrowserSource).toContain("initialEventCreateRequestId?: number;"); + expect(objectBrowserSource).toMatch(/initialEventName\?: string;[\s\S]*?initialEventCreateRequestId\?: number;[\s\S]*?initialObjectFilter\?: "tables" \| "events";/); + }); + + it("routes the initial event request through the shared resolver", () => { + const openInitialEventIfNeeded = functionBody("openInitialEventIfNeeded", objectBrowserSource); + expect(openInitialEventIfNeeded).toContain("resolveInitialEventEditorRequest({"); + expect(openInitialEventIfNeeded).toContain("eventCreateRequestId: props.initialEventCreateRequestId"); + expect(openInitialEventIfNeeded).toContain("openedRequestKey: openedInitialEvent.value"); + expect(openInitialEventIfNeeded).toContain("hasEventRow: rows.value.some"); + expect(openInitialEventIfNeeded).toContain("loadingObjects: loadingObjects.value"); + }); + + it("opens the CREATE editor without any existing EVENT row", () => { + const openInitialEventIfNeeded = functionBody("openInitialEventIfNeeded", objectBrowserSource); + expect(openInitialEventIfNeeded).toContain('if (decision.type === "create") {'); + expect(openInitialEventIfNeeded).toContain("sidePanelRow.value = null;"); + expect(openInitialEventIfNeeded).toContain('sidePanelMode.value = "event-editor";'); + expect(openInitialEventIfNeeded).toContain("openedInitialEvent.value = decision.requestKey;"); + // The editor must see an empty name so MySqlEventEditor renders CREATE mode + expect(openInitialEventIfNeeded).not.toContain("sidePanelRow.value = row;"); + }); + + it("renders the editor with sidePanelRow name (empty in create mode -> CREATE SQL)", () => { + expect(objectBrowserSource).toContain(' { + expect(objectBrowserSource).toContain("() => props.initialEventCreateRequestId"); + expect(objectBrowserSource).toMatch(/watch\(\[\(\) => props\.initialEventName, \(\) => props\.initialEventOpenRequestId, \(\) => props\.initialEventCreateRequestId\][\s\S]*?openedInitialEvent\.value = "";[\s\S]*?openInitialEventIfNeeded\(\);/); + }); + + it("lists the events filter as the preferred view for a create request", () => { + expect(objectBrowserSource).toMatch(/const preferredFilter = props\.initialObjectFilter \?\? \(props\.initialEventName \|\| props\.initialEventCreateRequestId !== undefined \? "events" : "tables"\);/); + }); + + it("ContentArea passes the create request id into ObjectBrowser", () => { + expect(contentAreaSource).toContain(':initial-event-name="activeTab.objectBrowser?.eventName"'); + expect(contentAreaSource).toContain(':initial-event-create-request-id="activeTab.objectBrowser?.eventCreateRequestId"'); + }); +}); diff --git a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue index 7116037b80..c352b904f7 100644 --- a/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue +++ b/apps/desktop/src/components/sidebar/SidebarTreeRuntimeHost.vue @@ -1504,15 +1504,19 @@ async function confirmDeleteSavedSqlFile() { releaseActiveNodeReference([node.id]); } -async function openObjectBrowser(eventReadOnly = false, openEventEditor = false) { +async function openObjectBrowser(eventReadOnly = false, openEventEditor: boolean | "create" = false) { const node = activeNode.value; if (!node.connectionId) return; try { await connectionStore.ensureConnected(node.connectionId); connectionStore.activeConnectionId = node.connectionId; + const eventName = openEventEditor === true && node.type === "event" ? node.objectName || node.label : undefined; + const eventCreateRequestId = openEventEditor === "create" && node.type === "group-events" ? ++mysqlEventCreateRequestSeq : undefined; + const objectFilter = node.type === "event" || node.type === "group-events" ? "events" : undefined; + if (hasTreeNodeDatabaseContext(node)) { - queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, openEventEditor && node.type === "event" ? node.objectName || node.label : undefined, eventReadOnly, node.type === "event" || node.type === "group-events" ? "events" : undefined); + queryStore.openObjectBrowser(node.connectionId, node.database, node.schema, node.catalog, eventName, eventReadOnly, objectFilter, eventCreateRequestId); return; } @@ -1521,7 +1525,7 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor = false) const options = await getDatabaseOptions(node.connectionId); const database = resolveDefaultDatabase(connection, options); if (database) { - queryStore.openObjectBrowser(node.connectionId, database, undefined, undefined, openEventEditor && node.type === "event" ? node.objectName || node.label : undefined, eventReadOnly, node.type === "event" || node.type === "group-events" ? "events" : undefined); + queryStore.openObjectBrowser(node.connectionId, database, undefined, undefined, eventName, eventReadOnly, objectFilter, eventCreateRequestId); } else { await toggle(); } @@ -1531,6 +1535,14 @@ async function openObjectBrowser(eventReadOnly = false, openEventEditor = false) } } +// 每次"新建事件"菜单点击都会分配一个单调递增的请求号,保证同一个 +// ObjectBrowser tab 被复用时也能重新进入 MySQL Event 的 CREATE 编辑器。 +let mysqlEventCreateRequestSeq = 0; + +function openMysqlEventCreateEditor() { + void openObjectBrowser(false, "create"); +} + async function openDatabaseBrowser() { const node = activeNode.value; if (node.type !== "connection" || !node.connectionId) return; @@ -5705,7 +5717,7 @@ function buildObjectGroupSidebarMenu(context: SidebarMenuFactoryContext): boolea items.push({ label: t("contextMenu.createView"), action: createView, icon: Plus }); } if (node.type === "group-events" && node.connectionId && node.database) { - items.push({ label: t("contextMenu.createEvent"), action: openObjectBrowser, icon: Plus }); + items.push({ label: t("contextMenu.createEvent"), action: openMysqlEventCreateEditor, icon: Plus }); } if (mysqlObjectTemplate) { items.push({ label: t(mysqlObjectTemplate.titleKey), action: createMysqlObjectTemplate, icon: Plus }); diff --git a/apps/desktop/src/lib/table/eventEditorRequest.spec.ts b/apps/desktop/src/lib/table/eventEditorRequest.spec.ts new file mode 100644 index 0000000000..9d53d9dba5 --- /dev/null +++ b/apps/desktop/src/lib/table/eventEditorRequest.spec.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from "vitest"; +import { resolveInitialEventEditorRequest } from "./eventEditorRequest"; + +const base = { openedRequestKey: "" }; + +describe("resolveInitialEventEditorRequest", () => { + describe("create request (New Event)", () => { + it("opens CREATE mode without requiring an existing EVENT row", () => { + const decision = resolveInitialEventEditorRequest({ ...base, eventCreateRequestId: 7, hasEventRow: false }); + expect(decision).toEqual({ type: "create", requestKey: "create:7" }); + }); + + it("dedupes only the same request id", () => { + expect(resolveInitialEventEditorRequest({ ...base, eventCreateRequestId: 3, openedRequestKey: "create:2" })).toEqual({ type: "create", requestKey: "create:3" }); + expect(resolveInitialEventEditorRequest({ openedRequestKey: "create:3", eventCreateRequestId: 3 })).toEqual({ type: "ignore" }); + }); + + it("re-enters CREATE mode when the request id increments on a reused tab", () => { + const first = resolveInitialEventEditorRequest({ openedRequestKey: "", eventCreateRequestId: 1 }); + expect(first).toEqual({ type: "create", requestKey: "create:1" }); + // second click on the SAME tab with a fresh request id must open again + const second = resolveInitialEventEditorRequest({ openedRequestKey: first.type === "create" ? first.requestKey : "", eventCreateRequestId: 2 }); + expect(second).toEqual({ type: "create", requestKey: "create:2" }); + }); + + it("defers while the object list is still loading", () => { + expect(resolveInitialEventEditorRequest({ ...base, eventCreateRequestId: 1, loadingObjects: true })).toEqual({ type: "ignore" }); + expect(resolveInitialEventEditorRequest({ ...base, eventCreateRequestId: 1, loadingObjects: false })).toEqual({ type: "create", requestKey: "create:1" }); + }); + }); + + describe("edit request (existing event)", () => { + it("opens the existing EVENT editor only when the row is present", () => { + expect(resolveInitialEventEditorRequest({ ...base, eventName: "foo_event", eventOpenRequestId: 2, hasEventRow: true })).toEqual({ type: "edit", requestKey: "2:foo_event" }); + expect(resolveInitialEventEditorRequest({ ...base, eventName: "foo_event", eventOpenRequestId: 2, hasEventRow: false })).toEqual({ type: "ignore" }); + }); + + it("remains ALTER-mode safe: same name + new request id reopens", () => { + const first = resolveInitialEventEditorRequest({ openedRequestKey: "", eventName: "foo_event", eventOpenRequestId: 1, hasEventRow: true }); + expect(first).toEqual({ type: "edit", requestKey: "1:foo_event" }); + const second = resolveInitialEventEditorRequest({ openedRequestKey: first.type === "edit" ? first.requestKey : "", eventName: "foo_event", eventOpenRequestId: 2, hasEventRow: true }); + expect(second).toEqual({ type: "edit", requestKey: "2:foo_event" }); + }); + }); + + describe("plain Event list open", () => { + it("does not open any editor when there is no event intent", () => { + expect(resolveInitialEventEditorRequest({ ...base, openedRequestKey: "" })).toEqual({ type: "ignore" }); + expect(resolveInitialEventEditorRequest({ ...base, openedRequestKey: "", initialObjectFilterIntent: undefined } as any)).toEqual({ type: "ignore" }); + }); + + it("does not open CREATE mode for stale blank names", () => { + expect(resolveInitialEventEditorRequest({ ...base, eventName: " " })).toEqual({ type: "ignore" }); + }); + }); + + describe("create priority over stale edit target", () => { + it("honors a fresh create request even when an edit target lingers", () => { + const decision = resolveInitialEventEditorRequest({ ...base, eventCreateRequestId: 5, eventName: "old_event", eventOpenRequestId: 9, hasEventRow: true }); + expect(decision).toEqual({ type: "create", requestKey: "create:5" }); + }); + }); +}); diff --git a/apps/desktop/src/lib/table/eventEditorRequest.ts b/apps/desktop/src/lib/table/eventEditorRequest.ts new file mode 100644 index 0000000000..51ad43735b --- /dev/null +++ b/apps/desktop/src/lib/table/eventEditorRequest.ts @@ -0,0 +1,50 @@ +/** + * Resolve which MySQL Event editor request an ObjectBrowser tab should apply. + * + * ObjectBrowser receives event-open intent through three independent props: + * + * - `eventCreateRequestId`: explicit "create a NEW event" request. It does not + * depend on the EVENT row existing in the object list, so it can open the + * MySqlEventEditor in CREATE mode even before the list is loaded. + * - `eventName` + `eventOpenRequestId`: "open/edit THIS existing event". It + * requires the EVENT row to be present in the loaded object list. + * + * The create request takes priority over an edit request so a stale edit target + * can never swallow a fresh "New Event" action. Callers (queryStore) keep the + * two states mutually exclusive, but this resolver stays defensive anyway. + */ +export type InitialEventEditorDecision = { type: "create"; requestKey: string } | { type: "edit"; requestKey: string } | { type: "ignore" }; + +export interface InitialEventEditorRequestInput { + /** Monotonic "New Event" request id (undefined = no create intent). */ + eventCreateRequestId?: number; + /** Existing event name to edit (undefined = no edit intent). */ + eventName?: string; + /** Request id that pairs with eventName for repeat open-intent. */ + eventOpenRequestId?: number; + /** Request key already honored by this ObjectBrowser instance (dedupe). */ + openedRequestKey: string; + /** Whether the requested event already exists in the loaded object list. */ + hasEventRow?: boolean; + /** Object list is still loading; defer honoring any request. */ + loadingObjects?: boolean; +} + +export function resolveInitialEventEditorRequest(input: InitialEventEditorRequestInput): InitialEventEditorDecision { + const { eventCreateRequestId, eventName, eventOpenRequestId, openedRequestKey, hasEventRow = false, loadingObjects = false } = input; + + // A "New Event" request opens the CREATE editor without needing an existing + // EVENT row. It is deduped by its own monotonic request id so the SAME tab + // can be asked to re-enter create mode on every click. + if (eventCreateRequestId !== undefined) { + const requestKey = `create:${eventCreateRequestId}`; + if (loadingObjects || openedRequestKey === requestKey) return { type: "ignore" }; + return { type: "create", requestKey }; + } + + const name = eventName?.trim(); + if (!name) return { type: "ignore" }; + const requestKey = `${eventOpenRequestId ?? 0}:${name}`; + if (loadingObjects || openedRequestKey === requestKey || !hasEventRow) return { type: "ignore" }; + return { type: "edit", requestKey }; +} diff --git a/apps/desktop/src/stores/__tests__/queryStore.eventCreate.spec.ts b/apps/desktop/src/stores/__tests__/queryStore.eventCreate.spec.ts new file mode 100644 index 0000000000..8b259bbde3 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/queryStore.eventCreate.spec.ts @@ -0,0 +1,129 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); + return data; +} + +// queryStore 是巨型模块,每个用例 vi.resetModules() 后动态 import 需要数秒, +// 显式放宽超时(与 queryStore.database-open.spec.ts 的做法一致)。 +const QUERY_STORE_TEST_TIMEOUT = 30_000; + +describe("queryStore MySQL Event create navigation", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }, QUERY_STORE_TEST_TIMEOUT); + + it( + "records an explicit CREATE request when opening a brand-new ObjectBrowser for New Event", + async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.openObjectBrowser("mysql-1", "shop", undefined, undefined, undefined, false, "events", 1); + + expect(store.tabs).toHaveLength(1); + expect(store.tabs.find((tab) => tab.id === tabId)?.objectBrowser).toMatchObject({ + eventName: undefined, + eventReadOnly: false, + eventOpenRequestId: undefined, + eventCreateRequestId: 1, + initialObjectFilter: "events", + }); + }, + QUERY_STORE_TEST_TIMEOUT, + ); + + it( + "reuses the existing ObjectBrowser tab and re-triggers CREATE on every New Event click", + async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const firstTabId = store.openObjectBrowser("mysql-1", "shop", undefined, undefined, undefined, false, "events", 1); + const tab = store.tabs.find((item) => item.id === firstTabId)!; + + // Second click on the SAME tab: request id must increment so the tab can + // re-enter CREATE mode instead of swallowing the action. + const secondTabId = store.openObjectBrowser("mysql-1", "shop", undefined, undefined, undefined, false, "events", 2); + + expect(secondTabId).toBe(firstTabId); + expect(tab.objectBrowser?.eventCreateRequestId).toBe(2); + expect(tab.objectBrowser?.eventName).toBeUndefined(); + }, + QUERY_STORE_TEST_TIMEOUT, + ); + + it( + "opens an existing event in edit mode without any create state", + async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.openObjectBrowser("mysql-1", "shop", undefined, undefined, "foo_event", false, "events"); + + const tab = store.tabs.find((item) => item.id === tabId)!; + expect(tab.objectBrowser?.eventName).toBe("foo_event"); + expect(tab.objectBrowser?.eventOpenRequestId).toBe(1); + expect(tab.objectBrowser?.eventCreateRequestId).toBeUndefined(); + expect(tab.objectBrowser?.initialObjectFilter).toBe("events"); + + // Re-opening the same existing event on the reused tab increments edit request id + store.openObjectBrowser("mysql-1", "shop", undefined, undefined, "foo_event", true, "events"); + expect(tab.objectBrowser?.eventOpenRequestId).toBe(2); + expect(tab.objectBrowser?.eventReadOnly).toBe(true); + expect(tab.objectBrowser?.eventCreateRequestId).toBeUndefined(); + }, + QUERY_STORE_TEST_TIMEOUT, + ); + + it( + "keeps create and edit requests mutually exclusive on a reused tab", + async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.openObjectBrowser("mysql-1", "shop", undefined, undefined, undefined, false, "events", 1); + const tab = store.tabs.find((item) => item.id === tabId)!; + + // Edit request after a create request clears the stale create state + store.openObjectBrowser("mysql-1", "shop", undefined, undefined, "foo_event", false, "events"); + expect(tab.objectBrowser?.eventName).toBe("foo_event"); + expect(tab.objectBrowser?.eventCreateRequestId).toBeUndefined(); + + // A later create request clears the stale edit state + store.openObjectBrowser("mysql-1", "shop", undefined, undefined, undefined, false, "events", 3); + expect(tab.objectBrowser?.eventCreateRequestId).toBe(3); + expect(tab.objectBrowser?.eventName).toBeUndefined(); + expect(tab.objectBrowser?.eventOpenRequestId).toBeUndefined(); + }, + QUERY_STORE_TEST_TIMEOUT, + ); + + it( + "plain Event list open carries no create or edit intent", + async () => { + const { useQueryStore } = await import("@/stores/queryStore"); + const store = useQueryStore(); + + const tabId = store.openObjectBrowser("mysql-1", "shop", undefined, undefined, undefined, false, "events"); + + expect(store.tabs.find((tab) => tab.id === tabId)?.objectBrowser).toMatchObject({ + eventName: undefined, + eventCreateRequestId: undefined, + eventOpenRequestId: undefined, + initialObjectFilter: "events", + }); + }, + QUERY_STORE_TEST_TIMEOUT, + ); +}); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index f0338a7b1e..9d8264edca 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -1942,15 +1942,27 @@ export const useQueryStore = defineStore("query", () => { return id; } - function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string, eventName?: string, eventReadOnly = false, initialObjectFilter?: "tables" | "events") { + function openObjectBrowser(connectionId: string, database: string, schema?: string, catalog?: string, eventName?: string, eventReadOnly = false, initialObjectFilter?: "tables" | "events", eventCreateRequestId?: number) { const title = catalog ? `${catalog}.${database} objects` : schema ? `${schema} objects` : `${database} objects`; const existing = tabs.value.find((tab) => tab.mode === "objects" && tab.connectionId === connectionId && tab.database === database && (tab.objectBrowser?.catalog || "") === (catalog || "") && (tab.objectBrowser?.schema || "") === (schema || "")); if (existing) { - if (eventName) { + if (eventCreateRequestId !== undefined) { + // 新建事件:显式 CREATE 请求优先,并清掉可能残留的"编辑已有事件"状态, + // 保证同一 tab 被复用时每次点击都能重新进入 CREATE 编辑器(请求号单调递增)。 + existing.objectBrowser = { + ...existing.objectBrowser, + eventName: undefined, + eventReadOnly: false, + eventOpenRequestId: undefined, + eventCreateRequestId, + initialObjectFilter: initialObjectFilter ?? "events", + }; + } else if (eventName) { existing.objectBrowser = { ...existing.objectBrowser, eventName, eventReadOnly, + eventCreateRequestId: undefined, initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : existing.objectBrowser?.initialObjectFilter), eventOpenRequestId: (existing.objectBrowser?.eventOpenRequestId ?? 0) + 1, }; @@ -1975,10 +1987,11 @@ export const useQueryStore = defineStore("query", () => { catalog, schema, objectType: "tables", - eventName, - eventReadOnly, - initialObjectFilter: initialObjectFilter ?? (eventName ? "events" : undefined), + eventName: eventCreateRequestId !== undefined ? undefined : eventName, + eventReadOnly: eventCreateRequestId !== undefined ? false : eventReadOnly, + initialObjectFilter: initialObjectFilter ?? (eventName || eventCreateRequestId !== undefined ? "events" : undefined), eventOpenRequestId: eventName ? 1 : undefined, + eventCreateRequestId, }, }; tabs.value.push(tab); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index ab9035ff72..8ade30a5e3 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1218,6 +1218,8 @@ export interface QueryTab { eventName?: string; eventReadOnly?: boolean; eventOpenRequestId?: number; + /** 显式的"新建事件"请求:单调递增,用于让已复用 tab 也能重复进入 CREATE 编辑器 */ + eventCreateRequestId?: number; initialObjectFilter?: "tables" | "events"; viewport?: ObjectBrowserViewport; };