feat(sidebar): add configurable object metadata display
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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 })}`);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user