From 1ad609f0dfcf2010eb7045037787419fbf6ae7af Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 12 Aug 2026 18:33:50 +0800 Subject: [PATCH] fix(structure): debounce SQL preview updates --- .../TableStructureEditor.primaryKey.spec.ts | 53 ++++++++++++++++++- .../structure/TableStructureEditor.vue | 33 ++++++++---- 2 files changed, 75 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts index cd180b353c..ee20fe756e 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts +++ b/apps/desktop/src/components/structure/TableStructureEditor.primaryKey.spec.ts @@ -68,10 +68,16 @@ vi.mock("@/components/ui/input", async () => { Input: defineComponent({ name: "Input", inheritAttrs: false, + props: { modelValue: { type: [String, Number], default: "" } }, + emits: ["update:modelValue"], setup: - (_props, { attrs }) => + (props, { attrs, emit }) => () => - h("input", attrs), + h("input", { + ...attrs, + value: props.modelValue, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), }), }; }); @@ -316,6 +322,12 @@ function columnCheckbox(root: HTMLElement, header: string): HTMLInputElement { return checkbox; } +function buttonWithText(root: HTMLElement, text: string): HTMLButtonElement { + const button = Array.from(root.querySelectorAll("button")).find((item) => item.textContent?.includes(text)); + if (!button) throw new Error(`Missing ${text} button`); + return button; +} + beforeEach(() => { vi.clearAllMocks(); mocks.loadObjectDdl.mockResolvedValue({ ddl: "CREATE TABLE users (id bigint)", cacheStatus: "remote" }); @@ -324,6 +336,7 @@ beforeEach(() => { }); afterEach(() => { + vi.useRealTimers(); for (const app of mountedApps.splice(0)) app.unmount(); document.body.innerHTML = ""; }); @@ -398,6 +411,10 @@ describe("TableStructureEditor primary key editing", () => { ); nullable.checked = true; nullable.dispatchEvent(new Event("change", { bubbles: true })); + await nextTick(); + + expect(buttonWithText(root, "structureEditor.copySql").disabled).toBe(true); + expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(true); await vi.waitFor(() => expect(mocks.buildTableStructureChangeSql).toHaveBeenCalledTimes(2)); expect(root.textContent).toContain(firstSql); @@ -405,6 +422,38 @@ describe("TableStructureEditor primary key editing", () => { resolveLatestPreview({ statements: ["ALTER TABLE users ALTER COLUMN id DROP NOT NULL;"], warnings: [] }); await vi.waitFor(() => expect(root.textContent).toContain("ALTER TABLE users ALTER COLUMN id DROP NOT NULL;")); + expect(buttonWithText(root, "structureEditor.copySql").disabled).toBe(false); + expect(buttonWithText(root, "structureEditor.apply").disabled).toBe(false); + }); + + it("debounces SQL preview generation while editing a column name", async () => { + vi.useFakeTimers(); + const root = await mountEditor("dameng"); + const nameInput = root.querySelector("[data-column-name-input]"); + if (!nameInput) throw new Error("Missing column name input"); + + nameInput.value = "user"; + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + await vi.advanceTimersByTimeAsync(200); + + nameInput.value = "user_id"; + nameInput.dispatchEvent(new Event("input", { bubbles: true })); + await nextTick(); + await vi.advanceTimersByTimeAsync(299); + + expect(mocks.buildTableStructureChangeSql).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(1); + await Promise.resolve(); + await nextTick(); + + expect(mocks.buildTableStructureChangeSql).toHaveBeenCalledTimes(1); + expect(mocks.buildTableStructureChangeSql).toHaveBeenLastCalledWith( + expect.objectContaining({ + columns: [expect.objectContaining({ name: "user_id" })], + }), + ); }); }); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index b8d7e29ecb..dd89ac6be7 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -100,6 +100,7 @@ const ddlScrollerRef = ref(); const dynamicDataTypeOptionsCache = new Map(); const sqlHighlighter = ref(); +const SQL_PREVIEW_DEBOUNCE_MS = 300; onMounted(async () => { sqlHighlighter.value = await createShikiSqlHighlighter({ appearance: () => (isDark.value ? "dark" : "light"), @@ -137,6 +138,7 @@ const loading = ref(false); const saving = ref(false); const postSaveRefreshing = ref(false); const sqlPreviewLoading = ref(false); +const sqlPreviewPending = ref(false); const indexesLoading = ref(false); const foreignKeysLoading = ref(false); const triggersLoading = ref(false); @@ -1030,6 +1032,7 @@ function clearSqlPreviewState() { sqlPreviewRequestId++; deferredSqlPreviewRefresh = false; sqlPreviewLoading.value = false; + sqlPreviewPending.value = false; pendingStatements.value = []; warnings.value = []; sqliteSchemaRevision.value = undefined; @@ -1134,9 +1137,10 @@ function scheduleSqlPreviewRefresh() { warnings.value = []; sqliteSchemaRevision.value = undefined; sqlPreviewLoading.value = false; + sqlPreviewPending.value = false; return; } - sqlPreviewLoading.value = true; + sqlPreviewPending.value = true; if (hydratingRestoredDraft || needsColumnDraftMetadataHydration()) return; if (!isCreateMode.value && secondaryMetadataLoading.value) { deferredSqlPreviewRefresh = true; @@ -1145,7 +1149,7 @@ function scheduleSqlPreviewRefresh() { sqlPreviewDebounceTimer = setTimeout(() => { sqlPreviewDebounceTimer = undefined; void refreshSqlPreview(); - }, 80); + }, SQL_PREVIEW_DEBOUNCE_MS); } function structureChangeOptions(): BuildTableStructureChangeSqlOptions { @@ -1169,6 +1173,7 @@ async function refreshSqlPreview() { warnings.value = []; sqliteSchemaRevision.value = undefined; sqlPreviewLoading.value = false; + sqlPreviewPending.value = false; return; } sqlPreviewLoading.value = true; @@ -1185,7 +1190,10 @@ async function refreshSqlPreview() { warnings.value = [e?.message || String(e)]; sqliteSchemaRevision.value = undefined; } finally { - if (requestId === sqlPreviewRequestId) sqlPreviewLoading.value = false; + if (requestId === sqlPreviewRequestId) { + sqlPreviewLoading.value = false; + sqlPreviewPending.value = false; + } } } @@ -1196,6 +1204,7 @@ const canApply = computed( !postSaveRefreshing.value && !secondaryMetadataLoading.value && !sqlPreviewLoading.value && + !sqlPreviewPending.value && pendingStatements.value.length > 0 && warnings.value.length === 0 && (!hasSqliteTypeChange.value || !!sqliteSchemaRevision.value) && @@ -1213,6 +1222,7 @@ function resetState() { saving.value = false; postSaveRefreshing.value = false; sqlPreviewLoading.value = false; + sqlPreviewPending.value = false; indexesLoading.value = false; foreignKeysLoading.value = false; triggersLoading.value = false; @@ -2273,7 +2283,7 @@ async function recordStructureHistory(sql: string, start: number, success: boole } async function copyPreviewSql() { - if (!previewSqlText.value.trim()) return; + if (sqlPreviewPending.value || sqlPreviewLoading.value || !previewSqlText.value.trim()) return; try { await copyToClipboard(previewSqlText.value); toast(t("grid.copied")); @@ -2335,6 +2345,8 @@ async function applyChanges() { loadedMetadataFacets.clear(); } toast(t("structureEditor.saved"), 2500); + sqlPreviewPending.value = false; + sqlPreviewLoading.value = false; pendingStatements.value = []; warnings.value = []; sqliteSchemaRevision.value = undefined; @@ -3360,7 +3372,7 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
{{ t("structureEditor.sqlPreview") }} - + {{ t("structureEditor.ready") }} @@ -3376,17 +3388,17 @@ watch([activeTab, ddlLoading], ([tab, loading]) => { - - - + + {{ pendingStatements.length }}
-
+
{{ t("structureEditor.sqliteRebuildNotice") }} @@ -3398,6 +3410,9 @@ watch([activeTab, ddlLoading], ([tab, loading]) => {
+          
+ +
{{ t("structureEditor.noChanges") }}