Files
dbx/src/components/layout/ContentArea.vue
T

494 lines
19 KiB
Vue

<script setup lang="ts">
import { computed, ref, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import { Loader2, Square, Bot, Table2, GitBranch, BarChart3, TableProperties } from "lucide-vue-next";
import { Splitpanes, Pane } from "splitpanes";
import "splitpanes/dist/splitpanes.css";
import { Button } from "@/components/ui/button";
import QueryEditor from "@/components/editor/QueryEditor.vue";
import DataGrid from "@/components/grid/DataGrid.vue";
import RedisKeyBrowser from "@/components/redis/RedisKeyBrowser.vue";
import MongoDocBrowser from "@/components/mongo/MongoDocBrowser.vue";
import ObjectBrowser from "@/components/objects/ObjectBrowser.vue";
import ColumnInfoPanel from "@/components/editor/ColumnInfoPanel.vue";
import type { ColumnInfo } from "@/components/editor/ColumnInfoPanel.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";
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];
}>();
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<InstanceType<typeof DataGrid>>();
const redisKeyBrowserRef = ref<InstanceType<typeof RedisKeyBrowser>>();
const objectBrowserRef = ref<InstanceType<typeof ObjectBrowser>>();
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] ?? "");
});
// 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" && props.activeOutputView !== "result") return false;
return dataGridRef.value?.focusSearch() ?? false;
}
defineExpose({ focusSearch });
</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="40" :min-size="15">
<div class="h-full flex flex-col relative">
<QueryEditor
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"
/>
</div>
</Pane>
<Pane :size="60" :min-size="20">
<div class="h-full flex flex-col">
<div
v-if="
activeTab.result ||
activeTab.explainPlan ||
activeTab.explainError ||
activeTab.isExecuting ||
activeTab.isExplaining
"
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>
</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"
: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"
:table-meta="activeTab.tableMeta"
: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>
<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"
: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"
: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>
</div>
</template>