feat(ddl): formatted DDL display with dialog or tab mode

This commit is contained in:
_Mr丶s ゛😁® authored and GitHub committed 2026-09-24 12:27:15 +08:00
1 parent 86bdf9f78a
commit 76e41a6a4d
30 files changed
+956 -51

No files matched your search

@@ -629,6 +629,7 @@ const completionTriggerModeDescription = computed(() => {
});
const editWordWrap = ref(settingsStore.editorSettings.wordWrap);
const editShowWhitespace = ref(settingsStore.editorSettings.showWhitespace);
const editDdlOpenMode = ref<EditorSettings["ddlOpenMode"]>(settingsStore.editorSettings.ddlOpenMode);
const editVimModeEnabled = ref(settingsStore.editorSettings.vimModeEnabled);
const editAutoCloseBrackets = ref(settingsStore.editorSettings.autoCloseBrackets);
const editSqlSemanticDiagnosticsMode = ref<SqlSemanticDiagnosticsMode>(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(() => {
<Switch id="editor-word-wrap" v-model="editWordWrap" class="mt-0.5" />
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-ddl-open-mode">{{ t("settings.ddlOpenMode") }}</Label>
<p class="text-xs text-muted-foreground">{{ t("settings.ddlOpenModeDescription") }}</p>
</div>
<Select :model-value="editDdlOpenMode" @update:model-value="setDdlOpenMode">
<SelectTrigger id="editor-ddl-open-mode" class="h-8 w-36 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dialog">{{ t("settings.ddlOpenModeDialog") }}</SelectItem>
<SelectItem value="tab">{{ t("settings.ddlOpenModeTab") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="settings-item flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-1">
<Label for="editor-vim-mode">{{ t("settings.vimMode") }}</Label>
@@ -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({
<Splitpanes horizontal class="query-output-splitpanes flex-1 min-h-0 overflow-hidden" @resize="onResultsSplitResize" @resized="onResultsResized">
<Pane v-if="!resultOnly" class="min-h-0" :size="editorPaneSize" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<div v-if="activeTab.ddlViewer" class="flex h-10 shrink-0 items-center gap-2 border-b px-3">
<Button variant="outline" size="sm" :disabled="ddlRefreshInProgress" @click="refreshDdlViewer">
<RefreshCcw class="h-4 w-4" :class="ddlRefreshInProgress ? 'animate-spin' : ''" />
{{ t("structureEditor.refresh") }}
</Button>
<Button variant="outline" size="sm" @click="viewDdlTableData">
<TableProperties class="h-4 w-4" />
{{ t("contextMenu.viewData") }}
</Button>
</div>
<div v-if="activeProductionContext.active" class="production-watermark pointer-events-none absolute inset-0 z-10 grid select-none" aria-hidden="true">
<span v-for="index in 4" :key="index" class="production-watermark__label whitespace-nowrap font-mono text-6xl font-extrabold text-red-700/[0.12] dark:text-red-200/[0.1]">{{ productionWatermarkText }}</span>
</div>
<!-- issue #9035:源码 tab 先出现再加载。pending 期间不挂载编辑器
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。 -->
(还没有内容可编辑,也省下一次 Monaco 初始化),失败则就地重试。
issue #9387:DDL 新标签同样先出 tab 再加载,失败就地显示错误。 -->
<QueryLoadingState v-if="activeTab.sourceLoad && !activeTab.sourceLoad.error" class="relative z-0 flex-1" :elapsed-seconds="sourceLoadElapsedSeconds" />
<div v-else-if="activeTab.sourceLoad?.error" class="relative z-0 flex flex-1 min-h-0 flex-col items-center justify-center gap-3 px-6 text-sm" data-object-source-load-error>
<p class="max-w-[80%] text-center break-words text-destructive">{{ activeTab.sourceLoad.error }}</p>
@@ -1516,6 +1614,14 @@ defineExpose({
{{ t("common.retry") }}
</Button>
</div>
<QueryLoadingState v-else-if="activeTab.ddlLoad && !activeTab.ddlLoad.error" class="relative z-0 flex-1" :elapsed-seconds="ddlLoadElapsedSeconds" />
<div v-else-if="activeTab.ddlLoad?.error" class="relative z-0 flex flex-1 min-h-0 flex-col items-center justify-center gap-3 px-6 text-sm" data-ddl-load-error>
<p class="max-w-[80%] text-center break-words text-destructive">{{ t("contextMenu.ddlRefreshFailed", { message: activeTab.ddlLoad.error }) }}</p>
<Button variant="outline" size="sm" class="gap-1.5" @click="queryStore.retryDdlViewerTab(activeTab.id)">
<RotateCcw class="h-4 w-4" />
{{ t("common.retry") }}
</Button>
</div>
<QueryEditor
v-else
ref="queryEditorRef"
@@ -1546,6 +1652,8 @@ defineExpose({
:initial-selection="activeTab.editorSelection"
:reveal-request="activeTab.editorRevealRequest"
:force-word-wrap="activeTab.forceWordWrap"
:read-only="!!activeTab.ddlViewer"
:hide-execution-controls="!!activeTab.ddlViewer"
enable-explain-shortcut
:can-explain="!activeTab.isExecuting && !activeTab.isExplaining && !!executableSql.trim()"
@update:model-value="emit('editorUpdate', activeTab.id, $event)"
@@ -111,7 +111,7 @@ const groupTabs = computed(() => {
});
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.
@@ -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<HTMLDivElement>();
const ddlSearchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const ddlEditorView = shallowRef<EditorView | null>(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<number | null>(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<number | null>(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<HTMLElement>("[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() {
<template>
<Dialog :open="props.open" @update:open="onClose">
<DialogContent :style="dialogContentStyle" class="dbx-ddl-view-dialog sm:max-w-190" @close-auto-focus="onDdlDialogCloseAutoFocus">
<DialogHeader class="cursor-move select-none" @pointerdown="startDialogDrag" @pointermove="moveDialogDrag" @pointerup="endDialogDrag" @pointercancel="endDialogDrag">
<DialogContent :style="dialogContentStyle" class="dbx-ddl-view-dialog flex min-h-0 flex-col overflow-hidden sm:max-w-190" @close-auto-focus="onDdlDialogCloseAutoFocus">
<DialogHeader class="shrink-0 cursor-move select-none" @pointerdown="startDialogDrag" @pointermove="moveDialogDrag" @pointerup="endDialogDrag" @pointercancel="endDialogDrag">
<DialogTitle>DDL - {{ props.tableName }}</DialogTitle>
</DialogHeader>
<div class="grid gap-3">
<div v-if="ddlLoading" class="flex min-h-80 items-center justify-center gap-2 text-sm text-muted-foreground">
<div class="flex min-h-0 flex-1 flex-col gap-3">
<div v-if="!ddlLoading && !ddlError && ddlContent" class="flex shrink-0 items-center justify-between gap-3">
<span class="text-sm text-muted-foreground">{{ t("contextMenu.ddlDisplayMode") }}</span>
<div class="flex items-center gap-1" role="group" :aria-label="t('contextMenu.ddlDisplayMode')">
<Button variant="outline" size="sm" :class="ddlDisplayMode === 'formatted' ? 'border-primary bg-accent' : ''" :aria-pressed="ddlDisplayMode === 'formatted'" @click="ddlDisplayMode = 'formatted'">
{{ t("contextMenu.ddlDisplayFormatted") }}
</Button>
<Button variant="outline" size="sm" :class="ddlDisplayMode === 'original' ? 'border-primary bg-accent' : ''" :aria-pressed="ddlDisplayMode === 'original'" @click="ddlDisplayMode = 'original'">
{{ t("contextMenu.ddlDisplayOriginal") }}
</Button>
</div>
</div>
<div v-if="ddlLoading" class="flex min-h-80 flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 class="h-4 w-4 animate-spin" />
<span>{{ t("contextMenu.viewDdlLoading") }}</span>
</div>
<div v-else-if="ddlError" class="flex min-h-80 flex-col items-center justify-center gap-3 text-sm">
<div v-else-if="ddlError" class="flex min-h-80 flex-1 flex-col items-center justify-center gap-3 text-sm">
<p class="text-destructive">{{ ddlError }}</p>
<Button variant="outline" size="sm" @click="retry">
<RefreshCw />
{{ t("common.retry") }}
</Button>
</div>
<div v-else class="ddl-view-editor relative min-h-80 max-h-[60vh] overflow-hidden rounded border">
<div v-else class="ddl-view-editor relative min-h-0 flex-1 overflow-hidden rounded border">
<div ref="ddlEditorContainer" class="h-full" />
<EditorSearchPanel v-if="ddlEditorView" ref="ddlSearchPanelRef" :view="ddlEditorView" />
</div>
</div>
<DdlStorageToggle :database-type="props.databaseType" :disabled="ddlLoading || !!ddlError" />
<DialogFooter>
<DialogFooter class="shrink-0 pr-7">
<div class="mr-auto flex items-center gap-2 text-sm text-muted-foreground">
<Switch id="ddl-refresh-on-open" size="sm" :model-value="settingsStore.editorSettings.refreshDdlOnOpen" @update:model-value="setRefreshDdlOnOpen" />
<div class="flex items-center gap-1">
@@ -328,8 +465,22 @@ function onClose() {
{{ t("contextMenu.refreshDdlOnOpenHint") }}
</HelpTooltip>
</div>
<span class="ml-3">{{ t("contextMenu.ddlOpenMode") }}</span>
<Select :model-value="settingsStore.editorSettings.ddlOpenMode" @update:model-value="setDdlOpenMode">
<SelectTrigger class="h-8 w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dialog">{{ t("contextMenu.ddlOpenModeDialog") }}</SelectItem>
<SelectItem value="tab">{{ t("contextMenu.ddlOpenModeTab") }}</SelectItem>
</SelectContent>
</Select>
</div>
<Button variant="outline" @click="onClose">{{ t("common.close") }}</Button>
<Button variant="outline" :disabled="ddlLoading || !ddlContent" :title="t('contextMenu.openDdlInNewTab')" @click="openDdlInNewTab">
<ExternalLink class="h-4 w-4" />
{{ t("contextMenu.openDdlInNewTab") }}
</Button>
<Button variant="outline" :disabled="ddlLoading" :title="t('structureEditor.refresh')" @click="loadDdl(true)">
<RefreshCw class="h-4 w-4" />
{{ t("structureEditor.refresh") }}
@@ -339,6 +490,17 @@ function onClose() {
{{ t("grid.copyDdl") }}
</Button>
</DialogFooter>
<button
type="button"
class="absolute bottom-1 right-1 z-20 grid size-6 touch-none cursor-nwse-resize place-items-center rounded text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
:aria-label="t('contextMenu.resizeDdlDialog')"
@pointerdown="startDialogResize"
@pointermove="moveDialogResize"
@pointerup="endDialogResize"
@pointercancel="endDialogResize"
>
<span aria-hidden="true" class="h-3 w-3 border-b-2 border-r-2 border-current" />
</button>
</DialogContent>
</Dialog>
</template>
@@ -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"');
});
});
@@ -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<string> {
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 {
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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: "自動括弧補完",
+13
View File
@@ -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: "괄호 자동 닫기",
+13
View File
@@ -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",
+13
View File
@@ -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",
+13
View File
@@ -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: "自动成对补全",
+13
View File
@@ -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: "自動成對補全",
@@ -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",
+67
View File
@@ -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);
});
});
@@ -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 } : {}),
@@ -33,6 +33,7 @@ function makeSettings(overrides: Partial<EditorSettings> = {}): 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" });
@@ -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();
});
@@ -31,6 +31,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"selectFirstCompletionOnOpen",
"wordWrap",
"showWhitespace",
"ddlOpenMode",
"vimModeEnabled",
"autoCloseBrackets",
"sqlSemanticDiagnosticsMode",
@@ -102,6 +102,7 @@ const SETTINGS_TRANSFER_CATEGORY_KEYS: Record<SettingsTransferCategoryId, readon
"generateSqlIncludeDatabaseName",
"generateSqlQuoteIdentifiers",
"formatSqlOnSqlFileSave",
"ddlOpenMode",
"showTableDdlHoverPreview",
"tableHoverLookupMode",
"sqlVariableSubstitutionEnabled",
+223 -1
View File
@@ -1,11 +1,30 @@
import { requiresDamengIdentifierQuote, requiresMysqlIdentifierQuote, requiresOracleIdentifierQuote, requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
import { tokenIsIdentifier, tokenizeSqlSemantic, unquoteSqlSemanticIdentifier } from "@/lib/sql/semantic/tokens";
import type { SqlSemanticToken } from "@/lib/sql/semantic/types";
import { sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { formatSqlForDisplay, sqlFormatDialectForDbType, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { DEFAULT_SQL_FORMATTER_SETTINGS, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
import { applyDdlStoragePreference } from "@/lib/sql/ddlStorage";
import { dropsSchemaQualifier, quoteTableIdentifier } from "@/lib/table/tableSelectSql";
import type { DatabaseType } from "@/types/database";
const SIMPLE_SQLSERVER_IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
const CREATE_TABLE_MODIFIERS = new Set(["external", "global", "local", "materialized", "or", "replace", "temp", "temporary", "transient", "unlogged", "volatile"]);
const TABLE_CONSTRAINT_NAMES = new Set(["check", "constraint", "exclude", "foreign", "fulltext", "index", "key", "period", "primary", "spatial", "unique"]);
type DdlColumnAlignment = {
lineIndex: number;
nameEnd: number;
};
type DdlColumnAttributePositions = {
nullable?: number;
default?: number;
identity?: number;
onUpdate?: number;
unique?: number;
references?: number;
comment?: number;
};
function canRenderUnquoted(identifier: string, dialect: SqlFormatDialect): boolean {
switch (dialect) {
@@ -361,3 +380,206 @@ export function applyDdlDatabaseQualifier(sql: string, dialect: SqlFormatDialect
return applyDdlSpans(sql, removals);
}
function findLineIndex(lineStarts: number[], position: number): number {
for (let index = lineStarts.length - 1; index >= 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<keyof DdlColumnAttributePositions> = 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<SqlFormatterSettings> = DEFAULT_SQL_FORMATTER_SETTINGS): Promise<string> {
return applyDdlDisplayPreferences(await formatSqlForDisplay(ddl, options.dialect, formatter), options);
}
@@ -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);
+65
View File
@@ -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,
+4
View File
@@ -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<EditorSettings>, 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;
@@ -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");
+4
View File
@@ -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;
}
+20
View File
@@ -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;