Files FIles Files
This commit is contained in:
@@ -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 (
|
||||
|
||||
+42
-26
@@ -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,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
+12
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user