fix(sidebar): Dameng tree blank band, admin node duplication and schema auto-expand
This commit is contained in:
1 parent
707296c71d
commit
57b350eb49
8 files changed
+1330
-15
No files matched your search
@@ -48,7 +48,7 @@ import {
|
||||
type SidebarNodeScrollAlign,
|
||||
} from "@/lib/sidebar/sidebarActiveTabTarget";
|
||||
import { findLoadedTableTargetForCandidate, queryContextTargetFromCandidate, queryCursorTableCandidate, type QueryCursorTableCandidate } from "@/lib/sql/queryCursorTableTarget";
|
||||
import { createFlatTreeIndex, flatTreeRowsChanged, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
|
||||
import { createFlatTreeIndex, flatTreeRowsChanged, SIDEBAR_TREE_ROW_HEIGHT, SIDEBAR_TREE_PRERENDER_COUNT, SIDEBAR_TREE_SCROLL_BUFFER, SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS, SIDEBAR_TREE_VIRTUALIZE_THRESHOLD, flattenTree, shouldVirtualizeFlatTree, type FlatTreeNode } from "@/composables/useFlatTree";
|
||||
import { sidebarTreeContextKey } from "@/lib/sidebar/sidebarTreeContext";
|
||||
import { createSidebarTreeRuntime, sidebarTreeRuntimeKey, type SidebarTreeRuntimeHostInstance } from "@/lib/sidebar/sidebarTreeRuntime";
|
||||
import { createSidebarPasteHandlerRegistry } from "@/lib/sidebar/sidebarPasteHandlerRegistry";
|
||||
@@ -81,6 +81,7 @@ import { sidebarDisplayTableName } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { alignedSidebarCommentLabelWidths, isSidebarCommentAlignableNode, sidebarTreeNaturalContentWidth, sidebarTreeNodeComment, usesFullWidthTreeLabel } from "@/lib/sidebar/sidebarTreeItemLayout";
|
||||
import { formatSidebarObjectStorage, sidebarTableStorageScopes, supportsSidebarTableStorage } from "@/lib/sidebar/sidebarDatabaseStorage";
|
||||
import { sidebarScrollbarGeometry as calculateSidebarScrollbarGeometry } from "@/lib/sidebar/sidebarScrollbar";
|
||||
import { createSidebarLayoutMonitor, type SidebarExpandedConnectionInfo } from "@/lib/sidebar/sidebarLayoutMonitor";
|
||||
import { disconnectSidebarConnections } from "@/lib/sidebar/sidebarConnectionDisconnect";
|
||||
import { compileSearchRegex } from "@/lib/common/searchPattern";
|
||||
|
||||
@@ -103,6 +104,7 @@ const sidebarRootStyle = computed<Record<string, string>>(() => ({ fontSize: `${
|
||||
const pointerInsideTree = ref(false);
|
||||
const treeScrollerRef = ref<InstanceType<typeof RecycleScroller> | null>(null);
|
||||
const plainTreeScrollerRef = ref<HTMLElement | null>(null);
|
||||
const treeScrollShellRef = ref<HTMLElement | null>(null);
|
||||
const sidebarScrollbarTrackRef = ref<HTMLElement | null>(null);
|
||||
const sidebarHorizontalScrollbarTrackRef = ref<HTMLElement | null>(null);
|
||||
const sidebarContextMenuRef = ref<{ close: () => void } | null>(null);
|
||||
@@ -745,6 +747,68 @@ const flatNodes = computed<FlatTreeNode[]>(() =>
|
||||
}),
|
||||
);
|
||||
|
||||
// Debug instrumentation for the "expanded tree leaves the scroll shell stuck
|
||||
// (e.g. Dameng connection expanded and the viewport froze near half height)"
|
||||
// class of layout bugs. Feeds the layout monitor with expand events and
|
||||
// tree-size changes so its anomaly reports can describe the transition.
|
||||
function readExpandedSidebarConnections(): SidebarExpandedConnectionInfo[] {
|
||||
const expanded: SidebarExpandedConnectionInfo[] = [];
|
||||
for (const node of filteredNodes.value) {
|
||||
if (node.type !== "connection" && node.type !== "connection-group") continue;
|
||||
if (!node.isExpanded) continue;
|
||||
let descendantCount = 0;
|
||||
const stack = [...(node.children ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const child = stack.pop() as TreeNode;
|
||||
descendantCount += 1;
|
||||
if (child.children) stack.push(...child.children);
|
||||
}
|
||||
expanded.push({ id: node.id, label: node.label, type: node.type, descendantCount });
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// Which schema nodes are expanded right now — lets the layout monitor tell a
|
||||
// user-initiated expand (an expand-toggle event precedes it) from a
|
||||
// programmatic one (the schema shows up here without one).
|
||||
function readExpandedSidebarSchemas(): Array<{ id: string; label: string }> {
|
||||
const expanded: Array<{ id: string; label: string }> = [];
|
||||
for (const connection of filteredNodes.value) {
|
||||
if (connection.type !== "connection" && connection.type !== "connection-group") continue;
|
||||
const stack = [...(connection.children ?? [])];
|
||||
while (stack.length > 0) {
|
||||
const child = stack.pop() as TreeNode;
|
||||
if (child.type === "schema" && child.isExpanded) expanded.push({ id: child.id, label: child.label });
|
||||
if (child.children) stack.push(...child.children);
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// The plain (non-virtualized) renderer keeps the sticky database/schema header
|
||||
// via native CSS position: sticky on the container rows, mirroring the overlay
|
||||
// the virtual branch renders. Only container rows stick; children scroll under
|
||||
// them until the next container takes over.
|
||||
function isPlainStickyContainerNode(node: TreeNode): boolean {
|
||||
// Mirror the virtual branch's sticky overlay: both suppress sticky headers
|
||||
// while a search filter is active so filtered rows don't pin at the top.
|
||||
if (isTreeSearchFiltering.value) return false;
|
||||
return DATABASE_LEVEL_TYPES.has(node.type) || SCHEMA_LEVEL_TYPES.has(node.type);
|
||||
}
|
||||
|
||||
const sidebarLayoutMonitor = createSidebarLayoutMonitor({
|
||||
readContext: () => ({
|
||||
flatNodeCount: flatNodes.value.length,
|
||||
useVirtualTree: useVirtualTree.value,
|
||||
virtualItemSize: SIDEBAR_TREE_ROW_HEIGHT,
|
||||
scrollerEl: currentTreeScroller(),
|
||||
shellEl: treeScrollShellRef.value,
|
||||
rootEl: rootRef.value ?? null,
|
||||
virtualScroller: (treeScrollerRef.value as unknown as Record<string, unknown> | null) ?? null,
|
||||
expandedConnections: readExpandedSidebarConnections(),
|
||||
}),
|
||||
});
|
||||
|
||||
watch(flatNodes, refreshPendingInvalidatedTableSearchScopes, { flush: "post" });
|
||||
|
||||
const sidebarCommentLabelWidths = shallowRef(new Map<string, number>());
|
||||
@@ -876,7 +940,38 @@ const flatTreeIndex = computed(() =>
|
||||
const visibleNodes = computed<TreeNode[]>(() => flatTreeIndex.value.visibleNodes);
|
||||
const selectableVisibleNodes = computed<TreeNode[]>(() => flatTreeIndex.value.selectableVisibleNodes);
|
||||
const selectableVisibleNodeIndexById = computed(() => flatTreeIndex.value.selectableVisibleNodeIndexById);
|
||||
const useVirtualTree = computed(() => shouldVirtualizeFlatTree(flatNodes.value.length));
|
||||
// Virtualization with hysteresis: switching between the plain and virtual
|
||||
// renderers remounts the tree (scroll reset), so once virtualized stay virtual
|
||||
// until the tree drops well below the threshold — otherwise search/filter
|
||||
// oscillation around the boundary would thrash between the two branches.
|
||||
const treeVirtualizationArmed = ref(false);
|
||||
watch(
|
||||
() => flatNodes.value.length,
|
||||
(count) => {
|
||||
if (treeVirtualizationArmed.value) {
|
||||
if (count < SIDEBAR_TREE_VIRTUALIZE_THRESHOLD - SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS) {
|
||||
treeVirtualizationArmed.value = false;
|
||||
}
|
||||
} else if (shouldVirtualizeFlatTree(count)) {
|
||||
treeVirtualizationArmed.value = true;
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
const useVirtualTree = computed(() => treeVirtualizationArmed.value);
|
||||
|
||||
// The plain and virtual branches mount separate scroller elements; keep the
|
||||
// scroll position when the renderer flips so crossing the threshold does not
|
||||
// jump the user back to the top.
|
||||
watch(useVirtualTree, (virtual, wasVirtual) => {
|
||||
const previous = (wasVirtual ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined;
|
||||
const savedScrollTop = previous?.scrollTop ?? 0;
|
||||
if (!savedScrollTop) return;
|
||||
void nextTick(() => {
|
||||
const next = (virtual ? treeScrollerRef.value?.$el : plainTreeScrollerRef.value) as HTMLElement | undefined;
|
||||
if (next) next.scrollTop = savedScrollTop;
|
||||
});
|
||||
});
|
||||
const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryStore.activeTabId));
|
||||
const hasTreeMultiSelection = computed(() => store.connectionMultiSelectActive || store.selectedTreeNodeIds.length > 1);
|
||||
|
||||
@@ -1014,6 +1109,26 @@ const stickyHeaderStyle = computed<CSSProperties>(() => {
|
||||
// Reset tracking when the tree rebuilds (connect/disconnect/collapse) so a
|
||||
// stale scrollTop doesn't keep the overlay mounted after a structural change.
|
||||
watch(flatNodes, (nodes, previousNodes) => {
|
||||
// The diagnostic events walk the whole expanded tree; only do that work when
|
||||
// the layout monitor is actually armed (dev default, disabled in production).
|
||||
if (sidebarLayoutMonitor.isEnabled()) {
|
||||
const previousCount = previousNodes?.length ?? 0;
|
||||
if (nodes.length !== previousCount) {
|
||||
const previousIds = new Set((previousNodes ?? []).map((entry) => entry.id));
|
||||
const added = nodes
|
||||
.filter((entry) => !previousIds.has(entry.id))
|
||||
.slice(0, 5)
|
||||
.map((entry) => ({ type: entry.node.type, label: entry.node.label }));
|
||||
sidebarLayoutMonitor.recordEvent({
|
||||
type: "tree-change",
|
||||
prevCount: previousCount,
|
||||
count: nodes.length,
|
||||
expandedConnections: readExpandedSidebarConnections(),
|
||||
added,
|
||||
expandedSchemas: readExpandedSidebarSchemas(),
|
||||
});
|
||||
}
|
||||
}
|
||||
const contextMenuTarget = sidebarContextMenuTarget.value;
|
||||
if (contextMenuTarget) {
|
||||
const visibleContextMenuTarget = nodes.find(({ node }) => matchesSidebarActionTarget(node, contextMenuTarget))?.node;
|
||||
@@ -1035,9 +1150,10 @@ watch(flatNodes, (nodes, previousNodes) => {
|
||||
// Reading scrollHeight forces that layout (applying the clamp), then one
|
||||
// explicit pool refresh keeps the rendered window aligned.
|
||||
if (!flatTreeRowsChanged(nodes, previousNodes)) return;
|
||||
if (!useVirtualTree.value) return;
|
||||
void nextTick(() => {
|
||||
const scroller = treeScrollerRef.value;
|
||||
if (!scroller || !useVirtualTree.value) return;
|
||||
if (!scroller) return;
|
||||
const el = scroller.$el as HTMLElement | undefined;
|
||||
if (!el) return;
|
||||
const maxScrollTop = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
@@ -1649,6 +1765,13 @@ function onSearchToggle(node: TreeNode) {
|
||||
|
||||
function onNodeToggled(node: TreeNode, expanded: boolean) {
|
||||
if (isTreeSearchFiltering.value) return;
|
||||
sidebarLayoutMonitor.recordEvent({
|
||||
type: "expand-toggle",
|
||||
nodeId: node.id,
|
||||
label: node.label,
|
||||
nodeType: node.type,
|
||||
expanded,
|
||||
});
|
||||
syncSidebarTreeNodeExpansion(store.treeNodes, node, expanded);
|
||||
}
|
||||
|
||||
@@ -2181,10 +2304,12 @@ function requestSelectedSidebarPaste(): boolean {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
sidebarLayoutMonitor.start();
|
||||
window.addEventListener("keydown", onWindowKeydown);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
sidebarLayoutMonitor.dispose();
|
||||
sidebarTreeRuntime.dispose();
|
||||
sidebarActionGeneration += 1;
|
||||
sidebarContextMenuTarget.value = null;
|
||||
@@ -2344,7 +2469,13 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
</div>
|
||||
</div>
|
||||
<CustomContextMenu ref="sidebarContextMenuRef" :items="sidebarContextMenuItems" v-slot="contextMenuSlot">
|
||||
<div v-if="flatNodes.length > 0 && useVirtualTree" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
|
||||
<div v-if="flatNodes.length > 0 && useVirtualTree" ref="treeScrollShellRef" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
|
||||
<!-- NOTE: flow-mode is intentionally NOT used here. vue-virtual-scroller
|
||||
v3.0.4's flow-mode view-pool bookkeeping produces an internally
|
||||
inconsistent DOM in this app (window/spacers updated but pool views
|
||||
never materialized -> blank band). Tree rows are fixed 28px (labels
|
||||
truncate), so the standard absolute-positioning mode is safe and
|
||||
keeps the materialized window in sync with the viewport. -->
|
||||
<RecycleScroller
|
||||
ref="treeScrollerRef"
|
||||
class="sidebar-tree connection-tree-scroller h-full overflow-y-auto"
|
||||
@@ -2359,7 +2490,6 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
key-field="renderKey"
|
||||
type-field="poolType"
|
||||
list-class="connection-tree-content"
|
||||
flow-mode
|
||||
>
|
||||
<template #default="{ item }">
|
||||
<TreeItem
|
||||
@@ -2405,12 +2535,12 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
<div class="sidebar-tree-horizontal-scrollbar__thumb" :style="sidebarHorizontalScrollbarThumbStyle" @pointerdown.stop="onSidebarHorizontalScrollbarThumbPointerDown" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="flatNodes.length > 0" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
|
||||
<div v-else-if="flatNodes.length > 0" ref="treeScrollShellRef" class="connection-tree-scroll-shell relative min-h-0 flex-1" :class="{ 'connection-tree-scroll-shell--horizontal-overflow': hasSidebarHorizontalOverflow }">
|
||||
<div ref="plainTreeScrollerRef" class="sidebar-tree connection-tree-scroller h-full overflow-y-auto" :class="sidebarTreeOverflowClass" :style="sidebarTreeScrollerStyle" @click="clearSidebarSelection" @scroll.passive="onTreeScroll">
|
||||
<div class="connection-tree-content">
|
||||
<TreeItem
|
||||
v-for="item in flatNodes"
|
||||
:key="item.id"
|
||||
:key="item.renderKey"
|
||||
:node="item.node"
|
||||
:depth="item.depth"
|
||||
:reorder-disabled="isRootListPartial"
|
||||
@@ -2418,6 +2548,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
:pending-rename="pendingRenameNodeId === item.node.id"
|
||||
:highlighted="highlightedNodeId === item.id"
|
||||
:comment-label-width="sidebarCommentLabelWidths.get(item.node.id)"
|
||||
:sticky-header="isPlainStickyContainerNode(item.node)"
|
||||
@context-menu="(event, node) => openSidebarContextMenu(event, node, contextMenuSlot.onContextMenu)"
|
||||
@rename-started="pendingRenameNodeId = null"
|
||||
@group-created="startRenamingCreatedGroup"
|
||||
@@ -2610,6 +2741,7 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
contain: content;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
overflow-anchor: none;
|
||||
}
|
||||
|
||||
.connection-tree-scroller::-webkit-scrollbar {
|
||||
@@ -2620,6 +2752,13 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes, locateTabInSid
|
||||
.connection-tree-scroller :deep(.vue-recycle-scroller__item-view) {
|
||||
min-width: 100%;
|
||||
contain: style;
|
||||
/* The virtual renderer positions rows at fixed item-size offsets (28px, see
|
||||
SIDEBAR_TREE_ROW_HEIGHT). TreeItem rows only guarantee min-h-7, so rename
|
||||
inputs or larger sidebar fonts could grow a row beyond 28px and overlap
|
||||
the next row. Pin every materialized row to the fixed height and clip any
|
||||
overflow instead of letting the layout drift. */
|
||||
height: 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.connection-tree-scroller.sidebar-tree-horizontal-scroll :deep(.vue-recycle-scroller__item-view) {
|
||||
|
||||
@@ -176,6 +176,10 @@ const props = defineProps<{
|
||||
pendingRename?: boolean;
|
||||
highlighted?: boolean;
|
||||
commentLabelWidth?: number;
|
||||
/** Plain (non-virtualized) renderer: make database/schema container rows
|
||||
* stick to the top of the tree scroller while their children scroll under
|
||||
* them (mirrors the overlay sticky header of the virtual renderer). */
|
||||
stickyHeader?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -1395,6 +1399,7 @@ function onKeydown(event: KeyboardEvent) {
|
||||
'tree-item-active': selectionVisual.rowSelected,
|
||||
'tree-item-active--selection-set': selectionVisual.usesSelectionSetHighlight && selectionVisual.rowSelected,
|
||||
'tree-item-highlight': highlighted,
|
||||
'sidebar-tree-item--sticky': stickyHeader,
|
||||
},
|
||||
]"
|
||||
:tabindex="selectionVisual.selected || selectionVisual.multiSelected ? 0 : -1"
|
||||
@@ -1620,6 +1625,19 @@ function onKeydown(event: KeyboardEvent) {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
/* Plain (non-virtualized) renderer: database/schema container rows stick to
|
||||
the top of the tree scroller while their children scroll under them,
|
||||
mirroring the overlay sticky header the virtual renderer uses. The row is
|
||||
min-h-7, so a solid background guarantees no content shows through while
|
||||
rows slide underneath. */
|
||||
.sidebar-tree-item--sticky {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--background);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.tree-item-connection-tint:hover::before {
|
||||
background-color: var(--tree-connection-row-hover-bg, var(--tree-connection-row-bg));
|
||||
}
|
||||
|
||||
@@ -4,6 +4,25 @@ export const SIDEBAR_TREE_ROW_HEIGHT = 28;
|
||||
export const SIDEBAR_TREE_SCROLL_BUFFER = 600;
|
||||
export const SIDEBAR_TREE_PRERENDER_COUNT = 48;
|
||||
|
||||
/**
|
||||
* Sidebar trees at or above this many rows use the virtualized renderer.
|
||||
* Smaller trees render plainly (all rows in the DOM). Virtualized rendering
|
||||
* has a failure mode where the view pool stops matching the viewport when the
|
||||
* tree mutates while scrolled (observed with the Dameng connection, whose
|
||||
* subtree keeps streaming in new nodes) — a blank band appears below the last
|
||||
* materialized row. Plain rendering cannot exhibit that failure, so keep
|
||||
* small/medium trees off the virtual path.
|
||||
*/
|
||||
export const SIDEBAR_TREE_VIRTUALIZE_THRESHOLD = 500;
|
||||
|
||||
/**
|
||||
* Once a tree is virtualized it stays virtualized until the row count drops
|
||||
* below THRESHOLD - HYSTERESIS. Switching renderers remounts the whole tree,
|
||||
* so the hysteresis prevents search/filter oscillation around the threshold
|
||||
* from thrashing between the two branches.
|
||||
*/
|
||||
export const SIDEBAR_TREE_VIRTUALIZE_HYSTERESIS = 50;
|
||||
|
||||
export interface FlatTreeNode {
|
||||
node: TreeNode;
|
||||
depth: number;
|
||||
@@ -108,7 +127,7 @@ export function flatTreeRowsChanged(nodes: readonly FlatTreeNode[], previousNode
|
||||
}
|
||||
|
||||
export function shouldVirtualizeFlatTree(count: number): boolean {
|
||||
return count > 0;
|
||||
return count >= SIDEBAR_TREE_VIRTUALIZE_THRESHOLD;
|
||||
}
|
||||
|
||||
export function createFlatTreeIndex(nodes: readonly FlatTreeNode[], options: FlatTreeIndexOptions): FlatTreeIndex {
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildAnomalyDigest, buildSidebarLayoutReport, captureSidebarLayoutSample, detectLayoutAnomalies, ringPush, unreachableRowCount, type SidebarLayoutMonitorContext, type SidebarLayoutSample } from "@/lib/sidebar/sidebarLayoutMonitor";
|
||||
|
||||
function makeSample(overrides: Partial<SidebarLayoutSample> = {}): SidebarLayoutSample {
|
||||
const base: SidebarLayoutSample = {
|
||||
t: 1000,
|
||||
windowHeight: 900,
|
||||
rootHeight: 860,
|
||||
shellHeight: 800,
|
||||
shellRatioOfRoot: 800 / 860,
|
||||
shellRatioOfWindow: 800 / 900,
|
||||
flatNodeCount: 60,
|
||||
scroller: { clientHeight: 800, scrollHeight: 1680, scrollTop: 0, computedHeight: "800px", overflowY: "auto", position: "relative" },
|
||||
virtual: { itemSize: 28, expectedScrollHeight: 1680, renderedRows: 32, visiblePoolSize: 32, spacers: { start: 0, end: 0 }, scrollWindow: { start: 0, end: 800 }, listHeight: 1680 },
|
||||
rows: { renderedRows: 32, firstTop: 0, firstBottom: 28, lastTop: 868, lastBottom: 896 },
|
||||
sticky: null,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
...overrides,
|
||||
scroller: { ...base.scroller, ...overrides.scroller },
|
||||
virtual: overrides.virtual !== undefined ? overrides.virtual : base.virtual,
|
||||
rows: overrides.rows !== undefined ? overrides.rows : base.rows,
|
||||
};
|
||||
}
|
||||
|
||||
function samples(count: number, sample: SidebarLayoutSample): SidebarLayoutSample[] {
|
||||
return Array.from({ length: count }, (_, index) => ({ ...sample, t: sample.t + index }));
|
||||
}
|
||||
|
||||
describe("ringPush", () => {
|
||||
it("appends entries and caps the ring at maxSize", () => {
|
||||
expect(ringPush(["a", "b"], "c", 3)).toEqual(["a", "b", "c"]);
|
||||
expect(ringPush(["a", "b", "c"], "d", 3)).toEqual(["b", "c", "d"]);
|
||||
expect(ringPush([], "a", 3)).toEqual(["a"]);
|
||||
});
|
||||
|
||||
it("never mutates the source ring", () => {
|
||||
const source = ["a", "b"];
|
||||
ringPush(source, "c", 3);
|
||||
expect(source).toEqual(["a", "b"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("captureSidebarLayoutSample", () => {
|
||||
it("falls back to a zeroed sample without DOM but keeps the tree context", () => {
|
||||
const sample = captureSidebarLayoutSample({ flatNodeCount: 42 } as SidebarLayoutMonitorContext);
|
||||
expect(sample.flatNodeCount).toBe(42);
|
||||
expect(sample.windowHeight).toBe(0);
|
||||
expect(sample.shellHeight).toBe(0);
|
||||
expect(sample.virtual).toBeNull();
|
||||
expect(sample.rows).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unreachableRowCount", () => {
|
||||
it("counts rows that can never enter the scrollable viewport", () => {
|
||||
const healthy = makeSample();
|
||||
expect(unreachableRowCount(healthy)).toBe(0);
|
||||
|
||||
const truncated = makeSample({ scroller: { scrollHeight: 840 } });
|
||||
expect(unreachableRowCount(truncated)).toBe(60 - Math.floor(840 / 28));
|
||||
});
|
||||
|
||||
it("returns zero for the plain (non-virtual) tree", () => {
|
||||
const plain = makeSample({ virtual: null });
|
||||
expect(unreachableRowCount(plain)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("detectLayoutAnomalies", () => {
|
||||
it("reports nothing for a stable healthy tree", () => {
|
||||
expect(detectLayoutAnomalies(samples(6, makeSample()))).toEqual([]);
|
||||
});
|
||||
|
||||
it("flags the shell stuck near half the window height after 3 consecutive samples", () => {
|
||||
const halfShell = makeSample({ windowHeight: 900, rootHeight: 860, shellHeight: 450, shellRatioOfRoot: 450 / 860, shellRatioOfWindow: 450 / 900, scroller: { clientHeight: 450 } });
|
||||
expect(detectLayoutAnomalies(samples(2, halfShell))).toEqual([]);
|
||||
expect(detectLayoutAnomalies(samples(4, halfShell))).toEqual(["half-height-shell"]);
|
||||
});
|
||||
|
||||
it("flags under-scroll when scrollHeight falls far below expected itemSize x count", () => {
|
||||
const truncated = makeSample({ scroller: { scrollHeight: 840 } });
|
||||
expect(detectLayoutAnomalies(samples(3, truncated))).toContain("under-scroll");
|
||||
});
|
||||
|
||||
it("flags bottom rows as unreachable only when scrolled to the bottom", () => {
|
||||
const truncated = makeSample({ scroller: { scrollHeight: 840, clientHeight: 450, scrollTop: 100 } });
|
||||
expect(detectLayoutAnomalies(samples(3, truncated))).not.toContain("bottom-unreachable");
|
||||
|
||||
const atBottom = makeSample({ scroller: { scrollHeight: 840, clientHeight: 450, scrollTop: 390 } });
|
||||
const flags = detectLayoutAnomalies(samples(3, atBottom));
|
||||
expect(flags).toContain("under-scroll");
|
||||
expect(flags).toContain("bottom-unreachable");
|
||||
});
|
||||
|
||||
it("flags plain-tree rows clipped beyond scrollHeight", () => {
|
||||
const plain = makeSample({ virtual: null, rows: { renderedRows: 60, firstTop: 0, firstBottom: 28, lastTop: 1800, lastBottom: 1900 } });
|
||||
expect(detectLayoutAnomalies(samples(3, plain))).toEqual(["rows-clipped"]);
|
||||
});
|
||||
|
||||
it("flags a blank gap between the rendered pool and the viewport bottom", () => {
|
||||
const blank = makeSample({
|
||||
rows: { renderedRows: 12, firstTop: 0, firstBottom: 28, lastTop: 300, lastBottom: 500 },
|
||||
scroller: { scrollTop: 100, clientHeight: 800 },
|
||||
});
|
||||
expect(detectLayoutAnomalies(samples(3, blank))).toContain("blank-viewport");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSidebarLayoutReport", () => {
|
||||
const context: SidebarLayoutMonitorContext = {
|
||||
flatNodeCount: 60,
|
||||
useVirtualTree: true,
|
||||
virtualItemSize: 28,
|
||||
scrollerEl: null,
|
||||
shellEl: null,
|
||||
rootEl: null,
|
||||
virtualScroller: null,
|
||||
expandedConnections: [{ id: "conn-dm-1", label: "达梦测试库", type: "connection", descendantCount: 132 }],
|
||||
};
|
||||
|
||||
it("builds a digest that describes geometry, flags and expanded connections without a DOM", () => {
|
||||
const truncated = makeSample({ scroller: { scrollHeight: 840, scrollTop: 390 } });
|
||||
const report = buildSidebarLayoutReport(truncated, { sampleHistory: samples(6, makeSample()).concat([truncated]), events: [], context, flags: ["under-scroll", "bottom-unreachable"] });
|
||||
|
||||
expect(report.ancestors).toEqual([]);
|
||||
expect(report.content).toBeNull();
|
||||
expect(report.expandedConnections).toHaveLength(1);
|
||||
expect(report.sampleHistory.length).toBe(7);
|
||||
|
||||
const digest = buildAnomalyDigest(report);
|
||||
expect(digest).toContain("anomaly detected");
|
||||
expect(digest).toContain("under-scroll");
|
||||
expect(digest).toContain("达梦测试库");
|
||||
expect(digest).toContain("expected 1680px");
|
||||
expect(digest).toContain("actual 840px");
|
||||
expect(report.digest).toBe(digest);
|
||||
});
|
||||
|
||||
it("keeps an empty sample ring from breaking the report", () => {
|
||||
const sample = makeSample();
|
||||
const report = buildSidebarLayoutReport(sample, { sampleHistory: [sample], events: [], context, flags: [] });
|
||||
expect(report.transition).toBeNull();
|
||||
expect(report.digest).toContain("anomaly detected (sidebar-layout-");
|
||||
expect(report.digest).toContain("none");
|
||||
});
|
||||
|
||||
it("trims embedded history and events so snapshots stay copy-paste sized", () => {
|
||||
const truncated = makeSample({ scroller: { scrollHeight: 840, scrollTop: 390 } });
|
||||
const history = samples(200, makeSample()).concat([truncated]);
|
||||
const events = Array.from({ length: 200 }, (_, index) => ({ type: "tree-change" as const, t: 1000 + index, prevCount: index, count: index + 1, expandedConnections: [] }));
|
||||
const report = buildSidebarLayoutReport(truncated, { sampleHistory: history, events, context, flags: [] });
|
||||
expect(report.sampleHistory).toHaveLength(60);
|
||||
expect(report.events).toHaveLength(60);
|
||||
});
|
||||
|
||||
it("describes the maximum scroll offset in the digest", () => {
|
||||
const truncated = makeSample({ scroller: { scrollHeight: 840, clientHeight: 450, scrollTop: 390 } });
|
||||
const report = buildSidebarLayoutReport(truncated, { sampleHistory: [truncated], events: [], context, flags: ["under-scroll"] });
|
||||
expect(report.digest).toContain("max-scroll=390");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,929 @@
|
||||
/**
|
||||
* Sidebar layout monitor — diagnostic instrumentation for the "tree expands and
|
||||
* the sidebar scroll viewport gets stuck" class of layout bugs. A typical
|
||||
* report from the field: after expanding a Dameng connection the viewport
|
||||
* height freezes near 50% of the window and scrolling cannot reach the
|
||||
* connections listed below the Dameng subtree.
|
||||
*
|
||||
* The monitor does not fix anything. It:
|
||||
* - continuously samples the scroll shell geometry (shell/scroller/rows),
|
||||
* - records tree-change and expand/collapse events,
|
||||
* - detects anomaly signatures with hysteresis (3 consecutive samples),
|
||||
* - and produces serializable reports (sample ring + event ring + DOM
|
||||
* ancestor chain) written to the console — which also lands in the
|
||||
* user-facing debug logs whenever debug logging is enabled.
|
||||
*
|
||||
* Enabling:
|
||||
* - `pnpm dev` builds sample by default.
|
||||
* - Production builds: `window.__dbxSidebarLayoutMonitor.enable()` or set
|
||||
* localStorage["dbx-sidebar-layout-monitor"] = "1" before the sidebar
|
||||
* mounts. Enabling the in-app "debug logs" setting also arms the monitor.
|
||||
*/
|
||||
|
||||
import { isDebugLoggingEnabled } from "@/lib/backend/debugLog";
|
||||
|
||||
export const SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY = "dbx-sidebar-layout-monitor";
|
||||
export const SIDEBAR_LAYOUT_MONITOR_LABEL = "[DBX][sidebar-layout-monitor]";
|
||||
|
||||
export const SIDEBAR_LAYOUT_MONITOR_MAX_SAMPLES = 240;
|
||||
export const SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS = 200;
|
||||
export const SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK = 3;
|
||||
const SIDEBAR_LAYOUT_MONITOR_SAMPLE_INTERVAL_MS = 1000;
|
||||
// A gap larger than this between consecutive samples invalidates anomaly
|
||||
// streaks: the samples are no longer "consecutive" in time.
|
||||
const SIDEBAR_LAYOUT_MONITOR_STREAK_GAP_MS = 3000;
|
||||
// Reports embed trimmed history: the full rings stay available on the window
|
||||
// handle (recentSamples/recentEvents), while snapshots stay copy-paste sized.
|
||||
export const SIDEBAR_LAYOUT_MONITOR_REPORT_SAMPLES = 60;
|
||||
export const SIDEBAR_LAYOUT_MONITOR_REPORT_EVENTS = 60;
|
||||
|
||||
export type SidebarLayoutAnomalyFlag =
|
||||
| "half-height-shell" // shell stuck near ~50% of the window while content overflows
|
||||
| "under-scroll" // virtual tree: scrollHeight far below expected itemSize x count -> bottom rows unreachable
|
||||
| "rows-clipped" // plain tree: DOM content extends beyond scrollHeight
|
||||
| "blank-viewport" // rendered pool ends far above the viewport bottom while more content exists below
|
||||
| "bottom-unreachable"; // even at max scrollTop the last rows stay out of view
|
||||
|
||||
export interface SidebarExpandedConnectionInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
type: string;
|
||||
descendantCount: number;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutMonitorContext {
|
||||
flatNodeCount: number;
|
||||
useVirtualTree: boolean;
|
||||
virtualItemSize: number;
|
||||
scrollerEl: HTMLElement | null;
|
||||
shellEl: HTMLElement | null;
|
||||
rootEl: HTMLElement | null;
|
||||
virtualScroller: Record<string, unknown> | null;
|
||||
expandedConnections: SidebarExpandedConnectionInfo[];
|
||||
}
|
||||
|
||||
export interface SidebarLayoutSampleScroller {
|
||||
clientHeight: number;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
computedHeight: string;
|
||||
overflowY: string;
|
||||
position: string;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutSampleRows {
|
||||
renderedRows: number;
|
||||
firstTop: number;
|
||||
firstBottom: number;
|
||||
lastTop: number;
|
||||
lastBottom: number;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutSample {
|
||||
t: number;
|
||||
windowHeight: number;
|
||||
rootHeight: number;
|
||||
shellHeight: number;
|
||||
shellRatioOfRoot: number | null;
|
||||
shellRatioOfWindow: number | null;
|
||||
flatNodeCount: number;
|
||||
scroller: SidebarLayoutSampleScroller;
|
||||
virtual: {
|
||||
itemSize: number;
|
||||
expectedScrollHeight: number;
|
||||
renderedRows: number;
|
||||
visiblePoolSize: number | null;
|
||||
spacers: { start: number; end: number } | null;
|
||||
/** Live viewport window reported by the scroller (getScroll()). */
|
||||
scrollWindow: { start: number; end: number } | null;
|
||||
/** Height of the .connection-tree-content wrapper element. */
|
||||
listHeight: number | null;
|
||||
} | null;
|
||||
rows: SidebarLayoutSampleRows | null;
|
||||
sticky: { active: boolean; top: number; height: number; transform: string } | null;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutTreeChangeEvent {
|
||||
type: "tree-change";
|
||||
t: number;
|
||||
prevCount: number;
|
||||
count: number;
|
||||
expandedConnections: SidebarExpandedConnectionInfo[];
|
||||
/** Diagnostics: types/labels of the nodes added since the previous sample (capped). */
|
||||
added?: Array<{ type: string; label: string }>;
|
||||
/** Diagnostics: schema nodes currently expanded. A schema expanding without a
|
||||
* preceding expand-toggle event means the expansion was programmatic. */
|
||||
expandedSchemas?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutExpandToggleEvent {
|
||||
type: "expand-toggle";
|
||||
t: number;
|
||||
nodeId: string;
|
||||
label: string;
|
||||
nodeType: string;
|
||||
expanded: boolean;
|
||||
}
|
||||
|
||||
export type SidebarLayoutMonitorEvent =
|
||||
| SidebarLayoutTreeChangeEvent
|
||||
| SidebarLayoutExpandToggleEvent
|
||||
| { type: "anomaly"; t: number; flags: SidebarLayoutAnomalyFlag[]; sample: SidebarLayoutSample; digest: string }
|
||||
| { type: "settle"; t: number; flags: SidebarLayoutAnomalyFlag[] }
|
||||
| { type: "scroll-bottom"; t: number; atBottom: boolean; scrollTop: number; maxScrollTop: number };
|
||||
|
||||
export type SidebarLayoutMonitorEventInput = Omit<SidebarLayoutTreeChangeEvent, "t"> | Omit<SidebarLayoutExpandToggleEvent, "t">;
|
||||
|
||||
export interface SidebarLayoutAncestorInfo {
|
||||
tag: string;
|
||||
className: string;
|
||||
id: string;
|
||||
height: number;
|
||||
computed: {
|
||||
height: string;
|
||||
minHeight: string;
|
||||
flexGrow: string;
|
||||
flexShrink: string;
|
||||
flexBasis: string;
|
||||
overflowY: string;
|
||||
position: string;
|
||||
display: string;
|
||||
paddingTop: string;
|
||||
paddingBottom: string;
|
||||
borderTop: string;
|
||||
borderBottom: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SidebarLayoutReport {
|
||||
id: string;
|
||||
t: number;
|
||||
flags: SidebarLayoutAnomalyFlag[];
|
||||
sample: SidebarLayoutSample;
|
||||
transition: {
|
||||
shellHeightBefore: number | null;
|
||||
shellHeightAfter: number;
|
||||
rootHeightBefore: number | null;
|
||||
rootHeightAfter: number;
|
||||
treeCountBefore: number | null;
|
||||
treeCountAfter: number;
|
||||
changedWithinSamples: number;
|
||||
} | null;
|
||||
ancestors: SidebarLayoutAncestorInfo[];
|
||||
content: {
|
||||
list: { height: number; scrollHeight: number; rectHeight: number; overflowY: string; position: string; display: string } | null;
|
||||
sticky: { active: boolean; top: number; height: number; rectHeight: number; transform: string } | null;
|
||||
} | null;
|
||||
internals: {
|
||||
visiblePoolSize: number | null;
|
||||
startSpacerSize: number | null;
|
||||
endSpacerSize: number | null;
|
||||
scrollWindow: unknown;
|
||||
} | null;
|
||||
expandedConnections: SidebarExpandedConnectionInfo[];
|
||||
sampleHistory: SidebarLayoutSample[];
|
||||
events: SidebarLayoutMonitorEvent[];
|
||||
digest: string;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutMonitorState {
|
||||
enabled: boolean;
|
||||
startedAt: number | null;
|
||||
sampleCount: number;
|
||||
activeFlags: SidebarLayoutAnomalyFlag[];
|
||||
activations: Partial<Record<SidebarLayoutAnomalyFlag, number>>;
|
||||
lastReport: SidebarLayoutReport | null;
|
||||
lastSample: SidebarLayoutSample | null;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutMonitorOptions {
|
||||
readContext: () => SidebarLayoutMonitorContext;
|
||||
/** Logs every sample and event at console.debug level. */
|
||||
verbose?: boolean;
|
||||
intervalMs?: number;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutMonitor {
|
||||
start(): void;
|
||||
stop(): void;
|
||||
dispose(): void;
|
||||
setEnabled(enabled: boolean): void;
|
||||
isEnabled(): boolean;
|
||||
recordEvent(event: SidebarLayoutMonitorEventInput): void;
|
||||
snapshotNow(): SidebarLayoutReport;
|
||||
getState(): SidebarLayoutMonitorState;
|
||||
}
|
||||
|
||||
export interface SidebarLayoutMonitorDebugHandle {
|
||||
enable(): void;
|
||||
disable(): void;
|
||||
isEnabled(): boolean;
|
||||
setVerbose(verbose: boolean): void;
|
||||
snapshot(): SidebarLayoutReport;
|
||||
state(): SidebarLayoutMonitorState;
|
||||
recentSamples(count?: number): SidebarLayoutSample[];
|
||||
recentEvents(count?: number): SidebarLayoutMonitorEvent[];
|
||||
}
|
||||
|
||||
export function isSidebarLayoutMonitorRequested(): boolean {
|
||||
if (import.meta.env.DEV) return true;
|
||||
try {
|
||||
return localStorage.getItem(SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY) === "1" || isDebugLoggingEnabled();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function ringPush<T>(ring: readonly T[], entry: T, maxSize: number): T[] {
|
||||
const next = ring.length >= maxSize ? ring.slice(ring.length - maxSize + 1) : [...ring];
|
||||
next.push(entry);
|
||||
return next;
|
||||
}
|
||||
|
||||
function ratioOrNull(value: number, total: number): number | null {
|
||||
return total > 0 ? value / total : null;
|
||||
}
|
||||
|
||||
function rounded(value: number): number {
|
||||
return Math.round(value);
|
||||
}
|
||||
|
||||
function elementGeometry(element: HTMLElement | null): { height: number } | null {
|
||||
if (!element) return null;
|
||||
const rect = element.getBoundingClientRect();
|
||||
return { height: rounded(rect.height) };
|
||||
}
|
||||
|
||||
function scrollerMetrics(element: HTMLElement | null): SidebarLayoutSampleScroller {
|
||||
if (!element) {
|
||||
return { clientHeight: 0, scrollHeight: 0, scrollTop: 0, computedHeight: "n/a", overflowY: "n/a", position: "n/a" };
|
||||
}
|
||||
const style = window.getComputedStyle(element);
|
||||
return {
|
||||
clientHeight: element.clientHeight,
|
||||
scrollHeight: element.scrollHeight,
|
||||
scrollTop: element.scrollTop,
|
||||
computedHeight: style.height,
|
||||
overflowY: style.overflowY,
|
||||
position: style.position,
|
||||
};
|
||||
}
|
||||
|
||||
function captureRows(scrollerEl: HTMLElement | null, virtual: boolean): SidebarLayoutSampleRows | null {
|
||||
if (!scrollerEl) return null;
|
||||
const selector = virtual ? ".vue-recycle-scroller__item-view" : ".connection-tree-content > *";
|
||||
const elements = Array.from(scrollerEl.querySelectorAll<HTMLElement>(selector));
|
||||
if (elements.length === 0) return null;
|
||||
|
||||
if (!virtual) {
|
||||
// Plain tree: rows are in normal flow, so offsetTop is relative to the
|
||||
// content wrapper and reflects the true scroll position.
|
||||
const visible = elements.filter((element) => element.offsetHeight > 0);
|
||||
if (visible.length === 0) return null;
|
||||
const first = visible[0];
|
||||
const last = visible[visible.length - 1];
|
||||
return {
|
||||
renderedRows: visible.length,
|
||||
firstTop: first.offsetTop,
|
||||
firstBottom: first.offsetTop + first.offsetHeight,
|
||||
lastTop: last.offsetTop,
|
||||
lastBottom: last.offsetTop + last.offsetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// Virtual (transform) tree: item-views are absolutely positioned with
|
||||
// translateY, so offsetTop is always 0 and ignores the scroll position.
|
||||
// Measure via getBoundingClientRect (transform-aware) relative to the
|
||||
// scroller viewport, and skip the recycled views parked far above
|
||||
// (hiddenPosition = -999999). Rows are viewport-relative here.
|
||||
const scrollerRect = scrollerEl.getBoundingClientRect();
|
||||
const scrollerTop = scrollerRect.top;
|
||||
const scrollerHeight = scrollerRect.height;
|
||||
let renderedRows = 0;
|
||||
let firstTop = Number.POSITIVE_INFINITY;
|
||||
let firstBottom = 0;
|
||||
let lastTop = 0;
|
||||
let lastBottom = 0;
|
||||
for (const element of elements) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.height <= 0) continue;
|
||||
const top = rect.top - scrollerTop;
|
||||
if (top < -scrollerHeight) continue;
|
||||
renderedRows += 1;
|
||||
const bottom = top + rect.height;
|
||||
if (top < firstTop) {
|
||||
firstTop = top;
|
||||
firstBottom = bottom;
|
||||
}
|
||||
if (bottom > lastBottom) {
|
||||
lastTop = top;
|
||||
lastBottom = bottom;
|
||||
}
|
||||
}
|
||||
if (renderedRows === 0) return null;
|
||||
return { renderedRows, firstTop: Math.round(firstTop), firstBottom: Math.round(firstBottom), lastTop: Math.round(lastTop), lastBottom: Math.round(lastBottom) };
|
||||
}
|
||||
|
||||
function captureSticky(scrollerEl: HTMLElement | null): SidebarLayoutSample["sticky"] {
|
||||
const header = scrollerEl?.querySelector<HTMLElement>(".sticky-database-header") ?? null;
|
||||
if (!header || header.clientHeight === 0) return null;
|
||||
const style = window.getComputedStyle(header);
|
||||
return {
|
||||
active: true,
|
||||
top: rounded(header.getBoundingClientRect().top),
|
||||
height: header.clientHeight,
|
||||
transform: style.transform,
|
||||
};
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? Math.round(value) : null;
|
||||
}
|
||||
|
||||
function captureVirtualInternals(virtualScroller: Record<string, unknown> | null) {
|
||||
if (!virtualScroller) return null;
|
||||
const visiblePool = virtualScroller["visiblePool"];
|
||||
let scrollWindow: unknown = null;
|
||||
const getScroll = virtualScroller["getScroll"];
|
||||
if (typeof getScroll === "function") {
|
||||
try {
|
||||
const result = (getScroll as () => unknown)();
|
||||
if (typeof result === "object" && result !== null) scrollWindow = result;
|
||||
} catch {
|
||||
// Reading scroller internals must never break the app.
|
||||
}
|
||||
}
|
||||
const scrollWindowNumbers = (() => {
|
||||
if (typeof scrollWindow !== "object" || scrollWindow === null) return null;
|
||||
const candidate = scrollWindow as Record<string, unknown>;
|
||||
return asFiniteNumber(candidate["start"]) != null && asFiniteNumber(candidate["end"]) != null ? { start: asFiniteNumber(candidate["start"]) as number, end: asFiniteNumber(candidate["end"]) as number } : null;
|
||||
})();
|
||||
return {
|
||||
visiblePoolSize: Array.isArray(visiblePool) ? visiblePool.length : null,
|
||||
startSpacerSize: asFiniteNumber(virtualScroller["startSpacerSize"]),
|
||||
endSpacerSize: asFiniteNumber(virtualScroller["endSpacerSize"]),
|
||||
scrollWindow: scrollWindowNumbers,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the DOM geometry. Every value is a plain number/string so samples can
|
||||
* be stored, serialized and compared without retaining element references.
|
||||
*/
|
||||
export function captureSidebarLayoutSample(context: SidebarLayoutMonitorContext): SidebarLayoutSample {
|
||||
const now = Date.now();
|
||||
if (typeof document === "undefined") {
|
||||
return {
|
||||
t: now,
|
||||
windowHeight: 0,
|
||||
rootHeight: 0,
|
||||
shellHeight: 0,
|
||||
shellRatioOfRoot: null,
|
||||
shellRatioOfWindow: null,
|
||||
flatNodeCount: context.flatNodeCount,
|
||||
scroller: { clientHeight: 0, scrollHeight: 0, scrollTop: 0, computedHeight: "n/a", overflowY: "n/a", position: "n/a" },
|
||||
virtual: null,
|
||||
rows: null,
|
||||
sticky: null,
|
||||
};
|
||||
}
|
||||
|
||||
const root = elementGeometry(context.rootEl);
|
||||
const shell = elementGeometry(context.shellEl);
|
||||
const windowHeight = window.innerHeight;
|
||||
const rowGeometry = captureRows(context.scrollerEl, context.useVirtualTree);
|
||||
const internals = captureVirtualInternals(context.virtualScroller);
|
||||
const listElement = context.scrollerEl?.querySelector<HTMLElement>(".connection-tree-content") ?? null;
|
||||
|
||||
return {
|
||||
t: now,
|
||||
windowHeight,
|
||||
rootHeight: root?.height ?? 0,
|
||||
shellHeight: shell?.height ?? 0,
|
||||
shellRatioOfRoot: shell ? ratioOrNull(shell.height, root?.height ?? 0) : null,
|
||||
shellRatioOfWindow: shell ? ratioOrNull(shell.height, windowHeight) : null,
|
||||
flatNodeCount: context.flatNodeCount,
|
||||
scroller: scrollerMetrics(context.scrollerEl),
|
||||
virtual: context.useVirtualTree
|
||||
? {
|
||||
itemSize: context.virtualItemSize,
|
||||
expectedScrollHeight: context.flatNodeCount * context.virtualItemSize,
|
||||
renderedRows: rowGeometry?.renderedRows ?? 0,
|
||||
visiblePoolSize: internals?.visiblePoolSize ?? null,
|
||||
spacers: internals?.startSpacerSize != null || internals?.endSpacerSize != null ? { start: internals.startSpacerSize ?? 0, end: internals.endSpacerSize ?? 0 } : null,
|
||||
scrollWindow: internals?.scrollWindow ?? null,
|
||||
listHeight: listElement ? listElement.offsetHeight : null,
|
||||
}
|
||||
: null,
|
||||
rows: rowGeometry,
|
||||
sticky: captureSticky(context.scrollerEl),
|
||||
};
|
||||
}
|
||||
|
||||
export function unreachableRowCount(sample: SidebarLayoutSample): number {
|
||||
const virtual = sample.virtual;
|
||||
if (!virtual || virtual.itemSize <= 0 || sample.flatNodeCount <= 0) return 0;
|
||||
const reachable = Math.floor(sample.scroller.scrollHeight / virtual.itemSize);
|
||||
return Math.max(0, sample.flatNodeCount - reachable);
|
||||
}
|
||||
|
||||
function maxOf(values: number[]): number {
|
||||
return values.length > 0 ? Math.max(...values) : 0;
|
||||
}
|
||||
|
||||
function minOf(values: number[]): number {
|
||||
return values.length > 0 ? Math.min(...values) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure anomaly detection over a sample history. Only the last sample is
|
||||
* classified; the earlier entries provide the stability context (hysteresis).
|
||||
* Callers feed consecutive samples so streaks build up naturally.
|
||||
*/
|
||||
export function detectLayoutAnomalies(samples: readonly SidebarLayoutSample[]): SidebarLayoutAnomalyFlag[] {
|
||||
const sample = samples[samples.length - 1];
|
||||
if (!sample) return [];
|
||||
const flags: SidebarLayoutAnomalyFlag[] = [];
|
||||
const streakWindow = samples.slice(Math.max(0, samples.length - SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK));
|
||||
const halfInBand = (value: number | null) => value !== null && value >= 0.4 && value <= 0.6;
|
||||
|
||||
if (
|
||||
sample.shellHeight >= 120 &&
|
||||
sample.scroller.clientHeight > 0 &&
|
||||
sample.scroller.scrollHeight > sample.shellHeight + 4 &&
|
||||
streakWindow.length >= SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK &&
|
||||
streakWindow.every((entry) => entry.shellHeight >= 120 && halfInBand(entry.shellRatioOfWindow)) &&
|
||||
maxOf(streakWindow.map((entry) => entry.shellHeight)) - minOf(streakWindow.map((entry) => entry.shellHeight)) <= 3
|
||||
) {
|
||||
flags.push("half-height-shell");
|
||||
}
|
||||
|
||||
const virtual = sample.virtual;
|
||||
if (virtual) {
|
||||
if (virtual.itemSize > 0 && sample.flatNodeCount > 8 && virtual.expectedScrollHeight - sample.scroller.scrollHeight >= 2 * virtual.itemSize) {
|
||||
flags.push("under-scroll");
|
||||
}
|
||||
if (sample.flatNodeCount > 0 && sample.rows) {
|
||||
// Rows in virtual mode are measured relative to the scroller viewport
|
||||
// (rect-based), so the gap is against clientHeight — not content
|
||||
// coordinates. A blank band means the last materialized row ends well
|
||||
// above the viewport bottom while more content exists below it.
|
||||
const gap = sample.scroller.clientHeight - sample.rows.lastBottom;
|
||||
const contentBelowViewport = sample.scroller.scrollHeight > sample.scroller.scrollTop + sample.scroller.clientHeight + 2 * virtual.itemSize;
|
||||
if ((gap > 2 * virtual.itemSize && contentBelowViewport) || sample.rows.lastBottom < 0) {
|
||||
flags.push("blank-viewport");
|
||||
}
|
||||
}
|
||||
if (unreachableRowCount(sample) > 0 && sample.scroller.scrollTop >= sample.scroller.scrollHeight - sample.scroller.clientHeight - 2) {
|
||||
flags.push("bottom-unreachable");
|
||||
}
|
||||
} else if (sample.rows && sample.rows.lastBottom > sample.scroller.scrollHeight + 2) {
|
||||
flags.push("rows-clipped");
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
function captureAncestors(shellEl: HTMLElement | null): SidebarLayoutAncestorInfo[] {
|
||||
if (typeof window === "undefined" || !shellEl) return [];
|
||||
const result: SidebarLayoutAncestorInfo[] = [];
|
||||
let element: HTMLElement | null = shellEl;
|
||||
while (element && result.length < 14) {
|
||||
const style = window.getComputedStyle(element);
|
||||
result.push({
|
||||
tag: element.tagName.toLowerCase(),
|
||||
className: typeof element.className === "string" ? element.className : "",
|
||||
id: element.id,
|
||||
height: rounded(element.getBoundingClientRect().height),
|
||||
computed: {
|
||||
height: style.height,
|
||||
minHeight: style.minHeight,
|
||||
flexGrow: style.flexGrow,
|
||||
flexShrink: style.flexShrink,
|
||||
flexBasis: style.flexBasis,
|
||||
overflowY: style.overflowY,
|
||||
position: style.position,
|
||||
display: style.display,
|
||||
paddingTop: style.paddingTop,
|
||||
paddingBottom: style.paddingBottom,
|
||||
borderTop: style.borderTopWidth,
|
||||
borderBottom: style.borderBottomWidth,
|
||||
},
|
||||
});
|
||||
element = element.parentElement;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function captureContentInfo(scrollerEl: HTMLElement | null): SidebarLayoutReport["content"] {
|
||||
if (typeof window === "undefined" || !scrollerEl) return null;
|
||||
const list = scrollerEl.querySelector<HTMLElement>(".connection-tree-content") ?? null;
|
||||
const sticky = scrollerEl.querySelector<HTMLElement>(".sticky-database-header") ?? null;
|
||||
return {
|
||||
list: list
|
||||
? {
|
||||
height: list.offsetHeight,
|
||||
scrollHeight: list.scrollHeight,
|
||||
rectHeight: rounded(list.getBoundingClientRect().height),
|
||||
overflowY: getComputedStyle(list).overflowY,
|
||||
position: getComputedStyle(list).position,
|
||||
display: getComputedStyle(list).display,
|
||||
}
|
||||
: null,
|
||||
sticky: sticky
|
||||
? {
|
||||
active: true,
|
||||
top: rounded(sticky.getBoundingClientRect().top),
|
||||
height: sticky.clientHeight,
|
||||
rectHeight: rounded(sticky.getBoundingClientRect().height),
|
||||
transform: getComputedStyle(sticky).transform,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function findShellHeightTransition(samples: readonly SidebarLayoutSample[]): SidebarLayoutReport["transition"] {
|
||||
if (samples.length < 2) return null;
|
||||
const recent = samples.slice(-12);
|
||||
let largestDelta = 0;
|
||||
let index = -1;
|
||||
for (let i = 1; i < recent.length; i += 1) {
|
||||
const delta = Math.abs(recent[i].shellHeight - recent[i - 1].shellHeight);
|
||||
if (delta > largestDelta) {
|
||||
largestDelta = delta;
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
if (index < 0) return null;
|
||||
const before = recent[index - 1];
|
||||
const after = recent[index];
|
||||
return {
|
||||
shellHeightBefore: before.shellHeight,
|
||||
shellHeightAfter: after.shellHeight,
|
||||
rootHeightBefore: before.rootHeight,
|
||||
rootHeightAfter: after.rootHeight,
|
||||
treeCountBefore: before.flatNodeCount,
|
||||
treeCountAfter: after.flatNodeCount,
|
||||
changedWithinSamples: recent.length - index,
|
||||
};
|
||||
}
|
||||
|
||||
function formatConnections(connections: SidebarExpandedConnectionInfo[]): string {
|
||||
if (connections.length === 0) return "none";
|
||||
return connections.map((entry) => `${entry.label} (id=${entry.id.slice(0, 8)}, type=${entry.type}, desc=${entry.descendantCount})`).join(", ");
|
||||
}
|
||||
|
||||
export function buildAnomalyDigest(report: SidebarLayoutReport): string {
|
||||
const sample = report.sample;
|
||||
const virtual = sample.virtual;
|
||||
const windowRatio = sample.shellRatioOfWindow == null ? "?" : `${Math.round(sample.shellRatioOfWindow * 100)}%`;
|
||||
const rootRatio = sample.shellRatioOfRoot == null ? "?" : `${Math.round(sample.shellRatioOfRoot * 100)}%`;
|
||||
const lines = [
|
||||
`${SIDEBAR_LAYOUT_MONITOR_LABEL} anomaly detected (${report.id}): ${report.flags.join(", ") || "none"}`,
|
||||
` geometry: window=${sample.windowHeight} root=${sample.rootHeight} shell=${sample.shellHeight} (window ${windowRatio}, root ${rootRatio}) | scroller client=${sample.scroller.clientHeight} scroll=${sample.scroller.scrollHeight} top=${sample.scroller.scrollTop} max-scroll=${sample.scroller.scrollHeight - sample.scroller.clientHeight} computed-height=${sample.scroller.computedHeight} overflow-y=${sample.scroller.overflowY}`,
|
||||
];
|
||||
if (virtual) {
|
||||
const viewport = virtual.scrollWindow ? `${virtual.scrollWindow.start}..${virtual.scrollWindow.end}` : "?";
|
||||
lines.push(
|
||||
` virtual: ${sample.flatNodeCount} rows x ${virtual.itemSize}px -> expected ${virtual.expectedScrollHeight}px, actual ${sample.scroller.scrollHeight}px, unreachable ${unreachableRowCount(sample)} rows | pool=${virtual.visiblePoolSize ?? "?"} spacers=${virtual.spacers ? `${virtual.spacers.start}/${virtual.spacers.end}` : "?"} rendered=${virtual.renderedRows} viewport=${viewport} list=${virtual.listHeight ?? "?"}`,
|
||||
);
|
||||
}
|
||||
if (sample.rows) {
|
||||
lines.push(` rows: rendered=${sample.rows.renderedRows} first=${sample.rows.firstTop}..${sample.rows.firstBottom} last=${sample.rows.lastTop}..${sample.rows.lastBottom}`);
|
||||
}
|
||||
if (sample.sticky) {
|
||||
lines.push(` sticky: active top=${sample.sticky.top} height=${sample.sticky.height} transform=${sample.sticky.transform}`);
|
||||
}
|
||||
if (report.transition) {
|
||||
const transition = report.transition;
|
||||
const shellBefore = transition.shellHeightBefore;
|
||||
const delta = shellBefore == null ? "?" : transition.shellHeightAfter - shellBefore;
|
||||
lines.push(` transition: shell ${shellBefore ?? "?"} -> ${transition.shellHeightAfter} px (delta ${delta}) within ${transition.changedWithinSamples} samples; tree count ${transition.treeCountBefore ?? "?"} -> ${transition.treeCountAfter}`);
|
||||
}
|
||||
const expansions = report.events.filter((event): event is SidebarLayoutExpandToggleEvent => event.type === "expand-toggle").slice(-3);
|
||||
for (const event of expansions) {
|
||||
lines.push(` expand-toggle: ${event.expanded ? "expanded" : "collapsed"} ${event.label} (${event.nodeType}, id=${event.nodeId.slice(0, 8)})`);
|
||||
}
|
||||
lines.push(` expanded connections: ${formatConnections(report.expandedConnections)}`);
|
||||
lines.push(` full report: window.__dbxSidebarLayoutMonitor.snapshot()`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function buildSidebarLayoutReport(
|
||||
sample: SidebarLayoutSample,
|
||||
options: {
|
||||
sampleHistory: readonly SidebarLayoutSample[];
|
||||
events: readonly SidebarLayoutMonitorEvent[];
|
||||
context: SidebarLayoutMonitorContext;
|
||||
flags: SidebarLayoutAnomalyFlag[];
|
||||
},
|
||||
): SidebarLayoutReport {
|
||||
const report: SidebarLayoutReport = {
|
||||
id: `sidebar-layout-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
t: sample.t,
|
||||
flags: options.flags,
|
||||
sample,
|
||||
transition: findShellHeightTransition(options.sampleHistory),
|
||||
ancestors: captureAncestors(options.context.shellEl),
|
||||
content: captureContentInfo(options.context.scrollerEl),
|
||||
internals: captureVirtualInternals(options.context.virtualScroller),
|
||||
expandedConnections: options.context.expandedConnections,
|
||||
sampleHistory: [...options.sampleHistory].slice(-SIDEBAR_LAYOUT_MONITOR_REPORT_SAMPLES),
|
||||
events: [...options.events].slice(-SIDEBAR_LAYOUT_MONITOR_REPORT_EVENTS),
|
||||
digest: "",
|
||||
};
|
||||
report.digest = buildAnomalyDigest(report);
|
||||
return report;
|
||||
}
|
||||
|
||||
export function createSidebarLayoutMonitor(options: SidebarLayoutMonitorOptions): SidebarLayoutMonitor {
|
||||
const readContext = options.readContext;
|
||||
const intervalMs = options.intervalMs ?? SIDEBAR_LAYOUT_MONITOR_SAMPLE_INTERVAL_MS;
|
||||
let verbose = options.verbose ?? false;
|
||||
let enabled = isSidebarLayoutMonitorRequested();
|
||||
let started = false;
|
||||
let active = false;
|
||||
let startedAt: number | null = null;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let burstTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let resizeObserver: ResizeObserver | null = null;
|
||||
let observedScroller: HTMLElement | null = null;
|
||||
let observedShell: HTMLElement | null = null;
|
||||
let sampleCount = 0;
|
||||
let samples: SidebarLayoutSample[] = [];
|
||||
let events: SidebarLayoutMonitorEvent[] = [];
|
||||
let streaks = new Map<SidebarLayoutAnomalyFlag, number>();
|
||||
let activeFlags: SidebarLayoutAnomalyFlag[] = [];
|
||||
let activations: Partial<Record<SidebarLayoutAnomalyFlag, number>> = {};
|
||||
let lastReport: SidebarLayoutReport | null = null;
|
||||
let handle: SidebarLayoutMonitorDebugHandle | null = null;
|
||||
let scrollListenerElement: HTMLElement | null = null;
|
||||
let lastScrollCaptureAt = 0;
|
||||
let lastScrollAtBottom: boolean | null = null;
|
||||
let lastSampleT = 0;
|
||||
// An explicit enable()/disable() from the debug handle wins over the
|
||||
// auto-arming rules (DEV default, localStorage flag, debug-logs setting),
|
||||
// so the user can actually turn the monitor off in a DEV session.
|
||||
let userOverride: boolean | null = null;
|
||||
|
||||
function onTreeScroll() {
|
||||
// Throttle scroll-driven captures so continuous scrolling cannot flood
|
||||
// the sample ring with near-identical entries.
|
||||
const now = Date.now();
|
||||
if (now - lastScrollCaptureAt < 200) return;
|
||||
lastScrollCaptureAt = now;
|
||||
const context = readContext();
|
||||
const scroller = context.scrollerEl;
|
||||
if (scroller) {
|
||||
const maxScrollTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
const atBottom = scroller.scrollTop >= maxScrollTop - 4;
|
||||
if (atBottom !== lastScrollAtBottom) {
|
||||
lastScrollAtBottom = atBottom;
|
||||
events = ringPush(events, { type: "scroll-bottom", t: now, atBottom, scrollTop: Math.round(scroller.scrollTop), maxScrollTop }, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
|
||||
}
|
||||
}
|
||||
captureSample(context);
|
||||
}
|
||||
|
||||
function attachScrollerListeners(context: SidebarLayoutMonitorContext) {
|
||||
const scroller = context.scrollerEl;
|
||||
if (scroller === scrollListenerElement) return;
|
||||
if (scrollListenerElement) scrollListenerElement.removeEventListener("scroll", onTreeScroll);
|
||||
scrollListenerElement = null;
|
||||
if (scroller) {
|
||||
scroller.addEventListener("scroll", onTreeScroll, { passive: true });
|
||||
scrollListenerElement = scroller;
|
||||
}
|
||||
}
|
||||
|
||||
function detachScrollerListeners() {
|
||||
if (scrollListenerElement) scrollListenerElement.removeEventListener("scroll", onTreeScroll);
|
||||
scrollListenerElement = null;
|
||||
}
|
||||
|
||||
function keepResizeObservation(context: SidebarLayoutMonitorContext) {
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
if (context.scrollerEl === observedScroller && context.shellEl === observedShell) return;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
observedScroller = null;
|
||||
observedShell = null;
|
||||
const targets = [context.scrollerEl, context.shellEl].filter((element): element is HTMLElement => !!element);
|
||||
if (targets.length === 0) return;
|
||||
resizeObserver = new ResizeObserver(() => captureSample());
|
||||
for (const target of targets) resizeObserver.observe(target);
|
||||
observedScroller = context.scrollerEl;
|
||||
observedShell = context.shellEl;
|
||||
}
|
||||
|
||||
function persistEnabled() {
|
||||
try {
|
||||
if (enabled) localStorage.setItem(SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY, "1");
|
||||
else localStorage.removeItem(SIDEBAR_LAYOUT_MONITOR_STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage may be unavailable; the in-memory flag still applies.
|
||||
}
|
||||
}
|
||||
|
||||
function setEnabled(next: boolean, fromUser = true) {
|
||||
if (enabled === next) return;
|
||||
enabled = next;
|
||||
if (fromUser) userOverride = next;
|
||||
persistEnabled();
|
||||
active = next;
|
||||
if (next) {
|
||||
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, "enabled", new Date().toISOString());
|
||||
captureSample();
|
||||
} else {
|
||||
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, "disabled");
|
||||
}
|
||||
}
|
||||
|
||||
function captureSample(prefetchedContext?: SidebarLayoutMonitorContext) {
|
||||
if (!started || !active) return;
|
||||
const context = prefetchedContext ?? readContext();
|
||||
if (!context.scrollerEl || !context.shellEl) return;
|
||||
attachScrollerListeners(context);
|
||||
keepResizeObservation(context);
|
||||
const sample = captureSidebarLayoutSample(context);
|
||||
samples = ringPush(samples, sample, SIDEBAR_LAYOUT_MONITOR_MAX_SAMPLES);
|
||||
sampleCount += 1;
|
||||
|
||||
// Streaks are built from consecutive samples; a long gap (tab hidden,
|
||||
// tree emptied, renderer switch) means the previous streak is stale.
|
||||
if (lastSampleT > 0 && sample.t - lastSampleT > SIDEBAR_LAYOUT_MONITOR_STREAK_GAP_MS) {
|
||||
streaks.clear();
|
||||
}
|
||||
lastSampleT = sample.t;
|
||||
|
||||
const detected = detectLayoutAnomalies(samples);
|
||||
const flagSet = new Set(detected);
|
||||
for (const flag of detected) streaks.set(flag, (streaks.get(flag) ?? 0) + 1);
|
||||
const newlyActivated: SidebarLayoutAnomalyFlag[] = [];
|
||||
for (const [flag, streak] of streaks) {
|
||||
if (!flagSet.has(flag)) {
|
||||
streaks.delete(flag);
|
||||
} else if (streak >= SIDEBAR_LAYOUT_MONITOR_ANOMALY_STREAK && !activeFlags.includes(flag)) {
|
||||
activeFlags = [...activeFlags, flag];
|
||||
activations[flag] = (activations[flag] ?? 0) + 1;
|
||||
newlyActivated.push(flag);
|
||||
}
|
||||
}
|
||||
if (newlyActivated.length > 0) {
|
||||
// Anomaly events carry the activating sample + digest so that
|
||||
// recentEvents() alone tells the full geometry story.
|
||||
const report = buildSidebarLayoutReport(sample, { sampleHistory: samples, events, context, flags: [...activeFlags] });
|
||||
lastReport = report;
|
||||
events = ringPush(events, { type: "anomaly", t: sample.t, flags: newlyActivated, sample: report.sample, digest: report.digest }, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
|
||||
}
|
||||
const settledFlags = activeFlags.filter((flag) => !flagSet.has(flag));
|
||||
if (settledFlags.length > 0) {
|
||||
activeFlags = activeFlags.filter((flag) => flagSet.has(flag));
|
||||
events = ringPush(events, { type: "settle", t: sample.t, flags: settledFlags }, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
|
||||
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, "anomaly settled", settledFlags.join(","));
|
||||
}
|
||||
if (activeFlags.length > 0) {
|
||||
const report = buildSidebarLayoutReport(sample, { sampleHistory: samples, events, context, flags: [...activeFlags] });
|
||||
lastReport = report;
|
||||
if (newlyActivated.length > 0 || sampleCount % 4 === 0) {
|
||||
console.warn(report.digest);
|
||||
}
|
||||
}
|
||||
if (verbose) {
|
||||
console.debug(SIDEBAR_LAYOUT_MONITOR_LABEL, "sample", JSON.stringify({ t: sample.t, shell: sample.shellHeight, ratio: sample.shellRatioOfWindow, scroller: sample.scroller, count: sample.flatNodeCount, rows: sample.rows?.renderedRows ?? 0 }));
|
||||
}
|
||||
}
|
||||
|
||||
function recordEvent(event: SidebarLayoutMonitorEventInput) {
|
||||
const stamped = { ...event, t: Date.now() } as SidebarLayoutMonitorEvent;
|
||||
events = ringPush(events, stamped, SIDEBAR_LAYOUT_MONITOR_MAX_EVENTS);
|
||||
if (event.type === "tree-change") {
|
||||
scheduleBurst();
|
||||
}
|
||||
if (verbose) {
|
||||
console.debug(SIDEBAR_LAYOUT_MONITOR_LABEL, "event", JSON.stringify(stamped));
|
||||
}
|
||||
}
|
||||
|
||||
// Streaming tree changes (e.g. a Dameng subtree loading in batches) fire many
|
||||
// tree-change events back to back; merge the post-change captures into a
|
||||
// single pending sample instead of stacking one timer per event.
|
||||
function scheduleBurst() {
|
||||
if (burstTimer) return;
|
||||
burstTimer = setTimeout(() => {
|
||||
burstTimer = null;
|
||||
captureSample();
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function snapshotNow(): SidebarLayoutReport {
|
||||
const context = readContext();
|
||||
const sample = captureSidebarLayoutSample(context);
|
||||
const history = [...samples, sample];
|
||||
const flags = detectLayoutAnomalies(history);
|
||||
const report = buildSidebarLayoutReport(sample, { sampleHistory: history, events, context, flags });
|
||||
lastReport = report;
|
||||
return report;
|
||||
}
|
||||
|
||||
function installWindowHandle() {
|
||||
if (typeof window === "undefined") return;
|
||||
const nextHandle: SidebarLayoutMonitorDebugHandle = {
|
||||
enable: () => setEnabled(true),
|
||||
disable: () => setEnabled(false),
|
||||
isEnabled: () => enabled,
|
||||
setVerbose: (nextVerbose) => {
|
||||
verbose = nextVerbose;
|
||||
},
|
||||
snapshot: () => snapshotNow(),
|
||||
state: () => getState(),
|
||||
recentSamples: (count = 40) => samples.slice(-count),
|
||||
recentEvents: (count = 30) => events.slice(-count),
|
||||
};
|
||||
handle = nextHandle;
|
||||
window.__dbxSidebarLayoutMonitor = nextHandle;
|
||||
}
|
||||
|
||||
function uninstallWindowHandle() {
|
||||
if (typeof window === "undefined") return;
|
||||
if (window.__dbxSidebarLayoutMonitor === handle) {
|
||||
delete window.__dbxSidebarLayoutMonitor;
|
||||
}
|
||||
handle = null;
|
||||
}
|
||||
|
||||
function getState(): SidebarLayoutMonitorState {
|
||||
return {
|
||||
enabled,
|
||||
startedAt,
|
||||
sampleCount,
|
||||
activeFlags: [...activeFlags],
|
||||
activations: { ...activations },
|
||||
lastReport,
|
||||
lastSample: samples[samples.length - 1] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (!started) return;
|
||||
started = false;
|
||||
active = false;
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
if (burstTimer) clearTimeout(burstTimer);
|
||||
burstTimer = null;
|
||||
resizeObserver?.disconnect();
|
||||
resizeObserver = null;
|
||||
observedScroller = null;
|
||||
observedShell = null;
|
||||
detachScrollerListeners();
|
||||
}
|
||||
|
||||
function dispose() {
|
||||
stop();
|
||||
uninstallWindowHandle();
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return;
|
||||
started = true;
|
||||
startedAt = Date.now();
|
||||
active = enabled;
|
||||
installWindowHandle();
|
||||
console.info(SIDEBAR_LAYOUT_MONITOR_LABEL, `started (${enabled ? "armed" : "idle — call window.__dbxSidebarLayoutMonitor.enable() or enable the debug-logs setting to arm"})`, "interval", intervalMs);
|
||||
// The interval always runs so the monitor can arm itself when the
|
||||
// debug-logs setting or the localStorage flag is turned on later.
|
||||
timer = setInterval(tick, intervalMs);
|
||||
if (enabled) captureSample();
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (userOverride !== null) {
|
||||
if (enabled) captureSample();
|
||||
return;
|
||||
}
|
||||
const requested = isSidebarLayoutMonitorRequested();
|
||||
if (requested !== enabled) {
|
||||
setEnabled(requested, false);
|
||||
return;
|
||||
}
|
||||
if (enabled) captureSample();
|
||||
}
|
||||
|
||||
return {
|
||||
start,
|
||||
stop,
|
||||
dispose,
|
||||
setEnabled,
|
||||
isEnabled: () => enabled,
|
||||
recordEvent,
|
||||
snapshotNow,
|
||||
getState,
|
||||
};
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__dbxSidebarLayoutMonitor?: SidebarLayoutMonitorDebugHandle;
|
||||
}
|
||||
}
|
||||
@@ -1334,7 +1334,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
function isConnectionUtilityNode(node: TreeNode): boolean {
|
||||
return node.type === "user-admin" || node.type === "dameng-job-admin" || node.type === "saved-sql-root";
|
||||
// dameng-users / dameng-roles must be here too: they are synthesized admin
|
||||
// nodes (fixed ids, rebuilt on every re-normalization). If they were treated
|
||||
// as metadata children, withConnectionUtilityNodes would keep the old copies
|
||||
// AND append fresh ones on every useCachedChildren pass, duplicating the
|
||||
// 用户/角色 menus once per refresh cycle.
|
||||
return node.type === "user-admin" || node.type === "dameng-users" || node.type === "dameng-roles" || node.type === "dameng-job-admin" || node.type === "saved-sql-root";
|
||||
}
|
||||
|
||||
function connectionMetadataChildren(children: TreeNode[] | undefined): TreeNode[] {
|
||||
@@ -1431,6 +1436,22 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return false;
|
||||
}
|
||||
|
||||
function dedupeTreeNodeChildrenById(children: TreeNode[]): TreeNode[] {
|
||||
// Keep the LAST occurrence of each id (reverse pass): if a stale copy was
|
||||
// preserved ahead of a freshly synthesized node with the same id, the fresh
|
||||
// node — carrying the current members/data — is the one that must survive.
|
||||
const seen = new Set<string>();
|
||||
const deduped: TreeNode[] = [];
|
||||
for (let index = children.length - 1; index >= 0; index -= 1) {
|
||||
const child = children[index];
|
||||
if (seen.has(child.id)) continue;
|
||||
seen.add(child.id);
|
||||
deduped.push(child);
|
||||
}
|
||||
deduped.reverse();
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function setChildren(parent: TreeNode, children: TreeNode[]) {
|
||||
// Compare markers against the resolved child list (after connection preserve), not the raw loader payload.
|
||||
children = preserveExistingConnectionMetadataChildren(parent, children);
|
||||
@@ -1438,6 +1459,12 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
if (parent.type === "database") {
|
||||
children = withDatabaseSavedSqlRoot(parent, children, savedSqlFilesByDatabase);
|
||||
}
|
||||
// Defensive: sibling ids must be unique (RecycleScroller keys rows by id).
|
||||
// The Dameng users/roles admin nodes were once re-appended by every
|
||||
// re-normalization, leaving duplicate rows in the sidebar; last-wins keeps
|
||||
// the freshly synthesized copy so such a regression can never surface as
|
||||
// repeated menus or stale member data.
|
||||
children = dedupeTreeNodeChildrenById(children);
|
||||
if (shouldClearDescendantLoadedMarkers(parent, children)) {
|
||||
clearDescendantLoadedChildrenMarkers(parent.id);
|
||||
// Parent load may still be current; only supersede descendant generations.
|
||||
@@ -2268,6 +2295,19 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
.finally(() => staleTreeRefreshIds.delete(liveNode.id));
|
||||
}
|
||||
|
||||
// The metadata tree cache must not persist UI state. isExpanded flags written
|
||||
// while a schema was expanded would replay that expansion on every cache hit
|
||||
// (a manual collapse never rewrites the cache), so opening a connection would
|
||||
// keep auto-expanding a schema the user collapsed. Expansion state is instead
|
||||
// restored from the live tree (restoreExpandedChildren) within a session.
|
||||
function stripTreeNodeExpansionState(nodes: TreeNode[]): TreeNode[] {
|
||||
return nodes.map((node) => ({
|
||||
...node,
|
||||
isExpanded: false,
|
||||
children: node.children ? stripTreeNodeExpansionState(node.children) : node.children,
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadPersistedTreeChildren(node: TreeNode, cacheKey: string, load: TreeNodeLoadHandle): Promise<PersistedTreeChildrenLoadResult> {
|
||||
const trace = createMetadataLoadTrace({
|
||||
kind: "persisted-tree-cache",
|
||||
@@ -2284,7 +2324,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return { hit: false, isStale: false };
|
||||
}
|
||||
const config = node.connectionId ? getConfig(node.connectionId) : undefined;
|
||||
const cachedChildren = normalizeCataloglessDatabaseNodes(expandCachedObjectBrowserNodes(decoded.children));
|
||||
const cachedChildren = normalizeCataloglessDatabaseNodes(stripTreeNodeExpansionState(expandCachedObjectBrowserNodes(decoded.children)));
|
||||
const childrenWithLinkedServers = node.type === "connection" && node.connectionId ? ensureSqlServerLinkedRootNode(node.connectionId, cachedChildren, config) : cachedChildren;
|
||||
if (node.type === "connection" && !hasConnectionMetadataChildren(childrenWithLinkedServers)) {
|
||||
logMetadataLoadTrace(metadataTraceLogger, trace, "cache-miss", { cacheStatus: "miss", resultCount: 0 });
|
||||
@@ -2309,7 +2349,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
}
|
||||
|
||||
async function savePersistedTreeChildren(cacheKey: string, children: TreeNode[]) {
|
||||
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(stripDatabaseSavedSqlTreeNodes(children))).catch(() => undefined);
|
||||
await api.saveSchemaCache(cacheKey, encodeSchemaTreeCache(stripTreeNodeExpansionState(stripDatabaseSavedSqlTreeNodes(children)))).catch(() => undefined);
|
||||
}
|
||||
|
||||
function sidebarTableSearchTreeCacheKey(parent: TreeNode): string | null {
|
||||
|
||||
Reference in new issue
Block a user