feat(sidebar): add configurable object metadata display

This commit is contained in:
zipg 2026-07-23 00:23:19 +08:00 committed by GitHub
parent 8adf802444
commit 57f2d27313
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
29 changed files with 889 additions and 143 deletions

View File

@ -39,6 +39,7 @@ import {
type InterfaceLayout,
type DisconnectTabHandlingMode,
type OpenTabsRestoreMode,
type SidebarObjectInfoMode,
type SqlSemanticDiagnosticsMode,
type UpdateDownloadSource,
type CustomThemeColors,
@ -356,7 +357,7 @@ const editReuseDataTab = ref(settingsStore.editorSettings.reuseDataTab);
const editPrefillNewQueryWithSelect = ref(settingsStore.editorSettings.prefillNewQueryWithSelect);
const editUpdateNotificationsEnabled = ref(settingsStore.editorSettings.updateNotificationsEnabled);
const editSidebarHiddenTablePrefixes = ref(settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n"));
const editSidebarHideTableComments = ref(settingsStore.editorSettings.sidebarHideTableComments);
const editSidebarObjectInfoMode = ref<SidebarObjectInfoMode>(settingsStore.editorSettings.sidebarObjectInfoMode);
const editSidebarAllowHorizontalScroll = ref(settingsStore.editorSettings.sidebarAllowHorizontalScroll);
const editExportBatchSize = ref(settingsStore.editorSettings.exportBatchSize);
const editGlobalDateTimeDisplayFormat = ref(settingsStore.editorSettings.globalDateTimeDisplayFormat);
@ -456,7 +457,7 @@ function currentEditorSettingsDraft(): EditorSettingsDraft {
reuseDataTab: editReuseDataTab.value,
prefillNewQueryWithSelect: editPrefillNewQueryWithSelect.value,
updateNotificationsEnabled: editUpdateNotificationsEnabled.value,
sidebarHideTableComments: editSidebarHideTableComments.value,
sidebarObjectInfoMode: editSidebarObjectInfoMode.value,
sidebarAllowHorizontalScroll: editSidebarAllowHorizontalScroll.value,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(editSidebarHiddenTablePrefixes.value),
exportBatchSize: editExportBatchSize.value,
@ -709,7 +710,7 @@ function syncEditorSettingsDraftFromStore() {
editPrefillNewQueryWithSelect.value = settingsStore.editorSettings.prefillNewQueryWithSelect;
editUpdateNotificationsEnabled.value = settingsStore.editorSettings.updateNotificationsEnabled;
editSidebarHiddenTablePrefixes.value = settingsStore.editorSettings.sidebarHiddenTablePrefixes.join("\n");
editSidebarHideTableComments.value = settingsStore.editorSettings.sidebarHideTableComments;
editSidebarObjectInfoMode.value = settingsStore.editorSettings.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = settingsStore.editorSettings.sidebarAllowHorizontalScroll;
editExportBatchSize.value = settingsStore.editorSettings.exportBatchSize;
editGlobalDateTimeDisplayFormat.value = settingsStore.editorSettings.globalDateTimeDisplayFormat;
@ -817,6 +818,7 @@ async function persistSettings() {
const sidebarTablePageSizeChanged = editSidebarTablePageSize.value !== (settingsStore.desktopSettings.sidebar_table_page_size ?? DEFAULT_SIDEBAR_TABLE_PAGE_SIZE);
if (Object.keys(editorSettingsPatch).length > 0) {
settingsStore.updateEditorSettings(editorSettingsPatch);
await settingsStore.persistEditorSettings();
editEditorSettingsBase.value = editorSettingsDraftFromSettings(settingsStore.editorSettings);
}
await settingsStore.updateDesktopSettings({
@ -907,7 +909,7 @@ function resetDefaultsForTab(tab: SettingsCategory) {
editReuseDataTab.value = DEFAULT_EDITOR_SETTINGS.reuseDataTab;
editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect;
editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled;
editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments;
editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editToolbarItems.value = { ...DEFAULT_EDITOR_SETTINGS.toolbarItems };
@ -992,7 +994,7 @@ function resetAllDefaults() {
editReuseDataTab.value = DEFAULT_EDITOR_SETTINGS.reuseDataTab;
editPrefillNewQueryWithSelect.value = DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect;
editUpdateNotificationsEnabled.value = DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled;
editSidebarHideTableComments.value = DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments;
editSidebarObjectInfoMode.value = DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode;
editSidebarAllowHorizontalScroll.value = DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll;
editSidebarHiddenTablePrefixes.value = DEFAULT_EDITOR_SETTINGS.sidebarHiddenTablePrefixes.join("\n");
editExportBatchSize.value = DEFAULT_EDITOR_SETTINGS.exportBatchSize;
@ -4002,14 +4004,24 @@ onUnmounted(cleanupPreviewEditor);
{{ t(`settings.${disconnectTabHandlingModeDescriptionKey}`) }}
</p>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="space-y-2 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">
<Label for="sidebar-hide-table-comments">{{ t("settings.sidebarHideTableComments") }}</Label>
<HelpTooltip :label="t('settings.sidebarHideTableComments')">
{{ t("settings.sidebarHideTableCommentsDescription") }}
<Label for="sidebar-object-info-mode">{{ t("settings.sidebarObjectInfoMode") }}</Label>
<HelpTooltip :label="t('settings.sidebarObjectInfoMode')">
{{ t("settings.sidebarObjectInfoModeDescription") }}
</HelpTooltip>
</div>
<Switch id="sidebar-hide-table-comments" v-model="editSidebarHideTableComments" />
<Select :model-value="editSidebarObjectInfoMode" @update:model-value="(value) => (editSidebarObjectInfoMode = value as SidebarObjectInfoMode)">
<SelectTrigger id="sidebar-object-info-mode" class="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="comment-aligned">{{ t("settings.sidebarObjectInfoModeCommentAligned") }}</SelectItem>
<SelectItem value="comment-inline">{{ t("settings.sidebarObjectInfoModeCommentInline") }}</SelectItem>
<SelectItem value="size">{{ t("settings.sidebarObjectInfoModeSize") }}</SelectItem>
<SelectItem value="hidden">{{ t("settings.sidebarObjectInfoModeHidden") }}</SelectItem>
</SelectContent>
</Select>
</div>
<div class="flex items-center justify-between gap-4 rounded-md border bg-muted/20 px-3 py-2">
<div class="flex items-center gap-2">

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
import { ref, shallowRef, computed, nextTick, watch, provide, onMounted, onUnmounted, type Component, type ComponentPublicInstance, type CSSProperties } from "vue";
import { useI18n } from "vue-i18n";
import { Search, X, ListFilter, ListOrdered, ArrowDownAZ, ArrowUpZA, Crosshair, Server, Database, FolderTree, Table2, Eye, RotateCcw } from "@lucide/vue";
import { useConnectionStore } from "@/stores/connectionStore";
@ -38,6 +38,9 @@ import type { SidebarDangerDialogRequest } from "@/lib/sidebar/sidebarDangerDial
import { resetSidebarTreeDialogState } from "./sidebarTreeDialogState";
import { SidebarDangerConfirmDialog, SidebarDdlViewDialog, SidebarObjectSourceDialog, SidebarProcedureExecutionDialog, SidebarVisibleDatabasesDialog, SidebarVisibleSchemasDialog } from "./sidebarAsyncDialogs";
import { sortConnectionListForDisplay } from "@/lib/sidebar/connectionListSort";
import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNodeComment } from "@/lib/sidebar/sidebarTreeItemLayout";
import { sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
const { t } = useI18n();
const store = useConnectionStore();
@ -357,6 +360,64 @@ const flatNodes = computed<FlatTreeNode[]>(() =>
activeQueries: store.sidebarTableSearchQueries,
}),
);
const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
let sidebarCommentMeasureFrame = 0;
const sidebarTableNameDisplayTypes = new Set<TreeNodeType>(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]);
function sidebarCommentLabel(node: TreeNode): string {
const label = sidebarTableNameDisplayTypes.has(node.type) ? sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes) : node.label;
return node.valid === false ? `${label} · INVALID` : label;
}
function measureSidebarCommentLabelWidths() {
sidebarCommentMeasureFrame = 0;
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "comment-aligned" || typeof document === "undefined" || !rootRef.value) {
sidebarCommentLabelWidths.value = new Map();
return;
}
const context = document.createElement("canvas").getContext("2d");
if (!context) return;
const style = window.getComputedStyle(rootRef.value);
context.font = style.font || `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;
sidebarCommentLabelWidths.value = alignedSidebarCommentLabelWidths(
flatNodes.value.map(({ id, depth, node }) => ({
id,
depth,
alignable: isSidebarCommentAlignableNode(node),
hasComment: !!sidebarTreeNodeComment(node),
labelWidth: context.measureText(sidebarCommentLabel(node)).width,
})),
);
}
function scheduleSidebarCommentLabelMeasure() {
if (typeof window === "undefined") {
measureSidebarCommentLabelWidths();
return;
}
if (sidebarCommentMeasureFrame) window.cancelAnimationFrame(sidebarCommentMeasureFrame);
sidebarCommentMeasureFrame = window.requestAnimationFrame(measureSidebarCommentLabelWidths);
}
watch([flatNodes, () => settingsStore.editorSettings.sidebarObjectInfoMode, () => settingsStore.editorSettings.sidebarHiddenTablePrefixes, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarCommentLabelMeasure, {
flush: "post",
immediate: true,
});
const visibleSidebarTableStorageScopes = computed(() => {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return [];
return sidebarTableStorageScopes(flatNodes.value.map(({ node }) => node)).filter((scope) => supportsSidebarTableStorage(store.getConfig(scope.connectionId)));
});
watch(
visibleSidebarTableStorageScopes,
(scopes) => {
for (const scope of scopes) void store.loadSidebarTableStorage(scope);
},
{ flush: "post", immediate: true },
);
// Build all lookup tables in one linear pass whenever the visible tree changes.
// Selection, scrolling and sticky headers then avoid repeated full-array scans.
const flatTreeIndex = computed(() =>
@ -1306,6 +1367,7 @@ onUnmounted(() => {
sidebarScrollbarResizeObserver?.disconnect();
window.cancelAnimationFrame(sidebarScrollbarAnimationFrame);
window.clearTimeout(sidebarScrollingTimer);
if (sidebarCommentMeasureFrame) window.cancelAnimationFrame(sidebarCommentMeasureFrame);
});
defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
@ -1413,6 +1475,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:drag-disabled="isFiltering || isConnectionListAlphabeticallySorted"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.node.id"
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"
@ -1420,7 +1483,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
</template>
</RecycleScroller>
<div v-if="stickyNode" class="sticky-database-header pointer-events-auto absolute inset-x-0 top-0 z-[5] border-b border-border/60" :style="stickyHeaderStyle">
<TreeItem :node="stickyNode.node" :depth="stickyNode.depth" :drag-disabled="true" @context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)" />
<TreeItem :node="stickyNode.node" :depth="stickyNode.depth" :drag-disabled="true" :comment-label-width="sidebarCommentLabelWidths.get(stickyNode.node.id)" @context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)" />
</div>
<div v-if="hasSidebarVerticalOverflow" ref="sidebarScrollbarTrackRef" class="sidebar-tree-scrollbar" :class="{ 'sidebar-tree-scrollbar--scrolling': isScrollingSidebar, 'sidebar-tree-scrollbar--dragging': isDraggingSidebarScrollbar }" @pointerdown="onSidebarScrollbarTrackPointerDown">
<div class="sidebar-tree-scrollbar__thumb" :style="sidebarScrollbarThumbStyle" @pointerdown.stop="onSidebarScrollbarThumbPointerDown" />
@ -1436,6 +1499,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes });
:drag-disabled="isFiltering || isConnectionListAlphabeticallySorted"
:pending-rename="pendingRenameGroupId === item.node.id"
:highlighted="highlightedNodeId === item.id"
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
@rename-started="pendingRenameGroupId = null"
@group-created="startRenamingCreatedGroup"

View File

@ -44,8 +44,9 @@ import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import LightTooltip from "@/components/ui/LightTooltip.vue";
import type { ColumnInfo, ConnectionConfig, DatabaseType, TreeNode, TreeNodeType } from "@/types/database";
import { canTreeNodeShowExpander, trailingCommentAvailableWidth, trailingCommentGapPx, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { canTreeNodeShowExpander, sidebarTreeNodeComment, treeItemPaddingLeft, treeLabelWidthClass, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
import { clearActiveTableReferencePayload, createTableReferencePayload, createTableReferenceDropEvent, setActiveTableReferencePayload, type QueryEditorTableReferencePayload } from "@/lib/editor/queryEditorTableDrop";
import { formatSidebarObjectStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
import { dataTabOpenModeFromTreeClick } from "@/lib/sidebar/dataTabOpenPolicy";
import { effectiveDatabaseTypeForConnection } from "@/lib/database/jdbcDialect";
import { hexToRgba } from "@/lib/common/color";
@ -68,22 +69,12 @@ const labelRef = ref<HTMLElement>();
const rowRef = ref<HTMLElement>();
const trailingCommentLayoutRef = ref<HTMLElement>();
const trailingCommentLeadingRef = ref<HTMLElement>();
const trailingCommentMaxWidth = ref(0);
const labelOverflowing = ref(false);
let labelResizeObserver: ResizeObserver | null = null;
let trailingCommentResizeObserver: ResizeObserver | null = null;
let labelMeasureFrame = 0;
let trailingCommentMeasureFrame = 0;
function cancelLabelOverflowMeasure() {
if (!labelMeasureFrame) return;
window.cancelAnimationFrame(labelMeasureFrame);
@ -150,6 +141,7 @@ const props = defineProps<{
dragDisabled?: boolean;
pendingRename?: boolean;
highlighted?: boolean;
commentLabelWidth?: number;
}>();
const emit = defineEmits<{
@ -549,71 +541,26 @@ const isNodeDefaultDatabase = computed(
);
const trailingComment = computed(() => {
if (settingsStore.editorSettings.sidebarHideTableComments) return null;
if (activeNode.value.type === "column" && activeNode.value.meta && "comment" in activeNode.value.meta) return (activeNode.value.meta as any).comment || null;
if ((activeNode.value.type === "schema" || activeNode.value.type === "table" || activeNode.value.type === "view" || activeNode.value.type === "mongo-collection" || activeNode.value.type === "vector-collection" || activeNode.value.type === "elasticsearch-index") && activeNode.value.comment) {
return activeNode.value.comment;
}
return null;
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "comment-inline" && settingsStore.editorSettings.sidebarObjectInfoMode !== "comment-aligned") return null;
return sidebarTreeNodeComment(activeNode.value);
});
function cancelTrailingCommentMeasure() {
if (!trailingCommentMeasureFrame) return;
window.cancelAnimationFrame(trailingCommentMeasureFrame);
trailingCommentMeasureFrame = 0;
function formattedObjectStorage(): string {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || (activeNode.value.type !== "database" && activeNode.value.type !== "table" && activeNode.value.type !== "materialized_view")) return "";
return formatSidebarObjectStorage(activeNode.value.sizeBytes);
}
function measureTrailingCommentLayout() {
const container = trailingCommentLayoutRef.value;
const leading = trailingCommentLeadingRef.value;
if (!trailingComment.value || !container || !leading) {
trailingCommentMaxWidth.value = 0;
return;
}
const alignedCommentLabelWidth = computed(() => (settingsStore.editorSettings.sidebarObjectInfoMode === "comment-aligned" ? props.commentLabelWidth : undefined));
// The leading group keeps the complete table name ahead of the comment.
// Only the width remaining after that name and the fixed gap may be used
// by the comment; once it reaches zero, the comment is hidden.
trailingCommentMaxWidth.value = trailingCommentAvailableWidth(container.clientWidth, leading.scrollWidth);
function hasTrailingMetadata(): boolean {
return !!trailingComment.value || !!formattedObjectStorage();
}
function scheduleTrailingCommentMeasure() {
if (typeof window === "undefined") {
measureTrailingCommentLayout();
return;
}
cancelTrailingCommentMeasure();
trailingCommentMeasureFrame = window.requestAnimationFrame(() => {
trailingCommentMeasureFrame = 0;
measureTrailingCommentLayout();
});
}
function refreshTrailingCommentMeasurement() {
trailingCommentResizeObserver?.disconnect();
trailingCommentResizeObserver = null;
if (!trailingComment.value || !trailingCommentLayoutRef.value || !trailingCommentLeadingRef.value) {
trailingCommentMaxWidth.value = 0;
return;
}
scheduleTrailingCommentMeasure();
if (typeof ResizeObserver !== "undefined") {
trailingCommentResizeObserver = new ResizeObserver(scheduleTrailingCommentMeasure);
trailingCommentResizeObserver.observe(trailingCommentLayoutRef.value);
}
}
// Keep comment rows constrained to the sidebar. When space is tight, the
// comment truncates before the table name instead of creating a large gap.
const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(activeNode.value.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll, !!trailingComment.value));
const usesFullWidthLabel = computed(() => usesFullWidthTreeLabel(activeNode.value.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll, hasTrailingMetadata()));
const rowWidthClass = computed(() => (usesFullWidthLabel.value ? "w-max min-w-full" : "w-full min-w-0"));
const labelWidthClass = computed(() => treeLabelWidthClass({ fullWidth: usesFullWidthLabel.value, hasTrailingComment: !!trailingComment.value }));
watch(() => [trailingComment.value, visibleLabel(activeNode.value), trailingCommentLayoutRef.value, trailingCommentLeadingRef.value], refreshTrailingCommentMeasurement, { flush: "post", immediate: true });
const labelWidthClass = computed(() => treeLabelWidthClass({ fullWidth: usesFullWidthLabel.value, hasTrailingComment: hasTrailingMetadata() }));
const paddingLeft = computed(() => treeItemPaddingLeft(props.depth));
@ -921,8 +868,6 @@ watch(
onBeforeUnmount(() => {
stopPasteHandlerRegistration();
handleMouseLeave();
trailingCommentResizeObserver?.disconnect();
cancelTrailingCommentMeasure();
finishTableReferenceDrag();
});
@ -1058,8 +1003,8 @@ function onKeydown(event: KeyboardEvent) {
<DatabaseIcon v-if="node.type === 'connection'" :db-type="connectionIconType(node.connectionId)" class="h-3.5 w-3.5 shrink-0" />
<Loader2 v-else-if="node.type === 'load-more' && node.isLoading" class="w-3.5 h-3.5 shrink-0 animate-spin text-primary" />
<component v-else :is="getIconInfo(node)?.icon || Database" class="w-3.5 h-3.5 shrink-0" :class="databaseOpenVisual.iconClass" />
<div ref="trailingCommentLayoutRef" :class="trailingComment ? 'flex flex-1 min-w-0 items-center' : 'contents'">
<div ref="trailingCommentLeadingRef" :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : 'contents'">
<div :class="hasTrailingMetadata() ? 'flex flex-1 min-w-0 items-center' : 'contents'">
<div :class="trailingComment ? 'flex max-w-full min-w-0 shrink-0 items-center gap-2' : formattedObjectStorage() ? 'flex min-w-0 flex-1 items-center gap-2' : 'contents'" :style="alignedCommentLabelWidth ? { width: `${alignedCommentLabelWidth}px` } : undefined">
<input
v-if="isRenamingGroup"
ref="renameInputRef"
@ -1084,20 +1029,14 @@ function onKeydown(event: KeyboardEvent) {
{{ t("editor.defaultDatabase") }}
</Badge>
</div>
<span v-if="trailingComment && trailingCommentMaxWidth > 0" class="min-w-0 flex-1" aria-hidden="true" />
<span
v-if="trailingComment && trailingCommentMaxWidth > 0"
class="sidebar-object-comment min-w-0 shrink-0 truncate text-left"
:class="{ 'sidebar-object-comment--windows': useWindowsSidebarCommentFont }"
:style="{ marginLeft: `${trailingCommentGapPx}px`, maxWidth: `${trailingCommentMaxWidth}px` }"
>{{ trailingComment }}</span
>
<span v-if="trailingComment" class="sidebar-object-comment ml-2 min-w-0 flex-1 truncate text-left" :class="{ 'sidebar-object-comment--windows': useWindowsSidebarCommentFont }">{{ trailingComment }}</span>
</div>
<span v-if="node.type === 'connection' && node.connectionId && connectionStore.connectedIds.has(node.connectionId)" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<span v-if="databaseOpenVisual.showsIndicator" class="w-1.5 h-1.5 rounded-full bg-green-500 shrink-0" />
<Badge v-if="isConnectionReadonly" variant="secondary" class="h-4 px-1.5 text-[10px] gap-0.5"><Lock class="w-2.5 h-2.5" />{{ t("connection.readOnlyBadge") }}</Badge>
<ConnectionErrorIndicator v-if="node.type === 'connection'" :connection-id="node.connectionId" trigger-class="h-4 w-4" />
<Pin v-if="isPinned" class="w-3 h-3 shrink-0 text-primary fill-current" aria-hidden="true" />
<span v-if="formattedObjectStorage()" class="ml-auto shrink-0 text-right text-xs tabular-nums text-muted-foreground">{{ formattedObjectStorage() }}</span>
<button
v-if="isConnecting"
type="button"
@ -1145,11 +1084,6 @@ function onKeydown(event: KeyboardEvent) {
font-size: 10px;
line-height: 1rem;
opacity: 0.6;
/* Use the comment's natural width whenever the row has room for it. */
width: max-content;
max-width: 100%;
/* Preserve table names when a narrow row needs to truncate its comment. */
flex-shrink: 999;
/* Sidebar rows repaint on hover; avoid heavier font shaping and fallback here. */
text-rendering: auto;
}

View File

@ -3816,8 +3816,13 @@ export default {
sidebarTablePageSizeDescription: "Maximum number of tables/objects loaded per page in the sidebar tree. Increase if you have many tables and want fewer pages.",
sidebarTableSearchEnabled: "Enable table search inside databases",
sidebarTableSearchEnabledDescription: "Show a local table search box under expanded databases, schemas, or table groups so each scope can filter tables independently.",
sidebarHideTableComments: "Hide table comments in sidebar",
sidebarHideTableCommentsDescription: "Hide the inline table/view comments shown next to names in the sidebar tree to save horizontal space.",
sidebarObjectInfoMode: "Sidebar supplementary info",
sidebarObjectInfoModeDescription:
"Choose comments, object sizes, or no supplementary text after names. Comments and sizes are mutually exclusive. Database totals currently support PostgreSQL; table sizes support MySQL, PostgreSQL, GaussDB, Kingbase, GBase 8a, SQL Server, Oracle, Dameng, and ClickHouse.",
sidebarObjectInfoModeCommentInline: "Comments (after name)",
sidebarObjectInfoModeCommentAligned: "Comments (align siblings)",
sidebarObjectInfoModeSize: "Object size",
sidebarObjectInfoModeHidden: "None",
sidebarAllowHorizontalScroll: "Allow sidebar horizontal scroll",
sidebarAllowHorizontalScrollDescription: "Show long table, view, and collection names in full by allowing horizontal sidebar scrolling.",
snippetsDescription: "Customize SQL snippet templates triggered in the editor.",

View File

@ -3594,8 +3594,13 @@ export default withEnglishFallback({
sidebarTablePageSizeDescription: "Número máximo de tablas/objetos cargados por página en el árbol lateral. Auméntalo si tienes muchas tablas para reducir páginas.",
sidebarTableSearchEnabled: "Activar búsqueda de tablas por base",
sidebarTableSearchEnabledDescription: "Muestra un buscador local bajo bases de datos, schemas o grupos de tablas expandidos para filtrar tablas por separado.",
sidebarHideTableComments: "Ocultar comentarios de tablas en la barra lateral",
sidebarHideTableCommentsDescription: "Oculta los comentarios de tablas/vistas junto a los nombres en el árbol de la barra lateral para ahorrar espacio horizontal.",
sidebarObjectInfoMode: "Información adicional lateral",
sidebarObjectInfoModeDescription:
"Elige comentarios, tamaños de objetos o ningún texto tras los nombres. Los comentarios y tamaños son excluyentes. El tamaño total de bases admite PostgreSQL; los tamaños de tablas admiten MySQL, PostgreSQL, GaussDB, Kingbase, GBase 8a, SQL Server, Oracle, Dameng y ClickHouse.",
sidebarObjectInfoModeCommentInline: "Comentarios (junto al nombre)",
sidebarObjectInfoModeCommentAligned: "Comentarios (alinear nivel)",
sidebarObjectInfoModeSize: "Tamaño del objeto",
sidebarObjectInfoModeHidden: "Ninguna",
sidebarAllowHorizontalScroll: "Permitir desplazamiento horizontal lateral",
sidebarAllowHorizontalScrollDescription: "Muestra completos los nombres largos de tablas, vistas y colecciones permitiendo desplazamiento horizontal en la barra lateral.",
snippetsDescription: "Personaliza plantillas SQL activadas en el editor.",

View File

@ -3592,8 +3592,13 @@ export default withEnglishFallback({
sidebarTablePageSizeDescription: "Numero massimo di tabelle/oggetti caricati per pagina nell'albero laterale. Aumentalo se hai molte tabelle per ridurre le pagine.",
sidebarTableSearchEnabled: "Abilita ricerca tabelle nel database",
sidebarTableSearchEnabledDescription: "Mostra una casella di ricerca locale sotto database, schema o gruppi di tabelle espansi per filtrare le tabelle separatamente.",
sidebarHideTableComments: "Nascondi i commenti delle tabelle nella barra laterale",
sidebarHideTableCommentsDescription: "Nascondi i commenti in linea delle tabelle/viste mostrati accanto ai nomi nell'albero della barra laterale per risparmiare spazio orizzontale.",
sidebarObjectInfoMode: "Informazioni aggiuntive laterali",
sidebarObjectInfoModeDescription:
"Scegli commenti, dimensioni degli oggetti o nessun testo dopo i nomi. Commenti e dimensioni sono alternativi. La dimensione totale dei database supporta PostgreSQL; le dimensioni delle tabelle supportano MySQL, PostgreSQL, GaussDB, Kingbase, GBase 8a, SQL Server, Oracle, Dameng e ClickHouse.",
sidebarObjectInfoModeCommentInline: "Commenti (accanto al nome)",
sidebarObjectInfoModeCommentAligned: "Commenti (allinea livello)",
sidebarObjectInfoModeSize: "Dimensione oggetto",
sidebarObjectInfoModeHidden: "Nessuna",
sidebarAllowHorizontalScroll: "Consenti scorrimento orizzontale barra laterale",
sidebarAllowHorizontalScrollDescription: "Mostra i nomi lunghi di tabelle, viste e collezioni per intero consentendo lo scorrimento orizzontale della barra laterale.",
snippetsDescription: "Personalizza i modelli di snippet SQL attivati nell'editor.",

View File

@ -3583,8 +3583,13 @@ export default withEnglishFallback({
sidebarTablePageSizeDescription: "サイドバーツリーで1ページあたりに読み込むテーブル/オブジェクトの最大数です。テーブルが多い場合は増やすとページ切り替えが減ります。",
sidebarTableSearchEnabled: "データベース内のテーブル検索を有効化",
sidebarTableSearchEnabledDescription: "展開済みのデータベース、schema、またはテーブルグループの下にローカル検索ボックスを表示し、スコープごとにテーブルを絞り込みます。",
sidebarHideTableComments: "サイドバーのテーブルコメントを非表示",
sidebarHideTableCommentsDescription: "横のスペースを節約するため、サイドバーツリーで名前の横に表示されるテーブル/ビューコメントを非表示にします。",
sidebarObjectInfoMode: "サイドバーの補足情報",
sidebarObjectInfoModeDescription:
"名前の後にコメント、オブジェクトサイズ、または何も表示しないかを選択します。コメントとサイズは同時に表示されません。データベース全体のサイズは PostgreSQL、テーブルサイズは MySQL、PostgreSQL、GaussDB、Kingbase、GBase 8a、SQL Server、Oracle、Dameng、ClickHouse に対応しています。",
sidebarObjectInfoModeCommentInline: "コメント(名前の直後)",
sidebarObjectInfoModeCommentAligned: "コメント(同階層で整列)",
sidebarObjectInfoModeSize: "オブジェクトサイズ",
sidebarObjectInfoModeHidden: "表示しない",
sidebarAllowHorizontalScroll: "サイドバーの横スクロールを許可",
sidebarAllowHorizontalScrollDescription: "サイドバーの横スクロールを許可して、長いテーブル、ビュー、コレクション名を完全に表示します。",
snippetsDescription: "エディタでトリガーされるSQLスニペットテンプレートをカスタマイズします。",

View File

@ -3594,8 +3594,13 @@ export default withEnglishFallback({
sidebarTablePageSizeDescription: "Número máximo de tabelas/objetos carregados por página na árvore lateral. Aumente se tiver muitas tabelas para reduzir páginas.",
sidebarTableSearchEnabled: "Ativar busca de tabelas por banco",
sidebarTableSearchEnabledDescription: "Mostra uma busca local sob bancos, schemas ou grupos de tabelas expandidos para filtrar tabelas separadamente.",
sidebarHideTableComments: "Ocultar comentários de tabela na barra lateral",
sidebarHideTableCommentsDescription: "Ocultar os comentários inline de tabela/view exibidos ao lado dos nomes na árvore da barra lateral para economizar espaço horizontal.",
sidebarObjectInfoMode: "Informações adicionais laterais",
sidebarObjectInfoModeDescription:
"Escolha comentários, tamanhos de objetos ou nenhum texto após os nomes. Comentários e tamanhos são mutuamente exclusivos. O tamanho total dos bancos aceita PostgreSQL; os tamanhos das tabelas aceitam MySQL, PostgreSQL, GaussDB, Kingbase, GBase 8a, SQL Server, Oracle, Dameng e ClickHouse.",
sidebarObjectInfoModeCommentInline: "Comentários (junto ao nome)",
sidebarObjectInfoModeCommentAligned: "Comentários (alinhar nível)",
sidebarObjectInfoModeSize: "Tamanho do objeto",
sidebarObjectInfoModeHidden: "Nenhuma",
sidebarAllowHorizontalScroll: "Permitir rolagem horizontal da barra lateral",
sidebarAllowHorizontalScrollDescription: "Mostrar nomes longos de tabelas, views e coleções por completo, permitindo a rolagem horizontal da barra lateral.",
snippetsDescription: "Personalize os modelos de snippets SQL acionados no editor.",

View File

@ -3806,8 +3806,12 @@ export default withEnglishFallback({
sidebarTablePageSizeDescription: "侧边栏树每页最多加载的表/对象数量。表多时可以调大减少翻页次数。",
sidebarTableSearchEnabled: "启用库下表搜索框",
sidebarTableSearchEnabledDescription: "在已展开的数据库、schema 或表分组下显示局部表搜索框,用于为不同库分别过滤表。",
sidebarHideTableComments: "隐藏侧边栏表注释",
sidebarHideTableCommentsDescription: "隐藏侧边栏中表名旁边显示的表/视图注释,以节省横向空间。",
sidebarObjectInfoMode: "侧边栏附加信息",
sidebarObjectInfoModeDescription: "选择在名称后显示注释、对象大小或不显示。注释与大小互斥;数据库总大小目前支持 PostgreSQL表大小支持 MySQL、PostgreSQL、GaussDB、Kingbase、GBase 8a、SQL Server、Oracle、达梦和 ClickHouse。",
sidebarObjectInfoModeCommentInline: "注释(紧跟名称)",
sidebarObjectInfoModeCommentAligned: "注释(同级对齐)",
sidebarObjectInfoModeSize: "对象大小",
sidebarObjectInfoModeHidden: "不显示",
sidebarAllowHorizontalScroll: "允许侧边栏横向滚动",
sidebarAllowHorizontalScrollDescription: "完整显示较长的表、视图和集合名称;默认关闭以保留省略号截断。",
snippetsDescription: "自定义编辑器中触发的 SQL 代码片段模板。",

View File

@ -3406,8 +3406,12 @@ export default withEnglishFallback({
sidebarTablePageSizeDescription: "側邊欄樹每頁最多載入的資料表/物件數量。資料表多時可調大減少翻頁次數。",
sidebarTableSearchEnabled: "啟用庫下資料表搜尋框",
sidebarTableSearchEnabledDescription: "在已展開的資料庫、schema 或資料表分組下顯示局部資料表搜尋框,用於為不同庫分別篩選資料表。",
sidebarHideTableComments: "隱藏側邊欄資料表註解",
sidebarHideTableCommentsDescription: "隱藏側邊欄樹狀清單中名稱旁的資料表/檢視註解,以節省橫向空間。",
sidebarObjectInfoMode: "側邊欄附加資訊",
sidebarObjectInfoModeDescription: "選擇在名稱後顯示註解、物件大小或不顯示。註解與大小互斥;資料庫總大小目前支援 PostgreSQL資料表大小支援 MySQL、PostgreSQL、GaussDB、Kingbase、GBase 8a、SQL Server、Oracle、達夢和 ClickHouse。",
sidebarObjectInfoModeCommentInline: "註解(緊接名稱)",
sidebarObjectInfoModeCommentAligned: "註解(同層對齊)",
sidebarObjectInfoModeSize: "物件大小",
sidebarObjectInfoModeHidden: "不顯示",
sidebarAllowHorizontalScroll: "允許側邊欄水平捲動",
sidebarAllowHorizontalScrollDescription: "透過啟用側邊欄的水平捲動功能,完整顯示長表格、檢視和集合的名稱",
snippetsDescription: "自訂編輯器中觸發的 SQL 程式碼片段範本。",

View File

@ -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);
});
});

View File

@ -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);
});
});

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");

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 })}`);
}

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 });
}

