extracted all File components to apps/FileBrowser

This commit is contained in:
2026-02-21 18:18:39 +00:00
parent fe7874dd65
commit e275c0fea6
23 changed files with 80 additions and 237 deletions
@@ -0,0 +1,316 @@
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronUp, ChevronDown } from 'lucide-react';
import type { DirEntry } from './useFiles';
import type { TaskSummary } from './useTasks';
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>
</>
);
};
@@ -0,0 +1,619 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { cardStyle } from '@/components/Card';
import type { DirEntry } from './useFiles';
import type { TaskSummary } from './useTasks';
import { getFileType } from 'apps/FileViewer';
export type FileItemProps = {
entry: DirEntry;
viewMode: 'grid' | 'list';
selected: boolean;
anySelected: boolean;
selectedCount: number;
isCut: boolean;
onOpen: (entry: DirEntry) => void;
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onDownload: (entry: DirEntry) => void;
onSelect: (entry: DirEntry, ev: React.MouseEvent) => void;
onCut: () => void;
onCopy: () => void;
forceRename: boolean;
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;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
type MenuItemsProps = {
entry: DirEntry;
multiSelected: boolean;
onDelete: (e: DirEntry) => void;
onStartRename: () => void;
onChat: (e: DirEntry) => void;
onDownload: (e: DirEntry) => void;
onReadAloud: (e: DirEntry) => void;
onOcr: (e: DirEntry) => void;
onTranscribe: (e: DirEntry) => void;
onExtractAudio: (e: DirEntry) => void;
onExtract: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (e: DirEntry) => void;
};
const DropdownMenuItems = ({
entry,
multiSelected,
onDelete,
onStartRename,
onChat,
onDownload,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
onCut,
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showOcr = fileType === 'image';
const showTranscribe = fileType === 'audio';
const showExtractAudio = fileType === 'video';
const showExtract = fileType === 'archive';
return (
<>
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
{showReadAloud && (
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</DropdownMenuItem>
)}
{showOcr && (
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
</DropdownMenuItem>
)}
{showTranscribe && (
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
</DropdownMenuItem>
)}
{showExtractAudio && (
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
</DropdownMenuItem>
)}
{showExtract && (
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</DropdownMenuItem>
)}
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{matchingTasks.map((task) => (
<DropdownMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{entry.type === 'directory' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</DropdownMenuItem>
{!multiSelected && (
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</>
);
};
const ContextMenuItems = ({
entry,
multiSelected,
onDelete,
onStartRename,
onChat,
onDownload,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
onCut,
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showOcr = fileType === 'image';
const showTranscribe = fileType === 'audio';
const showExtractAudio = fileType === 'video';
const showExtract = fileType === 'archive';
return (
<>
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
{showReadAloud && (
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</ContextMenuItem>
)}
{showOcr && (
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
</ContextMenuItem>
)}
{showTranscribe && (
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
</ContextMenuItem>
)}
{showExtractAudio && (
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
</ContextMenuItem>
)}
{showExtract && (
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</ContextMenuItem>
)}
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{matchingTasks.map((task) => (
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
{entry.type === 'directory' && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</ContextMenuItem>
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</ContextMenuItem>
{!multiSelected && (
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</>
);
};
const EllipsisMenu = (props: MenuItemsProps) => (
<div onClick={(ev) => ev.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<MoreVertical className="h-4 w-4 text-duck-dark/60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[600]" onCloseAutoFocus={(ev) => ev.preventDefault()}>
<DropdownMenuItems {...props} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
const InlineRenameInput = ({
initialName,
onCommit,
onCancel,
}: {
initialName: string;
onCommit: (name: string) => void;
onCancel: () => void;
}) => {
const [value, setValue] = useState(initialName);
const mountRef = useCallback((node: HTMLInputElement | null) => {
if (!node) return;
requestAnimationFrame(() => {
node.focus();
const dotIndex = initialName.lastIndexOf('.');
if (dotIndex > 0) {
node.setSelectionRange(0, dotIndex);
} else {
node.select();
}
});
}, []);
const commit = () => {
const trimmed = value.trim();
if (trimmed && trimmed !== initialName) {
onCommit(trimmed);
} else {
onCancel();
}
};
return (
<input
ref={mountRef}
value={value}
onChange={(ev) => setValue(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commit();
if (ev.key === 'Escape') onCancel();
}}
onClick={(ev) => ev.stopPropagation()}
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
/>
);
};
const Checkbox = ({
checked,
anySelected,
onClick,
}: {
checked: boolean;
anySelected: boolean;
onClick: (ev: React.MouseEvent) => void;
}) => (
<button
onClick={(ev) => {
ev.stopPropagation();
onClick(ev);
}}
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
>
{checked && <Check className="h-3 w-3" />}
</button>
);
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
};
export const FileItem = ({
entry,
viewMode,
selected,
anySelected,
selectedCount,
isCut,
onOpen,
onDelete,
onRename,
onChat,
onDownload,
onSelect,
onCut,
onCopy,
forceRename,
onRenamingChange,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: FileItemProps) => {
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory';
const icon = isDir ? (
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
) : (
<MaterialFileIcon name={entry.name} className="inline-flex h-5 w-5" />
);
const iconLarge = isDir ? (
<Folder className="h-10 w-10 text-duck-yellow fill-duck-yellow/30" />
) : (
<MaterialFileIcon name={entry.name} className="inline-flex h-10 w-10" />
);
useEffect(() => {
return () => {
if (clickTimer.current) clearTimeout(clickTimer.current);
};
}, []);
useEffect(() => {
if (forceRename) {
setRenaming(true);
onRenamingChange(null);
}
}, [forceRename]);
const handleCommitRename = (newName: string) => {
setRenaming(false);
onRename(entry, newName);
};
const handleClick = (ev: React.MouseEvent) => {
if (renaming) return;
// Modifier clicks select immediately (intentional multi-select)
if (ev.ctrlKey || ev.metaKey || ev.shiftKey) {
onSelect(entry, ev);
return;
}
// Delay plain click so double-click doesn't trigger selection + layout shift
const syntheticEv = { ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent;
clickTimer.current = setTimeout(() => {
clickTimer.current = null;
onSelect(entry, syntheticEv);
}, 200);
};
const handleDoubleClick = () => {
if (renaming) return;
if (clickTimer.current) {
clearTimeout(clickTimer.current);
clickTimer.current = null;
}
onOpen(entry);
};
const handleContextMenu = (ev: React.MouseEvent) => {
ev.stopPropagation();
if (!selected) {
onSelect(entry, { ...ev, ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent);
}
};
const menuProps: MenuItemsProps = {
entry,
multiSelected: selectedCount > 1,
onDelete,
onStartRename: () => setRenaming(true),
onChat,
onDownload,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
onCut,
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
};
const cutOpacity = isCut ? 'opacity-50' : '';
if (viewMode === 'list') {
return (
<ContextMenu>
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 data-[state=open]:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0 transition-colors ${
selected ? 'bg-duck-teal/10 border-duck-teal/50' : ''
} ${cutOpacity} select-none`}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
>
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{icon}</span>
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
)}
</div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
{isDir ? 'Folder' : getFileType(entry.name).replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
{isDir ? '--' : formatSize(entry.size)}
</span>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-28 text-right">
{formatDate(entry.modifiedAt)}
</span>
<div className="file-item-ellipsis opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 transition-opacity shrink-0">
<EllipsisMenu {...menuProps} />
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItems {...menuProps} />
</ContextMenuContent>
</ContextMenu>
);
}
return (
<ContextMenu>
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
} ${cutOpacity} select-none`}
style={
selected
? undefined
: cardStyle({
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backgroundImage: `
linear-gradient(to right, rgba(20, 83, 45, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20, 83, 45, 0.06) 1px, transparent 1px)
`,
})
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
>
<div className="absolute top-1 left-1">
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
</div>
<div className="file-item-ellipsis absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<EllipsisMenu {...menuProps} />
</div>
{iconLarge}
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
)}
<span className="text-xs text-duck-dark/50">
{isDir ? 'Folder' : formatSize(entry.size)}
{' · '}
{formatDate(entry.modifiedAt)}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItems {...menuProps} />
</ContextMenuContent>
</ContextMenu>
);
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,120 @@
import { useEffect, useRef } from 'react';
import { X } from 'lucide-react';
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
import * as DialogPrimitive from '@radix-ui/react-dialog';
import { cardStyle } from '@/components/Card';
import type { TaskInfo } from 'apps/Chat';
import { usePi, EmbeddableChat } from 'apps/Chat';
import { useVisiblePiModels } from '@/state/useModels';
import { useSettings } from '@/state/useSettings';
import type { TaskSummary } from './useTasks';
const playDing = () => {
const ctx = new AudioContext();
const t = ctx.currentTime;
const play = (freq: number, start: number, dur: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, t + start);
gain.gain.setValueAtTime(0.8, t + start);
gain.gain.setValueAtTime(0.8, t + start + dur * 0.6);
gain.gain.exponentialRampToValueAtTime(0.001, t + start + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(t + start);
osc.stop(t + start + dur);
};
play(880, 0, 0.4);
play(1100, 0.25, 0.4);
play(1320, 0.5, 0.6);
setTimeout(() => ctx.close(), 1500);
};
type PiMonoInnerProps = {
defaultInput: string;
cwd: { root?: string; path: string };
initialModel: string | null;
taskInfo: TaskInfo;
};
const PiMonoInner = ({
defaultInput,
cwd,
initialModel,
taskInfo,
}: PiMonoInnerProps) => {
const chat = usePi(undefined, initialModel, { replaceUrl: false, taskInfo });
const models = useVisiblePiModels();
const wasGenerating = useRef(false);
useEffect(() => {
if (wasGenerating.current && !chat.isGenerating) playDing();
wasGenerating.current = chat.isGenerating;
}, [chat.isGenerating]);
return (
<EmbeddableChat
chat={chat}
availableModels={models}
defaultInput={defaultInput}
cwd={cwd}
className="flex-1 min-h-0"
/>
);
};
type TaskRunnerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
task: TaskSummary;
entryName?: string;
entryType?: 'file' | 'directory';
cwd?: { root?: string; path: string };
promptOverride?: string;
};
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
const { settings } = useSettings();
const taskSettings = settings.tasks;
const defaultInput = promptOverride
?? (entryName && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
: `Read the task instructions at ${task.filePath} and execute them`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay className="z-[700] bg-black/60 backdrop-blur-sm" />
<DialogPrimitive.Content
onOpenAutoFocus={(ev) => ev.preventDefault()}
className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-3xl h-[80vh] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
style={cardStyle()}
>
{/* Header */}
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-background/60">
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
</div>
<DialogPrimitive.Close className="p-1.5 rounded-md text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5 transition-colors cursor-pointer">
<X className="h-4 w-4" />
</DialogPrimitive.Close>
</div>
{/* Chat */}
<PiMonoInner
key="pi"
defaultInput={defaultInput}
cwd={cwd}
initialModel={taskSettings.defaultProvider === 'pi' ? taskSettings.defaultModel : null}
taskInfo={taskInfo}
/>
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
};
+268
View File
@@ -0,0 +1,268 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate } from 'react-router';
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from './useFiles';
import { Breadcrumb } from './Breadcrumb';
import { useRecentFiles } from './useRecentFiles';
import { usePinnedFiles } from './usePinnedFiles';
import { Widget } from 'widgets/Widget';
type Tab = 'browse' | 'recent' | 'pinned';
const TABS: { key: Tab; label: string; icon: typeof Folder }[] = [
{ key: 'browse', label: 'Browse', icon: FolderOpen },
{ key: 'recent', label: 'Recent', icon: Clock },
{ key: 'pinned', label: 'Pinned', icon: Pin },
];
export const FileBrowser = () => {
const navigate = useNavigate();
const { listDir, search } = useFiles();
const { recents, addRecent } = useRecentFiles();
const { pinned, togglePin, isPinned } = usePinnedFiles();
const [tab, setTab] = useState<Tab>('browse');
const [browsePath, setBrowsePath] = useState('/');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[]>([]);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
setLoading(true);
listDir(browsePath)
.then((res) => setEntries(res.entries))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [browsePath]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const q = searchQuery.trim();
if (!q) {
setSearchResults([]);
return;
}
debounceRef.current = setTimeout(() => {
setSearching(true);
search(q)
.then((res) => setSearchResults(res.results))
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery]);
const openFile = (path: string, name: string) => {
addRecent(path, name);
navigate(`/files?view=${encodeURIComponent(path)}`);
};
const isSearching = searchQuery.trim().length > 0;
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<Widget title="File Browser">
{/* Tab bar + search */}
<div className="flex items-center gap-2 px-4 pb-1">
<div className="flex items-center gap-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
!isSearching && tab === key
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
<div className="relative ml-auto w-28 md:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
type="text"
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{isSearching ? (
searching ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
) : (
<ul className="space-y-0.5">
{searchResults.map((entry) => (
<EntryRow
key={entry.path}
name={entry.name}
subtitle={entry.path}
type={entry.type}
pinned={entry.type === 'file' && isPinned(entry.path!)}
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
onClick={() => {
if (entry.type === 'file') openFile(entry.path!, entry.name);
}}
/>
))}
</ul>
)
) : (
<>
{tab === 'browse' && (
<div>
<div className="py-2">
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
</div>
{loading ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : sorted.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
) : (
<ul className="space-y-0.5">
{sorted.map((entry) => {
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
return (
<EntryRow
key={entry.name}
name={entry.name}
type={entry.type}
pinned={entry.type === 'file' && isPinned(fullPath)}
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
onClick={() => {
if (entry.type === 'directory') setBrowsePath(fullPath);
else openFile(fullPath, entry.name);
}}
/>
);
})}
</ul>
)}
</div>
)}
{tab === 'recent' && (
<div className="pt-2">
{recents.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
) : (
<ul className="space-y-0.5">
{recents.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned={isPinned(f.path)}
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
{tab === 'pinned' && (
<div className="pt-2">
{pinned.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
) : (
<ul className="space-y-0.5">
{pinned.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
</Widget>
);
};
type EntryRowProps = {
name: string;
subtitle?: string;
type: 'file' | 'directory';
pinned?: boolean;
onPin?: () => void;
onClick: () => void;
};
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
};
const EntryRow = ({ name, subtitle, type, pinned, onPin, onClick }: EntryRowProps) => (
<li className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
{type === 'directory' ? (
<Folder className="h-4 w-4 shrink-0 text-duck-teal" />
) : (
<MaterialFileIcon name={name} className="inline-flex h-4 w-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{name}</span>
{subtitle && <span className="text-xs text-duck-dark/40 truncate block">{subtitle}</span>}
</div>
</button>
{onPin && (
<button
onClick={(ev) => {
ev.stopPropagation();
onPin();
}}
className={`shrink-0 p-1 rounded transition-colors cursor-pointer ${
pinned
? 'text-duck-teal hover:text-duck-teal/70'
: 'text-duck-dark/20 opacity-0 group-hover:opacity-100 hover:text-duck-dark/50'
}`}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
</li>
);
+7
View File
@@ -2,3 +2,10 @@ export { Breadcrumb } from './Breadcrumb';
export { Toolbar } from './Toolbar';
export { useFiles, type DirEntry } from './useFiles';
export { useTasks, type TaskSummary } from './useTasks';
export { Files } from './Files';
export { FileBrowser } from './Widget';
export { FileGrid } from './FileGrid';
export { FileItem, type FileItemProps } from './FileItem';
export { TaskRunnerModal } from './TaskRunnerModal';
export { useRecentFiles } from './useRecentFiles';
export { usePinnedFiles } from './usePinnedFiles';
@@ -0,0 +1,23 @@
import { useCallback } from 'react';
import { useUserState } from '@/state/useUserState';
type PinnedFile = { path: string; name: string; pinnedAt: number };
export const usePinnedFiles = () => {
const [pinned, setPinned] = useUserState<PinnedFile[]>('pinnedFiles', []);
const togglePin = useCallback(
(path: string, name: string) => {
setPinned((prev) => {
const exists = prev.some((f) => f.path === path);
if (exists) return prev.filter((f) => f.path !== path);
return [{ path, name, pinnedAt: Date.now() }, ...prev];
});
},
[setPinned],
);
const isPinned = useCallback((path: string) => pinned.some((f) => f.path === path), [pinned]);
return { pinned, togglePin, isPinned };
};
@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { useUserState } from '@/state/useUserState';
type RecentFile = { path: string; name: string; openedAt: number };
const MAX_RECENTS = 20;
export const useRecentFiles = () => {
const [recents, setRecents] = useUserState<RecentFile[]>('recentFiles', []);
const addRecent = useCallback(
(path: string, name: string) => {
setRecents((prev) => {
const filtered = prev.filter((f) => f.path !== path);
return [{ path, name, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
});
},
[setRecents],
);
return { recents, addRecent };
};