fix(editor): save object source tabs contextually

This commit is contained in:
t8y2 2026-05-15 13:28:41 +08:00
parent 72dd4d728b
commit f91ccf89c4
14 changed files with 136 additions and 3 deletions

View File

@ -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<typeof activeTab.value>) {
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();

View File

@ -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 {
<Save class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("toolbar.saveSql") }}</TooltipContent>
<TooltipContent>{{ saveTooltip }}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>

View File

@ -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);

View File

@ -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...",

View File

@ -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...",

View File

@ -619,6 +619,7 @@ export default {
saveSource: "保存",
cancelEdit: "取消",
sourceSaved: "源码已保存",
sourceSaveFailed: "保存源码失败:{message}",
schemaColumn: "Schema",
comment: "注释",
loadingSchemas: "加载 Schema...",

View File

@ -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";

View File

@ -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";
}

View File

@ -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,
}));
}

View File

@ -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<QueryTab["objectSource"]>) {
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,

View File

@ -268,6 +268,11 @@ export interface QueryTab {
schema?: string;
objectType?: "tables";
};
objectSource?: {
schema?: string;
name: string;
objectType: ObjectSourceKind;
};
tableMeta?: {
schema?: string;
tableName: string;

View File

@ -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);
});

View File

@ -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",

View File

@ -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([
{