Files FIles Files

This commit is contained in:
2026-02-20 04:28:02 +00:00
parent 1f7eb64eb3
commit d7503ca56b
20 changed files with 2195 additions and 633 deletions
@@ -1,10 +1,15 @@
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import { ChevronUp, ChevronDown } from 'lucide-react';
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
import { getFileType } from 'apps/FileViewer';
import { FileItem } from './FileItem';
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type SortField = 'name' | 'size' | 'type' | 'date';
type SortDirection = 'asc' | 'desc';
type FileGridProps = {
entries: DirEntry[];
viewMode: 'grid' | 'list';
@@ -21,6 +26,11 @@ type FileGridProps = {
onCopy: () => void;
renamingName: string | null;
onRenamingChange: (name: string | null) => void;
onReadAloud: (entry: DirEntry) => void;
onOcr: (entry: DirEntry) => void;
onTranscribe: (entry: DirEntry) => void;
onExtractAudio: (entry: DirEntry) => void;
onExtract: (entry: DirEntry) => void;
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
@@ -30,6 +40,7 @@ type FileGridProps = {
const LIST_ROW_HEIGHT = 42;
const GRID_ROW_HEIGHT = 130;
const GRID_GAP = 12;
const HEADER_HEIGHT = 36;
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
const [cols, setCols] = useState(4);
@@ -71,21 +82,55 @@ export const FileGrid = ({
onCopy,
renamingName,
onRenamingChange,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
getMatchingTasks,
onRunTask,
onCreateWorkspace,
scrollRef,
}: FileGridProps) => {
const lastClickedIdx = useRef<number>(-1);
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const sorted = useMemo(
() =>
[...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
}),
[entries],
);
const sorted = useMemo(() => {
const compare = (a: DirEntry, b: DirEntry): number => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
let result: number;
switch (sortField) {
case 'name':
result = a.name.localeCompare(b.name);
break;
case 'size':
result = a.size - b.size;
break;
case 'type': {
const typeA = a.type === 'directory' ? 'directory' : getFileType(a.name);
const typeB = b.type === 'directory' ? 'directory' : getFileType(b.name);
result = typeA.localeCompare(typeB);
if (result === 0) result = a.name.localeCompare(b.name);
break;
}
case 'date':
result = a.modifiedAt - b.modifiedAt;
break;
}
return sortDirection === 'asc' ? result : -result;
};
return [...entries].sort(compare);
}, [entries, sortField, sortDirection]);
const toggleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
const handleSelect = useCallback(
(entry: DirEntry, ev: React.MouseEvent) => {
@@ -128,6 +173,7 @@ export const FileGrid = ({
getScrollElement: () => scrollRef.current,
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
overscan: 10,
scrollMargin: HEADER_HEIGHT,
});
if (entries.length === 0) {
@@ -153,44 +199,117 @@ export const FileGrid = ({
onCopy={onCopy}
forceRename={renamingName === entry.name}
onRenamingChange={onRenamingChange}
onReadAloud={onReadAloud}
onOcr={onOcr}
onTranscribe={onTranscribe}
onExtractAudio={onExtractAudio}
onExtract={onExtract}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask}
onCreateWorkspace={onCreateWorkspace}
/>
);
const sortIcon = (field: SortField) =>
sortField === field ? (
sortDirection === 'asc' ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)
) : null;
const sortBtnClass = (field: SortField) =>
`flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors ${
sortField === field ? 'text-duck-teal' : 'text-duck-dark/50 hover:text-duck-dark/80'
}`;
return (
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
if (viewMode === 'list') {
const entry = sorted[virtualRow.index]!;
<>
{viewMode === 'list' ? (
<div
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-3 px-3"
style={{ height: HEADER_HEIGHT }}
>
<span className="shrink-0 w-5" />
<span className="shrink-0 w-5" />
<button className={`flex-1 min-w-0 ${sortBtnClass('name')}`} onClick={() => toggleSort('name')}>
Name {sortIcon('name')}
</button>
<button
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('type')}`}
onClick={() => toggleSort('type')}
>
Type {sortIcon('type')}
</button>
<button
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('size')}`}
onClick={() => toggleSort('size')}
>
Size {sortIcon('size')}
</button>
<button
className={`hidden md:flex shrink-0 w-28 justify-end ${sortBtnClass('date')}`}
onClick={() => toggleSort('date')}
>
Date {sortIcon('date')}
</button>
<span className="shrink-0 w-8" />
</div>
) : (
<div
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-2 px-3"
style={{ height: HEADER_HEIGHT }}
>
<span className="text-xs text-duck-dark/40">Sort:</span>
{(['name', 'size', 'type', 'date'] as SortField[]).map((field) => (
<button
key={field}
onClick={() => toggleSort(field)}
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs cursor-pointer transition-colors ${
sortField === field
? 'bg-duck-teal/10 text-duck-teal font-medium'
: 'text-duck-dark/50 hover:bg-duck-dark/5'
}`}
>
{field.charAt(0).toUpperCase() + field.slice(1)}
{sortIcon(field)}
</button>
))}
</div>
)}
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
if (viewMode === 'list') {
const entry = sorted[virtualRow.index]!;
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full"
style={{ top: virtualRow.start, height: virtualRow.size }}
>
{renderItem(entry)}
</div>
);
}
const startIdx = virtualRow.index * cols;
const rowEntries = sorted.slice(startIdx, startIdx + cols);
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full"
style={{ top: virtualRow.start, height: virtualRow.size }}
className="absolute left-0 w-full grid gap-3"
style={{
top: virtualRow.start,
height: virtualRow.size - GRID_GAP,
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
}}
>
{renderItem(entry)}
{rowEntries.map(renderItem)}
</div>
);
}
const startIdx = virtualRow.index * cols;
const rowEntries = sorted.slice(startIdx, startIdx + cols);
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full grid gap-3"
style={{
top: virtualRow.start,
height: virtualRow.size - GRID_GAP,
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
}}
>
{rowEntries.map(renderItem)}
</div>
);
})}
</div>
})}
</div>
</>
);
};
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid } from 'lucide-react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid, Volume2, ScanText, FileText, AudioLines, FolderArchive } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
@@ -23,6 +23,7 @@ import {
} from '@/components/ui/context-menu';
import { cardStyle } from '@/components/Card';
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
import { getFileType } from 'apps/FileViewer';
export type FileItemProps = {
entry: DirEntry;
@@ -41,6 +42,11 @@ export type FileItemProps = {
onCopy: () => void;
forceRename: boolean;
onRenamingChange: (name: string | null) => void;
onReadAloud: (entry: DirEntry) => void;
onOcr: (entry: DirEntry) => void;
onTranscribe: (entry: DirEntry) => void;
onExtractAudio: (entry: DirEntry) => void;
onExtract: (entry: DirEntry) => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
@@ -67,6 +73,11 @@ type MenuItemsProps = {
onStartRename: () => void;
onChat: (e: DirEntry) => void;
onDownload: (e: DirEntry) => void;
onReadAloud: (e: DirEntry) => void;
onOcr: (e: DirEntry) => void;
onTranscribe: (e: DirEntry) => void;
onExtractAudio: (e: DirEntry) => void;
onExtract: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
matchingTasks: TaskSummary[];
@@ -81,12 +92,25 @@ const DropdownMenuItems = ({
onStartRename,
onChat,
onDownload,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
onCut,
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => (
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showOcr = fileType === 'image';
const showTranscribe = fileType === 'audio';
const showExtractAudio = fileType === 'video';
const showExtract = fileType === 'archive';
return (
<>
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
@@ -96,6 +120,36 @@ const DropdownMenuItems = ({
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
{showReadAloud && (
<DropdownMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</DropdownMenuItem>
)}
{showOcr && (
<DropdownMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
</DropdownMenuItem>
)}
{showTranscribe && (
<DropdownMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
</DropdownMenuItem>
)}
{showExtractAudio && (
<DropdownMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
</DropdownMenuItem>
)}
{showExtract && (
<DropdownMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</DropdownMenuItem>
)}
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
@@ -141,7 +195,8 @@ const DropdownMenuItems = ({
Delete
</DropdownMenuItem>
</>
);
);
};
const ContextMenuItems = ({
entry,
@@ -150,12 +205,25 @@ const ContextMenuItems = ({
onStartRename,
onChat,
onDownload,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
onCut,
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => (
}: MenuItemsProps) => {
const fileType = entry.type === 'file' ? getFileType(entry.name) : null;
const showReadAloud = fileType === 'markdown' || fileType === 'code' || fileType === 'text';
const showOcr = fileType === 'image';
const showTranscribe = fileType === 'audio';
const showExtractAudio = fileType === 'video';
const showExtract = fileType === 'archive';
return (
<>
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
@@ -165,6 +233,36 @@ const ContextMenuItems = ({
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
{showReadAloud && (
<ContextMenuItem onClick={() => onReadAloud(entry)} className="cursor-pointer">
<Volume2 className="mr-2 h-4 w-4" />
Read Aloud
</ContextMenuItem>
)}
{showOcr && (
<ContextMenuItem onClick={() => onOcr(entry)} className="cursor-pointer">
<ScanText className="mr-2 h-4 w-4" />
Extract Text (OCR)
</ContextMenuItem>
)}
{showTranscribe && (
<ContextMenuItem onClick={() => onTranscribe(entry)} className="cursor-pointer">
<FileText className="mr-2 h-4 w-4" />
Transcribe
</ContextMenuItem>
)}
{showExtractAudio && (
<ContextMenuItem onClick={() => onExtractAudio(entry)} className="cursor-pointer">
<AudioLines className="mr-2 h-4 w-4" />
Extract Audio
</ContextMenuItem>
)}
{showExtract && (
<ContextMenuItem onClick={() => onExtract(entry)} className="cursor-pointer">
<FolderArchive className="mr-2 h-4 w-4" />
Extract
</ContextMenuItem>
)}
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
@@ -210,7 +308,8 @@ const ContextMenuItems = ({
Delete
</ContextMenuItem>
</>
);
);
};
const EllipsisMenu = (props: MenuItemsProps) => (
<div onClick={(ev) => ev.stopPropagation()}>
@@ -320,6 +419,11 @@ export const FileItem = ({
onCopy,
forceRename,
onRenamingChange,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
matchingTasks,
onRunTask,
onCreateWorkspace,
@@ -395,6 +499,11 @@ export const FileItem = ({
onStartRename: () => setRenaming(true),
onChat,
onDownload,
onReadAloud,
onOcr,
onTranscribe,
onExtractAudio,
onExtract,
onCut,
onCopy,
matchingTasks,
@@ -430,6 +539,9 @@ export const FileItem = ({
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
)}
</div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
{isDir ? 'Folder' : getFileType(entry.name).replace(/^\w/, (c) => c.toUpperCase())}
</span>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
{isDir ? '--' : formatSize(entry.size)}
</span>
File diff suppressed because it is too large Load Diff
@@ -5,7 +5,6 @@ import {
Loader2,
LayoutGrid,
List,
ClipboardPaste,
FolderPlus,
Search,
@@ -18,15 +17,16 @@ import {
Code,
Eye,
EyeOff,
Upload,
} from 'lucide-react';
import { getIcon } from 'material-file-icons';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
import { useClient } from 'hooks/useClient';
import { useUserState } from '@/state/useUserState';
import { useAuth } from 'hooks/useAuth';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { FileGrid } from './FileGrid';
import { FileViewer } from './FileViewer';
import { TaskRunnerModal } from './TaskRunnerModal';
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
@@ -39,6 +39,7 @@ type FilesProps = {
export const Files = ({ basePath = '/' }: FilesProps) => {
const { user } = useAuth();
const client = useClient();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
@@ -58,13 +59,14 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
const [showCloneInput, setShowCloneInput] = useState(false);
const [cloneUrl, setCloneUrl] = useState('');
const [cloning, setCloning] = useState(false);
const [dragging, setDragging] = useState(false);
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
const dragCounter = useRef(0);
const { getMatchingTasks } = useTasks();
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const searchInputRef = useRef<HTMLInputElement | null>(null);
const fileScrollRef = useRef<HTMLDivElement | null>(null);
const viewPath = searchParams.get('view');
const viewerFileName = viewPath ? viewPath.split('/').pop()! : '';
const files = useFiles(homeRoot);
const filesRef = useRef(files);
filesRef.current = files;
@@ -174,7 +176,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
}
};
const handleUpload = async (fileList: FileList) => {
const handleUpload = async (fileList: FileList | File[]) => {
const count = fileList.length;
setUploadProgress(0);
try {
@@ -286,6 +288,67 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
navigate(`/workspaces/new?${params}`);
};
const handleReadAloud = async (entry: DirEntry) => {
const filePath = entryPath(entry.name);
const toastId = toast.loading('Generating speech audio...');
try {
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root: homeRoot });
toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
} catch {
toast.error('Failed to generate speech audio', { id: toastId });
}
};
const handleOcr = async (entry: DirEntry) => {
const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting text from image...');
try {
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root: homeRoot });
toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: ocrPath, ephemeralRoot: ocrRoot });
} catch {
toast.error('Failed to extract text from image', { id: toastId });
}
};
const handleTranscribe = async (entry: DirEntry) => {
const filePath = entryPath(entry.name);
const toastId = toast.loading('Transcribing audio...');
try {
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root: homeRoot });
toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: transcriptionPath, ephemeralRoot: transcriptionRoot });
} catch {
toast.error('Failed to transcribe audio', { id: toastId });
}
};
const handleExtractAudio = async (entry: DirEntry) => {
const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting audio from video...');
try {
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root: homeRoot });
toast.dismiss(toastId);
setSearchParams({ view: filePath, ephemeral: audioPath, ephemeralRoot: audioRoot });
} catch {
toast.error('Failed to extract audio from video', { id: toastId });
}
};
const handleExtract = async (entry: DirEntry) => {
const filePath = entryPath(entry.name);
const toastId = toast.loading('Extracting archive...');
try {
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root: homeRoot });
const folderName = extractedPath.split('/').pop() ?? extractedPath;
toast.success(`Extracted to "${folderName}"`, { id: toastId });
await refresh();
} catch {
toast.error('Failed to extract archive', { id: toastId });
}
};
const handleGitClone = async () => {
const url = cloneUrl.trim();
if (!url) return;
@@ -318,21 +381,86 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
};
const handlePaste = async () => {
if (!clipboard) return;
// Internal file clipboard takes priority
if (clipboard) {
try {
if (clipboard.mode === 'copy') {
await files.copy(clipboard.paths, currentPath);
} else {
await files.move(clipboard.paths, currentPath);
setClipboard(null);
}
await refresh();
toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`);
} catch {
toast.error('Failed to paste');
}
return;
}
// Try system clipboard for images/files
try {
if (clipboard.mode === 'copy') {
await files.copy(clipboard.paths, currentPath);
} else {
await files.move(clipboard.paths, currentPath);
setClipboard(null);
const clipboardItems = await navigator.clipboard.read();
const imageFiles: File[] = [];
for (const item of clipboardItems) {
for (const type of item.types) {
if (type.startsWith('image/')) {
const blob = await item.getType(type);
const ext = type.split('/')[1] ?? 'png';
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
imageFiles.push(new File([blob], `clipboard-${ts}.${ext}`, { type }));
}
}
}
if (imageFiles.length > 0) {
const dt = new DataTransfer();
imageFiles.forEach((f) => dt.items.add(f));
await handleUpload(dt.files);
}
await refresh();
toast.success(`Pasted ${clipboard.paths.length} item${clipboard.paths.length !== 1 ? 's' : ''}`);
} catch {
toast.error('Failed to paste');
// Clipboard API not available or permission denied — silently ignore
}
};
// Clipboard image paste
useEffect(() => {
const handler = (ev: ClipboardEvent) => {
const tag = (ev.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
const items = ev.clipboardData?.items;
if (!items) return;
const imageFiles: File[] = [];
for (let i = 0; i < items.length; i++) {
const item = items[i]!;
if (item.type.startsWith('image/')) {
const file = item.getAsFile();
if (file) imageFiles.push(file);
}
}
if (imageFiles.length === 0) return;
ev.preventDefault();
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const ts = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
const dt = new DataTransfer();
imageFiles.forEach((file, i) => {
const ext = file.type.split('/')[1] ?? 'png';
const name = imageFiles.length === 1 ? `clipboard-${ts}.${ext}` : `clipboard-${ts}-${i + 1}.${ext}`;
dt.items.add(new File([file], name, { type: file.type }));
});
handleUpload(dt.files);
};
window.addEventListener('paste', handler);
return () => window.removeEventListener('paste', handler);
}, [currentPath, homeRoot]);
// Keyboard shortcuts
useEffect(() => {
const handler = (ev: KeyboardEvent) => {
@@ -378,8 +506,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
}
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
ev.preventDefault();
handlePaste();
if (clipboard) {
ev.preventDefault();
handlePaste();
}
return;
}
@@ -409,6 +539,91 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
return () => window.removeEventListener('keydown', handler);
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
const handleDragEnter = (ev: React.DragEvent) => {
ev.preventDefault();
dragCounter.current++;
if (ev.dataTransfer.types.includes('Files')) {
setDragging(true);
}
};
const handleDragLeave = (ev: React.DragEvent) => {
ev.preventDefault();
dragCounter.current--;
if (dragCounter.current === 0) {
setDragging(false);
}
};
const handleDragOver = (ev: React.DragEvent) => {
ev.preventDefault();
};
const handleDrop = async (ev: React.DragEvent) => {
ev.preventDefault();
dragCounter.current = 0;
setDragging(false);
const items = ev.dataTransfer.items;
if (!items || items.length === 0) return;
// Collect FileSystemEntry objects (supports folders)
const entries: FileSystemEntry[] = [];
for (let i = 0; i < items.length; i++) {
const entry = items[i]!.webkitGetAsEntry?.();
if (entry) entries.push(entry);
}
// If no entries (browser doesn't support webkitGetAsEntry), fall back to files
if (entries.length === 0) {
if (ev.dataTransfer.files.length > 0) {
handleUpload(ev.dataTransfer.files);
}
return;
}
// Recursively read all files from entries
const allFiles: File[] = [];
const readEntry = (entry: FileSystemEntry, path: string): Promise<void> => {
if (entry.isFile) {
return new Promise((resolve) => {
(entry as FileSystemFileEntry).file((file) => {
allFiles.push(new File([file], path + file.name, { type: file.type, lastModified: file.lastModified }));
resolve();
});
});
}
if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
return new Promise((resolve) => {
const readBatch = () => {
reader.readEntries(async (batch) => {
if (batch.length === 0) {
resolve();
return;
}
for (const child of batch) {
await readEntry(child, path + entry.name + '/');
}
readBatch(); // readEntries may not return all at once
});
};
readBatch();
});
}
return Promise.resolve();
};
for (const entry of entries) {
await readEntry(entry, '');
}
if (allFiles.length > 0) {
handleUpload(allFiles);
}
};
const handleBackgroundClick = (ev: React.MouseEvent) => {
if ((ev.target as HTMLElement).closest('[data-file-item]')) return;
setSelected(new Set());
@@ -583,7 +798,21 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
)}
{/* File content */}
<div className="flex-1 min-h-0">
<div
className="flex-1 min-h-0 relative"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
{dragging && (
<div className="absolute inset-0 z-50 flex items-center justify-center bg-duck-teal/10 border-2 border-dashed border-duck-teal rounded-lg m-2 pointer-events-none">
<div className="flex flex-col items-center gap-2 text-duck-teal">
<Upload className="h-8 w-8" />
<span className="text-sm font-medium">Drop files to upload</span>
</div>
</div>
)}
{searchQuery.trim() ? (
<div className="h-full overflow-auto p-4">
{searching ? (
@@ -647,6 +876,11 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
onCopy={handleCopy}
renamingName={renamingName}
onRenamingChange={setRenamingName}
onReadAloud={handleReadAloud}
onOcr={handleOcr}
onTranscribe={handleTranscribe}
onExtractAudio={handleExtractAudio}
onExtract={handleExtract}
getMatchingTasks={getMatchingTasks}
onRunTask={handleRunTask}
onCreateWorkspace={handleCreateWorkspace}
@@ -656,7 +890,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItem onClick={handlePaste} disabled={!clipboard} className="cursor-pointer">
<ContextMenuItem onClick={handlePaste} className="cursor-pointer">
<ClipboardPaste className="mr-2 h-4 w-4" />
Paste
</ContextMenuItem>
@@ -680,16 +914,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
</div>
</div>
<FileViewer
open={viewPath !== null}
onOpenChange={(open) => {
if (!open) setSearchParams({});
}}
filePath={viewPath ?? ''}
fileName={viewerFileName}
root={homeRoot}
/>
{runningTask && (
<TaskRunnerModal
open
@@ -1,23 +1,218 @@
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import type { ReactNode } from 'react';
import { useSearchParams } from 'react-router';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
import { useUserState } from '@/state/useUserState';
import { appRegistry } from '../Workspaces/app-registry';
import { Files as FilesInner } from './Screen';
export { FilesInner as Files };
const layout: LayoutNode = {
const baseLayout: LayoutNode = {
type: 'panel',
id: 'files-main',
appType: null,
};
const viewerLayout: LayoutNode = {
type: 'group',
id: 'files-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'files-main', appType: null }, size: 40 },
{ node: { type: 'panel', id: 'files-viewer', appType: null }, size: 60 },
],
};
const viewerWithEphemeralLayout: LayoutNode = {
type: 'group',
id: 'files-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'files-main', appType: null }, size: 40 },
{
node: {
type: 'group',
id: 'files-viewer-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'files-viewer', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'files-ephemeral', appType: null }, size: 50 },
],
},
size: 60,
},
],
};
const viewerWithEphemeralSplitLayout: LayoutNode = {
type: 'group',
id: 'files-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'files-main', appType: null }, size: 40 },
{
node: {
type: 'group',
id: 'files-viewer-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'files-viewer', appType: null }, size: 50 },
{
node: {
type: 'group',
id: 'files-ephemeral-group',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'files-ephemeral', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'files-ephemeral2', appType: null }, size: 50 },
],
},
size: 50,
},
],
},
size: 60,
},
],
};
const FilesFileViewerProvider = ({ children }: { children: ReactNode }) => {
const [searchParams, setSearchParams] = useSearchParams();
const [homeRoot] = useUserState<string>('files/homeRoot', 'home');
const viewPath = searchParams.get('view');
const fileName = viewPath ? viewPath.split('/').pop()! : '';
const handleOpenFile = useCallback(
(filePath: string, root: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('ephemeral', filePath);
next.set('ephemeralRoot', root);
return next;
});
},
[setSearchParams],
);
if (!viewPath) return null;
return (
<FileViewerProvider filePath={viewPath} fileName={fileName} root={homeRoot} onOpenFile={handleOpenFile}>
{children}
</FileViewerProvider>
);
};
const EphemeralFileViewerProvider = ({ children }: { children: ReactNode }) => {
const [searchParams, setSearchParams] = useSearchParams();
const ephemeralPath = searchParams.get('ephemeral');
const ephemeralRoot = searchParams.get('ephemeralRoot') ?? 'home';
const fileName = ephemeralPath ? ephemeralPath.split('/').pop()! : '';
const handleOpenFile = useCallback(
(filePath: string, root: string) => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set('ephemeral2', filePath);
next.set('ephemeral2Root', root);
next.set('ephemeral2Auto', '1');
return next;
});
},
[setSearchParams],
);
if (!ephemeralPath) return null;
return (
<FileViewerProvider filePath={ephemeralPath} fileName={fileName} root={ephemeralRoot} onOpenFile={handleOpenFile}>
{children}
</FileViewerProvider>
);
};
const Ephemeral2FileViewerProvider = ({ children }: { children: ReactNode }) => {
const [searchParams] = useSearchParams();
const path = searchParams.get('ephemeral2');
const root = searchParams.get('ephemeral2Root') ?? 'home';
const autoPlay = searchParams.get('ephemeral2Auto') === '1';
const fileName = path ? path.split('/').pop()! : '';
if (!path) return null;
return (
<FileViewerProvider filePath={path} fileName={fileName} root={root} autoPlay={autoPlay}>
{children}
</FileViewerProvider>
);
};
export const FilesPage = () => {
const [searchParams, setSearchParams] = useSearchParams();
const viewPath = searchParams.get('view');
const ephemeralPath = searchParams.get('ephemeral');
const ephemeral2Path = searchParams.get('ephemeral2');
const layout = useMemo(
() =>
viewPath && ephemeralPath && ephemeral2Path
? viewerWithEphemeralSplitLayout
: viewPath && ephemeralPath
? viewerWithEphemeralLayout
: viewPath
? viewerLayout
: baseLayout,
[viewPath, ephemeralPath, ephemeral2Path],
);
const closeEphemeral = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('ephemeral');
next.delete('ephemeralRoot');
next.delete('ephemeral2');
next.delete('ephemeral2Root');
next.delete('ephemeral2Auto');
return next;
});
}, [setSearchParams]);
const closeEphemeral2 = useCallback(() => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete('ephemeral2');
next.delete('ephemeral2Root');
next.delete('ephemeral2Auto');
return next;
});
}, [setSearchParams]);
const panelComponents: PanelComponents = useMemo(
() => ({
'files-main': FilesInner,
'files-viewer': {
provider: FilesFileViewerProvider,
header: FileViewerHeader,
component: FileViewerBody,
onClose: () => setSearchParams({}),
},
'files-ephemeral': {
provider: EphemeralFileViewerProvider,
header: FileViewerHeader,
component: FileViewerBody,
onClose: closeEphemeral,
},
'files-ephemeral2': {
provider: Ephemeral2FileViewerProvider,
header: FileViewerHeader,
component: FileViewerBody,
onClose: closeEphemeral2,
},
}),
[],
[setSearchParams, closeEphemeral, closeEphemeral2],
);
return (
@@ -1,7 +1,8 @@
import { useState } from 'react';
import { Box, Circle, Server, Wrench } from 'lucide-react';
import { Circle, Server, Wrench } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
type ResourceItemProps = {
@@ -24,46 +25,61 @@ const ResourceItem = ({ resource: r, isActive, onSelect }: ResourceItemProps) =>
<div className="text-sm font-medium truncate">{r.name}</div>
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
</div>
<Circle
className={`h-2 w-2 shrink-0 mt-1.5 ${r.installed ? 'fill-green-500 text-green-500' : 'fill-duck-dark/20 text-duck-dark/20'}`}
/>
<Circle className="h-2 w-2 shrink-0 mt-1.5 fill-green-500 text-green-500" />
</button>
);
export const ResourceSidebar = () => {
const { resources, isLoading } = useResources();
const [selectedId, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [showCatalog, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
const [search, setSearch] = useState('');
const query = search.toLowerCase();
const filtered = resources?.filter(
(r: Resource) =>
r.name.toLowerCase().includes(query) ||
r.subtitle.toLowerCase().includes(query) ||
r.description.toLowerCase().includes(query),
);
const installed = resources?.filter((r: Resource) => r.installed) ?? [];
const apiBased = filtered?.filter((r: Resource) => getResourceCategory(r) === 'api-based') ?? [];
const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? [];
const query = search.toLowerCase();
const filtered = query
? installed.filter(
(r: Resource) => r.name.toLowerCase().includes(query) || r.subtitle.toLowerCase().includes(query),
)
: installed;
const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based');
const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli');
const handleCatalog = () => {
setSelectedId(null);
setShowCatalog(true);
};
const handleSelect = (id: string) => {
setShowCatalog(false);
setSelectedId(id);
};
return (
<div className="flex flex-col h-full overflow-y-auto">
<div className="p-3 pb-2">
<div className="flex items-center gap-2.5 rounded-lg px-3 py-2 text-sm font-medium bg-duck-teal/15 text-duck-teal">
<Box className="h-4 w-4" />
Resources
</div>
</div>
<div className="px-3 pb-2">
<div className="flex flex-col h-full">
<div className="shrink-0 px-3 pt-3 pb-2 flex flex-col gap-2">
<h2 className="text-sm font-semibold text-duck-dark px-1">Resources</h2>
<Button
size="sm"
onClick={handleCatalog}
className="w-full text-xs cursor-pointer bg-duck-teal text-white hover:bg-duck-teal/90"
>
Catalog
</Button>
<Input
placeholder="Search..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs"
className="h-7 text-xs"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
{isLoading && <p className="text-xs text-duck-dark/50 px-3 py-2">Loading...</p>}
{!isLoading && filtered.length === 0 && (
<p className="text-xs text-duck-dark/40 px-3 py-2">{search ? 'No matches' : 'No active resources'}</p>
)}
{apiBased.length > 0 && (
<>
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
@@ -74,8 +90,8 @@ export const ResourceSidebar = () => {
<ResourceItem
key={r.id}
resource={r}
isActive={selectedId === r.id}
onSelect={() => setSelectedId(r.id)}
isActive={!showCatalog && selectedId === r.id}
onSelect={() => handleSelect(r.id)}
/>
))}
</>
@@ -90,8 +106,8 @@ export const ResourceSidebar = () => {
<ResourceItem
key={r.id}
resource={r}
isActive={selectedId === r.id}
onSelect={() => setSelectedId(r.id)}
isActive={!showCatalog && selectedId === r.id}
onSelect={() => handleSelect(r.id)}
/>
))}
</>
@@ -1,39 +1,22 @@
import { useState } from 'react';
import { Copy, Check, Server, Wrench, Loader2 } from 'lucide-react';
import { Server, Wrench, Loader2 } from 'lucide-react';
import { useGlobal } from 'hooks/useGlobal';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { CommandBlock } from '@/components/CommandBlock';
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
const CopyCommand = ({ command }: { command: string }) => {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(command);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
};
return (
<div className="flex items-center gap-1 mt-1">
<code className="flex-1 bg-duck-dark/5 rounded px-2 py-1 text-xs text-duck-dark/70">{command}</code>
<button
type="button"
onClick={copy}
className="shrink-0 p-1 rounded hover:bg-duck-dark/10 cursor-pointer transition-colors"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5 text-duck-dark/50" />}
</button>
</div>
);
};
const CommandRow = ({ label, command }: { label: string; command: string }) => (
<div className="text-xs text-duck-dark/50">
{label}:
<CopyCommand command={command} />
</div>
);
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
type ConnectionSectionProps = {
resource: Resource;
@@ -147,7 +130,12 @@ const ConnectionSection = ({ resource }: ConnectionSectionProps) => {
);
};
const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
type LocalAvailabilitySectionProps = {
resource: Resource;
onRun: (command: string) => void;
};
const LocalAvailabilitySection = ({ resource, onRun }: LocalAvailabilitySectionProps) => (
<div>
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
<div className="mb-3">
@@ -159,60 +147,203 @@ const LocalAvailabilitySection = ({ resource }: { resource: Resource }) => (
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
)}
</div>
<div className="flex flex-col gap-2">
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />}
{resource.uninstallCommand && <CommandRow label="Uninstall" command={resource.uninstallCommand} />}
{resource.manageCommand && <CommandRow label="Manage" command={resource.manageCommand} />}
{resource.verifyCommand && <CommandRow label="Verify" command={resource.verifyCommand} />}
{resource.updateCommand && <CommandRow label="Update" command={resource.updateCommand} />}
<div className="flex flex-col gap-3">
{resource.installCommand && <CommandBlock label="Install" command={resource.installCommand} onRun={onRun} />}
{resource.uninstallCommand && (
<CommandBlock label="Uninstall" command={resource.uninstallCommand} onRun={onRun} />
)}
{resource.manageCommand && <CommandBlock label="Manage" command={resource.manageCommand} onRun={onRun} />}
{resource.verifyCommand && <CommandBlock label="Verify" command={resource.verifyCommand} onRun={onRun} />}
{resource.updateCommand && <CommandBlock label="Update" command={resource.updateCommand} onRun={onRun} />}
</div>
</div>
);
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
type CatalogCardProps = {
resource: Resource;
onSelect: (id: string) => void;
};
const resource = resources?.find((r: Resource) => r.id === selectedId);
if (!resource) {
return (
<div className="h-full flex items-center justify-center">
<p className="text-sm text-duck-dark/30">Select a resource to view details</p>
const CatalogCard = ({ resource: r, onSelect }: CatalogCardProps) => {
const category = getResourceCategory(r);
return (
<button
onClick={() => onSelect(r.id)}
className="flex flex-col gap-2 p-4 rounded-lg border border-duck-dark/10 text-left cursor-pointer transition-colors hover:border-duck-teal/30 hover:bg-duck-teal/5"
>
<div className="flex items-center gap-2">
{r.port ? (
<Server className="h-3.5 w-3.5 text-duck-teal shrink-0" />
) : (
<Wrench className="h-3.5 w-3.5 text-duck-dark/40 shrink-0" />
)}
<span className="text-sm font-medium text-duck-dark truncate">{r.name}</span>
</div>
);
}
<p className="text-xs text-duck-dark/40 line-clamp-1">{r.subtitle}</p>
<div className="flex items-center gap-1.5">
<span className="text-[10px] bg-duck-dark/5 text-duck-dark/40 rounded-full px-1.5 py-0.5">{r.type}</span>
<span
className={`text-[10px] rounded-full px-1.5 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/40'}`}
>
{category === 'api-based' ? 'API' : 'CLI'}
</span>
</div>
</button>
);
};
const category = getResourceCategory(resource);
const ResourceCatalog = () => {
const { resources, isLoading } = useResources();
const [, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
const [search, setSearch] = useState('');
const query = search.toLowerCase();
const filtered =
resources?.filter(
(r: Resource) =>
!r.installed &&
(r.name.toLowerCase().includes(query) ||
r.subtitle.toLowerCase().includes(query) ||
r.description.toLowerCase().includes(query)),
) ?? [];
const apiBased = filtered.filter((r: Resource) => getResourceCategory(r) === 'api-based');
const localCli = filtered.filter((r: Resource) => getResourceCategory(r) === 'local-cli');
const handleSelect = (id: string) => {
setShowCatalog(false);
setSelectedId(id);
};
return (
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1">
{resource.port ? (
<Server className="h-4 w-4 text-duck-teal shrink-0" />
) : (
<Wrench className="h-4 w-4 text-duck-dark/40 shrink-0" />
)}
<h2 className="text-lg font-bold text-duck-dark">{resource.name}</h2>
<span className="text-sm text-duck-dark/40">{resource.subtitle}</span>
</div>
<div className="flex items-center gap-2 mb-4">
<span className="text-xs bg-duck-dark/5 text-duck-dark/50 rounded-full px-2 py-0.5">{resource.type}</span>
<span
className={`text-xs rounded-full px-2 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/50'}`}
>
{category === 'api-based' ? 'API Based' : 'Local CLI'}
</span>
{resource.port && (
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
)}
</div>
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
<LocalAvailabilitySection resource={resource} />
<h2 className="text-lg font-bold text-duck-dark mb-1">Resource Catalog</h2>
<p className="text-sm text-duck-dark/40 mb-4">All available resources. Select one to configure.</p>
<Input
placeholder="Search resources..."
value={search}
onChange={(ev) => setSearch(ev.target.value)}
className="h-8 text-xs mb-4 max-w-xs"
/>
{isLoading && <p className="text-xs text-duck-dark/50">Loading...</p>}
{apiBased.length > 0 && (
<div className="mb-6">
<div className="flex items-center gap-2 mb-3">
<Server className="h-3.5 w-3.5 text-duck-teal" />
<span className="text-xs font-semibold uppercase tracking-wider text-duck-dark/40">API Based</span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
{apiBased.map((r: Resource) => (
<CatalogCard key={r.id} resource={r} onSelect={handleSelect} />
))}
</div>
</div>
)}
{localCli.length > 0 && (
<div>
<div className="flex items-center gap-2 mb-3">
<Wrench className="h-3.5 w-3.5 text-duck-dark/40" />
<span className="text-xs font-semibold uppercase tracking-wider text-duck-dark/40">Local CLI</span>
</div>
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3">
{localCli.map((r: Resource) => (
<CatalogCard key={r.id} resource={r} onSelect={handleSelect} />
))}
</div>
</div>
)}
</div>
);
};
const ResourceDetail = ({ resource }: { resource: Resource }) => {
const category = getResourceCategory(resource);
const [, setRunCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [confirmCommand, setConfirmCommand] = useState<string | null>(null);
const handleRun = (command: string) => {
const isSudo = command.trimStart().startsWith('sudo');
if (isSudo) {
setConfirmCommand(command);
} else {
setRunCommand({ command });
}
};
return (
<>
<div className="h-full overflow-y-auto p-6">
<div className="flex items-center gap-2 mb-1">
{resource.port ? (
<Server className="h-4 w-4 text-duck-teal shrink-0" />
) : (
<Wrench className="h-4 w-4 text-duck-dark/40 shrink-0" />
)}
<h2 className="text-lg font-bold text-duck-dark">{resource.name}</h2>
<span className="text-sm text-duck-dark/40">{resource.subtitle}</span>
</div>
<div className="flex items-center gap-2 mb-4">
<span className="text-xs bg-duck-dark/5 text-duck-dark/50 rounded-full px-2 py-0.5">{resource.type}</span>
<span
className={`text-xs rounded-full px-2 py-0.5 ${category === 'api-based' ? 'bg-duck-teal/10 text-duck-teal' : 'bg-duck-dark/5 text-duck-dark/50'}`}
>
{category === 'api-based' ? 'API Based' : 'Local CLI'}
</span>
{resource.port && (
<span className="text-xs bg-duck-teal/10 text-duck-teal rounded-full px-2 py-0.5">:{resource.port}</span>
)}
</div>
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
<LocalAvailabilitySection resource={resource} onRun={handleRun} />
</div>
<AlertDialog open={!!confirmCommand} onOpenChange={(open) => !open && setConfirmCommand(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Run with elevated privileges</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
You are about to run a command with elevated privileges (sudo)
<br />
<span className="text-red-500">
in the host machine.
<br />
ARE YOU SURE?
</span>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<code className="text-sm font-mono bg-[#1a1a2e] text-[#e0e0e0] rounded-lg px-3 py-2 break-all">{confirmCommand}</code>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
className="cursor-pointer bg-duck-yellow hover:bg-duck-yellow/90 text-duck-teal font-bold"
onClick={() => {
if (confirmCommand) setRunCommand({ command: confirmCommand });
setConfirmCommand(null);
}}
>
Run
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};
export const Resources = () => {
const { resources } = useResources();
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
const [showCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
const resource = selectedId ? resources?.find((r: Resource) => r.id === selectedId) : null;
if (showCatalog || !resource) return <ResourceCatalog />;
return <ResourceDetail resource={resource} />;
};
@@ -1,11 +1,24 @@
import { useMemo } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { X } from 'lucide-react';
import { toast } from 'sonner';
import { useQueryClient } from '@tanstack/react-query';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { useClient } from 'hooks/useClient';
import { TerminalView } from 'apps/Terminal';
import { FileViewerView } from 'apps/FileViewer';
import { appRegistry } from '../../Workspaces/app-registry';
import { Resources } from './Resources';
import { ResourceSidebar } from './ResourceSidebar';
import {
RUN_COMMAND_CHANNEL,
ERROR_LOG_CHANNEL,
type RunCommandState,
type ErrorLogState,
} from './run-command-channel';
const layout: LayoutNode = {
const baseLayout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
@@ -15,11 +28,147 @@ const layout: LayoutNode = {
],
};
const splitLayout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'resources-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'resources-terminal', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const errorLayout: LayoutNode = {
type: 'group',
id: 'resources-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'resources-left', appType: null }, size: 20 },
{
node: {
type: 'group',
id: 'resources-right-group',
direction: 'vertical',
children: [
{ node: { type: 'panel', id: 'resources-right', appType: null }, size: 50 },
{ node: { type: 'panel', id: 'resources-error-log', appType: null }, size: 50 },
],
},
size: 80,
},
],
};
const ResourceTerminalPanel = () => {
const queryClient = useQueryClient();
const client = useClient();
const [state, setState] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [, setErrorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
const [session, setSession] = useState<{ id: string; command: string } | null>(null);
useEffect(() => {
if (state && (!session || session.command !== state.command)) {
setSession({ id: `res-cmd-${Date.now()}`, command: state.command });
} else if (!state) {
setSession(null);
}
}, [state]);
const close = () => setState(null);
const onCommandDone = (exitCode: number, output: string) => {
queryClient.invalidateQueries({ queryKey: ['RESOURCES'] });
if (exitCode === 0) {
toast.success('Command completed successfully');
setTimeout(() => setState(null), 2000);
} else {
const command = session?.command ?? 'unknown';
const md = [
`# Command Failed (exit code ${exitCode})`,
'',
'```',
command,
'```',
'',
'## Output',
'',
'```',
output,
'```',
].join('\n');
client.post('/server-settings/resources/error-log', { command, output, exitCode }).catch(() => {});
setState(null);
setErrorLog({ content: md, fileName: 'error.md' });
}
};
if (!state || !session) return null;
return (
<div className="h-full flex flex-col">
<div className="shrink-0 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 flex items-center gap-2">
<span className="text-xs font-medium text-duck-dark/50 dark:text-foreground/50 flex-1">Run Command</span>
<button
onClick={close}
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 cursor-pointer transition-colors"
>
<X className="h-3.5 w-3.5 text-duck-dark/50 dark:text-foreground/50" />
</button>
</div>
<TerminalView
className="flex-1"
sandboxed={false}
command={session.command}
sessionId={session.id}
onCommandDone={onCommandDone}
/>
</div>
);
};
const ErrorLogPanel = () => {
const [errorLog, setErrorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
if (!errorLog) return null;
return (
<FileViewerView
filePath=""
fileName={errorLog.fileName}
content={errorLog.content}
onClose={() => setErrorLog(null)}
/>
);
};
export const ResourceSettings = () => {
const [runCommand] = usePanelChannel<RunCommandState>(RUN_COMMAND_CHANNEL, null);
const [errorLog] = usePanelChannel<ErrorLogState>(ERROR_LOG_CHANNEL, null);
const layout = useMemo(
() => (errorLog ? errorLayout : runCommand ? splitLayout : baseLayout),
[errorLog, runCommand],
);
const panelComponents: PanelComponents = useMemo(
() => ({
'resources-left': ResourceSidebar,
'resources-right': Resources,
'resources-terminal': ResourceTerminalPanel,
'resources-error-log': ErrorLogPanel,
}),
[],
);
@@ -0,0 +1,12 @@
export type RunCommandState = {
command: string;
} | null;
export const RUN_COMMAND_CHANNEL = 'resource-settings:run-command';
export type ErrorLogState = {
content: string;
fileName: string;
} | null;
export const ERROR_LOG_CHANNEL = 'resource-settings:error-log';
@@ -1,11 +1,14 @@
import { useState, useEffect, useRef } from 'react';
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
import type { ReactNode } from 'react';
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles, Eye } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import type { AppRegistry } from '@/components/Workspace';
import { useWorkspace } from '@/components/Workspace';
import { CodeEditorView } from 'apps/CodeEditor';
import { TerminalView } from 'apps/Terminal';
import { useWorkspacesState } from '@/state/useWorkspacesState';
import { usePanelChannel } from 'hooks/usePanelChannel';
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
import { useClaude } from '../Chat/useClaude';
import { useOpenCode } from '../Chat/useOpenCode';
import { usePiMono } from '../Chat/usePiMono';
@@ -127,6 +130,32 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
};
type FileViewerChannelState = {
filePath: string;
fileName: string;
root?: string;
} | null;
const FILE_VIEWER_CHANNEL = 'file-viewer';
const FileViewerWorkspaceProvider = ({ panelId, children }: { panelId: string; children: ReactNode }) => {
const [state] = usePanelChannel<FileViewerChannelState>(`${FILE_VIEWER_CHANNEL}:${panelId}`, null);
if (!state) {
return (
<div className="flex h-full w-full items-center justify-center text-duck-dark/30 text-sm">
No file selected
</div>
);
}
return (
<FileViewerProvider filePath={state.filePath} fileName={state.fileName} root={state.root}>
{children}
</FileViewerProvider>
);
};
export const appRegistry: AppRegistry = {
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
@@ -136,6 +165,7 @@ export const appRegistry: AppRegistry = {
'terminal': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper },
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp /> },
'file-viewer': { name: 'File Viewer', icon: Eye, component: FileViewerBody, header: FileViewerHeader, provider: FileViewerWorkspaceProvider },
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
...widgetRegistry,