fix(mysql): prevalidate routine saves and support procedure refresh
This commit is contained in:
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({
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -4320,6 +4320,7 @@ export default withEnglishFallback({
|
||||
validity: "有効性",
|
||||
valid: "有効",
|
||||
invalid: "無効",
|
||||
refreshDiscardConfirm: "更新すると、保存されていないソースコードの変更が破棄されます。続行しますか?",
|
||||
},
|
||||
structureEditor: {
|
||||
mysqlDataTypeHelp: {
|
||||
|
||||
@@ -4247,6 +4247,7 @@ export default withEnglishFallback({
|
||||
sortAsc: "오름차순",
|
||||
sortDesc: "내림차순",
|
||||
sortBy: "정렬 기준",
|
||||
refreshDiscardConfirm: "새로 고침하면 저장되지 않은 소스 코드 변경 사항이 삭제됩니다. 계속하시겠습니까?",
|
||||
},
|
||||
structureEditor: {
|
||||
title: "테이블 구조 편집",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4000,6 +4000,7 @@ export default withEnglishFallback({
|
||||
validity: "有效性",
|
||||
valid: "有效",
|
||||
invalid: "無效",
|
||||
refreshDiscardConfirm: "重新整理將捨棄未儲存的原始碼修改,是否繼續?",
|
||||
},
|
||||
structureEditor: {
|
||||
mysqlDataTypeHelp: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user