Files FIles Files

This commit is contained in:
2026-02-20 04:28:02 +00:00
parent 1f7eb64eb3
commit d7503ca56b
20 changed files with 2195 additions and 633 deletions
@@ -1,10 +1,15 @@
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';
@@ -21,6 +26,11 @@ type FileGridProps = {
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;
@@ -30,6 +40,7 @@ type FileGridProps = {
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);
@@ -71,21 +82,55 @@ export const FileGrid = ({
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(
() =>
[...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
}),
[entries],
);
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) => {
@@ -128,6 +173,7 @@ export const FileGrid = ({
getScrollElement: () => scrollRef.current,
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
overscan: 10,
scrollMargin: HEADER_HEIGHT,
});
if (entries.length === 0) {
@@ -153,44 +199,117 @@ export const FileGrid = ({
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 (
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
if (viewMode === 'list') {
const entry = sorted[virtualRow.index]!;
<>
{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"
style={{ top: virtualRow.start, height: virtualRow.size }}
className="absolute left-0 w-full grid gap-3"
style={{
top: virtualRow.start,
height: virtualRow.size - GRID_GAP,
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
}}
>
{renderItem(entry)}
{rowEntries.map(renderItem)}
</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>
})}
</div>
</>
);
};