Files
Foxel/web/src/pages/FileExplorerPage/components/SearchResultsView.tsx

217 lines
8.2 KiB
TypeScript

import React from 'react';
import { Empty, Flex, Spin, Tag, Typography, theme, Button } from 'antd';
import { MoreOutlined } from '@ant-design/icons';
import { useI18n } from '../../../i18n';
import type { VfsEntry } from '../../../api/client';
import type { ViewMode } from '../types';
import type { SearchDisplayItem, SearchMode } from '../hooks/useFileSearch';
interface SearchResultsViewProps {
viewMode: ViewMode;
loading: boolean;
mode: SearchMode;
query: string;
items: SearchDisplayItem[];
selectedPaths: string[];
entrySnapshot: Record<string, VfsEntry>;
mobile?: boolean;
onClearSearch: () => void;
onSelect: (fullPath: string, additive: boolean) => void;
onOpen: (fullPath: string) => void;
onContextMenu: (e: React.MouseEvent, fullPath: string) => void;
onOpenMenu?: (fullPath: string, anchor: HTMLElement) => void;
}
export const SearchResultsView: React.FC<SearchResultsViewProps> = ({
viewMode,
loading,
mode,
query,
items,
selectedPaths,
entrySnapshot,
mobile = false,
onClearSearch,
onSelect,
onOpen,
onContextMenu,
onOpenMenu,
}) => {
const { token } = theme.useToken();
const { t } = useI18n();
const renderSourceLabel = (value?: string) => {
switch ((value || '').toLowerCase()) {
case 'vector':
return t('Vector Search');
case 'filename':
return t('Name Search');
case 'text':
return t('Text Chunk');
case 'image':
return t('Image Description');
default:
return t('Vector Search');
}
};
const sourceColor = (value?: string) => {
switch ((value || '').toLowerCase()) {
case 'vector':
return 'blue';
case 'filename':
return 'green';
case 'image':
return 'volcano';
case 'text':
return 'geekblue';
default:
return 'purple';
}
};
const normalizeSnippet = (rawSnippet: string | undefined, name: string, fullPath: string) => {
const snippet = (rawSnippet || '').trim();
if (!snippet) return '';
if (snippet === name) return '';
if (snippet === fullPath) return '';
if (snippet === fullPath.replace(/^\/+/, '')) return '';
return snippet;
};
return (
<div style={{ padding: mobile ? 12 : 16 }}>
<Flex align="center" justify="space-between" style={{ marginBottom: 12, gap: 12, flexWrap: 'wrap' }}>
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
<Typography.Text strong>{t('Search Results')}</Typography.Text>
<Tag color={mode === 'filename' ? 'green' : 'blue'}>{mode === 'filename' ? t('Name Search') : t('Smart Search')}</Tag>
<Tag closable onClose={(ev) => { ev.preventDefault(); onClearSearch(); }}>
{query}
</Tag>
</Flex>
</Flex>
{loading ? (
<Flex align="center" justify="center" style={{ padding: 48 }}>
<Spin />
</Flex>
) : items.length === 0 ? (
<Flex align="center" justify="center" style={{ padding: 48 }}>
<Empty description={t('No files found')} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Flex>
) : viewMode === 'grid' ? (
<div className="fx-grid" style={{ padding: 0, gridTemplateColumns: mobile ? 'repeat(auto-fill, minmax(160px, 1fr))' : 'repeat(auto-fill, minmax(220px, 1fr))' }}>
{items.map(({ item, fullPath, dir, name }) => {
const selected = selectedPaths.includes(fullPath);
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
const isDir = Boolean(entrySnapshot[fullPath]?.is_dir);
return (
<div
key={fullPath}
className={['fx-grid-item', selected ? 'selected' : '', 'file'].join(' ')}
onClick={(ev) => {
if (mobile) {
onOpen(fullPath);
return;
}
onSelect(fullPath, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(fullPath);
}}
onContextMenu={(ev) => {
if (!mobile) onContextMenu(ev, fullPath);
}}
style={{ userSelect: 'none' }}
>
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(fullPath, e.currentTarget);
}}
style={{ position: 'absolute', top: 4, right: 4, zIndex: 2 }}
/>
)}
<div className="thumb" style={{ background: 'var(--ant-color-bg-container, #fff)' }}>
<span className="badge score-badge">{scoreText}</span>
{isDir ? <Typography.Text style={{ fontSize: 32, color: token.colorPrimary }}>📁</Typography.Text> : <Typography.Text style={{ fontSize: 32, color: token.colorTextTertiary }}>📄</Typography.Text>}
</div>
<div className="name ellipsis">{name}</div>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>
{dir}
</Typography.Text>
</div>
);
})}
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{items.map(({ item, fullPath, name }) => {
const selected = selectedPaths.includes(fullPath);
const retrieval = item.metadata?.retrieval_source || item.source_type;
const scoreText = Number.isFinite(item.score) ? item.score.toFixed(2) : '-';
const snippet = normalizeSnippet(item.snippet, name, fullPath);
return (
<div
key={fullPath}
className={selected ? 'row-selected' : ''}
onClick={(ev) => {
if (mobile) {
onOpen(fullPath);
return;
}
onSelect(fullPath, ev.ctrlKey || ev.metaKey);
}}
onDoubleClick={() => {
if (!mobile) onOpen(fullPath);
}}
onContextMenu={(ev) => {
if (!mobile) onContextMenu(ev, fullPath);
}}
style={{
padding: '10px 12px',
borderRadius: token.borderRadius,
background: token.colorFillTertiary,
cursor: 'pointer',
userSelect: 'none',
position: 'relative',
}}
>
{mobile && onOpenMenu && (
<Button
size="small"
type="text"
icon={<MoreOutlined />}
aria-label={t('More')}
onClick={(e) => {
e.stopPropagation();
onOpenMenu(fullPath, e.currentTarget);
}}
style={{ position: 'absolute', top: 6, right: 6 }}
/>
)}
<Flex vertical style={{ gap: 6, paddingRight: mobile ? 28 : 0 }}>
<Typography.Text strong className="ellipsis">{name}</Typography.Text>
<Typography.Text type="secondary" className="ellipsis" style={{ fontSize: 12 }}>{fullPath}</Typography.Text>
{snippet ? <Typography.Paragraph ellipsis={{ rows: 3 }} style={{ marginBottom: 0 }}>{snippet}</Typography.Paragraph> : null}
<Flex align="center" style={{ gap: 8, flexWrap: 'wrap' }}>
{retrieval ? <Tag color={sourceColor(retrieval)} style={{ marginRight: 0 }}>{renderSourceLabel(retrieval)}</Tag> : null}
<Tag style={{ marginRight: 0, background: token.colorBgContainer, borderColor: token.colorBorderSecondary, color: token.colorText }}>{scoreText}</Tag>
</Flex>
</Flex>
</div>
);
})}
</div>
)}
</div>
);
};