Files
dbx/apps/desktop/src/components/objects/DdlViewDialog.vue
T

348 lines
14 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, onUnmounted, ref, shallowRef, watch } from "vue";
import { useI18n } from "vue-i18n";
import { Clipboard, Loader2, RefreshCw } from "@lucide/vue";
import { useToast } from "@/composables/useToast";
import { useTheme } from "@/composables/useTheme";
import { useSettingsStore } from "@/stores/settingsStore";
import { loadEditorTheme, editorFontTheme } from "@/lib/editor/editorThemes";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { copyToClipboard } from "@/lib/common/clipboard";
import { formatSqlForDisplay, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
import { loadObjectDdl } from "@/lib/metadata/objectDdlCache";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Switch } from "@/components/ui/switch";
import { HelpTooltip } from "@/components/ui/tooltip";
import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue";
import type { EditorView } from "@codemirror/view";
import type { DatabaseType, ObjectSourceKind } from "@/types/database";
const props = withDefaults(
defineProps<{
open: boolean;
connectionId: string;
database: string;
catalog?: string;
schema?: string;
tableName: string;
objectType?: ObjectSourceKind;
/** Effective database type selects database-specific syntax rules; older callers can still rely on the dialect fallback. */
databaseType?: DatabaseType;
/** SQL dialect fallback for syntax highlighting when the effective database type is unavailable. */
dialect: "mysql" | "postgres" | "sqlserver";
/** SQL formatter dialect. Kept separate from the syntax-highlighting dialect because several PG-compatible DBs highlight as MySQL. */
formatDialect?: SqlFormatDialect;
}>(),
{},
);
const emit = defineEmits<{
"update:open": [value: boolean];
}>();
const { t } = useI18n();
const { toast } = useToast();
const { isDark, themePalette } = useTheme();
const settingsStore = useSettingsStore();
const ddlContent = ref("");
const ddlLoading = ref(false);
const ddlError = ref("");
const ddlEditorContainer = ref<HTMLDivElement>();
const ddlSearchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
const ddlEditorView = shallowRef<EditorView | 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.
const dragOffset = ref({ x: 0, y: 0 });
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 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 {};
});
// The CodeMirror editor (if any) that was focused when this dialog opened, so focus can be
// restored to it on close. Not a ref: read/written outside of render, never needs reactivity.
let editorRootToRestoreFocus: HTMLElement | null = null;
function resetDialogDragOffset() {
dragOffset.value = { x: 0, y: 0 };
isDragging.value = false;
activePointerId.value = null;
}
function startDialogDrag(event: PointerEvent) {
if (event.button !== undefined && event.button !== 0) return;
isDragging.value = true;
activePointerId.value = event.pointerId;
dragStartPosition.value = { x: event.clientX, y: event.clientY };
dragStartOffset.value = { ...dragOffset.value };
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function moveDialogDrag(event: PointerEvent) {
if (!isDragging.value || event.pointerId !== activePointerId.value) return;
dragOffset.value = {
x: dragStartOffset.value.x + event.clientX - dragStartPosition.value.x,
y: dragStartOffset.value.y + event.clientY - dragStartPosition.value.y,
};
}
function endDialogDrag(event: PointerEvent) {
if (!isDragging.value || event.pointerId !== activePointerId.value) return;
isDragging.value = false;
activePointerId.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;
if (force) destroyDdlEditor();
try {
const schema = props.schema || props.database;
const { ddl } = await loadObjectDdl(
{
connectionId: props.connectionId,
database: props.database,
schema,
tableName: props.tableName,
objectType: props.objectType,
catalog: props.catalog,
},
{ force },
);
ddlContent.value = await formatSqlForDisplay(ddl, props.formatDialect ?? props.dialect, settingsStore.editorSettings.sqlFormatter);
} catch (e: any) {
ddlError.value = e?.message || String(e);
} finally {
ddlLoading.value = false;
}
}
/** Loads from the persisted snapshot by default; users can opt into a fresh database query on every open. */
watch(
() => props.open,
async (open) => {
resetDialogDragOffset();
if (!open) return;
const active = document.activeElement;
editorRootToRestoreFocus = active instanceof HTMLElement ? active.closest(".cm-editor") : null;
ddlContent.value = "";
await loadDdl(settingsStore.editorSettings.refreshDdlOnOpen);
},
{ immediate: true },
);
/**
* Restores focus through CodeMirror's own `EditorView.focus()` instead of the browser default.
*
* Radix's default close-auto-focus calls the plain DOM `.focus()` on whatever was focused before
* the dialog opened. In WebKit (the desktop app's webview on macOS), refocusing a contenteditable
* this way resets its caret to the very start of the document; CodeMirror then treats that as a
* real selection change and scrolls to follow it, snapping a long query editor to the top (#6067).
* `EditorView.focus()` avoids this by suppressing its own selection observer while it restores the
* DOM selection to match its actual (unmoved) internal state.
*/
function onDdlDialogCloseAutoFocus(event: Event) {
const target = editorRootToRestoreFocus;
editorRootToRestoreFocus = null;
if (!target || !target.isConnected) return;
event.preventDefault();
void import("@codemirror/view").then(({ EditorView }) => {
EditorView.findFromDOM(target)?.focus();
});
}
/**
* Creates a lightweight read-only CodeMirror editor inside the dialog.
*
* Follows the same pattern as QueryEditor:
* - cmSearch with hidden createPanel replaces the default search UI,
* so EditorSearchPanel is the only visible search panel.
* - Prec.highest overrides Cmd+F to open EditorSearchPanel.
* - Editor theme/font are loaded from user settings for consistent appearance.
*/
async function initDdlEditor(content: string) {
if (!ddlEditorContainer.value) return;
destroyDdlEditor();
const [{ EditorView, keymap }, { EditorState, Prec }, langSql, { basicSetup }, { search: cmSearch }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror"), import("@codemirror/search")]);
const editorTheme = settingsStore.editorSettings.theme;
const appAppearance = isDark.value ? "dark" : "light";
const fontSize = settingsStore.editorSettings.fontSize;
const fontFamily = settingsStore.editorSettings.fontFamily;
const themeExt = await loadEditorTheme(editorTheme, appAppearance, undefined, themePalette.value);
const fontExt = editorFontTheme(EditorView, fontSize, fontFamily, { fixedHeight: true, scrollable: true });
const dialect = createDbxCodeMirrorSqlDialect(langSql, props.dialect, props.databaseType);
const state = EditorState.create({
doc: content,
extensions: [
// Enable search functionality but hide the default panel —
// EditorSearchPanel provides the visible search UI instead, same as QueryEditor.
cmSearch({
top: true,
createPanel: () => {
const dom = document.createElement("span");
dom.style.display = "none";
return { dom };
},
}),
basicSetup,
EditorState.allowMultipleSelections.of(true),
langSql.sql({ dialect }),
themeExt,
fontExt,
// Intercept Cmd+F at highest precedence so EditorSearchPanel opens
// instead of the default search panel (which is hidden above).
Prec.highest(keymap.of([{ key: "Mod-f", run: () => ddlSearchPanelRef.value?.openSearch() ?? false, preventDefault: true }])),
// Remove CodeMirror's default 1px dotted focus outline,
// which is visible below the content when the DDL is short.
EditorView.theme({
"&.cm-focused": { outline: "none" },
".cm-content": {
cursor: "text",
userSelect: "text",
WebkitUserSelect: "text",
},
".cm-line": {
userSelect: "text",
WebkitUserSelect: "text",
},
}),
EditorState.readOnly.of(true),
],
});
const editorView = new EditorView({ state, parent: ddlEditorContainer.value });
ddlEditorView.value = editorView;
editorView.focus();
}
/** Tears down the CodeMirror instance when the dialog closes. */
function destroyDdlEditor() {
ddlEditorView.value?.destroy();
ddlEditorView.value = null;
}
/** Copies the DDL text to clipboard. */
function copyDdlContent() {
if (ddlContent.value) {
copyToClipboard(ddlContent.value);
toast(t("contextMenu.ddlCopied"), 2000);
}
}
// When DDL finishes loading, create the editor inside the dialog.
watch(ddlLoading, (loading) => {
if (!loading && ddlContent.value && props.open) {
nextTick(() => initDdlEditor(ddlContent.value));
}
});
// Destroy CodeMirror when the dialog hides, so the editor's event listeners
// and DOM aren't consuming resources while the dialog is closed.
// The editor is re-created on next open via the ddlLoading watch.
watch(
() => props.open,
(open) => {
if (!open) destroyDdlEditor();
},
);
// Safety net: destroy editor when component eventually unmounts.
onUnmounted(() => {
destroyDdlEditor();
});
function retry() {
ddlContent.value = "";
void loadDdl(true);
}
function setRefreshDdlOnOpen(value: boolean) {
settingsStore.updateEditorSettings({ refreshDdlOnOpen: value });
if (value) void loadDdl(true);
}
function onClose() {
emit("update:open", false);
}
</script>
<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">
<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">
<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">
<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 ref="ddlEditorContainer" class="h-full" />
<EditorSearchPanel v-if="ddlEditorView" ref="ddlSearchPanelRef" :view="ddlEditorView" />
</div>
</div>
<DialogFooter>
<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">
<label for="ddl-refresh-on-open" class="cursor-pointer">{{ t("contextMenu.refreshDdlOnOpen") }}</label>
<HelpTooltip :label="t('contextMenu.refreshDdlOnOpenHint')" trigger-class="[&_svg]:h-3 [&_svg]:w-3">
{{ t("contextMenu.refreshDdlOnOpenHint") }}
</HelpTooltip>
</div>
</div>
<Button variant="outline" @click="onClose">{{ t("common.close") }}</Button>
<Button variant="outline" :disabled="ddlLoading" :title="t('structureEditor.refresh')" @click="loadDdl(true)">
<RefreshCw class="h-4 w-4" />
{{ t("structureEditor.refresh") }}
</Button>
<Button variant="outline" :disabled="!ddlContent" @click="copyDdlContent">
<Clipboard class="h-4 w-4" />
{{ t("grid.copyDdl") }}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</template>
<style scoped>
.ddl-view-editor :deep(.cm-content),
.ddl-view-editor :deep(.cm-line) {
cursor: text;
user-select: text !important;
-webkit-user-select: text !important;
}
.ddl-view-editor :deep(.cm-selectionBackground),
.ddl-view-editor :deep(.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)) !important;
}
.ddl-view-editor :deep(.cm-content ::selection) {
background: var(--dbx-editor-selection-background, rgba(59, 130, 246, 0.35)) !important;
}
</style>