mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 00:48:04 +08:00
feat: use virtual scroll for db preview
This commit is contained in:
@ -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 = {
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user