From 451fde014ba79e0a3a029590da1c605d20b0ec39 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 16 Sep 2026 21:53:29 +0800 Subject: [PATCH] fix(tabs): allow saving a saved SQL tab after clearing content --- apps/desktop/src/App.vue | 5 +-- .../src/components/layout/EditorToolbar.vue | 3 +- .../__tests__/tabs/sqlTabSaveTarget.spec.ts | 41 +++++++++++++++++++ apps/desktop/src/lib/tabs/sqlTabSaveTarget.ts | 22 ++++++++++ 4 files changed, 66 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/tabs/sqlTabSaveTarget.spec.ts create mode 100644 apps/desktop/src/lib/tabs/sqlTabSaveTarget.ts diff --git a/apps/desktop/src/App.vue b/apps/desktop/src/App.vue index 8621bc1649..20e4a471ad 100644 --- a/apps/desktop/src/App.vue +++ b/apps/desktop/src/App.vue @@ -105,6 +105,7 @@ import { tabSwitcherDirectionFromShortcut, } from "@/lib/editor/keyboardShortcuts"; import { createTabNavigationHistory, moveInTabNavigationHistory, recordTabVisit } from "@/lib/tabs/tabNavigationHistory"; +import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget"; import { initialTabSwitcherSelection, moveTabSwitcherSelection, tabSwitcherOrder } from "@/lib/tabs/tabSwitcher"; import { createTabSwitcherKeyboardController } from "@/lib/tabs/tabSwitcherKeyboard"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; @@ -1669,10 +1670,6 @@ function finishSqlLibraryFlyAnimation(animationId: number) { sqlLibrarySaveFeedbackId.value += 1; } -function canSaveSqlTab(tab: QueryTab): boolean { - return !!tab.externalSqlPath || !!tab.sql.trim(); -} - function closePendingSavedTab() { if (!pendingSaveAndCloseTabId.value) return; const closeId = pendingSaveAndCloseTabId.value; diff --git a/apps/desktop/src/components/layout/EditorToolbar.vue b/apps/desktop/src/components/layout/EditorToolbar.vue index 55a42b858d..d67c860d4c 100644 --- a/apps/desktop/src/components/layout/EditorToolbar.vue +++ b/apps/desktop/src/components/layout/EditorToolbar.vue @@ -24,6 +24,7 @@ import { hexToRgba } from "@/lib/common/color"; import { productionContextForDatabase } from "@/lib/database/productionSafety"; import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay"; import { resolveNextEditorToolbarTier, type EditorToolbarTier } from "@/lib/tabs/editorToolbarLayout"; +import { canSaveSqlTab } from "@/lib/tabs/sqlTabSaveTarget"; import { looksLikeDmlStatement } from "@/lib/sql/dmlChangePreview"; import { canFormatSqlForDatabaseType } from "@/lib/sql/sqlFormatter"; import type { QueryTab, ConnectionConfig } from "@/types/database"; @@ -249,7 +250,7 @@ const explainAnalyzeTooltip = computed(() => { if (dbType === "sqlserver") return t("toolbar.actualPlan"); return t("toolbar.autotrace"); }); -const canSaveSql = computed(() => !!props.activeTab.externalSqlPath || !!props.activeTab.sql.trim()); +const canSaveSql = computed(() => canSaveSqlTab(props.activeTab)); const keywordCaseIsLower = computed(() => props.sqlKeywordCase === "lower"); const keywordCaseToggleTooltip = computed(() => (keywordCaseIsLower.value ? t("toolbar.keywordCaseUpper") : t("toolbar.keywordCaseLower"))); const sqlSemanticDiagnosticsEnabled = computed(() => settingsStore.editorSettings.sqlSemanticDiagnosticsEnabled); diff --git a/apps/desktop/src/lib/__tests__/tabs/sqlTabSaveTarget.spec.ts b/apps/desktop/src/lib/__tests__/tabs/sqlTabSaveTarget.spec.ts new file mode 100644 index 0000000000..7a5c2b4cb8 --- /dev/null +++ b/apps/desktop/src/lib/__tests__/tabs/sqlTabSaveTarget.spec.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { canSaveSqlTab, type SqlTabSaveTarget } from "@/lib/tabs/sqlTabSaveTarget"; + +const PROCEDURE_SOURCE = { schema: "dbo", name: "p", objectType: "PROCEDURE" } as const; + +function saveTarget(overrides: Partial): SqlTabSaveTarget { + return { externalSqlPath: undefined, savedSqlId: undefined, sql: "", objectSource: undefined, ...overrides }; +} + +describe("canSaveSqlTab", () => { + it("keeps a saved SQL library tab saveable after its content is deleted", () => { + // Issue #9247: emptying a query file is a legitimate edit that must reach the file. + expect(canSaveSqlTab(saveTarget({ savedSqlId: "file-1", sql: "" }))).toBe(true); + }); + + it("treats whitespace-only content in a saved tab as the emptied state, not as content", () => { + expect(canSaveSqlTab(saveTarget({ savedSqlId: "file-1", sql: "\n\t " }))).toBe(true); + }); + + it("keeps an external .sql file tab saveable after its content is deleted", () => { + expect(canSaveSqlTab(saveTarget({ externalSqlPath: "/tmp/query.sql", sql: "" }))).toBe(true); + }); + + it("still allows a brand-new tab with content", () => { + expect(canSaveSqlTab(saveTarget({ sql: "SELECT 1;" }))).toBe(true); + }); + + it("still blocks a brand-new tab with nothing to persist", () => { + expect(canSaveSqlTab(saveTarget({ sql: " \n" }))).toBe(false); + }); + + it("still blocks an emptied object-source tab", () => { + // Saving an empty routine body would send a body-less CREATE to the server. + expect(canSaveSqlTab(saveTarget({ objectSource: PROCEDURE_SOURCE, sql: "" }))).toBe(false); + expect(canSaveSqlTab(saveTarget({ objectSource: PROCEDURE_SOURCE, savedSqlId: "file-1", sql: "" }))).toBe(false); + }); + + it("allows an object-source tab with content", () => { + expect(canSaveSqlTab(saveTarget({ objectSource: PROCEDURE_SOURCE, sql: "CREATE PROCEDURE dbo.p AS SELECT 1;" }))).toBe(true); + }); +}); diff --git a/apps/desktop/src/lib/tabs/sqlTabSaveTarget.ts b/apps/desktop/src/lib/tabs/sqlTabSaveTarget.ts new file mode 100644 index 0000000000..2816811434 --- /dev/null +++ b/apps/desktop/src/lib/tabs/sqlTabSaveTarget.ts @@ -0,0 +1,22 @@ +import type { QueryTab } from "@/types/database"; + +/** Tab fields that decide whether its SQL can be persisted. */ +export type SqlTabSaveTarget = Pick; + +/** + * Whether the tab's SQL can be saved at all. + * + * A tab that already has a persistence target — an external `.sql` file or a + * saved-SQL library entry — stays saveable after its content is emptied. + * Deleting everything is a legitimate edit that must reach the target, and the + * emptied state is also the only state in which the user can observe the guard. + * + * A brand-new tab has nothing to persist, and an emptied object-source tab would + * push a body-less `CREATE` for a routine or view to the server, so both keep the + * content requirement. + */ +export function canSaveSqlTab(tab: SqlTabSaveTarget): boolean { + if (tab.externalSqlPath || tab.sql.trim()) return true; + if (tab.objectSource) return false; + return !!tab.savedSqlId; +}