fix(tabs): allow saving a saved SQL tab after clearing content

This commit is contained in:
t8y2
2026-09-16 21:54:47 +08:00
parent 1d19c8b273
commit 451fde014b
4 changed files with 66 additions and 5 deletions
+1 -4
View File
@@ -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;
@@ -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);
@@ -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>): 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);
});
});
@@ -0,0 +1,22 @@
import type { QueryTab } from "@/types/database";
/** Tab fields that decide whether its SQL can be persisted. */
export type SqlTabSaveTarget = Pick<QueryTab, "externalSqlPath" | "savedSqlId" | "sql" | "objectSource">;
/**
* 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;
}