Files FIles Files
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||
import { getFileType } from 'apps/FileViewer';
|
||||
import { FileItem } from './FileItem';
|
||||
|
||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||
|
||||
type SortField = 'name' | 'size' | 'type' | 'date';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
type FileGridProps = {
|
||||
entries: DirEntry[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -21,6 +26,11 @@ type FileGridProps = {
|
||||
onCopy: () => void;
|
||||
renamingName: string | null;
|
||||
onRenamingChange: (name: string | null) => void;
|
||||
onReadAloud: (entry: DirEntry) => void;
|
||||
onOcr: (entry: DirEntry) => void;
|
||||
onTranscribe: (entry: DirEntry) => void;
|
||||
onExtractAudio: (entry: DirEntry) => void;
|
||||
onExtract: (entry: DirEntry) => void;
|
||||
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
onCreateWorkspace: (entry: DirEntry) => void;
|
||||
@@ -30,6 +40,7 @@ type FileGridProps = {
|
||||
const LIST_ROW_HEIGHT = 42;
|
||||
const GRID_ROW_HEIGHT = 130;
|
||||
const GRID_GAP = 12;
|
||||
const HEADER_HEIGHT = 36;
|
||||
|
||||
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
const [cols, setCols] = useState(4);
|
||||
@@ -71,21 +82,55 @@ export const FileGrid = ({
|
||||
onCopy,
|
||||
renamingName,
|
||||
onRenamingChange,
|
||||
onReadAloud,
|
||||
onOcr,
|
||||
onTranscribe,
|
||||
onExtractAudio,
|
||||
onExtract,
|
||||
getMatchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
scrollRef,
|
||||
}: FileGridProps) => {
|
||||
const lastClickedIdx = useRef<number>(-1);
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...entries].sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
}),
|
||||
[entries],
|
||||
);
|
||||
const sorted = useMemo(() => {
|
||||
const compare = (a: DirEntry, b: DirEntry): number => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
let result: number;
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
result = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'size':
|
||||
result = a.size - b.size;
|
||||
break;
|
||||
case 'type': {
|
||||
const typeA = a.type === 'directory' ? 'directory' : getFileType(a.name);
|
||||
const typeB = b.type === 'directory' ? 'directory' : getFileType(b.name);
|
||||
result = typeA.localeCompare(typeB);
|
||||
if (result === 0) result = a.name.localeCompare(b.name);
|
||||
break;
|
||||
}
|
||||
case 'date':
|
||||
result = a.modifiedAt - b.modifiedAt;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
};
|
||||
return [...entries].sort(compare);
|
||||
}, [entries, sortField, sortDirection]);
|
||||
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(entry: DirEntry, ev: React.MouseEvent) => {
|
||||
@@ -128,6 +173,7 @@ export const FileGrid = ({
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
|
||||
overscan: 10,
|
||||
scrollMargin: HEADER_HEIGHT,
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -153,44 +199,117 @@ export const FileGrid = ({
|
||||
onCopy={onCopy}
|
||||
forceRename={renamingName === entry.name}
|
||||
onRenamingChange={onRenamingChange}
|
||||
onReadAloud={onReadAloud}
|
||||
onOcr={onOcr}
|
||||
onTranscribe={onTranscribe}
|
||||
onExtractAudio={onExtractAudio}
|
||||
onExtract={onExtract}
|
||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||
onRunTask={onRunTask}
|
||||
onCreateWorkspace={onCreateWorkspace}
|
||||
/>
|
||||
);
|
||||
|
||||
const sortIcon = (field: SortField) =>
|
||||
sortField === field ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)
|
||||
) : null;
|
||||
|
||||
const sortBtnClass = (field: SortField) =>
|
||||
`flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors ${
|
||||
sortField === field ? 'text-duck-teal' : 'text-duck-dark/50 hover:text-duck-dark/80'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
if (viewMode === 'list') {
|
||||
const entry = sorted[virtualRow.index]!;
|
||||
<>
|
||||
{viewMode === 'list' ? (
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-3 px-3"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className="shrink-0 w-5" />
|
||||
<span className="shrink-0 w-5" />
|
||||
<button className={`flex-1 min-w-0 ${sortBtnClass('name')}`} onClick={() => toggleSort('name')}>
|
||||
Name {sortIcon('name')}
|
||||
</button>
|
||||
<button
|
||||
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('type')}`}
|
||||
onClick={() => toggleSort('type')}
|
||||
>
|
||||
Type {sortIcon('type')}
|
||||
</button>
|
||||
<button
|
||||
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('size')}`}
|
||||
onClick={() => toggleSort('size')}
|
||||
>
|
||||
Size {sortIcon('size')}
|
||||
</button>
|
||||
<button
|
||||
className={`hidden md:flex shrink-0 w-28 justify-end ${sortBtnClass('date')}`}
|
||||
onClick={() => toggleSort('date')}
|
||||
>
|
||||
Date {sortIcon('date')}
|
||||
</button>
|
||||
<span className="shrink-0 w-8" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-2 px-3"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className="text-xs text-duck-dark/40">Sort:</span>
|
||||
{(['name', 'size', 'type', 'date'] as SortField[]).map((field) => (
|
||||
<button
|
||||
key={field}
|
||||
onClick={() => toggleSort(field)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs cursor-pointer transition-colors ${
|
||||
sortField === field
|
||||
? 'bg-duck-teal/10 text-duck-teal font-medium'
|
||||
: 'text-duck-dark/50 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
{field.charAt(0).toUpperCase() + field.slice(1)}
|
||||
{sortIcon(field)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
if (viewMode === 'list') {
|
||||
const entry = sorted[virtualRow.index]!;
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 w-full"
|
||||
style={{ top: virtualRow.start, height: virtualRow.size }}
|
||||
>
|
||||
{renderItem(entry)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startIdx = virtualRow.index * cols;
|
||||
const rowEntries = sorted.slice(startIdx, startIdx + cols);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 w-full"
|
||||
style={{ top: virtualRow.start, height: virtualRow.size }}
|
||||
className="absolute left-0 w-full grid gap-3"
|
||||
style={{
|
||||
top: virtualRow.start,
|
||||
height: virtualRow.size - GRID_GAP,
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{renderItem(entry)}
|
||||
{rowEntries.map(renderItem)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const startIdx = virtualRow.index * cols;
|
||||
const rowEntries = sorted.slice(startIdx, startIdx + cols);
|
||||
return (
|
||||
<div
|
||||
key={virtualRow.key}
|
||||
className="absolute left-0 w-full grid gap-3"
|
||||
style={{
|
||||
top: virtualRow.start,
|
||||
height: virtualRow.size - GRID_GAP,
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{rowEntries.map(renderItem)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid } from 'lucide-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,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@/components/ui/context-menu';
|
||||
import { cardStyle } from '@/components/Card';
|
||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||
import { getFileType } from 'apps/FileViewer';
|
||||
|
||||
export type FileItemProps = {
|
||||
entry: DirEntry;
|
||||
@@ -41,6 +42,11 @@ export type FileItemProps = {
|
||||
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;
|
||||
@@ -67,6 +73,11 @@ type MenuItemsProps = {
|
||||
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[];
|
||||
@@ -81,12 +92,25 @@ const DropdownMenuItems = ({
|
||||
onStartRename,
|
||||
onChat,
|
||||
onDownload,
|
||||
onReadAloud,
|
||||
onOcr,
|
||||
onTranscribe,
|
||||
onExtractAudio,
|
||||
onExtract,
|
||||
onCut,
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
}: MenuItemsProps) => (
|
||||
}: 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" />
|
||||
@@ -96,6 +120,36 @@ const DropdownMenuItems = ({
|
||||
<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">
|
||||
@@ -141,7 +195,8 @@ const DropdownMenuItems = ({
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const ContextMenuItems = ({
|
||||
entry,
|
||||
@@ -150,12 +205,25 @@ const ContextMenuItems = ({
|
||||
onStartRename,
|
||||
onChat,
|
||||
onDownload,
|
||||
onReadAloud,
|
||||
onOcr,
|
||||
onTranscribe,
|
||||
onExtractAudio,
|
||||
onExtract,
|
||||
onCut,
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
}: MenuItemsProps) => (
|
||||
}: 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" />
|
||||
@@ -165,6 +233,36 @@ const ContextMenuItems = ({
|
||||
<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">
|
||||
@@ -210,7 +308,8 @@ const ContextMenuItems = ({
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const EllipsisMenu = (props: MenuItemsProps) => (
|
||||
<div onClick={(ev) => ev.stopPropagation()}>
|
||||
@@ -320,6 +419,11 @@ export const FileItem = ({
|
||||
onCopy,
|
||||
forceRename,
|
||||
onRenamingChange,
|
||||
onReadAloud,
|
||||
onOcr,
|
||||
onTranscribe,
|
||||
onExtractAudio,
|
||||
onExtract,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
@@ -395,6 +499,11 @@ export const FileItem = ({
|
||||
onStartRename: () => setRenaming(true),
|
||||
onChat,
|
||||
onDownload,
|
||||
onReadAloud,
|
||||
onOcr,
|
||||
onTranscribe,
|
||||
onExtractAudio,
|
||||
onExtract,
|
||||
onCut,
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
@@ -430,6 +539,9 @@ export const FileItem = ({
|
||||
<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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,6 @@ import {
|
||||
Loader2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
|
||||
ClipboardPaste,
|
||||
FolderPlus,
|
||||
Search,
|
||||
@@ -18,15 +17,16 @@ import {
|
||||
Code,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { getIcon } from 'material-file-icons';
|
||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { FileGrid } from './FileGrid';
|
||||
import { FileViewer } from './FileViewer';
|
||||
import { TaskRunnerModal } from './TaskRunnerModal';
|
||||
|
||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||
@@ -39,6 +39,7 @@ type FilesProps = {
|
||||
|
||||
export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
const { user } = useAuth();
|
||||
const client = useClient();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||
@@ -58,13 +59,14 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
const [showCloneInput, setShowCloneInput] = useState(false);
|
||||
const [cloneUrl, setCloneUrl] = useState('');
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
|
||||
const dragCounter = useRef(0);
|
||||
const { getMatchingTasks } = useTasks();
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const viewPath = searchParams.get('view');
|
||||
const viewerFileName = viewPath ? viewPath.split('/').pop()! : '';
|
||||
const files = useFiles(homeRoot);
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
@@ -174,7 +176,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (fileList: FileList) => {
|
||||
const handleUpload = async (fileList: FileList | File[]) => {
|
||||
const count = fileList.length;
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
@@ -286,6 +288,67 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
navigate(`/workspaces/new?${params}`);
|
||||
};
|
||||
|
||||
const handleReadAloud = async (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
const toastId = toast.loading('Generating speech audio...');
|
||||
try {
|
||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root: homeRoot });
|
||||
toast.dismiss(toastId);
|
||||
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
|
||||
} catch {
|
||||
toast.error('Failed to generate speech audio', { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleOcr = async (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
const toastId = toast.loading('Extracting text from image...');
|
||||
try {
|
||||
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root: homeRoot });
|
||||
toast.dismiss(toastId);
|
||||
setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot });
|
||||
} catch {
|
||||
toast.error('Failed to extract text from image', { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleTranscribe = async (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
const toastId = toast.loading('Transcribing audio...');
|
||||
try {
|
||||
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root: homeRoot });
|
||||
toast.dismiss(toastId);
|
||||
setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot });
|
||||
} catch {
|
||||
toast.error('Failed to transcribe audio', { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtractAudio = async (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
const toastId = toast.loading('Extracting audio from video...');
|
||||
try {
|
||||
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root: homeRoot });
|
||||
toast.dismiss(toastId);
|
||||
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
|
||||
} catch {
|
||||
toast.error('Failed to extract audio from video', { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleExtract = async (entry: DirEntry) => {
|
||||
const filePath = entryPath(entry.name);
|
||||
const toastId = toast.loading('Extracting archive...');
|
||||
try {
|
||||
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root: homeRoot });
|
||||
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
||||
toast.success(`Extracted to "${folderName}"`, { id: toastId });
|
||||
await refresh();
|
||||
} catch {
|
||||
toast.error('Failed to extract archive', { id: toastId });
|
||||
}
|
||||
};
|
||||
|
||||
const handleGitClone = async () => {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
@@ -318,21 +381,86 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
if (!clipboard) return;
|
||||
// Internal file clipboard takes priority
|
||||
if (clipboard) {
|
||||
try {
|
||||
if (clipboard.mode === 'copy') {
|
||||
await files.copy(clipboard.paths, currentPath);
|
||||
} else {
|
||||
await files.move(clipboard.paths, currentPath);
|
||||
setClipboard(null);
|
||||
}
|
||||
await refresh();
|
||||
toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toast.error('Failed to paste');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Try system clipboard for images/files
|
||||
try {
|
||||
if (clipboard.mode === 'copy') {
|
||||
await files.copy(clipboard.paths, currentPath);
|
||||
} else {
|
||||
await files.move(clipboard.paths, currentPath);
|
||||
setClipboard(null);
|
||||
const clipboardItems = await navigator.clipboard.read();
|
||||
const imageFiles: File[] = [];
|
||||
for (const item of clipboardItems) {
|
||||
for (const type of item.types) {
|
||||
if (type.startsWith('image/')) {
|
||||
const blob = await item.getType(type);
|
||||
const ext = type.split('/')[1] ?? 'png';
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
imageFiles.push(new File([blob], `clipboard-${ts}.${ext}`, { type }));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (imageFiles.length > 0) {
|
||||
const dt = new DataTransfer();
|
||||
imageFiles.forEach((f) => dt.items.add(f));
|
||||
await handleUpload(dt.files);
|
||||
}
|
||||
await refresh();
|
||||
toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toast.error('Failed to paste');
|
||||
// Clipboard API not available or permission denied — silently ignore
|
||||
}
|
||||
};
|
||||
|
||||
// Clipboard image paste
|
||||
useEffect(() => {
|
||||
const handler = (ev: ClipboardEvent) => {
|
||||
const tag = (ev.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
|
||||
const items = ev.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
const imageFiles: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i]!;
|
||||
if (item.type.startsWith('image/')) {
|
||||
const file = item.getAsFile();
|
||||
if (file) imageFiles.push(file);
|
||||
}
|
||||
}
|
||||
if (imageFiles.length === 0) return;
|
||||
|
||||
ev.preventDefault();
|
||||
const now = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
|
||||
const dt = new DataTransfer();
|
||||
imageFiles.forEach((file, i) => {
|
||||
const ext = file.type.split('/')[1] ?? 'png';
|
||||
const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`;
|
||||
dt.items.add(new File([file], name, { type: file.type }));
|
||||
});
|
||||
handleUpload(dt.files);
|
||||
};
|
||||
|
||||
window.addEventListener('paste', handler);
|
||||
return () => window.removeEventListener('paste', handler);
|
||||
}, [currentPath, homeRoot]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
@@ -378,8 +506,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
}
|
||||
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
|
||||
ev.preventDefault();
|
||||
handlePaste();
|
||||
if (clipboard) {
|
||||
ev.preventDefault();
|
||||
handlePaste();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -409,6 +539,91 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
|
||||
|
||||
const handleDragEnter = (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
dragCounter.current++;
|
||||
if (ev.dataTransfer.types.includes('Files')) {
|
||||
setDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
dragCounter.current--;
|
||||
if (dragCounter.current === 0) {
|
||||
setDragging(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
const handleDrop = async (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
dragCounter.current = 0;
|
||||
setDragging(false);
|
||||
|
||||
const items = ev.dataTransfer.items;
|
||||
if (!items || items.length === 0) return;
|
||||
|
||||
// Collect FileSystemEntry objects (supports folders)
|
||||
const entries: FileSystemEntry[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const entry = items[i]!.webkitGetAsEntry?.();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
|
||||
// If no entries (browser doesn't support webkitGetAsEntry), fall back to files
|
||||
if (entries.length === 0) {
|
||||
if (ev.dataTransfer.files.length > 0) {
|
||||
handleUpload(ev.dataTransfer.files);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Recursively read all files from entries
|
||||
const allFiles: File[] = [];
|
||||
|
||||
const readEntry = (entry: FileSystemEntry, path: string): Promise<void> => {
|
||||
if (entry.isFile) {
|
||||
return new Promise((resolve) => {
|
||||
(entry as FileSystemFileEntry).file((file) => {
|
||||
allFiles.push(new File([file], path + file.name, { type: file.type, lastModified: file.lastModified }));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
return new Promise((resolve) => {
|
||||
const readBatch = () => {
|
||||
reader.readEntries(async (batch) => {
|
||||
if (batch.length === 0) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
for (const child of batch) {
|
||||
await readEntry(child, path + entry.name + '/');
|
||||
}
|
||||
readBatch(); // readEntries may not return all at once
|
||||
});
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
await readEntry(entry, '');
|
||||
}
|
||||
|
||||
if (allFiles.length > 0) {
|
||||
handleUpload(allFiles);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBackgroundClick = (ev: React.MouseEvent) => {
|
||||
if ((ev.target as HTMLElement).closest('[data-file-item]')) return;
|
||||
setSelected(new Set());
|
||||
@@ -583,7 +798,21 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
)}
|
||||
|
||||
{/* File content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div
|
||||
className="flex-1 min-h-0 relative"
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{dragging && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-duck-teal/10 border-2 border-dashed border-duck-teal rounded-lg m-2 pointer-events-none">
|
||||
<div className="flex flex-col items-center gap-2 text-duck-teal">
|
||||
<Upload className="h-8 w-8" />
|
||||
<span className="text-sm font-medium">Drop files to upload</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{searchQuery.trim() ? (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
{searching ? (
|
||||
@@ -647,6 +876,11 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
onCopy={handleCopy}
|
||||
renamingName={renamingName}
|
||||
onRenamingChange={setRenamingName}
|
||||
onReadAloud={handleReadAloud}
|
||||
onOcr={handleOcr}
|
||||
onTranscribe={handleTranscribe}
|
||||
onExtractAudio={handleExtractAudio}
|
||||
onExtract={handleExtract}
|
||||
getMatchingTasks={getMatchingTasks}
|
||||
onRunTask={handleRunTask}
|
||||
onCreateWorkspace={handleCreateWorkspace}
|
||||
@@ -656,7 +890,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-[600]">
|
||||
<ContextMenuItem onClick={handlePaste} disabled={!clipboard} className="cursor-pointer">
|
||||
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
@@ -680,16 +914,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileViewer
|
||||
open={viewPath !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSearchParams({});
|
||||
}}
|
||||
filePath={viewPath ?? ''}
|
||||
fileName={viewerFileName}
|
||||
root={homeRoot}
|
||||
/>
|
||||
|
||||
{runningTask && (
|
||||
<TaskRunnerModal
|
||||
open
|
||||
|
||||
@@ -1,23 +1,218 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||
import { WorkspaceLayout } from '@/components/Workspace';
|
||||
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { appRegistry } from '../Workspaces/app-registry';
|
||||
import { Files as FilesInner } from './Screen';
|
||||
|
||||
export { FilesInner as Files };
|
||||
|
||||
const layout: LayoutNode = {
|
||||
const baseLayout: LayoutNode = {
|
||||
type: 'panel',
|
||||
id: 'files-main',
|
||||
appType: null,
|
||||
};
|
||||
|
||||
const viewerLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'files-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'files-main', appType: null }, size: 40 },
|
||||
{ node: { type: 'panel', id: 'files-viewer', appType: null }, size: 60 },
|
||||
],
|
||||
};
|
||||
|
||||
const viewerWithEphemeralLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'files-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'files-main', appType: null }, size: 40 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'files-viewer-group',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'files-viewer', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'files-ephemeral', appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const viewerWithEphemeralSplitLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'files-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'files-main', appType: null }, size: 40 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'files-viewer-group',
|
||||
direction: 'vertical',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'files-viewer', appType: null }, size: 50 },
|
||||
{
|
||||
node: {
|
||||
type: 'group',
|
||||
id: 'files-ephemeral-group',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'files-ephemeral', appType: null }, size: 50 },
|
||||
{ node: { type: 'panel', id: 'files-ephemeral2', appType: null }, size: 50 },
|
||||
],
|
||||
},
|
||||
size: 50,
|
||||
},
|
||||
],
|
||||
},
|
||||
size: 60,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const FilesFileViewerProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [homeRoot] = useUserState<string>('files/homeRoot', 'home');
|
||||
const viewPath = searchParams.get('view');
|
||||
const fileName = viewPath ? viewPath.split('/').pop()! : '';
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string, root: string) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('ephemeral', filePath);
|
||||
next.set('ephemeralRoot', root);
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
if (!viewPath) return null;
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={viewPath} fileName={fileName} root={homeRoot} onOpenFile={handleOpenFile}>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EphemeralFileViewerProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const ephemeralPath = searchParams.get('ephemeral');
|
||||
const ephemeralRoot = searchParams.get('ephemeralRoot') ?? 'home';
|
||||
const fileName = ephemeralPath ? ephemeralPath.split('/').pop()! : '';
|
||||
|
||||
const handleOpenFile = useCallback(
|
||||
(filePath: string, root: string) => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.set('ephemeral2', filePath);
|
||||
next.set('ephemeral2Root', root);
|
||||
next.set('ephemeral2Auto', '1');
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setSearchParams],
|
||||
);
|
||||
|
||||
if (!ephemeralPath) return null;
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={ephemeralPath} fileName={fileName} root={ephemeralRoot} onOpenFile={handleOpenFile}>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const Ephemeral2FileViewerProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const path = searchParams.get('ephemeral2');
|
||||
const root = searchParams.get('ephemeral2Root') ?? 'home';
|
||||
const autoPlay = searchParams.get('ephemeral2Auto') === '1';
|
||||
const fileName = path ? path.split('/').pop()! : '';
|
||||
|
||||
if (!path) return null;
|
||||
|
||||
return (
|
||||
<FileViewerProvider filePath={path} fileName={fileName} root={root} autoPlay={autoPlay}>
|
||||
{children}
|
||||
</FileViewerProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export const FilesPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const viewPath = searchParams.get('view');
|
||||
const ephemeralPath = searchParams.get('ephemeral');
|
||||
const ephemeral2Path = searchParams.get('ephemeral2');
|
||||
|
||||
const layout = useMemo(
|
||||
() =>
|
||||
viewPath && ephemeralPath && ephemeral2Path
|
||||
? viewerWithEphemeralSplitLayout
|
||||
: viewPath && ephemeralPath
|
||||
? viewerWithEphemeralLayout
|
||||
: viewPath
|
||||
? viewerLayout
|
||||
: baseLayout,
|
||||
[viewPath, ephemeralPath, ephemeral2Path],
|
||||
);
|
||||
|
||||
const closeEphemeral = useCallback(() => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('ephemeral');
|
||||
next.delete('ephemeralRoot');
|
||||
next.delete('ephemeral2');
|
||||
next.delete('ephemeral2Root');
|
||||
next.delete('ephemeral2Auto');
|
||||
return next;
|
||||
});
|
||||
}, [setSearchParams]);
|
||||
|
||||
const closeEphemeral2 = useCallback(() => {
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
next.delete('ephemeral2');
|
||||
next.delete('ephemeral2Root');
|
||||
next.delete('ephemeral2Auto');
|
||||
return next;
|
||||
});
|
||||
}, [setSearchParams]);
|
||||
|
||||
const panelComponents: PanelComponents = useMemo(
|
||||
() => ({
|
||||
'files-main': FilesInner,
|
||||
'files-viewer': {
|
||||
provider: FilesFileViewerProvider,
|
||||
header: FileViewerHeader,
|
||||
component: FileViewerBody,
|
||||
onClose: () => setSearchParams({}),
|
||||
},
|
||||
'files-ephemeral': {
|
||||
provider: EphemeralFileViewerProvider,
|
||||
header: FileViewerHeader,
|
||||
component: FileViewerBody,
|
||||
onClose: closeEphemeral,
|
||||
},
|
||||
'files-ephemeral2': {
|
||||
provider: Ephemeral2FileViewerProvider,
|
||||
header: FileViewerHeader,
|
||||
component: FileViewerBody,
|
||||
onClose: closeEphemeral2,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
[setSearchParams, closeEphemeral, closeEphemeral2],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user