first
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
|
||||
type BreadcrumbProps = {
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
};
|
||||
|
||||
export const Breadcrumb = ({ path, onNavigate }: BreadcrumbProps) => {
|
||||
const segments = path.split('/').filter(Boolean);
|
||||
|
||||
return (
|
||||
<nav className="flex items-center gap-1 text-sm flex-wrap">
|
||||
<button
|
||||
onClick={() => onNavigate('/')}
|
||||
className="flex items-center gap-1 text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
<span>home</span>
|
||||
</button>
|
||||
|
||||
{segments.map((segment, i) => {
|
||||
const segmentPath = '/' + segments.slice(0, i + 1).join('/');
|
||||
const isLast = i === segments.length - 1;
|
||||
|
||||
return (
|
||||
<span key={segmentPath} className="flex items-center gap-1">
|
||||
<ChevronRight className="h-4 w-4 text-duck-dark/40" />
|
||||
{isLast ? (
|
||||
<span className="text-duck-dark font-semibold">{segment}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => onNavigate(segmentPath)}
|
||||
className="text-duck-forest hover:text-duck-teal transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
{segment}
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,469 @@
|
||||
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
@@ -0,0 +1,169 @@
|
||||
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 } from '@/Screens/Dashboard/Chat/useClaude';
|
||||
import { useOpenCode } from '@/Screens/Dashboard/Chat/useOpenCode';
|
||||
import { EmbeddableChat } from '@/Screens/Dashboard/Chat/EmbeddableChat';
|
||||
import { useVisibleClaudeModels, useVisibleOpenCodeModels } from '@/state/useModels';
|
||||
import { useSettings } from '@/state/useSettings';
|
||||
import type { TaskInfo } from '@/Screens/Dashboard/Chat/types';
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FolderPlus, Upload, FolderUp, Scissors, Copy, ClipboardPaste, Trash2, X, Check } from 'lucide-react';
|
||||
|
||||
type ToolbarProps = {
|
||||
onCreateDir: (name: string) => void;
|
||||
onUpload: (files: FileList) => void;
|
||||
selectionCount: number;
|
||||
hasClipboard: boolean;
|
||||
onCut: () => void;
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onDeleteSelected: () => void;
|
||||
onClearSelection: () => void;
|
||||
};
|
||||
|
||||
export const Toolbar = ({
|
||||
onCreateDir,
|
||||
onUpload,
|
||||
selectionCount,
|
||||
hasClipboard,
|
||||
onCut,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onDeleteSelected,
|
||||
onClearSelection,
|
||||
}: ToolbarProps) => {
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [folderName, setFolderName] = useState('');
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const folderInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const input = folderInputRef.current;
|
||||
if (!input) return;
|
||||
input.setAttribute('webkitdirectory', '');
|
||||
}, []);
|
||||
|
||||
const handleCreate = () => {
|
||||
const name = folderName.trim();
|
||||
if (!name) return;
|
||||
onCreateDir(name);
|
||||
setFolderName('');
|
||||
setShowInput(false);
|
||||
};
|
||||
|
||||
if (selectionCount > 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm font-medium text-duck-dark/70 mr-1">
|
||||
{selectionCount}
|
||||
<span className="hidden md:inline"> selected</span>
|
||||
</span>
|
||||
<button
|
||||
onClick={onCut}
|
||||
title="Cut"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Scissors className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onCopy}
|
||||
title="Copy"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
{hasClipboard && (
|
||||
<button
|
||||
onClick={onPaste}
|
||||
title="Paste"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDeleteSelected}
|
||||
title="Delete"
|
||||
className="p-1.5 rounded-md text-red-500 hover:bg-red-50 cursor-pointer transition-colors"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
title="Clear selection"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{showInput ? (
|
||||
<form
|
||||
onSubmit={(ev) => {
|
||||
ev.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
autoFocus
|
||||
value={folderName}
|
||||
onChange={(ev) => setFolderName(ev.target.value)}
|
||||
placeholder="Folder name"
|
||||
className="h-8 w-40 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"
|
||||
onKeyDown={(ev) => {
|
||||
if (ev.key === 'Escape') {
|
||||
setShowInput(false);
|
||||
setFolderName('');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="p-1.5 rounded-md text-duck-teal hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
title="Create"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowInput(false);
|
||||
setFolderName('');
|
||||
}}
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowInput(true)}
|
||||
title="New folder"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
title="Upload files"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => folderInputRef.current?.click()}
|
||||
title="Upload folder"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<FolderUp className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{hasClipboard && (
|
||||
<button
|
||||
onClick={onPaste}
|
||||
title="Paste"
|
||||
className="p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
|
||||
>
|
||||
<ClipboardPaste className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
if (ev.target.files?.length) {
|
||||
onUpload(ev.target.files);
|
||||
ev.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
ref={folderInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={(ev) => {
|
||||
if (ev.target.files?.length) {
|
||||
onUpload(ev.target.files);
|
||||
ev.target.value = '';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,675 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,287 @@
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
export { FileBrowser as Widget } from './Widget';
|
||||
export { Files as Screen } from './Screen';
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useClient, getHeaders } from 'hooks/useClient';
|
||||
import { config } from 'config';
|
||||
|
||||
export type DirEntry = {
|
||||
name: string;
|
||||
path?: string;
|
||||
type: 'file' | 'directory';
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
};
|
||||
|
||||
type ListDirResponse = {
|
||||
path: string;
|
||||
entries: DirEntry[];
|
||||
reset?: boolean;
|
||||
};
|
||||
|
||||
export const useFiles = (root: string = 'home') => {
|
||||
const client = useClient();
|
||||
const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : '';
|
||||
const withRoot = (url: string) =>
|
||||
rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url;
|
||||
|
||||
return {
|
||||
listDir: (path: string) =>
|
||||
client.get<ListDirResponse>(withRoot(`/file-browser/ls?path=${encodeURIComponent(path)}`)),
|
||||
|
||||
createDir: (path: string) => client.post(withRoot('/file-browser/mkdir'), { path }),
|
||||
|
||||
remove: (path: string) => client.delete(withRoot('/file-browser/rm'), { path }),
|
||||
|
||||
rename: (path: string, newName: string) => client.post(withRoot('/file-browser/rename'), { path, newName }),
|
||||
|
||||
readFile: (path: string) =>
|
||||
client.get<{ content: string; size: number }>(withRoot(`/file-browser/read?path=${encodeURIComponent(path)}`)),
|
||||
|
||||
search: (query: string) =>
|
||||
client.get<{ results: DirEntry[] }>(withRoot(`/file-browser/search?q=${encodeURIComponent(query)}`)),
|
||||
|
||||
copy: (sources: string[], destination: string) =>
|
||||
client.post(withRoot('/file-browser/copy'), {
|
||||
items: sources.map((source) => ({
|
||||
source,
|
||||
destination: `${destination}/${source.split('/').pop()}`,
|
||||
})),
|
||||
}),
|
||||
|
||||
move: (sources: string[], destination: string) =>
|
||||
client.post(withRoot('/file-browser/move'), {
|
||||
items: sources.map((source) => ({
|
||||
source,
|
||||
destination: `${destination}/${source.split('/').pop()}`,
|
||||
})),
|
||||
}),
|
||||
|
||||
gitClone: (url: string, path: string) => client.post(withRoot('/file-browser/git-clone'), { url, path }),
|
||||
|
||||
uploadFiles: (path: string, files: FileList | File[], onProgress?: (pct: number) => void): Promise<void> => {
|
||||
const formData = new FormData();
|
||||
for (const file of Array.from(files)) {
|
||||
const name = (file as any).webkitRelativePath || file.name;
|
||||
formData.append('file', file, name);
|
||||
}
|
||||
const authHeaders = getHeaders();
|
||||
const url = withRoot(`${config.API_URL}/file-browser/upload?path=${encodeURIComponent(path)}`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url);
|
||||
if (authHeaders['Authorization']) {
|
||||
xhr.setRequestHeader('Authorization', authHeaders['Authorization']);
|
||||
}
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && onProgress) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 400) reject(new Error(xhr.responseText));
|
||||
else resolve();
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Upload failed'));
|
||||
xhr.send(formData);
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
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 };
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
export type TaskSummary = {
|
||||
dirName: string;
|
||||
name: string;
|
||||
description: string;
|
||||
scope: 'user' | 'global';
|
||||
triggers: TriggerConfig[];
|
||||
filePath: string;
|
||||
};
|
||||
|
||||
export const useTasks = () => {
|
||||
const client = useClient();
|
||||
|
||||
const { data: tasks = [] } = useQuery<TaskSummary[]>({
|
||||
queryKey: ['tasks'],
|
||||
queryFn: () => client.get('/tasks'),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const getMatchingTasks = useCallback(
|
||||
(fileName: string, entryType: 'file' | 'directory'): TaskSummary[] => {
|
||||
if (entryType === 'directory') {
|
||||
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'directory'));
|
||||
}
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
if (!ext) return [];
|
||||
return tasks.filter((t) => t.triggers.some((tr) => tr.type === 'file' && tr.extensions.includes(ext)));
|
||||
},
|
||||
[tasks],
|
||||
);
|
||||
|
||||
return { tasks, getMatchingTasks };
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Widget, Screen } from './client';
|
||||
export { router, apiPath } from './server';
|
||||
|
||||
export const plugin = {
|
||||
id: 'FileBrowser',
|
||||
name: 'File Browser',
|
||||
description: 'Browse, upload, and manage files',
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './router';
|
||||
|
||||
export const apiPath = 'file-browser';
|
||||
@@ -0,0 +1,413 @@
|
||||
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 });
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
# Building a Plugin
|
||||
|
||||
Plugins are self-contained modules that extend Officer with new features. They live under `src/workspaces/plugins/` and are auto-discovered at startup — no registration step required.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/workspaces/plugins/
|
||||
├── package.json # Workspace exports (update when adding a plugin)
|
||||
└── MyPlugin/
|
||||
├── index.ts # Metadata + re-exports
|
||||
├── server/
|
||||
│ ├── index.ts # Exports router and apiPath
|
||||
│ └── router.ts # Hono API routes
|
||||
└── client/
|
||||
├── index.ts # Exports Widget and/or Screen
|
||||
├── Widget.tsx # Compact component (dashboard home)
|
||||
├── Screen/
|
||||
│ └── index.tsx # Full-page component (dedicated route)
|
||||
└── state/
|
||||
└── useMyPlugin.ts # API client hook
|
||||
```
|
||||
|
||||
A plugin can be server-only, client-only, or both. The discovery system checks for the presence of `server/index.ts` and `client/index.ts` to determine what the plugin provides.
|
||||
|
||||
## Step-by-Step
|
||||
|
||||
### 1. Create the plugin directory
|
||||
|
||||
```
|
||||
mkdir -p src/workspaces/plugins/MyPlugin/{server,client/state,client/Screen}
|
||||
```
|
||||
|
||||
### 2. Plugin metadata — `MyPlugin/index.ts`
|
||||
|
||||
Every plugin must export a `plugin` object with metadata. This is read by the settings UI and the `/plugins` API.
|
||||
|
||||
```ts
|
||||
export { Widget, Screen } from './client';
|
||||
export { router, apiPath } from './server';
|
||||
|
||||
export const plugin = {
|
||||
id: 'MyPlugin', // Must match directory name
|
||||
name: 'My Plugin', // Display name in settings
|
||||
description: 'What this plugin does',
|
||||
};
|
||||
```
|
||||
|
||||
If your plugin is server-only, omit the client export. If client-only, omit the server export.
|
||||
|
||||
### 3. Server router — `MyPlugin/server/`
|
||||
|
||||
**`server/index.ts`** — Exports the router instance and the API path prefix:
|
||||
|
||||
```ts
|
||||
export * from './router';
|
||||
|
||||
export const apiPath = 'my-plugin';
|
||||
```
|
||||
|
||||
The `apiPath` determines the URL prefix. This router gets mounted at `/api/my-plugin`.
|
||||
|
||||
**`server/router.ts`** — Define your API endpoints:
|
||||
|
||||
```ts
|
||||
import { createRouter } from '@@/create-router';
|
||||
import * as errors from '@@/custom-errors';
|
||||
|
||||
export const router = createRouter();
|
||||
|
||||
router.get('/items', async (ctx) => {
|
||||
const user = ctx.get('user'); // Authenticated user
|
||||
// ...
|
||||
return ctx.json({ items: [] });
|
||||
});
|
||||
|
||||
router.post('/items', async (ctx) => {
|
||||
const body = ctx.get('body'); // Parsed request body
|
||||
if (!body.name) throw errors.BAD_REQUEST('Name is required');
|
||||
// ...
|
||||
return ctx.json({ created: true });
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
- `createRouter()` from `@@/create-router` gives you a typed Hono router
|
||||
- All plugin routes are **protected** — user authentication is enforced automatically
|
||||
- Access the authenticated user with `ctx.get('user')` (returns `User` from types)
|
||||
- Access parsed body with `ctx.get('body')`
|
||||
- Throw `CustomError` instances for error responses — they're caught by the global error handler
|
||||
|
||||
**Available error helpers** (`@@/custom-errors`):
|
||||
- `BAD_REQUEST(msg?)` — 400
|
||||
- `UNAUTHORIZED(msg?)` — 401
|
||||
- `FORBIDDEN(msg?)` — 403
|
||||
- `NOT_FOUND(msg?)` — 404
|
||||
- `CONFLICT(msg?)` — 409
|
||||
- `INTERNAL_SERVER_ERROR(msg?)` — 500
|
||||
- `TOO_MANY_REQUESTS(msg?, retryAfter?)` — 429
|
||||
|
||||
**User data helpers** (`@@/data-path`):
|
||||
- `DATA_PATH` — base data directory
|
||||
- `getHomeDir(email)` — user's home directory
|
||||
|
||||
### 4. Client components — `MyPlugin/client/`
|
||||
|
||||
**`client/index.ts`** — Export your components with standardized names:
|
||||
|
||||
```ts
|
||||
export { MyPluginWidget as Widget } from './Widget';
|
||||
export { MyPluginScreen as Screen } from './Screen';
|
||||
```
|
||||
|
||||
**`client/state/useMyPlugin.ts`** — API client hook:
|
||||
|
||||
```ts
|
||||
import { useClient } from 'hooks/useClient';
|
||||
|
||||
type Item = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const useMyPlugin = () => {
|
||||
const client = useClient();
|
||||
|
||||
return {
|
||||
listItems: () => client.get<Item[]>('/my-plugin/items'),
|
||||
createItem: (name: string) => client.post('/my-plugin/items', { name }),
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
The `useClient()` hook provides an authenticated HTTP client. The path must match your `apiPath` from the server.
|
||||
|
||||
**`client/Widget.tsx`** — Compact component for the dashboard home:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useMyPlugin } from './state/useMyPlugin';
|
||||
|
||||
export const MyPluginWidget = () => {
|
||||
// Widget implementation
|
||||
};
|
||||
```
|
||||
|
||||
**`client/Screen/index.tsx`** — Full-page component:
|
||||
|
||||
```tsx
|
||||
import { DashboardLayout } from '@/Screens/Dashboard/Layout';
|
||||
|
||||
export const MyPluginScreen = () => {
|
||||
return (
|
||||
<DashboardLayout>
|
||||
{/* Screen implementation */}
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Register workspace exports — `plugins/package.json`
|
||||
|
||||
Add subpath exports so the monorepo can import your plugin:
|
||||
|
||||
```json
|
||||
{
|
||||
"exports": {
|
||||
"./FileBrowser": "./FileBrowser/index.ts",
|
||||
"./FileBrowser/client": "./FileBrowser/client/index.ts",
|
||||
"./FileBrowser/server": "./FileBrowser/server/index.ts",
|
||||
"./MyPlugin": "./MyPlugin/index.ts",
|
||||
"./MyPlugin/client": "./MyPlugin/client/index.ts",
|
||||
"./MyPlugin/server": "./MyPlugin/server/index.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This lets other code import your plugin as:
|
||||
|
||||
```ts
|
||||
import { Widget, Screen } from 'plugins/MyPlugin/client';
|
||||
import { router, apiPath } from 'plugins/MyPlugin/server';
|
||||
import { plugin } from 'plugins/MyPlugin';
|
||||
```
|
||||
|
||||
### 6. Wire into the app (optional)
|
||||
|
||||
The server router is auto-discovered and mounted — no changes needed. But if your plugin has client components that should appear in the main app, you'll need to add them manually:
|
||||
|
||||
**Route** — `src/apps/officer-web/App.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Screen as MyPluginScreen } from 'plugins/MyPlugin/client';
|
||||
|
||||
// Inside the authenticated routes:
|
||||
{plugins?.MyPlugin !== false && <Route path="/my-plugin" element={<MyPluginScreen />} />}
|
||||
```
|
||||
|
||||
**Nav item** — `src/apps/officer-web/Screens/Dashboard/Layout.tsx`:
|
||||
|
||||
```tsx
|
||||
{plugins?.MyPlugin !== false && (
|
||||
<DropdownMenuItem asChild className="cursor-pointer">
|
||||
<Link to="/my-plugin">
|
||||
<SomeIcon className="mr-2 h-4 w-4" />
|
||||
My Plugin
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
```
|
||||
|
||||
**Home widget** — `src/apps/officer-web/Screens/Dashboard/Home/index.tsx`:
|
||||
|
||||
```tsx
|
||||
import { Widget as MyPluginWidget } from 'plugins/MyPlugin/client';
|
||||
|
||||
// Inside the layout:
|
||||
{plugins?.MyPlugin !== false && <MyPluginWidget />}
|
||||
```
|
||||
|
||||
The `plugins` object comes from `useServerSettings()` and is `undefined` when no overrides exist — so `plugins?.MyPlugin !== false` defaults to showing the plugin (opt-out model).
|
||||
|
||||
## Enable/Disable
|
||||
|
||||
Plugins are **enabled by default**. Admins can disable them in Server Settings > Plugins, which writes to `~/.config/officer.dev/server-settings.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"plugins": {
|
||||
"MyPlugin": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When disabled:
|
||||
- **Server-side**: The plugin router is not mounted (requires server restart)
|
||||
- **Client-side**: Routes, nav items, and widgets are hidden immediately
|
||||
|
||||
## Import Aliases
|
||||
|
||||
| Alias | Resolves to | Use in |
|
||||
|-------|-------------|--------|
|
||||
| `@@/` | `src/servers/` | Server code (`createRouter`, `custom-errors`, `data-path`) |
|
||||
| `@/` | `src/apps/officer-web/` | Client code (`components/ui/*`, `Screens/*`, `state/*`) |
|
||||
| `hooks/` | `src/workspaces/hooks/src/` | Both (`useClient`, `useAuth`) |
|
||||
| `types` | `src/workspaces/types/` | Both |
|
||||
| `config` | `src/workspaces/config/` | Client code |
|
||||
| `plugins/` | `src/workspaces/plugins/` | Both |
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] `MyPlugin/index.ts` exports `plugin` metadata with `id` matching directory name
|
||||
- [ ] `MyPlugin/server/index.ts` exports `router` and `apiPath`
|
||||
- [ ] `MyPlugin/client/index.ts` exports `Widget` and/or `Screen`
|
||||
- [ ] `plugins/package.json` has subpath exports for the new plugin
|
||||
- [ ] App routes gated with `plugins?.MyPlugin !== false`
|
||||
- [ ] Nav items gated with `plugins?.MyPlugin !== false`
|
||||
- [ ] Home widgets gated with `plugins?.MyPlugin !== false`
|
||||
- [ ] `bunx tsgo` — no new type errors
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Terminal as XTerm } from '@xterm/xterm';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { DashboardLayout } from '@/Screens/Dashboard/Layout';
|
||||
import { useMounted } from 'hooks/useMounted';
|
||||
|
||||
export const Terminal = () => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const isMounted = useMounted();
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMounted) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Deferred init for StrictMode compatibility
|
||||
const initTimeout = setTimeout(() => {
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 14,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
theme: {
|
||||
background: '#1a1a2e',
|
||||
foreground: '#e0e0e0',
|
||||
cursor: '#e0e0e0',
|
||||
selectionBackground: '#3a3a5e',
|
||||
},
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
term.loadAddon(fitAddon);
|
||||
term.open(container);
|
||||
fitAddon.fit();
|
||||
|
||||
termRef.current = term;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// WebSocket connection
|
||||
const token = localStorage.getItem('BEARER_TOKEN');
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/api/terminal/ws?token=${token}`);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
// Send initial size
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
});
|
||||
|
||||
ws.addEventListener('message', (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
if (msg.type === 'output') {
|
||||
term.write(msg.data);
|
||||
} else if (msg.type === 'exit') {
|
||||
term.write('\r\n[Process exited]\r\n');
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener('close', () => {
|
||||
term.write('\r\n[Disconnected]\r\n');
|
||||
});
|
||||
|
||||
// Send terminal input to server
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'input', data }));
|
||||
}
|
||||
});
|
||||
|
||||
// ResizeObserver for auto-fitting
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
}
|
||||
});
|
||||
resizeObserver.observe(container);
|
||||
|
||||
// Store observer for cleanup
|
||||
(container as any).__resizeObserver = resizeObserver;
|
||||
}, 0);
|
||||
|
||||
return () => {
|
||||
clearTimeout(initTimeout);
|
||||
const resizeObserver = (container as any).__resizeObserver as ResizeObserver | undefined;
|
||||
resizeObserver?.disconnect();
|
||||
delete (container as any).__resizeObserver;
|
||||
wsRef.current?.close();
|
||||
wsRef.current = null;
|
||||
termRef.current?.dispose();
|
||||
termRef.current = null;
|
||||
fitAddonRef.current = null;
|
||||
};
|
||||
}, [isMounted]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<div ref={containerRef} className="h-full w-full p-2" style={{ backgroundColor: '#1a1a2e' }} />
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { Terminal as Screen } from './Screen';
|
||||
@@ -0,0 +1,8 @@
|
||||
export { Screen } from './client';
|
||||
export { terminalWebsocket } from './server';
|
||||
|
||||
export const plugin = {
|
||||
id: 'Terminal',
|
||||
name: 'Terminal',
|
||||
description: 'Browser-based terminal emulator',
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
FROM node:20-bookworm-slim
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y python3 make g++ zsh git curl ca-certificates fortune-mod cowsay \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pty-sidecar.mjs /app/pty-sidecar.mjs
|
||||
COPY templates /opt/terminal-templates
|
||||
|
||||
RUN npm init -y \
|
||||
&& npm install ws@8.18.1 node-pty@1.1.0
|
||||
|
||||
RUN curl -fsSL https://starship.rs/install.sh | sh -s -- -y -b /usr/local/bin
|
||||
|
||||
RUN git clone --depth 1 https://github.com/ohmyzsh/ohmyzsh.git /opt/oh-my-zsh
|
||||
|
||||
ENV EZA_VERSION=0.18.15
|
||||
RUN curl -fsSL "https://github.com/eza-community/eza/releases/download/v${EZA_VERSION}/eza_x86_64-unknown-linux-gnu.tar.gz" -o /tmp/eza.tar.gz \
|
||||
&& tar -xzf /tmp/eza.tar.gz -C /tmp \
|
||||
&& mv /tmp/eza /usr/local/bin/eza \
|
||||
&& chmod +x /usr/local/bin/eza \
|
||||
&& rm -rf /tmp/eza.tar.gz /tmp/completions /tmp/man
|
||||
|
||||
|
||||
WORKDIR /home/officer
|
||||
|
||||
ENV TERMINAL_PTY_PORT=5337
|
||||
|
||||
ENV PATH="/usr/games:${PATH}"
|
||||
|
||||
EXPOSE 5337
|
||||
|
||||
CMD ["node", "/app/pty-sidecar.mjs"]
|
||||
@@ -0,0 +1 @@
|
||||
export { terminalWebsocket } from './websocket';
|
||||
@@ -0,0 +1,146 @@
|
||||
import http from 'node:http';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { cp, mkdir } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { WebSocketServer } from 'ws';
|
||||
import * as pty from 'node-pty';
|
||||
|
||||
const port = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
|
||||
const host = process.env.TERMINAL_PTY_HOST ?? '127.0.0.1';
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
||||
res.end('terminal-sidecar');
|
||||
});
|
||||
|
||||
const wss = new WebSocketServer({ server });
|
||||
|
||||
const sendJson = (ws, msg) => {
|
||||
try {
|
||||
ws.send(JSON.stringify(msg));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const ensureUserFiles = async (homeDir) => {
|
||||
await mkdir(homeDir, { recursive: true });
|
||||
await mkdir(join(homeDir, '.config'), { recursive: true });
|
||||
|
||||
const zshrcPath = join(homeDir, '.zshrc');
|
||||
if (!existsSync(zshrcPath)) {
|
||||
await cp('/opt/terminal-templates/.zshrc', zshrcPath);
|
||||
}
|
||||
|
||||
const starshipPath = join(homeDir, '.config', 'starship-officer.toml');
|
||||
if (!existsSync(starshipPath)) {
|
||||
await cp('/opt/terminal-templates/starship-officer.toml', starshipPath);
|
||||
}
|
||||
|
||||
const ohMyZshPath = join(homeDir, '.oh-my-zsh');
|
||||
if (!existsSync(ohMyZshPath)) {
|
||||
await cp('/opt/oh-my-zsh', ohMyZshPath, { recursive: true });
|
||||
}
|
||||
};
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
let term = null;
|
||||
let initialized = false;
|
||||
|
||||
const cleanup = () => {
|
||||
if (term) {
|
||||
try {
|
||||
term.kill();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
term = null;
|
||||
};
|
||||
|
||||
ws.on('message', async (data) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(typeof data === 'string' ? data : data.toString());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'init' && !initialized) {
|
||||
const shell = msg.shell ?? { command: '/bin/bash', args: ['-i'] };
|
||||
const cwd = msg.cwd ?? process.cwd();
|
||||
const homeDir = msg.homeDir ?? process.cwd();
|
||||
const userLabel = msg.userLabel ?? 'officer';
|
||||
const prompt = `${userLabel} in %~ %# `;
|
||||
const bashPrompt = `${userLabel} \w \$ `;
|
||||
|
||||
try {
|
||||
await ensureUserFiles(homeDir);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
try {
|
||||
term = pty.spawn(shell.command, shell.args ?? [], {
|
||||
name: 'xterm-256color',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
ZDOTDIR: homeDir,
|
||||
ZSH: `${homeDir}/.oh-my-zsh`,
|
||||
SHELL: shell.command,
|
||||
USER: userLabel,
|
||||
LOGNAME: userLabel,
|
||||
OFFICER_TERMINAL_USER: userLabel,
|
||||
PROMPT: prompt,
|
||||
PS1: bashPrompt,
|
||||
TERM: 'xterm-256color',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to start terminal';
|
||||
sendJson(ws, { type: 'output', data: `\r\n[Terminal error] ${message}\r\n` });
|
||||
sendJson(ws, { type: 'exit' });
|
||||
return;
|
||||
}
|
||||
|
||||
initialized = true;
|
||||
|
||||
term.onData((output) => {
|
||||
sendJson(ws, { type: 'output', data: output });
|
||||
});
|
||||
|
||||
term.onExit(() => {
|
||||
sendJson(ws, { type: 'exit' });
|
||||
cleanup();
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!term) return;
|
||||
|
||||
switch (msg.type) {
|
||||
case 'input':
|
||||
term.write(msg.data ?? '');
|
||||
break;
|
||||
case 'resize':
|
||||
if (msg.cols > 0 && msg.rows > 0) term.resize(msg.cols, msg.rows);
|
||||
break;
|
||||
case 'cwd':
|
||||
if (msg.path) term.write(`cd ${JSON.stringify(msg.path)}\r`);
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(port, host, () => {
|
||||
console.log(`[terminal-sidecar] listening on ${host}:${port}`);
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
# If you come from bash you might have to change your $PATH.
|
||||
export PATH=$HOME/.local/bin:$PATH
|
||||
|
||||
# Path to your Oh My Zsh installation.
|
||||
export ZSH="$HOME/.oh-my-zsh"
|
||||
|
||||
# Set name of the theme to load --- if set to "random", it will
|
||||
# load a random theme each time Oh My Zsh is loaded, in which case,
|
||||
# to know which specific one was loaded, run: echo $RANDOM_THEME
|
||||
# See https://github.com/ohmyzsh/ohmyzsh/wiki/Themes
|
||||
ZSH_THEME=""
|
||||
|
||||
# Which plugins would you like to load?
|
||||
# Standard plugins can be found in $ZSH/plugins/
|
||||
# Custom plugins may be added to $ZSH_CUSTOM/plugins/
|
||||
# Example format: plugins=(rails git textmate ruby lighthouse)
|
||||
# Add wisely, as too many plugins slow down shell startup.
|
||||
plugins=(git)
|
||||
|
||||
source $ZSH/oh-my-zsh.sh
|
||||
|
||||
# ============================================================================
|
||||
# STARSHIP PROMPT
|
||||
# ============================================================================
|
||||
if [[ -n "$OFFICER_TERMINAL_USER" ]]; then
|
||||
export STARSHIP_CONFIG="$HOME/.config/starship-officer.toml"
|
||||
fi
|
||||
eval "$(starship init zsh)"
|
||||
|
||||
# ============================================================================
|
||||
# EZA ALIASES (colors and icons for ls)
|
||||
# ============================================================================
|
||||
alias ls='eza --icons'
|
||||
alias la='eza --icons -la'
|
||||
alias ll='eza --icons -l'
|
||||
alias lll='eza --icons -lA'
|
||||
alias lh='eza --icons -lhA'
|
||||
alias ltr='eza --icons -ltr'
|
||||
alias l='eza --icons -la'
|
||||
|
||||
# Other common aliases (oh-my-zsh standard)
|
||||
alias grep='grep --color=auto'
|
||||
alias less='less -R'
|
||||
alias diff='diff --color=auto'
|
||||
alias cp='cp -iv'
|
||||
alias mv='mv -iv'
|
||||
alias rm='rm -i'
|
||||
alias mkdir='mkdir -p'
|
||||
alias which='which -a'
|
||||
alias history='fc -l 1'
|
||||
|
||||
alias n="nvim"
|
||||
alias vim="n"
|
||||
alias sz="source ~/.zshrc"
|
||||
alias ld="lazydocker"
|
||||
alias hr="hyprctl reload"
|
||||
alias hir="omarchy-restart-hypridle"
|
||||
alias setupmines="WINEPREFIX=~/wine/minesweeper winecfg"
|
||||
alias httpserver="python -m http.server 8888"
|
||||
|
||||
clear
|
||||
fortune | cowsay
|
||||
@@ -0,0 +1,20 @@
|
||||
format = "$env_var:$hostname $directory $character"
|
||||
|
||||
[env_var]
|
||||
variable = "OFFICER_TERMINAL_USER"
|
||||
format = "[$env_value]($style)"
|
||||
style = "bold #0891B2"
|
||||
|
||||
[hostname]
|
||||
ssh_only = false
|
||||
format = "[officer.dev]($style)"
|
||||
style = "bold yellow"
|
||||
|
||||
[directory]
|
||||
truncation_length = 3
|
||||
truncate_to_repo = false
|
||||
style = "blue"
|
||||
|
||||
[character]
|
||||
success_symbol = ">"
|
||||
error_symbol = ">"
|
||||
@@ -0,0 +1,378 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { mkdirSync, existsSync, statSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { getHomeDir } from '@@/data-path';
|
||||
|
||||
type WSData = { userId: number; email: string };
|
||||
type ShellInfo = { command: string; args: string[]; name: string };
|
||||
type TerminalMode = 'host' | 'docker';
|
||||
type BridgeSession = {
|
||||
client: ServerWebSocket<WSData>;
|
||||
sidecar: WebSocket | null;
|
||||
mode: TerminalMode;
|
||||
dockerId?: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type ContainerInfo = {
|
||||
userId: number;
|
||||
email: string;
|
||||
dockerId: string;
|
||||
port: number;
|
||||
};
|
||||
|
||||
const sessions = new Map<ServerWebSocket<WSData>, BridgeSession>();
|
||||
const defaultSidecarPort = Number(process.env.TERMINAL_PTY_PORT ?? '5337');
|
||||
const containerMapPath = join(getHomeDir(''), '..', 'terminal-containers.json');
|
||||
|
||||
let sidecarProcess: Bun.Subprocess | null = null;
|
||||
let dockerImageReady = false;
|
||||
let containersCache: Record<string, ContainerInfo> | null = null;
|
||||
|
||||
const resolveShell = (): ShellInfo => {
|
||||
const envShell = process.env.SHELL?.trim();
|
||||
if (envShell) {
|
||||
const shellName = envShell.split('/').pop() ?? envShell;
|
||||
return {
|
||||
command: envShell,
|
||||
args: shellName === 'zsh' ? ['-d', '-i'] : shellName === 'bash' ? ['-i'] : ['-i'],
|
||||
name: shellName,
|
||||
};
|
||||
}
|
||||
|
||||
const candidates = ['/bin/zsh', '/usr/bin/zsh', '/bin/bash', '/usr/bin/bash'];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(candidate)) {
|
||||
const shellName = candidate.split('/').pop() ?? candidate;
|
||||
return {
|
||||
command: candidate,
|
||||
args: shellName === 'zsh' ? ['-d', '-i'] : shellName === 'bash' ? ['-i'] : ['-i'],
|
||||
name: shellName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const proc = Bun.spawnSync(['which', 'zsh'], { stdout: 'pipe', stderr: 'pipe' });
|
||||
if (proc.exitCode === 0) {
|
||||
const command = proc.stdout.toString().trim();
|
||||
return { command, args: ['-d', '-i'], name: 'zsh' };
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
|
||||
return { command: 'bash', args: ['-i'], name: 'bash' };
|
||||
};
|
||||
|
||||
const sendOutput = (ws: ServerWebSocket<WSData>, data: string) => {
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'output', data }));
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
};
|
||||
|
||||
const startSidecar = (port: number) => {
|
||||
if (sidecarProcess) return;
|
||||
const nodePath = Bun.which('node') ?? 'node';
|
||||
const sidecarPath = fileURLToPath(new URL('./pty-sidecar.mjs', import.meta.url));
|
||||
sidecarProcess = Bun.spawn({
|
||||
cmd: [nodePath, sidecarPath],
|
||||
env: { ...process.env, TERMINAL_PTY_PORT: String(port), TERMINAL_PTY_HOST: '127.0.0.1' },
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
sidecarProcess.exited.then(() => {
|
||||
sidecarProcess = null;
|
||||
});
|
||||
};
|
||||
|
||||
const connectSidecar = async (port: number, mode: TerminalMode): Promise<WebSocket> => {
|
||||
if (mode === 'host') startSidecar(port);
|
||||
|
||||
const delays = mode === 'docker' ? [200, 300, 500, 800, 1200, 1600, 2000] : [50, 150, 300, 600, 1200];
|
||||
let lastError: Error | null = null;
|
||||
|
||||
for (const delay of delays) {
|
||||
try {
|
||||
const ws = await new Promise<WebSocket>((resolve, reject) => {
|
||||
const socket = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
const timeout = setTimeout(() => {
|
||||
try {
|
||||
socket.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
reject(new Error('Terminal sidecar timeout'));
|
||||
}, 2000);
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
clearTimeout(timeout);
|
||||
resolve(socket);
|
||||
});
|
||||
socket.addEventListener('error', () => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error('Terminal sidecar connection failed'));
|
||||
});
|
||||
});
|
||||
|
||||
return ws;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err : new Error('Terminal sidecar connection failed');
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? new Error('Terminal sidecar connection failed');
|
||||
};
|
||||
|
||||
const getSettings = async (): Promise<{ terminalSandboxed?: boolean }> => {
|
||||
const settingsPath = `${homedir()}/.config/officer.dev/server-settings.json`;
|
||||
return await Bun.file(settingsPath)
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
};
|
||||
|
||||
const prebuildDockerImage = async () => {
|
||||
ensureDockerImage();
|
||||
};
|
||||
|
||||
const ensureDockerImage = () => {
|
||||
if (dockerImageReady) return;
|
||||
const dockerPath = Bun.which('docker');
|
||||
if (!dockerPath) throw new Error('Docker not found');
|
||||
|
||||
const tag = 'officer-terminal-sidecar:v1';
|
||||
const inspect = Bun.spawnSync({ cmd: [dockerPath, 'image', 'inspect', tag], stdout: 'ignore', stderr: 'ignore' });
|
||||
if (inspect.exitCode === 0) {
|
||||
dockerImageReady = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const dockerfilePath = fileURLToPath(new URL('./Dockerfile.terminal-sidecar', import.meta.url));
|
||||
const build = Bun.spawnSync({
|
||||
cmd: [dockerPath, 'build', '-f', dockerfilePath, '-t', tag, '.'],
|
||||
cwd: fileURLToPath(new URL('./', import.meta.url)),
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
if (build.exitCode !== 0) throw new Error('Failed to build terminal sandbox image');
|
||||
dockerImageReady = true;
|
||||
};
|
||||
|
||||
const startDockerSidecar = (port: number, homeDir: string, userId: number): { dockerId: string } => {
|
||||
ensureDockerImage();
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const dockerId = `officer-terminal-${userId}`;
|
||||
const tag = 'officer-terminal-sidecar:v1';
|
||||
|
||||
let userArgs: string[] = [];
|
||||
try {
|
||||
const stats = statSync(homeDir);
|
||||
userArgs = ['--user', `${stats.uid}:${stats.gid}`];
|
||||
} catch {
|
||||
userArgs = [];
|
||||
}
|
||||
|
||||
const run = Bun.spawnSync({
|
||||
cmd: [
|
||||
dockerPath,
|
||||
'run',
|
||||
'-d',
|
||||
'--name',
|
||||
dockerId,
|
||||
'--restart',
|
||||
'unless-stopped',
|
||||
...userArgs,
|
||||
'-p',
|
||||
`127.0.0.1:${port}:${port}`,
|
||||
'-e',
|
||||
`TERMINAL_PTY_PORT=${port}`,
|
||||
'-e',
|
||||
'TERMINAL_PTY_HOST=0.0.0.0',
|
||||
'-v',
|
||||
`${homeDir}:/home/officer`,
|
||||
'-w',
|
||||
'/home/officer',
|
||||
tag,
|
||||
],
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
});
|
||||
|
||||
if (run.exitCode !== 0) throw new Error('Failed to start terminal sandbox container');
|
||||
return { dockerId };
|
||||
};
|
||||
|
||||
const stopDockerSidecar = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
Bun.spawnSync({ cmd: [dockerPath, 'rm', '-f', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
};
|
||||
|
||||
const readDockerLogs = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const logs = Bun.spawnSync({ cmd: [dockerPath, 'logs', '--tail', '200', dockerId], stdout: 'pipe', stderr: 'pipe' });
|
||||
if (logs.exitCode !== 0) return '';
|
||||
return logs.stdout.toString().trim();
|
||||
};
|
||||
|
||||
const loadContainerMap = async (): Promise<Record<string, ContainerInfo>> => {
|
||||
if (containersCache) return containersCache;
|
||||
const data = await Bun.file(containerMapPath)
|
||||
.json()
|
||||
.catch(() => ({}));
|
||||
containersCache = data as Record<string, ContainerInfo>;
|
||||
return containersCache;
|
||||
};
|
||||
|
||||
const saveContainerMap = async (map: Record<string, ContainerInfo>) => {
|
||||
containersCache = map;
|
||||
await Bun.write(containerMapPath, JSON.stringify(map, null, 2));
|
||||
};
|
||||
|
||||
const getAvailablePort = (map: Record<string, ContainerInfo>, userId: number) => {
|
||||
const base = 54000;
|
||||
const used = new Set(Object.values(map).map((item) => item.port));
|
||||
let port = base + (userId % 1000);
|
||||
while (used.has(port)) port += 1;
|
||||
return port;
|
||||
};
|
||||
|
||||
const dockerContainerExists = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-a', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
|
||||
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
||||
};
|
||||
|
||||
const dockerContainerRunning = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({ cmd: [dockerPath, 'ps', '-q', '-f', `name=${dockerId}`], stdout: 'pipe' });
|
||||
return result.exitCode === 0 && result.stdout.toString().trim().length > 0;
|
||||
};
|
||||
|
||||
const dockerStart = (dockerId: string) => {
|
||||
const dockerPath = Bun.which('docker') ?? 'docker';
|
||||
const result = Bun.spawnSync({ cmd: [dockerPath, 'start', dockerId], stdout: 'ignore', stderr: 'ignore' });
|
||||
return result.exitCode === 0;
|
||||
};
|
||||
|
||||
const ensureDockerContainer = async (email: string, userId: number, homeDir: string) => {
|
||||
const map = await loadContainerMap();
|
||||
const existing = map[email];
|
||||
if (existing && dockerContainerRunning(existing.dockerId)) return existing;
|
||||
|
||||
if (existing && dockerContainerExists(existing.dockerId)) {
|
||||
if (dockerStart(existing.dockerId)) return existing;
|
||||
}
|
||||
|
||||
const port = existing?.port ?? getAvailablePort(map, userId);
|
||||
const docker = startDockerSidecar(port, homeDir, userId);
|
||||
const next = { userId, email, dockerId: docker.dockerId, port };
|
||||
map[email] = next;
|
||||
await saveContainerMap(map);
|
||||
return next;
|
||||
};
|
||||
|
||||
void prebuildDockerImage();
|
||||
|
||||
export const terminalWebsocket = {
|
||||
async open(ws: ServerWebSocket<WSData>) {
|
||||
const { email } = ws.data;
|
||||
const cwd = getHomeDir(email);
|
||||
const userRoot = dirname(cwd);
|
||||
mkdirSync(userRoot, { recursive: true });
|
||||
mkdirSync(cwd, { recursive: true });
|
||||
|
||||
const shell = resolveShell();
|
||||
const settings = await getSettings();
|
||||
const mode: TerminalMode = settings.terminalSandboxed ? 'docker' : 'host';
|
||||
const containerHome = '/home/officer';
|
||||
const containerShell: ShellInfo = { command: '/bin/zsh', args: ['-d', '-i'], name: 'zsh' };
|
||||
const port =
|
||||
mode === 'docker' ? (await ensureDockerContainer(email, ws.data.userId, cwd)).port : defaultSidecarPort;
|
||||
|
||||
let sidecar: WebSocket | null = null;
|
||||
let dockerId: string | undefined;
|
||||
try {
|
||||
if (mode === 'docker') {
|
||||
const info = await ensureDockerContainer(email, ws.data.userId, cwd);
|
||||
dockerId = info.dockerId;
|
||||
}
|
||||
|
||||
sidecar = await connectSidecar(port, mode);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to connect terminal sidecar';
|
||||
sendOutput(ws, `\r\n[Terminal error] ${message}\r\n`);
|
||||
if (dockerId) {
|
||||
const logs = readDockerLogs(dockerId);
|
||||
if (logs) {
|
||||
sendOutput(ws, `\r\n[Docker logs]\r\n${logs}\r\n`);
|
||||
}
|
||||
}
|
||||
sendOutput(ws, '\r\n[Process exited]\r\n');
|
||||
if (dockerId) stopDockerSidecar(dockerId);
|
||||
return;
|
||||
}
|
||||
|
||||
sessions.set(ws, { client: ws, sidecar, mode, dockerId, port });
|
||||
|
||||
sidecar.addEventListener('message', (ev) => {
|
||||
try {
|
||||
if (typeof ev.data === 'string') {
|
||||
ws.send(ev.data);
|
||||
} else {
|
||||
ws.send(new TextDecoder().decode(ev.data));
|
||||
}
|
||||
} catch {
|
||||
// ws already closed
|
||||
}
|
||||
});
|
||||
|
||||
const initCwd = mode === 'docker' ? containerHome : cwd;
|
||||
const initHome = mode === 'docker' ? containerHome : cwd;
|
||||
const initShell = mode === 'docker' ? containerShell : shell;
|
||||
sidecar.send(
|
||||
JSON.stringify({
|
||||
type: 'init',
|
||||
shell: initShell,
|
||||
cwd: initCwd,
|
||||
homeDir: initHome,
|
||||
userLabel: email,
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<WSData>, raw: string | Buffer) {
|
||||
const session = sessions.get(ws);
|
||||
if (!session?.sidecar || session.sidecar.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
try {
|
||||
const payload = typeof raw === 'string' ? raw : raw.toString();
|
||||
session.sidecar.send(payload);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<WSData>) {
|
||||
const session = sessions.get(ws);
|
||||
if (session?.sidecar) {
|
||||
try {
|
||||
session.sidecar.close();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (session?.dockerId) {
|
||||
// keep sandbox containers running for reuse
|
||||
}
|
||||
sessions.delete(ws);
|
||||
},
|
||||
|
||||
drain() {},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "plugins",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./FileBrowser": "./FileBrowser/index.ts",
|
||||
"./FileBrowser/client": "./FileBrowser/client/index.ts",
|
||||
"./FileBrowser/server": "./FileBrowser/server/index.ts",
|
||||
"./Terminal": "./Terminal/index.ts",
|
||||
"./Terminal/client": "./Terminal/client/index.ts",
|
||||
"./Terminal/server": "./Terminal/server/index.ts"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user