feat(sidebar): add configurable object metadata display

This commit is contained in:
zipg authored and GitHub committed 2026-07-23 00:23:19 +08:00
1 parent 8adf802444
commit 57f2d27313
29 files changed
+889 -143

No files matched your search

@@ -0,0 +1,65 @@
import { describe, expect, it } from "vitest";
import { applySidebarDatabaseStorage, applySidebarTableStorage, formatSidebarObjectStorage, sidebarDatabaseNames, sidebarTableStorageScopes, supportsSidebarDatabaseStorage, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import type { ConnectionConfig, TreeNode } from "@/types/database";
function config(dbType: ConnectionConfig["db_type"]): ConnectionConfig {
return { id: "connection", name: "connection", db_type: dbType } as ConnectionConfig;
}
describe("sidebar database storage", () => {
it("keeps database totals PostgreSQL-specific while reusing supported table statistics", () => {
expect(supportsSidebarDatabaseStorage(config("postgres"))).toBe(true);
expect(supportsSidebarDatabaseStorage(config("mysql"))).toBe(false);
expect(supportsSidebarDatabaseStorage({ ...config("postgres"), driver_profile: "cockroachdb" })).toBe(false);
for (const dbType of ["mysql", "postgres", "sqlserver", "oracle", "clickhouse", "dameng", "gaussdb", "kingbase", "gbase"] as const) {
expect(supportsSidebarTableStorage(config(dbType))).toBe(true);
}
expect(supportsSidebarTableStorage(config("jdbc"))).toBe(false);
expect(supportsSidebarTableStorage({ ...config("postgres"), driver_profile: "cockroachdb" })).toBe(false);
expect(supportsSidebarTableStorage({ ...config("gbase"), driver_profile: "gbase8s" })).toBe(false);
});
it("requests and applies only visible database nodes", () => {
const nodes: TreeNode[] = [
{ id: "a", label: "app", type: "database", connectionId: "connection", database: "app" },
{ id: "b", label: "hidden", type: "database", connectionId: "connection", database: "hidden" },
{ id: "utility", label: "users", type: "user-admin", connectionId: "connection" },
];
expect(sidebarDatabaseNames(nodes)).toEqual(["app", "hidden"]);
expect(applySidebarDatabaseStorage(nodes, [{ name: "app", size_bytes: 2048 }])).toBe(true);
expect(nodes[0].sizeBytes).toBe(2048);
expect(nodes[1].sizeBytes).toBeUndefined();
});
it("keeps unavailable values blank and formats known sizes compactly", () => {
expect(formatSidebarObjectStorage(null)).toBe("");
expect(formatSidebarObjectStorage(0)).toBe("0 B");
expect(formatSidebarObjectStorage(1536)).toBe("1.5 KB");
expect(formatSidebarObjectStorage(15 * 1024 * 1024)).toBe("15 MB");
expect(formatSidebarObjectStorage(15.25 * 1024 * 1024)).toBe("15.3 MB");
});
it("collects table scopes and applies PostgreSQL table sizes without crossing schemas", () => {
const publicTable: TreeNode = { id: "public-users", label: "users", type: "table", connectionId: "connection", database: "app", schema: "public" };
const auditTable: TreeNode = { id: "audit-users", label: "users", type: "table", connectionId: "connection", database: "app", schema: "audit" };
const nodes: TreeNode[] = [
{ id: "public", label: "public", type: "schema", connectionId: "connection", database: "app", schema: "public", children: [publicTable] },
{ id: "audit", label: "audit", type: "schema", connectionId: "connection", database: "app", schema: "audit", children: [auditTable] },
];
expect(sidebarTableStorageScopes([publicTable, auditTable])).toEqual([
{ connectionId: "connection", database: "app", schema: "public" },
{ connectionId: "connection", database: "app", schema: "audit" },
]);
expect(applySidebarTableStorage(nodes, { connectionId: "connection", database: "app", schema: "public" }, [{ name: "users", schema: "public", total_bytes: 8192 }])).toBe(true);
expect(publicTable.sizeBytes).toBe(8192);
expect(auditTable.sizeBytes).toBeUndefined();
});
it("applies database-scoped MySQL statistics to tree nodes without a schema", () => {
const table: TreeNode = { id: "products", label: "products", type: "table", connectionId: "connection", database: "shop" };
expect(applySidebarTableStorage([table], { connectionId: "connection", database: "shop", schema: "" }, [{ name: "products", schema: "shop", total_bytes: 49152 }])).toBe(true);
expect(table.sizeBytes).toBe(49152);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { trailingCommentAvailableWidth, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { alignedSidebarCommentLabelWidths, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
describe("sidebar tree item layout", () => {
it("keeps a table row constrained when it displays a comment", () => {
@@ -11,10 +11,17 @@ describe("sidebar tree item layout", () => {
expect(treeLabelWidthClass({ fullWidth: false, hasTrailingComment: true })).toBe("min-w-0 flex-1 truncate");
});
it("gives the comment only the width left after the full table name and gap", () => {
expect(trailingCommentAvailableWidth(260, 100)).toBe(152);
expect(trailingCommentAvailableWidth(108, 100)).toBe(0);
expect(trailingCommentAvailableWidth(100, 100)).toBe(0);
expect(trailingCommentAvailableWidth(99, 100)).toBe(0);
it("aligns comments to the longest sibling name without crossing parent groups", () => {
const widths = alignedSidebarCommentLabelWidths([
{ id: "tables", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
{ id: "short", depth: 2, alignable: true, hasComment: true, labelWidth: 48 },
{ id: "long", depth: 2, alignable: true, hasComment: false, labelWidth: 136 },
{ id: "views", depth: 1, alignable: false, hasComment: false, labelWidth: 0 },
{ id: "view", depth: 2, alignable: true, hasComment: true, labelWidth: 72 },
]);
expect(widths.get("short")).toBe(136);
expect(widths.has("long")).toBe(false);
expect(widths.get("view")).toBe(72);
});
});
+1
View File
@@ -133,6 +133,7 @@ export const syncSavedSqlDirectory = forward("syncSavedSqlDirectory");
// Schema
export const listDatabases = forward("listDatabases");
export const listDatabaseStorage = forward("listDatabaseStorage");
export const listDorisCatalogs = forward("listDorisCatalogs");
export const listDorisCatalogDatabases = forward("listDorisCatalogDatabases");
export const listSqlServerLinkedServers = forward("listSqlServerLinkedServers");
+5
View File
@@ -3,6 +3,7 @@ import type {
ConnectionTestResult,
DatabaseConnectionInfo,
DatabaseInfo,
DatabaseStorageInfo,
SchemaInfo,
LinkedServerInfo,
CatalogInfo,
@@ -570,6 +571,10 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return get(`/api/schema/databases?${qs({ connection_id: connectionId })}`);
}
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise<DatabaseStorageInfo[]> {
return post("/api/schema/database-storage", { connection_id: connectionId, databases });
}
export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
return get(`/api/schema/doris/catalogs?${qs({ connection_id: connectionId })}`);
}
+5
View File
@@ -6,6 +6,7 @@ import type {
ConnectionTestResult,
DatabaseConnectionInfo,
DatabaseInfo,
DatabaseStorageInfo,
SchemaInfo,
LinkedServerInfo,
CatalogInfo,
@@ -759,6 +760,10 @@ export async function listDatabases(connectionId: string): Promise<DatabaseInfo[
return invoke("list_databases", { connectionId });
}
export async function listDatabaseStorage(connectionId: string, databases: string[]): Promise<DatabaseStorageInfo[]> {
return invoke("list_database_storage", { connectionId, databases });
}
export async function listDorisCatalogs(connectionId: string): Promise<CatalogInfo[]> {
return invoke("list_doris_catalogs", { connectionId });
}
@@ -44,7 +44,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"reuseDataTab",
"prefillNewQueryWithSelect",
"updateNotificationsEnabled",
"sidebarHideTableComments",
"sidebarObjectInfoMode",
"sidebarAllowHorizontalScroll",
"sidebarHiddenTablePrefixes",
"exportBatchSize",
@@ -0,0 +1,84 @@
import type { ConnectionConfig, DatabaseStorageInfo, ObjectStatistics, TreeNode } from "@/types/database";
const sidebarTableStorageTypes = new Set<ConnectionConfig["db_type"]>(["mysql", "postgres", "sqlserver", "oracle", "clickhouse", "dameng", "gaussdb", "kingbase", "gbase"]);
export function supportsSidebarDatabaseStorage(connection: ConnectionConfig | undefined): boolean {
return connection?.db_type === "postgres" && connection.driver_profile !== "cockroachdb";
}
export function supportsSidebarTableStorage(connection: ConnectionConfig | undefined): boolean {
if (!connection || !sidebarTableStorageTypes.has(connection.db_type)) return false;
if (connection.db_type === "gbase" && connection.driver_profile === "gbase8s") return false;
return connection.db_type !== "postgres" || connection.driver_profile !== "cockroachdb";
}
export function sidebarDatabaseNames(nodes: readonly TreeNode[] | undefined): string[] {
if (!nodes) return [];
return nodes.flatMap((node) => (node.type === "database" && !node.catalog && node.database ? [node.database] : []));
}
export function applySidebarDatabaseStorage(nodes: readonly TreeNode[] | undefined, storage: readonly DatabaseStorageInfo[]): boolean {
if (!nodes?.length || !storage.length) return false;
const byName = new Map(storage.map((item) => [item.name, item.size_bytes] as const));
let changed = false;
for (const node of nodes) {
if (node.type !== "database" || node.catalog || !node.database || !byName.has(node.database)) continue;
const sizeBytes = byName.get(node.database) ?? null;
if (node.sizeBytes === sizeBytes) continue;
node.sizeBytes = sizeBytes;
changed = true;
}
return changed;
}
export interface SidebarTableStorageScope {
connectionId: string;
database: string;
schema: string;
}
export function sidebarTableStorageScopes(nodes: readonly TreeNode[]): SidebarTableStorageScope[] {
const scopes = new Map<string, SidebarTableStorageScope>();
for (const node of nodes) {
if ((node.type !== "table" && node.type !== "materialized_view") || !node.connectionId || !node.database) continue;
const scope = { connectionId: node.connectionId, database: node.database, schema: node.schema || "" };
scopes.set(`${scope.connectionId}\0${scope.database}\0${scope.schema}`, scope);
}
return [...scopes.values()];
}
export function applySidebarTableStorage(nodes: readonly TreeNode[] | undefined, scope: SidebarTableStorageScope, statistics: readonly ObjectStatistics[]): boolean {
if (!nodes?.length || !statistics.length) return false;
const sizeByName = new Map(statistics.filter((item) => !scope.schema || !item.schema || item.schema === scope.schema).map((item) => [item.name, item.total_bytes ?? null] as const));
let changed = false;
const visit = (items: readonly TreeNode[]) => {
for (const node of items) {
if ((node.type === "table" || node.type === "materialized_view") && node.connectionId === scope.connectionId && node.database === scope.database && (node.schema || "") === scope.schema && sizeByName.has(node.label)) {
const sizeBytes = sizeByName.get(node.label) ?? null;
if (node.sizeBytes !== sizeBytes) {
node.sizeBytes = sizeBytes;
changed = true;
}
}
if (node.children?.length) visit(node.children);
if (node.hiddenChildren?.length) visit(node.hiddenChildren);
}
};
visit(nodes);
return changed;
}
export function formatSidebarObjectStorage(value: number | null | undefined): string {
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return "";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let size = value;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
const fractionDigits = unitIndex === 0 || size >= 100 ? 0 : size >= 10 ? 1 : 2;
const rounded = size.toFixed(fractionDigits);
const displaySize = rounded.includes(".") ? rounded.replace(/0+$/, "").replace(/\.$/, "") : rounded;
return `${displaySize} ${units[unitIndex]}`;
}
@@ -1,4 +1,4 @@
import type { TreeNodeType } from "@/types/database";
import type { TreeNode, TreeNodeType } from "@/types/database";
const leafTypes: Set<TreeNodeType> = new Set([
"column",
@@ -51,14 +51,53 @@ const pinnableTypes: Set<TreeNodeType> = new Set([
"nacos-namespace",
]);
const commentTypes: Set<TreeNodeType> = new Set(["schema", "table", "view", "materialized_view", "column", "mongo-collection", "vector-collection", "elasticsearch-index"]);
export function treeItemPaddingLeft(depth: number): string {
return `${depth * 16 + 8}px`;
}
export const trailingCommentGapPx = 8;
export interface SidebarCommentAlignmentItem {
id: string;
depth: number;
alignable: boolean;
hasComment: boolean;
labelWidth: number;
}
export function trailingCommentAvailableWidth(containerWidth: number, leadingWidth: number): number {
return Math.max(0, Math.floor(containerWidth - leadingWidth - trailingCommentGapPx));
export function alignedSidebarCommentLabelWidths(items: readonly SidebarCommentAlignmentItem[]): Map<string, number> {
const ancestorIds: string[] = [];
const parentIdByCommentId = new Map<string, string>();
const maxWidthByParentId = new Map<string, number>();
for (const item of items) {
ancestorIds.length = item.depth;
const parentId = item.depth > 0 ? (ancestorIds[item.depth - 1] ?? "__root__") : "__root__";
ancestorIds[item.depth] = item.id;
if (!item.alignable) continue;
maxWidthByParentId.set(parentId, Math.max(maxWidthByParentId.get(parentId) ?? 0, Math.ceil(item.labelWidth)));
if (item.hasComment) parentIdByCommentId.set(item.id, parentId);
}
const widths = new Map<string, number>();
for (const [id, parentId] of parentIdByCommentId) {
widths.set(id, maxWidthByParentId.get(parentId) ?? 0);
}
return widths;
}
export function sidebarTreeNodeComment(node: TreeNode): string | null {
if (!commentTypes.has(node.type)) return null;
if (node.type === "column" && node.meta && "comment" in node.meta) {
const comment = node.meta.comment;
return typeof comment === "string" && comment ? comment : null;
}
return node.comment || null;
}
export function isSidebarCommentAlignableNode(node: TreeNode): boolean {
return commentTypes.has(node.type);
}
export function usesFullWidthTreeLabel(type: TreeNodeType, allowHorizontalScroll: boolean, hasTrailingComment = false): boolean {