feat(sidebar): add configurable object metadata display
This commit is contained in:
parent
8adf802444
commit
57f2d27313
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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スニペットテンプレートをカスタマイズします。",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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 代码片段模板。",
|
||||
|
|
|
|||
|
|
@ -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 程式碼片段範本。",
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>>,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue