From 76e41a6a4d0c296989195b848e0abd16ffa55863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=5FMr=E4=B8=B6s=20=E3=82=9B=F0=9F=98=81=C2=AE?= <31917567+ma123456mai@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:27:15 +0800 Subject: [PATCH] feat(ddl): formatted DDL display with dialog or tab mode --- .../editor/EditorSettingsDialog.vue | 26 ++ .../src/components/layout/ContentArea.vue | 116 ++++++++- .../src/components/layout/EditorGroup.vue | 2 +- .../src/components/objects/DdlViewDialog.vue | 216 ++++++++++++++--- .../__tests__/DdlDialog.legacyScope.spec.ts | 37 +++ .../structure/TableStructureEditor.vue | 50 ++-- apps/desktop/src/i18n/locales/az.ts | 13 + apps/desktop/src/i18n/locales/en.ts | 13 + apps/desktop/src/i18n/locales/es.ts | 13 + apps/desktop/src/i18n/locales/it.ts | 13 + apps/desktop/src/i18n/locales/ja.ts | 13 + apps/desktop/src/i18n/locales/ko.ts | 13 + apps/desktop/src/i18n/locales/pt-BR.ts | 13 + apps/desktop/src/i18n/locales/tr.ts | 13 + apps/desktop/src/i18n/locales/zh-CN.ts | 13 + apps/desktop/src/i18n/locales/zh-TW.ts | 13 + .../__tests__/app/openTabsPersistence.spec.ts | 7 + .../src/lib/__tests__/sql/ddlDisplay.spec.ts | 67 ++++++ .../src/lib/app/openTabsPersistence.ts | 2 + .../__tests__/editorSettingsDraft.spec.ts | 18 ++ .../__tests__/settingsTransfer.spec.ts | 1 + .../src/lib/settings/editorSettingsDraft.ts | 1 + .../src/lib/settings/settingsTransfer.ts | 1 + apps/desktop/src/lib/sql/ddlDisplay.ts | 224 +++++++++++++++++- .../stores/__tests__/settingsStore.spec.ts | 7 + apps/desktop/src/stores/queryStore.ts | 65 +++++ apps/desktop/src/stores/settingsStore.ts | 4 + .../__tests__/legacyWebviewFallback.spec.ts | 9 +- apps/desktop/src/styles/globals.css | 4 + apps/desktop/src/types/database.ts | 20 ++ 30 files changed, 956 insertions(+), 51 deletions(-) create mode 100644 apps/desktop/src/lib/__tests__/sql/ddlDisplay.spec.ts diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index d57aec8158..b72279b6fb 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -629,6 +629,7 @@ const completionTriggerModeDescription = computed(() => { }); const editWordWrap = ref(settingsStore.editorSettings.wordWrap); const editShowWhitespace = ref(settingsStore.editorSettings.showWhitespace); +const editDdlOpenMode = ref(settingsStore.editorSettings.ddlOpenMode); const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled); const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets); const editSqlSemanticDiagnosticsMode = ref(settingsStore.editorSettings.sqlSemanticDiagnosticsMode); @@ -985,6 +986,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft { completionTriggerMode: editCompletionTriggerMode.value, wordWrap: editWordWrap.value, showWhitespace: editShowWhitespace.value, + ddlOpenMode: editDdlOpenMode.value, vimModeEnabled: editVimModeEnabled.value, autoCloseBrackets: editAutoCloseBrackets.value, sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode.value, @@ -1628,6 +1630,7 @@ function syncEditorSettingsDraftFromStore() { editCompletionTriggerMode.value = settingsStore.editorSettings.completionTriggerMode; editWordWrap.value = settingsStore.editorSettings.wordWrap; editShowWhitespace.value = settingsStore.editorSettings.showWhitespace; + editDdlOpenMode.value = settingsStore.editorSettings.ddlOpenMode; editVimModeEnabled.value = settingsStore.editorSettings.vimModeEnabled; editAutoCloseBrackets.value = settingsStore.editorSettings.autoCloseBrackets; editSqlSemanticDiagnosticsMode.value = settingsStore.editorSettings.sqlSemanticDiagnosticsMode; @@ -1762,6 +1765,7 @@ const editorSettingsDraftRefs: EditorSettingsDraftRefMap = { selectFirstCompletionOnOpen: editSelectFirstCompletionOnOpen, wordWrap: editWordWrap, showWhitespace: editShowWhitespace, + ddlOpenMode: editDdlOpenMode, vimModeEnabled: editVimModeEnabled, autoCloseBrackets: editAutoCloseBrackets, sqlSemanticDiagnosticsMode: editSqlSemanticDiagnosticsMode, @@ -2231,6 +2235,7 @@ function resetDefaultsForTab(tab: SettingsCategory) { editCompletionTriggerMode.value = DEFAULT_EDITOR_SETTINGS.completionTriggerMode; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace; + editDdlOpenMode.value = DEFAULT_EDITOR_SETTINGS.ddlOpenMode; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode; @@ -2383,6 +2388,7 @@ function resetAllDefaults() { editSelectFirstCompletionOnOpen.value = DEFAULT_EDITOR_SETTINGS.selectFirstCompletionOnOpen; editWordWrap.value = DEFAULT_EDITOR_SETTINGS.wordWrap; editShowWhitespace.value = DEFAULT_EDITOR_SETTINGS.showWhitespace; + editDdlOpenMode.value = DEFAULT_EDITOR_SETTINGS.ddlOpenMode; editVimModeEnabled.value = DEFAULT_EDITOR_SETTINGS.vimModeEnabled; editAutoCloseBrackets.value = DEFAULT_EDITOR_SETTINGS.autoCloseBrackets; editSqlSemanticDiagnosticsMode.value = DEFAULT_EDITOR_SETTINGS.sqlSemanticDiagnosticsMode; @@ -2734,6 +2740,10 @@ function setRoutineSourceOpenMode(value: "query-tab" | "dialog") { editRoutineSourceOpenMode.value = value; } +function setDdlOpenMode(value: unknown) { + if (value === "dialog" || value === "tab") editDdlOpenMode.value = value; +} + function setIconTheme(value: DesktopIconTheme) { editIconTheme.value = value; } @@ -6325,6 +6335,22 @@ onUnmounted(() => { +
+
+ +

{{ t("settings.ddlOpenModeDescription") }}

+
+ +
+
diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 4280e07102..aff77252f9 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -181,6 +181,9 @@ import { useTabScroll } from "@/composables/useTabScroll"; import { useToolbarOverflow } from "@/composables/useToolbarOverflow"; import { formatElapsedSeconds } from "@/lib/common/elapsedTime"; import { copyToClipboard } from "@/lib/common/clipboard"; +import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; +import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay"; +import { sqlObjectNavigationTypeFromTableType } from "@/lib/sql/sqlNavigation"; import type { CustomSaveHandler } from "@/composables/useDataGridEditor"; import type { QueryMessage, QueryTab, TableInfoTab, TreeNode, VectorCollectionMeta } from "@/types/database"; import type { SqlObjectNavigationTarget } from "@/lib/sql/sqlNavigation"; @@ -280,6 +283,7 @@ const colorizeDataGridCellTypes = computed(() => settingsStore.editorSettings.co const setColorizeDataGridCellTypes = (value: boolean) => settingsStore.updateEditorSettings({ colorizeDataGridCellTypes: value }); const { toast } = useToast(); const DEFAULT_QUERY_RESULTS_PANE_SIZE = 68; +const ddlRefreshInProgress = ref(false); onMounted(() => { // The watcher below warms the grid for query/data tabs. Keep source-only @@ -700,6 +704,7 @@ const resultsPaneSize = ref(Number(safeLocalStorageGet("dbx-results-pane-size")) const editorPaneSize = computed(() => (props.editorOnly || !resultsPaneOpen.value ? 100 : 100 - resultsPaneSize.value)); const queryRunningElapsed = ref(0); const sourceLoadElapsed = ref(0); +const ddlLoadElapsed = ref(0); function toggleResultsPane(): boolean { if (props.activeTab.mode !== "query" || !hasQueryOutput.value) return false; @@ -748,19 +753,27 @@ function pendingSourceLoadStartedAt(): number | undefined { return load && !load.error ? load.startedAt : undefined; } -// 一个 rAF 循环同时驱动「查询执行中」与「对象源码加载中」两个耗时显示: +/** DDL 新标签仍在加载(未失败)时的开始时间;与源码加载共用同一个 rAF 循环。 */ +function pendingDdlLoadStartedAt(): number | undefined { + const load = props.activeTab.ddlLoad; + return load && !load.error ? load.startedAt : undefined; +} + +// 一个 rAF 循环同时驱动「查询执行中」「对象源码加载中」「DDL 加载中」三个耗时显示: // issue #9035 的核心体感就是「不知道要等多久」,所以源码加载也要显示已耗时。 function updateRunningElapsed() { const startedAt = props.activeTab.queryExecutionStartedAt; queryRunningElapsed.value = props.activeTab.isExecuting && startedAt ? Math.max(0, Date.now() - startedAt) : 0; const sourceStartedAt = pendingSourceLoadStartedAt(); sourceLoadElapsed.value = sourceStartedAt ? Math.max(0, Date.now() - sourceStartedAt) : 0; + const ddlStartedAt = pendingDdlLoadStartedAt(); + ddlLoadElapsed.value = ddlStartedAt ? Math.max(0, Date.now() - ddlStartedAt) : 0; } function startRunningElapsedTimer() { stopRunningElapsedTimer(); updateRunningElapsed(); - const isTicking = () => (props.activeTab.isExecuting && !!props.activeTab.queryExecutionStartedAt) || pendingSourceLoadStartedAt() !== undefined; + const isTicking = () => (props.activeTab.isExecuting && !!props.activeTab.queryExecutionStartedAt) || pendingSourceLoadStartedAt() !== undefined || pendingDdlLoadStartedAt() !== undefined; if (!isTicking()) return; const updateOnNextFrame = () => { updateRunningElapsed(); @@ -773,8 +786,11 @@ function startRunningElapsedTimer() { const queryRunningElapsedSeconds = computed(() => formatElapsedSeconds(queryRunningElapsed.value)); const sourceLoadElapsedSeconds = computed(() => formatElapsedSeconds(sourceLoadElapsed.value)); +const ddlLoadElapsedSeconds = computed(() => formatElapsedSeconds(ddlLoadElapsed.value)); -watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt, props.activeTab.sourceLoad?.startedAt, props.activeTab.sourceLoad?.error] as const, startRunningElapsedTimer, { immediate: true }); +watch(() => [props.activeTab.id, props.activeTab.isExecuting, props.activeTab.queryExecutionStartedAt, props.activeTab.sourceLoad?.startedAt, props.activeTab.sourceLoad?.error, props.activeTab.ddlLoad?.startedAt, props.activeTab.ddlLoad?.error] as const, startRunningElapsedTimer, { + immediate: true, +}); onUnmounted(() => { stopRunningElapsedTimer(); @@ -976,6 +992,77 @@ function onHandleStructureViewData() { }); } +/** + * Loads the DDL of the active DDL viewer tab. Serves the toolbar refresh, the + * in-place retry, and the initial pending load of a freshly created tab (which + * renders the loading state instead of an editor): a pending load that fails + * lands in the tab itself with an in-place retry, while a refresh of + * already-loaded content only reports through a toast. + */ +async function loadActiveDdlViewer(force: boolean) { + const tab = props.activeTab; + const ddlViewer = tab.ddlViewer; + if (!ddlViewer || ddlRefreshInProgress.value) return; + ddlRefreshInProgress.value = true; + const pendingLoad = !!tab.ddlLoad; + if (pendingLoad) tab.ddlLoad = { startedAt: Date.now() }; + try { + const { ddl } = await loadObjectDdl( + { + connectionId: tab.connectionId, + database: tab.database, + schema: ddlViewer.schema || tab.database, + tableName: ddlViewer.tableName, + objectType: ddlViewer.objectType, + catalog: tab.catalog, + }, + { force }, + ); + const dialect = ddlViewer.formatDialect ?? activeSqlFormatDialect.value; + const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(tab.connectionId) ?? props.activeConnection); + const displayed = await formatDdlForDisplay( + ddl, + { + dialect, + databaseType, + database: tab.database, + catalog: tab.catalog, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, + quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers, + excludeDdlStorage: settingsStore.editorSettings.excludeDdlStorage, + }, + settingsStore.editorSettings.sqlFormatter, + ); + queryStore.updateSql(tab.id, displayed); + tab.originalSql = displayed; + if (pendingLoad) tab.ddlLoad = undefined; + } catch (error: any) { + if (pendingLoad) { + tab.ddlLoad = { startedAt: Date.now(), error: error?.message || String(error) }; + } else { + toast(t("contextMenu.ddlRefreshFailed", { message: error?.message || String(error) }), 5000); + } + } finally { + ddlRefreshInProgress.value = false; + } +} + +function refreshDdlViewer() { + void loadActiveDdlViewer(true); +} + +function viewDdlTableData() { + const tab = props.activeTab; + const ddlViewer = tab.ddlViewer; + if (!ddlViewer) return; + emit("viewTableData", tab.id, { + name: ddlViewer.tableName, + database: tab.database, + schema: ddlViewer.schema, + type: ddlViewer.objectType ? sqlObjectNavigationTypeFromTableType(ddlViewer.objectType) : "table", + }); +} + function onHandleViewTableDdl(target: SqlObjectNavigationTarget) { emit("viewTableDdl", props.activeTab.id, target); } @@ -1503,11 +1590,22 @@ defineExpose({
+
+ + +
+ (还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。 + issue #9387:DDL 新标签同样先出 tab 再加载,失败就地显示错误。 -->

{{ activeTab.sourceLoad.error }}

@@ -1516,6 +1614,14 @@ defineExpose({ {{ t("common.retry") }}
+ +
+

{{ t("contextMenu.ddlRefreshFailed", { message: activeTab.ddlLoad.error }) }}

+ +
{ }); const activeTab = computed(() => groupTabs.value.find((tab) => tab.id === props.activeTabId) ?? groupTabs.value[0] ?? null); const activeConnection = computed(() => (activeTab.value ? connectionStore.getConfig(activeTab.value.connectionId) : undefined)); -const showGroupToolbar = computed(() => activeTab.value?.mode === "query" && !isPreviewTab(activeTab.value)); +const showGroupToolbar = computed(() => activeTab.value?.mode === "query" && !activeTab.value.ddlViewer && !isPreviewTab(activeTab.value)); const isGroupStickyManualTransaction = computed(() => usesProvenReadOnlyStickyTransactionState(effectiveDatabaseTypeForConnection(activeConnection.value)) && (activeTab.value?.autoCommit ?? true) === false); // Each group previews the executable SQL of its own active tab (selection // stored on the tab), not the focused tab's global selection. diff --git a/apps/desktop/src/components/objects/DdlViewDialog.vue b/apps/desktop/src/components/objects/DdlViewDialog.vue index 56eb5868fb..abcbefd2f3 100644 --- a/apps/desktop/src/components/objects/DdlViewDialog.vue +++ b/apps/desktop/src/components/objects/DdlViewDialog.vue @@ -3,7 +3,7 @@ import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage"; import DdlStorageToggle from "@/components/objects/DdlStorageToggle.vue"; import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from "vue"; import { useI18n } from "vue-i18n"; -import { Clipboard, Loader2, RefreshCw } from "@lucide/vue"; +import { Clipboard, ExternalLink, Loader2, RefreshCw } from "@lucide/vue"; import { useToast } from "@/composables/useToast"; import { useTheme } from "@/composables/useTheme"; import { useSettingsStore } from "@/stores/settingsStore"; @@ -11,11 +11,13 @@ import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes"; import { editorClipboardLineEndingsExtension } from "@/lib/editor/editorClipboardLineEndings"; import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect"; import { copyToClipboard } from "@/lib/common/clipboard"; -import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; -import { applyDdlDatabaseQualifier, ddlFormatDialectFor, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter"; +import { ddlFormatDialectFor, formatDdlForDisplay } from "@/lib/sql/ddlDisplay"; import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; +import { useQueryStore } from "@/stores/queryStore"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { HelpTooltip } from "@/components/ui/tooltip"; import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue"; @@ -50,13 +52,16 @@ const { toast } = useToast(); const { isDark, themePalette } = useTheme(); const settingsStore = useSettingsStore(); -const rawDdlContent = ref(""); -const ddlContent = computed(() => applyDdlStoragePreference(rawDdlContent.value, props.databaseType, settingsStore.editorSettings.excludeDdlStorage)); +const originalDdlContent = ref(""); +const formattedDdlContent = ref(""); +const ddlDisplayMode = ref<"formatted" | "original">("formatted"); +const ddlContent = computed(() => applyDdlStoragePreference(ddlDisplayMode.value === "formatted" ? formattedDdlContent.value : originalDdlContent.value, props.databaseType, settingsStore.editorSettings.excludeDdlStorage)); const ddlLoading = ref(false); const ddlError = ref(""); const ddlEditorContainer = ref(); const ddlSearchPanelRef = ref>(); const ddlEditorView = shallowRef(null); +let ddlEditorResizeObserver: ResizeObserver | null = null; // Keep the dialog movable for the duration of one open cycle. This mirrors the // existing draggable dialogs without persisting a potentially off-screen position. @@ -65,14 +70,25 @@ const isDragging = ref(false); const dragStartPosition = ref({ x: 0, y: 0 }); const dragStartOffset = ref({ x: 0, y: 0 }); const activePointerId = ref(null); +const dialogSize = ref({ width: 980, height: 720 }); +const isResizing = ref(false); +const resizeStartPosition = ref({ x: 0, y: 0 }); +const resizeStartSize = ref({ width: 980, height: 720 }); +const resizeStartOffset = ref({ x: 0, y: 0 }); +const resizeStartRect = ref({ left: 0, top: 0 }); +const activeResizePointerId = ref(null); const dialogContentStyle = computed(() => { - if (isDragging.value || dragOffset.value.x !== 0 || dragOffset.value.y !== 0) { - return { - transform: `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)`, - transition: isDragging.value ? "none" : "transform 0.15s ease-out", - }; - } - return {}; + const moved = isDragging.value || dragOffset.value.x !== 0 || dragOffset.value.y !== 0; + return { + width: `min(${dialogSize.value.width}px, calc(100vw - 32px))`, + height: `min(${dialogSize.value.height}px, calc(100vh - 32px))`, + maxWidth: "calc(100vw - 32px)", + maxHeight: "calc(100vh - 32px)", + minWidth: "min(560px, calc(100vw - 32px))", + minHeight: "min(420px, calc(100vh - 32px))", + transform: moved ? `translate(${dragOffset.value.x}px, ${dragOffset.value.y}px)` : undefined, + transition: isDragging.value || isResizing.value ? "none" : "transform 0.15s ease-out", + }; }); // The CodeMirror editor (if any) that was focused when this dialog opened, so focus can be @@ -83,6 +99,9 @@ function resetDialogDragOffset() { dragOffset.value = { x: 0, y: 0 }; isDragging.value = false; activePointerId.value = null; + dialogSize.value = { width: 980, height: 720 }; + isResizing.value = false; + activeResizePointerId.value = null; } function startDialogDrag(event: PointerEvent) { @@ -113,6 +132,46 @@ function endDialogDrag(event: PointerEvent) { } } +function startDialogResize(event: PointerEvent) { + if (event.button !== undefined && event.button !== 0) return; + event.preventDefault(); + event.stopPropagation(); + isResizing.value = true; + activeResizePointerId.value = event.pointerId; + resizeStartPosition.value = { x: event.clientX, y: event.clientY }; + const dialogRect = (event.currentTarget as HTMLElement).closest("[data-slot='dialog-content']")?.getBoundingClientRect(); + resizeStartSize.value = dialogRect ? { width: dialogRect.width, height: dialogRect.height } : { ...dialogSize.value }; + resizeStartRect.value = dialogRect ? { left: dialogRect.left, top: dialogRect.top } : { left: 0, top: 0 }; + resizeStartOffset.value = { ...dragOffset.value }; + (event.currentTarget as HTMLElement).setPointerCapture(event.pointerId); +} + +function moveDialogResize(event: PointerEvent) { + if (!isResizing.value || event.pointerId !== activeResizePointerId.value) return; + const maxWidth = Math.max(1, Math.min(window.innerWidth - 32, window.innerWidth - 16 - resizeStartRect.value.left)); + const maxHeight = Math.max(1, Math.min(window.innerHeight - 32, window.innerHeight - 16 - resizeStartRect.value.top)); + const minWidth = Math.min(560, maxWidth); + const minHeight = Math.min(420, maxHeight); + const width = Math.min(maxWidth, Math.max(minWidth, resizeStartSize.value.width + event.clientX - resizeStartPosition.value.x)); + const height = Math.min(maxHeight, Math.max(minHeight, resizeStartSize.value.height + event.clientY - resizeStartPosition.value.y)); + dialogSize.value = { width, height }; + dragOffset.value = { + x: resizeStartOffset.value.x + (width - resizeStartSize.value.width) / 2, + y: resizeStartOffset.value.y + (height - resizeStartSize.value.height) / 2, + }; +} + +function endDialogResize(event: PointerEvent) { + if (!isResizing.value || event.pointerId !== activeResizePointerId.value) return; + isResizing.value = false; + activeResizePointerId.value = null; + try { + (event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId); + } catch { + // Pointer capture may already have been released by the browser. + } +} + async function loadDdl(force = false) { ddlError.value = ""; ddlLoading.value = true; @@ -131,9 +190,19 @@ async function loadDdl(force = false) { { force }, ); const formatDialect = ddlFormatDialectFor({ formatDialect: props.formatDialect, databaseType: props.databaseType, highlightDialect: props.dialect }); - const formatted = await formatSqlForDisplay(ddl, formatDialect, settingsStore.editorSettings.sqlFormatter); - const unqualified = applyDdlDatabaseQualifier(formatted, formatDialect, props.databaseType, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog); - rawDdlContent.value = settingsStore.editorSettings.generateSqlQuoteIdentifiers ? unqualified : omitDdlIdentifierQuotes(unqualified, formatDialect); + originalDdlContent.value = ddl; + formattedDdlContent.value = await formatDdlForDisplay( + ddl, + { + dialect: formatDialect, + databaseType: props.databaseType, + database: props.database, + catalog: props.catalog, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, + quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers, + }, + settingsStore.editorSettings.sqlFormatter, + ); } catch (e: any) { ddlError.value = e?.message || String(e); } finally { @@ -149,7 +218,13 @@ watch( if (!open) return; const active = document.activeElement; editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null; - rawDdlContent.value = ""; + ddlDisplayMode.value = "formatted"; + originalDdlContent.value = ""; + formattedDdlContent.value = ""; + if (settingsStore.editorSettings.ddlOpenMode === "tab") { + openDdlViewerTabPending(); + return; + } await loadDdl(settingsStore.editorSettings.refreshDdlOnOpen); }, { immediate: true }, @@ -236,11 +311,17 @@ async function initDdlEditor(content: string) { }); const editorView = new EditorView({ state, parent: ddlEditorContainer.value }); ddlEditorView.value = editorView; + if (typeof ResizeObserver !== "undefined") { + ddlEditorResizeObserver = new ResizeObserver(() => editorView.requestMeasure()); + ddlEditorResizeObserver.observe(ddlEditorContainer.value); + } editorView.focus(); } /** Tears down the CodeMirror instance when the dialog closes. */ function destroyDdlEditor() { + ddlEditorResizeObserver?.disconnect(); + ddlEditorResizeObserver = null; ddlEditorView.value?.destroy(); ddlEditorView.value = null; } @@ -253,15 +334,54 @@ function copyDdlContent() { } } +/** + * Creates the read-only DDL tab. With `ddl` empty the tab is created pending + * (`ddlLoad`) and loads through the tab surface itself — the same "tab first, + * load with visible state" flow as object-source tabs — so in tab mode the + * click shows the tab and its loading state immediately instead of silently + * waiting behind a closed dialog; a failure is shown in place with a retry. + */ +function createDdlViewerTab(ddl: string, pending: boolean) { + const queryStore = useQueryStore(); + const tabId = queryStore.createTab(props.connectionId, props.database, `${t("contextMenu.viewDdl")} - ${props.tableName}`, "query", props.schema, ddl, props.catalog, { forceNew: true, sourceView: true }); + const tab = queryStore.tabs.find((item) => item.id === tabId); + if (tab) { + tab.ddlViewer = { + schema: props.schema || props.database, + tableName: props.tableName, + objectType: props.objectType, + formatDialect: ddlFormatDialectFor({ formatDialect: props.formatDialect, databaseType: props.databaseType, highlightDialect: props.dialect }), + }; + if (pending) { + tab.ddlLoad = { startedAt: Date.now() }; + void queryStore.loadDdlViewerTab(tabId); + } + } + onClose(); +} + +function openDdlInNewTab() { + const ddl = ddlContent.value.trim(); + if (!ddl) return; + + createDdlViewerTab(ddl, false); +} + +function openDdlViewerTabPending() { + createDdlViewerTab("", true); +} + watch(ddlContent, (content) => { const view = ddlEditorView.value; if (view) view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: content } }); }); // When DDL finishes loading, create the editor inside the dialog. -watch(ddlLoading, (loading) => { - if (!loading && ddlContent.value && props.open) { - nextTick(() => initDdlEditor(ddlContent.value)); +watch([ddlLoading, ddlContent], ([loading, content]) => { + if (!loading && content && props.open) { + nextTick(() => { + if (!ddlLoading.value && props.open && ddlContent.value === content) void initDdlEditor(content); + }); } }); @@ -281,7 +401,9 @@ onUnmounted(() => { }); function retry() { - rawDdlContent.value = ""; + originalDdlContent.value = ""; + formattedDdlContent.value = ""; + ddlDisplayMode.value = "formatted"; void loadDdl(true); } @@ -290,6 +412,10 @@ function setRefreshDdlOnOpen(value: boolean) { if (value) void loadDdl(true); } +function setDdlOpenMode(value: unknown) { + if (value === "dialog" || value === "tab") settingsStore.updateEditorSettings({ ddlOpenMode: value }); +} + function onClose() { emit("update:open", false); } @@ -297,29 +423,40 @@ function onClose() { diff --git a/apps/desktop/src/components/objects/__tests__/DdlDialog.legacyScope.spec.ts b/apps/desktop/src/components/objects/__tests__/DdlDialog.legacyScope.spec.ts index 1254ba6013..0d55b2b481 100644 --- a/apps/desktop/src/components/objects/__tests__/DdlDialog.legacyScope.spec.ts +++ b/apps/desktop/src/components/objects/__tests__/DdlDialog.legacyScope.spec.ts @@ -3,8 +3,14 @@ import { createApp, defineComponent, h, nextTick, type App } from "vue"; import { createI18n } from "vue-i18n"; import { afterEach, describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import DdlScopeFixture from "./DdlDialog.legacyScope.fixture.vue"; +const ddlViewDialogSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/objects/DdlViewDialog.vue"), "utf8"); +const ddlTabContentSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/layout/ContentArea.vue"), "utf8"); +const editorGroupSource = readFileSync(resolve(process.cwd(), "apps/desktop/src/components/layout/EditorGroup.vue"), "utf8"); + const mountedApps: App[] = []; afterEach(() => { @@ -57,4 +63,35 @@ describe("DdlViewDialog legacy fallback selectors", () => { expect(content!.hasAttribute(scopeId!)).toBe(false); expect(footer!.hasAttribute(scopeId!)).toBe(true); }); + + it("keeps the DDL dialog free of per-dialog legacy fallback rules", () => { + // The dialog content element is rendered through reka-ui's portal Teleport and + // never carries this component's scoped data-v attribute, so per-dialog rules + // are avoided entirely: width comes from the global sm:max-w-190 legacy entry + // and the footer layout from the global dialog-footer rule. + expect(ddlViewDialogSource).not.toContain("dbx-legacy-webview"); + expect(ddlViewDialogSource).not.toContain("@media"); + expect(ddlViewDialogSource).toContain('class="dbx-ddl-view-dialog flex min-h-0 flex-col overflow-hidden sm:max-w-190"'); + expect(ddlViewDialogSource).toContain("ddlDisplayOriginal"); + expect(ddlViewDialogSource).toContain("resizeDdlDialog"); + }); + + it("keeps the dialog as the default and offers opening the DDL in a new tab", () => { + expect(ddlViewDialogSource).toContain('import { useQueryStore } from "@/stores/queryStore";'); + expect(ddlViewDialogSource).toContain("queryStore.createTab(props.connectionId, props.database"); + expect(ddlViewDialogSource).toContain("sourceView: true"); + expect(ddlViewDialogSource).toContain('ddlOpenMode === "tab"'); + expect(ddlViewDialogSource).toContain("ddlViewer = {"); + expect(ddlViewDialogSource).toContain("updateEditorSettings({ ddlOpenMode: value })"); + expect(ddlViewDialogSource).toContain('@click="openDdlInNewTab"'); + }); + + it("renders DDL tabs read-only with only refresh and table-data actions", () => { + expect(editorGroupSource).toContain("!activeTab.value.ddlViewer"); + expect(ddlTabContentSource).toContain('v-if="activeTab.ddlViewer" class="flex h-10'); + expect(ddlTabContentSource).toContain('@click="refreshDdlViewer"'); + expect(ddlTabContentSource).toContain('@click="viewDdlTableData"'); + expect(ddlTabContentSource).toContain(':read-only="!!activeTab.ddlViewer"'); + expect(ddlTabContentSource).toContain(':hide-execution-controls="!!activeTab.ddlViewer"'); + }); }); diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index 000bc7aab8..f297de2d37 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -32,11 +32,11 @@ import { useToast } from "@/composables/useToast"; import { useVerticalOverlayScrollbar } from "@/composables/useVerticalOverlayScrollbar"; import { type SqlHighlighter, createShikiSqlHighlighter } from "@/lib/sql/sqlHighlighter"; import { joinSqlStatementsForScript } from "@/lib/sql/sqlBatchScript"; -import { applyDdlDatabaseQualifier, formatGeneratedDdlIdentifierQuotes, omitDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; +import { applyDdlDatabaseQualifier, formatDdlForDisplay, formatGeneratedDdlIdentifierQuotes } from "@/lib/sql/ddlDisplay"; import { splitSqlStatementRanges } from "@/lib/sql/sqlStatementRanges"; import { copyToClipboard } from "@/lib/common/clipboard"; import DataGridCopyColumnNamesDialog from "@/components/grid/DataGridCopyColumnNamesDialog.vue"; -import { formatSqlForDisplay, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; +import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import { queryTimeoutSecsForConcurrentIndex, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; import { isMacOS } from "@/lib/backend/platform"; import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage"; @@ -264,6 +264,27 @@ function ddlEditorDocument(): string { return ddlDraft.value ?? (ddlContent.value || t("structureEditor.emptyReadonly")); } +/** + * Formats fetched DDL through the shared read-only display pipeline for the + * structure editor's DDL tab. The storage preference is not baked in: the + * `ddlContent` computed re-filters on read so toggling it updates without a + * reload. + */ +function formatStructureDdl(ddl: string): Promise { + return formatDdlForDisplay( + ddl, + { + dialect: sqlFormatDialectForDbType(databaseType.value), + databaseType: databaseType.value, + database: props.database, + catalog: props.catalog, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, + quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers, + }, + settingsStore.editorSettings.sqlFormatter, + ); +} + function resetDdlDraft() { ddlDraft.value = null; updateDdlEditorContent(ddlEditorDocument()); @@ -396,14 +417,15 @@ function scheduleDdlEditorInit() { } /** - * Applies the DDL display preferences (database qualifier + identifier quoting) - * to DDL shown by the structure editor, whether loaded from the server or - * generated from the pending structure changes. + * Applies the SQL generation preferences (database qualifier + identifier + * quoting) to a DDL statement generated from the pending structure changes + * before it is executed. Oracle folds bare identifiers to uppercase, so safe + * quoted names are uppercased before their quotes are removed. */ -function formatDdlForDisplay(sql: string, dialect: SqlFormatDialect, generated = false): string { +function applyDdlGenerationPreferences(sql: string, dialect: SqlFormatDialect): string { const unqualified = applyDdlDatabaseQualifier(sql, dialect, databaseType.value, settingsStore.editorSettings.generateSqlIncludeDatabaseName, props.database, props.catalog); if (settingsStore.editorSettings.generateSqlQuoteIdentifiers) return unqualified; - return generated ? formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false, { preserveCaseSensitiveIdentifiers: tableStoresCaseSensitiveIdentifiers.value }) : omitDdlIdentifierQuotes(unqualified, dialect); + return formatGeneratedDdlIdentifierQuotes(unqualified, dialect, false, { preserveCaseSensitiveIdentifiers: tableStoresCaseSensitiveIdentifiers.value }); } function ddlRequest() { @@ -422,9 +444,7 @@ async function fetchDdl(force = false) { ddlLoading.value = true; try { const { ddl } = await loadObjectDdl(ddlRequest(), { force }); - const dialect = sqlFormatDialectForDbType(databaseType.value); - const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = formatDdlForDisplay(formatted, dialect); + rawDdlContent.value = await formatStructureDdl(ddl); ddlFetched.value = true; } catch (e: any) { rawDdlContent.value = `-- Error: ${e?.message || e}`; @@ -2017,9 +2037,7 @@ async function hydrateRestoredDraftFromDatabase() { if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { try { const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }); - const dialect = sqlFormatDialectForDbType(databaseType.value); - const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = formatDdlForDisplay(formatted, dialect); + rawDdlContent.value = await formatStructureDdl(ddl); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); } catch { @@ -2319,7 +2337,7 @@ async function refreshSqlPreview() { if (requestId !== sqlPreviewRequestId) return; const statements = [...result.statements, ...ownerResult.statements, ...(mysqlAutoIncrementStatement ? [mysqlAutoIncrementStatement] : []), ...partitionResult.statements]; // SQLite type-change apply regenerates this revision-checked plan, so its preview must stay byte-for-byte aligned. - pendingStatements.value = hasSqliteTypeChange.value ? statements : statements.map((statement) => formatDdlForDisplay(statement, sqlFormatDialectForDbType(databaseType.value), true)); + pendingStatements.value = hasSqliteTypeChange.value ? statements : statements.map((statement) => applyDdlGenerationPreferences(statement, sqlFormatDialectForDbType(databaseType.value))); warnings.value = [...result.warnings, ...ownerResult.warnings, ...partitionResult.warnings]; sqliteSchemaRevision.value = "schemaRevision" in result && typeof result.schemaRevision === "string" ? result.schemaRevision : undefined; } catch (e: any) { @@ -2733,9 +2751,7 @@ async function loadStructure( if (databaseType.value === "manticoresearch" && tableMetadataCapabilities.value.ddl) { try { const { ddl } = await loadObjectDdl({ connectionId, database, schema, tableName, catalog }, { force: options.forceDdl }); - const dialect = sqlFormatDialectForDbType(databaseType.value); - const formatted = await formatSqlForDisplay(ddl, dialect, settingsStore.editorSettings.sqlFormatter); - rawDdlContent.value = formatDdlForDisplay(formatted, dialect); + rawDdlContent.value = await formatStructureDdl(ddl); ddlFetched.value = true; nextColumns = applyManticoreDdlColumnExtras(nextColumns, ddl); } catch { diff --git a/apps/desktop/src/i18n/locales/az.ts b/apps/desktop/src/i18n/locales/az.ts index 8b15e502ff..a2ebd0701b 100644 --- a/apps/desktop/src/i18n/locales/az.ts +++ b/apps/desktop/src/i18n/locales/az.ts @@ -3510,12 +3510,21 @@ export default withEnglishFallback({ changeOpenMode: "Açılma rejimini dəyiş", viewDetails: "Təfərrüatlara bax", viewDdl: "DDL-ə bax", + openDdlInNewTab: "Yeni vərəqdə aç", viewDdlLoading: "DDL yüklənir...", + ddlDisplayMode: "DDL-in göstərilmə üsulu", + ddlDisplayFormatted: "Formatlanmış", + ddlDisplayOriginal: "Orijinal", + ddlOpenMode: "DDL-in açılma rejimi", + ddlOpenModeDialog: "Dialoqda aç", + ddlOpenModeTab: "Yeni vərəqdə aç", + resizeDdlDialog: "DDL dialoqunun ölçüsünü dəyiş", ddlCopied: "DDL kopyalandı", excludeDdlStorage: "Saxlama atributlarını çıxar", excludeDdlStorageHint: "Oracle və OceanBase Oracle DDL-in göstərilməsinə, kopyalanmasına və strukturun ixracına tətbiq olunur. Sıxılma, replika və saxlama parametrlərini saxlamaq üçün söndürün. Bölmələr və məhdudiyyətlər həmişə saxlanılır.", refreshDdlOnOpen: "Hər açılışda yenilə", refreshDdlOnOpenHint: "Aktiv olduqda DDL hər açılışda verilənlər bazasından yenidən yüklənir; deaktiv olduqda keşdəki məlumatlara üstünlük verilir", + ddlRefreshFailed: "DDL yenilənməsi alınmadı: {message}", dropObject: "Obyekti sil", dropEventSuccess: '"{name}" hadisəsi silindi', editObject: "Obyekti redaktə et", @@ -7544,6 +7553,10 @@ export default withEnglishFallback({ wordWrapDescription: "Uzun sətirləri redaktorun eninə uyğun bük", showWhitespace: "Boşluq simvollarını göstər", showWhitespaceDescription: "SQL redaktorunda boşluqları ·, tabulyasiyaları → kimi göstərir. Yalnız görünüş dəyişir, SQL mətni dəyişmir.", + ddlOpenMode: "DDL-in açılma rejimi", + ddlOpenModeDescription: "Cədvəlin DDL-inə baxarkən standart olaraq dialoqun, yoxsa yeni vərəqin açılacağını seçin", + ddlOpenModeDialog: "Dialoq (standart)", + ddlOpenModeTab: "Yeni vərəq", vimMode: "Vim rejimi", vimModeDescription: "SQL redaktorunda Vim üslublu modal redaktədən istifadə et", autoCloseBrackets: "Mötərizələri avtomatik bağla", diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts index dc6e9ce441..a9872d7b11 100644 --- a/apps/desktop/src/i18n/locales/en.ts +++ b/apps/desktop/src/i18n/locales/en.ts @@ -3769,12 +3769,21 @@ export default { changeOpenMode: "Change open mode", viewDetails: "View Details", viewDdl: "View DDL", + openDdlInNewTab: "Open in New Tab", viewDdlLoading: "Loading DDL...", + ddlDisplayMode: "DDL display", + ddlDisplayFormatted: "Formatted", + ddlDisplayOriginal: "Original", + ddlOpenMode: "DDL open mode", + ddlOpenModeDialog: "Open in dialog", + ddlOpenModeTab: "Open in new tab", + resizeDdlDialog: "Resize DDL dialog", ddlCopied: "DDL copied", excludeDdlStorage: "Exclude storage attributes", excludeDdlStorageHint: "Applies to Oracle and OceanBase Oracle DDL display, copying and structure export. Disable to retain compression, replica and storage options. Partitions and constraints are preserved.", refreshDdlOnOpen: "Refresh each time it opens", refreshDdlOnOpenHint: "When enabled, DDL is reloaded from the database each time it opens; when disabled, cached data is preferred", + ddlRefreshFailed: "Failed to refresh DDL: {message}", dropObject: "Drop Object", dropEventSuccess: 'Event "{name}" dropped', editObject: "Edit Object", @@ -8101,6 +8110,10 @@ export default { wordWrapDescription: "Wrap long lines within the editor width", showWhitespace: "Show whitespace", showWhitespaceDescription: "Show spaces as · and tabs as → in the SQL editor. Only the display changes, not the SQL text.", + ddlOpenMode: "DDL open mode", + ddlOpenModeDescription: "Choose whether viewing table DDL opens a dialog or a new tab by default", + ddlOpenModeDialog: "Dialog (default)", + ddlOpenModeTab: "New tab", vimMode: "Vim mode", vimModeDescription: "Use Vim-style modal editing in the SQL editor", autoCloseBrackets: "Auto-close brackets", diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts index ab5d7d915b..02e7092dfd 100644 --- a/apps/desktop/src/i18n/locales/es.ts +++ b/apps/desktop/src/i18n/locales/es.ts @@ -3623,12 +3623,21 @@ export default withEnglishFallback({ viewSource: "Ver código fuente", viewDetails: "Ver detalles", viewDdl: "Ver DDL", + openDdlInNewTab: "Abrir en una nueva pestaña", viewDdlLoading: "Cargando DDL...", + ddlDisplayMode: "Visualización de DDL", + ddlDisplayFormatted: "Formateado", + ddlDisplayOriginal: "Original", + ddlOpenMode: "Modo de apertura de DDL", + ddlOpenModeDialog: "Abrir en diálogo", + ddlOpenModeTab: "Abrir en una nueva pestaña", + resizeDdlDialog: "Cambiar tamaño del diálogo DDL", ddlCopied: "DDL copiado", excludeDdlStorage: "Excluir atributos de almacenamiento", excludeDdlStorageHint: "Se aplica a la visualización y copia de DDL y a la exportación de estructuras de Oracle y OceanBase Oracle. Desactiva esta opción para conservar los parámetros de compresión, réplicas y almacenamiento. Las particiones y restricciones se conservan siempre.", refreshDdlOnOpen: "Actualizar cada vez que se abra", refreshDdlOnOpenHint: "Si se activa, el DDL se vuelve a cargar de la base de datos cada vez que se abre; si se desactiva, se usan primero los datos en caché", + ddlRefreshFailed: "Error al actualizar el DDL: {message}", dropObject: "Eliminar objeto", dropView: "Eliminar vista", dropColumn: "Eliminar columna", @@ -7457,6 +7466,10 @@ export default withEnglishFallback({ wordWrapDescription: "Ajustar las líneas largas al ancho del editor", showWhitespace: "Mostrar espacios en blanco", showWhitespaceDescription: "Mostrar espacios como · y tabulaciones como → en el editor SQL. Solo cambia la visualización, no el texto SQL.", + ddlOpenMode: "Modo de apertura de DDL", + ddlOpenModeDescription: "Elige si, al ver el DDL de una tabla, se abre un diálogo o una pestaña nueva de forma predeterminada", + ddlOpenModeDialog: "Diálogo (predeterminado)", + ddlOpenModeTab: "Nueva pestaña", vimMode: "Modo Vim", vimModeDescription: "Usar edición modal estilo Vim en el editor SQL", autoCloseBrackets: "Cerrar paréntesis automáticamente", diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts index b3d0a0cf5c..baa2351027 100644 --- a/apps/desktop/src/i18n/locales/it.ts +++ b/apps/desktop/src/i18n/locales/it.ts @@ -3620,12 +3620,21 @@ export default withEnglishFallback({ viewSource: "Visualizza Origine", viewDetails: "Visualizza dettagli", viewDdl: "Visualizza DDL", + openDdlInNewTab: "Apri in una nuova scheda", viewDdlLoading: "Caricamento DDL...", + ddlDisplayMode: "Visualizzazione DDL", + ddlDisplayFormatted: "Formattato", + ddlDisplayOriginal: "Originale", + ddlOpenMode: "Modalità di apertura DDL", + ddlOpenModeDialog: "Apri in finestra di dialogo", + ddlOpenModeTab: "Apri in una nuova scheda", + resizeDdlDialog: "Ridimensiona finestra di dialogo DDL", ddlCopied: "DDL copiato", excludeDdlStorage: "Escludi attributi di archiviazione", excludeDdlStorageHint: "Si applica alla visualizzazione e alla copia del DDL e all'esportazione della struttura in Oracle e OceanBase Oracle. Disattiva questa opzione per mantenere i parametri di compressione, replica e archiviazione. Le partizioni e i vincoli vengono sempre mantenuti.", refreshDdlOnOpen: "Aggiorna a ogni apertura", refreshDdlOnOpenHint: "Se attivo, il DDL viene ricaricato dal database a ogni apertura; se disattivo, vengono usati prima i dati memorizzati nella cache", + ddlRefreshFailed: "Aggiornamento del DDL non riuscito: {message}", dropObject: "Elimina Oggetto", dropView: "Elimina Vista", dropColumn: "Elimina Colonna", @@ -7456,6 +7465,10 @@ export default withEnglishFallback({ wordWrapDescription: "Manda a capo le righe lunghe entro la larghezza dell'editor", showWhitespace: "Mostra spazi bianchi", showWhitespaceDescription: "Mostra gli spazi come · e le tabulazioni come → nell'editor SQL. Cambia solo la visualizzazione, non il testo SQL.", + ddlOpenMode: "Modalità di apertura DDL", + ddlOpenModeDescription: "Scegli se la visualizzazione del DDL di una tabella si apre per impostazione predefinita in una finestra di dialogo o in una nuova scheda", + ddlOpenModeDialog: "Finestra di dialogo (predefinita)", + ddlOpenModeTab: "Nuova scheda", vimMode: "Modalita Vim", vimModeDescription: "Usa la modifica modale in stile Vim nell'editor SQL", autoCloseBrackets: "Chiusura automatica parentesi", diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts index 3dd78a2232..e5c526a336 100644 --- a/apps/desktop/src/i18n/locales/ja.ts +++ b/apps/desktop/src/i18n/locales/ja.ts @@ -3643,6 +3643,14 @@ export default withEnglishFallback({ viewSource: "ソースを表示", viewDetails: "詳細を表示", viewDdl: "DDLを表示", + openDdlInNewTab: "新しいタブで開く", + ddlDisplayMode: "DDLの表示方法", + ddlDisplayFormatted: "フォーマット済み", + ddlDisplayOriginal: "元の形式", + ddlOpenMode: "DDLの開き方", + ddlOpenModeDialog: "ダイアログで開く", + ddlOpenModeTab: "新しいタブで開く", + resizeDdlDialog: "DDLダイアログのサイズを変更", dropObject: "オブジェクトを削除", dropView: "ビューを削除", dropColumn: "列を削除", @@ -3863,6 +3871,7 @@ export default withEnglishFallback({ excludeDdlStorageHint: "Oracle と OceanBase Oracle の DDL の表示・コピーと構造のエクスポートに適用されます。無効にすると、圧縮・レプリカ・ストレージのパラメーターを保持します。パーティションと制約は常に保持されます。", refreshDdlOnOpen: "開くたびに更新", refreshDdlOnOpenHint: "有効にすると開くたびにデータベースから DDL を再取得し、無効にするとキャッシュを優先して使用します", + ddlRefreshFailed: "DDL の更新に失敗しました: {message}", manageExtension: "拡張機能を管理...", dropExtension: "拡張機能を削除", processList: "プロセス一覧", @@ -7479,6 +7488,10 @@ export default withEnglishFallback({ wordWrapDescription: "長い行をエディタ幅内で折り返します", showWhitespace: "空白文字を表示", showWhitespaceDescription: "SQL エディタでスペースを ·、タブを → で表示します。表示のみ変わり、SQL テキストは変わりません。", + ddlOpenMode: "DDLの開き方", + ddlOpenModeDescription: "テーブルのDDLを表示するとき、ダイアログと新しいタブのどちらを既定にするか選択します", + ddlOpenModeDialog: "ダイアログ(既定)", + ddlOpenModeTab: "新しいタブ", vimMode: "Vimモード", vimModeDescription: "SQLエディタでVim形式のモーダル編集を使用します", autoCloseBrackets: "自動括弧補完", diff --git a/apps/desktop/src/i18n/locales/ko.ts b/apps/desktop/src/i18n/locales/ko.ts index ace537e867..812cb53c49 100644 --- a/apps/desktop/src/i18n/locales/ko.ts +++ b/apps/desktop/src/i18n/locales/ko.ts @@ -3523,7 +3523,15 @@ export default withEnglishFallback({ viewSource: "소스 보기", viewDetails: "세부 정보 보기", viewDdl: "DDL 보기", + openDdlInNewTab: "새 탭에서 열기", viewDdlLoading: "DDL을 불러오는 중...", + ddlDisplayMode: "DDL 표시 방식", + ddlDisplayFormatted: "서식 지정", + ddlDisplayOriginal: "원본", + ddlOpenMode: "DDL 열기 방식", + ddlOpenModeDialog: "대화상자에서 열기", + ddlOpenModeTab: "새 탭에서 열기", + resizeDdlDialog: "DDL 대화상자 크기 조정", ddlCopied: "DDL을 복사했습니다", excludeDdlStorage: "스토리지 속성 제외", excludeDdlStorageHint: "Oracle 및 OceanBase Oracle의 DDL 표시, 복사 및 구조 내보내기에 적용됩니다. 끄면 압축, 복제본 및 스토리지 매개변수를 유지합니다. 파티션과 제약 조건은 항상 유지됩니다.", @@ -3760,6 +3768,7 @@ export default withEnglishFallback({ eventSave: "Save", refreshDdlOnOpen: "열 때마다 새로 고침", refreshDdlOnOpenHint: "켜면 열 때마다 데이터베이스에서 DDL을 다시 불러오고, 끄면 캐시된 데이터를 우선 사용합니다", + ddlRefreshFailed: "DDL 새로 고침 실패: {message}", viewDependencies: "의존 관계 보기", copyConnectionInfo: "연결 정보 복사", copyConnectionUrl: "연결 URL", @@ -7241,6 +7250,10 @@ export default withEnglishFallback({ wordWrapDescription: "편집기 너비 내에서 긴 줄을 줄바꿈합니다", showWhitespace: "공백 문자 표시", showWhitespaceDescription: "SQL 편집기에서 공백을 ·, 탭을 →로 표시합니다. 표시만 바뀌며 SQL 텍스트는 변경되지 않습니다.", + ddlOpenMode: "DDL 열기 방식", + ddlOpenModeDescription: "테이블 DDL을 볼 때 대화상자와 새 탭 중 기본 열기 방식을 선택합니다", + ddlOpenModeDialog: "대화상자 (기본값)", + ddlOpenModeTab: "새 탭", vimMode: "Vim 모드", vimModeDescription: "SQL 편집기에서 Vim 스타일의 모달 편집을 사용합니다", autoCloseBrackets: "괄호 자동 닫기", diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts index ad04a9cb52..6376881232 100644 --- a/apps/desktop/src/i18n/locales/pt-BR.ts +++ b/apps/desktop/src/i18n/locales/pt-BR.ts @@ -3618,12 +3618,21 @@ export default withEnglishFallback({ viewSource: "Ver Código-fonte", viewDetails: "Ver detalhes", viewDdl: "Ver DDL", + openDdlInNewTab: "Abrir em nova aba", viewDdlLoading: "Carregando DDL...", + ddlDisplayMode: "Exibição do DDL", + ddlDisplayFormatted: "Formatado", + ddlDisplayOriginal: "Original", + ddlOpenMode: "Modo de abertura do DDL", + ddlOpenModeDialog: "Abrir em diálogo", + ddlOpenModeTab: "Abrir em nova aba", + resizeDdlDialog: "Redimensionar diálogo do DDL", ddlCopied: "DDL copiado", excludeDdlStorage: "Excluir atributos de armazenamento", excludeDdlStorageHint: "Aplica-se à exibição e cópia de DDL e à exportação de estruturas do Oracle e OceanBase Oracle. Desative esta opção para manter os parâmetros de compressão, réplicas e armazenamento. As partições e restrições são sempre mantidas.", refreshDdlOnOpen: "Atualizar sempre que abrir", refreshDdlOnOpenHint: "Quando ativado, o DDL é recarregado do banco de dados sempre que é aberto; quando desativado, os dados em cache têm prioridade", + ddlRefreshFailed: "Falha ao atualizar o DDL: {message}", dropObject: "Remover Objeto", dropView: "Remover Visão", dropColumn: "Remover Coluna", @@ -7458,6 +7467,10 @@ export default withEnglishFallback({ wordWrapDescription: "Quebrar linhas longas dentro da largura do editor", showWhitespace: "Mostrar espaços em branco", showWhitespaceDescription: "Mostrar espaços como · e tabulações como → no editor SQL. Altera apenas a exibição, não o texto SQL.", + ddlOpenMode: "Modo de abertura do DDL", + ddlOpenModeDescription: "Escolha se a visualização do DDL de uma tabela abre em um diálogo ou em uma nova aba por padrão", + ddlOpenModeDialog: "Diálogo (padrão)", + ddlOpenModeTab: "Nova aba", vimMode: "Modo Vim", vimModeDescription: "Usar edição modal no estilo Vim no editor SQL", autoCloseBrackets: "Fechar parênteses automaticamente", diff --git a/apps/desktop/src/i18n/locales/tr.ts b/apps/desktop/src/i18n/locales/tr.ts index 09be9c9ac7..6fe7d19177 100644 --- a/apps/desktop/src/i18n/locales/tr.ts +++ b/apps/desktop/src/i18n/locales/tr.ts @@ -3482,12 +3482,21 @@ export default withEnglishFallback({ changeOpenMode: "Açılış modunu değiştir", viewDetails: "Ayrıntıları Görüntüle", viewDdl: "DDL'i Görüntüle", + openDdlInNewTab: "Yeni sekmede aç", viewDdlLoading: "DDL yükleniyor...", + ddlDisplayMode: "DDL görüntüleme biçimi", + ddlDisplayFormatted: "Biçimlendirilmiş", + ddlDisplayOriginal: "Orijinal", + ddlOpenMode: "DDL açılma modu", + ddlOpenModeDialog: "İletişim kutusunda aç", + ddlOpenModeTab: "Yeni sekmede aç", + resizeDdlDialog: "DDL iletişim kutusunu yeniden boyutlandır", ddlCopied: "DDL kopyalandı", excludeDdlStorage: "Depolama özelliklerini hariç tut", excludeDdlStorageHint: "Oracle ve OceanBase Oracle DDL görüntüleme, kopyalama ve yapı dışa aktarma işlemlerine uygulanır. Sıkıştırma, replika ve depolama parametrelerini korumak için kapatın. Bölümler ve kısıtlamalar her zaman korunur.", refreshDdlOnOpen: "Her açılışta yenile", refreshDdlOnOpenHint: "Etkinleştirildiğinde DDL her açılışta veritabanından yeniden yüklenir; kapatıldığında önbellekteki veri tercih edilir", + ddlRefreshFailed: "DDL yenilenemedi: {message}", dropObject: "Nesneyi Sil", dropEventSuccess: '"{name}" olayı silindi', editObject: "Nesneyi Düzenle", @@ -7440,6 +7449,10 @@ export default withEnglishFallback({ wordWrapDescription: "Uzun satırları düzenleyici genişliğinde kaydır", showWhitespace: "Boşluk karakterlerini göster", showWhitespaceDescription: "SQL düzenleyicide boşlukları ·, sekmeleri → olarak gösterir. Yalnızca görünüm değişir, SQL metni değişmez.", + ddlOpenMode: "DDL açılma modu", + ddlOpenModeDescription: "Tablo DDL'sini görüntülerken varsayılan olarak iletişim kutusunun mu yoksa yeni sekmenin mi açılacağını seçin", + ddlOpenModeDialog: "İletişim kutusu (varsayılan)", + ddlOpenModeTab: "Yeni sekme", vimMode: "Vim modu", vimModeDescription: "SQL düzenleyicisinde Vim biçemli kipli düzenleme kullan", autoCloseBrackets: "Parantezleri otomatik kapat", diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts index 13df05c2ac..448f9d6946 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.ts +++ b/apps/desktop/src/i18n/locales/zh-CN.ts @@ -3681,12 +3681,21 @@ export default withEnglishFallback({ changeOpenMode: "修改打开方式", viewDetails: "查看详情", viewDdl: "查看 DDL", + openDdlInNewTab: "在新标签中打开", viewDdlLoading: "正在读取 DDL...", + ddlDisplayMode: "DDL 显示方式", + ddlDisplayFormatted: "格式化", + ddlDisplayOriginal: "原始", + ddlOpenMode: "查看 DDL 打开方式", + ddlOpenModeDialog: "弹框打开", + ddlOpenModeTab: "新标签打开", + resizeDdlDialog: "调整 DDL 弹窗大小", ddlCopied: "DDL 已复制", excludeDdlStorage: "去掉存储属性", excludeDdlStorageHint: "用于 Oracle 与 OceanBase Oracle 的 DDL 显示、复制和结构导出。关闭后保留压缩、副本和存储参数;分区和约束始终保留。", refreshDdlOnOpen: "每次打开时刷新", refreshDdlOnOpenHint: "开启后每次打开都会从数据库重新读取;关闭时优先使用缓存", + ddlRefreshFailed: "刷新 DDL 失败:{message}", dropObject: "删除对象", dropView: "删除视图", dropColumn: "删除字段", @@ -8070,6 +8079,10 @@ export default withEnglishFallback({ wordWrapDescription: "长内容在编辑器宽度内自动折行显示", showWhitespace: "显示空白字符", showWhitespaceDescription: "在 SQL 编辑器中将空格显示为 ·,制表符显示为 →。仅影响显示,不改变 SQL 文本。", + ddlOpenMode: "查看 DDL 打开方式", + ddlOpenModeDescription: "设置查看所有表 DDL 时默认使用弹框还是新标签", + ddlOpenModeDialog: "弹框(默认)", + ddlOpenModeTab: "新标签", vimMode: "Vim 模式", vimModeDescription: "在 SQL 编辑器中使用 Vim 风格的模态编辑", autoCloseBrackets: "自动成对补全", diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts index 7fc2fb4e67..9ff46ca8e7 100644 --- a/apps/desktop/src/i18n/locales/zh-TW.ts +++ b/apps/desktop/src/i18n/locales/zh-TW.ts @@ -3619,12 +3619,21 @@ export default withEnglishFallback({ viewDependencies: "檢視相依關係", viewDetails: "檢視詳細資料", viewDdl: "檢視 DDL", + openDdlInNewTab: "在新標籤中開啟", viewDdlLoading: "正在讀取 DDL...", + ddlDisplayMode: "DDL 顯示方式", + ddlDisplayFormatted: "格式化", + ddlDisplayOriginal: "原始", + ddlOpenMode: "DDL 開啟方式", + ddlOpenModeDialog: "以對話框開啟", + ddlOpenModeTab: "在新標籤中開啟", + resizeDdlDialog: "調整 DDL 對話框大小", ddlCopied: "DDL 已複製", excludeDdlStorage: "移除儲存屬性", excludeDdlStorageHint: "用於 Oracle 與 OceanBase Oracle 的 DDL 顯示、複製和結構匯出。關閉後保留壓縮、副本和儲存參數;分割區和約束條件一律保留。", refreshDdlOnOpen: "每次開啟時重新整理", refreshDdlOnOpenHint: "開啟後每次開啟都會從資料庫重新讀取;關閉時優先使用快取", + ddlRefreshFailed: "重新整理 DDL 失敗:{message}", dropObject: "刪除物件", dropView: "刪除檢視", dropColumn: "刪除欄位", @@ -6772,6 +6781,10 @@ export default withEnglishFallback({ wordWrapDescription: "長內容在編輯器寬度內自動折行顯示", showWhitespace: "顯示空白字元", showWhitespaceDescription: "在 SQL 編輯器中將空格顯示為 ·,定位字元顯示為 →。僅影響顯示,不改變 SQL 文字。", + ddlOpenMode: "DDL 開啟方式", + ddlOpenModeDescription: "設定檢視資料表 DDL 時,預設以對話框或新標籤開啟", + ddlOpenModeDialog: "對話框(預設)", + ddlOpenModeTab: "新標籤", vimMode: "Vim 模式", vimModeDescription: "在 SQL 編輯器中使用 Vim 風格的模態編輯", autoCloseBrackets: "自動成對補全", diff --git a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts index 3eb0276d55..c988fb8089 100644 --- a/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts +++ b/apps/desktop/src/lib/__tests__/app/openTabsPersistence.spec.ts @@ -62,6 +62,13 @@ describe("openTabsPersistence originalSql round-trip", () => { expect(restored.objectSource).toBeUndefined(); }); + it("preserves the DDL viewer identity needed for read-only tabs", () => { + const ddlViewer = { schema: "public", tableName: "users", objectType: "VIEW" as const, formatDialect: "postgres" as const }; + const [restored] = roundTrip([queryTab({ sourceView: true, ddlViewer, sql: "CREATE VIEW users AS SELECT 1" })]); + + expect(restored.ddlViewer).toEqual(ddlViewer); + }); + it("does not persist a pending object-source tab without its in-flight request", () => { const pending = queryTab({ id: "pending-source", diff --git a/apps/desktop/src/lib/__tests__/sql/ddlDisplay.spec.ts b/apps/desktop/src/lib/__tests__/sql/ddlDisplay.spec.ts new file mode 100644 index 0000000000..ac7f2010af --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/ddlDisplay.spec.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { alignDdlColumnDefinitions, uppercaseDdlColumnTypes } from "@/lib/sql/ddlDisplay"; + +describe("alignDdlColumnDefinitions", () => { + it("aligns column names, types, nullability, defaults, and comments", () => { + const sql = `CREATE TABLE events ( + id bigint NOT NULL AUTO_INCREMENT COMMENT 'primary id', + project_id bigint unsigned NOT NULL COMMENT 'project default NOT NULL', + status varchar(24) DEFAULT NULL COMMENT 'status DEFAULT', + active tinyint DEFAULT 1 NOT NULL COMMENT 'enabled', + PRIMARY KEY (id) +);`; + + const formatted = alignDdlColumnDefinitions(sql, "mysql"); + const lines = formatted.split("\n"); + const columnLines = lines.filter((line) => /^\s+(?:id|project_id|status|active)\s/.test(line)); + const typePositions = columnLines.map((line) => line.search(/\b(?:bigint|varchar|tinyint)\b/)); + const nullablePositions = columnLines.filter((line) => line.includes("NOT NULL")).map((line) => line.indexOf("NOT NULL")); + const defaultPositions = columnLines.filter((line) => line.includes("DEFAULT")).map((line) => line.indexOf("DEFAULT")); + const commentPositions = columnLines.map((line) => line.indexOf("COMMENT")); + + expect(columnLines).toHaveLength(4); + expect(new Set(typePositions).size).toBe(1); + expect(new Set(nullablePositions).size).toBe(1); + expect(new Set(defaultPositions).size).toBe(1); + expect(new Set(commentPositions).size).toBe(1); + expect(lines.find((line) => line.includes("PRIMARY KEY"))).toBe(" PRIMARY KEY (id)"); + expect(formatted).toContain("'project default NOT NULL'"); + }); + + it("leaves non-table SQL unchanged", () => { + const sql = "SELECT id, name FROM customers;"; + + expect(alignDdlColumnDefinitions(sql, "mysql")).toBe(sql); + }); + + it("leaves view definitions unchanged", () => { + const sql = `CREATE VIEW customer_summary AS +SELECT customer_id, COUNT(*) AS total +FROM customer_events +GROUP BY customer_id;`; + + expect(alignDdlColumnDefinitions(sql, "mysql")).toBe(sql); + }); +}); + +describe("uppercaseDdlColumnTypes", () => { + it("uppercases table column types without changing identifiers, defaults, or comments", () => { + const sql = `CREATE TABLE events ( + id bigint unsigned NOT NULL DEFAULT 0 COMMENT 'bigint not null', + created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, + label varchar(40) DEFAULT 'varchar default' +);`; + + const formatted = uppercaseDdlColumnTypes(sql, "postgres"); + + expect(formatted).toContain("id BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'bigint not null'"); + expect(formatted).toContain("created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP"); + expect(formatted).toContain("label VARCHAR(40) DEFAULT 'varchar default'"); + }); + + it("leaves non-table SQL unchanged", () => { + const sql = "SELECT id bigint FROM events;"; + + expect(uppercaseDdlColumnTypes(sql, "postgres")).toBe(sql); + }); +}); diff --git a/apps/desktop/src/lib/app/openTabsPersistence.ts b/apps/desktop/src/lib/app/openTabsPersistence.ts index 9f6bfeda0f..51ae97d47c 100644 --- a/apps/desktop/src/lib/app/openTabsPersistence.ts +++ b/apps/desktop/src/lib/app/openTabsPersistence.ts @@ -61,6 +61,7 @@ export interface SavedOpenTab { objectBrowser?: QueryTab["objectBrowser"]; objectSource?: QueryTab["objectSource"]; sourceView?: boolean; + ddlViewer?: QueryTab["ddlViewer"]; tableComment?: QueryTab["tableComment"]; tableMeta?: QueryTab["tableMeta"]; mongoEditTarget?: QueryTab["mongoEditTarget"]; @@ -204,6 +205,7 @@ export function serializeOpenTabs(tabs: QueryTab[]): SavedOpenTab[] { objectBrowser: tab.objectBrowser, objectSource: tab.objectSource, ...(tab.sourceView ? { sourceView: true } : {}), + ...(tab.ddlViewer ? { ddlViewer: { ...tab.ddlViewer } } : {}), ...(tab.tableComment !== undefined ? { tableComment: tab.tableComment } : {}), tableMeta: tab.tableMeta, ...(tab.mongoEditTarget !== undefined ? { mongoEditTarget: tab.mongoEditTarget } : {}), diff --git a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts index 8a70766024..95ba0681c2 100644 --- a/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/editorSettingsDraft.spec.ts @@ -33,6 +33,7 @@ function makeSettings(overrides: Partial = {}): EditorSettings { continueOnErrorOnBatch: false, confirmUnsavedSqlClose: true, savedSqlOpenTargetMode: "saved", + ddlOpenMode: "dialog", objectBrowserViewMode: "list", sqlVariableSubstitutionEnabled: true, sqlVariableSyntaxOverrides: {}, @@ -51,6 +52,10 @@ describe("EDITOR_SETTINGS_DRAFT_KEYS", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("showLineNumbers"); }); + it("includes the DDL open mode", () => { + expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("ddlOpenMode"); + }); + it("includes continueOnErrorOnBatch", () => { expect(EDITOR_SETTINGS_DRAFT_KEYS).toContain("continueOnErrorOnBatch"); }); @@ -416,6 +421,19 @@ describe("editorSettingsDraftFromSettings - tabLayout", () => { }); }); +describe("editorSettingsDraftFromSettings - ddlOpenMode", () => { + it("maps and tracks the selected DDL open mode", () => { + const settings = makeSettings({ ddlOpenMode: "dialog" }); + const draft = editorSettingsDraftFromSettings(settings); + const base = editorSettingsDraftFromSettings(settings); + + expect(draft.ddlOpenMode).toBe("dialog"); + draft.ddlOpenMode = "tab"; + expect(editorSettingsDraftChanged(draft, base)).toBe(true); + expect(editorSettingsPatchFromDraft(draft, base)).toEqual({ ddlOpenMode: "tab" }); + }); +}); + describe("editorSettingsDraftChanged - tabLayout", () => { it("detects change in tabLayout", () => { const settings = makeSettings({ tabLayout: "scroll" }); diff --git a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts index db874c06af..2c0fc2ea89 100644 --- a/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts +++ b/apps/desktop/src/lib/settings/__tests__/settingsTransfer.spec.ts @@ -195,6 +195,7 @@ describe("settingsTransfer", () => { for (const key of EDITOR_SETTINGS_DRAFT_KEYS) { expect(transferCategoryForKey(key), key).toBeDefined(); } + expect(transferCategoryForKey("ddlOpenMode")).toBe("editor"); expect(transferCategoryForKey("notARealSetting")).toBeUndefined(); }); diff --git a/apps/desktop/src/lib/settings/editorSettingsDraft.ts b/apps/desktop/src/lib/settings/editorSettingsDraft.ts index d5af1496b2..1fc7259693 100644 --- a/apps/desktop/src/lib/settings/editorSettingsDraft.ts +++ b/apps/desktop/src/lib/settings/editorSettingsDraft.ts @@ -31,6 +31,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [ "selectFirstCompletionOnOpen", "wordWrap", "showWhitespace", + "ddlOpenMode", "vimModeEnabled", "autoCloseBrackets", "sqlSemanticDiagnosticsMode", diff --git a/apps/desktop/src/lib/settings/settingsTransfer.ts b/apps/desktop/src/lib/settings/settingsTransfer.ts index 5237d187a7..3fb4c77456 100644 --- a/apps/desktop/src/lib/settings/settingsTransfer.ts +++ b/apps/desktop/src/lib/settings/settingsTransfer.ts @@ -102,6 +102,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record= 0; index -= 1) { + if (lineStarts[index]! <= position) return index; + } + return 0; +} + +function findCreateTableBody(sql: string, dialect: SqlFormatDialect): { start: number; end: number } | undefined { + const tokens = tokenizeSqlSemantic(sql, dialect); + const createIndex = tokens.findIndex((token) => token.kind === "word" && token.normalized === "create"); + if (createIndex < 0) return undefined; + + let tableIndex = createIndex + 1; + while (tableIndex < tokens.length && tokens[tableIndex]?.kind === "word" && CREATE_TABLE_MODIFIERS.has(tokens[tableIndex]!.normalized)) tableIndex += 1; + if (tokens[tableIndex]?.kind !== "word" || tokens[tableIndex]?.normalized !== "table") return undefined; + + let openingIndex = -1; + for (let index = tableIndex + 1; index < tokens.length; index += 1) { + const token = tokens[index]!; + if (token.depth === 0 && token.kind === "punctuation" && token.text === ";") break; + if (token.depth === 0 && token.kind === "word" && token.normalized === "as") break; + if (token.depth === 0 && token.kind === "punctuation" && token.text === "(") { + openingIndex = index; + break; + } + } + if (openingIndex < 0) return undefined; + const opening = tokens[openingIndex]!; + const closing = tokens.find((token, index) => index > openingIndex && token.kind === "punctuation" && token.text === ")" && token.depth === opening.depth); + if (!closing) return undefined; + return { start: opening.span.end, end: closing.span.start }; +} + +function readColumnAlignment(line: string, lineIndex: number, dialect: SqlFormatDialect): DdlColumnAlignment | undefined { + const tokens = tokenizeSqlSemantic(line, dialect); + const name = tokens[0]; + const dataType = tokens[1]; + if (!tokenIsIdentifier(name) || !tokenIsIdentifier(dataType) || TABLE_CONSTRAINT_NAMES.has(name.normalized)) return undefined; + return { lineIndex, nameEnd: name.span.end }; +} + +function findColumnAttributePositions(line: string, dialect: SqlFormatDialect): DdlColumnAttributePositions { + const tokens = tokenizeSqlSemantic(line, dialect); + const name = tokens[0]; + const dataType = tokens[1]; + if (!tokenIsIdentifier(name) || !tokenIsIdentifier(dataType)) return {}; + + const words = tokens.filter((token) => token.kind === "word" && token.depth === 0 && token.span.start >= dataType.span.end); + const findWord = (value: string) => words.find((token) => token.normalized === value); + const defaultToken = findWord("default"); + let nullableToken = words.find((token, index) => token.normalized === "not" && words[index + 1]?.normalized === "null"); + if (!nullableToken) { + nullableToken = words.find((token) => token.normalized === "null" && (!defaultToken || token.span.start < defaultToken.span.start)); + } + + const identityToken = words.find((token) => token.normalized === "auto_increment" || token.normalized === "identity"); + const onToken = words.find((token, index) => token.normalized === "on" && words[index + 1]?.normalized === "update"); + const uniqueToken = findWord("unique"); + const referencesToken = findWord("references"); + const commentToken = findWord("comment"); + + return { + ...(nullableToken ? { nullable: nullableToken.span.start } : {}), + ...(defaultToken ? { default: defaultToken.span.start } : {}), + ...(identityToken ? { identity: identityToken.span.start } : {}), + ...(onToken ? { onUpdate: onToken.span.start } : {}), + ...(uniqueToken ? { unique: uniqueToken.span.start } : {}), + ...(referencesToken ? { references: referencesToken.span.start } : {}), + ...(commentToken ? { comment: commentToken.span.start } : {}), + }; +} + +export function uppercaseDdlColumnTypes(sql: string, dialect: SqlFormatDialect): string { + const body = findCreateTableBody(sql, dialect); + if (!body) return sql; + + const lines = sql.split("\n"); + const lineStarts: number[] = []; + let offset = 0; + for (const line of lines) { + lineStarts.push(offset); + offset += line.length + 1; + } + + const firstBodyLine = findLineIndex(lineStarts, body.start) + 1; + const lastBodyLine = findLineIndex(lineStarts, body.end); + for (let lineIndex = firstBodyLine; lineIndex < lastBodyLine; lineIndex += 1) { + const line = lines[lineIndex]!; + if (!readColumnAlignment(line, lineIndex, dialect)) continue; + + const tokens = tokenizeSqlSemantic(line, dialect); + const dataType = tokens[1]; + if (dataType?.kind !== "word") continue; + + const attributePositions = findColumnAttributePositions(line, dialect); + const attributeStart = Math.min(line.length, ...Object.values(attributePositions).filter((position): position is number => position !== undefined)); + const typeWords = tokens.filter((token) => token.kind === "word" && token.span.start >= dataType.span.start && token.span.start < attributeStart); + for (let tokenIndex = typeWords.length - 1; tokenIndex >= 0; tokenIndex -= 1) { + const token = typeWords[tokenIndex]!; + const upper = token.text.toUpperCase(); + if (token.text === upper) continue; + lines[lineIndex] = `${lines[lineIndex]!.slice(0, token.span.start)}${upper}${lines[lineIndex]!.slice(token.span.end)}`; + } + } + + return lines.join("\n"); +} + +function alignColumnAttribute(lines: string[], columns: DdlColumnAlignment[], attribute: keyof DdlColumnAttributePositions, dialect: SqlFormatDialect) { + const positions = columns.map(({ lineIndex }) => findColumnAttributePositions(lines[lineIndex]!, dialect)[attribute]); + const target = Math.max(-1, ...positions.filter((position): position is number => position !== undefined)); + if (target < 0) return; + + columns.forEach(({ lineIndex }, index) => { + const position = positions[index]; + if (position === undefined || position >= target) return; + lines[lineIndex] = `${lines[lineIndex]!.slice(0, position)}${" ".repeat(target - position)}${lines[lineIndex]!.slice(position)}`; + }); +} + +export function alignDdlColumnDefinitions(sql: string, dialect: SqlFormatDialect): string { + const body = findCreateTableBody(sql, dialect); + if (!body) return sql; + + const lines = sql.split("\n"); + const lineStarts: number[] = []; + let offset = 0; + for (const line of lines) { + lineStarts.push(offset); + offset += line.length + 1; + } + + const firstBodyLine = findLineIndex(lineStarts, body.start) + 1; + const lastBodyLine = findLineIndex(lineStarts, body.end); + const columns: DdlColumnAlignment[] = []; + for (let lineIndex = firstBodyLine; lineIndex < lastBodyLine; lineIndex += 1) { + const column = readColumnAlignment(lines[lineIndex]!, lineIndex, dialect); + if (column) columns.push(column); + } + if (columns.length < 2) return sql; + + const maxNameWidth = Math.max( + ...columns.map(({ lineIndex, nameEnd }) => { + const line = lines[lineIndex]!; + const indentWidth = line.length - line.trimStart().length; + return nameEnd - indentWidth; + }), + ); + for (const { lineIndex, nameEnd } of columns) { + const line = lines[lineIndex]!; + const indentWidth = line.length - line.trimStart().length; + const name = line.slice(indentWidth, nameEnd); + lines[lineIndex] = `${line.slice(0, indentWidth)}${name.padEnd(maxNameWidth + 2)}${line.slice(nameEnd).trimStart()}`; + } + + const initialPositions = columns.map(({ lineIndex }) => findColumnAttributePositions(lines[lineIndex]!, dialect)); + const defaultBeforeNullable = initialPositions.filter((positions) => positions.default !== undefined && positions.nullable !== undefined && positions.default < positions.nullable).length; + const nullableBeforeDefault = initialPositions.filter((positions) => positions.default !== undefined && positions.nullable !== undefined && positions.nullable < positions.default).length; + const attributeOrder: Array = defaultBeforeNullable > nullableBeforeDefault ? ["default", "nullable", "identity", "onUpdate", "unique", "references", "comment"] : ["nullable", "default", "identity", "onUpdate", "unique", "references", "comment"]; + for (const attribute of attributeOrder) alignColumnAttribute(lines, columns, attribute, dialect); + + return lines.join("\n"); +} + +export interface DdlDisplayFormatOptions { + dialect: SqlFormatDialect; + databaseType?: DatabaseType; + database?: string; + catalog?: string; + includeDatabaseName: boolean; + quoteIdentifiers: boolean; + /** + * Storage-clause filtering for surfaces that bake the text once (DDL viewer + * tabs store plain SQL). The dialog and the structure editor keep it off and + * re-filter on read instead, so toggling the preference updates them without + * reloading the DDL. + */ + excludeDdlStorage?: boolean; +} + +/** + * Shared preference tail of the read-only DDL display pipeline: applies the + * database-name qualifier, the identifier-quoting preference, and the column + * type casing/alignment to already-formatted DDL. Use {@link formatDdlForDisplay} + * unless the input is formatted separately. + */ +export function applyDdlDisplayPreferences(sql: string, options: DdlDisplayFormatOptions): string { + const qualified = applyDdlDatabaseQualifier(sql, options.dialect, options.databaseType, options.includeDatabaseName, options.database, options.catalog); + const quoted = options.quoteIdentifiers ? qualified : omitDdlIdentifierQuotes(qualified, options.dialect); + const aligned = alignDdlColumnDefinitions(uppercaseDdlColumnTypes(quoted, options.dialect), options.dialect); + return options.excludeDdlStorage ? applyDdlStoragePreference(aligned, options.databaseType) : aligned; +} + +/** + * Canonical read-only DDL display pipeline shared by the DDL dialog, DDL viewer + * tabs, and the structure editor's DDL tab: format the statement, apply the + * SQL-editor preferences, and align the column definitions. Callers feed the + * result of the object DDL fetch straight through it. + */ +export async function formatDdlForDisplay(ddl: string, options: DdlDisplayFormatOptions, formatter: Partial = DEFAULT_SQL_FORMATTER_SETTINGS): Promise { + return applyDdlDisplayPreferences(await formatSqlForDisplay(ddl, options.dialect, formatter), options); +} diff --git a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts index 6b159d9155..af245da686 100644 --- a/apps/desktop/src/stores/__tests__/settingsStore.spec.ts +++ b/apps/desktop/src/stores/__tests__/settingsStore.spec.ts @@ -20,6 +20,13 @@ import type { AiConfigItem } from "@/types/ai"; import { DATA_GRID_EXTRACTOR_OPTIONS_MIGRATION_VERSION } from "@/lib/dataGrid/dataGridCopyExtractor"; describe("normalizeEditorSettings", () => { + it("defaults DDL viewing to a dialog and preserves the selected open mode", () => { + expect(DEFAULT_EDITOR_SETTINGS.ddlOpenMode).toBe("dialog"); + expect(normalizeEditorSettings({}).ddlOpenMode).toBe("dialog"); + expect(normalizeEditorSettings({ ddlOpenMode: "tab" }).ddlOpenMode).toBe("tab"); + expect(normalizeEditorSettings({ ddlOpenMode: "invalid" } as any).ddlOpenMode).toBe("dialog"); + }); + it("keeps automatic DDL refresh disabled unless explicitly enabled", () => { expect(normalizeEditorSettings({}).refreshDdlOnOpen).toBe(false); expect(normalizeEditorSettings({ refreshDdlOnOpen: true }).refreshDdlOnOpen).toBe(true); diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 7a00d7b3ba..e81b31d142 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -69,7 +69,9 @@ import { isDataTabMetadataLifecycleStale } from "@/lib/sidebar/dataTabOpenPolicy import type { SqlInsertMode } from "@/lib/export/sqlInsertMode"; import { tableOpenPageLimit } from "@/lib/table/tableOpenPageLimit"; import { getCachedTableMetadata, loadTableColumns, loadTableIndexes, loadTableMetadata, tableMetadataToDataTabMeta, updateCachedTableMetadataType, type TableMetadataRequest } from "@/lib/metadata/tableMetadataCache"; +import { loadObjectDdl } from "@/lib/metadata/objectDdlCache"; import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter"; +import { formatDdlForDisplay } from "@/lib/sql/ddlDisplay"; import { buildTableSelectSql, quoteTableDataIdentifier } from "@/lib/table/tableSelectSql"; import { connectionObjectTreeNodeSchema, connectionQueryExecutionSchema, connectionUsesDatabaseObjectTreeMode, effectiveDatabaseTypeForConnection, gaussdbCountQueryDopHint, jdbcConnectionUsesDriverRowOffset, metadataSchemaForConnection } from "@/lib/database/jdbcDialect"; import { frontendQueryTimeoutDelayMs, frontendQueryTimeoutSecsForSql, queryTimeoutSecsForConnection } from "@/lib/sql/queryTimeout"; @@ -3050,6 +3052,67 @@ export const useQueryStore = defineStore("query", () => { clearObjectSourceLoad(tab); } + /** + * 「先出 tab 再加载」(issue #9387):为 pending 的 DDL 新标签取回 DDL 并回填。 + * 加载在 store 侧进行,不依赖该 tab 是否处于激活状态;失败就地写入 + * `ddlLoad.error`,由编辑区渲染错误 + Retry,而不是弹 toast。 + */ + async function loadDdlViewerTab(id: string) { + const tab = tabs.value.find((candidate) => candidate.id === id); + const request = tab?.ddlLoad; + const ddlViewer = tab?.ddlViewer; + if (!tab || !request || !ddlViewer || !tab.connectionId) return; + const { connectionId } = tab; + const { database } = tab; + const settingsStore = useSettingsStore(); + try { + const connectionStore = useConnectionStore(); + const databaseType = effectiveDatabaseTypeForConnection(connectionStore.getConfig(connectionId)); + const { ddl } = await loadObjectDdl( + { + connectionId, + database, + schema: ddlViewer.schema || database, + tableName: ddlViewer.tableName, + objectType: ddlViewer.objectType, + catalog: tab.catalog, + }, + // 与弹框打开一致:默认优先使用缓存,开启「每次打开时刷新」才强制重查 + { force: settingsStore.editorSettings.refreshDdlOnOpen }, + ); + const displayed = await formatDdlForDisplay( + ddl, + { + dialect: ddlViewer.formatDialect ?? "generic", + databaseType, + database, + catalog: tab.catalog, + includeDatabaseName: settingsStore.editorSettings.generateSqlIncludeDatabaseName, + quoteIdentifiers: settingsStore.editorSettings.generateSqlQuoteIdentifiers, + excludeDdlStorage: settingsStore.editorSettings.excludeDdlStorage, + }, + settingsStore.editorSettings.sqlFormatter, + ); + // 加载期间 tab 被关掉,或 pending 状态已被一次重试取代:静默丢弃这次结果 + const current = tabs.value.find((candidate) => candidate.id === id); + if (current?.ddlLoad !== request) return; + updateSql(id, displayed); + markTabClean(current); + current.ddlLoad = undefined; + } catch (e: any) { + const failed = tabs.value.find((candidate) => candidate.id === id); + if (failed?.ddlLoad === request) failed.ddlLoad = { startedAt: Date.now(), error: e?.message || String(e) }; + } + } + + /** 就地重试失败的 DDL 加载;已完成加载的 DDL 标签走编辑区工具栏的刷新。 */ + function retryDdlViewerTab(id: string) { + const tab = tabs.value.find((candidate) => candidate.id === id); + if (!tab?.ddlLoad) return; + tab.ddlLoad = { startedAt: Date.now() }; + void loadDdlViewerTab(id); + } + function showExecutedQueryResults(connectionId: string, database: string, sql: string, queryResults: QueryResult[]) { const id = createTab(connectionId, database, undefined, "query", undefined, sql); const tab = tabs.value.find((item) => item.id === id); @@ -9131,6 +9194,8 @@ export const useQueryStore = defineStore("query", () => { openObjectSourceTabPending, retryObjectSourceTab, refreshObjectSourceTab, + loadDdlViewerTab, + retryDdlViewerTab, showExecutedQueryResults, focusGroup, activateTab, diff --git a/apps/desktop/src/stores/settingsStore.ts b/apps/desktop/src/stores/settingsStore.ts index c6633a4285..37e3b335f6 100644 --- a/apps/desktop/src/stores/settingsStore.ts +++ b/apps/desktop/src/stores/settingsStore.ts @@ -804,6 +804,7 @@ export interface EditorSettings { wordWrap: boolean; showWhitespace: boolean; tableDdlWordWrap: boolean; + ddlOpenMode: "dialog" | "tab"; refreshDdlOnOpen: boolean; excludeDdlStorage: boolean; vimModeEnabled: boolean; @@ -1081,6 +1082,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = { wordWrap: false, showWhitespace: false, tableDdlWordWrap: true, + ddlOpenMode: "dialog", refreshDdlOnOpen: false, excludeDdlStorage: true, vimModeEnabled: false, @@ -1635,6 +1637,7 @@ export function normalizeEditorSettings(settings: Partial, exist showWhitespace: typeof settings.showWhitespace === "boolean" ? settings.showWhitespace : DEFAULT_EDITOR_SETTINGS.showWhitespace, tableDdlWordWrap: typeof settings.tableDdlWordWrap === "boolean" ? settings.tableDdlWordWrap : DEFAULT_EDITOR_SETTINGS.tableDdlWordWrap, excludeDdlStorage: typeof settings.excludeDdlStorage === "boolean" ? settings.excludeDdlStorage : DEFAULT_EDITOR_SETTINGS.excludeDdlStorage, + ddlOpenMode: settings.ddlOpenMode === "tab" ? "tab" : DEFAULT_EDITOR_SETTINGS.ddlOpenMode, refreshDdlOnOpen: typeof settings.refreshDdlOnOpen === "boolean" ? settings.refreshDdlOnOpen : DEFAULT_EDITOR_SETTINGS.refreshDdlOnOpen, vimModeEnabled: typeof settings.vimModeEnabled === "boolean" ? settings.vimModeEnabled : DEFAULT_EDITOR_SETTINGS.vimModeEnabled, autoCloseBrackets: typeof settings.autoCloseBrackets === "boolean" ? settings.autoCloseBrackets : DEFAULT_EDITOR_SETTINGS.autoCloseBrackets, @@ -2450,6 +2453,7 @@ export const useSettingsStore = defineStore("settings", () => { if (partial.showWhitespace !== undefined) editorSettings.value.showWhitespace = partial.showWhitespace === true; if (partial.tableDdlWordWrap !== undefined) editorSettings.value.tableDdlWordWrap = partial.tableDdlWordWrap === true; if (partial.excludeDdlStorage !== undefined) editorSettings.value.excludeDdlStorage = partial.excludeDdlStorage === true; + if (partial.ddlOpenMode !== undefined) editorSettings.value.ddlOpenMode = partial.ddlOpenMode === "tab" ? "tab" : "dialog"; if (partial.refreshDdlOnOpen !== undefined) editorSettings.value.refreshDdlOnOpen = partial.refreshDdlOnOpen === true; if (partial.vimModeEnabled !== undefined) editorSettings.value.vimModeEnabled = partial.vimModeEnabled === true; if (partial.autoCloseBrackets !== undefined) editorSettings.value.autoCloseBrackets = partial.autoCloseBrackets === true; diff --git a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts index cef1f92f74..183e69915f 100644 --- a/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts +++ b/apps/desktop/src/styles/__tests__/legacyWebviewFallback.spec.ts @@ -150,13 +150,14 @@ describe("legacy WebView CSS fallbacks", () => { }); it("keeps the DDL dialog layout on the global legacy dialog fallbacks", () => { - expect(ddlViewDialogSource).toContain('class="dbx-ddl-view-dialog sm:max-w-190"'); + expect(ddlViewDialogSource).toContain('class="dbx-ddl-view-dialog flex min-h-0 flex-col overflow-hidden sm:max-w-190"'); // The dialog content element is rendered through reka-ui's portal Teleport and // never carries this component's scoped data-v attribute, so per-dialog rules // (scoped or unscoped) are avoided; the global width table covers the dialog. expect(ddlViewDialogSource).not.toContain("dbx-legacy-webview"); expect(ddlViewDialogSource).not.toContain("@media"); expect(globalsCss).toContain('html.dbx-legacy-webview [data-slot="dialog-content"][class~="sm:max-w-190"]'); + expect(globalsCss).toContain('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog'); }); it("keeps the global dialog fallback block outside media queries", () => { @@ -206,6 +207,12 @@ describe("legacy WebView CSS fallbacks", () => { expect(rule).toContain("var(--dbx-viewport-height)"); }); + it("allows the DDL viewer to resize beyond the default legacy dialog width", () => { + const ruleStart = globalsCss.indexOf('html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog'); + expect(ruleStart).toBeGreaterThan(-1); + expect(globalsCss.slice(ruleStart, globalsCss.indexOf("}", ruleStart))).toContain("max-width: calc(100vw - 32px) !important;"); + }); + it("uses an explicit tooltip copy-button hover color in legacy WebViews", () => { expect(dataGridColumnHeaderSource).toContain("data-column-header-copy-name"); expect(dataGridColumnHeaderSource).toContain("html.dbx-legacy-webview [data-column-header-copy-name]:hover"); diff --git a/apps/desktop/src/styles/globals.css b/apps/desktop/src/styles/globals.css index 9c366a8553..1936f91a16 100644 --- a/apps/desktop/src/styles/globals.css +++ b/apps/desktop/src/styles/globals.css @@ -2307,6 +2307,10 @@ html.dbx-legacy-webview [data-slot="dialog-content"][class~="sm:max-w-190"] { max-width: 47.5rem !important; } +html.dbx-legacy-webview [data-slot="dialog-content"].dbx-ddl-view-dialog { + max-width: calc(100vw - 32px) !important; +} + html.dbx-legacy-webview [data-slot="dialog-content"][class*="sm:max-w-[360px]"] { max-width: 360px !important; } diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 577b515804..b0088095e2 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1,5 +1,6 @@ import type { BackendError } from "@/lib/backend/errorUtils"; import type { TransferContent, TransferMode, TransferObjectKind, TransferTableNameCase } from "@/lib/backend/tauri"; +import type { SqlFormatDialect } from "@/lib/sql/sqlFormatter"; import type { MultiDbExecutionTarget, MultiDbResultRunExecution } from "@/types/sqlExecution"; import type { DatabaseType } from "@/types/generated/databaseTypes"; @@ -1977,6 +1978,25 @@ export interface QueryTab { }; /** Opened to view object source, including objects without editable source metadata. */ sourceView?: boolean; + ddlViewer?: { + schema?: string; + tableName: string; + objectType?: ObjectSourceKind; + formatDialect?: SqlFormatDialect; + }; + /** + * 「先出 tab 再加载」的中间态:DDL 新标签已经可见,但 DDL 还在路上 + * (issue #9387)。让 tab 栏与编辑区在等待期间就有反馈,失败时就地显示 + * 错误 + Retry,而不是加载完成后才建 tab、失败只弹 toast。 + * + * 纯运行期字段,刻意不进 openTabsPersistence 的落盘白名单:重启后恢复出的 + * tab 直接使用落盘的 SQL 文本,不会永久停在「加载中」。 + */ + ddlLoad?: { + startedAt: number; + /** 加载失败时写入;保留状态以便就地重试 */ + error?: string; + }; objectSource?: { schema?: string; name: string;