feat: use virtual scroll for db preview

This commit is contained in:
yyh
2026-01-22 15:38:19 +08:00
parent 878e34c582
commit 6c75893956
5 changed files with 92 additions and 76 deletions

View File

@ -38,7 +38,6 @@ type SQLiteAction
| { type: 'error', error: Error }
const TABLES_QUERY = 'SELECT name FROM sqlite_master WHERE type=\'table\' AND name NOT LIKE \'sqlite_%\' ORDER BY name'
const DEFAULT_ROW_LIMIT = 200
let sqliteClientPromise: Promise<SQLiteClient> | null = null
@ -218,10 +217,10 @@ export function useSQLiteDatabase(downloadUrl: string | undefined): UseSQLiteDat
if (!state.tables.includes(tableName))
return null
const rowLimit = Number.isFinite(limit) && limit && limit > 0
const resolvedLimit = Number.isFinite(limit) && limit && limit > 0
? Math.floor(limit)
: DEFAULT_ROW_LIMIT
const cacheKey = `${tableName}:${rowLimit}`
: null
const cacheKey = `${tableName}:${resolvedLimit ?? 'all'}`
const cached = cacheRef.current.get(cacheKey)
if (cached)
return cached
@ -229,7 +228,7 @@ export function useSQLiteDatabase(downloadUrl: string | undefined): UseSQLiteDat
const safeName = tableName.replaceAll('"', '""')
const result = await client.sqlite3.execWithParams(
db,
`SELECT * FROM "${safeName}" LIMIT ${rowLimit}`,
`SELECT * FROM "${safeName}"${resolvedLimit ? ` LIMIT ${resolvedLimit}` : ''}`,
[],
)
const data: SQLiteQueryResult = {

View File

@ -1,6 +1,7 @@
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { FC, RefObject } from 'react'
import type { SQLiteValue } from '../../hooks/use-sqlite-database'
import { useVirtualizer } from '@tanstack/react-virtual'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
@ -9,6 +10,7 @@ import { cn } from '@/utils/classnames'
type DataTableProps = {
columns: string[]
values: SQLiteValue[][]
scrollRef: RefObject<HTMLDivElement | null>
}
const MAX_CELL_LENGTH = 120
@ -29,38 +31,25 @@ const truncateValue = (value: string): string => {
return `${value.slice(0, MAX_CELL_LENGTH)}...`
}
const DataTable: FC<DataTableProps> = ({ columns, values }) => {
const DataTable: FC<DataTableProps> = ({ columns, values, scrollRef }) => {
const { t } = useTranslation('workflow')
const keyColumnIndex = useMemo(() => {
const candidates = new Set(['id', 'rowid', 'uuid'])
return columns.findIndex(column => candidates.has(column.toLowerCase()))
}, [columns])
const rows = useMemo(() => {
return values.map((row) => {
const rowKey = keyColumnIndex >= 0
? String(row[keyColumnIndex] ?? '')
: row.map((value) => {
if (value instanceof Uint8Array)
return `blob:${value.byteLength}`
return String(value ?? '')
}).join('|')
const rowVirtualizer = useVirtualizer({
count: values.length,
getScrollElement: () => scrollRef.current,
estimateSize: () => 32,
overscan: 8,
})
const cells = row.map((value) => {
const rawValue = formatValue(value, t)
return {
rawValue,
displayValue: truncateValue(rawValue),
isNull: value === null,
}
})
return {
key: rowKey,
cells,
}
})
}, [keyColumnIndex, t, values])
const virtualRows = rowVirtualizer.getVirtualItems()
const paddingTop = virtualRows.length > 0 ? virtualRows[0].start : 0
const paddingBottom = virtualRows.length > 0
? rowVirtualizer.getTotalSize() - virtualRows[virtualRows.length - 1].end
: 0
return (
<table className="w-max min-w-full table-auto border-separate border-spacing-0">
@ -77,24 +66,45 @@ const DataTable: FC<DataTableProps> = ({ columns, values }) => {
</tr>
</thead>
<tbody className="text-text-secondary">
{rows.map(row => (
<tr key={row.key}>
{row.cells.map((cell, cellIndex) => (
<td
key={`${row.key}-${columns[cellIndex]}`}
className={cn(
'px-2 py-1.5 align-middle',
'border-b border-r border-divider-subtle',
cellIndex === 0 && 'border-l',
)}
>
<div className={cn('system-xs-regular max-w-[240px] truncate', cell.isNull && 'text-text-quaternary')} title={cell.rawValue}>
{cell.displayValue}
</div>
</td>
))}
{paddingTop > 0 && (
<tr aria-hidden="true">
<td colSpan={columns.length} className="border-none p-0" style={{ height: paddingTop }} />
</tr>
))}
)}
{virtualRows.map((virtualRow) => {
const row = values[virtualRow.index]
const rowKey = keyColumnIndex >= 0
? String(row[keyColumnIndex] ?? virtualRow.index)
: String(virtualRow.index)
return (
<tr key={rowKey}>
{row.map((value, cellIndex) => {
const rawValue = formatValue(value, t)
const displayValue = truncateValue(rawValue)
return (
<td
key={`${rowKey}-${columns[cellIndex]}`}
className={cn(
'px-2 py-1.5 align-middle',
'border-b border-r border-divider-subtle',
cellIndex === 0 && 'border-l',
)}
>
<div className={cn('system-xs-regular max-w-[240px] truncate', value === null && 'text-text-quaternary')} title={rawValue}>
{displayValue}
</div>
</td>
)
})}
</tr>
)
})}
{paddingBottom > 0 && (
<tr aria-hidden="true">
<td colSpan={columns.length} className="border-none p-0" style={{ height: paddingBottom }} />
</tr>
)}
</tbody>
</table>
)

View File

@ -1,14 +1,12 @@
import type { FC } from 'react'
import * as React from 'react'
import { useEffect, useMemo, useReducer, useState } from 'react'
import { useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading from '@/app/components/base/loading'
import { useSQLiteDatabase } from '../../hooks/use-sqlite-database'
import DataTable from './data-table'
import TableSelector from './table-selector'
const ROW_LIMIT = 200
type SQLiteFilePreviewProps = {
downloadUrl: string
}
@ -19,6 +17,7 @@ const SQLiteFilePreview: FC<SQLiteFilePreviewProps> = ({
const { t } = useTranslation('workflow')
const { tables, isLoading, error, queryTable } = useSQLiteDatabase(downloadUrl)
const [selectedTableId, setSelectedTableId] = useState<string>('')
const tableScrollRef = useRef<HTMLDivElement | null>(null)
const [tableState, dispatch] = useReducer((
current: {
data: Awaited<ReturnType<typeof queryTable>> | null
@ -85,7 +84,7 @@ const SQLiteFilePreview: FC<SQLiteFilePreviewProps> = ({
dispatch({ type: 'loading' })
try {
const data = await queryTable(selectedTable, ROW_LIMIT)
const data = await queryTable(selectedTable)
if (!cancelled)
dispatch({ type: 'success', data })
}
@ -141,7 +140,7 @@ const SQLiteFilePreview: FC<SQLiteFilePreviewProps> = ({
}
return (
<div className="flex h-full w-full min-w-0 flex-col gap-1 p-1">
<div className="flex h-full w-full min-w-0 flex-col gap-1 overflow-hidden p-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<TableSelector
tables={tables}
@ -150,7 +149,10 @@ const SQLiteFilePreview: FC<SQLiteFilePreviewProps> = ({
isLoading={tableState.isLoading}
/>
</div>
<div className="min-h-0 min-w-0 flex-1 overflow-auto rounded-lg bg-components-panel-bg">
<div
ref={tableScrollRef}
className="min-h-0 min-w-0 flex-1 overflow-auto rounded-lg bg-components-panel-bg"
>
{tableState.isLoading
? (
<div className="flex h-full w-full items-center justify-center">
@ -165,20 +167,21 @@ const SQLiteFilePreview: FC<SQLiteFilePreviewProps> = ({
</span>
</div>
)
: (tableState.data && tableState.data.values.length > 0)
? (
<DataTable
columns={tableState.data.columns}
values={tableState.data.values}
/>
)
: (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillSidebar.sqlitePreview.emptyRows')}
</span>
</div>
)}
: tableState.data
? (
<DataTable
columns={tableState.data.columns}
values={tableState.data.values}
scrollRef={tableScrollRef}
/>
)
: (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillSidebar.sqlitePreview.emptyRows')}
</span>
</div>
)}
</div>
</div>
)

View File

@ -81,6 +81,7 @@
"@tailwindcss/typography": "0.5.19",
"@tanstack/react-form": "1.23.7",
"@tanstack/react-query": "5.90.5",
"@tanstack/react-virtual": "3.13.18",
"abcjs": "6.5.2",
"ahooks": "3.9.5",
"class-variance-authority": "0.7.1",

19
web/pnpm-lock.yaml generated
View File

@ -135,6 +135,9 @@ importers:
'@tanstack/react-query':
specifier: 5.90.5
version: 5.90.5(react@19.2.3)
'@tanstack/react-virtual':
specifier: 3.13.18
version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
abcjs:
specifier: 6.5.2
version: 6.5.2
@ -3476,8 +3479,8 @@ packages:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
'@tanstack/react-virtual@3.13.13':
resolution: {integrity: sha512-4o6oPMDvQv+9gMi8rE6gWmsOjtUZUYIJHv7EB+GblyYdi8U6OqLl8rhHWIUZSL1dUU2dPwTdTgybCKf9EjIrQg==}
'@tanstack/react-virtual@3.13.18':
resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
@ -3485,8 +3488,8 @@ packages:
'@tanstack/store@0.7.7':
resolution: {integrity: sha512-xa6pTan1bcaqYDS9BDpSiS63qa6EoDkPN9RsRaxHuDdVDNntzq3xNwR5YKTU/V3SkSyC9T4YVOPh2zRQN0nhIQ==}
'@tanstack/virtual-core@3.13.13':
resolution: {integrity: sha512-uQFoSdKKf5S8k51W5t7b2qpfkyIbdHMzAn+AMQvHPxKUPeo1SsGaA4JRISQT87jm28b7z8OEqPcg1IOZagQHcA==}
'@tanstack/virtual-core@3.13.18':
resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==}
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
@ -10154,7 +10157,7 @@ snapshots:
'@floating-ui/react': 0.26.28(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@react-aria/focus': 3.21.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@react-aria/interactions': 3.25.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/react-virtual': 3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@tanstack/react-virtual': 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
@ -11923,15 +11926,15 @@ snapshots:
react-dom: 19.2.3(react@19.2.3)
use-sync-external-store: 1.6.0(react@19.2.3)
'@tanstack/react-virtual@3.13.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
'@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@tanstack/virtual-core': 3.13.13
'@tanstack/virtual-core': 3.13.18
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
'@tanstack/store@0.7.7': {}
'@tanstack/virtual-core@3.13.13': {}
'@tanstack/virtual-core@3.13.18': {}
'@testing-library/dom@10.4.1':
dependencies: