extracted all File components to apps/FileBrowser
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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 };
|
||||
};
|
||||
Reference in New Issue
Block a user