diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 842582ffe..73a9c5166 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -113,6 +113,7 @@ import { focusSidebarRenameInput } from "@/lib/sidebarRenameFocus"; import { hasTreeNodeDatabaseContext } from "@/lib/treeNodeContext"; import { defaultPasteTableMode, pasteTableModeCopiesData, supportsWholeRowTableDataCopy, tableClipboardMatchesTarget, tableDataCopyColumnOptions, type PasteTableMode, type TableClipboardContext } from "@/lib/tableClipboard"; import { sidebarDisplayTableName } from "@/lib/sidebarTableNameDisplay"; +import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebarLabelTooltip"; import { selectedTreeNodesInVisibleOrder as orderSelectedTreeNodes, treeSelectionRangeIdsByIndex, treeSelectionRangeIds } from "@/lib/sidebarTreeSelection"; import { selectedConnectionDeleteTargets } from "@/lib/sidebarConnectionSelection"; import { supportsDatabaseUserAdmin } from "@/lib/databaseUserAdmin"; @@ -144,13 +145,55 @@ import { createDatabaseCollationOptionsForCharset, fallbackCreateDatabaseCharset const { t } = useI18n(); const labelRef = ref(); const rowRef = ref(); -function isLabelTruncated(): boolean { +const labelOverflowing = ref(false); +let labelResizeObserver: ResizeObserver | null = null; +let labelMeasureFrame = 0; + +function cancelLabelOverflowMeasure() { + if (!labelMeasureFrame) return; + window.cancelAnimationFrame(labelMeasureFrame); + labelMeasureFrame = 0; +} + +function measureLabelOverflow(): boolean { const el = labelRef.value; - if (!el) return false; + if (!el || !shouldMeasureLabelOverflow()) return false; const style = window.getComputedStyle(el); if (style.overflowX === "visible" || style.textOverflow !== "ellipsis") return false; return el.scrollWidth - el.clientWidth > 2; } + +function updateLabelOverflow() { + labelOverflowing.value = measureLabelOverflow(); +} + +function scheduleLabelOverflowMeasure() { + if (typeof window === "undefined") { + updateLabelOverflow(); + return; + } + cancelLabelOverflowMeasure(); + // Keep synchronous layout reads out of the hover path; they are expensive in + // large virtualized sidebar trees, especially on Linux WebKitGTK without GPU help. + labelMeasureFrame = window.requestAnimationFrame(() => { + labelMeasureFrame = 0; + updateLabelOverflow(); + }); +} + +function observeLabelOverflow() { + labelResizeObserver?.disconnect(); + labelResizeObserver = null; + if (!shouldMeasureLabelOverflow()) { + labelOverflowing.value = false; + return; + } + if (typeof ResizeObserver !== "undefined" && labelRef.value) { + labelResizeObserver = new ResizeObserver(scheduleLabelOverflowMeasure); + labelResizeObserver.observe(labelRef.value); + } + scheduleLabelOverflowMeasure(); +} const connectionStore = useConnectionStore(); const queryStore = useQueryStore(); const settingsStore = useSettingsStore(); @@ -439,7 +482,7 @@ const detailTooltip = computed(() => connectionInfoTooltip.value ?? objectCommen function isTooltipDisabled(): boolean { if (detailTooltip.value?.rows.length) return isRenamingGroup.value; - return isRenamingGroup.value || !isLabelTruncated(); + return isRenamingGroup.value || !labelOverflowing.value; } async function toggle() { @@ -3478,6 +3521,14 @@ const isRenamingGroup = ref(false); const renameInput = ref(""); const renameInputRef = ref(); +function shouldMeasureLabelOverflow(): boolean { + return shouldMeasureSidebarLabelOverflow({ + hasDetailTooltip: !!detailTooltip.value?.rows.length, + isRenaming: isRenamingGroup.value, + usesFullWidthLabel: usesFullWidthLabel.value, + }); +} + function startRenameGroup() { renameInput.value = props.node.label; isRenamingGroup.value = true; @@ -3497,6 +3548,14 @@ watch( { immediate: true }, ); +watch( + [() => props.node.id, () => visibleLabel(props.node), () => usesFullWidthLabel.value, () => detailTooltip.value?.rows.length ?? 0, isRenamingGroup], + () => { + nextTick(observeLabelOverflow); + }, + { flush: "post", immediate: true }, +); + function finishRenameGroup() { // Guard against double invocation: pressing Enter sets isRenamingGroup=false // and unmounts the input, which then fires @blur -> finishRenameGroup again. @@ -3718,10 +3777,14 @@ function onRowMouseDown(event: MouseEvent) { } onMounted(() => { + observeLabelOverflow(); window.addEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable); }); onBeforeUnmount(() => { + labelResizeObserver?.disconnect(); + labelResizeObserver = null; + cancelLabelOverflowMeasure(); window.removeEventListener("dbx:sidebar-request-paste-table", onSidebarRequestPasteTable); finishTableReferenceDrag(); }); diff --git a/apps/desktop/src/lib/__tests__/sidebarLabelTooltip.spec.ts b/apps/desktop/src/lib/__tests__/sidebarLabelTooltip.spec.ts new file mode 100644 index 000000000..7857db51f --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sidebarLabelTooltip.spec.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { shouldMeasureSidebarLabelOverflow } from "@/lib/sidebarLabelTooltip"; + +describe("shouldMeasureSidebarLabelOverflow", () => { + it("measures only plain truncated-label tooltip candidates", () => { + expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: false, isRenaming: false, usesFullWidthLabel: false })).toBe(true); + expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: true, isRenaming: false, usesFullWidthLabel: false })).toBe(false); + expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: false, isRenaming: true, usesFullWidthLabel: false })).toBe(false); + expect(shouldMeasureSidebarLabelOverflow({ hasDetailTooltip: false, isRenaming: false, usesFullWidthLabel: true })).toBe(false); + }); +}); diff --git a/apps/desktop/src/lib/sidebarLabelTooltip.ts b/apps/desktop/src/lib/sidebarLabelTooltip.ts new file mode 100644 index 000000000..64af04d8f --- /dev/null +++ b/apps/desktop/src/lib/sidebarLabelTooltip.ts @@ -0,0 +1,3 @@ +export function shouldMeasureSidebarLabelOverflow(options: { hasDetailTooltip: boolean; isRenaming: boolean; usesFullWidthLabel: boolean }): boolean { + return !options.isRenaming && !options.hasDetailTooltip && !options.usesFullWidthLabel; +}