View File

@ -44,7 +44,7 @@ export const EDITOR_SETTINGS_DRAFT_KEYS = [
"reuseDataTab",
"prefillNewQueryWithSelect",
"updateNotificationsEnabled",
"sidebarHideTableComments",
"sidebarObjectInfoMode",
"sidebarAllowHorizontalScroll",
"sidebarHiddenTablePrefixes",
"exportBatchSize",

View File

@ -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]}`;
}

View File

@ -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 {

View File

@ -1,9 +1,24 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { enforceRightSidebarPanelExclusivity, EXECUTE_MODE_CURRENT_DEFAULT_VERSION, normalizeDesktopSettings, normalizeEditorSettings, normalizeMcpGlobalPolicy, transitionRightSidebarPanels, type RightSidebarPanelState } from "@/stores/settingsStore";
import { createPinia, setActivePinia } from "pinia";
import { isProxy } from "vue";
import type { AiConfigItem } from "@/types/ai";
describe("normalizeEditorSettings", () => {
it("uses aligned comments by default and preserves legacy comment visibility", () => {
expect(normalizeEditorSettings({}).sidebarObjectInfoMode).toBe("comment-aligned");
expect(normalizeEditorSettings({ sidebarObjectInfoMode: "comment-aligned" }).sidebarObjectInfoMode).toBe("comment-aligned");
expect(normalizeEditorSettings({ sidebarObjectInfoMode: "comment-inline" }).sidebarObjectInfoMode).toBe("comment-inline");
expect(normalizeEditorSettings({ sidebarObjectInfoMode: "size" }).sidebarObjectInfoMode).toBe("size");
expect(normalizeEditorSettings({ sidebarTableCommentLayout: "aligned" } as any).sidebarObjectInfoMode).toBe("comment-aligned");
expect(normalizeEditorSettings({ sidebarTableCommentLayout: "hidden" } as any).sidebarObjectInfoMode).toBe("hidden");
expect(normalizeEditorSettings({ sidebarHideTableComments: false } as any).sidebarObjectInfoMode).toBe("comment-aligned");
expect(normalizeEditorSettings({ sidebarHideTableComments: true } as any).sidebarObjectInfoMode).toBe("hidden");
expect(normalizeEditorSettings({ sidebarHideTableComments: true, sidebarShowDatabaseSizes: true } as any).sidebarObjectInfoMode).toBe("hidden");
expect(normalizeEditorSettings({ sidebarShowDatabaseSizes: true } as any).sidebarObjectInfoMode).toBe("size");
expect(normalizeEditorSettings({ sidebarObjectInfoMode: "invalid" } as any).sidebarObjectInfoMode).toBe("comment-aligned");
});
it("defaults SQL execution to the current statement and migrates legacy execute-all settings", () => {
expect(normalizeEditorSettings({}).executeMode).toBe("current");
expect(normalizeEditorSettings({ executeMode: "all" }).executeMode).toBe("current");
@ -322,6 +337,10 @@ describe("settingsStore sidebar connection sort persistence", () => {
expect(store.editorSettings.sidebarConnectionSortMode).toBe("desc");
expect(saveEditorSettings).toHaveBeenCalledWith(expect.objectContaining({ sidebarConnectionSortMode: "desc" }));
expect(isProxy(saveEditorSettings.mock.calls[0][0])).toBe(false);
await store.persistEditorSettings();
expect(isProxy(saveEditorSettings.mock.calls[1][0])).toBe(false);
});
});

View File

@ -9,9 +9,11 @@ import type {
ConnectionConfig,
DatabaseType,
DatabaseConnectionInfo,
DatabaseStorageInfo,
CatalogInfo,
ForeignKeyInfo,
ObjectInfo,
ObjectStatistics,
SchemaInfo,
SidebarLayout,
TableInfo,
@ -97,6 +99,7 @@ import { MetadataTaskLimiter } from "@/lib/metadata/metadataTaskLimiter";
import i18n from "@/i18n";
import type { MqAdminConfig } from "@/types/mq";
import { RABBITMQ_MQ_TENANT, resolveMqSystemKindFromConnection } from "@/lib/mq/mqConsoleDefaults";
import { applySidebarDatabaseStorage, applySidebarTableStorage, sidebarDatabaseNames, supportsSidebarDatabaseStorage, supportsSidebarTableStorage, type SidebarTableStorageScope } from "@/lib/sidebar/sidebarDatabaseStorage";
const PINNED_TREE_NODES_STORAGE_KEY = "dbx-pinned-tree-nodes";
const ACTIVE_CONNECTION_STORAGE_KEY = "dbx-active-connection";
@ -108,6 +111,7 @@ const DISCONNECT_REQUEST_TIMEOUT_MS = 5_000;
const DEFAULT_KEEPALIVE_INTERVAL_SECS = 30;
const METADATA_LIST_PAGE_CACHE_TTL_MS = 30_000;
const METADATA_LIST_PAGE_CACHE_MAX_ENTRIES = 160;
const SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS = 30_000;
export const COMPLETION_METADATA_CONCURRENCY = 2;
const MONGO_LEGACY_DRIVER_PROFILE = "mongodb-legacy";
const MONGO_LEGACY_DRIVER_LABEL = "MongoDB (Legacy)";
@ -257,6 +261,10 @@ export const useConnectionStore = defineStore("connection", () => {
else localStorage.removeItem(ACTIVE_CONNECTION_STORAGE_KEY);
});
const treeNodes = ref<TreeNode[]>([]);
const sidebarDatabaseStorageCache = new Map<string, { expiresAt: number; value: DatabaseStorageInfo[] }>();
const sidebarDatabaseStorageInFlight = new Map<string, Promise<DatabaseStorageInfo[]>>();
const sidebarTableStorageCache = new Map<string, { expiresAt: number; value: ObjectStatistics[] }>();
const sidebarTableStorageInFlight = new Map<string, Promise<ObjectStatistics[]>>();
const pinnedTreeNodeIds = ref<Set<string>>(new Set());
const connectedIds = ref<Set<string>>(new Set());
const identifierQuotes = ref<Record<string, string>>({});
@ -2263,6 +2271,104 @@ export const useConnectionStore = defineStore("connection", () => {
beforeConnectHandler = handler;
}
function sidebarDatabaseStorageRequestKey(connectionId: string, databases: readonly string[]): string {
return `${connectionId}\0${[...databases].sort().join("\0")}`;
}
async function loadSidebarDatabaseStorage(connectionId: string, options?: { force?: boolean }): Promise<void> {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(connectionId)) return;
if (!supportsSidebarDatabaseStorage(getConfig(connectionId))) return;
const connectionNode = findConnectionNode(connectionId);
const databases = sidebarDatabaseNames(connectionNode?.children);
if (!databases.length) return;
const requestKey = sidebarDatabaseStorageRequestKey(connectionId, databases);
const cached = sidebarDatabaseStorageCache.get(requestKey);
if (!options?.force && cached && cached.expiresAt > Date.now()) {
applySidebarDatabaseStorage(connectionNode?.children, cached.value);
return;
}
let request = sidebarDatabaseStorageInFlight.get(requestKey);
if (!request) {
request = api.listDatabaseStorage(connectionId, databases);
sidebarDatabaseStorageInFlight.set(requestKey, request);
}
try {
const storage = await request;
sidebarDatabaseStorageCache.set(requestKey, {
expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
value: storage,
});
const currentNode = findConnectionNode(connectionId);
const currentNames = sidebarDatabaseNames(currentNode?.children);
if (sidebarDatabaseStorageRequestKey(connectionId, currentNames) === requestKey) {
applySidebarDatabaseStorage(currentNode?.children, storage);
}
} catch (error) {
console.debug("[DBX][sidebar-database-storage:unavailable]", { connectionId, error });
} finally {
if (sidebarDatabaseStorageInFlight.get(requestKey) === request) {
sidebarDatabaseStorageInFlight.delete(requestKey);
}
}
}
function sidebarTableStorageRequestKey(scope: SidebarTableStorageScope): string {
return `${scope.connectionId}\0${scope.database}\0${scope.schema}`;
}
async function loadSidebarTableStorage(scope: SidebarTableStorageScope, options?: { force?: boolean }): Promise<void> {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size" || !connectedIds.value.has(scope.connectionId)) return;
if (!supportsSidebarTableStorage(getConfig(scope.connectionId))) return;
const requestKey = sidebarTableStorageRequestKey(scope);
const cached = sidebarTableStorageCache.get(requestKey);
if (!options?.force && cached && cached.expiresAt > Date.now()) {
applySidebarTableStorage(treeNodes.value, scope, cached.value);
return;
}
let request = sidebarTableStorageInFlight.get(requestKey);
if (!request) {
request = api.listObjectStatistics(scope.connectionId, scope.database, scope.schema);
sidebarTableStorageInFlight.set(requestKey, request);
}
try {
const statistics = await request;
sidebarTableStorageCache.set(requestKey, {
expiresAt: Date.now() + SIDEBAR_DATABASE_STORAGE_CACHE_TTL_MS,
value: statistics,
});
applySidebarTableStorage(treeNodes.value, scope, statistics);
} catch (error) {
console.debug("[DBX][sidebar-table-storage:unavailable]", { ...scope, error });
} finally {
if (sidebarTableStorageInFlight.get(requestKey) === request) {
sidebarTableStorageInFlight.delete(requestKey);
}
}
}
const sidebarDatabaseStorageScope = computed(() => {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return "";
return [...connectedIds.value]
.filter((connectionId) => supportsSidebarDatabaseStorage(getConfig(connectionId)))
.map((connectionId) => sidebarDatabaseStorageRequestKey(connectionId, sidebarDatabaseNames(findConnectionNode(connectionId)?.children)))
.sort()
.join("\n");
});
watch(
sidebarDatabaseStorageScope,
() => {
if (settingsStore.editorSettings.sidebarObjectInfoMode !== "size") return;
for (const connectionId of connectedIds.value) {
void loadSidebarDatabaseStorage(connectionId);
}
},
{ flush: "post" },
);
async function loadDatabases(connectionId: string, options?: LoadTreeOptions) {
const configForScope = getConfig(connectionId);
return runTreeMetadataLoad(
@ -2410,6 +2516,7 @@ export const useConnectionStore = defineStore("connection", () => {
}
}
node.isExpanded = true;
if (options?.force) void loadSidebarDatabaseStorage(connectionId, { force: true });
} catch (e) {
recordMetadataLoadError(connectionId, e);
throw e;
@ -5504,6 +5611,8 @@ export const useConnectionStore = defineStore("connection", () => {
setBeforeConnectHandler,
initFromDisk,
loadDatabases,
loadSidebarDatabaseStorage,
loadSidebarTableStorage,
loadRedisDatabases,
refreshRedisDbKeyCounts,
loadEtcdRoot,

View File

@ -376,6 +376,8 @@ export interface CustomTheme {
export const DEFAULT_CUSTOM_THEMES: CustomTheme[] = [{ id: "default", name: "Custom", colors: { ...DEFAULT_CUSTOM_THEME_COLORS }, ddlColors: { ...DEFAULT_CUSTOM_THEME_DDL_COLORS } }];
export type SidebarObjectInfoMode = "comment-inline" | "comment-aligned" | "size" | "hidden";
export interface EditorSettings {
fontFamily: string;
fontSize: number;
@ -438,7 +440,7 @@ export interface EditorSettings {
prefillNewQueryWithSelect: boolean;
updateNotificationsEnabled: boolean;
sidebarHiddenTablePrefixes: string[];
sidebarHideTableComments: boolean;
sidebarObjectInfoMode: SidebarObjectInfoMode;
sidebarAllowHorizontalScroll: boolean;
columnFormatters: Record<string, ColumnFormatterConfig>;
customColumnFormatters: Record<string, CustomColumnFormatterConfig>;
@ -599,7 +601,7 @@ export const DEFAULT_EDITOR_SETTINGS: EditorSettings = {
prefillNewQueryWithSelect: true,
updateNotificationsEnabled: true,
sidebarHiddenTablePrefixes: [],
sidebarHideTableComments: false,
sidebarObjectInfoMode: "comment-aligned",
sidebarAllowHorizontalScroll: false,
columnFormatters: {},
customColumnFormatters: {},
@ -711,6 +713,14 @@ function normalizeConnectionListSortMode(value: unknown): ConnectionListSortMode
return value === "asc" || value === "desc" ? value : "manual";
}
function normalizeSidebarObjectInfoMode(value: unknown, legacyCommentLayout?: unknown, legacyHideTableComments?: unknown, legacyShowDatabaseSizes?: unknown): SidebarObjectInfoMode {
if (value === "comment-inline" || value === "comment-aligned" || value === "size" || value === "hidden") return value;
if (legacyCommentLayout === "hidden" || legacyHideTableComments === true) return "hidden";
if (legacyShowDatabaseSizes === true) return "size";
if (legacyCommentLayout === "aligned") return "comment-aligned";
return DEFAULT_EDITOR_SETTINGS.sidebarObjectInfoMode;
}
function normalizeColumnFormatters(value: unknown): Record<string, ColumnFormatterConfig> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
const formatters: Record<string, ColumnFormatterConfig> = {};
@ -861,7 +871,12 @@ export function normalizeEditorSettings(settings: Partial<EditorSettings>, exist
prefillNewQueryWithSelect: typeof settings.prefillNewQueryWithSelect === "boolean" ? settings.prefillNewQueryWithSelect : DEFAULT_EDITOR_SETTINGS.prefillNewQueryWithSelect,
updateNotificationsEnabled: settings.updateNotificationsEnabled ?? DEFAULT_EDITOR_SETTINGS.updateNotificationsEnabled,
sidebarHiddenTablePrefixes: normalizeSidebarHiddenTablePrefixes(settings.sidebarHiddenTablePrefixes),
sidebarHideTableComments: settings.sidebarHideTableComments ?? DEFAULT_EDITOR_SETTINGS.sidebarHideTableComments,
sidebarObjectInfoMode: normalizeSidebarObjectInfoMode(
settings.sidebarObjectInfoMode,
(settings as Partial<EditorSettings> & { sidebarTableCommentLayout?: string }).sidebarTableCommentLayout,
(settings as Partial<EditorSettings> & { sidebarHideTableComments?: boolean }).sidebarHideTableComments,
(settings as Partial<EditorSettings> & { sidebarShowDatabaseSizes?: boolean }).sidebarShowDatabaseSizes,
),
sidebarAllowHorizontalScroll: settings.sidebarAllowHorizontalScroll ?? DEFAULT_EDITOR_SETTINGS.sidebarAllowHorizontalScroll,
columnFormatters: normalizeColumnFormatters(settings.columnFormatters),
customColumnFormatters: normalizeCustomColumnFormatters(settings.customColumnFormatters),
@ -909,8 +924,12 @@ function clearLegacyEditorSettings() {
safeLocalStorageRemove(EXPORT_BATCH_SIZE_DEFAULT_MIGRATION_KEY);
}
function editorSettingsSnapshot(settings: EditorSettings): EditorSettings {
return JSON.parse(JSON.stringify(settings)) as EditorSettings;
}
function saveEditorSettings(settings: EditorSettings) {
void api.saveEditorSettings(settings).catch(() => {});
void api.saveEditorSettings(editorSettingsSnapshot(settings)).catch(() => {});
}
export interface SettingsNavigationRequest {
@ -1221,7 +1240,7 @@ export const useSettingsStore = defineStore("settings", () => {
if (partial.prefillNewQueryWithSelect !== undefined) editorSettings.value.prefillNewQueryWithSelect = partial.prefillNewQueryWithSelect;
if (partial.updateNotificationsEnabled !== undefined) editorSettings.value.updateNotificationsEnabled = partial.updateNotificationsEnabled;
if (partial.sidebarHiddenTablePrefixes !== undefined) editorSettings.value.sidebarHiddenTablePrefixes = normalizeSidebarHiddenTablePrefixes(partial.sidebarHiddenTablePrefixes);
if (partial.sidebarHideTableComments !== undefined) editorSettings.value.sidebarHideTableComments = partial.sidebarHideTableComments;
if (partial.sidebarObjectInfoMode !== undefined) editorSettings.value.sidebarObjectInfoMode = normalizeSidebarObjectInfoMode(partial.sidebarObjectInfoMode);
if (partial.sidebarAllowHorizontalScroll !== undefined) editorSettings.value.sidebarAllowHorizontalScroll = partial.sidebarAllowHorizontalScroll;
if (partial.columnFormatters !== undefined) editorSettings.value.columnFormatters = partial.columnFormatters;
if (partial.customColumnFormatters !== undefined) editorSettings.value.customColumnFormatters = partial.customColumnFormatters;
@ -1243,6 +1262,10 @@ export const useSettingsStore = defineStore("settings", () => {
saveEditorSettings(editorSettings.value);
}
async function persistEditorSettings(): Promise<void> {
await api.saveEditorSettings(editorSettingsSnapshot(editorSettings.value));
}
function updateColumnFormatter(key: string, formatter: ColumnFormatterConfig | undefined) {
const columnFormatters = { ...editorSettings.value.columnFormatters };
const normalized = normalizeColumnFormatter(formatter);
@ -1300,6 +1323,7 @@ export const useSettingsStore = defineStore("settings", () => {
mcpGlobalPolicy,
initEditorSettings,
updateEditorSettings,
persistEditorSettings,
initDesktopSettings,
updateDesktopSettings,
initMcpGlobalPolicy,

View File

@ -351,6 +351,11 @@ export interface DatabaseInfo {
name: string;
}
export interface DatabaseStorageInfo {
name: string;
size_bytes: number | null;
}
export interface SchemaInfo {
name: string;
comment?: string | null;
@ -714,6 +719,7 @@ export interface TreeNode {
tableType?: string;
comment?: string | null;
valid?: boolean | null;
sizeBytes?: number | null;
objectCount?: number;
loadedKeyCount?: number;
totalKeyCount?: number;

View File

@ -26,8 +26,8 @@ use crate::sql::starts_with_executable_sql_keyword;
use crate::types::{
ColumnInfo, CompletionAssistantCandidate, CompletionAssistantCandidateKind, CompletionAssistantMatchMode,
CompletionAssistantObjectKind, CompletionAssistantRequest, CompletionAssistantResponse, DatabaseInfo,
ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics, OwnerInfo, QueryResult,
RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
DatabaseStorageInfo, ExtensionInfo, ForeignKeyInfo, FunctionInfo, IndexInfo, ObjectInfo, ObjectStatistics,
OwnerInfo, QueryResult, RuleInfo, SchemaInfo, SequenceInfo, TableInfo, TriggerInfo,
};
fn pg_temporal_to_json_value(row: &Row, idx: usize) -> Option<serde_json::Value> {
@ -1542,21 +1542,53 @@ fn validate_postgres_ssl_paths(url: &str) -> Result<(), String> {
postgres_connection_url(url).map(|_| ())
}
fn list_databases_sql() -> &'static str {
"SELECT datname FROM pg_database \
WHERE datallowconn = true \
ORDER BY datname"
}
fn database_storage_sql() -> &'static str {
"SELECT d.datname, \
CASE \
WHEN has_database_privilege(d.datname, 'CONNECT') \
OR COALESCE(( \
SELECT pg_has_role(current_user, r.oid, 'MEMBER') \
FROM pg_roles r \
WHERE r.rolname = 'pg_read_all_stats' \
), false) \
THEN pg_database_size(d.oid) \
ELSE NULL \
END AS size_bytes \
FROM pg_database d \
WHERE d.datallowconn = true \
AND d.datname = ANY($1::text[]) \
ORDER BY d.datname"
}
pub async fn list_databases(pool: &Pool) -> Result<Vec<DatabaseInfo>, String> {
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows = postgres_query_cached(
&client,
"SELECT datname FROM pg_database \
WHERE datallowconn = true \
ORDER BY datname",
&[],
)
.await
.map_err(|e| e.to_string())?;
let rows = postgres_query_cached(&client, list_databases_sql(), &[]).await.map_err(|e| e.to_string())?;
Ok(rows.iter().map(|row| DatabaseInfo { name: pg_row_try_string(row, 0) }).collect())
}
pub async fn list_database_storage(pool: &Pool, database_names: &[String]) -> Result<Vec<DatabaseStorageInfo>, String> {
if database_names.is_empty() {
return Ok(Vec::new());
}
let client = checkout_postgres_client(pool, None, super::connection_timeout()).await?;
let rows =
postgres_query_cached(&client, database_storage_sql(), &[&database_names]).await.map_err(|e| e.to_string())?;
Ok(rows
.iter()
.map(|row| DatabaseStorageInfo {
name: pg_row_try_string(row, 0),
size_bytes: row.try_get::<_, Option<i64>>(1).ok().flatten(),
})
.collect())
}
pub async fn list_tables(pool: &Pool, schema: &str) -> Result<Vec<TableInfo>, String> {
list_tables_filtered(pool, schema, None, None, None).await
}
@ -3579,6 +3611,22 @@ mod tests {
use std::time::Instant;
use tokio_postgres::types::FromSql;
#[test]
fn database_list_does_not_collect_storage_usage() {
assert!(list_databases_sql().contains("pg_database"));
assert!(!list_databases_sql().contains("pg_database_size"));
}
#[test]
fn database_storage_is_scoped_and_permission_guarded() {
let sql = database_storage_sql();
assert!(sql.contains("d.datname = ANY($1::text[])"));
assert!(sql.contains("has_database_privilege"));
assert!(sql.contains("pg_read_all_stats"));
assert!(sql.contains("pg_database_size"));
assert!(sql.contains("ELSE NULL"));
}
#[test]
fn classify_pg_type_covers_all_dispatch_branches() {
assert_eq!(classify_pg_type("bytea"), PgColType::Bytea);

View File

@ -653,6 +653,56 @@ pub async fn list_databases_core(state: &AppState, connection_id: &str) -> Resul
retry_metadata_connection(state, connection_id, None, || list_databases_once(state, connection_id)).await
}
pub async fn list_database_storage_core(
state: &AppState,
connection_id: &str,
database_names: &[String],
) -> Result<Vec<db::DatabaseStorageInfo>, String> {
const DATABASE_STORAGE_TIMEOUT: Duration = Duration::from_secs(5);
const MAX_DATABASE_STORAGE_NAMES: usize = 2048;
if database_names.is_empty() {
return Ok(Vec::new());
}
let config = connection_config(state, connection_id).await;
if !config.as_ref().is_some_and(|config| {
config.db_type == DatabaseType::Postgres && config.driver_profile.as_deref() != Some("cockroachdb")
}) {
return Ok(Vec::new());
}
let pool = {
let connections = state.connections.read().await;
match connections.get(connection_id) {
Some(PoolKind::Postgres(pool)) => pool.clone(),
_ => return Ok(Vec::new()),
}
};
let mut seen = std::collections::HashSet::new();
let requested = database_names
.iter()
.filter(|name| !name.is_empty() && seen.insert((*name).clone()))
.take(MAX_DATABASE_STORAGE_NAMES)
.cloned()
.collect::<Vec<_>>();
if requested.is_empty() {
return Ok(Vec::new());
}
match tokio::time::timeout(DATABASE_STORAGE_TIMEOUT, db::postgres::list_database_storage(&pool, &requested)).await {
Ok(result) => result,
Err(_) => {
log::warn!(
"[list_database_storage:timeout] connection_id={} database_count={} timeout_ms={}",
connection_id,
requested.len(),
DATABASE_STORAGE_TIMEOUT.as_millis()
);
Ok(Vec::new())
}
}
}
pub async fn list_sqlserver_linked_servers_core(
state: &AppState,
connection_id: &str,
@ -1573,6 +1623,91 @@ fn oracle_object_statistics_rows_only_sql(schema: &str) -> String {
)
}
fn dameng_object_statistics_dba_segments_sql(schema: &str) -> String {
format!(
"SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, NVL(s.BYTES, 0) AS TOTAL_BYTES \
FROM ALL_TABLES t \
LEFT JOIN ( \
SELECT owner, table_name, SUM(bytes) AS BYTES \
FROM ( \
SELECT s.OWNER, s.SEGMENT_NAME AS TABLE_NAME, s.BYTES \
FROM DBA_SEGMENTS s \
WHERE s.OWNER = {} AND s.SEGMENT_TYPE IN ('TABLE','TABLE PARTITION','TABLE SUBPARTITION') \
UNION ALL \
SELECT i.TABLE_OWNER AS OWNER, i.TABLE_NAME, s.BYTES \
FROM ALL_INDEXES i \
JOIN DBA_SEGMENTS s ON s.OWNER = i.OWNER AND s.SEGMENT_NAME = i.INDEX_NAME \
WHERE i.TABLE_OWNER = {} AND s.SEGMENT_TYPE IN ('INDEX','INDEX PARTITION','INDEX SUBPARTITION') \
) \
GROUP BY owner, table_name \
) s ON s.OWNER = t.OWNER AND s.TABLE_NAME = t.TABLE_NAME \
WHERE t.OWNER = {} AND (t.NESTED IS NULL OR t.NESTED = 'NO') \
ORDER BY t.TABLE_NAME",
oracle_owner_filter(schema),
oracle_owner_filter(schema),
oracle_owner_filter(schema),
)
}
fn dameng_object_statistics_user_segments_sql(schema: &str) -> String {
format!(
"SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, NVL(s.BYTES, 0) AS TOTAL_BYTES \
FROM ALL_TABLES t \
LEFT JOIN ( \
SELECT table_name, SUM(bytes) AS BYTES \
FROM ( \
SELECT s.SEGMENT_NAME AS TABLE_NAME, s.BYTES \
FROM USER_SEGMENTS s \
WHERE s.SEGMENT_TYPE IN ('TABLE','TABLE PARTITION','TABLE SUBPARTITION') \
UNION ALL \
SELECT i.TABLE_NAME, s.BYTES \
FROM ALL_INDEXES i \
JOIN USER_SEGMENTS s ON s.SEGMENT_NAME = i.INDEX_NAME \
WHERE i.TABLE_OWNER = {} AND s.SEGMENT_TYPE IN ('INDEX','INDEX PARTITION','INDEX SUBPARTITION') \
) \
GROUP BY table_name \
) s ON s.TABLE_NAME = t.TABLE_NAME \
WHERE t.OWNER = {} AND t.OWNER = USER AND (t.NESTED IS NULL OR t.NESTED = 'NO') \
ORDER BY t.TABLE_NAME",
oracle_owner_filter(schema),
oracle_owner_filter(schema),
)
}
fn dameng_object_statistics_rows_only_sql(schema: &str) -> String {
format!(
"SELECT t.TABLE_NAME, t.OWNER, t.NUM_ROWS, CAST(NULL AS NUMBER) AS TOTAL_BYTES \
FROM ALL_TABLES t \
WHERE t.OWNER = {} AND (t.NESTED IS NULL OR t.NESTED = 'NO') \
ORDER BY t.TABLE_NAME",
oracle_owner_filter(schema),
)
}
fn kingbase_object_statistics_sql(schema: &str) -> String {
format!(
"SELECT c.relname, n.nspname, \
CAST(CASE WHEN c.reltuples < 0 THEN 0 ELSE c.reltuples END AS BIGINT) AS estimated_rows, \
CAST(sys_total_relation_size(c.oid) AS BIGINT) AS total_bytes \
FROM sys_catalog.sys_class c \
JOIN sys_catalog.sys_namespace n ON n.oid = c.relnamespace \
WHERE n.nspname = {} AND c.relkind IN ('r','m','f','p') \
ORDER BY c.relname",
sql_string(schema),
)
}
fn gbase8a_object_statistics_sql(database: &str) -> String {
format!(
"SELECT TABLE_NAME, TABLE_SCHEMA, TABLE_ROWS, \
COALESCE(DATA_LENGTH, 0) + COALESCE(INDEX_LENGTH, 0) AS TOTAL_BYTES \
FROM information_schema.TABLES \
WHERE TABLE_SCHEMA = {} AND TABLE_TYPE <> 'VIEW' \
ORDER BY TABLE_NAME",
sql_string(database),
)
}
fn query_result_cell_i64(row: &[serde_json::Value], index: usize) -> Option<i64> {
let value = row.get(index)?;
if value.is_null() {
@ -1755,7 +1890,7 @@ async fn oracle_agent_list_object_statistics(
];
let mut last_error = None;
for (source, sql, accept_empty) in queries {
match oracle_agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await {
match agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await {
Ok(result) if accept_empty || !result.rows.is_empty() => {
return Ok(oracle_object_statistics_from_query_result(result));
}
@ -1780,7 +1915,58 @@ async fn oracle_agent_list_object_statistics(
Err(last_error.unwrap_or_else(|| "Oracle object statistics are unavailable".to_string()))
}
async fn oracle_agent_object_statistics_query(
async fn dameng_agent_list_object_statistics(
client: Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
database: &str,
schema: &str,
timeout_duration: Option<Duration>,
) -> Result<Vec<db::ObjectStatistics>, String> {
let mut client = client.lock().await;
let queries = [
("dba-segments", dameng_object_statistics_dba_segments_sql(schema), true),
("user-segments", dameng_object_statistics_user_segments_sql(schema), false),
("rows-only", dameng_object_statistics_rows_only_sql(schema), true),
];
let mut last_error = None;
for (source, sql, accept_empty) in queries {
match agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await {
Ok(result) if accept_empty || !result.rows.is_empty() => {
return Ok(oracle_object_statistics_from_query_result(result));
}
Ok(_) => {
log::debug!(
"[schema][dameng:list_object_statistics:empty-fallback] schema={} source={}",
schema,
source
);
}
Err(error) => {
log::debug!(
"[schema][dameng:list_object_statistics:fallback-failed] schema={} source={} error={}",
schema,
source,
error
);
last_error = Some(error);
}
}
}
Err(last_error.unwrap_or_else(|| "Dameng object statistics are unavailable".to_string()))
}
async fn agent_list_object_statistics(
client: Arc<tokio::sync::Mutex<db::agent_driver::AgentDriverClient>>,
database: &str,
schema: &str,
sql: String,
timeout_duration: Option<Duration>,
) -> Result<Vec<db::ObjectStatistics>, String> {
let mut client = client.lock().await;
let result = agent_object_statistics_query(&mut client, database, schema, &sql, timeout_duration).await?;
Ok(oracle_object_statistics_from_query_result(result))
}
async fn agent_object_statistics_query(
client: &mut db::agent_driver::AgentDriverClient,
database: &str,
schema: &str,
@ -2453,11 +2639,13 @@ fn escape_presto_like_pattern(value: &str) -> String {
mod tests {
use super::db;
use super::{
clickhouse_metadata_database, deduplicate_column_infos, filter_mysql_system_databases_for_config,
filter_object_infos, filter_table_infos, filter_visible_schema_names,
is_agent_postgres_metadata_fallback_config, is_retryable_metadata_error, mysql_object_source_sql,
mysql_table_metadata_catalog, normalize_information_schema_table_type, oracle_columns_from_query_result,
oracle_columns_sql, oracle_object_statistics_dba_segments_sql, oracle_object_statistics_from_query_result,
clickhouse_metadata_database, dameng_object_statistics_dba_segments_sql,
dameng_object_statistics_rows_only_sql, dameng_object_statistics_user_segments_sql, deduplicate_column_infos,
filter_mysql_system_databases_for_config, filter_object_infos, filter_table_infos, filter_visible_schema_names,
gbase8a_object_statistics_sql, is_agent_postgres_metadata_fallback_config, is_retryable_metadata_error,
kingbase_object_statistics_sql, mysql_object_source_sql, mysql_table_metadata_catalog,
normalize_information_schema_table_type, oracle_columns_from_query_result, oracle_columns_sql,
oracle_object_statistics_dba_segments_sql, oracle_object_statistics_from_query_result,
oracle_object_statistics_rows_only_sql, oracle_object_statistics_sql,
oracle_object_statistics_user_segments_sql, oracle_table_comment_from_query_result, oracle_table_comment_sql,
oracle_table_comments_from_query_result, oracle_table_comments_sql, presto_like_columns_from_query_result,
@ -3465,6 +3653,40 @@ mod tests {
assert!(!rows_only_sql.contains("ALL_SEGMENTS"));
}
#[test]
fn dameng_object_statistics_sql_uses_available_segment_views() {
let dba_sql = dameng_object_statistics_dba_segments_sql("app's");
assert!(dba_sql.contains("DBA_SEGMENTS"));
assert!(dba_sql.contains("ALL_INDEXES"));
assert!(!dba_sql.contains("ALL_SEGMENTS"));
assert!(!dba_sql.contains("ALL_LOBS"));
assert!(dba_sql.contains("OWNER = 'APP''S'"));
assert!(dba_sql.contains("t.NESTED IS NULL OR t.NESTED = 'NO'"));
let user_sql = dameng_object_statistics_user_segments_sql("app's");
assert!(user_sql.contains("USER_SEGMENTS"));
assert!(user_sql.contains("t.OWNER = USER"));
let rows_only_sql = dameng_object_statistics_rows_only_sql("app's");
assert!(rows_only_sql.contains("CAST(NULL AS NUMBER) AS TOTAL_BYTES"));
assert!(!rows_only_sql.contains("SEGMENTS"));
}
#[test]
fn agent_database_object_statistics_sql_uses_native_catalogs() {
let kingbase_sql = kingbase_object_statistics_sql("core's");
assert!(kingbase_sql.contains("sys_catalog.sys_class"));
assert!(kingbase_sql.contains("sys_catalog.sys_namespace"));
assert!(kingbase_sql.contains("sys_total_relation_size"));
assert!(kingbase_sql.contains("n.nspname = 'core''s'"));
let gbase_sql = gbase8a_object_statistics_sql("shop's");
assert!(gbase_sql.contains("information_schema.TABLES"));
assert!(gbase_sql.contains("DATA_LENGTH"));
assert!(gbase_sql.contains("INDEX_LENGTH"));
assert!(gbase_sql.contains("TABLE_SCHEMA = 'shop''s'"));
}
#[test]
fn oracle_object_statistics_from_query_result_maps_numbers() {
let result = db::QueryResult {
@ -3935,6 +4157,42 @@ async fn list_object_statistics_once(
)
.await;
}
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Dameng) {
drop(connections);
return dameng_agent_list_object_statistics(
client,
database,
schema,
agent_metadata_timeout(db_config.as_ref()),
)
.await;
}
if db_config.as_ref().is_some_and(|config| config.db_type == DatabaseType::Kingbase) {
let sql = kingbase_object_statistics_sql(schema);
drop(connections);
return agent_list_object_statistics(
client,
database,
schema,
sql,
agent_metadata_timeout(db_config.as_ref()),
)
.await;
}
if db_config.as_ref().is_some_and(|config| {
config.db_type == DatabaseType::Gbase && config.driver_profile.as_deref() != Some("gbase8s")
}) {
let sql = gbase8a_object_statistics_sql(database);
drop(connections);
return agent_list_object_statistics(
client,
database,
schema,
sql,
agent_metadata_timeout(db_config.as_ref()),
)
.await;
}
}
let pool = connections.get(&pool_key).ok_or("Pool not found")?;
match pool {

View File

@ -5,6 +5,12 @@ pub struct DatabaseInfo {
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DatabaseStorageInfo {
pub name: String,
pub size_bytes: Option<i64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaInfo {
pub name: String,

View File

@ -278,6 +278,7 @@ async fn main() {
.route("/agents/progress/{operationId}", get(routes::agents::agent_progress))
// Schema
.route("/schema/databases", get(routes::schema::list_databases))
.route("/schema/database-storage", post(routes::schema::list_database_storage))
.route("/schema/doris/catalogs", get(routes::schema::list_doris_catalogs))
.route("/schema/doris/catalog-databases", get(routes::schema::list_doris_catalog_databases))
.route("/schema/sqlserver/linked-servers", get(routes::schema::list_sqlserver_linked_servers))

View File

@ -25,6 +25,12 @@ pub struct SchemaQuery {
pub client_session_id: Option<String>,
}
#[derive(Deserialize)]
pub struct DatabaseStorageRequest {
pub connection_id: String,
pub databases: Vec<String>,
}
pub async fn list_databases(
State(state): State<Arc<WebState>>,
Query(q): Query<SchemaQuery>,
@ -33,6 +39,16 @@ pub async fn list_databases(
Ok(Json(serde_json::to_value(result).map_err(|e| AppError::from(e.to_string()))?))
}
pub async fn list_database_storage(
State(state): State<Arc<WebState>>,
Json(request): Json<DatabaseStorageRequest>,
) -> Result<Json<Vec<dbx_core::db::DatabaseStorageInfo>>, AppError> {
let result = dbx_core::schema::list_database_storage_core(&state.app, &request.connection_id, &request.databases)
.await
.map_err(AppError::from)?;
Ok(Json(result))
}
/// Resolve a non-internal catalog for dispatch to the Doris multi-catalog path.
async fn external_doris_catalog(state: &Arc<WebState>, connection_id: &str, catalog: Option<&str>) -> Option<String> {
dbx_core::schema::resolve_external_doris_catalog(&state.app, connection_id, catalog).await

View File

@ -19,6 +19,15 @@ pub async fn list_databases(
dbx_core::schema::list_databases_core(&state, &connection_id).await
}
#[tauri::command]
pub async fn list_database_storage(
state: State<'_, Arc<AppState>>,
connection_id: String,
databases: Vec<String>,
) -> Result<Vec<db::DatabaseStorageInfo>, String> {
dbx_core::schema::list_database_storage_core(&state, &connection_id, &databases).await
}
#[tauri::command]
pub async fn list_doris_catalogs(
state: State<'_, Arc<AppState>>,

View File

@ -1145,6 +1145,7 @@ pub fn run() {
commands::plugins::install_jdbc_plugin_local,
commands::plugins::uninstall_jdbc_plugin,
commands::schema::list_databases,
commands::schema::list_database_storage,
commands::schema::list_doris_catalogs,
commands::schema::list_doris_catalog_databases,
commands::schema::list_sqlserver_linked_servers,