Files
dbx/apps/desktop/src/components/layout/ContentArea.vue
T
t8y2 025b465bf1 fix(editor): preserve scroll position after SQL execution
After DDL/DML execution via the danger confirmation dialog, the editor
scrolls to the top, making it hard to locate the executed statement.
Fix by scrolling the cursor back into view after execution completes,
using nextTick + requestAnimationFrame to wait for Splitpanes layout
stabilization.
2026-05-28 21:36:29 +08:00

721 lines
28 KiB
Vue

<script setup lang="ts">
import { computed, ref, defineAsyncComponent, watch, nextTick } from "vue";
import { useI18n } from "vue-i18n";
import {
Check,
Columns3,
Loader2,
Search,
Square,
Bot,
Table2,
GitBranch,
BarChart3,
TableProperties,
ChevronDown,
ChevronUp,
} from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.vue";
const DataGrid = defineAsyncComponent(() => import("@/components/grid/DataGrid.vue"));
const RedisKeyBrowser = defineAsyncComponent(() => import("@/components/redis/RedisKeyBrowser.vue"));
const MongoDocBrowser = defineAsyncComponent(() => import("@/components/mongo/MongoDocBrowser.vue"));
const ObjectBrowser = defineAsyncComponent(() => import("@/components/objects/ObjectBrowser.vue"));
const TableStructureEditor = defineAsyncComponent(() => import("@/components/structure/TableStructureEditor.vue"));
const ExplainPlanViewer = defineAsyncComponent(() => import("@/components/explain/ExplainPlanViewer.vue"));
const QueryChart = defineAsyncComponent(() => import("@/components/chart/QueryChart.vue"));
import { useQueryStore } from "@/stores/queryStore";
import { canCancelQueryExecution, queryExecutionLabelKey } from "@/lib/queryExecutionState";
import { databaseDisplayNameForTab } from "@/lib/tabPresentation";
import { isTableDataEditable } from "@/lib/tableEditing";
import type { QueryTab, ConnectionConfig } from "@/types/database";
import type { SqlFormatDialect } from "@/lib/sqlFormatter";
type DataGridHandle = {
onToolbarRefresh: () => Promise<void> | void;
focusSearch: () => boolean;
openCellDetailSearch: () => boolean;
visibleColumnCount: number;
displayableColumnCount: number;
hiddenColumnCount: number;
filteredColumnVisibilityOptions: (search: string) => Array<{ index: number; column: string }>;
isColumnVisible: (columnIndex: number) => boolean;
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => void;
invertColumnVisibility: () => void;
showDdl: boolean;
toggleDdl: () => void;
};
type SearchableBrowserHandle = {
focusSearch: () => boolean;
};
const props = defineProps<{
activeTab: QueryTab;
activeConnection?: ConnectionConfig;
executableSql: string;
activeOutputView: "result" | "explain" | "chart";
formatSqlRequestId: number;
selectedSql: string;
cursorPos: number;
}>();
const emit = defineEmits<{
"update:activeOutputView": [value: "result" | "explain" | "chart"];
fixWithAi: [errorMessage: string];
execute: [sqlOverride?: string];
saveSql: [];
cancel: [];
explain: [];
editorUpdate: [value: string];
editorSelectionChange: [value: string];
editorCursorChange: [pos: number];
formatError: [];
reload: [sql?: string, searchText?: string, whereInput?: string, orderBy?: string, limit?: number, offset?: number];
paginate: [offset: number, limit: number, whereInput?: string, orderBy?: string];
sort: [column: string, columnIndex: number, direction: "asc" | "desc" | null, whereInput?: string];
executeSql: [sql: string];
clickTable: [tableName: string];
openObjectTable: [target: { tableName: string; schema?: string }];
objectSchemaChange: [schema: string | undefined];
structureEditorSaved: [commentChanged: boolean];
structureEditorClose: [];
}>();
const { t } = useI18n();
const queryStore = useQueryStore();
// Column info panel state
const showColumnInfo = ref(false);
const columnInfoColumns = ref<ColumnInfo[]>([]);
const columnInfoLoading = ref(false);
const columnInfoError = ref<string | undefined>(undefined);
const dataGridRef = ref<DataGridHandle>();
const queryEditorRef = ref<InstanceType<typeof QueryEditor>>();
const columnVisibilitySearch = ref("");
const columnVisibilityOptions = computed(
() => dataGridRef.value?.filteredColumnVisibilityOptions(columnVisibilitySearch.value) ?? [],
);
const redisKeyBrowserRef = ref<SearchableBrowserHandle>();
const objectBrowserRef = ref<SearchableBrowserHandle>();
const activeSqlFormatDialect = computed<SqlFormatDialect>(() => {
switch (props.activeConnection?.db_type) {
case "mysql":
return "mysql";
case "postgres":
return "postgres";
case "sqlite":
return "sqlite";
case "sqlserver":
return "sqlserver";
default:
return "generic";
}
});
const editorDialect = computed<"mysql" | "postgres" | "sqlserver">(() => {
if (props.activeConnection?.db_type === "postgres") return "postgres";
if (props.activeConnection?.db_type === "sqlserver") return "sqlserver";
return "mysql";
});
const shortcutModifier = computed(() => (navigator.platform.toLowerCase().includes("mac") ? "Cmd" : "Ctrl"));
const hasNumericData = computed(() => {
const r = props.activeTab.result;
if (!r || r.rows.length === 0) return false;
return r.columns.some((_, idx) => r.rows.some((row) => typeof row[idx] === "number"));
});
const activeQueryError = computed(() => {
const result = props.activeTab.result;
if (!result?.columns.includes("Error")) return "";
return String(result.rows[0]?.[0] ?? "");
});
const hasQueryOutput = computed(
() =>
!!props.activeTab.result ||
!!props.activeTab.explainPlan ||
!!props.activeTab.explainError ||
props.activeTab.isExecuting === true ||
props.activeTab.isExplaining === true,
);
const resultsPaneOpen = ref(false);
watch(
hasQueryOutput,
(hasOutput) => {
resultsPaneOpen.value = hasOutput ? true : false;
},
{ immediate: true },
);
watch(
() => props.activeTab.id,
() => {
resultsPaneOpen.value = hasQueryOutput.value;
},
);
watch(
() => [props.activeTab.isExecuting, props.activeTab.isExplaining],
([isExecuting, isExplaining]) => {
if (isExecuting || isExplaining) resultsPaneOpen.value = true;
},
);
watch(
() => props.activeTab.isExecuting,
(isExecuting, wasExecuting) => {
if (!isExecuting && wasExecuting) {
nextTick(() => {
requestAnimationFrame(() => {
queryEditorRef.value?.scrollCursorIntoView();
});
});
}
},
);
// Column info panel handlers
async function onHandleClickColumn(
matchedCols: Array<{ name: string; table: string; schema?: string }>,
errorMsg?: string,
) {
if (!props.activeTab.connectionId || !props.activeTab.database) return;
// If error or no columns, silently ignore — don't show the panel
if (errorMsg || matchedCols.length === 0) return;
columnInfoLoading.value = true;
columnInfoError.value = undefined;
try {
// Fetch full column details from API
const apiModule = await import("@/lib/api");
const results: ColumnInfo[] = [];
for (const matchedCol of matchedCols) {
const querySchema = matchedCol.schema || props.activeTab.database || "";
try {
const fullColumns = await apiModule.getColumns(
props.activeTab.connectionId,
props.activeTab.database,
querySchema,
matchedCol.table,
);
for (const col of fullColumns) {
if (col.name === matchedCol.name) {
results.push({
name: col.name,
table: matchedCol.table,
dataType: col.data_type,
isNullable: col.is_nullable,
columnDefault: col.column_default,
isPrimaryKey: col.is_primary_key,
comment: col.comment,
extra: col.extra,
});
}
}
} catch {
// Skip tables that fail
}
}
columnInfoColumns.value = results;
} catch (e: any) {
// Silently ignore errors
console.error("[DBX] Failed to fetch column info:", e);
return;
} finally {
columnInfoLoading.value = false;
showColumnInfo.value = true;
}
}
function closeColumnInfo() {
showColumnInfo.value = false;
columnInfoColumns.value = [];
columnInfoError.value = undefined;
}
function onHandleClickTable(tableName: string) {
emit("clickTable", tableName);
}
function onHandleCloseColumnPanel() {
showColumnInfo.value = false;
columnInfoColumns.value = [];
columnInfoError.value = undefined;
}
function focusSearch(): boolean {
if (props.activeTab.mode === "redis") return redisKeyBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "objects") return objectBrowserRef.value?.focusSearch() ?? false;
if (props.activeTab.mode === "query") return queryEditorRef.value?.openSearch() ?? false;
return dataGridRef.value?.focusSearch() ?? false;
}
function refreshData(): boolean {
if (!dataGridRef.value) return false;
void dataGridRef.value.onToolbarRefresh();
return true;
}
function handleModRTarget(target: Element): boolean {
if (target.closest("[data-query-editor-root]")) return queryEditorRef.value?.openReplace() ?? false;
if (target.closest("[data-cell-detail-editor-root]")) return dataGridRef.value?.openCellDetailSearch() ?? false;
if (target.closest("[data-grid-root]")) return refreshData();
return false;
}
defineExpose({ focusSearch, refreshData, handleModRTarget });
</script>
<template>
<div class="flex flex-col flex-1 min-h-0">
<!-- Query mode: editor + results -->
<template v-if="activeTab.mode === 'query'">
<Splitpanes horizontal class="flex-1">
<Pane :size="resultsPaneOpen ? 40 : 100" :min-size="resultsPaneOpen ? 15 : 100">
<div class="h-full flex flex-col relative">
<QueryEditor
ref="queryEditorRef"
class="flex-1"
:model-value="activeTab.sql"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:dialect="editorDialect"
:format-dialect="activeSqlFormatDialect"
:format-request-id="formatSqlRequestId"
:execution-error="activeQueryError"
@update:model-value="emit('editorUpdate', $event)"
@selection-change="emit('editorSelectionChange', $event)"
@cursor-change="emit('editorCursorChange', $event)"
@format-error="emit('formatError')"
@execute="emit('execute')"
@save="emit('saveSql')"
@click-table="onHandleClickTable"
@click-column="onHandleClickColumn"
@close-column-panel="onHandleCloseColumnPanel"
/>
<ColumnInfoPanel
v-if="showColumnInfo"
:columns="columnInfoColumns"
:loading="columnInfoLoading"
:error="columnInfoError"
@close="closeColumnInfo"
/>
<Button
v-if="hasQueryOutput && !resultsPaneOpen"
variant="secondary"
size="sm"
class="absolute bottom-3 right-3 z-20 h-7 gap-1.5 rounded-full border bg-background/95 px-3 text-xs shadow-lg backdrop-blur hover:bg-accent"
@click="resultsPaneOpen = true"
>
<ChevronUp class="h-3.5 w-3.5" />
{{ t("editor.showResultsPane") }}
</Button>
</div>
</Pane>
<Pane v-if="resultsPaneOpen" :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div
v-if="hasQueryOutput"
class="h-8 shrink-0 border-b bg-muted/20 px-2 flex items-center gap-1 overflow-x-auto"
style="scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch"
>
<Button
size="sm"
:variant="activeOutputView === 'result' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs"
:disabled="!activeTab.result && !activeTab.isExecuting"
@click="emit('update:activeOutputView', 'result')"
>
{{ t("tabs.tableData") }}
</Button>
<template v-if="activeOutputView === 'result' && activeTab.results && activeTab.results.length > 1">
<span class="mx-1 h-4 w-px bg-border" />
<Button
v-for="(_, rIdx) in activeTab.results"
:key="rIdx"
size="sm"
:variant="activeTab.activeResultIndex === rIdx ? 'default' : 'ghost'"
class="h-6 px-2 text-xs shrink-0"
@click="queryStore.setActiveResultIndex(activeTab.id, rIdx)"
>
{{ t("tabs.resultN", { n: rIdx + 1 }) }}
</Button>
</template>
<Button
size="sm"
:variant="activeOutputView === 'explain' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs gap-1"
:disabled="!activeTab.explainPlan && !activeTab.explainError && !activeTab.isExplaining"
@click="emit('update:activeOutputView', 'explain')"
>
<GitBranch class="h-3.5 w-3.5" />
{{ t("explain.title") }}
</Button>
<Button
size="sm"
:variant="activeOutputView === 'chart' ? 'secondary' : 'ghost'"
class="h-6 px-2 text-xs gap-1"
:disabled="!hasNumericData"
@click="emit('update:activeOutputView', 'chart')"
>
<BarChart3 class="h-3.5 w-3.5" />
{{ t("chart.title") }}
</Button>
<Button
variant="ghost"
size="sm"
class="ml-auto h-6 shrink-0 gap-1 px-2 text-xs text-muted-foreground hover:text-foreground"
@click="resultsPaneOpen = false"
>
<ChevronDown class="h-3.5 w-3.5" />
{{ t("editor.hideResultsPane") }}
</Button>
</div>
<ExplainPlanViewer
v-if="activeOutputView === 'explain'"
class="flex-1 min-h-0"
:plan="activeTab.explainPlan"
:error="activeTab.explainError"
:loading="activeTab.isExplaining"
:source-sql="activeTab.lastExplainedSql"
:explain-sql="activeTab.explainSql"
/>
<QueryChart
v-else-if="activeOutputView === 'chart' && activeTab.result"
class="flex-1 min-h-0"
:result="activeTab.result"
/>
<template v-else>
<DataGrid
v-if="activeTab.result"
ref="dataGridRef"
:key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`"
:cache-key="`${activeTab.id}-${activeTab.activeResultIndex ?? 0}`"
class="flex-1 min-h-0"
:result="activeTab.result"
:sql="activeTab.lastExecutedSql || activeTab.sql"
:loading="activeTab.isExecuting"
:editable="!!activeTab.queryAnalysis"
:source-columns="activeTab.querySourceColumns"
:query-editability-reason="activeTab.queryEditabilityReason"
context="results"
:database-type="activeConnection?.db_type"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:schema="activeTab.schema"
:table-meta="activeTab.tableMeta"
:page-offset="activeTab.resultPageOffset"
:page-limit="activeTab.resultPageLimit"
:count-sql="activeTab.resultCountSql"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
@reload="
(
sql?: string,
searchText?: string,
whereInput?: string,
orderBy?: string,
limit?: number,
offset?: number,
) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)
"
@paginate="
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
emit('paginate', offset, limit, whereInput, orderBy)
"
@sort="
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, columnIndex, direction, whereInput)
"
/>
<div
v-if="activeTab.result?.columns.includes('Error')"
class="flex items-center gap-2 px-3 py-1.5 border-t bg-destructive/5"
>
<Bot class="h-3.5 w-3.5 text-destructive" />
<button
class="text-xs text-destructive hover:underline"
@click="emit('fixWithAi', String(activeTab.result?.rows?.[0]?.[0] ?? ''))"
>
{{ t("ai.fixWithAi") }}
</button>
</div>
<div
v-else-if="!activeTab.result && activeTab.isExecuting"
class="flex-1 min-h-0 flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
>
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
</div>
</div>
<div
v-else-if="!activeTab.result"
class="flex-1 min-h-0 flex flex-col items-center justify-center gap-1 text-muted-foreground text-sm"
>
<div>{{ t("editor.pressToExecute", { mod: shortcutModifier }) }}</div>
<div>{{ t("editor.pressToSaveSql", { mod: shortcutModifier }) }}</div>
</div>
</template>
</div>
</Pane>
</Splitpanes>
</template>
<!-- Data mode: full-height grid -->
<template v-else-if="activeTab.mode === 'data'">
<div class="flex-1 min-h-0 flex flex-col">
<div class="h-9 shrink-0 border-b bg-background/80 px-3 flex items-center gap-2 text-xs">
<span
class="inline-flex items-center gap-1 rounded border border-emerald-200 bg-emerald-50 px-2 py-0.5 font-medium text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950 dark:text-emerald-300"
>
<Table2 class="h-3.5 w-3.5" />
{{ t("tabs.tableData") }}
</span>
<span
class="inline-flex items-center rounded border border-border bg-muted/50 px-2 py-0.5 font-medium truncate"
>
{{ activeTab.tableMeta?.tableName || activeTab.title }}
</span>
<span
class="inline-flex items-center rounded border border-border bg-muted/30 px-2 py-0.5 text-muted-foreground truncate"
>
<template v-if="activeTab.tableMeta?.schema">{{ activeTab.tableMeta.schema }}@</template
>{{ databaseDisplayNameForTab(activeTab.connectionId, activeTab.database) }}
</span>
<span v-if="activeTab.tableMeta" class="ml-auto text-muted-foreground">
{{ activeTab.tableMeta.columns.length }} {{ t("tree.columns") }}
</span>
<Popover v-if="activeTab.result?.columns.length">
<PopoverTrigger as-child>
<Button
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:class="{ 'bg-accent text-foreground': (dataGridRef?.hiddenColumnCount ?? 0) > 0 }"
>
<Columns3 class="h-3.5 w-3.5" />
{{ t("grid.columnVisibility") }}
<span v-if="(dataGridRef?.hiddenColumnCount ?? 0) > 0" class="tabular-nums">
{{ dataGridRef?.visibleColumnCount }}/{{ dataGridRef?.displayableColumnCount }}
</span>
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
class="w-64 max-w-[calc(100vw-2rem)] gap-0 overflow-hidden rounded-xl border bg-popover p-0 text-popover-foreground shadow-xl"
@click.stop
@keydown.stop
>
<div class="border-b bg-muted/40 px-2 py-1.5">
<div class="flex items-center justify-between gap-2">
<div class="text-xs font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[10px] text-muted-foreground tabular-nums">
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
</div>
</div>
</div>
<div class="flex items-center gap-1.5 border-b px-2 py-1.5">
<Search class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<input
v-model="columnVisibilitySearch"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-6 min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.searchColumns')"
/>
</div>
<div class="max-h-72 overflow-auto py-0.5">
<button
v-for="option in columnVisibilityOptions"
:key="`${option.index}:${option.column}`"
type="button"
class="grid w-full grid-cols-[1.5rem_minmax(0,1fr)] items-center px-2 py-1 text-left text-xs hover:bg-accent"
@click="dataGridRef?.toggleColumnVisibility(option.index)"
>
<span
class="flex h-4 w-4 items-center justify-center rounded border"
:class="
dataGridRef?.isColumnVisible(option.index)
? 'border-primary bg-primary text-primary-foreground'
: 'border-border bg-background text-transparent'
"
>
<Check class="h-3 w-3 stroke-[3]" />
</span>
<span class="truncate font-mono text-xs" :title="option.column">{{ option.column }}</span>
</button>
<div
v-if="columnVisibilityOptions.length === 0"
class="px-2 py-6 text-center text-xs text-muted-foreground"
>
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-2 py-1.5">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="(dataGridRef?.displayableColumnCount ?? 0) <= 1"
@click="dataGridRef?.invertColumnVisibility()"
>
{{ t("grid.invertColumnVisibility") }}
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs"
:disabled="(dataGridRef?.hiddenColumnCount ?? 0) === 0"
@click="dataGridRef?.showAllColumns()"
>
{{ t("grid.showAllColumns") }}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
<Button
v-if="activeTab.tableMeta && activeTab.connectionId"
variant="ghost"
size="sm"
class="h-5 text-xs px-1.5 shrink-0"
:class="{ 'bg-accent': dataGridRef?.showDdl }"
@click="dataGridRef?.toggleDdl()"
>
<TableProperties class="h-3.5 w-3.5" /> {{ t("grid.tableInfo") }}
</Button>
</div>
<DataGrid
v-if="activeTab.result"
ref="dataGridRef"
class="flex-1 min-h-0"
:key="activeTab.id"
:cache-key="activeTab.id"
:result="activeTab.result"
:sql="activeTab.sql"
:loading="activeTab.isExecuting"
:editable="isTableDataEditable(activeConnection?.db_type, activeTab.tableMeta?.primaryKeys ?? [])"
context="table-data"
:initial-where-input="activeTab.whereInput"
:database-type="activeConnection?.db_type"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:table-meta="activeTab.tableMeta"
:page-offset="activeTab.resultPageOffset"
:page-limit="activeTab.resultPageLimit"
:on-execute-sql="async (sql: string) => emit('executeSql', sql)"
@update:where-input="(v: string) => (activeTab.whereInput = v)"
@reload="
(
sql?: string,
searchText?: string,
whereInput?: string,
orderBy?: string,
limit?: number,
offset?: number,
) => emit('reload', sql, searchText, whereInput, orderBy, limit, offset)
"
@paginate="
(offset: number, limit: number, whereInput?: string, orderBy?: string) =>
emit('paginate', offset, limit, whereInput, orderBy)
"
@sort="
(column: string, columnIndex: number, direction: 'asc' | 'desc' | null, whereInput?: string) =>
emit('sort', column, columnIndex, direction, whereInput)
"
/>
<div
v-else-if="activeTab.isExecuting"
class="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground text-sm"
>
<div class="flex items-center">
<Loader2 class="h-5 w-5 animate-spin mr-2" />
{{ t(queryExecutionLabelKey(activeTab)) }}
</div>
<Button
variant="destructive"
size="sm"
class="h-7 gap-1.5"
:disabled="!canCancelQueryExecution(activeTab)"
@click="emit('cancel')"
>
<Loader2 v-if="activeTab.isCancelling" class="h-3.5 w-3.5 animate-spin" />
<Square v-else class="h-3.5 w-3.5 fill-current" />
{{ t("toolbar.stopQuery") }}
</Button>
</div>
</div>
</template>
<!-- Redis mode: key browser -->
<template v-else-if="activeTab.mode === 'redis'">
<div class="flex-1 min-h-0">
<RedisKeyBrowser
ref="redisKeyBrowserRef"
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:db="Number(activeTab.database)"
/>
</div>
</template>
<!-- MongoDB mode: document browser -->
<template v-else-if="activeTab.mode === 'mongo'">
<div class="flex-1 min-h-0">
<MongoDocBrowser
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:collection="activeTab.sql"
/>
</div>
</template>
<!-- Objects mode: virtualized database object browser -->
<template v-else-if="activeTab.mode === 'objects' && activeConnection">
<ObjectBrowser
ref="objectBrowserRef"
:key="`${activeTab.id}-${activeTab.objectBrowser?.schema || ''}`"
:connection="activeConnection"
:database="activeTab.database"
:schema="activeTab.objectBrowser?.schema"
@open-table="emit('openObjectTable', $event)"
@schema-change="emit('objectSchemaChange', $event)"
/>
</template>
<!-- Structure mode: table structure editor -->
<template v-else-if="activeTab.mode === 'structure'">
<TableStructureEditor
:key="activeTab.id"
:connection-id="activeTab.connectionId"
:database="activeTab.database"
:schema="activeTab.schema"
:table-name="activeTab.structureTableName || ''"
@saved="(commentChanged) => emit('structureEditorSaved', commentChanged)"
@close="emit('structureEditorClose')"
/>
</template>
</div>
</template>