feat: add sqlite file preview

This commit is contained in:
yyh
2026-01-22 14:52:22 +08:00
parent 4a88ffdf2a
commit 11005ccb63
7 changed files with 666 additions and 3 deletions

View File

@ -0,0 +1,103 @@
import type { TFunction } from 'i18next'
import type { FC } from 'react'
import type { SQLiteValue } from '../../hooks/use-sqlite-database'
import * as React from 'react'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
type DataTableProps = {
columns: string[]
values: SQLiteValue[][]
}
const MAX_CELL_LENGTH = 120
const formatValue = (value: SQLiteValue, t: TFunction<'workflow'>): string => {
if (value === null)
return t('skillSidebar.sqlitePreview.nullValue')
if (value instanceof Uint8Array)
return t('skillSidebar.sqlitePreview.blobValue', { size: value.byteLength })
if (typeof value === 'bigint')
return value.toString()
return String(value)
}
const truncateValue = (value: string): string => {
if (value.length <= MAX_CELL_LENGTH)
return value
return `${value.slice(0, MAX_CELL_LENGTH)}...`
}
const DataTable: FC<DataTableProps> = ({ columns, values }) => {
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 cells = row.map((value) => {
const rawValue = formatValue(value, t)
return {
rawValue,
displayValue: truncateValue(rawValue),
isNull: value === null,
}
})
return {
key: rowKey,
cells,
}
})
}, [keyColumnIndex, t, values])
return (
<table className="min-w-full table-auto border-separate border-spacing-0">
<thead className="sticky top-0 z-10 text-text-secondary">
<tr>
{columns.map(column => (
<th
key={column}
className={cn('border-b border-r border-t border-divider-subtle bg-background-section px-2 py-1.5 text-left align-middle first:rounded-tl-lg first:border-l last:rounded-tr-lg')}
>
<span className="system-xs-medium block truncate">{column}</span>
</th>
))}
</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>
))}
</tr>
))}
</tbody>
</table>
)
}
export default React.memo(DataTable)

View File

@ -0,0 +1,187 @@
import type { FC } from 'react'
import * as React from 'react'
import { useEffect, useMemo, useReducer, 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
}
const SQLiteFilePreview: FC<SQLiteFilePreviewProps> = ({
downloadUrl,
}) => {
const { t } = useTranslation('workflow')
const { tables, isLoading, error, queryTable } = useSQLiteDatabase(downloadUrl)
const [selectedTableId, setSelectedTableId] = useState<string>('')
const [tableState, dispatch] = useReducer((
current: {
data: Awaited<ReturnType<typeof queryTable>> | null
isLoading: boolean
error: Error | null
},
action:
| { type: 'reset' }
| { type: 'loading' }
| { type: 'success', data: Awaited<ReturnType<typeof queryTable>> | null }
| { type: 'error', error: Error },
) => {
switch (action.type) {
case 'reset':
return {
data: null,
isLoading: false,
error: null,
}
case 'loading':
return {
data: null,
isLoading: true,
error: null,
}
case 'success':
return {
data: action.data,
isLoading: false,
error: null,
}
case 'error':
return {
data: null,
isLoading: false,
error: action.error,
}
default:
return current
}
}, {
data: null,
isLoading: false,
error: null,
})
const selectedTable = useMemo(() => {
if (tables.length === 0)
return ''
if (selectedTableId && tables.includes(selectedTableId))
return selectedTableId
return tables[0]
}, [selectedTableId, tables])
useEffect(() => {
if (!selectedTable) {
dispatch({ type: 'reset' })
return
}
let cancelled = false
const loadTable = async () => {
dispatch({ type: 'loading' })
try {
const data = await queryTable(selectedTable, ROW_LIMIT)
if (!cancelled)
dispatch({ type: 'success', data })
}
catch (err) {
if (!cancelled)
dispatch({ type: 'error', error: err instanceof Error ? err : new Error(String(err)) })
}
}
loadTable()
return () => {
cancelled = true
}
}, [queryTable, selectedTable])
if (!downloadUrl) {
return (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillEditor.previewUnavailable')}
</span>
</div>
)
}
if (isLoading) {
return (
<div className="flex h-full w-full items-center justify-center">
<Loading type="area" />
</div>
)
}
if (error) {
return (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillSidebar.sqlitePreview.loadError')}
</span>
</div>
)
}
if (tables.length === 0) {
return (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillSidebar.sqlitePreview.emptyTables')}
</span>
</div>
)
}
return (
<div className="flex h-full w-full flex-col gap-1 p-1">
<div className="flex flex-wrap items-center justify-between gap-2">
<TableSelector
tables={tables}
selectedTable={selectedTable}
onTableChange={setSelectedTableId}
isLoading={tableState.isLoading}
/>
</div>
<div className="min-h-0 flex-1 overflow-auto rounded-lg bg-components-panel-bg">
{tableState.isLoading
? (
<div className="flex h-full w-full items-center justify-center">
<Loading type="area" />
</div>
)
: tableState.error
? (
<div className="flex h-full w-full items-center justify-center text-text-tertiary">
<span className="system-sm-regular">
{t('skillSidebar.sqlitePreview.loadError')}
</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>
)}
</div>
</div>
)
}
export default React.memo(SQLiteFilePreview)

View File

@ -0,0 +1,98 @@
import type { FC } from 'react'
import { RiArrowDownSLine } from '@remixicon/react'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Check } from '@/app/components/base/icons/src/vender/line/general'
import { TableCells } from '@/app/components/base/icons/src/vender/solid/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { cn } from '@/utils/classnames'
type TableSelectorProps = {
tables: string[]
selectedTable: string
isLoading?: boolean
onTableChange: (tableName: string) => void
}
const TableSelector: FC<TableSelectorProps> = ({
tables,
selectedTable,
isLoading = false,
onTableChange,
}) => {
const { t } = useTranslation('workflow')
const [open, setOpen] = useState(false)
const items = useMemo(() => {
return tables.map(name => ({
value: name,
name,
}))
}, [tables])
const label = selectedTable || t('skillSidebar.sqlitePreview.selectTable')
const isPlaceholder = !selectedTable
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement="bottom-start"
offset={4}
>
<div className="relative">
<PortalToFollowElemTrigger asChild>
<button
type="button"
disabled={isLoading}
onClick={() => {
if (!isLoading)
setOpen(prev => !prev)
}}
className={cn(
'inline-flex items-center gap-1 rounded-[6px] px-1.5 py-1 text-text-secondary',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
isLoading ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:bg-state-base-hover',
)}
>
<TableCells className="h-3.5 w-3.5 text-text-secondary" aria-hidden="true" />
<span className={cn('system-sm-medium min-w-0 max-w-[220px] truncate', isPlaceholder && 'text-text-tertiary')}>
{label}
</span>
<RiArrowDownSLine className="h-3.5 w-3.5 text-text-secondary" aria-hidden="true" />
</button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-20">
<div className="min-w-[220px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-sm">
{items.map(item => (
<button
key={item.value}
type="button"
className={cn(
'system-xs-regular flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-text-secondary hover:bg-state-base-hover',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-components-input-border-active',
item.value === selectedTable && 'bg-state-base-hover',
)}
onClick={() => {
onTableChange(String(item.value))
setOpen(false)
}}
>
<span className="truncate">{item.name}</span>
{item.value === selectedTable && (
<Check className="ml-auto h-4 w-4 shrink-0 text-text-accent" aria-hidden="true" />
)}
</button>
))}
</div>
</PortalToFollowElemContent>
</div>
</PortalToFollowElem>
)
}
export default React.memo(TableSelector)