feat(grid): add table info overview tab to the info drawer

This commit is contained in:
Allen
2026-09-21 16:58:01 +08:00
committed by GitHub
parent 0f8ad280c1
commit 2c4395d412
17 changed files with 200 additions and 6 deletions
+60 -3
View File
@@ -91,10 +91,11 @@ import TemporalCellEditor from "@/components/grid/TemporalCellEditor.vue";
import EnumCellEditor from "@/components/grid/EnumCellEditor.vue";
import DataGridReadonlyTextSelection from "@/components/grid/DataGridReadonlyTextSelection.vue";
import GridSnapshotDialog from "@/components/grid/GridSnapshotDialog.vue";
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, PgTablePartitioning, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef, QueryPageJumpProgress } from "@/types/database";
import type { QueryResult, ColumnInfo, ConstraintInfo, DatabaseType, ForeignKeyInfo, IndexInfo, ObjectStatistics, PgTablePartitioning, TriggerInfo, TableInfoTab, QueryResultSourceColumnRef, QueryPageJumpProgress } from "@/types/database";
import { isQueryExecutionErrorResult } from "@/lib/query/queryResultError";
import { shouldNavigateFromTableInfoColumnClick } from "@/lib/table/tableInfoColumnNavigation";
import { tableInfoTabForDrawerToggle } from "@/lib/table/tableInfoTabPreference";
import { findTableStatistics } from "@/lib/dataGrid/tableInfoOverview";
import * as api from "@/lib/backend/api";
import { formatElapsedSeconds } from "@/lib/common/elapsedTime";
import type { SqlInsertMode } from "@/lib/export/sqlInsertMode";
@@ -10346,6 +10347,13 @@ const tableOwner = ref<string | null>(null);
const tableOwnerLoading = ref(false);
const tableOwnerError = ref("");
const tableOwnerRequestGeneration = ref(0);
// Overview tab state: row/size statistics plus the table comment, loaded lazily
// when the tab is first selected.
const tableOverviewStats = ref<ObjectStatistics | null>(null);
const tableOverviewComment = ref<string | null>(null);
const tableOverviewLoading = ref(false);
const tableOverviewLoaded = ref(false);
const tableOverviewRequestGeneration = ref(0);
const canShowTableOwner = computed(() => resolvedDatabaseType.value === "postgres" && !!props.connectionId && !!props.database && !!props.tableMeta?.schema && !!props.tableMeta?.tableName);
function scrollDdlSearchMatchIntoView(match: HTMLElement) {
@@ -10455,6 +10463,7 @@ const partitionStatusResolved = ref(false);
const constraintsForTab = computed(() => constraintsForConstraintsTab(constraints.value, tableMetadataCapabilities.value.foreignKeys));
const searchQuery = ref("");
const activeTableInfoLoading = computed(() => {
if (activeTableInfoTab.value === "info") return tableOverviewLoading.value;
if (activeTableInfoTab.value === "ddl") return ddlLoading.value;
if (activeTableInfoTab.value === "columns") return tableInfoColumnsLoading.value;
if (activeTableInfoTab.value === "indexes") return indexesLoading.value;
@@ -10635,9 +10644,41 @@ const metadataLoaders = useDataGridTableMetadataLoaders({
});
const { fetchDdl, fetchTableInfoColumns, fetchTableOwner, currentIndexTableIdentity, fetchIndexes, refreshMongoIndexMetadataAfterMutation, currentForeignKeyTableIdentity, fetchForeignKeys: fetchForeignKeysMetadata, fetchTriggers, fetchConstraints, fetchPartitions } = metadataLoaders;
async function fetchTableOverview(force = false) {
const connectionId = props.connectionId;
const database = props.database;
const schema = props.tableMeta?.schema;
const tableName = props.tableMeta?.tableName;
if (!connectionId || !database || !tableName) return;
if (!force && tableOverviewLoaded.value) return;
const generation = ++tableOverviewRequestGeneration.value;
tableOverviewLoading.value = true;
try {
// Both lookups are best-effort: drivers without statistics support simply
// leave the corresponding rows hidden in the overview tab.
const [stats, comment] = await Promise.all([
api.listObjectStatistics(connectionId, database, schema ?? "").catch((error) => {
console.debug("table overview statistics unavailable", error);
return [] as ObjectStatistics[];
}),
api.getTableComment(connectionId, database, schema ?? "", tableName, props.tableMeta?.catalog).catch((error) => {
console.debug("table overview comment unavailable", error);
return null;
}),
]);
if (generation !== tableOverviewRequestGeneration.value) return;
tableOverviewStats.value = findTableStatistics(stats, tableName, schema) ?? null;
tableOverviewComment.value = comment || null;
tableOverviewLoaded.value = true;
} finally {
if (generation === tableOverviewRequestGeneration.value) tableOverviewLoading.value = false;
}
}
tableMetadataLoaderFetchForeignKeys = fetchForeignKeysMetadata;
const tableInfoTabs = computed(() => {
const tabs: TableInfoTabItem[] = [];
tabs.push({ id: "info", label: t("grid.tableInfoOverview"), icon: Info });
if (tableMetadataCapabilities.value.ddl) {
tabs.push({ id: "ddl", label: "DDL", icon: Code2 });
}
@@ -10721,7 +10762,8 @@ async function selectTableInfoTab(tab: TableInfoTab) {
if (!nextTab) return;
activeTableInfoTab.value = nextTab;
if (tabSupported) settingsStore.updateEditorSettings({ tableInfoActiveTab: tab });
if (nextTab === "ddl") await fetchDdl();
if (nextTab === "info") await fetchTableOverview();
else if (nextTab === "ddl") await fetchDdl();
else if (nextTab === "indexes") await fetchIndexes();
else if (nextTab === "foreignKeys") await fetchForeignKeys();
else if (nextTab === "constraints") await fetchConstraints();
@@ -10751,7 +10793,10 @@ async function refreshActiveTableInfo() {
if (!showTableInfo.value || !props.tableMeta) return;
if (canShowTableOwner.value) void fetchTableOwner(true);
if (activeTableInfoTab.value === "ddl") await fetchDdl(true);
if (activeTableInfoTab.value === "info") {
tableOverviewLoaded.value = false;
await fetchTableOverview();
} else if (activeTableInfoTab.value === "ddl") await fetchDdl(true);
else if (activeTableInfoTab.value === "columns") await fetchTableInfoColumns(true);
else if (activeTableInfoTab.value === "indexes") {
indexesLoaded.value = false;
@@ -10781,6 +10826,11 @@ watch(
tableOwnerLoading.value = false;
tableOwnerError.value = "";
tableOwnerRequestGeneration.value += 1;
tableOverviewStats.value = null;
tableOverviewComment.value = null;
tableOverviewLoading.value = false;
tableOverviewLoaded.value = false;
tableOverviewRequestGeneration.value += 1;
rawDdlContent.value = "";
indexes.value = [];
indexesLoaded.value = false;
@@ -13525,6 +13575,13 @@ useUpdateBlocker(() => (hasPendingChanges.value || hasPendingDataEditorDraft.val
v-if="activeTableInfoTab !== 'ddl'"
:active-tab="activeTableInfoTab"
:search-query="searchQuery"
:table-name="props.tableMeta?.tableName ?? ''"
:table-schema="props.tableMeta?.schema ?? ''"
:database="props.database ?? ''"
:table-owner="tableOwner"
:overview-stats="tableOverviewStats"
:overview-comment="tableOverviewComment"
:overview-loading="tableOverviewLoading"
:columns="filteredColumns"
:columns-loading="tableInfoColumnsLoading"
:indexes="filteredIndexes"
@@ -1,14 +1,23 @@
<script setup lang="ts">
import { computed } from "vue";
import { KeyRound, Loader2, Trash2 } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
import TablePartitionsPanel from "@/components/structure/TablePartitionsPanel.vue";
import { tableColumnDefaultDisplayValue } from "@/lib/table/tableColumnDefaultPresentation";
import type { ColumnInfo, ConstraintInfo, ForeignKeyInfo, IndexInfo, PgTablePartitioning, TableInfoTab, TriggerInfo } from "@/types/database";
import { formatObjectBrowserBytes, formatObjectBrowserCount } from "@/lib/table/objectBrowserRows";
import type { ColumnInfo, ConstraintInfo, ForeignKeyInfo, IndexInfo, ObjectStatistics, PgTablePartitioning, TableInfoTab, TriggerInfo } from "@/types/database";
interface DataGridTableInfoPanelsProps {
activeTab: TableInfoTab;
searchQuery: string;
tableName: string;
tableSchema: string;
database: string;
tableOwner: string | null;
overviewStats: ObjectStatistics | null;
overviewComment: string | null;
overviewLoading: boolean;
columns: ColumnInfo[];
columnsLoading: boolean;
indexes: IndexInfo[];
@@ -40,6 +49,20 @@ const emit = defineEmits<{
}>();
const { t } = useI18n();
const overviewRows = computed(() => {
const rows: { label: string; value: string; mono?: boolean }[] = [
{ label: t("common.table"), value: props.tableName },
{ label: t("common.schema"), value: props.tableSchema },
{ label: t("common.database"), value: props.database },
{ label: t("grid.tableOwner"), value: props.tableOwner ?? "", mono: true },
{ label: t("structureEditor.comment"), value: props.overviewComment ?? "" },
{ label: t("grid.tableInfoEstimatedRows"), value: formatObjectBrowserCount(props.overviewStats?.estimated_rows), mono: true },
{ label: t("grid.tableInfoTotalSize"), value: formatObjectBrowserBytes(props.overviewStats?.total_bytes), mono: true },
];
const query = props.searchQuery.trim().toLowerCase();
return rows.filter((row) => row.value && (!query || row.label.toLowerCase().includes(query) || row.value.toLowerCase().includes(query)));
});
</script>
<template>
@@ -218,6 +241,20 @@ const { t } = useI18n();
</div>
</div>
</div>
<div v-else-if="props.activeTab === 'info'" class="table-info-scroller flex-1 min-h-0 overflow-auto">
<div v-if="props.overviewLoading" class="h-full flex items-center justify-center">
<Loader2 class="w-4 h-4 animate-spin text-muted-foreground" />
</div>
<div v-else-if="props.searchQuery && overviewRows.length === 0" class="p-6 text-center text-xs text-muted-foreground">
{{ t("grid.tableInfoNoResults") }}
</div>
<div v-else class="divide-y">
<div v-for="row in overviewRows" :key="row.label" class="flex items-baseline gap-3 px-3 py-2 text-xs">
<span class="w-20 shrink-0 text-muted-foreground">{{ row.label }}</span>
<span class="min-w-0 flex-1 select-text break-words" :class="row.mono ? 'font-mono text-[11px]' : ''" :title="row.value">{{ row.value }}</span>
</div>
</div>
</div>
<TablePartitionsPanel v-else-if="props.activeTab === 'partitions'" :partitioning="props.partitioning" :loading="props.partitionsLoading" :error="props.partitionsError" :search-query="props.searchQuery" />
</template>
+4
View File
@@ -2328,6 +2328,9 @@ export default withEnglishFallback({
rowsPerPageShort: " sətir",
columnDetails: "Sütunun təfərrüatları",
tableInfo: "Cədvəl haqqında məlumat",
tableInfoOverview: "Ümumi baxış",
tableInfoEstimatedRows: "Təxmini sətirlər",
tableInfoTotalSize: "Ümumi həcm",
pinTableInfo: "Cədvəl məlumatları panelini bərkit",
unpinTableInfo: "Cədvəl məlumatları panelinin bərkidilməsini ləğv et",
tableInfoColumns: "Sütunlar",
@@ -2520,6 +2523,7 @@ export default withEnglishFallback({
database: "Verilənlər bazası",
table: "Cədvəl",
view: "Görünüş",
schema: "Sxem",
materializedView: "Materiallaşdırılmış görünüş",
procedure: "Prosedur",
function: "Funksiya",
+4
View File
@@ -2522,6 +2522,9 @@ export default {
rowsPerPageShort: " rows",
columnDetails: "Column Details",
tableInfo: "Table Info",
tableInfoOverview: "Overview",
tableInfoEstimatedRows: "Estimated rows",
tableInfoTotalSize: "Total size",
pinTableInfo: "Pin table info panel",
unpinTableInfo: "Unpin table info panel",
tableInfoColumns: "Columns",
@@ -2714,6 +2717,7 @@ export default {
database: "Database",
table: "Table",
view: "View",
schema: "Schema",
materializedView: "Materialized View",
procedure: "Procedure",
function: "Function",
+4
View File
@@ -2363,6 +2363,9 @@ export default withEnglishFallback({
searchColumn: "Buscar columna/comentario...",
noColumnsFound: "No se encontraron columnas",
tableInfo: "Información de tabla",
tableInfoOverview: "Resumen",
tableInfoEstimatedRows: "Filas estimadas",
tableInfoTotalSize: "Tamaño total",
pinTableInfo: "Fijar el panel de información de la tabla",
unpinTableInfo: "Desfijar el panel de información de la tabla",
tableInfoColumns: "Columnas",
@@ -2614,6 +2617,7 @@ export default withEnglishFallback({
database: "Base de datos",
table: "Tabla",
view: "Vista",
schema: "Esquema",
materializedView: "Vista Materializada",
procedure: "Procedimiento",
function: "Función",
+4
View File
@@ -2349,6 +2349,9 @@ export default withEnglishFallback({
rowsPerPageShort: " righe",
columnDetails: "Dettagli Colonna",
tableInfo: "Informazioni Tabella",
tableInfoOverview: "Panoramica",
tableInfoEstimatedRows: "Righe stimate",
tableInfoTotalSize: "Dimensione totale",
pinTableInfo: "Blocca il pannello delle informazioni sulla tabella",
unpinTableInfo: "Sblocca il pannello delle informazioni sulla tabella",
tableInfoColumns: "Colonne",
@@ -2611,6 +2614,7 @@ export default withEnglishFallback({
database: "Database",
table: "Tabella",
view: "Vista",
schema: "Schema",
materializedView: "Vista Materializzata",
procedure: "Procedura",
function: "Funzione",
+4
View File
@@ -2360,6 +2360,9 @@ export default withEnglishFallback({
rowsPerPageShort: " 行",
columnDetails: "列詳細",
tableInfo: "テーブル情報",
tableInfoOverview: "概要",
tableInfoEstimatedRows: "推定行数",
tableInfoTotalSize: "合計サイズ",
pinTableInfo: "テーブル情報パネルを固定",
unpinTableInfo: "テーブル情報パネルの固定を解除",
tableInfoColumns: "列",
@@ -2634,6 +2637,7 @@ export default withEnglishFallback({
database: "データベース",
table: "テーブル",
view: "ビュー",
schema: "スキーマ",
materializedView: "マテリアライズドビュー",
procedure: "プロシージャ",
function: "関数",
+4
View File
@@ -2343,6 +2343,9 @@ export default withEnglishFallback({
rowsPerPageShort: "행",
columnDetails: "컬럼 상세",
tableInfo: "테이블 정보",
tableInfoOverview: "개요",
tableInfoEstimatedRows: "예상 행 수",
tableInfoTotalSize: "전체 크기",
pinTableInfo: "테이블 정보 패널 고정",
unpinTableInfo: "테이블 정보 패널 고정 해제",
tableInfoColumns: "컬럼",
@@ -2520,6 +2523,7 @@ export default withEnglishFallback({
database: "데이터베이스",
table: "테이블",
view: "뷰",
schema: "스키마",
materializedView: "구체화된 뷰",
procedure: "프로시저",
function: "함수",
+4
View File
@@ -2351,6 +2351,9 @@ export default withEnglishFallback({
rowsPerPageShort: " linhas",
columnDetails: "Detalhes da Coluna",
tableInfo: "Informações da Tabela",
tableInfoOverview: "Visão geral",
tableInfoEstimatedRows: "Linhas estimadas",
tableInfoTotalSize: "Tamanho total",
pinTableInfo: "Fixar painel de informações da tabela",
unpinTableInfo: "Desafixar painel de informações da tabela",
tableInfoColumns: "Colunas",
@@ -2613,6 +2616,7 @@ export default withEnglishFallback({
database: "Banco de dados",
table: "Tabela",
view: "Visualização",
schema: "Esquema",
materializedView: "Visualização Materializada",
procedure: "Procedimento",
function: "Função",
+4
View File
@@ -2323,6 +2323,9 @@ export default withEnglishFallback({
rowsPerPageShort: " satır",
columnDetails: "Sütun Ayrıntıları",
tableInfo: "Tablo Bilgisi",
tableInfoOverview: "Genel bakış",
tableInfoEstimatedRows: "Tahmini satır",
tableInfoTotalSize: "Toplam boyut",
pinTableInfo: "Tablo bilgileri panelini sabitle",
unpinTableInfo: "Tablo bilgileri panelinin sabitlemesini kaldır",
tableInfoColumns: "Sütunlar",
@@ -2498,6 +2501,7 @@ export default withEnglishFallback({
database: "Veritabanı",
table: "Tablo",
view: "Görünüm",
schema: "Şema",
materializedView: "Materyalleştirilmiş Görünüm",
procedure: "Yordam",
function: "İşlev",
+4
View File
@@ -2435,6 +2435,9 @@ export default withEnglishFallback({
rowsPerPageShort: " 行/页",
columnDetails: "列详情",
tableInfo: "表属性",
tableInfoOverview: "表信息",
tableInfoEstimatedRows: "估算行数",
tableInfoTotalSize: "总大小",
pinTableInfo: "固定表属性面板",
unpinTableInfo: "取消固定表属性面板",
tableInfoColumns: "字段",
@@ -2626,6 +2629,7 @@ export default withEnglishFallback({
database: "数据库",
table: "表",
view: "视图",
schema: "Schema",
materializedView: "物化视图",
procedure: "存储过程",
function: "函数",
+4
View File
@@ -2354,6 +2354,9 @@ export default withEnglishFallback({
rowsPerPageShort: " 筆/頁",
columnDetails: "欄位詳情",
tableInfo: "資料表屬性",
tableInfoOverview: "資料表資訊",
tableInfoEstimatedRows: "預估列數",
tableInfoTotalSize: "總大小",
pinTableInfo: "固定資料表屬性面板",
unpinTableInfo: "取消固定資料表屬性面板",
tableInfoColumns: "欄位",
@@ -2611,6 +2614,7 @@ export default withEnglishFallback({
database: "資料庫",
table: "資料表",
view: "檢視",
schema: "Schema",
materializedView: "物化檢視",
procedure: "預存程序",
function: "函數",
@@ -0,0 +1,39 @@
import { describe, expect, it } from "vitest";
import { findTableStatistics } from "@/lib/dataGrid/tableInfoOverview";
import type { ObjectStatistics } from "@/types/database";
const stats: ObjectStatistics[] = [
{ name: "orders", schema: "public", estimated_rows: 100, total_bytes: 4096 },
{ name: "orders", schema: "reporting", estimated_rows: 5, total_bytes: 1024 },
{ name: "users", schema: null, estimated_rows: 42, total_bytes: 2048 },
];
describe("findTableStatistics", () => {
it("prefers an exact schema match", () => {
expect(findTableStatistics(stats, "orders", "reporting")?.estimated_rows).toBe(5);
expect(findTableStatistics(stats, "orders", "public")?.estimated_rows).toBe(100);
});
it("falls back to a name-only match when the schema differs", () => {
expect(findTableStatistics(stats, "users", "public")?.estimated_rows).toBe(42);
});
it("matches by name when no schema is given", () => {
expect(findTableStatistics(stats, "orders")?.estimated_rows).toBe(100);
});
it("matches case-insensitively when drivers report inconsistent casing", () => {
const mixed: ObjectStatistics[] = [
{ name: "Orders", schema: "PUBLIC", estimated_rows: 7, total_bytes: 512 },
{ name: "Orders", schema: "reporting", estimated_rows: 9, total_bytes: 256 },
];
expect(findTableStatistics(mixed, "orders", "public")?.estimated_rows).toBe(7);
expect(findTableStatistics(mixed, "ORDERS", "REPORTING")?.estimated_rows).toBe(9);
expect(findTableStatistics(mixed, "orders")?.estimated_rows).toBe(7);
});
it("returns undefined when the table has no statistics", () => {
expect(findTableStatistics(stats, "missing", "public")).toBeUndefined();
expect(findTableStatistics([], "orders")).toBeUndefined();
});
});
@@ -0,0 +1,18 @@
import type { ObjectStatistics } from "@/types/database";
/**
* Picks the statistics entry for a table from a schema-level list. Drivers can
* report inconsistent casing between `listObjects` and `listObjectStatistics`,
* so both the schema and the name fold to lowercase — the same normalization
* the object browser's statistic keys apply. The name-only fallback stays:
* the statistics query is already scoped to one database/schema, and
* MySQL-family tables carry an empty `tableMeta.schema` while their statistics
* entries report the database as schema.
*/
export function findTableStatistics(stats: readonly ObjectStatistics[], tableName: string, schema?: string): ObjectStatistics | undefined {
if (schema !== undefined && schema !== "") {
const exact = stats.find((entry) => (entry.schema ?? "").toLowerCase() === schema.toLowerCase() && entry.name.toLowerCase() === tableName.toLowerCase());
if (exact) return exact;
}
return stats.find((entry) => entry.name.toLowerCase() === tableName.toLowerCase());
}
@@ -27,6 +27,9 @@ export function visibleTableStructureRefreshScope(activeTab: TableInfoTab): Tabl
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: true, tableComment: false };
case "ddl":
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: true };
default:
// "info" is a data-grid drawer tab and has no structure-editor scope.
return { columns: false, indexes: false, foreignKeys: false, constraints: false, triggers: false, partitions: false, tableComment: false };
}
}
+1 -1
View File
@@ -1498,7 +1498,7 @@ function normalizeToolbarItems(items: Partial<ToolbarItems> | undefined): Toolba
};
}
const TABLE_INFO_TABS = new Set<TableInfoTab>(["ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers", "partitions"]);
const TABLE_INFO_TABS = new Set<TableInfoTab>(["info", "ddl", "columns", "indexes", "foreignKeys", "constraints", "triggers", "partitions"]);
function normalizeTableInfoTab(value: unknown): TableInfoTab {
return typeof value === "string" && TABLE_INFO_TABS.has(value as TableInfoTab) ? (value as TableInfoTab) : DEFAULT_EDITOR_SETTINGS.tableInfoActiveTab;
+1 -1
View File
@@ -1525,7 +1525,7 @@ export interface TableNameFilter {
excludePatterns: string[];
}
export type TableInfoTab = "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "partitions" | "ddl";
export type TableInfoTab = "info" | "columns" | "indexes" | "foreignKeys" | "constraints" | "triggers" | "partitions" | "ddl";
/** PostgreSQL declarative partitioning strategy (`pg_partitioned_table.partstrat`). */
export type PgPartitionKind = "range" | "list" | "hash";