diff --git a/apps/desktop/src/components/sidebar/ConnectionTree.vue b/apps/desktop/src/components/sidebar/ConnectionTree.vue index bb5ab121f..5872c18fc 100644 --- a/apps/desktop/src/components/sidebar/ConnectionTree.vue +++ b/apps/desktop/src/components/sidebar/ConnectionTree.vue @@ -41,8 +41,9 @@ 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"; +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"; const { t } = useI18n(); const store = useConnectionStore(); @@ -58,6 +59,7 @@ const pointerInsideTree = ref(false); const treeScrollerRef = ref | null>(null); const plainTreeScrollerRef = ref(null); const sidebarScrollbarTrackRef = ref(null); +const sidebarHorizontalScrollbarTrackRef = ref(null); const sidebarContextMenuRef = ref<{ close: () => void } | null>(null); const sidebarContextMenuItems = ref([]); const sidebarContextMenuTarget = ref(null); @@ -377,7 +379,10 @@ const flatNodes = computed(() => const sidebarCommentLabelWidths = shallowRef(new Map()); let sidebarCommentMeasureFrame = 0; +const sidebarTreeContentWidth = ref(0); +let sidebarTreeContentMeasureFrame = 0; const sidebarTableNameDisplayTypes = new Set(["table", "view", "materialized_view", "mongo-collection", "vector-collection", "elasticsearch-index"]); +const sidebarStorageDisplayTypes = new Set(["database", "table", "materialized_view"]); function sidebarCommentLabel(node: TreeNode): string { const label = sidebarTableNameDisplayTypes.has(node.type) ? sidebarDisplayTableName(node.label, settingsStore.editorSettings.sidebarHiddenTablePrefixes) : node.label; @@ -415,11 +420,55 @@ function scheduleSidebarCommentLabelMeasure() { sidebarCommentMeasureFrame = window.requestAnimationFrame(measureSidebarCommentLabelWidths); } +function sidebarNodeHasTrailingMetadata(node: TreeNode): boolean { + const mode = settingsStore.editorSettings.sidebarObjectInfoMode; + if (mode.startsWith("comment-") && sidebarTreeNodeComment(node)) return true; + return mode === "size" && sidebarStorageDisplayTypes.has(node.type) && !!formatSidebarObjectStorage(node.sizeBytes); +} + +const sidebarTreeNaturalWidthItems = computed(() => + flatNodes.value.map(({ depth, node }) => ({ + depth, + label: sidebarCommentLabel(node), + usesNaturalWidth: usesFullWidthTreeLabel(node.type, settingsStore.editorSettings.sidebarAllowHorizontalScroll, sidebarNodeHasTrailingMetadata(node)), + trailingWidth: node.pinned || store.isTreeNodePinned(node) ? 20 : 0, + })), +); + +function measureSidebarTreeContentWidth() { + sidebarTreeContentMeasureFrame = 0; + if (!settingsStore.editorSettings.sidebarAllowHorizontalScroll || typeof document === "undefined" || !rootRef.value) { + sidebarTreeContentWidth.value = 0; + 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}`; + sidebarTreeContentWidth.value = sidebarTreeNaturalContentWidth(sidebarTreeNaturalWidthItems.value, (text) => context.measureText(text).width); + void nextTick(scheduleSidebarScrollMetricsUpdate); +} + +function scheduleSidebarTreeContentWidthMeasure() { + if (typeof window === "undefined") { + measureSidebarTreeContentWidth(); + return; + } + if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame); + sidebarTreeContentMeasureFrame = window.requestAnimationFrame(measureSidebarTreeContentWidth); +} + watch([flatNodes, () => settingsStore.editorSettings.sidebarObjectInfoMode, () => settingsStore.editorSettings.sidebarHiddenTablePrefixes, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarCommentLabelMeasure, { flush: "post", immediate: true, }); +watch([sidebarTreeNaturalWidthItems, () => settingsStore.editorSettings.uiFontFamily, () => settingsStore.editorSettings.uiScale], scheduleSidebarTreeContentWidthMeasure, { + 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))); @@ -454,26 +503,31 @@ const activeTab = computed(() => queryStore.tabs.find((tab) => tab.id === queryS // component, tracking scroll offset to find the topmost visible database-level // ancestor. The overlay reuses , so collapse/expand comes for free. const stickyScrollTop = ref(0); -const sidebarScrollMetrics = ref({ scrollTop: 0, clientHeight: 0, scrollHeight: 0 }); +const sidebarScrollMetrics = ref({ scrollTop: 0, scrollLeft: 0, clientHeight: 0, clientWidth: 0, scrollHeight: 0, scrollWidth: 0 }); const isScrollingSidebar = ref(false); const isDraggingSidebarScrollbar = ref(false); +const isDraggingSidebarHorizontalScrollbar = ref(false); let sidebarScrollbarResizeObserver: ResizeObserver | null = null; let sidebarScrollbarAnimationFrame = 0; let sidebarScrollbarDragOffset = 0; +let sidebarHorizontalScrollbarDragOffset = 0; let sidebarScrollingTimer = 0; function updateSidebarScrollMetrics() { const scroller = currentTreeScroller(); if (!scroller) { - sidebarScrollMetrics.value = { scrollTop: 0, clientHeight: 0, scrollHeight: 0 }; + sidebarScrollMetrics.value = { scrollTop: 0, scrollLeft: 0, clientHeight: 0, clientWidth: 0, scrollHeight: 0, scrollWidth: 0 }; return; } if (useVirtualTree.value) stickyScrollTop.value = scroller.scrollTop; sidebarScrollMetrics.value = { scrollTop: scroller.scrollTop, + scrollLeft: scroller.scrollLeft, clientHeight: scroller.clientHeight, + clientWidth: scroller.clientWidth, scrollHeight: scroller.scrollHeight, + scrollWidth: Math.max(scroller.scrollWidth, sidebarTreeContentWidth.value), }; } @@ -515,6 +569,8 @@ watch( sidebarScrollbarResizeObserver = new ResizeObserver(scheduleSidebarScrollMetricsUpdate); sidebarScrollbarResizeObserver.observe(scroller); + const content = scroller.querySelector(".connection-tree-content"); + if (content) sidebarScrollbarResizeObserver.observe(content); scheduleSidebarScrollMetricsUpdate(); onCleanup(() => { @@ -566,21 +622,32 @@ watch(flatNodes, () => { }); const sidebarTreeOverflowClass = computed(() => (settingsStore.editorSettings.sidebarAllowHorizontalScroll ? "overflow-x-auto sidebar-tree-horizontal-scroll" : "overflow-x-hidden")); +const sidebarTreeScrollerStyle = computed(() => ({ "--sidebar-tree-content-width": `${sidebarTreeContentWidth.value}px` }) as CSSProperties); const hasSidebarVerticalOverflow = computed(() => sidebarScrollMetrics.value.scrollHeight > sidebarScrollMetrics.value.clientHeight + 1); +const hasSidebarHorizontalOverflow = computed(() => settingsStore.editorSettings.sidebarAllowHorizontalScroll && sidebarScrollMetrics.value.scrollWidth > sidebarScrollMetrics.value.clientWidth + 1); + +watch( + () => settingsStore.editorSettings.sidebarAllowHorizontalScroll, + (enabled) => + void nextTick(() => { + const scroller = currentTreeScroller(); + if (!enabled && scroller) scroller.scrollLeft = 0; + scheduleSidebarScrollMetricsUpdate(); + }), + { flush: "post" }, +); function sidebarScrollbarGeometry() { const { scrollTop, clientHeight, scrollHeight } = sidebarScrollMetrics.value; const trackHeight = sidebarScrollbarTrackRef.value?.clientHeight ?? Math.max(0, clientHeight - 8); - if (trackHeight <= 0 || scrollHeight <= clientHeight) { - return { thumbTop: 0, thumbHeight: 0, maxThumbTop: 0, maxScrollTop: 0 }; - } - - const thumbHeight = Math.max(24, Math.min(trackHeight, (clientHeight / scrollHeight) * trackHeight)); - const maxThumbTop = Math.max(0, trackHeight - thumbHeight); - const maxScrollTop = Math.max(1, scrollHeight - clientHeight); - const thumbTop = Math.min(maxThumbTop, Math.max(0, (scrollTop / maxScrollTop) * maxThumbTop)); - return { thumbTop, thumbHeight, maxThumbTop, maxScrollTop }; + const { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset } = calculateSidebarScrollbarGeometry({ + scrollOffset: scrollTop, + viewportSize: clientHeight, + contentSize: scrollHeight, + trackSize: trackHeight, + }); + return { thumbTop: thumbOffset, thumbHeight: thumbSize, maxThumbTop: maxThumbOffset, maxScrollTop: maxScrollOffset }; } const sidebarScrollbarThumbStyle = computed(() => { @@ -644,6 +711,79 @@ function onSidebarScrollbarThumbPointerDown(event: PointerEvent) { window.addEventListener("pointercancel", stopSidebarScrollbarDrag); } +function sidebarHorizontalScrollbarGeometry() { + const { scrollLeft, clientWidth, scrollWidth } = sidebarScrollMetrics.value; + const trackWidth = sidebarHorizontalScrollbarTrackRef.value?.clientWidth ?? clientWidth; + const { thumbOffset, thumbSize, maxThumbOffset, maxScrollOffset } = calculateSidebarScrollbarGeometry({ + scrollOffset: scrollLeft, + viewportSize: clientWidth, + contentSize: scrollWidth, + trackSize: trackWidth, + }); + return { thumbLeft: thumbOffset, thumbWidth: thumbSize, maxThumbLeft: maxThumbOffset, maxScrollLeft: maxScrollOffset }; +} + +const sidebarHorizontalScrollbarThumbStyle = computed(() => { + const { thumbLeft, thumbWidth } = sidebarHorizontalScrollbarGeometry(); + return { + width: `${thumbWidth}px`, + transform: `translateX(${thumbLeft}px)`, + }; +}); + +function setSidebarHorizontalScrollFromPointer(clientX: number, offset: number) { + const scroller = currentTreeScroller(); + const track = sidebarHorizontalScrollbarTrackRef.value; + if (!scroller || !track) return; + + const rect = track.getBoundingClientRect(); + const { maxThumbLeft, maxScrollLeft } = sidebarHorizontalScrollbarGeometry(); + if (maxThumbLeft <= 0) return; + + const thumbLeft = Math.min(maxThumbLeft, Math.max(0, clientX - rect.left - offset)); + scroller.scrollLeft = (thumbLeft / maxThumbLeft) * maxScrollLeft; + updateSidebarScrollMetrics(); +} + +function stopSidebarHorizontalScrollbarDrag() { + isDraggingSidebarHorizontalScrollbar.value = false; + window.removeEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove); + window.removeEventListener("pointerup", stopSidebarHorizontalScrollbarDrag); + window.removeEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag); +} + +function onSidebarHorizontalScrollbarPointerMove(event: PointerEvent) { + event.preventDefault(); + setSidebarHorizontalScrollFromPointer(event.clientX, sidebarHorizontalScrollbarDragOffset); +} + +function onSidebarHorizontalScrollbarTrackPointerDown(event: PointerEvent) { + if (event.button !== 0) return; + event.preventDefault(); + const { thumbWidth } = sidebarHorizontalScrollbarGeometry(); + sidebarHorizontalScrollbarDragOffset = thumbWidth / 2; + setSidebarHorizontalScrollFromPointer(event.clientX, sidebarHorizontalScrollbarDragOffset); + isDraggingSidebarHorizontalScrollbar.value = true; + window.addEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove); + window.addEventListener("pointerup", stopSidebarHorizontalScrollbarDrag); + window.addEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag); +} + +function onSidebarHorizontalScrollbarThumbPointerDown(event: PointerEvent) { + if (event.button !== 0) return; + event.preventDefault(); + const track = sidebarHorizontalScrollbarTrackRef.value; + if (!track) return; + + const rect = track.getBoundingClientRect(); + const { thumbLeft } = sidebarHorizontalScrollbarGeometry(); + sidebarHorizontalScrollbarDragOffset = event.clientX - rect.left - thumbLeft; + isDraggingSidebarHorizontalScrollbar.value = true; + window.addEventListener("pointermove", onSidebarHorizontalScrollbarPointerMove); + window.addEventListener("pointerup", stopSidebarHorizontalScrollbarDrag); + window.addEventListener("pointercancel", stopSidebarHorizontalScrollbarDrag); +} + const pasteHandlerRegistry = createSidebarPasteHandlerRegistry(); provide(sidebarTreeContextKey, { @@ -1440,10 +1580,12 @@ onUnmounted(() => { tableSearchFocusRestoreTokens.clear(); latestTableSearchInteractionParentId = null; stopSidebarScrollbarDrag(); + stopSidebarHorizontalScrollbarDrag(); sidebarScrollbarResizeObserver?.disconnect(); window.cancelAnimationFrame(sidebarScrollbarAnimationFrame); window.clearTimeout(sidebarScrollingTimer); if (sidebarCommentMeasureFrame) window.cancelAnimationFrame(sidebarCommentMeasureFrame); + if (sidebarTreeContentMeasureFrame) window.cancelAnimationFrame(sidebarTreeContentMeasureFrame); }); defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); @@ -1541,11 +1683,12 @@ defineExpose({ focusSearch, createNewGroup, collapseAllTreeNodes }); -
+