mirror of
https://github.com/langgenius/dify.git
synced 2026-04-21 03:07:39 +08:00
feat: add sqlite file preview
This commit is contained in:
@ -3,6 +3,7 @@
|
||||
import type { OnMount } from '@monaco-editor/react'
|
||||
import type { FC } from 'react'
|
||||
import { loader } from '@monaco-editor/react'
|
||||
import dynamic from 'next/dynamic'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -22,6 +23,11 @@ import { getFileLanguage } from './utils/file-utils'
|
||||
import MediaFilePreview from './viewer/media-file-preview'
|
||||
import UnsupportedFileDownload from './viewer/unsupported-file-download'
|
||||
|
||||
const SQLiteFilePreview = dynamic(
|
||||
() => import('./viewer/sqlite-file-preview'),
|
||||
{ ssr: false, loading: () => <Loading type="area" /> },
|
||||
)
|
||||
|
||||
if (typeof window !== 'undefined')
|
||||
loader.config({ paths: { vs: `${window.location.origin}${basePath}/vs` } })
|
||||
|
||||
@ -43,7 +49,7 @@ const FileContentPanel: FC = () => {
|
||||
|
||||
const currentFileNode = activeTabId ? nodeMap?.get(activeTabId) : undefined
|
||||
|
||||
const { isMarkdown, isCodeOrText, isImage, isVideo, isEditable } = useFileTypeInfo(currentFileNode)
|
||||
const { isMarkdown, isCodeOrText, isImage, isVideo, isSQLite, isEditable } = useFileTypeInfo(currentFileNode)
|
||||
|
||||
const { fileContent, downloadUrlData, isLoading, error } = useSkillFileData(appId, activeTabId, isEditable)
|
||||
|
||||
@ -149,11 +155,11 @@ const FileContentPanel: FC = () => {
|
||||
)
|
||||
}
|
||||
|
||||
// For non-editable files (media, unsupported), use download URL
|
||||
// For non-editable files (media, sqlite, unsupported), use download URL
|
||||
const downloadUrl = downloadUrlData?.download_url || ''
|
||||
const fileName = currentFileNode?.name || ''
|
||||
const fileSize = currentFileNode?.size
|
||||
const isUnsupportedFile = !isMarkdown && !isCodeOrText && !isImage && !isVideo
|
||||
const isUnsupportedFile = !isMarkdown && !isCodeOrText && !isImage && !isVideo && !isSQLite
|
||||
|
||||
return (
|
||||
<div className="h-full w-full overflow-auto bg-components-panel-bg">
|
||||
@ -186,6 +192,14 @@ const FileContentPanel: FC = () => {
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isSQLite
|
||||
? (
|
||||
<SQLiteFilePreview
|
||||
key={activeTabId}
|
||||
downloadUrl={downloadUrl}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
{isUnsupportedFile
|
||||
? (
|
||||
<UnsupportedFileDownload
|
||||
|
||||
249
web/app/components/workflow/skill/hooks/use-sqlite-database.ts
Normal file
249
web/app/components/workflow/skill/hooks/use-sqlite-database.ts
Normal file
@ -0,0 +1,249 @@
|
||||
import type { MemoryVFS } from 'wa-sqlite/src/examples/MemoryVFS.js'
|
||||
import { useCallback, useEffect, useReducer, useRef } from 'react'
|
||||
|
||||
export type SQLiteValue = string | number | bigint | Uint8Array | null
|
||||
|
||||
export type SQLiteQueryResult = {
|
||||
columns: string[]
|
||||
values: SQLiteValue[][]
|
||||
}
|
||||
|
||||
export type UseSQLiteDatabaseResult = {
|
||||
tables: string[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
queryTable: (tableName: string, limit?: number) => Promise<SQLiteQueryResult | null>
|
||||
}
|
||||
|
||||
type SQLiteModuleType = typeof import('wa-sqlite')
|
||||
type SQLiteAPI = ReturnType<SQLiteModuleType['Factory']>
|
||||
type SQLiteVFS = Parameters<SQLiteAPI['vfs_register']>[0]
|
||||
|
||||
type SQLiteClient = {
|
||||
sqlite3: ReturnType<SQLiteModuleType['Factory']>
|
||||
sqlite: SQLiteModuleType
|
||||
vfs: MemoryVFS
|
||||
}
|
||||
|
||||
type SQLiteState = {
|
||||
tables: string[]
|
||||
isLoading: boolean
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
type SQLiteAction
|
||||
= | { type: 'reset' }
|
||||
| { type: 'loading' }
|
||||
| { type: 'success', tables: string[] }
|
||||
| { 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
|
||||
|
||||
function createTempFileName(): string {
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto)
|
||||
return `preview-${crypto.randomUUID()}.db`
|
||||
return `preview-${Date.now()}-${Math.random().toString(16).slice(2)}.db`
|
||||
}
|
||||
|
||||
async function getSQLiteClient(): Promise<SQLiteClient> {
|
||||
if (!sqliteClientPromise) {
|
||||
sqliteClientPromise = (async () => {
|
||||
const [{ default: SQLiteESMFactory }, sqlite, { MemoryVFS }] = await Promise.all([
|
||||
import('wa-sqlite/dist/wa-sqlite.mjs'),
|
||||
import('wa-sqlite'),
|
||||
import('wa-sqlite/src/examples/MemoryVFS.js'),
|
||||
])
|
||||
const sqliteModule = await SQLiteESMFactory()
|
||||
const sqlite3 = sqlite.Factory(sqliteModule)
|
||||
const vfs = new MemoryVFS()
|
||||
sqlite3.vfs_register(vfs as unknown as SQLiteVFS, false)
|
||||
return {
|
||||
sqlite3,
|
||||
sqlite,
|
||||
vfs,
|
||||
}
|
||||
})()
|
||||
}
|
||||
return sqliteClientPromise
|
||||
}
|
||||
|
||||
export function useSQLiteDatabase(downloadUrl: string | undefined): UseSQLiteDatabaseResult {
|
||||
const [state, dispatch] = useReducer((current: SQLiteState, action: SQLiteAction): SQLiteState => {
|
||||
switch (action.type) {
|
||||
case 'reset':
|
||||
return {
|
||||
tables: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
case 'loading':
|
||||
return {
|
||||
...current,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
tables: [],
|
||||
}
|
||||
case 'success':
|
||||
return {
|
||||
tables: action.tables,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
tables: [],
|
||||
isLoading: false,
|
||||
error: action.error,
|
||||
}
|
||||
default:
|
||||
return current
|
||||
}
|
||||
}, {
|
||||
tables: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
})
|
||||
const dbRef = useRef<number | null>(null)
|
||||
const fileRef = useRef<string | null>(null)
|
||||
const clientRef = useRef<SQLiteClient | null>(null)
|
||||
const cacheRef = useRef<Map<string, SQLiteQueryResult>>(new Map())
|
||||
|
||||
const closeDatabase = useCallback(async () => {
|
||||
const client = clientRef.current
|
||||
const db = dbRef.current
|
||||
const fileName = fileRef.current
|
||||
|
||||
if (client && db !== null) {
|
||||
try {
|
||||
await client.sqlite3.close(db)
|
||||
}
|
||||
catch {
|
||||
// Ignore cleanup errors.
|
||||
}
|
||||
}
|
||||
|
||||
if (client && fileName)
|
||||
client.vfs.mapNameToFile.delete(fileName)
|
||||
|
||||
dbRef.current = null
|
||||
fileRef.current = null
|
||||
cacheRef.current.clear()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!downloadUrl) {
|
||||
dispatch({ type: 'reset' })
|
||||
void closeDatabase()
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const controller = new AbortController()
|
||||
|
||||
const loadDatabase = async () => {
|
||||
dispatch({ type: 'loading' })
|
||||
|
||||
try {
|
||||
const [client, response] = await Promise.all([
|
||||
getSQLiteClient(),
|
||||
fetch(downloadUrl, { signal: controller.signal }),
|
||||
])
|
||||
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
if (!response.ok)
|
||||
throw new Error(`Failed to fetch database: ${response.status}`)
|
||||
|
||||
const buffer = await response.arrayBuffer()
|
||||
if (cancelled)
|
||||
return
|
||||
|
||||
await closeDatabase()
|
||||
|
||||
const fileName = createTempFileName()
|
||||
client.vfs.mapNameToFile.set(fileName, {
|
||||
name: fileName,
|
||||
flags: 0,
|
||||
size: buffer.byteLength,
|
||||
data: buffer,
|
||||
})
|
||||
|
||||
const db = await client.sqlite3.open_v2(
|
||||
fileName,
|
||||
client.sqlite.SQLITE_OPEN_READONLY,
|
||||
client.vfs.name,
|
||||
)
|
||||
|
||||
if (cancelled) {
|
||||
await client.sqlite3.close(db)
|
||||
client.vfs.mapNameToFile.delete(fileName)
|
||||
return
|
||||
}
|
||||
|
||||
clientRef.current = client
|
||||
dbRef.current = db
|
||||
fileRef.current = fileName
|
||||
|
||||
const result = await client.sqlite3.execWithParams(db, TABLES_QUERY, [])
|
||||
const tableNames = result.rows.map(row => String(row[0]))
|
||||
dispatch({ type: 'success', tables: tableNames })
|
||||
}
|
||||
catch (err) {
|
||||
if (!cancelled) {
|
||||
dispatch({ type: 'error', error: err instanceof Error ? err : new Error(String(err)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadDatabase()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
controller.abort()
|
||||
void closeDatabase()
|
||||
}
|
||||
}, [downloadUrl, closeDatabase])
|
||||
|
||||
const queryTable = useCallback(async (tableName: string, limit?: number): Promise<SQLiteQueryResult | null> => {
|
||||
const client = clientRef.current
|
||||
const db = dbRef.current
|
||||
|
||||
if (!client || db === null || !tableName)
|
||||
return null
|
||||
|
||||
if (!state.tables.includes(tableName))
|
||||
return null
|
||||
|
||||
const rowLimit = Number.isFinite(limit) && limit && limit > 0
|
||||
? Math.floor(limit)
|
||||
: DEFAULT_ROW_LIMIT
|
||||
const cacheKey = `${tableName}:${rowLimit}`
|
||||
const cached = cacheRef.current.get(cacheKey)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const safeName = tableName.replaceAll('"', '""')
|
||||
const result = await client.sqlite3.execWithParams(
|
||||
db,
|
||||
`SELECT * FROM "${safeName}" LIMIT ${rowLimit}`,
|
||||
[],
|
||||
)
|
||||
const data: SQLiteQueryResult = {
|
||||
columns: result.columns,
|
||||
values: result.rows as SQLiteValue[][],
|
||||
}
|
||||
cacheRef.current.set(cacheKey, data)
|
||||
return data
|
||||
}, [state.tables])
|
||||
|
||||
return {
|
||||
tables: state.tables,
|
||||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
queryTable,
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -1089,6 +1089,12 @@
|
||||
"skillSidebar.rootFolder": "root folder",
|
||||
"skillSidebar.searchNoResults": "No file were found",
|
||||
"skillSidebar.searchPlaceholder": "Search files…",
|
||||
"skillSidebar.sqlitePreview.blobValue": "BLOB ({{size}} bytes)",
|
||||
"skillSidebar.sqlitePreview.emptyRows": "No rows to display",
|
||||
"skillSidebar.sqlitePreview.emptyTables": "No tables available",
|
||||
"skillSidebar.sqlitePreview.loadError": "Failed to load SQLite preview",
|
||||
"skillSidebar.sqlitePreview.nullValue": "NULL",
|
||||
"skillSidebar.sqlitePreview.selectTable": "Select a table",
|
||||
"skillSidebar.toggleFolder": "Toggle folder",
|
||||
"skillSidebar.unsavedChanges.confirmClose": "Discard",
|
||||
"skillSidebar.unsavedChanges.content": "You have unsaved changes. Do you want to discard them?",
|
||||
|
||||
@ -1080,6 +1080,12 @@
|
||||
"skillSidebar.rootFolder": "根目录",
|
||||
"skillSidebar.searchNoResults": "未找到文件",
|
||||
"skillSidebar.searchPlaceholder": "搜索文件...",
|
||||
"skillSidebar.sqlitePreview.blobValue": "BLOB({{size}} 字节)",
|
||||
"skillSidebar.sqlitePreview.emptyRows": "没有可显示的行",
|
||||
"skillSidebar.sqlitePreview.emptyTables": "没有可用的表",
|
||||
"skillSidebar.sqlitePreview.loadError": "加载 SQLite 预览失败",
|
||||
"skillSidebar.sqlitePreview.nullValue": "NULL",
|
||||
"skillSidebar.sqlitePreview.selectTable": "选择表",
|
||||
"skillSidebar.unsavedChanges.confirmClose": "放弃",
|
||||
"skillSidebar.unsavedChanges.content": "您有未保存的更改,是否放弃?",
|
||||
"skillSidebar.unsavedChanges.title": "未保存的更改",
|
||||
|
||||
Reference in New Issue
Block a user