316 lines
10 KiB
TypeScript
316 lines
10 KiB
TypeScript
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { ChevronUp, ChevronDown } from 'lucide-react';
|
|
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
|
import { getFileType } from 'apps/FileViewer';
|
|
import { FileItem } from './FileItem';
|
|
|
|
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
|
|
|
type SortField = 'name' | 'size' | 'type' | 'date';
|
|
type SortDirection = 'asc' | 'desc';
|
|
|
|
type FileGridProps = {
|
|
entries: DirEntry[];
|
|
viewMode: 'grid' | 'list';
|
|
currentPath: string;
|
|
selected: Set<string>;
|
|
clipboard: ClipboardState;
|
|
onOpen: (entry: DirEntry) => void;
|
|
onDelete: (entry: DirEntry) => void;
|
|
onRename: (entry: DirEntry, newName: string) => void;
|
|
onChat: (entry: DirEntry) => void;
|
|
onDownload: (entry: DirEntry) => void;
|
|
onSelect: (names: Set<string>) => void;
|
|
onCut: () => void;
|
|
onCopy: () => void;
|
|
renamingName: string | null;
|
|
onRenamingChange: (name: string | null) => void;
|
|
onReadAloud: (entry: DirEntry) => void;
|
|
onOcr: (entry: DirEntry) => void;
|
|
onTranscribe: (entry: DirEntry) => void;
|
|
onExtractAudio: (entry: DirEntry) => void;
|
|
onExtract: (entry: DirEntry) => void;
|
|
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
|
onCreateWorkspace: (entry: DirEntry) => void;
|
|
scrollRef: React.RefObject<HTMLDivElement | null>;
|
|
};
|
|
|
|
const LIST_ROW_HEIGHT = 42;
|
|
const GRID_ROW_HEIGHT = 130;
|
|
const GRID_GAP = 12;
|
|
const HEADER_HEIGHT = 36;
|
|
|
|
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
|
const [cols, setCols] = useState(4);
|
|
|
|
useEffect(() => {
|
|
const el = scrollRef.current;
|
|
if (!el) return;
|
|
const measure = () => {
|
|
const width = el.clientWidth - 32; // subtract p-4 (16px each side)
|
|
// Breakpoints matching grid-cols-2 sm:3 md:4 lg:5 xl:6
|
|
if (width >= 1280) setCols(6);
|
|
else if (width >= 1024) setCols(5);
|
|
else if (width >= 768) setCols(4);
|
|
else if (width >= 640) setCols(3);
|
|
else setCols(2);
|
|
};
|
|
measure();
|
|
const observer = new ResizeObserver(measure);
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, [scrollRef]);
|
|
|
|
return cols;
|
|
};
|
|
|
|
export const FileGrid = ({
|
|
entries,
|
|
viewMode,
|
|
currentPath,
|
|
selected,
|
|
clipboard,
|
|
onOpen,
|
|
onDelete,
|
|
onRename,
|
|
onChat,
|
|
onDownload,
|
|
onSelect,
|
|
onCut,
|
|
onCopy,
|
|
renamingName,
|
|
onRenamingChange,
|
|
onReadAloud,
|
|
onOcr,
|
|
onTranscribe,
|
|
onExtractAudio,
|
|
onExtract,
|
|
getMatchingTasks,
|
|
onRunTask,
|
|
onCreateWorkspace,
|
|
scrollRef,
|
|
}: FileGridProps) => {
|
|
const lastClickedIdx = useRef<number>(-1);
|
|
const [sortField, setSortField] = useState<SortField>('name');
|
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
|
|
|
const sorted = useMemo(() => {
|
|
const compare = (a: DirEntry, b: DirEntry): number => {
|
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
|
let result: number;
|
|
switch (sortField) {
|
|
case 'name':
|
|
result = a.name.localeCompare(b.name);
|
|
break;
|
|
case 'size':
|
|
result = a.size - b.size;
|
|
break;
|
|
case 'type': {
|
|
const typeA = a.type === 'directory' ? 'directory' : getFileType(a.name);
|
|
const typeB = b.type === 'directory' ? 'directory' : getFileType(b.name);
|
|
result = typeA.localeCompare(typeB);
|
|
if (result === 0) result = a.name.localeCompare(b.name);
|
|
break;
|
|
}
|
|
case 'date':
|
|
result = a.modifiedAt - b.modifiedAt;
|
|
break;
|
|
}
|
|
return sortDirection === 'asc' ? result : -result;
|
|
};
|
|
return [...entries].sort(compare);
|
|
}, [entries, sortField, sortDirection]);
|
|
|
|
const toggleSort = (field: SortField) => {
|
|
if (sortField === field) {
|
|
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
|
} else {
|
|
setSortField(field);
|
|
setSortDirection('asc');
|
|
}
|
|
};
|
|
|
|
const handleSelect = useCallback(
|
|
(entry: DirEntry, ev: React.MouseEvent) => {
|
|
const idx = sorted.findIndex((e) => e.name === entry.name);
|
|
|
|
if (ev.shiftKey && lastClickedIdx.current >= 0) {
|
|
const start = Math.min(lastClickedIdx.current, idx);
|
|
const end = Math.max(lastClickedIdx.current, idx);
|
|
const next = new Set(selected);
|
|
for (let i = start; i <= end; i++) {
|
|
next.add(sorted[i]!.name);
|
|
}
|
|
onSelect(next);
|
|
} else if (ev.ctrlKey || ev.metaKey) {
|
|
const next = new Set(selected);
|
|
if (next.has(entry.name)) {
|
|
next.delete(entry.name);
|
|
} else {
|
|
next.add(entry.name);
|
|
}
|
|
onSelect(next);
|
|
lastClickedIdx.current = idx;
|
|
} else {
|
|
onSelect(new Set([entry.name]));
|
|
lastClickedIdx.current = idx;
|
|
}
|
|
},
|
|
[sorted, selected, onSelect],
|
|
);
|
|
|
|
const cols = useColumnCount(scrollRef);
|
|
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
|
const anySelected = selected.size > 0;
|
|
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
|
|
|
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
|
|
|
|
const virtualizer = useVirtualizer({
|
|
count: rowCount,
|
|
getScrollElement: () => scrollRef.current,
|
|
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
|
|
overscan: 10,
|
|
scrollMargin: HEADER_HEIGHT,
|
|
});
|
|
|
|
if (entries.length === 0) {
|
|
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
|
|
}
|
|
|
|
const renderItem = (entry: DirEntry) => (
|
|
<FileItem
|
|
key={entry.name}
|
|
entry={entry}
|
|
viewMode={viewMode}
|
|
selected={selected.has(entry.name)}
|
|
anySelected={anySelected}
|
|
selectedCount={selected.size}
|
|
isCut={cutPaths.has(entryPath(entry.name))}
|
|
onOpen={onOpen}
|
|
onDelete={onDelete}
|
|
onRename={onRename}
|
|
onChat={onChat}
|
|
onDownload={onDownload}
|
|
onSelect={handleSelect}
|
|
onCut={onCut}
|
|
onCopy={onCopy}
|
|
forceRename={renamingName === entry.name}
|
|
onRenamingChange={onRenamingChange}
|
|
onReadAloud={onReadAloud}
|
|
onOcr={onOcr}
|
|
onTranscribe={onTranscribe}
|
|
onExtractAudio={onExtractAudio}
|
|
onExtract={onExtract}
|
|
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
|
onRunTask={onRunTask}
|
|
onCreateWorkspace={onCreateWorkspace}
|
|
/>
|
|
);
|
|
|
|
const sortIcon = (field: SortField) =>
|
|
sortField === field ? (
|
|
sortDirection === 'asc' ? (
|
|
<ChevronUp className="h-3 w-3" />
|
|
) : (
|
|
<ChevronDown className="h-3 w-3" />
|
|
)
|
|
) : null;
|
|
|
|
const sortBtnClass = (field: SortField) =>
|
|
`flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors ${
|
|
sortField === field ? 'text-duck-teal' : 'text-duck-dark/50 hover:text-duck-dark/80'
|
|
}`;
|
|
|
|
return (
|
|
<>
|
|
{viewMode === 'list' ? (
|
|
<div
|
|
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-3 px-3"
|
|
style={{ height: HEADER_HEIGHT }}
|
|
>
|
|
<span className="shrink-0 w-5" />
|
|
<span className="shrink-0 w-5" />
|
|
<button className={`flex-1 min-w-0 ${sortBtnClass('name')}`} onClick={() => toggleSort('name')}>
|
|
Name {sortIcon('name')}
|
|
</button>
|
|
<button
|
|
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('type')}`}
|
|
onClick={() => toggleSort('type')}
|
|
>
|
|
Type {sortIcon('type')}
|
|
</button>
|
|
<button
|
|
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('size')}`}
|
|
onClick={() => toggleSort('size')}
|
|
>
|
|
Size {sortIcon('size')}
|
|
</button>
|
|
<button
|
|
className={`hidden md:flex shrink-0 w-28 justify-end ${sortBtnClass('date')}`}
|
|
onClick={() => toggleSort('date')}
|
|
>
|
|
Date {sortIcon('date')}
|
|
</button>
|
|
<span className="shrink-0 w-8" />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-2 px-3"
|
|
style={{ height: HEADER_HEIGHT }}
|
|
>
|
|
<span className="text-xs text-duck-dark/40">Sort:</span>
|
|
{(['name', 'size', 'type', 'date'] as SortField[]).map((field) => (
|
|
<button
|
|
key={field}
|
|
onClick={() => toggleSort(field)}
|
|
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs cursor-pointer transition-colors ${
|
|
sortField === field
|
|
? 'bg-duck-teal/10 text-duck-teal font-medium'
|
|
: 'text-duck-dark/50 hover:bg-duck-dark/5'
|
|
}`}
|
|
>
|
|
{field.charAt(0).toUpperCase() + field.slice(1)}
|
|
{sortIcon(field)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
|
if (viewMode === 'list') {
|
|
const entry = sorted[virtualRow.index]!;
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
className="absolute left-0 w-full"
|
|
style={{ top: virtualRow.start, height: virtualRow.size }}
|
|
>
|
|
{renderItem(entry)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const startIdx = virtualRow.index * cols;
|
|
const rowEntries = sorted.slice(startIdx, startIdx + cols);
|
|
return (
|
|
<div
|
|
key={virtualRow.key}
|
|
className="absolute left-0 w-full grid gap-3"
|
|
style={{
|
|
top: virtualRow.start,
|
|
height: virtualRow.size - GRID_GAP,
|
|
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
|
}}
|
|
>
|
|
{rowEntries.map(renderItem)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|