feat(ddl): formatted DDL display with dialog or tab mode
This commit is contained in:
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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "自動括弧補完",
|
||||
|
||||
@@ -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: "괄호 자동 닫기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: "自动成对补全",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user