diff --git a/scripts/query-results-layout.test.mjs b/scripts/query-results-layout.test.mjs new file mode 100644 index 0000000..efa80a1 --- /dev/null +++ b/scripts/query-results-layout.test.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { build } from 'esbuild'; + +const { outputFiles } = await build({ + entryPoints: ['src/utils/queryResultsLayout.ts'], + bundle: true, + platform: 'node', + format: 'esm', + write: false, +}); +const { headerContentWidthForBody } = await import( + `data:text/javascript;base64,${Buffer.from(outputFiles[0].contents).toString('base64')}` +); + +test('header reaches the same horizontal offset as a body with a vertical scrollbar', () => { + const viewportWidth = 320; + const gridWidth = 1000; + const scrollbarWidth = 8; + const bodyMaxScrollLeft = gridWidth - (viewportWidth - scrollbarWidth); + const headerMaxScrollLeft = headerContentWidthForBody(gridWidth, scrollbarWidth) - viewportWidth; + + assert.equal(headerMaxScrollLeft, bodyMaxScrollLeft); +}); + +test('overlay scrollbars do not add an unnecessary header gap', () => { + assert.equal(headerContentWidthForBody(1000, 0), 1000); + assert.equal(headerContentWidthForBody(1000, -1), 1000); +}); diff --git a/src/components/QueryResultsTable.tsx b/src/components/QueryResultsTable.tsx index 4763bd3..359da95 100644 --- a/src/components/QueryResultsTable.tsx +++ b/src/components/QueryResultsTable.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type CSSProperties, useEffect, useMemo, type MouseEvent } from 'react'; +import { useRef, useState, type CSSProperties, useEffect, useLayoutEffect, useMemo, type MouseEvent } from 'react'; import { List } from 'react-window'; import * as ContextMenu from '@radix-ui/react-context-menu'; import { useDatabaseStore } from '../store/databaseStore'; @@ -7,6 +7,7 @@ import { Copy, Eye, EyeOff, FileJson } from 'lucide-react'; import { shouldMaskColumn } from '../utils/sensitiveData'; import { SensitiveValue } from './SensitiveValue'; import { needsRawCopyConfirmation, rowsForCopy, serializeResultRows, type ResultCopyFormat } from '../utils/queryResultCopy'; +import { headerContentWidthForBody } from '../utils/queryResultsLayout'; interface QueryResultsTableProps { columns: string[]; @@ -36,6 +37,7 @@ export const QueryResultsTable = ({ const headerRef = useRef(null); const resultsRef = useRef(null); const [columnWidths, setColumnWidths] = useState(() => columns.map(() => COLUMN_WIDTH)); + const [scrollbarWidth, setScrollbarWidth] = useState(0); const [maskOverrides, setMaskOverrides] = useState>(() => new Set()); const contextTargetRef = useRef<{ rowIndex: number; columnIndex: number | null } | null>(null); const [contextTarget, setContextTarget] = useState<{ rowIndex: number; columnIndex: number | null } | null>(null); @@ -190,26 +192,6 @@ export const QueryResultsTable = ({ document.body.style.userSelect = 'none'; }; - const handleScroll = (e: any) => { - if (headerRef.current) { - headerRef.current.scrollLeft = e.scrollOffset || (e.target as any)?.scrollLeft || 0; - } - }; - - useEffect(() => { - // Try to attach native scroll listener if `react-window` inner element exposes it - const el = document.querySelector('.react-window-list'); - if (el) { - const nativeScroll = (e: Event) => { - if (headerRef.current) { - headerRef.current.scrollLeft = (e.currentTarget as HTMLDivElement).scrollLeft; - } - }; - el.addEventListener('scroll', nativeScroll); - return () => el.removeEventListener('scroll', nativeScroll); - } - }, []); - const formatValue = (value: any, columnName: string) => { if (value === null) return NULL; if (isColumnMasked(columnName)) { @@ -222,6 +204,30 @@ export const QueryResultsTable = ({ const totalWidth = (showLineNumbers ? INDEX_COLUMN_WIDTH : 0) + columnWidths.reduce((total, width) => total + width, 0); + useLayoutEffect(() => { + const list = resultsRef.current?.querySelector('.react-window-list'); + if (!list) return; + + const measureScrollbar = () => setScrollbarWidth(Math.max(0, list.offsetWidth - list.clientWidth)); + const syncHeader = () => { + if (headerRef.current) headerRef.current.scrollLeft = list.scrollLeft; + }; + measureScrollbar(); + syncHeader(); + list.addEventListener('scroll', syncHeader, { passive: true }); + const observer = new ResizeObserver(measureScrollbar); + observer.observe(list); + return () => { + list.removeEventListener('scroll', syncHeader); + observer.disconnect(); + }; + }, [data.length, rowHeight, totalWidth]); + + useLayoutEffect(() => { + const list = resultsRef.current?.querySelector('.react-window-list'); + if (list && headerRef.current) headerRef.current.scrollLeft = list.scrollLeft; + }, [scrollbarWidth, totalWidth]); + const Row = ({ index, style }: { index: number; style: CSSProperties }) => { const row = data[index]; if (!row) return null; @@ -290,43 +296,43 @@ export const QueryResultsTable = ({ return (
{/* Header */} -
-
- {showLineNumbers && ( -
- # -
- )} - {columns.map((name, index) => ( -
- {name} - +
+
+
+ {showLineNumbers &&
} + {columns.map((name, index) => (
handleResizeStart(index, event)} - style={{ transform: 'translateX(50%)' }} - /> -
- ))} + key={`${name}-${index}`} + className="shrink-0 px-3 border-r border-[#3C3C3C] flex items-center font-semibold text-text-secondary truncate relative group" + style={{ width: columnWidths[index] || COLUMN_WIDTH, height: headerHeight, ...cellTextStyle }} + > + {name} + +
handleResizeStart(index, event)} + style={{ transform: 'translateX(50%)' }} + /> +
+ ))} +
+ {showLineNumbers && ( +
+ # +
+ )}
{/* Body with Virtualization */} @@ -365,7 +371,6 @@ export const QueryResultsTable = ({ rowHeight={rowHeight} rowComponent={Row as any} rowProps={{}} - onScroll={handleScroll} onRowsRendered={({ stopIndex }: any) => { if (onReachBottom && stopIndex >= data.length - 20) { onReachBottom(); diff --git a/src/utils/queryResultsLayout.ts b/src/utils/queryResultsLayout.ts new file mode 100644 index 0000000..747c23d --- /dev/null +++ b/src/utils/queryResultsLayout.ts @@ -0,0 +1,3 @@ +export function headerContentWidthForBody(gridWidth: number, scrollbarWidth: number): number { + return gridWidth + Math.max(0, scrollbarWidth); +}