fix(mysql): prevalidate routine saves and support procedure refresh

This commit is contained in:
Mo-Moore authored and GitHub committed 2026-09-22 13:03:32 +08:00
1 parent 0023c03520
commit 4ac2c5dc40
16 files changed
+173 -20

No files matched your search

@@ -1035,6 +1035,10 @@ function refreshData(): boolean {
void pluginFilesystemTabRef.value?.refresh();
return true;
}
if (props.activeTab.objectSource || props.activeTab.sourceLoad) {
if (queryStore.isTabDirty(props.activeTab) && !window.confirm(t("objects.refreshDiscardConfirm"))) return false;
return queryStore.refreshObjectSourceTab(props.activeTab.id);
}
// Restored data tabs intentionally omit row data, so refresh must work before DataGrid mounts.
if (canReloadUnavailableDataTab(props.activeTab)) {
reloadUnavailableDataTab();
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, watch, watchEffect } from "vue";
import { useI18n } from "vue-i18n";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText } from "@lucide/vue";
import { Play, CirclePlay, Loader2, Square, Database, Check, Table2, AlignLeft, GitBranch, Save, FolderOpen, X, Shield, Download, RotateCcw, AlertTriangle, ClipboardPaste, Minimize2, SpellCheck2, Layers, MoreHorizontal, BetweenVerticalStart, Eye, WrapText, RefreshCw } from "@lucide/vue";
import { supportsInsertValueHints } from "@/lib/editor/codemirrorInsertValueHints";
import { Button } from "@/components/ui/button";
import { SearchableSelect } from "@/components/ui/searchable-select";
@@ -12,6 +12,7 @@ import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
import { useConnectionStore } from "@/stores/connectionStore";
import { useQueryStore } from "@/stores/queryStore";
import { useSettingsStore } from "@/stores/settingsStore";
import { catalogDatabaseOptionsKey, databaseAfterCatalogChange, normalizedQueryTabCatalog, queryCatalogSelectorVisible, selectedQueryCatalogName, useDatabaseOptions } from "@/composables/useDatabaseOptions";
import { useSchemaOptions } from "@/composables/useSchemaOptions";
@@ -92,6 +93,7 @@ const emit = defineEmits<{
const { t } = useI18n();
const connectionStore = useConnectionStore();
const queryStore = useQueryStore();
const settingsStore = useSettingsStore();
const { databaseOptions, loadingDatabaseOptions, loadDatabaseOptions, catalogOptions, loadingCatalogOptions, loadCatalogOptions, catalogDatabaseOptions, loadingCatalogDatabaseOptions, loadCatalogDatabaseOptions } = useDatabaseOptions();
const { loadSchemaOptions, getSchemaOptionsForDb, isLoadingSchemas, isSchemaAware } = useSchemaOptions();
@@ -244,6 +246,14 @@ const saveTooltip = computed(() => {
if (props.activeTab.externalSqlPath) return t("toolbar.saveSqlFile");
return t("toolbar.saveSql");
});
const isObjectSourceTab = computed(() => !!props.activeTab.objectSource || !!props.activeTab.sourceLoad);
const objectSourceRefreshing = computed(() => !!props.activeTab.sourceLoad && !props.activeTab.sourceLoad.error);
function refreshObjectSource() {
if (!isObjectSourceTab.value) return;
if (queryStore.isTabDirty(props.activeTab) && !window.confirm(t("objects.refreshDiscardConfirm"))) return;
queryStore.refreshObjectSourceTab(props.activeTab.id);
}
const executeShortcutDisplay = computed(() => formatShortcutDisplay(settingsStore.editorSettings.shortcuts.executeSql));
const executeShortcutTooltip = computed(() => t("toolbar.executeShortcut", { shortcut: executeShortcutDisplay.value }));
// executableSql 在无选区时可能是整篇文档;只要有 DML 语句出现就显示预览按钮,
@@ -606,6 +616,22 @@ async function changeCatalog(selectedCatalog: string) {
</TooltipTrigger>
<TooltipContent>{{ saveTooltip }}</TooltipContent>
</Tooltip>
<Tooltip v-if="isObjectSourceTab">
<TooltipTrigger as-child>
<Button
variant="ghost"
size="icon"
class="h-6 w-6 text-emerald-600 hover:bg-emerald-500/10 hover:text-emerald-700 dark:text-emerald-300 dark:hover:text-emerald-200"
:disabled="objectSourceRefreshing || activeTab.isExecuting"
:aria-label="t('structureEditor.refresh')"
@click="refreshObjectSource"
>
<Loader2 v-if="objectSourceRefreshing" class="h-3.5 w-3.5 animate-spin" />
<RefreshCw v-else class="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{{ t("structureEditor.refresh") }}</TooltipContent>
</Tooltip>
<Tooltip v-if="showOpenSqlButton">
<TooltipTrigger as-child>
<Button variant="ghost" size="icon" class="h-6 w-6 text-sky-600 hover:bg-sky-500/10 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200" @click="emit('openSql')">
@@ -1385,6 +1385,10 @@ async function fetchTableConstraints(force = false) {
}
async function refreshActiveTableInfo() {
if (sidePanelMode.value === "source") {
await refreshActiveSource();
return;
}
if (sidePanelMode.value !== "table-info" || !sidePanelRow.value) return;
sidePanelGuard.bump();
@@ -1502,9 +1506,14 @@ async function openSource(row: ObjectBrowserRow) {
closeSidePanel();
return;
}
await loadSourcePanel(row);
}
async function loadSourcePanel(row: ObjectBrowserRow, options?: { preserveEditing?: boolean }) {
// Starting a different object must invalidate slower source requests before
// any state is reset, otherwise an old response can populate the new row.
const epoch = sidePanelGuard.start();
const preserveEditing = options?.preserveEditing === true && sourceEditing.value;
sidePanelRow.value = row;
sidePanelMode.value = "source";
sourceRow.value = row;
@@ -1538,7 +1547,9 @@ async function openSource(row: ObjectBrowserRow) {
sourceEditableText.value = editable;
sourceContent.value = row.type === "SEQUENCE" ? result.source : editable;
sourceDraft.value = editable;
sourceEditing.value = sourceCanEdit.value && row.type !== "SEQUENCE";
// Fresh open always enters edit when allowed; refresh preserves prior edit mode.
const enterEditing = sourceCanEdit.value && row.type !== "SEQUENCE" && (options?.preserveEditing ? preserveEditing : true);
sourceEditing.value = enterEditing;
if (!sourceCanEdit.value && row.type !== "SEQUENCE") {
toast(t("objects.sourceReadOnly"), 3000);
}
@@ -1550,6 +1561,13 @@ async function openSource(row: ObjectBrowserRow) {
}
}
async function refreshActiveSource() {
const row = sourceRow.value || (sidePanelMode.value === "source" ? sidePanelRow.value : null);
if (!row || sidePanelMode.value !== "source") return;
if (sourceEditing.value && !window.confirm(t("objects.refreshDiscardConfirm"))) return;
await loadSourcePanel(row, { preserveEditing: true });
}
function openEventEditor(row: ObjectBrowserRow) {
sidePanelGuard.start();
sidePanelRow.value = row;
@@ -4039,6 +4057,9 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
<Button v-if="!sourceEditing && sourceCanEdit" variant="ghost" size="icon" class="h-5 w-5" :disabled="!sourceContent" @click="editSource">
<PencilLine class="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" :disabled="sourceLoading || sourceSaving" :title="t('structureEditor.refresh')" :aria-label="t('structureEditor.refresh')" @click="refreshActiveSource">
<RefreshCw class="h-3 w-3" :class="{ 'animate-spin': sourceLoading }" />
</Button>
<Button variant="ghost" size="icon" class="h-5 w-5" @click="closeSource">
<X class="h-3 w-3" />
</Button>
@@ -156,6 +156,12 @@ function cancelEditSource() {
saveError.value = "";
}
async function refreshSource() {
if (loading.value || saving.value) return;
if (editing.value && !window.confirm(t("objects.refreshDiscardConfirm"))) return;
await loadSource(editing.value && canEdit.value);
}
async function saveSource() {
if (saving.value || loading.value || !props.open) return;
if (!canEdit.value) {
@@ -271,6 +277,10 @@ function closeDialog() {
<DialogFooter>
<Button variant="outline" @click="closeDialog">{{ t("common.close") }}</Button>
<Button variant="outline" :disabled="loading || saving" :title="t('structureEditor.refresh')" @click="refreshSource">
<RefreshCw class="h-4 w-4" />
{{ t("structureEditor.refresh") }}
</Button>
<Button v-if="!editing" variant="outline" :disabled="!content" @click="copySource">
<Clipboard class="h-4 w-4" />
{{ t("grid.copy") }}
@@ -1395,7 +1395,7 @@ function requestRefreshSelectedNode(): boolean {
function canRefreshTreeNodeShortcut(): boolean {
const type = activeNode.value.type;
if (type === "connection" || type === "database" || type === "schema" || type === "table" || type === "view") {
if (type === "connection" || type === "database" || type === "schema" || type === "table" || type === "view" || type === "procedure" || type === "function") {
return true;
}
return isGroupLabel(activeNode.value) && type !== "group-partitions";
@@ -6394,6 +6394,12 @@ function buildObjectSidebarMenu(context: SidebarMenuFactoryContext): boolean {
}
items.push({ label: "", separator: true });
items.push({ label: t("contextMenu.changeOpenMode"), action: () => emit("open-settings", "navigation"), icon: Settings2 });
items.push({
label: t("contextMenu.refreshChildren"),
action: refresh,
icon: RefreshCw,
shortcut: shortcutRefresh,
});
if (!isPackageMember) {
items.push({ label: "", separator: true });
items.push({
+1
View File
@@ -4690,6 +4690,7 @@ export default {
cancelEdit: "Cancel",
sourceSaved: "Source saved",
sourceSaveFailed: "Failed to save source: {message}",
refreshDiscardConfirm: "Refreshing will discard unsaved source edits. Continue?",
postgresViewColumnChangeHint:
"PostgreSQL cannot use CREATE OR REPLACE VIEW to remove, reorder, rename, or change the type of existing view columns. Keep existing column count, order, names, and types unchanged; new columns can only be appended. Use ALTER VIEW ... RENAME COLUMN to rename a column. To remove a column, review dependencies and recreate the view manually.",
sourceReadOnly: "This source is read-only and cannot be edited.",
+1
View File
@@ -4262,6 +4262,7 @@ export default withEnglishFallback({
validity: "validez",
valid: "válido",
invalid: "inválido",
refreshDiscardConfirm: "Actualizar descartará los cambios sin guardar en el código fuente, ¿deseas continuar?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4259,6 +4259,7 @@ export default withEnglishFallback({
validity: "validità",
valid: "valido",
invalid: "non valido",
refreshDiscardConfirm: "L'aggiornamento eliminerà le modifiche non salvate al codice sorgente. Continuare?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4320,6 +4320,7 @@ export default withEnglishFallback({
validity: "有効性",
valid: "有効",
invalid: "無効",
refreshDiscardConfirm: "更新すると、保存されていないソースコードの変更が破棄されます。続行しますか?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4247,6 +4247,7 @@ export default withEnglishFallback({
sortAsc: "오름차순",
sortDesc: "내림차순",
sortBy: "정렬 기준",
refreshDiscardConfirm: "새로 고침하면 저장되지 않은 소스 코드 변경 사항이 삭제됩니다. 계속하시겠습니까?",
},
structureEditor: {
title: "테이블 구조 편집",
+1
View File
@@ -4261,6 +4261,7 @@ export default withEnglishFallback({
validity: "Validade",
valid: "Válido",
invalid: "Inválido",
refreshDiscardConfirm: "Atualizar descartará as modificações não salvas no código-fonte. Deseja continuar?",
},
structureEditor: {
mysqlDataTypeHelp: {
+1
View File
@@ -4665,6 +4665,7 @@ export default withEnglishFallback({
cancelEdit: "取消",
sourceSaved: "源码已保存",
sourceSaveFailed: "保存源码失败:{message}",
refreshDiscardConfirm: "刷新将丢弃未保存的源码修改,是否继续?",
postgresViewColumnChangeHint: "PostgreSQL 无法通过 CREATE OR REPLACE VIEW 删除、重排、重命名已有视图字段,或修改字段类型。请保持原字段的数量、顺序、名称和类型不变;新增字段只能追加在末尾。重命名字段请使用 ALTER VIEW ... RENAME COLUMN;删除字段前请检查依赖并手动重建视图。",
sourceReadOnly: "该源码为只读,不能编辑。",
schemaColumn: "Schema",
+1
View File
@@ -4000,6 +4000,7 @@ export default withEnglishFallback({
validity: "有效性",
valid: "有效",
invalid: "無效",
refreshDiscardConfirm: "重新整理將捨棄未儲存的原始碼修改,是否繼續?",
},
structureEditor: {
mysqlDataTypeHelp: {
+13 -1
View File
@@ -2346,7 +2346,19 @@ export const useConnectionStore = defineStore("connection", () => {
function invalidateMetadataCachesForNode(node: TreeNode, options?: { skipObjectCacheInvalidation?: boolean }) {
if (!node.connectionId) return;
const tableName = node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const isObjectLeaf =
node.type === "procedure" ||
node.type === "function" ||
node.type === "sequence" ||
node.type === "synonym" ||
node.type === "event" ||
node.type === "job" ||
node.type === "package" ||
node.type === "package-body" ||
node.type === "type" ||
node.type === "type-body" ||
node.type === "trigger";
const tableName = isObjectLeaf ? node.objectName || node.label : node.tableName || (node.type === "table" || node.type === "view" || node.type === "materialized_view" || node.type === "mongo-collection" || node.type === "dynamodb-table" ? node.label : undefined);
const match = {
connectionId: node.connectionId,
database: node.database || undefined,
+21
View File
@@ -2873,6 +2873,26 @@ export const useQueryStore = defineStore("query", () => {
void loadObjectSourceIntoTab(id);
}
/**
* Force-reload an object-source tab from the database (user-triggered refresh).
* Callers should confirm before discarding dirty edits.
*/
function refreshObjectSourceTab(id: string): boolean {
const tab = tabs.value.find((candidate) => candidate.id === id);
if (!tab) return false;
if (tab.sourceLoad && !tab.sourceLoad.error) return true;
const request = tab.sourceLoad?.request ? { ...tab.sourceLoad.request } : tab.objectSource ? { name: tab.objectSource.name, objectType: tab.objectSource.objectType, signature: tab.objectSource.signature } : null;
if (!request) return false;
sourceRevalidateInFlight.delete(id);
tab.sourceLoad = {
startedAt: Date.now(),
initialEditing: tab.sourceLoad?.initialEditing ?? !!tab.objectSource,
request,
};
void loadObjectSourceIntoTab(id);
return true;
}
function clearObjectSourceLoad(tab: QueryTab) {
tab.sourceLoad = undefined;
}
@@ -9006,6 +9026,7 @@ export const useQueryStore = defineStore("query", () => {
openObjectSourceTab,
openObjectSourceTabPending,
retryObjectSourceTab,
refreshObjectSourceTab,
showExecutedQueryResults,
focusGroup,
activateTab,