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

621 lines
24 KiB
Vue

<script setup lang="ts">
import { computed, ref, defineAsyncComponent } from "vue";
import { useI18n } from "vue-i18n";
import {
Check,
Columns3,
Loader2,
Search,
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 { 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 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;
visibleColumnCount: number;
displayableColumnCount: number;
hiddenColumnCount: number;
filteredColumnVisibilityOptions: (search: string) => Array<{ index: number; column: string }>;
isColumnVisible: (columnIndex: number) => boolean;
toggleColumnVisibility: (columnIndex: number) => void;
showAllColumns: () => 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];
}>();
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 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] ?? "");
});
// 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;
}
function refreshData(): boolean {
if (!dataGridRef.value) return false;
void dataGridRef.value.onToolbarRefresh();
return true;
}
defineExpose({ focusSearch, refreshData });
</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"
: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-72 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-3 py-2">
<div class="flex items-center justify-between gap-2">
<div class="text-sm font-semibold">{{ t("grid.columnVisibility") }}</div>
<div class="text-[11px] text-muted-foreground tabular-nums">
{{ dataGridRef?.visibleColumnCount ?? 0 }}/{{ dataGridRef?.displayableColumnCount ?? 0 }}
</div>
</div>
</div>
<div class="flex items-center gap-2 border-b px-3 py-2">
<Search class="h-4 w-4 shrink-0 text-muted-foreground" />
<input
v-model="columnVisibilitySearch"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
class="h-7 min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
:placeholder="t('grid.searchColumns')"
/>
</div>
<div class="max-h-72 overflow-auto py-1">
<button
v-for="option in columnVisibilityOptions"
:key="`${option.index}:${option.column}`"
type="button"
class="grid w-full grid-cols-[1.75rem_minmax(0,1fr)] items-center px-3 py-1.5 text-left text-sm hover:bg-accent"
@click="dataGridRef?.toggleColumnVisibility(option.index)"
>
<span
class="flex h-5 w-5 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.5 w-3.5 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-3 py-8 text-center text-sm text-muted-foreground"
>
{{ t("grid.noSearchResults") }}
</div>
</div>
<div class="flex items-center justify-between gap-2 border-t bg-muted/30 px-3 py-2">
<span class="text-[11px] text-muted-foreground">{{ t("grid.columnVisibilityHint") }}</span>
<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>
</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"
: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>
</div>
</template>