File browser as widget
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import { useRef, useCallback } from 'react';
|
||||
import type { DirEntry, TaskSummary } from 'widgets/FileBrowser';
|
||||
import { FileItem } from './FileItem';
|
||||
|
||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||
|
||||
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;
|
||||
onSelect: (names: Set<string>) => void;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
renamingName: string | null;
|
||||
onRenamingChange: (name: string | null) => void;
|
||||
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
};
|
||||
|
||||
export const FileGrid = ({
|
||||
entries,
|
||||
viewMode,
|
||||
currentPath,
|
||||
selected,
|
||||
clipboard,
|
||||
onOpen,
|
||||
onDelete,
|
||||
onRename,
|
||||
onChat,
|
||||
onSelect,
|
||||
onCut,
|
||||
onCopy,
|
||||
renamingName,
|
||||
onRenamingChange,
|
||||
getMatchingTasks,
|
||||
onRunTask,
|
||||
}: FileGridProps) => {
|
||||
const lastClickedIdx = useRef<number>(-1);
|
||||
|
||||
// Sort: directories first, then files, alphabetically within each group
|
||||
const sorted = [...entries].sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
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 {
|
||||
// Plain select (from context menu or checkbox click without modifiers)
|
||||
onSelect(new Set([entry.name]));
|
||||
lastClickedIdx.current = idx;
|
||||
}
|
||||
},
|
||||
[sorted, selected, onSelect],
|
||||
);
|
||||
|
||||
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
|
||||
const anySelected = selected.size > 0;
|
||||
|
||||
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 entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
|
||||
const items = sorted.map((entry) => (
|
||||
<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}
|
||||
onSelect={handleSelect}
|
||||
onCut={onCut}
|
||||
onCopy={onCopy}
|
||||
forceRename={renamingName === entry.name}
|
||||
onRenamingChange={onRenamingChange}
|
||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||
onRunTask={onRunTask}
|
||||
/>
|
||||
));
|
||||
|
||||
if (viewMode === 'list') {
|
||||
return <div className="file-grid flex flex-col">{items}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="file-grid grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3">
|
||||
{items}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,468 @@
|
||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play } 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 'widgets/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;
|
||||
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;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
matchingTasks: TaskSummary[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
};
|
||||
|
||||
const DropdownMenuItems = ({
|
||||
entry,
|
||||
multiSelected,
|
||||
onDelete,
|
||||
onStartRename,
|
||||
onChat,
|
||||
onCut,
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
}: MenuItemsProps) => (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Chat...
|
||||
</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,
|
||||
onCut,
|
||||
onCopy,
|
||||
matchingTasks,
|
||||
onRunTask,
|
||||
}: MenuItemsProps) => (
|
||||
<>
|
||||
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||
<MessageSquare className="mr-2 h-4 w-4" />
|
||||
Chat...
|
||||
</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-white 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-white/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,
|
||||
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,
|
||||
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>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
import { useState, 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 { useClaude, useOpenCode, EmbeddableChat, type TaskInfo } from 'plugins/Chat/client';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import type { TaskSummary } from 'widgets/FileBrowser';
|
||||
|
||||
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 InnerProps = {
|
||||
defaultInput: string;
|
||||
cwd: { root?: string; path: string };
|
||||
initialModel: string | null;
|
||||
onProviderChange: (p: 'claude' | 'opencode') => void;
|
||||
};
|
||||
|
||||
const ClaudeInner = ({
|
||||
defaultInput,
|
||||
cwd,
|
||||
initialModel,
|
||||
onProviderChange,
|
||||
taskInfo,
|
||||
}: InnerProps & { taskInfo: TaskInfo }) => {
|
||||
const chat = useClaude(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const models = useVisibleClaudeModels();
|
||||
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !chat.isGenerating) playDing();
|
||||
wasGenerating.current = chat.isGenerating;
|
||||
}, [chat.isGenerating]);
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
provider="claude"
|
||||
availableModels={models}
|
||||
onProviderChange={onProviderChange}
|
||||
defaultInput={defaultInput}
|
||||
cwd={cwd}
|
||||
className="flex-1 min-h-0"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const OpenCodeInner = ({
|
||||
defaultInput,
|
||||
cwd,
|
||||
initialModel,
|
||||
onProviderChange,
|
||||
taskInfo,
|
||||
}: InnerProps & { taskInfo: TaskInfo }) => {
|
||||
const chat = useOpenCode(undefined, initialModel, { replaceUrl: false, taskInfo });
|
||||
const models = useVisibleOpenCodeModels();
|
||||
|
||||
const wasGenerating = useRef(false);
|
||||
useEffect(() => {
|
||||
if (wasGenerating.current && !chat.isGenerating) playDing();
|
||||
wasGenerating.current = chat.isGenerating;
|
||||
}, [chat.isGenerating]);
|
||||
|
||||
return (
|
||||
<EmbeddableChat
|
||||
chat={chat}
|
||||
provider="opencode"
|
||||
availableModels={models}
|
||||
onProviderChange={onProviderChange}
|
||||
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 };
|
||||
};
|
||||
|
||||
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd }: TaskRunnerModalProps) => {
|
||||
const { settings } = useSettings();
|
||||
const taskSettings = settings.tasks;
|
||||
const [provider, setProvider] = useState<'claude' | 'opencode'>(taskSettings.defaultProvider);
|
||||
const defaultInput = `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`;
|
||||
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName, entryType };
|
||||
|
||||
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({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.97)',
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
|
||||
`,
|
||||
})}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-white/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 */}
|
||||
{provider === 'claude' ? (
|
||||
<ClaudeInner
|
||||
key="claude"
|
||||
defaultInput={defaultInput}
|
||||
cwd={cwd}
|
||||
initialModel={taskSettings.defaultProvider === 'claude' ? taskSettings.defaultModel : null}
|
||||
onProviderChange={setProvider}
|
||||
taskInfo={taskInfo}
|
||||
/>
|
||||
) : (
|
||||
<OpenCodeInner
|
||||
key="opencode"
|
||||
defaultInput={defaultInput}
|
||||
cwd={cwd}
|
||||
initialModel={taskSettings.defaultProvider === 'opencode' ? taskSettings.defaultModel : null}
|
||||
onProviderChange={setProvider}
|
||||
taskInfo={taskInfo}
|
||||
/>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,671 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
Loader2,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
ClipboardPaste,
|
||||
FolderPlus,
|
||||
Search,
|
||||
X,
|
||||
Check,
|
||||
Folder,
|
||||
GitBranch,
|
||||
Home,
|
||||
FolderRoot,
|
||||
Code,
|
||||
Eye,
|
||||
EyeOff,
|
||||
} 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 'widgets/FileBrowser';
|
||||
import { useUserState } from '@/state/useUserState';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Card } from '@/components/Card';
|
||||
import { FileGrid } from './FileGrid';
|
||||
import { FileViewer } from './FileViewer';
|
||||
import { TaskRunnerModal } from './TaskRunnerModal';
|
||||
|
||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||
|
||||
type HomeRoot = 'home' | '~' | 'officer.dev';
|
||||
|
||||
export const Files = () => {
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||
const [currentPath, setCurrentPath] = useUserState<string>('files/currentPath', '/');
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
|
||||
const [fullscreen, setFullscreen] = useUserState<boolean>('files/fullscreen', false);
|
||||
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [clipboard, setClipboard] = useState<ClipboardState>(null);
|
||||
const [renamingName, setRenamingName] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<DirEntry[] | null>(null);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [showCloneInput, setShowCloneInput] = useState(false);
|
||||
const [cloneUrl, setCloneUrl] = useState('');
|
||||
const [cloning, setCloning] = useState(false);
|
||||
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
|
||||
const { getMatchingTasks } = useTasks();
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const viewPath = searchParams.get('view');
|
||||
const viewerFileName = viewPath ? viewPath.split('/').pop()! : '';
|
||||
const files = useFiles(homeRoot);
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
const currentPathRef = useRef(currentPath);
|
||||
currentPathRef.current = currentPath;
|
||||
const visibleEntries = showHidden ? entries : entries.filter((e) => !e.name.startsWith('.'));
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await filesRef.current.listDir(currentPathRef.current);
|
||||
if (data.reset) {
|
||||
setCurrentPath('/');
|
||||
return;
|
||||
}
|
||||
setEntries(data.entries);
|
||||
} catch (err: any) {
|
||||
console.error('[FileBrowser] refresh error:', err);
|
||||
toast.error(err?.message || 'Failed to load directory');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, [currentPath, homeRoot]);
|
||||
|
||||
// Clear selection when navigating
|
||||
useEffect(() => {
|
||||
setSelected(new Set());
|
||||
}, [currentPath]);
|
||||
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
const q = searchQuery.trim();
|
||||
if (!q) {
|
||||
setSearchResults(null);
|
||||
setSearching(false);
|
||||
return;
|
||||
}
|
||||
setSearching(true);
|
||||
searchTimerRef.current = setTimeout(async () => {
|
||||
try {
|
||||
const data = await filesRef.current.search(q);
|
||||
setSearchResults(data.results);
|
||||
} catch {
|
||||
toast.error('Search failed');
|
||||
setSearchResults(null);
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleSearchResultClick = (entry: DirEntry) => {
|
||||
if (!entry.path) return;
|
||||
if (entry.type === 'directory') {
|
||||
setCurrentPath(entry.path);
|
||||
} else {
|
||||
const parentPath = entry.path.substring(0, entry.path.lastIndexOf('/')) || '/';
|
||||
setCurrentPath(parentPath);
|
||||
setSearchParams({ view: entry.path });
|
||||
}
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
|
||||
|
||||
const selectedPaths = () => Array.from(selected).map(entryPath);
|
||||
|
||||
const handleNavigate = (path: string) => {
|
||||
setCurrentPath(path);
|
||||
};
|
||||
|
||||
const handleOpen = (entry: DirEntry) => {
|
||||
setSelected(new Set());
|
||||
if (entry.type === 'directory') {
|
||||
const next = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
|
||||
setCurrentPath(next);
|
||||
} else {
|
||||
const filePath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
|
||||
setSearchParams({ view: filePath });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateDir = async (name: string) => {
|
||||
const dirPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
|
||||
try {
|
||||
await files.createDir(dirPath);
|
||||
await refresh();
|
||||
toast.success(`Created folder "${name}"`);
|
||||
} catch {
|
||||
toast.error('Failed to create folder');
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async (fileList: FileList) => {
|
||||
const count = fileList.length;
|
||||
setUploadProgress(0);
|
||||
try {
|
||||
await files.uploadFiles(currentPath, fileList, (pct) => setUploadProgress(pct));
|
||||
await refresh();
|
||||
toast.success(`Uploaded ${count} file${count !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toast.error('Failed to upload files');
|
||||
} finally {
|
||||
setUploadProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async (entry: DirEntry, newName: string) => {
|
||||
const path = entryPath(entry.name);
|
||||
try {
|
||||
await files.rename(path, newName);
|
||||
await refresh();
|
||||
toast.success(`Renamed to "${newName}"`);
|
||||
} catch {
|
||||
toast.error('Failed to rename');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (entry: DirEntry) => {
|
||||
// If the entry is part of a multi-selection, delete all selected
|
||||
if (selected.has(entry.name) && selected.size > 1) {
|
||||
handleDeleteSelected();
|
||||
return;
|
||||
}
|
||||
if (!confirm(`Delete "${entry.name}"?`)) return;
|
||||
const path = entryPath(entry.name);
|
||||
try {
|
||||
await files.remove(path);
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(entry.name);
|
||||
return next;
|
||||
});
|
||||
await refresh();
|
||||
toast.success(`Deleted "${entry.name}"`);
|
||||
} catch {
|
||||
toast.error('Failed to delete');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
const names = Array.from(selected);
|
||||
if (names.length === 0) return;
|
||||
if (!confirm(`Delete ${names.length} item${names.length !== 1 ? 's' : ''}?`)) return;
|
||||
try {
|
||||
for (const name of names) {
|
||||
await files.remove(entryPath(name));
|
||||
}
|
||||
setSelected(new Set());
|
||||
await refresh();
|
||||
toast.success(`Deleted ${names.length} item${names.length !== 1 ? 's' : ''}`);
|
||||
} catch {
|
||||
toast.error('Failed to delete some items');
|
||||
await refresh();
|
||||
}
|
||||
};
|
||||
|
||||
const handleChat = (entry: DirEntry) => {
|
||||
const path = entryPath(entry.name).replace(/^\//, '');
|
||||
const isDir = entry.type === 'directory';
|
||||
const tag = isDir ? 'folder' : 'file';
|
||||
const cwdPath = isDir ? path : currentPath.replace(/^\//, '');
|
||||
const message = isDir
|
||||
? `[${tag}: ${path}] consider, for this session, this directory as your current working directory`
|
||||
: `[${tag}: ${path}] Let's talk about this file`;
|
||||
navigate('/chat/new', {
|
||||
state: { initialMessage: message, cwd: { root: homeRoot, path: cwdPath } },
|
||||
});
|
||||
};
|
||||
|
||||
const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
|
||||
setRunningTask({ task, entry });
|
||||
};
|
||||
|
||||
const handleGitClone = async () => {
|
||||
const url = cloneUrl.trim();
|
||||
if (!url) return;
|
||||
setCloning(true);
|
||||
try {
|
||||
await files.gitClone(url, currentPath);
|
||||
await refresh();
|
||||
toast.success('Repository cloned');
|
||||
} catch {
|
||||
toast.error('Failed to clone repository');
|
||||
} finally {
|
||||
setCloning(false);
|
||||
setCloneUrl('');
|
||||
setShowCloneInput(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCut = () => {
|
||||
const paths = selected.size > 0 ? selectedPaths() : [];
|
||||
if (paths.length === 0) return;
|
||||
setClipboard({ paths, mode: 'cut' });
|
||||
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
const paths = selected.size > 0 ? selectedPaths() : [];
|
||||
if (paths.length === 0) return;
|
||||
setClipboard({ paths, mode: 'copy' });
|
||||
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
||||
};
|
||||
|
||||
const handlePaste = async () => {
|
||||
if (!clipboard) return;
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handler = (ev: KeyboardEvent) => {
|
||||
// Don't capture when typing in inputs
|
||||
const tag = (ev.target as HTMLElement).tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
||||
|
||||
if (ev.key === 'Escape') {
|
||||
if (viewPath) return; // Let the viewer handle it
|
||||
if (searchQuery) {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.blur();
|
||||
return;
|
||||
}
|
||||
setSelected(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'a') {
|
||||
ev.preventDefault();
|
||||
setSelected(new Set(visibleEntries.map((e) => e.name)));
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'c') {
|
||||
ev.preventDefault();
|
||||
if (selected.size > 0) {
|
||||
const paths = selectedPaths();
|
||||
setClipboard({ paths, mode: 'copy' });
|
||||
toast.success(`Copied ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'x') {
|
||||
ev.preventDefault();
|
||||
if (selected.size > 0) {
|
||||
const paths = selectedPaths();
|
||||
setClipboard({ paths, mode: 'cut' });
|
||||
toast.success(`Cut ${paths.length} item${paths.length !== 1 ? 's' : ''}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
|
||||
ev.preventDefault();
|
||||
handlePaste();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'Delete' || ev.key === 'Backspace') {
|
||||
if (selected.size > 0) {
|
||||
ev.preventDefault();
|
||||
handleDeleteSelected();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.key === 'F2') {
|
||||
if (selected.size === 1) {
|
||||
ev.preventDefault();
|
||||
setRenamingName(Array.from(selected)[0]!);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Type-to-search: focus search bar on printable character
|
||||
if (ev.key.length === 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handler);
|
||||
return () => window.removeEventListener('keydown', handler);
|
||||
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
|
||||
|
||||
const handleBackgroundClick = (ev: React.MouseEvent) => {
|
||||
if ((ev.target as HTMLElement).closest('[data-file-item]')) return;
|
||||
setSelected(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-center h-full p-0 md:p-4">
|
||||
<Card
|
||||
className={`flex flex-col overflow-hidden transition-all duration-200 ${
|
||||
fullscreen ? 'w-[90vw] h-[90%]' : 'w-full h-full md:w-[90vw] md:h-[90%] lg:w-[50vw]'
|
||||
}`}
|
||||
>
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
|
||||
<Toolbar
|
||||
onCreateDir={handleCreateDir}
|
||||
onUpload={handleUpload}
|
||||
selectionCount={selected.size}
|
||||
hasClipboard={clipboard !== null}
|
||||
onCut={handleCut}
|
||||
onCopy={handleCopy}
|
||||
onPaste={handlePaste}
|
||||
onDeleteSelected={handleDeleteSelected}
|
||||
onClearSelection={() => setSelected(new Set())}
|
||||
/>
|
||||
<div className="flex-1" />
|
||||
{showCloneInput ? (
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
handleGitClone();
|
||||
}}
|
||||
className="hidden md:flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={cloneUrl}
|
||||
onChange={(ev) => setCloneUrl(ev.target.value)}
|
||||
placeholder="https://github.com/user/repo.git"
|
||||
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
|
||||
disabled={cloning}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setShowCloneInput(false);
|
||||
setCloneUrl('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={cloning}
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Clone"
|
||||
>
|
||||
{cloning ? <Loader2 className="h-4 w-4 animate-spin" /> : <Check className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={cloning}
|
||||
onClick={() => {
|
||||
setShowCloneInput(false);
|
||||
setCloneUrl('');
|
||||
}}
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors disabled:opacity-40"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowCloneInput(true)}
|
||||
title="Git clone"
|
||||
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<GitBranch className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="relative flex items-center">
|
||||
<Search className="absolute left-2 h-4 w-4 text-duck-dark/40 pointer-events-none" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
value={searchQuery}
|
||||
onChange={(ev) => setSearchQuery(ev.target.value)}
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setSearchQuery('');
|
||||
searchInputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
placeholder="Search files..."
|
||||
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-1.5 p-0.5 rounded text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowHidden((v) => !v)}
|
||||
className={`hidden md:block p-1.5 rounded-md cursor-pointer transition-colors ${showHidden ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||
title={showHidden ? 'Hide hidden files' : 'Show hidden files'}
|
||||
>
|
||||
{showHidden ? <Eye className="h-4 w-4" /> : <EyeOff className="h-4 w-4" />}
|
||||
</button>
|
||||
<div className="flex items-center border border-duck-dark/20 rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => setViewMode('grid')}
|
||||
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'grid' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<LayoutGrid className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('list')}
|
||||
className={`p-1.5 cursor-pointer transition-colors ${viewMode === 'list' ? 'bg-duck-teal text-duck-yellow' : 'text-duck-dark/50 hover:bg-duck-dark/5'}`}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setFullscreen((f) => !f)}
|
||||
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Home dir selector (Super Admin only) */}
|
||||
{user?.role === 'Super Admin' && (
|
||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||
<RadioGroup
|
||||
value={homeRoot}
|
||||
onValueChange={(v) => {
|
||||
setHomeRoot(v as HomeRoot);
|
||||
if (currentPath !== '/') setCurrentPath('/');
|
||||
}}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
||||
<RadioGroupItem value="home" />
|
||||
<Home className="h-3.5 w-3.5" />
|
||||
Home dir
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
||||
<RadioGroupItem value="~" />
|
||||
<FolderRoot className="h-3.5 w-3.5" />~
|
||||
</label>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-duck-dark/70">
|
||||
<RadioGroupItem value="officer.dev" />
|
||||
<Code className="h-3.5 w-3.5" />
|
||||
officer.dev
|
||||
</label>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
|
||||
<Breadcrumb path={currentPath} onNavigate={handleNavigate} />
|
||||
</div>
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploadProgress !== null && (
|
||||
<div className="shrink-0 px-4 pt-3">
|
||||
<div className="flex items-center justify-between text-sm text-duck-dark/70 mb-1">
|
||||
<span>Uploading...</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-duck-dark/10 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-duck-teal transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{searchQuery.trim() ? (
|
||||
<div className="h-full overflow-auto p-4">
|
||||
{searching ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||
</div>
|
||||
) : searchResults && searchResults.length > 0 ? (
|
||||
<div className="flex flex-col">
|
||||
{searchResults.map((entry) => {
|
||||
const isDir = entry.type === 'directory';
|
||||
return (
|
||||
<div
|
||||
key={entry.path}
|
||||
className="flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0"
|
||||
onClick={() => handleSearchResultClick(entry)}
|
||||
>
|
||||
{isDir ? (
|
||||
<Folder className="h-5 w-5 shrink-0 text-duck-yellow fill-duck-yellow/30" />
|
||||
) : (
|
||||
<span
|
||||
className="inline-flex h-5 w-5 shrink-0"
|
||||
dangerouslySetInnerHTML={{ __html: getIcon(entry.name).svg }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-duck-dark block truncate">{entry.name}</span>
|
||||
<span className="text-xs text-duck-dark/40 block truncate">{entry.path}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : searchResults ? (
|
||||
<div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">
|
||||
No results found
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="h-full overflow-auto p-4" onClick={handleBackgroundClick}>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<FileGrid
|
||||
entries={visibleEntries}
|
||||
viewMode={viewMode}
|
||||
currentPath={currentPath}
|
||||
selected={selected}
|
||||
clipboard={clipboard}
|
||||
onOpen={handleOpen}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
onChat={handleChat}
|
||||
onSelect={setSelected}
|
||||
onCut={handleCut}
|
||||
onCopy={handleCopy}
|
||||
renamingName={renamingName}
|
||||
onRenamingChange={setRenamingName}
|
||||
getMatchingTasks={getMatchingTasks}
|
||||
onRunTask={handleRunTask}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="z-[600]">
|
||||
<ContextMenuItem onClick={handlePaste} disabled={!clipboard} className="cursor-pointer">
|
||||
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||
Paste
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
const name = prompt('Folder name');
|
||||
if (name?.trim()) handleCreateDir(name.trim());
|
||||
}}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" />
|
||||
New folder
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<FileViewer
|
||||
open={viewPath !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSearchParams({});
|
||||
}}
|
||||
filePath={viewPath ?? ''}
|
||||
fileName={viewerFileName}
|
||||
root={homeRoot}
|
||||
/>
|
||||
|
||||
{runningTask && (
|
||||
<TaskRunnerModal
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setRunningTask(null);
|
||||
refresh();
|
||||
}
|
||||
}}
|
||||
task={runningTask.task}
|
||||
entryName={runningTask.entry.name}
|
||||
entryType={runningTask.entry.type}
|
||||
cwd={{ root: homeRoot, path: currentPath.replace(/^\//, '') }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user