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

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