fix(browser): preserve database browser scroll position

This commit is contained in:
lxk955
2026-10-16 15:06:40 +08:00
committed by GitHub
parent 8dfe536100
commit 8ec7667322
2 changed files with 480 additions and 7 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { computed, nextTick, onActivated, onBeforeUnmount, ref, watch } from "vue";
import { ArrowDown, ArrowUp, Check, Copy, Database, GripVertical, LayoutGrid, List, Loader2, RefreshCw, Search, TerminalSquare, X } from "@lucide/vue";
import { useI18n } from "vue-i18n";
import { Button } from "@/components/ui/button";
@@ -55,6 +55,8 @@ interface DatabaseBrowserTabUiState {
sortKey?: DatabaseBrowserSortKey;
sortDirection?: SortDirection;
columnWidths?: Partial<Record<DatabaseBrowserColumnKey, number>>;
scrollTop?: number;
scrollLeft?: number;
}
const props = defineProps<{
@@ -82,13 +84,27 @@ const columnWidths = ref<Record<DatabaseBrowserColumnKey, number>>(
}),
) as Record<DatabaseBrowserColumnKey, number>,
);
const scrollTop = ref(typeof restoredUiState.scrollTop === "number" && Number.isFinite(restoredUiState.scrollTop) ? Math.max(0, restoredUiState.scrollTop) : 0);
const scrollLeft = ref(typeof restoredUiState.scrollLeft === "number" && Number.isFinite(restoredUiState.scrollLeft) ? Math.max(0, restoredUiState.scrollLeft) : 0);
const listScrollerRef = ref<HTMLElement | null>(null);
const listOuterRef = ref<HTMLElement | null>(null);
const gridScrollerRef = ref<HTMLElement | null>(null);
let stopColumnResize: (() => void) | null = null;
let refreshGeneration = 0;
let restoreScrollFrame = 0;
let isRestoringScroll = false;
const sidebarRuntimeHost = ref<InstanceType<typeof SidebarTreeRuntimeHost> | null>(null);
const sidebarRuntimeNode: TreeNode = { id: "__database-browser-runtime__", label: "", type: "connection-group" };
const sidebarDialogController = ref<Record<string, any> | null>(null);
trackUiState(() => ({ search: search.value, sortKey: sortKey.value, sortDirection: sortDirection.value, columnWidths: columnWidths.value }));
trackUiState(() => ({
search: search.value,
sortKey: sortKey.value,
sortDirection: sortDirection.value,
columnWidths: columnWidths.value,
scrollTop: scrollTop.value,
scrollLeft: scrollLeft.value,
}));
const hasSize = computed(() => rows.value.some((row) => row.sizeBytes != null));
const hasCreatedAt = computed(() => rows.value.some((row) => !!row.createdAt?.trim()));
@@ -233,6 +249,7 @@ function toggleSort(key: DatabaseBrowserSortKey) {
function setViewMode(mode: "list" | "grid") {
settingsStore.updateEditorSettings({ objectBrowserViewMode: mode });
scrollToTop();
}
function minimumColumnWidth(key: DatabaseBrowserColumnKey): number {
@@ -267,6 +284,88 @@ function resetColumnWidth(key: DatabaseBrowserColumnKey, width: number, event: M
columnWidths.value = { ...columnWidths.value, [key]: width };
}
function onListVerticalScroll(event: Event) {
if (isRestoringScroll) return;
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
scrollTop.value = Math.max(0, Math.round(target.scrollTop));
if (target.scrollLeft > 0) {
scrollLeft.value = Math.max(0, Math.round(target.scrollLeft));
}
}
function onListHorizontalScroll(event: Event) {
if (isRestoringScroll) return;
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
scrollLeft.value = Math.max(0, Math.round(target.scrollLeft));
}
function onGridScroll(event: Event) {
if (isRestoringScroll) return;
const target = event.currentTarget;
if (!(target instanceof HTMLElement)) return;
scrollTop.value = Math.max(0, Math.round(target.scrollTop));
scrollLeft.value = Math.max(0, Math.round(target.scrollLeft));
}
function applyScrollPosition() {
const targetTop = scrollTop.value;
const targetLeft = scrollLeft.value;
if (!targetTop && !targetLeft) return;
if (isListView.value) {
if (listScrollerRef.value) {
if (targetTop) listScrollerRef.value.scrollTop = targetTop;
if (targetLeft) listScrollerRef.value.scrollLeft = targetLeft;
}
if (listOuterRef.value && targetLeft) {
listOuterRef.value.scrollLeft = targetLeft;
}
} else if (gridScrollerRef.value) {
if (targetTop) gridScrollerRef.value.scrollTop = targetTop;
if (targetLeft) gridScrollerRef.value.scrollLeft = targetLeft;
}
}
function restoreScrollPosition() {
const targetTop = scrollTop.value;
const targetLeft = scrollLeft.value;
if (!targetTop && !targetLeft) return;
if (restoreScrollFrame) window.cancelAnimationFrame(restoreScrollFrame);
isRestoringScroll = true;
void nextTick(() => {
applyScrollPosition();
restoreScrollFrame = window.requestAnimationFrame(() => {
restoreScrollFrame = 0;
applyScrollPosition();
window.requestAnimationFrame(() => {
isRestoringScroll = false;
});
});
});
}
function scrollToTop() {
if (restoreScrollFrame) {
window.cancelAnimationFrame(restoreScrollFrame);
restoreScrollFrame = 0;
}
isRestoringScroll = false;
scrollTop.value = 0;
scrollLeft.value = 0;
if (listScrollerRef.value) {
listScrollerRef.value.scrollTop = 0;
listScrollerRef.value.scrollLeft = 0;
}
if (listOuterRef.value) {
listOuterRef.value.scrollLeft = 0;
}
if (gridScrollerRef.value) {
gridScrollerRef.value.scrollTop = 0;
gridScrollerRef.value.scrollLeft = 0;
}
}
async function refresh(): Promise<boolean> {
const generation = ++refreshGeneration;
loading.value = true;
@@ -291,6 +390,7 @@ async function refresh(): Promise<boolean> {
defaultCollation: database.default_collation ?? null,
}));
loading.value = false;
restoreScrollPosition();
// Name enumeration is cheap and should render immediately. Enrich the
// rows separately because MySQL metadata calculates sizes by aggregating
@@ -333,6 +433,7 @@ function focusSearch(): boolean {
function clearDatabaseSearch() {
search.value = "";
scrollToTop();
searchInput.value?.$el?.focus();
}
@@ -343,12 +444,33 @@ watch(sortKeyOptions, (options) => {
}
});
watch(
[listScrollerRef, gridScrollerRef, isListView],
() => {
restoreScrollPosition();
},
{ flush: "post" },
);
watch(search, (next, prev) => {
if (next !== prev) {
scrollToTop();
}
});
watch([sortKey, sortDirection], () => {
scrollToTop();
});
let initialConnectionWatch = true;
watch(
() => props.connection.id,
() => {
if (initialConnectionWatch) initialConnectionWatch = false;
else search.value = "";
else {
search.value = "";
scrollToTop();
}
void refresh();
},
{ immediate: true },
@@ -358,7 +480,14 @@ useDatabaseBrowserMutation(({ connectionId }) => {
if (connectionId === props.connection.id) void refresh();
});
onBeforeUnmount(() => stopColumnResize?.());
onActivated(() => {
restoreScrollPosition();
});
onBeforeUnmount(() => {
stopColumnResize?.();
if (restoreScrollFrame) window.cancelAnimationFrame(restoreScrollFrame);
});
defineExpose({ focusSearch, refresh });
</script>
@@ -435,7 +564,7 @@ defineExpose({ focusSearch, refresh });
<div v-else-if="error" class="flex flex-1 items-center justify-center px-6 text-center text-sm text-destructive">{{ t("databaseBrowser.loadFailed", { message: error }) }}</div>
<div v-else-if="visibleRows.length === 0" class="flex flex-1 items-center justify-center text-sm text-muted-foreground">{{ t("databaseBrowser.empty") }}</div>
<div v-else class="flex min-h-0 min-w-0 flex-1">
<div v-if="isListView" class="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden">
<div v-if="isListView" ref="listOuterRef" class="flex min-h-0 min-w-0 flex-1 flex-col overflow-x-auto overflow-y-hidden" @scroll.passive="onListHorizontalScroll">
<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: `${gridMinWidth}px` }">
<div v-for="key in columns" :key="key" 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(key)">
@@ -451,7 +580,7 @@ defineExpose({ focusSearch, refresh });
</div>
</div>
</div>
<div class="min-h-0 flex-1 overflow-auto">
<div ref="listScrollerRef" class="min-h-0 flex-1 overflow-auto" @scroll.passive="onListVerticalScroll">
<div
v-for="row in visibleRows"
:key="row.name"
@@ -478,7 +607,7 @@ defineExpose({ focusSearch, refresh });
</div>
</div>
</div>
<div v-else class="grid min-h-0 flex-1 grid-cols-[repeat(auto-fill,minmax(160px,1fr))] content-start gap-3 overflow-auto p-3">
<div v-else ref="gridScrollerRef" class="grid min-h-0 flex-1 grid-cols-[repeat(auto-fill,minmax(160px,1fr))] content-start gap-3 overflow-auto p-3" @scroll.passive="onGridScroll">
<div
v-for="row in visibleRows"
:key="row.name"
@@ -0,0 +1,344 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import DatabaseBrowser from "../DatabaseBrowser.vue";
import { provideTabUiState, TAB_UI_STATE_TRACK_DEBOUNCE_MS } from "@/lib/tabs/tabUiState";
import type { ConnectionConfig } from "@/types/database";
const testDatabases = Array.from({ length: 30 }, (_, index) => ({
name: `db_${String(index + 1).padStart(2, "0")}`,
size_bytes: 1024 * (index + 1),
created_at: "2026-01-01 00:00:00",
updated_at: "2026-01-02 00:00:00",
comment: `Database ${index + 1}`,
default_charset: "utf8mb4",
default_collation: "utf8mb4_0900_ai_ci",
}));
const mocks = vi.hoisted(() => ({
listDatabases: vi.fn(),
listDatabaseMetadata: vi.fn(),
editorSettings: {
objectBrowserViewMode: "list" as "list" | "grid",
},
}));
vi.mock("@/lib/backend/api", () => ({
listDatabases: (...args: unknown[]) => mocks.listDatabases(...args),
listDatabaseMetadata: (...args: unknown[]) => mocks.listDatabaseMetadata(...args),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({
setDefaultDatabase: vi.fn(),
clearDefaultDatabase: vi.fn(),
}),
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({
openObjectBrowser: vi.fn(),
createTab: vi.fn(),
}),
}));
vi.mock("@/stores/settingsStore", () => ({
useSettingsStore: () => ({
editorSettings: mocks.editorSettings,
updateEditorSettings: (patch: Partial<typeof mocks.editorSettings>) => {
Object.assign(mocks.editorSettings, patch);
},
}),
}));
vi.mock("vue-i18n", () => ({
useI18n: () => ({
t: (key: string) => key,
locale: ref("en-US"),
}),
}));
vi.mock("@/components/sidebar/SidebarTreeRuntimeHost.vue", () => ({
default: defineComponent({
setup() {
return () => null;
},
}),
}));
vi.mock("@/components/sidebar/SidebarTreeItemDialogs.vue", () => ({
default: defineComponent({
setup() {
return () => null;
},
}),
}));
vi.mock("@/composables/useToolbarOverflow", () => ({
useToolbarOverflow: () => ({
tier: ref(0),
}),
}));
describe("DatabaseBrowser scroll position preservation (#12432)", () => {
let app: App | undefined;
let host: HTMLElement | undefined;
const testConnection: ConnectionConfig = {
id: "conn-mysql-1",
name: "MySQL Prod",
db_type: "mysql",
};
beforeEach(() => {
mocks.listDatabases.mockResolvedValue(testDatabases);
mocks.listDatabaseMetadata.mockResolvedValue([]);
mocks.editorSettings.objectBrowserViewMode = "list";
});
afterEach(() => {
vi.useRealTimers();
app?.unmount();
host?.remove();
app = undefined;
host = undefined;
vi.clearAllMocks();
});
it("restores scrollTop from tab UI state when mounting in list view", async () => {
let capturedState: Record<string, any> = {};
const Parent = defineComponent({
setup() {
provideTabUiState(() => ({
snapshot: {
DatabaseBrowser: {
scrollTop: 350,
scrollLeft: 0,
},
},
update: (patch) => {
Object.assign(capturedState, patch);
},
}));
return () => h(DatabaseBrowser, { connection: testConnection });
},
});
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(Parent);
app.mount(host);
await vi.waitFor(() => {
const rows = host!.querySelectorAll(".grid.h-\\[34px\\]");
expect(rows.length).toBe(testDatabases.length);
});
await new Promise((resolve) => requestAnimationFrame(resolve));
await nextTick();
const scroller = host.querySelector<HTMLElement>(".overflow-auto");
expect(scroller).not.toBeNull();
expect(scroller?.scrollTop).toBe(350);
});
it("tracks vertical scrolling and debounces updates to tab UI state", async () => {
let trackedUiState: Record<string, any> = {};
const Parent = defineComponent({
setup() {
provideTabUiState(() => ({
snapshot: { DatabaseBrowser: {} },
update: (patch) => {
Object.assign(trackedUiState, patch);
},
}));
return () => h(DatabaseBrowser, { connection: testConnection });
},
});
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(Parent);
app.mount(host);
await vi.waitFor(() => {
const rows = host!.querySelectorAll(".grid.h-\\[34px\\]");
expect(rows.length).toBe(testDatabases.length);
});
const scroller = host.querySelector<HTMLElement>(".overflow-auto");
expect(scroller).not.toBeNull();
vi.useFakeTimers();
// User scrolls down
Object.defineProperty(scroller, "scrollTop", { value: 480, writable: true });
scroller!.dispatchEvent(new Event("scroll"));
await nextTick();
expect(trackedUiState.DatabaseBrowser?.scrollTop).toBeUndefined();
// Advance past debounce timer
vi.advanceTimersByTime(TAB_UI_STATE_TRACK_DEBOUNCE_MS + 20);
await nextTick();
expect(trackedUiState.DatabaseBrowser?.scrollTop).toBe(480);
vi.useRealTimers();
});
it("resets scrollTop when user enters a search query", async () => {
let capturedState: Record<string, any> = {};
const Parent = defineComponent({
setup() {
provideTabUiState(() => ({
snapshot: {
DatabaseBrowser: {
scrollTop: 200,
},
},
update: (patch) => {
Object.assign(capturedState, patch);
},
}));
return () => h(DatabaseBrowser, { connection: testConnection });
},
});
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(Parent);
app.mount(host);
await vi.waitFor(() => {
const rows = host!.querySelectorAll(".grid.h-\\[34px\\]");
expect(rows.length).toBe(testDatabases.length);
});
await new Promise((resolve) => requestAnimationFrame(resolve));
await nextTick();
const scroller = host.querySelector<HTMLElement>(".overflow-auto");
expect(scroller?.scrollTop).toBe(200);
// Enter search query
const input = host.querySelector<HTMLInputElement>("input");
expect(input).not.toBeNull();
input!.value = "db_01";
input!.dispatchEvent(new Event("input"));
await nextTick();
expect(scroller?.scrollTop).toBe(0);
});
it("restores scrollTop from tab UI state when mounting in grid view", async () => {
mocks.editorSettings.objectBrowserViewMode = "grid";
const Parent = defineComponent({
setup() {
provideTabUiState(() => ({
snapshot: {
DatabaseBrowser: {
scrollTop: 180,
},
},
update: vi.fn(),
}));
return () => h(DatabaseBrowser, { connection: testConnection });
},
});
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(Parent);
app.mount(host);
await vi.waitFor(() => {
const cards = host!.querySelectorAll(".flex.min-h-28");
expect(cards.length).toBe(testDatabases.length);
});
await new Promise((resolve) => requestAnimationFrame(resolve));
await nextTick();
const gridScroller = host.querySelector<HTMLElement>(".grid-cols-\\[repeat\\(auto-fill\\,minmax\\(160px\\,1fr\\)\\)\\]");
expect(gridScroller).not.toBeNull();
expect(gridScroller?.scrollTop).toBe(180);
});
it("resets scrollTop when sort column or direction changes", async () => {
const Parent = defineComponent({
setup() {
provideTabUiState(() => ({
snapshot: {
DatabaseBrowser: {
scrollTop: 240,
},
},
update: vi.fn(),
}));
return () => h(DatabaseBrowser, { connection: testConnection });
},
});
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(Parent);
app.mount(host);
await vi.waitFor(() => {
const rows = host!.querySelectorAll(".grid.h-\\[34px\\]");
expect(rows.length).toBe(testDatabases.length);
});
await new Promise((resolve) => requestAnimationFrame(resolve));
await nextTick();
const scroller = host.querySelector<HTMLElement>(".overflow-auto");
expect(scroller?.scrollTop).toBe(240);
// Click sort header button
const sortBtn = host.querySelector<HTMLButtonElement>("button.gap-1");
expect(sortBtn).not.toBeNull();
sortBtn!.click();
await nextTick();
expect(scroller?.scrollTop).toBe(0);
});
it("restores scrollLeft from tab UI state when mounting in list view", async () => {
const Parent = defineComponent({
setup() {
provideTabUiState(() => ({
snapshot: {
DatabaseBrowser: {
scrollTop: 100,
scrollLeft: 150,
},
},
update: vi.fn(),
}));
return () => h(DatabaseBrowser, { connection: testConnection });
},
});
host = document.createElement("div");
document.body.appendChild(host);
app = createApp(Parent);
app.mount(host);
await vi.waitFor(() => {
const rows = host!.querySelectorAll(".grid.h-\\[34px\\]");
expect(rows.length).toBe(testDatabases.length);
});
await new Promise((resolve) => requestAnimationFrame(resolve));
await nextTick();
const outer = host.querySelector<HTMLElement>(".overflow-x-auto");
const scroller = host.querySelector<HTMLElement>(".overflow-auto");
expect(scroller?.scrollTop).toBe(100);
expect(outer?.scrollLeft).toBe(150);
});
});