Merge pull request #8903 from dienaso/fix/object-browser-vertical-scrollbar-viewport

fix(objects): pin object list vertical scrollbar to the viewport
This commit is contained in:
zipg authored and GitHub committed 2026-09-12 16:21:39 +08:00
commit 20f6092cf2
2 files changed
+192 -86

No files matched your search

@@ -433,6 +433,7 @@ type ObjectBrowserScroller =
// type loose because vue-virtual-scroller does not ship complete ref typings. // type loose because vue-virtual-scroller does not ship complete ref typings.
const listScrollerRef = ref<ObjectBrowserScroller | null>(null); const listScrollerRef = ref<ObjectBrowserScroller | null>(null);
const gridScrollerRef = ref<ObjectBrowserScroller | null>(null); const gridScrollerRef = ref<ObjectBrowserScroller | null>(null);
const objectListHeaderRef = ref<HTMLElement | null>(null);
let viewportFrame = 0; let viewportFrame = 0;
let restoreViewportFrame = 0; let restoreViewportFrame = 0;
@@ -463,6 +464,7 @@ function emitViewportChange(scrollTop: number) {
} }
function onObjectsScroll() { function onObjectsScroll() {
syncObjectListHeaderScroll();
if (viewportFrame) return; if (viewportFrame) return;
viewportFrame = window.requestAnimationFrame(() => { viewportFrame = window.requestAnimationFrame(() => {
viewportFrame = 0; viewportFrame = 0;
@@ -472,6 +474,17 @@ function onObjectsScroll() {
}); });
} }
// The list header sits outside the row scroller and is clipped (overflow:
// hidden), so keep its programmatic scrollLeft aligned with the scroller's
// horizontal position on every scroll, resize, and (re)attach.
function syncObjectListHeaderScroll() {
if (!isListView.value) return;
const header = objectListHeaderRef.value;
const el = scrollerElement(listScrollerRef.value);
if (!header || !el) return;
if (header.scrollLeft !== el.scrollLeft) header.scrollLeft = el.scrollLeft;
}
function flushObjectBrowserViewport() { function flushObjectBrowserViewport() {
if (viewportFrame) { if (viewportFrame) {
window.cancelAnimationFrame(viewportFrame); window.cancelAnimationFrame(viewportFrame);
@@ -522,6 +535,7 @@ watch(
if (!el) return; if (!el) return;
el.addEventListener("scroll", onObjectsScroll, { passive: true }); el.addEventListener("scroll", onObjectsScroll, { passive: true });
restoreObjectBrowserViewport(); restoreObjectBrowserViewport();
nextTick(() => syncObjectListHeaderScroll());
onCleanup(() => el.removeEventListener("scroll", onObjectsScroll)); onCleanup(() => el.removeEventListener("scroll", onObjectsScroll));
}, },
{ flush: "post" }, { flush: "post" },
@@ -697,6 +711,14 @@ const selectedTableRows = computed(() => {
const selectedTableCount = computed(() => selectedTableRows.value.length); const selectedTableCount = computed(() => selectedTableRows.value.length);
const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value)); const canBatchDropCascade = computed(() => selectedTableCount.value > 0 && supportsDropTableCascade(effectiveDatabaseType.value));
const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value)); const canBatchTruncateCascade = computed(() => selectedTableCount.value > 0 && supportsTruncateTableCascade(effectiveDatabaseType.value));
// Column resizes change the scrollable content width, so the header's clamped
// scrollLeft has to be re-aligned right after the DOM updates. Registered here
// (after every computed it transitively reads) because watch sources are
// evaluated eagerly at registration time.
watch([objectGridMinWidth, objectColumnWidths], () => {
nextTick(() => syncObjectListHeaderScroll());
});
const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id))); const allVisibleTablesSelected = computed(() => visibleSelectableRows.value.length > 0 && visibleSelectableRows.value.every((row) => selectedTableIds.value.has(row.id)));
const batchDropProgressPercent = computed(() => (batchDropProgress.value.total > 0 ? Math.round((batchDropProgress.value.completed / batchDropProgress.value.total) * 100) : 0)); const batchDropProgressPercent = computed(() => (batchDropProgress.value.total > 0 ? Math.round((batchDropProgress.value.completed / batchDropProgress.value.total) * 100) : 0));
@@ -3470,102 +3492,104 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
</div> </div>
<div v-else class="flex min-h-0 min-w-0 flex-1" :class="{ 'event-editor-layout': isEventEditor }"> <div v-else class="flex min-h-0 min-w-0 flex-1" :class="{ 'event-editor-layout': isEventEditor }">
<div class="flex min-h-0 min-w-0 flex-1 flex-col"> <div class="flex min-h-0 min-w-0 flex-1 flex-col">
<div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden"> <div v-if="isListView" class="object-browser-table flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<div class="grid h-7 shrink-0 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }"> <div ref="objectListHeaderRef" class="h-7 shrink-0 overflow-hidden">
<div v-if="showCheckboxColumn" class="relative flex min-w-0 items-center"> <div class="grid h-7 items-center gap-3 border-b bg-muted/40 px-3 text-xs font-medium text-muted-foreground" :style="{ gridTemplateColumns, minWidth: `${objectGridMinWidth}px` }">
<button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection"> <div v-if="showCheckboxColumn" class="relative flex min-w-0 items-center">
<CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" /> <button class="flex h-6 w-6 items-center justify-center rounded-sm hover:bg-accent" type="button" :disabled="visibleSelectableRows.length === 0" @click="toggleVisibleTableSelection">
<Square v-else class="h-3.5 w-3.5" /> <CheckSquare v-if="allVisibleTablesSelected" class="h-3.5 w-3.5 text-primary" />
</button> <Square v-else class="h-3.5 w-3.5" />
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('select', $event)" @dblclick="resetObjectColumnWidth('select', 34, $event)"> </button>
<GripVertical class="h-3 w-3" /> <div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('select', $event)" @dblclick="resetObjectColumnWidth('select', 34, $event)">
<GripVertical class="h-3 w-3" />
</div>
</div> </div>
</div> <div class="relative flex min-w-0 items-center">
<div class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('name')"> <span class="truncate">{{ t("objects.name") }}</span>
<span class="truncate">{{ t("objects.name") }}</span> <component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('name')" v-if="sortIconFor('name')" class="h-3 w-3 shrink-0" /> </button>
</button> <div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('name', $event)" @dblclick="resetObjectColumnWidth('name', 260, $event)">
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('name', $event)" @dblclick="resetObjectColumnWidth('name', 260, $event)"> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> <div class="relative flex min-w-0 items-center">
<div class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('type')"> <span class="truncate">{{ t("objects.type") }}</span>
<span class="truncate">{{ t("objects.type") }}</span> <component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('type')" v-if="sortIconFor('type')" class="h-3 w-3 shrink-0" /> </button>
</button> <div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('type', $event)" @dblclick="resetObjectColumnWidth('type', 110, $event)">
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('type', $event)" @dblclick="resetObjectColumnWidth('type', 110, $event)"> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> <div v-if="showObjectRowStats" class="relative flex min-w-0 items-center">
<div v-if="showObjectRowStats" class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('estimatedRows')"> <span class="truncate">{{ objectRowsLabel }}</span>
<span class="truncate">{{ objectRowsLabel }}</span> <component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('estimatedRows')" v-if="sortIconFor('estimatedRows')" class="h-3 w-3 shrink-0" /> </button>
</button> <div
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('estimatedRows', $event)"
@mousedown="onObjectColumnResizeStart('estimatedRows', $event)" @dblclick="resetObjectColumnWidth('estimatedRows', 110, $event)"
@dblclick="resetObjectColumnWidth('estimatedRows', 110, $event)" >
> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> <div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center">
<div v-if="showObjectSizeStats" class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" :title="t('objects.statisticsHint')" @click="toggleSort('totalBytes')"> <span class="truncate">{{ t("objects.size") }}</span>
<span class="truncate">{{ t("objects.size") }}</span> <component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('totalBytes')" v-if="sortIconFor('totalBytes')" class="h-3 w-3 shrink-0" /> </button>
</button> <div
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('totalBytes', $event)"
@mousedown="onObjectColumnResizeStart('totalBytes', $event)" @dblclick="resetObjectColumnWidth('totalBytes', 100, $event)"
@dblclick="resetObjectColumnWidth('totalBytes', 100, $event)" >
> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> <div v-if="hasCreatedAt" class="relative flex min-w-0 items-center">
<div v-if="hasCreatedAt" class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('created_at')"> <span class="truncate">{{ t("objects.createdAt") }}</span>
<span class="truncate">{{ t("objects.createdAt") }}</span> <component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('created_at')" v-if="sortIconFor('created_at')" class="h-3 w-3 shrink-0" /> </button>
</button> <div
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('created_at', $event)"
@mousedown="onObjectColumnResizeStart('created_at', $event)" @dblclick="resetObjectColumnWidth('created_at', 150, $event)"
@dblclick="resetObjectColumnWidth('created_at', 150, $event)" >
> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> <div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center">
<div v-if="hasUpdatedAt" class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('updated_at')"> <span class="truncate">{{ t("objects.updatedAt") }}</span>
<span class="truncate">{{ t("objects.updatedAt") }}</span> <component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('updated_at')" v-if="sortIconFor('updated_at')" class="h-3 w-3 shrink-0" /> </button>
</button> <div
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('updated_at', $event)"
@mousedown="onObjectColumnResizeStart('updated_at', $event)" @dblclick="resetObjectColumnWidth('updated_at', 150, $event)"
@dblclick="resetObjectColumnWidth('updated_at', 150, $event)" >
> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> <div class="relative flex min-w-0 items-center">
<div class="relative flex min-w-0 items-center"> <button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')">
<button class="flex min-w-0 items-center gap-1 truncate pr-4 text-left" type="button" @click="toggleSort('comment')"> <span class="truncate">{{ t("objects.comment") }}</span>
<span class="truncate">{{ t("objects.comment") }}</span> <component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" />
<component :is="sortIconFor('comment')" v-if="sortIconFor('comment')" class="h-3 w-3 shrink-0" /> </button>
</button> <div
<div class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary"
class="absolute -right-2 top-0 bottom-0 z-10 flex w-3 cursor-col-resize items-center justify-center text-muted-foreground/70 hover:bg-primary/30 hover:text-primary" @mousedown="onObjectColumnResizeStart('comment', $event)"
@mousedown="onObjectColumnResizeStart('comment', $event)" @dblclick="resetObjectColumnWidth('comment', 260, $event)"
@dblclick="resetObjectColumnWidth('comment', 260, $event)" >
> <GripVertical class="h-3 w-3" />
<GripVertical class="h-3 w-3" /> </div>
</div> </div>
</div> </div>
</div> </div>
<RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ minWidth: `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id"> <RecycleScroller ref="listScrollerRef" class="object-browser-scroller min-h-0 flex-1" :style="{ '--dbx-object-grid-min-width': `${objectGridMinWidth}px` }" :items="filteredRows" :item-size="34" :buffer="600" :skip-hover="true" key-field="id">
<template #default="{ item }"> <template #default="{ item }">
<CustomContextMenu :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu, isOpen }"> <CustomContextMenu :items="() => getObjectBrowserMenuItems(item)" v-slot="{ onContextMenu, isOpen }">
<div <div
@@ -4136,6 +4160,47 @@ function getObjectBrowserMenuItems(item: ObjectBrowserRow): ContextMenuItem[] {
.object-browser-scroller { .object-browser-scroller {
will-change: scroll-position; will-change: scroll-position;
contain: content; contain: content;
overflow-x: auto;
}
/* Keep the horizontal track discoverable when the platform uses overlay
scrollbars, while leaving the native vertical scrollbar in place. */
.object-browser-scroller::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.object-browser-scroller::-webkit-scrollbar-track {
background: transparent;
}
.object-browser-scroller::-webkit-scrollbar-thumb {
border: 1px solid transparent;
border-radius: 999px;
background: rgba(82, 82, 82, 0.28);
background: color-mix(in oklch, var(--foreground) 28%, transparent);
background-clip: padding-box;
}
.object-browser-scroller:hover::-webkit-scrollbar-thumb {
border: 0;
background: rgba(82, 82, 82, 0.45);
background: color-mix(in oklch, var(--foreground) 45%, transparent);
}
html.dbx-legacy-webview.dark .object-browser-scroller::-webkit-scrollbar-thumb {
background: rgba(212, 212, 216, 0.28);
}
html.dbx-legacy-webview.dark .object-browser-scroller:hover::-webkit-scrollbar-thumb {
background: rgba(212, 212, 216, 0.45);
}
/* The scroller itself stays viewport-width so its vertical scrollbar remains
visible at the right edge; the row content inside scrolls horizontally past
that width instead (issue #8885). */
.object-browser-scroller :deep(.vue-recycle-scroller__item-wrapper) {
min-width: var(--dbx-object-grid-min-width, 0px);
} }
.object-browser-scroller :deep(.vue-recycle-scroller__item-view) { .object-browser-scroller :deep(.vue-recycle-scroller__item-view) {
@@ -0,0 +1,41 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
const source = readFileSync(new URL("../ObjectBrowser.vue", import.meta.url), "utf8");
// Regression test for #8885: when the object-browser columns are wider than the
// panel, the vertical scrollbar must stay pinned to the visible right edge
// instead of travelling with the content. The list scroller therefore owns the
// horizontal scrolling (with the content width applied to its inner item
// wrapper), while the header is clipped and aligned programmatically.
describe("ObjectBrowser vertical scrollbar viewport lock", () => {
it("keeps the outer table container from scrolling horizontally", () => {
const tableMatch = /class="object-browser-table[^"]*"/.exec(source);
expect(tableMatch).not.toBeNull();
expect(tableMatch![0]).toContain("overflow-hidden");
expect(tableMatch![0]).not.toContain("overflow-x-auto");
});
it("moves the content min-width onto the scroller's inner wrapper", () => {
const scrollerMatch = /<RecycleScroller[^>]*listScrollerRef[^>]*>/.exec(source);
expect(scrollerMatch).not.toBeNull();
expect(scrollerMatch![0]).toContain("--dbx-object-grid-min-width");
expect(scrollerMatch![0]).not.toContain("minWidth:");
expect(source).toContain(".object-browser-scroller {");
expect(source).toContain("overflow-x: auto;");
expect(source).toContain("min-width: var(--dbx-object-grid-min-width, 0px);");
});
it("clips the header and syncs its scroll position with the scroller", () => {
const headerMatch = /ref="objectListHeaderRef"[^>]*/.exec(source);
expect(headerMatch).not.toBeNull();
expect(headerMatch![0]).toContain("overflow-hidden");
expect(source).toContain("function syncObjectListHeaderScroll(");
expect(source).toContain("header.scrollLeft = el.scrollLeft");
// The sync runs on every scroller scroll event, when the scroller (re)attaches,
// and whenever the column widths change the content width.
const scrollHandler = source.indexOf("function onObjectsScroll(");
expect(source.slice(scrollHandler, scrollHandler + 300)).toContain("syncObjectListHeaderScroll()");
expect(source).toContain("nextTick(() => syncObjectListHeaderScroll())");
});
});