483 lines
15 KiB
TypeScript
483 lines
15 KiB
TypeScript
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } 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, TaskSummary } from 'apps/FileBrowser';
|
|
|
|
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;
|
|
matchingTasks: TaskSummary[];
|
|
onRunTask: (task: TaskSummary, 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;
|
|
onCut: () => void;
|
|
onCopy: () => void;
|
|
matchingTasks: TaskSummary[];
|
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
|
};
|
|
|
|
const DropdownMenuItems = ({
|
|
entry,
|
|
multiSelected,
|
|
onDelete,
|
|
onStartRename,
|
|
onChat,
|
|
onDownload,
|
|
onCut,
|
|
onCopy,
|
|
matchingTasks,
|
|
onRunTask,
|
|
}: MenuItemsProps) => (
|
|
<>
|
|
<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>
|
|
{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>
|
|
)}
|
|
<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,
|
|
onCut,
|
|
onCopy,
|
|
matchingTasks,
|
|
onRunTask,
|
|
}: MenuItemsProps) => (
|
|
<>
|
|
<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>
|
|
{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>
|
|
)}
|
|
<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,
|
|
matchingTasks,
|
|
onRunTask,
|
|
}: 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,
|
|
onCut,
|
|
onCopy,
|
|
matchingTasks,
|
|
onRunTask,
|
|
};
|
|
|
|
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 ? '--' : 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>
|
|
);
|
|
};
|