mono/packages/ui/src/modules/storage/FileListView.tsx
2026-03-21 20:18:25 +01:00

120 lines
6.6 KiB
TypeScript

import React from 'react';
import { ArrowUp } from 'lucide-react';
import type { INode } from './types';
import { FOCUS_BG, FOCUS_BORDER, SELECTED_BG, SELECTED_BORDER } from './types';
import { getMimeCategory, CATEGORY_STYLE, formatSize, formatDate } from './helpers';
import { NodeIcon } from './ThumbPreview';
// ── Props ────────────────────────────────────────────────────────
interface FileListViewProps {
listRef: React.RefObject<HTMLDivElement>;
sorted: INode[];
canGoUp: boolean;
goUp: () => void;
focusIdx: number;
setFocusIdx: (idx: number) => void;
selected: INode[];
onItemClick: (idx: number, e?: React.MouseEvent) => void;
onItemDoubleClick: (idx: number) => void;
fontSize: number;
mode: string;
/** Current type-ahead search buffer for visual highlight */
searchBuffer?: string;
isSearchMode?: boolean;
}
// ── Component ────────────────────────────────────────────────────
const FileListView: React.FC<FileListViewProps> = ({
listRef, sorted, canGoUp, goUp, focusIdx, setFocusIdx,
selected, onItemClick, onItemDoubleClick, fontSize, mode, searchBuffer, isSearchMode
}) => (
<div ref={listRef as any} data-testid="file-list-view" style={{ overflowY: 'auto', flex: 1, padding: 2 }}>
{canGoUp && !isSearchMode && (
<div data-fb-idx={0} onClick={() => setFocusIdx(0)} onDoubleClick={goUp}
data-testid="file-list-node-up"
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '5px 10px',
cursor: 'pointer', fontSize, borderBottom: '1px solid rgba(255,255,255,0.06)',
background: focusIdx === 0 ? FOCUS_BG : 'transparent',
borderLeftWidth: 2, borderLeftColor: 'transparent',
borderLeftStyle: 'solid',
outline: focusIdx === 0 ? `2px solid ${FOCUS_BORDER}` : 'none',
outlineOffset: '-2px',
}}>
<ArrowUp size={14} style={{ color: CATEGORY_STYLE.dir.color }} />
<span style={{ fontWeight: 500 }}>..</span>
</div>
)}
{sorted.map((node, i) => {
// Note: in search mode, canGoUp is usually false, or we explicitly don't shift index by 1 since there is no ".." node rendered
const idx = ((canGoUp && !isSearchMode) ? i + 1 : i);
const isDir = getMimeCategory(node) === 'dir';
const isFocused = focusIdx === idx;
const isSelected = selected.some(sel => sel.path === node.path);
return (
<div key={node.path || node.name} data-fb-idx={idx}
data-testid="file-list-node"
data-node-id={node.path || node.name}
onClick={(e) => onItemClick(idx, e)}
onDoubleClick={() => onItemDoubleClick(idx)}
className="fb-row" style={{
display: 'flex', alignItems: 'center', gap: 8, padding: isSearchMode ? '8px 10px' : '5px 10px',
cursor: 'pointer', fontSize,
borderBottomWidth: 1, borderBottomColor: 'rgba(255,255,255,0.06)', borderBottomStyle: 'solid',
background: isSelected ? SELECTED_BG : isFocused ? FOCUS_BG : 'transparent',
borderLeftWidth: 2, borderLeftColor: isSelected ? SELECTED_BORDER : 'transparent',
borderLeftStyle: isSelected ? 'outset' : 'solid',
outline: isFocused ? `2px solid ${FOCUS_BORDER}` : 'none',
outlineOffset: '-2px',
}}>
<NodeIcon node={node} />
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column', gap: isSearchMode ? 2 : 0 }}>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{(() => {
if (!isFocused || !searchBuffer) return node.name;
const lower = node.name.toLowerCase();
const pos = lower.startsWith(searchBuffer) ? 0 : lower.indexOf(searchBuffer);
if (pos < 0) return node.name;
const isDark = document.documentElement.classList.contains('dark');
const hlStyle = {
background: isDark ? 'rgba(14,165,233,0.3)' : 'rgba(245,158,11,0.25)',
color: isDark ? '#bae6fd' : '#92400e',
borderBottom: isDark ? '1px solid #38bdf8' : '1px solid #f59e0b',
};
return (
<>
{pos > 0 && node.name.slice(0, pos)}
<span style={hlStyle}>
{node.name.slice(pos, pos + searchBuffer.length)}
</span>
{node.name.slice(pos + searchBuffer.length)}
</>
);
})()}
</span>
{isSearchMode && (
<span style={{ fontSize: 10, color: 'var(--muted-foreground)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{node.parent || '/'}
</span>
)}
</div>
{node.size !== undefined && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0 }}>
{formatSize(node.size)}
</span>
)}
{mode === 'advanced' && node.mtime && (
<span style={{ color: 'var(--muted-foreground, #64748b)', fontSize: 10, flexShrink: 0, width: 120, textAlign: 'right' }}>
{formatDate(node.mtime)}
</span>
)}
</div>
);
})}
</div>
);
export default FileListView;