diff --git a/src/App.vue b/src/App.vue index 6f753a621..eb082c89e 100644 --- a/src/App.vue +++ b/src/App.vue @@ -33,12 +33,14 @@ import "@/i18n"; import { translateBackendError } from "@/i18n/backend-errors"; import * as api from "@/lib/api"; import { resolveDefaultDatabase } from "@/lib/defaultDatabase"; +import { buildExecutableObjectSourceSql, objectSourceSaveExecutionMode } from "@/lib/objectSourceEditor"; import { resolveExecutableSql } from "@/lib/sqlExecutionTarget"; import { isTauriRuntime } from "@/lib/tauriRuntime"; import { isCloseTabShortcut, isExecuteSqlShortcut, isFocusSearchShortcut, + isObjectSourceSaveShortcutTarget, isSaveShortcut, } from "@/lib/keyboardShortcuts"; import { isPreviewTab } from "@/lib/tabPresentation"; @@ -203,6 +205,10 @@ function defaultSavedSqlName(title: string) { async function openSaveSqlDialog() { const tab = activeTab.value; if (!tab || !tab.sql.trim()) return; + if (tab.objectSource) { + await saveActiveObjectSource(tab); + return; + } const existing = tab.savedSqlId ? savedSqlStore.getFile(tab.savedSqlId) : undefined; if (existing) { const updated = await savedSqlStore.saveFile({ @@ -225,6 +231,30 @@ async function openSaveSqlDialog() { showSaveSqlDialog.value = true; } +async function saveActiveObjectSource(tab: NonNullable) { + const connection = connectionStore.getConfig(tab.connectionId); + const source = tab.objectSource; + if (!connection || !source) return; + + try { + const sql = buildExecutableObjectSourceSql({ + databaseType: connection.db_type, + objectType: source.objectType, + schema: source.schema || tab.schema || tab.database, + name: source.name, + source: tab.sql, + }); + if (objectSourceSaveExecutionMode(connection.db_type) === "single") { + await api.executeQuery(tab.connectionId, tab.database, sql, source.schema || tab.schema); + } else { + await api.executeScript(tab.connectionId, tab.database, sql, source.schema || tab.schema); + } + toast(t("objects.sourceSaved"), 2000); + } catch (e: any) { + toast(t("objects.sourceSaveFailed", { message: e?.message || String(e) }), 5000); + } +} + async function confirmSaveSqlToLibrary() { const tab = activeTab.value; const name = saveSqlName.value.trim(); @@ -437,6 +467,9 @@ function handleKeydown(e: KeyboardEvent) { } return; } + if (isSaveShortcut(e) && e.target instanceof Element && isObjectSourceSaveShortcutTarget(e.target)) { + return; + } if (activeTab.value?.mode === "query" && !showSaveSqlDialog.value && isSaveShortcut(e)) { e.preventDefault(); e.stopPropagation(); diff --git a/src/components/layout/EditorToolbar.vue b/src/components/layout/EditorToolbar.vue index 45b3ba496..8dc51b4d6 100644 --- a/src/components/layout/EditorToolbar.vue +++ b/src/components/layout/EditorToolbar.vue @@ -63,6 +63,7 @@ const activeConnectionValue = computed(() => props.activeConnection?.id || ""); const activeSchemaValue = computed(() => props.activeTab.schema || ""); const isSingleDb = computed(() => isSingleDatabase(props.activeConnection?.db_type)); const schemaDatabaseKey = computed(() => props.activeTab.database || (isSingleDb.value ? "_" : "")); +const saveTooltip = computed(() => (props.activeTab.objectSource ? t("objects.saveSource") : t("toolbar.saveSql"))); const showSchemaSelector = computed(() => { const connection = props.activeConnection; @@ -178,7 +179,7 @@ function databaseDisplayName(database: string): string { - {{ t("toolbar.saveSql") }} + {{ saveTooltip }} diff --git a/src/components/sidebar/TreeItem.vue b/src/components/sidebar/TreeItem.vue index f0c23dd19..c9771ed06 100644 --- a/src/components/sidebar/TreeItem.vue +++ b/src/components/sidebar/TreeItem.vue @@ -590,6 +590,11 @@ function viewObjectSource() { .then((result) => { const tabId = queryStore.createTab(node.connectionId!, node.database!, node.label); queryStore.updateSql(tabId, result.source); + queryStore.setObjectSource(tabId, { + schema, + name: node.label, + objectType: objectType as "PROCEDURE" | "FUNCTION", + }); }) .catch((e: any) => { toast(e?.message || String(e), 5000); diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7889a505b..b7aa9fb23 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -634,6 +634,7 @@ export default { saveSource: "Save", cancelEdit: "Cancel", sourceSaved: "Source saved", + sourceSaveFailed: "Failed to save source: {message}", schemaColumn: "Schema", comment: "Comment", loadingSchemas: "Loading schemas...", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index df5ec2620..fee8a0970 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -635,6 +635,7 @@ export default { saveSource: "Guardar", cancelEdit: "Cancelar", sourceSaved: "Código fuente guardado", + sourceSaveFailed: "Error al guardar el código fuente: {message}", schemaColumn: "Esquema", comment: "Comentario", loadingSchemas: "Cargando esquemas...", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 1370edadb..44a220666 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -619,6 +619,7 @@ export default { saveSource: "保存", cancelEdit: "取消", sourceSaved: "源码已保存", + sourceSaveFailed: "保存源码失败:{message}", schemaColumn: "Schema", comment: "注释", loadingSchemas: "加载 Schema...", diff --git a/src/lib/keyboardShortcuts.ts b/src/lib/keyboardShortcuts.ts index afe4fa409..427b0ba41 100644 --- a/src/lib/keyboardShortcuts.ts +++ b/src/lib/keyboardShortcuts.ts @@ -33,6 +33,12 @@ export function isSaveShortcut(event: ShortcutLikeEvent): boolean { return event.key.toLowerCase() === "s"; } +export function isObjectSourceSaveShortcutTarget( + target: { closest(selector: string): unknown } | null | undefined, +): boolean { + return !!target?.closest("[data-object-source-editor], [data-object-source-preview]"); +} + export function isCancelSearchShortcut(event: ShortcutLikeEvent): boolean { if (event.isComposing) return false; return event.key === "Escape"; diff --git a/src/lib/objectSourceEditor.ts b/src/lib/objectSourceEditor.ts index eb62fce45..b03ed0fd6 100644 --- a/src/lib/objectSourceEditor.ts +++ b/src/lib/objectSourceEditor.ts @@ -39,6 +39,6 @@ export function buildExecutableObjectSourceSql(input: BuildEditableObjectSourceS return ensureSemicolon(source); } -export function objectSourceSaveExecutionMode(databaseType: DatabaseType): ObjectSourceSaveExecutionMode { - return databaseType === "sqlserver" ? "single" : "script"; +export function objectSourceSaveExecutionMode(_databaseType: DatabaseType): ObjectSourceSaveExecutionMode { + return "single"; } diff --git a/src/lib/openTabsPersistence.ts b/src/lib/openTabsPersistence.ts index 258d682cd..f9e1f6aaf 100644 --- a/src/lib/openTabsPersistence.ts +++ b/src/lib/openTabsPersistence.ts @@ -11,6 +11,7 @@ export interface SavedOpenTab { pinned?: boolean; mode?: QueryTab["mode"]; objectBrowser?: QueryTab["objectBrowser"]; + objectSource?: QueryTab["objectSource"]; tableMeta?: QueryTab["tableMeta"]; } @@ -31,6 +32,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { pinned: tab.pinned, mode: tab.mode, objectBrowser: tab.objectBrowser, + objectSource: tab.objectSource, tableMeta: tab.tableMeta, })); } diff --git a/src/stores/queryStore.ts b/src/stores/queryStore.ts index c8c4c4ea9..1319ee3af 100644 --- a/src/stores/queryStore.ts +++ b/src/stores/queryStore.ts @@ -52,6 +52,7 @@ export const useQueryStore = defineStore("query", () => { pinned: t.pinned, mode: t.mode, objectBrowser: t.objectBrowser, + objectSource: t.objectSource, tableMeta: t.tableMeta, })), ); @@ -253,6 +254,11 @@ export const useQueryStore = defineStore("query", () => { if (tab) tab.tableMeta = meta; } + function setObjectSource(id: string, objectSource: NonNullable) { + const tab = tabs.value.find((t) => t.id === id); + if (tab) tab.objectSource = objectSource; + } + function setExecuting(id: string, isExecuting: boolean) { const tab = tabs.value.find((t) => t.id === id); if (!tab) return; @@ -600,6 +606,7 @@ export const useQueryStore = defineStore("query", () => { updateSchema, updateConnection, setTableMeta, + setObjectSource, setExecuting, setErrorResult, setActiveResultIndex, diff --git a/src/types/database.ts b/src/types/database.ts index c59e7ad4b..04d45fa24 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -268,6 +268,11 @@ export interface QueryTab { schema?: string; objectType?: "tables"; }; + objectSource?: { + schema?: string; + name: string; + objectType: ObjectSourceKind; + }; tableMeta?: { schema?: string; tableName: string; diff --git a/tests/keyboardShortcuts.test.ts b/tests/keyboardShortcuts.test.ts index fbe570095..6efc39388 100644 --- a/tests/keyboardShortcuts.test.ts +++ b/tests/keyboardShortcuts.test.ts @@ -5,6 +5,7 @@ import { isCloseTabShortcut, isExecuteSqlShortcut, isFocusSearchShortcut, + isObjectSourceSaveShortcutTarget, isSaveShortcut, } from "../src/lib/keyboardShortcuts.ts"; @@ -64,6 +65,23 @@ test("ignores Alt+S for saving", () => { assert.equal(isSaveShortcut({ key: "s", altKey: true }), false); }); +test("detects object source editor targets for contextual save", () => { + const target = { + closest: (selector: string) => + selector === "[data-object-source-editor], [data-object-source-preview]" ? {} : null, + }; + + assert.equal(isObjectSourceSaveShortcutTarget(target), true); +}); + +test("ignores regular editor targets for contextual object source save", () => { + const target = { + closest: () => null, + }; + + assert.equal(isObjectSourceSaveShortcutTarget(target), false); +}); + test("matches Escape for cancelling search", () => { assert.equal(isCancelSearchShortcut({ key: "Escape" }), true); }); diff --git a/tests/objectSourceEditor.test.ts b/tests/objectSourceEditor.test.ts index 657bdea75..6afb73265 100644 --- a/tests/objectSourceEditor.test.ts +++ b/tests/objectSourceEditor.test.ts @@ -30,6 +30,19 @@ test("SQL Server object source saves as a single batch", () => { assert.equal(objectSourceSaveExecutionMode("sqlserver"), "single"); }); +test("Kingbase object source saves as a single statement", () => { + assert.equal(objectSourceSaveExecutionMode("kingbase"), "single"); +}); + +test("Postgres-family object source saves as a single statement", () => { + assert.equal(objectSourceSaveExecutionMode("postgres"), "single"); + assert.equal(objectSourceSaveExecutionMode("gaussdb"), "single"); +}); + +test("MySQL object source saves as a single statement", () => { + assert.equal(objectSourceSaveExecutionMode("mysql"), "single"); +}); + test("Postgres view body opens as CREATE OR REPLACE VIEW", () => { const sql = buildExecutableObjectSourceSql({ databaseType: "postgres", diff --git a/tests/openTabsPersistence.test.ts b/tests/openTabsPersistence.test.ts index e5ecde66f..c732bd43b 100644 --- a/tests/openTabsPersistence.test.ts +++ b/tests/openTabsPersistence.test.ts @@ -35,11 +35,31 @@ test("serializes unsaved query tabs with editor context", () => { pinned: true, mode: "query", objectBrowser: undefined, + objectSource: undefined, tableMeta: undefined, }, ]); }); +test("serializes object source query tabs with save context", () => { + const saved = serializeOpenTabs([ + queryTab({ + title: "fn_add", + objectSource: { + schema: "public", + name: "fn_add", + objectType: "FUNCTION", + }, + }), + ]); + + assert.deepEqual(saved[0]?.objectSource, { + schema: "public", + name: "fn_add", + objectType: "FUNCTION", + }); +}); + test("restores unsaved query tabs and active tab after restart", () => { const raw = JSON.stringify([ queryTab({ id: "tab-1", sql: "select 1" }), @@ -58,6 +78,26 @@ test("restores unsaved query tabs and active tab after restart", () => { assert.equal(restored.activeTabId, "tab-2"); }); +test("restores object source save context", () => { + const raw = JSON.stringify([ + queryTab({ + objectSource: { + schema: "public", + name: "fn_add", + objectType: "FUNCTION", + }, + }), + ]); + + const restored = restoreOpenTabsState(raw, "tab-1"); + + assert.deepEqual(restored.tabs[0]?.objectSource, { + schema: "public", + name: "fn_add", + objectType: "FUNCTION", + }); +}); + test("desktop restore keeps legacy query tabs without a mode", () => { const raw = JSON.stringify([ {