File browser as widget
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
@@ -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';
|
||||
@@ -0,0 +1 @@
|
||||
export { TerminalView, type TerminalViewProps } from './Terminal';
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "widgets",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./TerminalView": "./TerminalView.tsx"
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./FileBrowser": "./FileBrowser/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user