File browser as widget

This commit is contained in:
2026-02-17 19:23:01 +00:00
parent 0746844d6f
commit 21213c281d
27 changed files with 23 additions and 34 deletions
@@ -1,124 +0,0 @@
import { useRef, useCallback } from 'react';
import type { DirEntry } from '../state/useFiles';
import type { TaskSummary } from '../state/useTasks';
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>
);
};
@@ -1,469 +0,0 @@
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 } from '../state/useFiles';
import type { TaskSummary } from '../state/useTasks';
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
@@ -1,166 +0,0 @@
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 '../state/useTasks';
const playDing = () => {
const ctx = new AudioContext();
const t = ctx.currentTime;
const play = (freq: number, start: number, dur: number) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, t + start);
gain.gain.setValueAtTime(0.8, t + start);
gain.gain.setValueAtTime(0.8, t + start + dur * 0.6);
gain.gain.exponentialRampToValueAtTime(0.001, t + start + dur);
osc.connect(gain).connect(ctx.destination);
osc.start(t + start);
osc.stop(t + start + dur);
};
play(880, 0, 0.4);
play(1100, 0.25, 0.4);
play(1320, 0.5, 0.6);
setTimeout(() => ctx.close(), 1500);
};
type 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>
);
};
@@ -1,675 +0,0 @@
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 { DashboardLayout } from '@/Screens/Dashboard/Layout';
import { useFiles, type DirEntry } from '../state/useFiles';
import { useTasks, type TaskSummary } from '../state/useTasks';
import { useUserState } from '@/state/useUserState';
import { useAuth } from 'hooks/useAuth';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card } from '@/components/Card';
import { Breadcrumb } from './Breadcrumb';
import { Toolbar } from './Toolbar';
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 (
<DashboardLayout>
<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(/^\//, '') }}
/>
)}
</DashboardLayout>
);
};
@@ -1,287 +0,0 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { useNavigate, Link } from 'react-router';
import { Folder, Pin, PinOff, Search, Clock, FolderOpen, Loader2, X, ChevronDown, ChevronUp } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { useFiles, type DirEntry } from './state/useFiles';
import { useRecentFiles } from './state/useRecentFiles';
import { usePinnedFiles } from './state/usePinnedFiles';
import { useUserState } from '@/state/useUserState';
import { Card } from '@/components/Card';
import { Breadcrumb } from './Screen/Breadcrumb';
type Tab = 'browse' | 'recent' | 'pinned';
const TABS: { key: Tab; label: string; icon: typeof Folder }[] = [
{ key: 'browse', label: 'Browse', icon: FolderOpen },
{ key: 'recent', label: 'Recent', icon: Clock },
{ key: 'pinned', label: 'Pinned', icon: Pin },
];
export const FileBrowser = () => {
const navigate = useNavigate();
const { listDir, search } = useFiles();
const { recents, addRecent } = useRecentFiles();
const { pinned, togglePin, isPinned } = usePinnedFiles();
const [collapsed, setCollapsed] = useUserState('widget:fileBrowser:collapsed', true);
const [tab, setTab] = useState<Tab>('browse');
const [browsePath, setBrowsePath] = useState('/');
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<DirEntry[]>([]);
const [searching, setSearching] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(null);
useEffect(() => {
setLoading(true);
listDir(browsePath)
.then((res) => setEntries(res.entries))
.catch(() => setEntries([]))
.finally(() => setLoading(false));
}, [browsePath]);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
const q = searchQuery.trim();
if (!q) {
setSearchResults([]);
return;
}
debounceRef.current = setTimeout(() => {
setSearching(true);
search(q)
.then((res) => setSearchResults(res.results))
.catch(() => setSearchResults([]))
.finally(() => setSearching(false));
}, 300);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchQuery]);
const openFile = (path: string, name: string) => {
addRecent(path, name);
navigate(`/files?view=${encodeURIComponent(path)}`);
};
const isSearching = searchQuery.trim().length > 0;
const sorted = [...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<div className="w-full">
<Card className="overflow-hidden">
<div className={`flex items-center justify-between px-4 pt-3 ${collapsed ? 'pb-3' : 'pb-1'}`}>
<Link to="/files" className="text-xs font-semibold text-duck-dark/60 uppercase tracking-wide hover:underline">
File Browser
</Link>
<button
onClick={() => setCollapsed((c) => !c)}
className="text-duck-dark/40 hover:text-duck-dark/70 cursor-pointer transition-colors"
>
{collapsed ? <ChevronDown className="h-4 w-4" /> : <ChevronUp className="h-4 w-4" />}
</button>
</div>
{!collapsed && (
<>
{/* Tab bar + search */}
<div className="flex items-center gap-2 px-4 pb-1">
<div className="flex items-center gap-1">
{TABS.map(({ key, label, icon: Icon }) => (
<button
key={key}
onClick={() => setTab(key)}
className={`flex items-center gap-1.5 rounded-md px-3 py-1.5 text-xs font-medium transition-colors cursor-pointer ${
!isSearching && tab === key
? 'bg-duck-teal/10 text-duck-teal'
: 'text-duck-dark/50 hover:text-duck-dark/70 hover:bg-duck-dark/5'
}`}
>
<Icon className="h-3.5 w-3.5" />
{label}
</button>
))}
</div>
<div className="relative ml-auto w-28 md:w-44">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-duck-dark/30" />
<input
type="text"
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
className="absolute right-2 top-1/2 -translate-y-1/2 text-duck-dark/30 hover:text-duck-dark/60 cursor-pointer"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Content */}
<div className="px-4 pb-3 max-h-72 overflow-y-auto">
{isSearching ? (
searching ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : searchResults.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No results</p>
) : (
<ul className="space-y-0.5">
{searchResults.map((entry) => (
<EntryRow
key={entry.path}
name={entry.name}
subtitle={entry.path}
type={entry.type}
pinned={entry.type === 'file' && isPinned(entry.path!)}
onPin={entry.type === 'file' ? () => togglePin(entry.path!, entry.name) : undefined}
onClick={() => {
if (entry.type === 'file') openFile(entry.path!, entry.name);
}}
/>
))}
</ul>
)
) : (
<>
{tab === 'browse' && (
<div>
<div className="py-2">
<Breadcrumb path={browsePath} onNavigate={setBrowsePath} />
</div>
{loading ? (
<div className="flex items-center justify-center py-6 text-duck-dark/40">
<Loader2 className="h-4 w-4 animate-spin" />
</div>
) : sorted.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">Empty directory</p>
) : (
<ul className="space-y-0.5">
{sorted.map((entry) => {
const fullPath = browsePath === '/' ? `/${entry.name}` : `${browsePath}/${entry.name}`;
return (
<EntryRow
key={entry.name}
name={entry.name}
type={entry.type}
pinned={entry.type === 'file' && isPinned(fullPath)}
onPin={entry.type === 'file' ? () => togglePin(fullPath, entry.name) : undefined}
onClick={() => {
if (entry.type === 'directory') setBrowsePath(fullPath);
else openFile(fullPath, entry.name);
}}
/>
);
})}
</ul>
)}
</div>
)}
{tab === 'recent' && (
<div className="pt-2">
{recents.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No recent files</p>
) : (
<ul className="space-y-0.5">
{recents.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned={isPinned(f.path)}
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
{tab === 'pinned' && (
<div className="pt-2">
{pinned.length === 0 ? (
<p className="text-xs text-duck-dark/40 py-4 text-center">No pinned files</p>
) : (
<ul className="space-y-0.5">
{pinned.map((f) => (
<EntryRow
key={f.path}
name={f.name}
subtitle={f.path}
type="file"
pinned
onPin={() => togglePin(f.path, f.name)}
onClick={() => openFile(f.path, f.name)}
/>
))}
</ul>
)}
</div>
)}
</>
)}
</div>
</>
)}
</Card>
</div>
);
};
type EntryRowProps = {
name: string;
subtitle?: string;
type: 'file' | 'directory';
pinned?: boolean;
onPin?: () => void;
onClick: () => void;
};
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
};
const EntryRow = ({ name, subtitle, type, pinned, onPin, onClick }: EntryRowProps) => (
<li className="flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-duck-dark/5 group">
<button onClick={onClick} className="flex items-center gap-2 flex-1 min-w-0 text-left cursor-pointer">
{type === 'directory' ? (
<Folder className="h-4 w-4 shrink-0 text-duck-teal" />
) : (
<MaterialFileIcon name={name} className="inline-flex h-4 w-4 shrink-0" />
)}
<div className="min-w-0 flex-1">
<span className="text-sm text-duck-dark truncate block">{name}</span>
{subtitle && <span className="text-xs text-duck-dark/40 truncate block">{subtitle}</span>}
</div>
</button>
{onPin && (
<button
onClick={(ev) => {
ev.stopPropagation();
onPin();
}}
className={`shrink-0 p-1 rounded transition-colors cursor-pointer ${
pinned
? 'text-duck-teal hover:text-duck-teal/70'
: 'text-duck-dark/20 opacity-0 group-hover:opacity-100 hover:text-duck-dark/50'
}`}
>
{pinned ? <PinOff className="h-3.5 w-3.5" /> : <Pin className="h-3.5 w-3.5" />}
</button>
)}
</li>
);
@@ -1,2 +0,0 @@
export { FileBrowser as Widget } from './Widget';
export { Files as Screen } from './Screen';
@@ -1,23 +0,0 @@
import { useCallback } from 'react';
import { useUserState } from '@/state/useUserState';
type PinnedFile = { path: string; name: string; pinnedAt: number };
export const usePinnedFiles = () => {
const [pinned, setPinned] = useUserState<PinnedFile[]>('pinnedFiles', []);
const togglePin = useCallback(
(path: string, name: string) => {
setPinned((prev) => {
const exists = prev.some((f) => f.path === path);
if (exists) return prev.filter((f) => f.path !== path);
return [{ path, name, pinnedAt: Date.now() }, ...prev];
});
},
[setPinned],
);
const isPinned = useCallback((path: string) => pinned.some((f) => f.path === path), [pinned]);
return { pinned, togglePin, isPinned };
};
@@ -1,22 +0,0 @@
import { useCallback } from 'react';
import { useUserState } from '@/state/useUserState';
type RecentFile = { path: string; name: string; openedAt: number };
const MAX_RECENTS = 20;
export const useRecentFiles = () => {
const [recents, setRecents] = useUserState<RecentFile[]>('recentFiles', []);
const addRecent = useCallback(
(path: string, name: string) => {
setRecents((prev) => {
const filtered = prev.filter((f) => f.path !== path);
return [{ path, name, openedAt: Date.now() }, ...filtered].slice(0, MAX_RECENTS);
});
},
[setRecents],
);
return { recents, addRecent };
};
@@ -1,8 +0,0 @@
export { Widget, Screen } from './client';
export { router, apiPath } from './server';
export const plugin = {
id: 'FileBrowser',
name: 'File Browser',
description: 'Browse, upload, and manage files',
};
@@ -1,3 +0,0 @@
export * from './router';
export const apiPath = 'file-browser';
@@ -1,413 +0,0 @@
import { createRouter } from '@@/create-router';
import { resolve, dirname } from 'node:path';
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
import { homedir } from 'node:os';
import { getHomeDir } from '@@/data-path';
import * as errors from '@@/custom-errors';
export const router = createRouter();
type UserCtx = { email: string; role: string | null };
function getRootDir(user: UserCtx, root?: string): string {
if (!root || root === 'home') return getHomeDir(user.email);
if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root');
if (root === '~') return homedir();
if (root === 'officer.dev') return resolve(process.cwd(), '..');
throw errors.BAD_REQUEST(`Invalid root: ${root}`);
}
function resolveUserPath(rootDir: string, relPath: string): string {
const resolved = resolve(rootDir, relPath.replace(/^\/+/, ''));
if (!resolved.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside root directory');
return resolved;
}
// List directory entries
router.get('/ls', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const absPath = resolveUserPath(rootDir, relPath);
// Auto-create dir if missing (only for user home root)
if (!ctx.req.query('root') || ctx.req.query('root') === 'home') {
await mkdir(absPath, { recursive: true });
}
let names: string[];
try {
names = await readdir(absPath);
} catch {
return ctx.json({ path: '/', entries: [], reset: true });
}
const entries = await Promise.all(
names.map(async (name) => {
const fullPath = resolve(absPath, name);
// Skip entries that escape the home dir (shouldn't happen but be safe)
if (!fullPath.startsWith(rootDir)) return null;
const s = await stat(fullPath).catch(() => null);
if (!s) return null;
return {
name,
type: s.isDirectory() ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
};
}),
);
const path = '/' + absPath.slice(rootDir.length).replace(/^\/+/, '');
return ctx.json({ path, entries: entries.filter(Boolean) });
});
// Read file contents
router.get('/read', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read a directory');
const MAX_TEXT_SIZE = 5 * 1024 * 1024; // 5 MB
if (s.size > MAX_TEXT_SIZE) throw errors.BAD_REQUEST('File too large to read (max 5 MB)');
const content = await readFile(absPath, 'utf-8');
return ctx.json({ content, size: s.size });
});
// Create directory
router.post('/mkdir', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { path } = ctx.get('body') as { path: string };
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
return ctx.json({ ok: true });
});
// Upload files
router.post('/upload', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '/').replace(/^\/+/, '');
const targetDir = resolveUserPath(rootDir, relPath);
await mkdir(targetDir, { recursive: true });
const body = ctx.get('body') as Record<string, unknown>;
const raw = body['file'];
const files = Array.isArray(raw) ? raw : raw ? [raw] : [];
for (const file of files) {
if (!(file instanceof File)) continue;
const filePath = resolve(targetDir, file.name);
if (!filePath.startsWith(rootDir)) continue;
await mkdir(dirname(filePath), { recursive: true });
await Bun.write(filePath, file);
}
return ctx.json({ ok: true });
});
// Rename file or directory
router.post('/rename', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { path: filePath, newName } = ctx.get('body') as { path: string; newName: string };
if (!filePath || !newName) throw errors.BAD_REQUEST('path and newName are required');
if (newName.includes('/')) throw errors.BAD_REQUEST('newName must not contain /');
const absPath = resolveUserPath(rootDir, filePath);
if (absPath === rootDir) throw errors.FORBIDDEN('Cannot rename home directory');
const newPath = resolve(dirname(absPath), newName);
if (!newPath.startsWith(rootDir)) throw errors.FORBIDDEN('Path outside home directory');
await rename(absPath, newPath);
return ctx.json({ ok: true });
});
// Serve raw file (binary-safe, for audio/video/images/download)
// Supports Range requests for audio/video seeking
router.get('/raw', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot serve a directory');
const file = Bun.file(absPath);
const contentType = file.type || 'application/octet-stream';
const total = s.size;
const rangeHeader = ctx.req.header('range');
if (rangeHeader) {
const match = rangeHeader.match(/bytes=(\d*)-(\d*)/);
if (match) {
const start = match[1] ? parseInt(match[1], 10) : 0;
const end = match[2] ? parseInt(match[2], 10) : total - 1;
const chunkSize = end - start + 1;
const slice = file.slice(start, end + 1);
return new Response(slice, {
status: 206,
headers: {
'Content-Type': contentType,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(chunkSize),
'Accept-Ranges': 'bytes',
},
});
}
}
return new Response(file, {
headers: {
'Content-Type': contentType,
'Content-Length': String(total),
'Accept-Ranges': 'bytes',
},
});
});
// Transcode video via ffmpeg for non-native browser formats (mkv, avi, wmv, etc.)
// Outputs fragmented MP4 streamed to the client
router.get('/transcode', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const relPath = (ctx.req.query('path') || '').replace(/^\/+/, '');
if (!relPath) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, relPath);
const s = await stat(absPath);
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcode a directory');
const startTime = ctx.req.query('t') || '0';
const proc = Bun.spawn(
[
'ffmpeg',
'-ss',
startTime,
'-i',
absPath,
'-c:v',
'libx264',
'-preset',
'ultrafast',
'-crf',
'23',
'-c:a',
'aac',
'-b:a',
'128k',
'-movflags',
'frag_mp4+empty_moov+default_base_moof',
'-f',
'mp4',
'pipe:1',
],
{ stdout: 'pipe', stderr: 'ignore' },
);
return new Response(proc.stdout as ReadableStream, {
headers: {
'Content-Type': 'video/mp4',
'Transfer-Encoding': 'chunked',
},
});
});
// Search files by name
router.get('/search', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const query = (ctx.req.query('q') || '').trim().toLowerCase();
if (!query) throw errors.BAD_REQUEST('q is required');
const MAX_RESULTS = 50;
const results: { path: string; name: string; type: string; size: number; modifiedAt: number }[] = [];
async function walk(dir: string) {
if (results.length >= MAX_RESULTS) return;
const names = await readdir(dir).catch(() => [] as string[]);
for (const name of names) {
if (results.length >= MAX_RESULTS) break;
const fullPath = resolve(dir, name);
if (!fullPath.startsWith(rootDir)) continue;
const s = await stat(fullPath).catch(() => null);
if (!s) continue;
if (name.toLowerCase().includes(query)) {
const relPath = '/' + fullPath.slice(rootDir.length).replace(/^\/+/, '');
results.push({
path: relPath,
name,
type: s.isDirectory() ? 'directory' : 'file',
size: s.size,
modifiedAt: s.mtimeMs,
});
}
if (s.isDirectory()) await walk(fullPath);
}
}
await walk(rootDir);
return ctx.json({ results });
});
// Resolve a destination path, appending " (copy)", " (copy 2)", etc. if it already exists
async function resolveCollision(destPath: string): Promise<string> {
try {
await stat(destPath);
} catch {
return destPath;
}
const dir = dirname(destPath);
const base = destPath.split('/').pop()!;
const dotIdx = base.lastIndexOf('.');
const name = dotIdx > 0 ? base.slice(0, dotIdx) : base;
const ext = dotIdx > 0 ? base.slice(dotIdx) : '';
let n = 1;
while (true) {
const suffix = n === 1 ? ' (copy)' : ` (copy ${n})`;
const candidate = resolve(dir, `${name}${suffix}${ext}`);
try {
await stat(candidate);
n++;
} catch {
return candidate;
}
}
}
type CopyMoveItem = { source: string; destination: string };
type CopyMoveBody = CopyMoveItem | { items: CopyMoveItem[] };
function parseCopyMoveBody(body: CopyMoveBody): CopyMoveItem[] {
if ('items' in body && Array.isArray(body.items)) return body.items;
return [body as CopyMoveItem];
}
// Copy file or directory
router.post('/copy', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const body = ctx.get('body') as CopyMoveBody;
const items = parseCopyMoveBody(body);
if (items.length === 0) throw errors.BAD_REQUEST('No items provided');
const results: { source: string; destination: string; error?: string }[] = [];
for (const item of items) {
if (!item.source || !item.destination) {
results.push({
source: item.source,
destination: item.destination,
error: 'source and destination are required',
});
continue;
}
try {
const srcAbs = resolveUserPath(rootDir, item.source);
const destAbs = resolveUserPath(rootDir, item.destination);
await mkdir(dirname(destAbs), { recursive: true });
const finalDest = await resolveCollision(destAbs);
await cp(srcAbs, finalDest, { recursive: true });
const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
results.push({ source: item.source, destination: relDest });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.push({ source: item.source, destination: item.destination, error: msg });
}
}
return ctx.json({ ok: true, results });
});
// Move file or directory
router.post('/move', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const body = ctx.get('body') as CopyMoveBody;
const items = parseCopyMoveBody(body);
if (items.length === 0) throw errors.BAD_REQUEST('No items provided');
const results: { source: string; destination: string; error?: string }[] = [];
for (const item of items) {
if (!item.source || !item.destination) {
results.push({
source: item.source,
destination: item.destination,
error: 'source and destination are required',
});
continue;
}
try {
const srcAbs = resolveUserPath(rootDir, item.source);
const destAbs = resolveUserPath(rootDir, item.destination);
// Prevent moving a directory into itself
if (destAbs.startsWith(srcAbs + '/')) {
throw new Error('Cannot move a directory into itself');
}
await mkdir(dirname(destAbs), { recursive: true });
const finalDest = await resolveCollision(destAbs);
await rename(srcAbs, finalDest);
const relDest = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
results.push({ source: item.source, destination: relDest });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : 'Unknown error';
results.push({ source: item.source, destination: item.destination, error: msg });
}
}
return ctx.json({ ok: true, results });
});
// Git clone a repository into a directory
router.post('/git-clone', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const { url, path } = ctx.get('body') as { url: string; path: string };
if (!url) throw errors.BAD_REQUEST('url is required');
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
await mkdir(absPath, { recursive: true });
const proc = Bun.spawn(['git', 'clone', url], { cwd: absPath, stdout: 'pipe', stderr: 'pipe' });
const exitCode = await proc.exited;
if (exitCode !== 0) {
const stderr = await new Response(proc.stderr).text();
throw errors.BAD_REQUEST(stderr.trim() || 'git clone failed');
}
return ctx.json({ ok: true });
});
// Delete file or directory
router.delete('/rm', async (ctx) => {
const user = ctx.get('user');
const rootDir = getRootDir(user, ctx.req.query('root') ?? undefined);
const body = await ctx.req.json<{ path: string }>();
const { path } = body;
if (!path) throw errors.BAD_REQUEST('path is required');
const absPath = resolveUserPath(rootDir, path);
// Prevent deleting the home dir itself
if (absPath === rootDir) throw errors.FORBIDDEN('Cannot delete home directory');
await rm(absPath, { recursive: true, force: true });
return ctx.json({ ok: true });
});
-3
View File
@@ -4,9 +4,6 @@
"private": true,
"type": "module",
"exports": {
"./FileBrowser": "./FileBrowser/index.ts",
"./FileBrowser/client": "./FileBrowser/client/index.ts",
"./FileBrowser/server": "./FileBrowser/server/index.ts",
"./Chat": "./Chat/index.ts",
"./Chat/client": "./Chat/client/index.ts",
"./ChatHistory": "./ChatHistory/index.ts",
@@ -0,0 +1,4 @@
export { Breadcrumb } from './Breadcrumb';
export { Toolbar } from './Toolbar';
export { useFiles, type DirEntry } from './useFiles';
export { useTasks, type TaskSummary } from './useTasks';
+1
View File
@@ -0,0 +1 @@
export { TerminalView, type TerminalViewProps } from './Terminal';
+2 -1
View File
@@ -2,6 +2,7 @@
"name": "widgets",
"private": true,
"exports": {
"./TerminalView": "./TerminalView.tsx"
"./Terminal": "./Terminal/index.ts",
"./FileBrowser": "./FileBrowser/index.ts"
}
}