Files FIles Files
This commit is contained in:
@@ -1,10 +1,15 @@
|
|||||||
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
||||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||||
|
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||||
|
import { getFileType } from 'apps/FileViewer';
|
||||||
import { FileItem } from './FileItem';
|
import { FileItem } from './FileItem';
|
||||||
|
|
||||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||||
|
|
||||||
|
type SortField = 'name' | 'size' | 'type' | 'date';
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
type FileGridProps = {
|
type FileGridProps = {
|
||||||
entries: DirEntry[];
|
entries: DirEntry[];
|
||||||
viewMode: 'grid' | 'list';
|
viewMode: 'grid' | 'list';
|
||||||
@@ -21,6 +26,11 @@ type FileGridProps = {
|
|||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
renamingName: string | null;
|
renamingName: string | null;
|
||||||
onRenamingChange: (name: string | null) => void;
|
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[];
|
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||||
onCreateWorkspace: (entry: DirEntry) => void;
|
onCreateWorkspace: (entry: DirEntry) => void;
|
||||||
@@ -30,6 +40,7 @@ type FileGridProps = {
|
|||||||
const LIST_ROW_HEIGHT = 42;
|
const LIST_ROW_HEIGHT = 42;
|
||||||
const GRID_ROW_HEIGHT = 130;
|
const GRID_ROW_HEIGHT = 130;
|
||||||
const GRID_GAP = 12;
|
const GRID_GAP = 12;
|
||||||
|
const HEADER_HEIGHT = 36;
|
||||||
|
|
||||||
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||||
const [cols, setCols] = useState(4);
|
const [cols, setCols] = useState(4);
|
||||||
@@ -71,21 +82,55 @@ export const FileGrid = ({
|
|||||||
onCopy,
|
onCopy,
|
||||||
renamingName,
|
renamingName,
|
||||||
onRenamingChange,
|
onRenamingChange,
|
||||||
|
onReadAloud,
|
||||||
|
onOcr,
|
||||||
|
onTranscribe,
|
||||||
|
onExtractAudio,
|
||||||
|
onExtract,
|
||||||
getMatchingTasks,
|
getMatchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
onCreateWorkspace,
|
onCreateWorkspace,
|
||||||
scrollRef,
|
scrollRef,
|
||||||
}: FileGridProps) => {
|
}: FileGridProps) => {
|
||||||
const lastClickedIdx = useRef<number>(-1);
|
const lastClickedIdx = useRef<number>(-1);
|
||||||
|
const [sortField, setSortField] = useState<SortField>('name');
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||||
|
|
||||||
const sorted = useMemo(
|
const sorted = useMemo(() => {
|
||||||
() =>
|
const compare = (a: DirEntry, b: DirEntry): number => {
|
||||||
[...entries].sort((a, b) => {
|
|
||||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||||
return a.name.localeCompare(b.name);
|
let result: number;
|
||||||
}),
|
switch (sortField) {
|
||||||
[entries],
|
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(
|
const handleSelect = useCallback(
|
||||||
(entry: DirEntry, ev: React.MouseEvent) => {
|
(entry: DirEntry, ev: React.MouseEvent) => {
|
||||||
@@ -128,6 +173,7 @@ export const FileGrid = ({
|
|||||||
getScrollElement: () => scrollRef.current,
|
getScrollElement: () => scrollRef.current,
|
||||||
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
|
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
|
||||||
overscan: 10,
|
overscan: 10,
|
||||||
|
scrollMargin: HEADER_HEIGHT,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
@@ -153,13 +199,85 @@ export const FileGrid = ({
|
|||||||
onCopy={onCopy}
|
onCopy={onCopy}
|
||||||
forceRename={renamingName === entry.name}
|
forceRename={renamingName === entry.name}
|
||||||
onRenamingChange={onRenamingChange}
|
onRenamingChange={onRenamingChange}
|
||||||
|
onReadAloud={onReadAloud}
|
||||||
|
onOcr={onOcr}
|
||||||
|
onTranscribe={onTranscribe}
|
||||||
|
onExtractAudio={onExtractAudio}
|
||||||
|
onExtract={onExtract}
|
||||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||||
onRunTask={onRunTask}
|
onRunTask={onRunTask}
|
||||||
onCreateWorkspace={onCreateWorkspace}
|
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 (
|
return (
|
||||||
|
<>
|
||||||
|
{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() }}>
|
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
if (viewMode === 'list') {
|
if (viewMode === 'list') {
|
||||||
@@ -192,5 +310,6 @@ export const FileGrid = ({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
|
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 { getIcon } from 'material-file-icons';
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
} from '@/components/ui/context-menu';
|
} from '@/components/ui/context-menu';
|
||||||
import { cardStyle } from '@/components/Card';
|
import { cardStyle } from '@/components/Card';
|
||||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||||
|
import { getFileType } from 'apps/FileViewer';
|
||||||
|
|
||||||
export type FileItemProps = {
|
export type FileItemProps = {
|
||||||
entry: DirEntry;
|
entry: DirEntry;
|
||||||
@@ -41,6 +42,11 @@ export type FileItemProps = {
|
|||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
forceRename: boolean;
|
forceRename: boolean;
|
||||||
onRenamingChange: (name: string | null) => void;
|
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[];
|
matchingTasks: TaskSummary[];
|
||||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||||
onCreateWorkspace: (entry: DirEntry) => void;
|
onCreateWorkspace: (entry: DirEntry) => void;
|
||||||
@@ -67,6 +73,11 @@ type MenuItemsProps = {
|
|||||||
onStartRename: () => void;
|
onStartRename: () => void;
|
||||||
onChat: (e: DirEntry) => void;
|
onChat: (e: DirEntry) => void;
|
||||||
onDownload: (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;
|
onCut: () => void;
|
||||||
onCopy: () => void;
|
onCopy: () => void;
|
||||||
matchingTasks: TaskSummary[];
|
matchingTasks: TaskSummary[];
|
||||||
@@ -81,12 +92,25 @@ const DropdownMenuItems = ({
|
|||||||
onStartRename,
|
onStartRename,
|
||||||
onChat,
|
onChat,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onReadAloud,
|
||||||
|
onOcr,
|
||||||
|
onTranscribe,
|
||||||
|
onExtractAudio,
|
||||||
|
onExtract,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
onCreateWorkspace,
|
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">
|
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
@@ -96,6 +120,36 @@ const DropdownMenuItems = ({
|
|||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
Download
|
||||||
</DropdownMenuItem>
|
</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 && (
|
{matchingTasks.length > 0 && (
|
||||||
<DropdownMenuSub>
|
<DropdownMenuSub>
|
||||||
<DropdownMenuSubTrigger className="cursor-pointer">
|
<DropdownMenuSubTrigger className="cursor-pointer">
|
||||||
@@ -141,7 +195,8 @@ const DropdownMenuItems = ({
|
|||||||
Delete
|
Delete
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const ContextMenuItems = ({
|
const ContextMenuItems = ({
|
||||||
entry,
|
entry,
|
||||||
@@ -150,12 +205,25 @@ const ContextMenuItems = ({
|
|||||||
onStartRename,
|
onStartRename,
|
||||||
onChat,
|
onChat,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onReadAloud,
|
||||||
|
onOcr,
|
||||||
|
onTranscribe,
|
||||||
|
onExtractAudio,
|
||||||
|
onExtract,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
onCreateWorkspace,
|
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">
|
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
|
||||||
<MessageSquare className="mr-2 h-4 w-4" />
|
<MessageSquare className="mr-2 h-4 w-4" />
|
||||||
@@ -165,6 +233,36 @@ const ContextMenuItems = ({
|
|||||||
<Download className="mr-2 h-4 w-4" />
|
<Download className="mr-2 h-4 w-4" />
|
||||||
Download
|
Download
|
||||||
</ContextMenuItem>
|
</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 && (
|
{matchingTasks.length > 0 && (
|
||||||
<ContextMenuSub>
|
<ContextMenuSub>
|
||||||
<ContextMenuSubTrigger className="cursor-pointer">
|
<ContextMenuSubTrigger className="cursor-pointer">
|
||||||
@@ -210,7 +308,8 @@ const ContextMenuItems = ({
|
|||||||
Delete
|
Delete
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const EllipsisMenu = (props: MenuItemsProps) => (
|
const EllipsisMenu = (props: MenuItemsProps) => (
|
||||||
<div onClick={(ev) => ev.stopPropagation()}>
|
<div onClick={(ev) => ev.stopPropagation()}>
|
||||||
@@ -320,6 +419,11 @@ export const FileItem = ({
|
|||||||
onCopy,
|
onCopy,
|
||||||
forceRename,
|
forceRename,
|
||||||
onRenamingChange,
|
onRenamingChange,
|
||||||
|
onReadAloud,
|
||||||
|
onOcr,
|
||||||
|
onTranscribe,
|
||||||
|
onExtractAudio,
|
||||||
|
onExtract,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
onRunTask,
|
onRunTask,
|
||||||
onCreateWorkspace,
|
onCreateWorkspace,
|
||||||
@@ -395,6 +499,11 @@ export const FileItem = ({
|
|||||||
onStartRename: () => setRenaming(true),
|
onStartRename: () => setRenaming(true),
|
||||||
onChat,
|
onChat,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onReadAloud,
|
||||||
|
onOcr,
|
||||||
|
onTranscribe,
|
||||||
|
onExtractAudio,
|
||||||
|
onExtract,
|
||||||
onCut,
|
onCut,
|
||||||
onCopy,
|
onCopy,
|
||||||
matchingTasks,
|
matchingTasks,
|
||||||
@@ -430,6 +539,9 @@ export const FileItem = ({
|
|||||||
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
|
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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">
|
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
|
||||||
{isDir ? '--' : formatSize(entry.size)}
|
{isDir ? '--' : formatSize(entry.size)}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
LayoutGrid,
|
LayoutGrid,
|
||||||
List,
|
List,
|
||||||
|
|
||||||
ClipboardPaste,
|
ClipboardPaste,
|
||||||
FolderPlus,
|
FolderPlus,
|
||||||
Search,
|
Search,
|
||||||
@@ -18,15 +17,16 @@ import {
|
|||||||
Code,
|
Code,
|
||||||
Eye,
|
Eye,
|
||||||
EyeOff,
|
EyeOff,
|
||||||
|
Upload,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getIcon } from 'material-file-icons';
|
import { getIcon } from 'material-file-icons';
|
||||||
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
|
||||||
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
|
import { useFiles, type DirEntry, useTasks, type TaskSummary, Breadcrumb, Toolbar } from 'apps/FileBrowser';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useUserState } from '@/state/useUserState';
|
import { useUserState } from '@/state/useUserState';
|
||||||
import { useAuth } from 'hooks/useAuth';
|
import { useAuth } from 'hooks/useAuth';
|
||||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||||
import { FileGrid } from './FileGrid';
|
import { FileGrid } from './FileGrid';
|
||||||
import { FileViewer } from './FileViewer';
|
|
||||||
import { TaskRunnerModal } from './TaskRunnerModal';
|
import { TaskRunnerModal } from './TaskRunnerModal';
|
||||||
|
|
||||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||||
@@ -39,6 +39,7 @@ type FilesProps = {
|
|||||||
|
|
||||||
export const Files = ({ basePath = '/' }: FilesProps) => {
|
export const Files = ({ basePath = '/' }: FilesProps) => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const client = useClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
const [homeRoot, setHomeRoot] = useUserState<HomeRoot>('files/homeRoot', 'home');
|
||||||
@@ -58,13 +59,14 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
const [showCloneInput, setShowCloneInput] = useState(false);
|
const [showCloneInput, setShowCloneInput] = useState(false);
|
||||||
const [cloneUrl, setCloneUrl] = useState('');
|
const [cloneUrl, setCloneUrl] = useState('');
|
||||||
const [cloning, setCloning] = useState(false);
|
const [cloning, setCloning] = useState(false);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
|
const [runningTask, setRunningTask] = useState<{ task: TaskSummary; entry: DirEntry } | null>(null);
|
||||||
|
const dragCounter = useRef(0);
|
||||||
const { getMatchingTasks } = useTasks();
|
const { getMatchingTasks } = useTasks();
|
||||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
const searchInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
const fileScrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
const viewPath = searchParams.get('view');
|
const viewPath = searchParams.get('view');
|
||||||
const viewerFileName = viewPath ? viewPath.split('/').pop()! : '';
|
|
||||||
const files = useFiles(homeRoot);
|
const files = useFiles(homeRoot);
|
||||||
const filesRef = useRef(files);
|
const filesRef = useRef(files);
|
||||||
filesRef.current = 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;
|
const count = fileList.length;
|
||||||
setUploadProgress(0);
|
setUploadProgress(0);
|
||||||
try {
|
try {
|
||||||
@@ -286,6 +288,67 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
navigate(`/workspaces/new?${params}`);
|
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 handleGitClone = async () => {
|
||||||
const url = cloneUrl.trim();
|
const url = cloneUrl.trim();
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
@@ -318,7 +381,8 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePaste = async () => {
|
const handlePaste = async () => {
|
||||||
if (!clipboard) return;
|
// Internal file clipboard takes priority
|
||||||
|
if (clipboard) {
|
||||||
try {
|
try {
|
||||||
if (clipboard.mode === 'copy') {
|
if (clipboard.mode === 'copy') {
|
||||||
await files.copy(clipboard.paths, currentPath);
|
await files.copy(clipboard.paths, currentPath);
|
||||||
@@ -331,8 +395,72 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
} catch {
|
} catch {
|
||||||
toast.error('Failed to paste');
|
toast.error('Failed to paste');
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try system clipboard for images/files
|
||||||
|
try {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 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
|
// Keyboard shortcuts
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = (ev: KeyboardEvent) => {
|
const handler = (ev: KeyboardEvent) => {
|
||||||
@@ -378,8 +506,10 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
|
if ((ev.ctrlKey || ev.metaKey) && ev.key === 'v') {
|
||||||
|
if (clipboard) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
handlePaste();
|
handlePaste();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,6 +539,91 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
return () => window.removeEventListener('keydown', handler);
|
return () => window.removeEventListener('keydown', handler);
|
||||||
}, [visibleEntries, selected, clipboard, currentPath, viewPath]);
|
}, [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) => {
|
const handleBackgroundClick = (ev: React.MouseEvent) => {
|
||||||
if ((ev.target as HTMLElement).closest('[data-file-item]')) return;
|
if ((ev.target as HTMLElement).closest('[data-file-item]')) return;
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
@@ -583,7 +798,21 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File content */}
|
{/* 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() ? (
|
{searchQuery.trim() ? (
|
||||||
<div className="h-full overflow-auto p-4">
|
<div className="h-full overflow-auto p-4">
|
||||||
{searching ? (
|
{searching ? (
|
||||||
@@ -647,6 +876,11 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
onCopy={handleCopy}
|
onCopy={handleCopy}
|
||||||
renamingName={renamingName}
|
renamingName={renamingName}
|
||||||
onRenamingChange={setRenamingName}
|
onRenamingChange={setRenamingName}
|
||||||
|
onReadAloud={handleReadAloud}
|
||||||
|
onOcr={handleOcr}
|
||||||
|
onTranscribe={handleTranscribe}
|
||||||
|
onExtractAudio={handleExtractAudio}
|
||||||
|
onExtract={handleExtract}
|
||||||
getMatchingTasks={getMatchingTasks}
|
getMatchingTasks={getMatchingTasks}
|
||||||
onRunTask={handleRunTask}
|
onRunTask={handleRunTask}
|
||||||
onCreateWorkspace={handleCreateWorkspace}
|
onCreateWorkspace={handleCreateWorkspace}
|
||||||
@@ -656,7 +890,7 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</ContextMenuTrigger>
|
</ContextMenuTrigger>
|
||||||
<ContextMenuContent className="z-[600]">
|
<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" />
|
<ClipboardPaste className="mr-2 h-4 w-4" />
|
||||||
Paste
|
Paste
|
||||||
</ContextMenuItem>
|
</ContextMenuItem>
|
||||||
@@ -680,16 +914,6 @@ export const Files = ({ basePath = '/' }: FilesProps) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileViewer
|
|
||||||
open={viewPath !== null}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
if (!open) setSearchParams({});
|
|
||||||
}}
|
|
||||||
filePath={viewPath ?? ''}
|
|
||||||
fileName={viewerFileName}
|
|
||||||
root={homeRoot}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{runningTask && (
|
{runningTask && (
|
||||||
<TaskRunnerModal
|
<TaskRunnerModal
|
||||||
open
|
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 type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
import { WorkspaceLayout } 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 { appRegistry } from '../Workspaces/app-registry';
|
||||||
import { Files as FilesInner } from './Screen';
|
import { Files as FilesInner } from './Screen';
|
||||||
|
|
||||||
export { FilesInner as Files };
|
export { FilesInner as Files };
|
||||||
|
|
||||||
const layout: LayoutNode = {
|
const baseLayout: LayoutNode = {
|
||||||
type: 'panel',
|
type: 'panel',
|
||||||
id: 'files-main',
|
id: 'files-main',
|
||||||
appType: null,
|
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 = () => {
|
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(
|
const panelComponents: PanelComponents = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
'files-main': FilesInner,
|
'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 (
|
return (
|
||||||
|
|||||||
+42
-26
@@ -1,7 +1,8 @@
|
|||||||
import { useState } from 'react';
|
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 { useGlobal } from 'hooks/useGlobal';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
|
import { useResources, getResourceCategory, type Resource } from '@/state/useResources';
|
||||||
|
|
||||||
type ResourceItemProps = {
|
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-sm font-medium truncate">{r.name}</div>
|
||||||
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
<div className="text-xs text-duck-dark/40 truncate">{r.subtitle}</div>
|
||||||
</div>
|
</div>
|
||||||
<Circle
|
<Circle className="h-2 w-2 shrink-0 mt-1.5 fill-green-500 text-green-500" />
|
||||||
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'}`}
|
|
||||||
/>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ResourceSidebar = () => {
|
export const ResourceSidebar = () => {
|
||||||
const { resources, isLoading } = useResources();
|
const { resources, isLoading } = useResources();
|
||||||
const [selectedId, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
const [selectedId, setSelectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
||||||
|
const [showCatalog, setShowCatalog] = useGlobal<boolean>('RESOURCE_CATALOG', false);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
const query = search.toLowerCase();
|
const installed = resources?.filter((r: Resource) => r.installed) ?? [];
|
||||||
const filtered = resources?.filter(
|
|
||||||
(r: Resource) =>
|
|
||||||
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 query = search.toLowerCase();
|
||||||
const localCli = filtered?.filter((r: Resource) => getResourceCategory(r) === 'local-cli') ?? [];
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full overflow-y-auto">
|
<div className="flex flex-col h-full">
|
||||||
<div className="p-3 pb-2">
|
<div className="shrink-0 px-3 pt-3 pb-2 flex flex-col gap-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">
|
<h2 className="text-sm font-semibold text-duck-dark px-1">Resources</h2>
|
||||||
<Box className="h-4 w-4" />
|
<Button
|
||||||
Resources
|
size="sm"
|
||||||
</div>
|
onClick={handleCatalog}
|
||||||
</div>
|
className="w-full text-xs cursor-pointer bg-duck-teal text-white hover:bg-duck-teal/90"
|
||||||
<div className="px-3 pb-2">
|
>
|
||||||
|
Catalog
|
||||||
|
</Button>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search..."
|
placeholder="Search..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(ev) => setSearch(ev.target.value)}
|
onChange={(ev) => setSearch(ev.target.value)}
|
||||||
className="h-8 text-xs"
|
className="h-7 text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-0.5 px-3 overflow-y-auto flex-1">
|
<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 && <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 && (
|
{apiBased.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
<div className="flex items-center gap-2 px-3 pt-3 pb-1">
|
||||||
@@ -74,8 +90,8 @@ export const ResourceSidebar = () => {
|
|||||||
<ResourceItem
|
<ResourceItem
|
||||||
key={r.id}
|
key={r.id}
|
||||||
resource={r}
|
resource={r}
|
||||||
isActive={selectedId === r.id}
|
isActive={!showCatalog && selectedId === r.id}
|
||||||
onSelect={() => setSelectedId(r.id)}
|
onSelect={() => handleSelect(r.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
@@ -90,8 +106,8 @@ export const ResourceSidebar = () => {
|
|||||||
<ResourceItem
|
<ResourceItem
|
||||||
key={r.id}
|
key={r.id}
|
||||||
resource={r}
|
resource={r}
|
||||||
isActive={selectedId === r.id}
|
isActive={!showCatalog && selectedId === r.id}
|
||||||
onSelect={() => setSelectedId(r.id)}
|
onSelect={() => handleSelect(r.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,39 +1,22 @@
|
|||||||
import { useState } from 'react';
|
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 { useGlobal } from 'hooks/useGlobal';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
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';
|
import { useResources, getResourceCategory, type Resource, type PingResult } from '@/state/useResources';
|
||||||
|
import { RUN_COMMAND_CHANNEL, type RunCommandState } from './run-command-channel';
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
type ConnectionSectionProps = {
|
type ConnectionSectionProps = {
|
||||||
resource: Resource;
|
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>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
|
<h3 className="text-sm font-semibold text-duck-dark mb-3">Local Availability</h3>
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
@@ -159,33 +147,132 @@ 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>
|
<span className="text-xs bg-duck-dark/5 text-duck-dark/40 rounded-full px-2 py-0.5">Not installed</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-3">
|
||||||
{resource.installCommand && <CommandRow label="Install" command={resource.installCommand} />}
|
{resource.installCommand && <CommandBlock label="Install" command={resource.installCommand} onRun={onRun} />}
|
||||||
{resource.uninstallCommand && <CommandRow label="Uninstall" command={resource.uninstallCommand} />}
|
{resource.uninstallCommand && (
|
||||||
{resource.manageCommand && <CommandRow label="Manage" command={resource.manageCommand} />}
|
<CommandBlock label="Uninstall" command={resource.uninstallCommand} onRun={onRun} />
|
||||||
{resource.verifyCommand && <CommandRow label="Verify" command={resource.verifyCommand} />}
|
)}
|
||||||
{resource.updateCommand && <CommandRow label="Update" command={resource.updateCommand} />}
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
export const Resources = () => {
|
type CatalogCardProps = {
|
||||||
const { resources } = useResources();
|
resource: Resource;
|
||||||
const [selectedId] = useGlobal<string | null>('RESOURCE_SELECTED', null);
|
onSelect: (id: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
const resource = resources?.find((r: Resource) => r.id === selectedId);
|
const CatalogCard = ({ resource: r, onSelect }: CatalogCardProps) => {
|
||||||
|
const category = getResourceCategory(r);
|
||||||
if (!resource) {
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex items-center justify-center">
|
<button
|
||||||
<p className="text-sm text-duck-dark/30">Select a resource to view details</p>
|
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 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">
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const ResourceDetail = ({ resource }: { resource: Resource }) => {
|
||||||
const category = getResourceCategory(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 (
|
return (
|
||||||
|
<>
|
||||||
<div className="h-full overflow-y-auto p-6">
|
<div className="h-full overflow-y-auto p-6">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{resource.port ? (
|
{resource.port ? (
|
||||||
@@ -212,7 +299,51 @@ export const Resources = () => {
|
|||||||
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
|
<p className="text-sm text-duck-dark/70 mb-6">{resource.description}</p>
|
||||||
|
|
||||||
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
|
{category === 'api-based' && <ConnectionSection key={resource.id} resource={resource} />}
|
||||||
<LocalAvailabilitySection resource={resource} />
|
<LocalAvailabilitySection resource={resource} onRun={handleRun} />
|
||||||
</div>
|
</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 type { LayoutNode, PanelComponents } from '@/components/Workspace';
|
||||||
import { WorkspaceLayout } 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 { appRegistry } from '../../Workspaces/app-registry';
|
||||||
import { Resources } from './Resources';
|
import { Resources } from './Resources';
|
||||||
import { ResourceSidebar } from './ResourceSidebar';
|
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',
|
type: 'group',
|
||||||
id: 'resources-root',
|
id: 'resources-root',
|
||||||
direction: 'horizontal',
|
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 = () => {
|
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(
|
const panelComponents: PanelComponents = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
'resources-left': ResourceSidebar,
|
'resources-left': ResourceSidebar,
|
||||||
'resources-right': Resources,
|
'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 { 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 { useAuth } from 'hooks/useAuth';
|
||||||
import type { AppRegistry } from '@/components/Workspace';
|
import type { AppRegistry } from '@/components/Workspace';
|
||||||
import { useWorkspace } from '@/components/Workspace';
|
import { useWorkspace } from '@/components/Workspace';
|
||||||
import { CodeEditorView } from 'apps/CodeEditor';
|
import { CodeEditorView } from 'apps/CodeEditor';
|
||||||
import { TerminalView } from 'apps/Terminal';
|
import { TerminalView } from 'apps/Terminal';
|
||||||
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
import { useWorkspacesState } from '@/state/useWorkspacesState';
|
||||||
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
|
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from 'apps/FileViewer';
|
||||||
import { useClaude } from '../Chat/useClaude';
|
import { useClaude } from '../Chat/useClaude';
|
||||||
import { useOpenCode } from '../Chat/useOpenCode';
|
import { useOpenCode } from '../Chat/useOpenCode';
|
||||||
import { usePiMono } from '../Chat/usePiMono';
|
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} />;
|
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 = {
|
export const appRegistry: AppRegistry = {
|
||||||
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
|
||||||
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
|
'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': { name: 'Terminal', icon: TerminalSquare, component: TerminalWrapper },
|
||||||
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper },
|
'terminal-host': { name: 'Host Terminal', icon: Monitor, component: HostTerminalWrapper },
|
||||||
'workspace-list': { name: 'Workspaces', icon: LayoutGrid, component: () => <WorkspaceListApp /> },
|
'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 },
|
'chat-launcher': { name: 'Chat Launcher', icon: Sparkles, component: () => <ChatLauncher />, fixedHeight: 180 },
|
||||||
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
'widget-panel': { name: 'Widget Panel', icon: LayoutDashboard, component: WidgetPanel, transparent: true },
|
||||||
...widgetRegistry,
|
...widgetRegistry,
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
import { createRouter } from '@@/create-router';
|
import { createRouter } from '@@/create-router';
|
||||||
import { resolve, dirname } from 'node:path';
|
import { resolve, dirname, join, parse as parsePath } from 'node:path';
|
||||||
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
import { homedir } from 'node:os';
|
import { homedir } from 'node:os';
|
||||||
import { getHomeDir } from '@@/data-path';
|
import { getHomeDir, DATA_PATH, getUserSettingsFile } from '@@/data-path';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
|
import { readConfig } from '@@/api/server-settings/resources';
|
||||||
|
|
||||||
export const router = createRouter();
|
export const router = createRouter();
|
||||||
|
|
||||||
type UserCtx = { email: string; role: string | null };
|
type UserCtx = { email: string; role: string | null };
|
||||||
|
|
||||||
|
function getUserDataDir(email: string): string {
|
||||||
|
return join(DATA_PATH, email);
|
||||||
|
}
|
||||||
|
|
||||||
function getRootDir(user: UserCtx, root?: string): string {
|
function getRootDir(user: UserCtx, root?: string): string {
|
||||||
if (!root || root === 'home') return getHomeDir(user.email);
|
if (!root || root === 'home') return getHomeDir(user.email);
|
||||||
|
if (root === 'user-data') return getUserDataDir(user.email);
|
||||||
if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root');
|
if (user.role !== 'Super Admin') throw errors.FORBIDDEN('Only Super Admin can access this root');
|
||||||
if (root === '~') return homedir();
|
if (root === '~') return homedir();
|
||||||
if (root === 'officer.dev') return resolve(process.cwd(), '..');
|
if (root === 'officer.dev') return resolve(process.cwd(), '..');
|
||||||
@@ -244,6 +251,298 @@ router.get('/transcode', async (ctx) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Text-to-speech with caching
|
||||||
|
router.post('/tts', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
|
||||||
|
if (!filePath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const rootDir = getRootDir(user, root);
|
||||||
|
const absPath = resolveUserPath(rootDir, filePath);
|
||||||
|
const s = await stat(absPath);
|
||||||
|
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot read aloud a directory');
|
||||||
|
|
||||||
|
const userDataDir = getUserDataDir(user.email);
|
||||||
|
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||||
|
const cacheRel = dir ? `tts/${dir}/${name}.mp3` : `tts/${name}.mp3`;
|
||||||
|
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||||
|
|
||||||
|
if (existsSync(cacheAbs)) {
|
||||||
|
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
const kokoroUrl = config.kokoro?.url;
|
||||||
|
if (!kokoroUrl) throw errors.BAD_REQUEST('Kokoro TTS not configured');
|
||||||
|
|
||||||
|
const content = await readFile(absPath, 'utf-8');
|
||||||
|
const res = await fetch(`${kokoroUrl}/v1/audio/speech`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ model: 'kokoro', input: content, voice: 'af_heart', response_format: 'mp3' }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw errors.BAD_REQUEST('TTS request failed');
|
||||||
|
|
||||||
|
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
await Bun.write(cacheAbs, buffer);
|
||||||
|
|
||||||
|
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data' });
|
||||||
|
});
|
||||||
|
|
||||||
|
// OCR image via vision model with caching
|
||||||
|
router.post('/ocr', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
|
||||||
|
if (!filePath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const rootDir = getRootDir(user, root);
|
||||||
|
const absPath = resolveUserPath(rootDir, filePath);
|
||||||
|
const s = await stat(absPath);
|
||||||
|
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot OCR a directory');
|
||||||
|
|
||||||
|
const userDataDir = getUserDataDir(user.email);
|
||||||
|
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||||
|
const cacheRel = dir ? `ocr/${dir}/${name}.md` : `ocr/${name}.md`;
|
||||||
|
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||||
|
|
||||||
|
if (existsSync(cacheAbs)) {
|
||||||
|
const text = await readFile(cacheAbs, 'utf-8');
|
||||||
|
return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
const llamaUrl = config.llama?.url;
|
||||||
|
if (!llamaUrl) throw errors.BAD_REQUEST('llama.cpp not configured');
|
||||||
|
|
||||||
|
const imageBytes = await Bun.file(absPath).arrayBuffer();
|
||||||
|
const base64 = Buffer.from(imageBytes).toString('base64');
|
||||||
|
const ext = absPath.split('.').pop()?.toLowerCase() ?? 'png';
|
||||||
|
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : ext === 'webp' ? 'image/webp' : `image/${ext}`;
|
||||||
|
|
||||||
|
const res = await fetch(`${llamaUrl}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'Qwen2.5-VL-7B-Instruct-q4_k_m.gguf',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [
|
||||||
|
{ type: 'image_url', image_url: { url: `data:${mime};base64,${base64}` } },
|
||||||
|
{ type: 'text', text: 'Extract all text from this image. Return only the extracted text, nothing else.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw errors.BAD_REQUEST('OCR request failed');
|
||||||
|
|
||||||
|
const json = (await res.json()) as { choices: { message: { content: string } }[] };
|
||||||
|
const text = json.choices[0]?.message?.content ?? '';
|
||||||
|
|
||||||
|
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||||
|
await Bun.write(cacheAbs, text);
|
||||||
|
|
||||||
|
return ctx.json({ text, ocrPath: cacheRel, ocrRoot: 'user-data', cached: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract audio from video via ffmpeg with caching
|
||||||
|
router.post('/extract-audio', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
|
||||||
|
if (!filePath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const rootDir = getRootDir(user, root);
|
||||||
|
const absPath = resolveUserPath(rootDir, filePath);
|
||||||
|
const s = await stat(absPath);
|
||||||
|
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract audio from a directory');
|
||||||
|
|
||||||
|
const userDataDir = getUserDataDir(user.email);
|
||||||
|
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||||
|
const cacheRel = dir ? `audio/${dir}/${name}.mp3` : `audio/${name}.mp3`;
|
||||||
|
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||||
|
|
||||||
|
if (existsSync(cacheAbs)) {
|
||||||
|
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||||
|
|
||||||
|
const proc = Bun.spawn(['ffmpeg', '-i', absPath, '-vn', '-codec:a', 'libmp3lame', '-q:a', '2', '-y', cacheAbs], {
|
||||||
|
stdout: 'ignore',
|
||||||
|
stderr: 'pipe',
|
||||||
|
});
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
throw errors.BAD_REQUEST(stderr.trim() || 'Audio extraction failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctx.json({ audioPath: cacheRel, audioRoot: 'user-data', cached: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Extract archive (zip, tar, 7z, rar) into a sibling folder
|
||||||
|
router.post('/extract', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
|
||||||
|
if (!filePath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const rootDir = getRootDir(user, root);
|
||||||
|
const absPath = resolveUserPath(rootDir, filePath);
|
||||||
|
const s = await stat(absPath);
|
||||||
|
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot extract a directory');
|
||||||
|
|
||||||
|
const fileName = absPath.split('/').pop()!;
|
||||||
|
const lower = fileName.toLowerCase();
|
||||||
|
|
||||||
|
// Determine archive type and build command
|
||||||
|
type ArchiveType = 'tar' | 'zip' | '7z' | 'rar';
|
||||||
|
let archiveType: ArchiveType;
|
||||||
|
if (
|
||||||
|
lower.endsWith('.tar') ||
|
||||||
|
lower.endsWith('.tar.gz') ||
|
||||||
|
lower.endsWith('.tgz') ||
|
||||||
|
lower.endsWith('.tar.bz2') ||
|
||||||
|
lower.endsWith('.tbz2') ||
|
||||||
|
lower.endsWith('.tar.xz') ||
|
||||||
|
lower.endsWith('.txz') ||
|
||||||
|
lower.endsWith('.tar.zst') ||
|
||||||
|
lower.endsWith('.gz') ||
|
||||||
|
lower.endsWith('.bz2') ||
|
||||||
|
lower.endsWith('.xz') ||
|
||||||
|
lower.endsWith('.zst')
|
||||||
|
) {
|
||||||
|
archiveType = 'tar';
|
||||||
|
} else if (lower.endsWith('.zip')) {
|
||||||
|
archiveType = 'zip';
|
||||||
|
} else if (lower.endsWith('.7z')) {
|
||||||
|
archiveType = '7z';
|
||||||
|
} else if (lower.endsWith('.rar')) {
|
||||||
|
archiveType = 'rar';
|
||||||
|
} else {
|
||||||
|
throw errors.BAD_REQUEST('Unsupported archive format');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute destination folder name (strip archive extension)
|
||||||
|
const stripArchiveExt = (name: string): string => {
|
||||||
|
const l = name.toLowerCase();
|
||||||
|
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
|
||||||
|
if (l.endsWith(compound)) return name.slice(0, -compound.length);
|
||||||
|
}
|
||||||
|
const dotIdx = name.lastIndexOf('.');
|
||||||
|
return dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseName = stripArchiveExt(fileName);
|
||||||
|
const destPath = resolve(dirname(absPath), baseName);
|
||||||
|
const finalDest = await resolveCollision(destPath);
|
||||||
|
await mkdir(finalDest, { recursive: true });
|
||||||
|
|
||||||
|
let cmd: string[];
|
||||||
|
switch (archiveType) {
|
||||||
|
case 'tar':
|
||||||
|
cmd = ['tar', 'xf', absPath, '-C', finalDest];
|
||||||
|
break;
|
||||||
|
case 'zip':
|
||||||
|
cmd = ['unzip', '-q', absPath, '-d', finalDest];
|
||||||
|
break;
|
||||||
|
case '7z':
|
||||||
|
cmd = ['7z', 'x', absPath, `-o${finalDest}`, '-y'];
|
||||||
|
break;
|
||||||
|
case 'rar':
|
||||||
|
cmd = ['unrar', 'x', '-o+', absPath, `${finalDest}/`];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proc = Bun.spawn(cmd, { stdout: 'ignore', stderr: 'pipe' });
|
||||||
|
const exitCode = await proc.exited;
|
||||||
|
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
const stderr = await new Response(proc.stderr).text();
|
||||||
|
// Clean up the empty directory on failure
|
||||||
|
await rm(finalDest, { recursive: true, force: true }).catch(() => {});
|
||||||
|
throw errors.BAD_REQUEST(stderr.trim() || 'Archive extraction failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const extractedPath = '/' + finalDest.slice(rootDir.length).replace(/^\/+/, '');
|
||||||
|
return ctx.json({ extractedPath });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transcribe audio via Whisper with caching
|
||||||
|
// Workflow: detect language → check user's spoken languages → translate if needed → transcribe
|
||||||
|
router.post('/transcribe', async (ctx) => {
|
||||||
|
const user = ctx.get('user');
|
||||||
|
const { path: filePath, root } = ctx.get('body') as { path: string; root?: string };
|
||||||
|
if (!filePath) throw errors.BAD_REQUEST('path is required');
|
||||||
|
|
||||||
|
const rootDir = getRootDir(user, root);
|
||||||
|
const absPath = resolveUserPath(rootDir, filePath);
|
||||||
|
const s = await stat(absPath);
|
||||||
|
if (s.isDirectory()) throw errors.BAD_REQUEST('Cannot transcribe a directory');
|
||||||
|
|
||||||
|
const userDataDir = getUserDataDir(user.email);
|
||||||
|
const { dir, name } = parsePath(filePath.replace(/^\/+/, ''));
|
||||||
|
const cacheRel = dir ? `transcriptions/${dir}/${name}.md` : `transcriptions/${name}.md`;
|
||||||
|
const cacheAbs = resolve(userDataDir, cacheRel);
|
||||||
|
|
||||||
|
if (existsSync(cacheAbs)) {
|
||||||
|
return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await readConfig();
|
||||||
|
const whisperUrl = config.whisper?.url;
|
||||||
|
if (!whisperUrl) throw errors.BAD_REQUEST('Whisper not configured');
|
||||||
|
|
||||||
|
const audioFile = Bun.file(absPath);
|
||||||
|
|
||||||
|
// Step 1: Detect language
|
||||||
|
const detectForm = new FormData();
|
||||||
|
detectForm.append('file', audioFile);
|
||||||
|
detectForm.append('temperature', '0.0');
|
||||||
|
detectForm.append('response_format', 'verbose_json');
|
||||||
|
detectForm.append('detect_language', 'true');
|
||||||
|
|
||||||
|
const detectRes = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: detectForm });
|
||||||
|
if (!detectRes.ok) throw errors.BAD_REQUEST('Language detection failed');
|
||||||
|
|
||||||
|
const detectJson = (await detectRes.json()) as { language?: string };
|
||||||
|
const detectedLang = detectJson.language ?? 'en';
|
||||||
|
|
||||||
|
// Step 2: Check user's spoken languages to decide if translation is needed
|
||||||
|
let shouldTranslate = false;
|
||||||
|
const settingsFile = Bun.file(getUserSettingsFile(user.email));
|
||||||
|
if (await settingsFile.exists()) {
|
||||||
|
const settings = (await settingsFile.json()) as { languages?: { spoken?: string[] } };
|
||||||
|
const spokenLanguages = settings.languages?.spoken ?? [];
|
||||||
|
if (spokenLanguages.length > 0 && !spokenLanguages.includes(detectedLang)) {
|
||||||
|
shouldTranslate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Full transcription
|
||||||
|
const transcribeForm = new FormData();
|
||||||
|
transcribeForm.append('file', audioFile);
|
||||||
|
transcribeForm.append('temperature', '0.0');
|
||||||
|
transcribeForm.append('temperature_inc', '0.2');
|
||||||
|
transcribeForm.append('response_format', 'text');
|
||||||
|
transcribeForm.append('language', detectedLang);
|
||||||
|
if (shouldTranslate) {
|
||||||
|
transcribeForm.append('translate', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${whisperUrl}/inference`, { method: 'POST', body: transcribeForm });
|
||||||
|
if (!res.ok) throw errors.BAD_REQUEST('Transcription request failed');
|
||||||
|
|
||||||
|
const text = (await res.text()).trim();
|
||||||
|
|
||||||
|
await mkdir(dirname(cacheAbs), { recursive: true });
|
||||||
|
await Bun.write(cacheAbs, text);
|
||||||
|
|
||||||
|
return ctx.json({ transcriptionPath: cacheRel, transcriptionRoot: 'user-data', cached: false });
|
||||||
|
});
|
||||||
|
|
||||||
// Search files by name
|
// Search files by name
|
||||||
router.get('/search', async (ctx) => {
|
router.get('/search', async (ctx) => {
|
||||||
const user = ctx.get('user');
|
const user = ctx.get('user');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { readdir, mkdir } from 'node:fs/promises';
|
|||||||
import { existsSync } from 'node:fs';
|
import { existsSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import { getResourcesDir } from '../../data-path';
|
import { DATA_PATH, getResourcesDir } from '../../data-path';
|
||||||
|
|
||||||
type ResourceCredentials = {
|
type ResourceCredentials = {
|
||||||
apiKey?: string;
|
apiKey?: string;
|
||||||
@@ -10,7 +10,7 @@ type ResourceCredentials = {
|
|||||||
password?: string;
|
password?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResourceConnectionConfig = {
|
export type ResourceConnectionConfig = {
|
||||||
url: string;
|
url: string;
|
||||||
credentials?: ResourceCredentials;
|
credentials?: ResourceCredentials;
|
||||||
};
|
};
|
||||||
@@ -37,6 +37,10 @@ type Resource = {
|
|||||||
|
|
||||||
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
|
const stripBackticks = (value: string) => value.replace(/^`(.+)`$/, '$1');
|
||||||
|
|
||||||
|
const resolveCommand = (command: string): string => {
|
||||||
|
return command.replace(/\$DATA_PATH/g, DATA_PATH);
|
||||||
|
};
|
||||||
|
|
||||||
function parseResourceFile(
|
function parseResourceFile(
|
||||||
filename: string,
|
filename: string,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -70,11 +74,11 @@ function parseResourceFile(
|
|||||||
port,
|
port,
|
||||||
path: rawPath ? stripBackticks(rawPath) : null,
|
path: rawPath ? stripBackticks(rawPath) : null,
|
||||||
description: field('Description') ?? '',
|
description: field('Description') ?? '',
|
||||||
installCommand: field('Install') ? stripBackticks(field('Install')!) : null,
|
installCommand: field('Install') ? resolveCommand(stripBackticks(field('Install')!)) : null,
|
||||||
uninstallCommand: field('Uninstall') ? stripBackticks(field('Uninstall')!) : null,
|
uninstallCommand: field('Uninstall') ? resolveCommand(stripBackticks(field('Uninstall')!)) : null,
|
||||||
manageCommand: field('Manage') ? stripBackticks(field('Manage')!) : null,
|
manageCommand: field('Manage') ? resolveCommand(stripBackticks(field('Manage')!)) : null,
|
||||||
verifyCommand: field('Verify') ? stripBackticks(field('Verify')!) : null,
|
verifyCommand: field('Verify') ? resolveCommand(stripBackticks(field('Verify')!)) : null,
|
||||||
updateCommand: field('Update') ? stripBackticks(field('Update')!) : null,
|
updateCommand: field('Update') ? resolveCommand(stripBackticks(field('Update')!)) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +86,7 @@ const CONFIG_FILENAME = 'resources-config.json';
|
|||||||
|
|
||||||
const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME);
|
const getConfigPath = () => join(getResourcesDir(), CONFIG_FILENAME);
|
||||||
|
|
||||||
async function readConfig(): Promise<ResourcesConfig> {
|
export async function readConfig(): Promise<ResourcesConfig> {
|
||||||
const path = getConfigPath();
|
const path = getConfigPath();
|
||||||
if (!existsSync(path)) return {};
|
if (!existsSync(path)) return {};
|
||||||
const text = await Bun.file(path).text();
|
const text = await Bun.file(path).text();
|
||||||
@@ -265,6 +269,27 @@ resourcesRouter.post('/:id/ping', async (ctx) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
resourcesRouter.post('/error-log', async (ctx) => {
|
||||||
|
const body = await ctx.req.json<{ command: string; output: string; exitCode: number }>();
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const filePath = `/tmp/officer-error-${timestamp}.md`;
|
||||||
|
const md = [
|
||||||
|
`# Command Failed (exit code ${body.exitCode})`,
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
body.command,
|
||||||
|
'```',
|
||||||
|
'',
|
||||||
|
'## Output',
|
||||||
|
'',
|
||||||
|
'```',
|
||||||
|
body.output,
|
||||||
|
'```',
|
||||||
|
].join('\n');
|
||||||
|
await Bun.write(filePath, md);
|
||||||
|
return ctx.json({ filePath });
|
||||||
|
});
|
||||||
|
|
||||||
resourcesRouter.get('/:id', async (ctx) => {
|
resourcesRouter.get('/:id', async (ctx) => {
|
||||||
const resources = await loadResources();
|
const resources = await loadResources();
|
||||||
const resource = resources.find((r) => r.id === ctx.req.param('id'));
|
const resource = resources.find((r) => r.id === ctx.req.param('id'));
|
||||||
|
|||||||
@@ -0,0 +1,358 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Download,
|
||||||
|
Loader2,
|
||||||
|
Music,
|
||||||
|
Film,
|
||||||
|
Image,
|
||||||
|
FileType2,
|
||||||
|
Volume2,
|
||||||
|
ArrowUp,
|
||||||
|
ScanText,
|
||||||
|
FileText,
|
||||||
|
AudioLines,
|
||||||
|
FolderArchive,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useFiles } from 'apps/FileBrowser';
|
||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { getIcon } from 'material-file-icons';
|
||||||
|
import { getFileType, getLang, getRawUrl, getTranscodeUrl, needsTranscode, getArchiveBaseName } from './file-types';
|
||||||
|
import type { FileType } from './file-types';
|
||||||
|
import {
|
||||||
|
PdfRenderer,
|
||||||
|
ImageRenderer,
|
||||||
|
VideoRenderer,
|
||||||
|
AudioRenderer,
|
||||||
|
CodeRenderer,
|
||||||
|
MarkdownRenderer,
|
||||||
|
TextRenderer,
|
||||||
|
ScrollToTopButton,
|
||||||
|
} from './FileViewerView';
|
||||||
|
|
||||||
|
// ── Context ──
|
||||||
|
|
||||||
|
type FileViewerContextValue = {
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
root: string;
|
||||||
|
fileType: FileType;
|
||||||
|
content: string | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
directContent: boolean;
|
||||||
|
ttsLoading: boolean;
|
||||||
|
ocrLoading: boolean;
|
||||||
|
transcribeLoading: boolean;
|
||||||
|
extractAudioLoading: boolean;
|
||||||
|
extractLoading: boolean;
|
||||||
|
autoPlay: boolean;
|
||||||
|
handleReadAloud: () => void;
|
||||||
|
handleOcr: () => void;
|
||||||
|
handleTranscribe: () => void;
|
||||||
|
handleExtractAudio: () => void;
|
||||||
|
handleExtract: () => void;
|
||||||
|
handleDownload: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FileViewerContext = createContext<FileViewerContextValue | null>(null);
|
||||||
|
|
||||||
|
const useFileViewer = () => {
|
||||||
|
const ctx = useContext(FileViewerContext);
|
||||||
|
if (!ctx) throw new Error('useFileViewer must be used within FileViewerProvider');
|
||||||
|
return ctx;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Provider ──
|
||||||
|
|
||||||
|
type FileViewerProviderProps = {
|
||||||
|
filePath: string;
|
||||||
|
fileName: string;
|
||||||
|
root?: string;
|
||||||
|
content?: string;
|
||||||
|
onOpenFile?: (filePath: string, root: string) => void;
|
||||||
|
autoPlay?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileViewerProvider = ({ filePath, fileName, root = 'home', content: directContent, onOpenFile, autoPlay = false, children }: FileViewerProviderProps) => {
|
||||||
|
const [content, setContent] = useState<string | null>(directContent ?? null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [ttsLoading, setTtsLoading] = useState(false);
|
||||||
|
const [ocrLoading, setOcrLoading] = useState(false);
|
||||||
|
const [transcribeLoading, setTranscribeLoading] = useState(false);
|
||||||
|
const [extractAudioLoading, setExtractAudioLoading] = useState(false);
|
||||||
|
const [extractLoading, setExtractLoading] = useState(false);
|
||||||
|
const client = useClient();
|
||||||
|
const files = useFiles(root);
|
||||||
|
const fileType = getFileType(fileName);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (directContent !== undefined) {
|
||||||
|
setContent(directContent);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fileType === 'audio' || fileType === 'video' || fileType === 'image' || fileType === 'pdf' || fileType === 'archive') {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
files
|
||||||
|
.readFile(filePath)
|
||||||
|
.then((res) => setContent(res.content))
|
||||||
|
.catch(() => setError('Failed to read file'))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [filePath, directContent]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
const url = getRawUrl(filePath, root);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
a.click();
|
||||||
|
}, [filePath, root, fileName]);
|
||||||
|
|
||||||
|
const handleReadAloud = useCallback(async () => {
|
||||||
|
setTtsLoading(true);
|
||||||
|
try {
|
||||||
|
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/tts', { path: filePath, root });
|
||||||
|
onOpenFile?.(audioPath, audioRoot);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to generate speech audio');
|
||||||
|
} finally {
|
||||||
|
setTtsLoading(false);
|
||||||
|
}
|
||||||
|
}, [filePath, root, client, onOpenFile]);
|
||||||
|
|
||||||
|
const handleOcr = useCallback(async () => {
|
||||||
|
setOcrLoading(true);
|
||||||
|
try {
|
||||||
|
const { ocrPath, ocrRoot } = await client.post<{ ocrPath: string; ocrRoot: string }>('/file-browser/ocr', { path: filePath, root });
|
||||||
|
onOpenFile?.(ocrPath, ocrRoot);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to extract text from image');
|
||||||
|
} finally {
|
||||||
|
setOcrLoading(false);
|
||||||
|
}
|
||||||
|
}, [filePath, root, client, onOpenFile]);
|
||||||
|
|
||||||
|
const handleTranscribe = useCallback(async () => {
|
||||||
|
setTranscribeLoading(true);
|
||||||
|
try {
|
||||||
|
const { transcriptionPath, transcriptionRoot } = await client.post<{ transcriptionPath: string; transcriptionRoot: string }>('/file-browser/transcribe', { path: filePath, root });
|
||||||
|
onOpenFile?.(transcriptionPath, transcriptionRoot);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to transcribe audio');
|
||||||
|
} finally {
|
||||||
|
setTranscribeLoading(false);
|
||||||
|
}
|
||||||
|
}, [filePath, root, client, onOpenFile]);
|
||||||
|
|
||||||
|
const handleExtractAudio = useCallback(async () => {
|
||||||
|
setExtractAudioLoading(true);
|
||||||
|
try {
|
||||||
|
const { audioPath, audioRoot } = await client.post<{ audioPath: string; audioRoot: string }>('/file-browser/extract-audio', { path: filePath, root });
|
||||||
|
onOpenFile?.(audioPath, audioRoot);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to extract audio from video');
|
||||||
|
} finally {
|
||||||
|
setExtractAudioLoading(false);
|
||||||
|
}
|
||||||
|
}, [filePath, root, client, onOpenFile]);
|
||||||
|
|
||||||
|
const handleExtract = useCallback(async () => {
|
||||||
|
setExtractLoading(true);
|
||||||
|
try {
|
||||||
|
const { extractedPath } = await client.post<{ extractedPath: string }>('/file-browser/extract', { path: filePath, root });
|
||||||
|
const folderName = extractedPath.split('/').pop() ?? extractedPath;
|
||||||
|
toast.success(`Extracted to "${folderName}"`);
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to extract archive');
|
||||||
|
} finally {
|
||||||
|
setExtractLoading(false);
|
||||||
|
}
|
||||||
|
}, [filePath, root, client]);
|
||||||
|
|
||||||
|
const value: FileViewerContextValue = {
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
|
root,
|
||||||
|
fileType,
|
||||||
|
content,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
directContent: directContent !== undefined,
|
||||||
|
ttsLoading,
|
||||||
|
ocrLoading,
|
||||||
|
transcribeLoading,
|
||||||
|
extractAudioLoading,
|
||||||
|
extractLoading,
|
||||||
|
autoPlay,
|
||||||
|
handleReadAloud,
|
||||||
|
handleOcr,
|
||||||
|
handleTranscribe,
|
||||||
|
handleExtractAudio,
|
||||||
|
handleExtract,
|
||||||
|
handleDownload,
|
||||||
|
};
|
||||||
|
|
||||||
|
return <FileViewerContext value={value}>{children}</FileViewerContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Header (content fragments only — no container div) ──
|
||||||
|
|
||||||
|
export const FileViewerHeader = () => {
|
||||||
|
const { fileName, fileType, content, directContent, ttsLoading, ocrLoading, transcribeLoading, extractAudioLoading, extractLoading, handleReadAloud, handleOcr, handleTranscribe, handleExtractAudio, handleExtract, handleDownload } =
|
||||||
|
useFileViewer();
|
||||||
|
|
||||||
|
const textContent = content;
|
||||||
|
const showDownload = !directContent;
|
||||||
|
|
||||||
|
const headerIcon =
|
||||||
|
fileType === 'audio' ? (
|
||||||
|
<Music className="h-4 w-4 text-duck-teal shrink-0" />
|
||||||
|
) : fileType === 'video' ? (
|
||||||
|
<Film className="h-4 w-4 text-duck-orange shrink-0" />
|
||||||
|
) : fileType === 'image' ? (
|
||||||
|
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
|
||||||
|
) : fileType === 'pdf' ? (
|
||||||
|
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
|
||||||
|
) : fileType === 'archive' ? (
|
||||||
|
<FolderArchive className="h-4 w-4 text-duck-orange shrink-0" />
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: getIcon(fileName).svg }} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{headerIcon}
|
||||||
|
<span className="text-xs font-medium truncate flex-1">{fileName}</span>
|
||||||
|
<span className="text-[10px] font-mono uppercase tracking-wider shrink-0 opacity-60">
|
||||||
|
{fileType === 'code' ? getLang(fileName) : fileType}
|
||||||
|
</span>
|
||||||
|
{(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && textContent && (
|
||||||
|
<button
|
||||||
|
onClick={handleReadAloud}
|
||||||
|
disabled={ttsLoading}
|
||||||
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
|
title="Read Aloud"
|
||||||
|
>
|
||||||
|
{ttsLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Volume2 className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fileType === 'image' && (
|
||||||
|
<button
|
||||||
|
onClick={handleOcr}
|
||||||
|
disabled={ocrLoading}
|
||||||
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
|
title="Extract Text (OCR)"
|
||||||
|
>
|
||||||
|
{ocrLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ScanText className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fileType === 'audio' && (
|
||||||
|
<button
|
||||||
|
onClick={handleTranscribe}
|
||||||
|
disabled={transcribeLoading}
|
||||||
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
|
title="Transcribe"
|
||||||
|
>
|
||||||
|
{transcribeLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fileType === 'video' && (
|
||||||
|
<button
|
||||||
|
onClick={handleExtractAudio}
|
||||||
|
disabled={extractAudioLoading}
|
||||||
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
|
title="Extract Audio"
|
||||||
|
>
|
||||||
|
{extractAudioLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <AudioLines className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{fileType === 'archive' && (
|
||||||
|
<button
|
||||||
|
onClick={handleExtract}
|
||||||
|
disabled={extractLoading}
|
||||||
|
className="p-1 rounded hover:bg-current/10 disabled:opacity-40 transition-colors cursor-pointer"
|
||||||
|
title="Extract"
|
||||||
|
>
|
||||||
|
{extractLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FolderArchive className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showDownload && (
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
className="p-1 rounded hover:bg-current/10 transition-colors cursor-pointer"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Body (renderers) ──
|
||||||
|
|
||||||
|
export const FileViewerBody = () => {
|
||||||
|
const { filePath, fileName, root, fileType, content, loading, error, autoPlay } = useFileViewer();
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const videoSrc =
|
||||||
|
fileType === 'video'
|
||||||
|
? needsTranscode(fileName)
|
||||||
|
? getTranscodeUrl(filePath, root)
|
||||||
|
: getRawUrl(filePath, root)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={scrollRef} className="h-full overflow-auto">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="text-sm text-red-500">{error}</span>
|
||||||
|
</div>
|
||||||
|
) : fileType === 'pdf' ? (
|
||||||
|
<PdfRenderer src={getRawUrl(filePath, root)} />
|
||||||
|
) : fileType === 'archive' ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-duck-dark/50">
|
||||||
|
<FolderArchive className="h-10 w-10" />
|
||||||
|
<span className="text-sm">Archive file</span>
|
||||||
|
<span className="text-xs">{getArchiveBaseName(fileName)}</span>
|
||||||
|
</div>
|
||||||
|
) : fileType === 'image' ? (
|
||||||
|
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
||||||
|
) : fileType === 'video' ? (
|
||||||
|
<VideoRenderer src={videoSrc} fileName={fileName} />
|
||||||
|
) : fileType === 'audio' ? (
|
||||||
|
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} autoPlay={autoPlay} />
|
||||||
|
) : content !== null ? (
|
||||||
|
fileType === 'code' ? (
|
||||||
|
<div>
|
||||||
|
<CodeRenderer content={content} lang={getLang(fileName)} />
|
||||||
|
<ScrollToTopButton scrollContainer={scrollRef} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="px-8 py-6">
|
||||||
|
{fileType === 'markdown' ? (
|
||||||
|
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
|
||||||
|
) : (
|
||||||
|
<TextRenderer content={content} />
|
||||||
|
)}
|
||||||
|
<ScrollToTopButton scrollContainer={scrollRef} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
+47
-438
@@ -1,15 +1,10 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
X,
|
X,
|
||||||
Download,
|
|
||||||
Loader2,
|
Loader2,
|
||||||
Music,
|
Music,
|
||||||
Film,
|
|
||||||
Image,
|
|
||||||
FileType2,
|
|
||||||
Maximize2,
|
Maximize2,
|
||||||
Minimize2,
|
Minimize2,
|
||||||
Square,
|
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
Volume2,
|
Volume2,
|
||||||
@@ -23,172 +18,8 @@ import ReactMarkdown from 'react-markdown';
|
|||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSlug from 'rehype-slug';
|
import rehypeSlug from 'rehype-slug';
|
||||||
import { Dialog, DialogOverlay, DialogPortal } from '@/components/ui/dialog';
|
import { getExt, formatTime } from './file-types';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
|
||||||
import { cardStyle } from '@/components/Card';
|
|
||||||
import { useFiles } from 'apps/FileBrowser';
|
|
||||||
import { getHeaders } from 'hooks/useClient';
|
|
||||||
import { config } from 'config';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
import { getIcon } from 'material-file-icons';
|
|
||||||
|
|
||||||
type FileViewerProps = {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
filePath: string;
|
|
||||||
fileName: string;
|
|
||||||
root?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type FileType = 'markdown' | 'audio' | 'video' | 'image' | 'pdf' | 'code' | 'text';
|
|
||||||
|
|
||||||
const AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma'];
|
|
||||||
const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov'];
|
|
||||||
const TRANSCODE_VIDEO_EXTS = [
|
|
||||||
'mkv',
|
|
||||||
'avi',
|
|
||||||
'wmv',
|
|
||||||
'flv',
|
|
||||||
'ts',
|
|
||||||
'mts',
|
|
||||||
'm2ts',
|
|
||||||
'3gp',
|
|
||||||
'3g2',
|
|
||||||
'vob',
|
|
||||||
'divx',
|
|
||||||
'asf',
|
|
||||||
'f4v',
|
|
||||||
'rm',
|
|
||||||
'rmvb',
|
|
||||||
];
|
|
||||||
const ALL_VIDEO_EXTS = [...NATIVE_VIDEO_EXTS, ...TRANSCODE_VIDEO_EXTS];
|
|
||||||
const IMAGE_EXTS = [
|
|
||||||
'jpg',
|
|
||||||
'jpeg',
|
|
||||||
'png',
|
|
||||||
'gif',
|
|
||||||
'webp',
|
|
||||||
'svg',
|
|
||||||
'bmp',
|
|
||||||
'ico',
|
|
||||||
'tiff',
|
|
||||||
'tif',
|
|
||||||
'avif',
|
|
||||||
'heic',
|
|
||||||
'heif',
|
|
||||||
'jfif',
|
|
||||||
'apng',
|
|
||||||
];
|
|
||||||
|
|
||||||
// Map file extension to shiki language identifier
|
|
||||||
const EXT_TO_LANG: Record<string, string> = {
|
|
||||||
js: 'javascript',
|
|
||||||
mjs: 'javascript',
|
|
||||||
cjs: 'javascript',
|
|
||||||
jsx: 'jsx',
|
|
||||||
ts: 'typescript',
|
|
||||||
mts: 'typescript',
|
|
||||||
cts: 'typescript',
|
|
||||||
tsx: 'tsx',
|
|
||||||
json: 'json',
|
|
||||||
jsonc: 'jsonc',
|
|
||||||
html: 'html',
|
|
||||||
htm: 'html',
|
|
||||||
css: 'css',
|
|
||||||
scss: 'scss',
|
|
||||||
sass: 'sass',
|
|
||||||
less: 'less',
|
|
||||||
py: 'python',
|
|
||||||
rb: 'ruby',
|
|
||||||
rs: 'rust',
|
|
||||||
go: 'go',
|
|
||||||
java: 'java',
|
|
||||||
kt: 'kotlin',
|
|
||||||
kts: 'kotlin',
|
|
||||||
swift: 'swift',
|
|
||||||
c: 'c',
|
|
||||||
h: 'c',
|
|
||||||
cpp: 'cpp',
|
|
||||||
cc: 'cpp',
|
|
||||||
cxx: 'cpp',
|
|
||||||
hpp: 'cpp',
|
|
||||||
cs: 'csharp',
|
|
||||||
php: 'php',
|
|
||||||
sh: 'bash',
|
|
||||||
bash: 'bash',
|
|
||||||
zsh: 'bash',
|
|
||||||
sql: 'sql',
|
|
||||||
yaml: 'yaml',
|
|
||||||
yml: 'yaml',
|
|
||||||
toml: 'toml',
|
|
||||||
xml: 'xml',
|
|
||||||
xsl: 'xml',
|
|
||||||
lua: 'lua',
|
|
||||||
r: 'r',
|
|
||||||
dart: 'dart',
|
|
||||||
vue: 'vue',
|
|
||||||
svelte: 'svelte',
|
|
||||||
graphql: 'graphql',
|
|
||||||
gql: 'graphql',
|
|
||||||
dockerfile: 'dockerfile',
|
|
||||||
makefile: 'makefile',
|
|
||||||
zig: 'zig',
|
|
||||||
elixir: 'elixir',
|
|
||||||
ex: 'elixir',
|
|
||||||
exs: 'elixir',
|
|
||||||
};
|
|
||||||
|
|
||||||
function getFileType(name: string): FileType {
|
|
||||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
const baseName = name.toLowerCase();
|
|
||||||
if (['md', 'mdx', 'markdown'].includes(ext)) return 'markdown';
|
|
||||||
if (ext === 'pdf') return 'pdf';
|
|
||||||
if (EXT_TO_LANG[ext]) return 'code';
|
|
||||||
if (AUDIO_EXTS.includes(ext)) return 'audio';
|
|
||||||
if (ALL_VIDEO_EXTS.includes(ext)) return 'video';
|
|
||||||
if (IMAGE_EXTS.includes(ext)) return 'image';
|
|
||||||
if (baseName === 'dockerfile' || baseName === 'makefile') return 'code';
|
|
||||||
return 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLang(name: string): string {
|
|
||||||
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
const baseName = name.toLowerCase();
|
|
||||||
if (baseName === 'dockerfile') return 'dockerfile';
|
|
||||||
if (baseName === 'makefile') return 'makefile';
|
|
||||||
return EXT_TO_LANG[ext] ?? 'text';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getExt(name: string): string {
|
|
||||||
return name.split('.').pop()?.toLowerCase() ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function needsTranscode(name: string): boolean {
|
|
||||||
return TRANSCODE_VIDEO_EXTS.includes(getExt(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRawUrl(filePath: string, root?: string): string {
|
|
||||||
const headers = getHeaders();
|
|
||||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
|
||||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
|
||||||
return `${config.API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTranscodeUrl(filePath: string, root?: string, t = '0'): string {
|
|
||||||
const headers = getHeaders();
|
|
||||||
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
|
||||||
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
|
||||||
return `${config.API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&t=${encodeURIComponent(t)}&token=${encodeURIComponent(token)}${rootParam}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatTime(s: number): string {
|
|
||||||
if (!isFinite(s) || isNaN(s)) return '0:00';
|
|
||||||
const h = Math.floor(s / 3600);
|
|
||||||
const m = Math.floor((s % 3600) / 60);
|
|
||||||
const sec = Math.floor(s % 60);
|
|
||||||
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
|
||||||
return `${m}:${sec.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Shared seek bar hook ──
|
// ── Shared seek bar hook ──
|
||||||
function useSeekBar(mediaRef: React.RefObject<HTMLMediaElement | null>, duration: number) {
|
function useSeekBar(mediaRef: React.RefObject<HTMLMediaElement | null>, duration: number) {
|
||||||
@@ -318,7 +149,7 @@ function HighlightedCodeBlock({ code, lang }: { code: string; lang: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Scroll to top button ──
|
// ── Scroll to top button ──
|
||||||
function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject<HTMLDivElement | null> }) {
|
export function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObject<HTMLDivElement | null> }) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -343,7 +174,7 @@ function ScrollToTopButton({ scrollContainer }: { scrollContainer: React.RefObje
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Markdown renderer ──
|
// ── Markdown renderer ──
|
||||||
const MarkdownRenderer = ({
|
export const MarkdownRenderer = ({
|
||||||
content,
|
content,
|
||||||
scrollContainer,
|
scrollContainer,
|
||||||
}: {
|
}: {
|
||||||
@@ -400,12 +231,12 @@ const MarkdownRenderer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Text renderer ──
|
// ── Text renderer ──
|
||||||
const TextRenderer = ({ content }: { content: string }) => (
|
export const TextRenderer = ({ content }: { content: string }) => (
|
||||||
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
|
<pre className="whitespace-pre-wrap font-mono text-sm text-foreground leading-relaxed p-4">{content}</pre>
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── Code renderer with syntax highlighting ──
|
// ── Code renderer with syntax highlighting ──
|
||||||
const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => {
|
export const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => {
|
||||||
const [html, setHtml] = useState<string | null>(null);
|
const [html, setHtml] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -436,7 +267,7 @@ const CodeRenderer = ({ content, lang }: { content: string; lang: string }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Audio renderer ──
|
// ── Audio renderer ──
|
||||||
const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
export const AudioRenderer = ({ src, fileName, autoPlay = false }: { src: string; fileName: string; autoPlay?: boolean }) => {
|
||||||
const audioRef = useRef<HTMLAudioElement>(null);
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [currentTime, setCurrent] = useState(0);
|
const [currentTime, setCurrent] = useState(0);
|
||||||
@@ -455,6 +286,10 @@ const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
const onLoaded = () => {
|
const onLoaded = () => {
|
||||||
setDuration(a.duration);
|
setDuration(a.duration);
|
||||||
setLoaded(true);
|
setLoaded(true);
|
||||||
|
if (autoPlay) {
|
||||||
|
a.play();
|
||||||
|
setPlaying(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
const onTime = () => setCurrent(a.currentTime);
|
const onTime = () => setCurrent(a.currentTime);
|
||||||
const onEnded = () => setPlaying(false);
|
const onEnded = () => setPlaying(false);
|
||||||
@@ -479,8 +314,8 @@ const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
setPlaying(!playing);
|
setPlaying(!playing);
|
||||||
}, [playing]);
|
}, [playing]);
|
||||||
|
|
||||||
const changeVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const v = parseFloat(e.target.value);
|
const v = parseFloat(ev.target.value);
|
||||||
setVolume(v);
|
setVolume(v);
|
||||||
setMuted(v === 0);
|
setMuted(v === 0);
|
||||||
if (audioRef.current) audioRef.current.volume = v;
|
if (audioRef.current) audioRef.current.volume = v;
|
||||||
@@ -570,7 +405,7 @@ const AudioRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── Video renderer ──
|
// ── Video renderer ──
|
||||||
const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
export const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
const hideTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
||||||
@@ -613,14 +448,12 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fullscreen change detection
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||||
document.addEventListener('fullscreenchange', onChange);
|
document.addEventListener('fullscreenchange', onChange);
|
||||||
return () => document.removeEventListener('fullscreenchange', onChange);
|
return () => document.removeEventListener('fullscreenchange', onChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Auto-hide controls
|
|
||||||
const resetHideTimer = useCallback(() => {
|
const resetHideTimer = useCallback(() => {
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
if (hideTimer.current) clearTimeout(hideTimer.current);
|
if (hideTimer.current) clearTimeout(hideTimer.current);
|
||||||
@@ -636,8 +469,8 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
else v.pause();
|
else v.pause();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const changeVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
const changeVolume = useCallback((ev: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const val = parseFloat(e.target.value);
|
const val = parseFloat(ev.target.value);
|
||||||
setVolume(val);
|
setVolume(val);
|
||||||
setMuted(val === 0);
|
setMuted(val === 0);
|
||||||
if (videoRef.current) videoRef.current.volume = val;
|
if (videoRef.current) videoRef.current.volume = val;
|
||||||
@@ -683,7 +516,6 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
>
|
>
|
||||||
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
|
<video ref={videoRef} src={src} preload="metadata" className="max-w-full max-h-full" onClick={togglePlay} />
|
||||||
|
|
||||||
{/* Big center play button when paused */}
|
|
||||||
{loaded && !playing && (
|
{loaded && !playing && (
|
||||||
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
<button onClick={togglePlay} className="absolute inset-0 flex items-center justify-center cursor-pointer">
|
||||||
<div className="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center">
|
<div className="w-16 h-16 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center">
|
||||||
@@ -692,20 +524,17 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading spinner */}
|
|
||||||
{!loaded && (
|
{!loaded && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
<Loader2 className="h-8 w-8 text-white animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Bottom controls overlay */}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
|
className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent pt-12 pb-3 px-4 transition-opacity duration-300 ${
|
||||||
showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
showControls || !playing ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Seek bar */}
|
|
||||||
<SeekBar
|
<SeekBar
|
||||||
barRef={barRef}
|
barRef={barRef}
|
||||||
onSeekDown={onSeekDown}
|
onSeekDown={onSeekDown}
|
||||||
@@ -715,7 +544,6 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
thumbClass="bg-duck-teal border-white"
|
thumbClass="bg-duck-teal border-white"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Controls row */}
|
|
||||||
<div className="flex items-center gap-3 mt-1">
|
<div className="flex items-center gap-3 mt-1">
|
||||||
<button onClick={togglePlay} className="p-1 text-white hover:text-duck-teal transition-colors cursor-pointer">
|
<button onClick={togglePlay} className="p-1 text-white hover:text-duck-teal transition-colors cursor-pointer">
|
||||||
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
|
||||||
@@ -762,7 +590,7 @@ const VideoRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
// ── Image renderer ──
|
// ── Image renderer ──
|
||||||
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
const ZOOM_STEPS = [0.25, 0.5, 0.75, 1, 1.5, 2, 3, 5];
|
||||||
|
|
||||||
const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
export const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const minZoom = ZOOM_STEPS[0] ?? 1;
|
const minZoom = ZOOM_STEPS[0] ?? 1;
|
||||||
const maxZoom = ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? 1;
|
const maxZoom = ZOOM_STEPS[ZOOM_STEPS.length - 1] ?? 1;
|
||||||
@@ -798,28 +626,26 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
setRotation((r) => (r + 90) % 360);
|
setRotation((r) => (r + 90) % 360);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Scroll to zoom
|
|
||||||
const onWheel = useCallback(
|
const onWheel = useCallback(
|
||||||
(e: React.WheelEvent) => {
|
(ev: React.WheelEvent) => {
|
||||||
e.preventDefault();
|
ev.preventDefault();
|
||||||
if (e.deltaY < 0) zoomIn();
|
if (ev.deltaY < 0) zoomIn();
|
||||||
else zoomOut();
|
else zoomOut();
|
||||||
},
|
},
|
||||||
[zoomIn, zoomOut],
|
[zoomIn, zoomOut],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Pan with mouse drag when zoomed
|
|
||||||
const onMouseDown = useCallback(
|
const onMouseDown = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(ev: React.MouseEvent) => {
|
||||||
if (zoom <= 1) return;
|
if (zoom <= 1) return;
|
||||||
e.preventDefault();
|
ev.preventDefault();
|
||||||
setDragging(true);
|
setDragging(true);
|
||||||
dragStart.current = { x: e.clientX, y: e.clientY, ox: offset.x, oy: offset.y };
|
dragStart.current = { x: ev.clientX, y: ev.clientY, ox: offset.x, oy: offset.y };
|
||||||
|
|
||||||
const onMove = (ev: MouseEvent) => {
|
const onMove = (e: MouseEvent) => {
|
||||||
setOffset({
|
setOffset({
|
||||||
x: dragStart.current.ox + (ev.clientX - dragStart.current.x),
|
x: dragStart.current.ox + (e.clientX - dragStart.current.x),
|
||||||
y: dragStart.current.oy + (ev.clientY - dragStart.current.y),
|
y: dragStart.current.oy + (e.clientY - dragStart.current.y),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
@@ -833,7 +659,6 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
[zoom, offset],
|
[zoom, offset],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset offset when zoom goes to 1
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (zoom <= 1) setOffset({ x: 0, y: 0 });
|
if (zoom <= 1) setOffset({ x: 0, y: 0 });
|
||||||
}, [zoom]);
|
}, [zoom]);
|
||||||
@@ -848,7 +673,6 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative w-full h-full flex flex-col">
|
<div className="relative w-full h-full flex flex-col">
|
||||||
{/* Image area */}
|
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(hsl(var(--muted))_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
|
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(hsl(var(--muted))_0%_25%,transparent_0%_50%)] bg-[length:16px_16px] ${
|
||||||
@@ -879,7 +703,6 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Toolbar */}
|
|
||||||
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
|
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
|
||||||
<button
|
<button
|
||||||
onClick={zoomOut}
|
onClick={zoomOut}
|
||||||
@@ -914,252 +737,38 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ── PDF renderer ──
|
// ── PDF renderer ──
|
||||||
const PdfRenderer = ({ src }: { src: string }) => (
|
export const PdfRenderer = ({ src }: { src: string }) => (
|
||||||
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
|
<iframe src={src} className="w-full h-full border-0" title="PDF viewer" />
|
||||||
);
|
);
|
||||||
|
|
||||||
// ── File Viewer modal ──
|
// ── Backward-compat FileViewerView wrapper ──
|
||||||
export const FileViewer = ({ open, onOpenChange, filePath, fileName, root }: FileViewerProps) => {
|
type FileViewerViewProps = {
|
||||||
const [content, setContent] = useState<string | null>(null);
|
filePath: string;
|
||||||
const [loading, setLoading] = useState(false);
|
fileName: string;
|
||||||
const [error, setError] = useState<string | null>(null);
|
root?: string;
|
||||||
const [expanded, setExpanded] = useState(false);
|
content?: string;
|
||||||
const [ttsLoading, setTtsLoading] = useState(false);
|
onClose?: () => void;
|
||||||
const [ttsPlaying, setTtsPlaying] = useState(false);
|
};
|
||||||
const ttsAudioRef = useRef<HTMLAudioElement | null>(null);
|
|
||||||
const ttsUrlRef = useRef<string | null>(null);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const files = useFiles(root);
|
|
||||||
const fileType = getFileType(fileName);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
setContent(null);
|
|
||||||
setError(null);
|
|
||||||
setExpanded(false);
|
|
||||||
if (ttsAudioRef.current) {
|
|
||||||
ttsAudioRef.current.pause();
|
|
||||||
ttsAudioRef.current = null;
|
|
||||||
}
|
|
||||||
if (ttsUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(ttsUrlRef.current);
|
|
||||||
ttsUrlRef.current = null;
|
|
||||||
}
|
|
||||||
setTtsLoading(false);
|
|
||||||
setTtsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Media types stream via URL — no text fetch needed
|
|
||||||
if (fileType === 'audio' || fileType === 'video' || fileType === 'image' || fileType === 'pdf') {
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
files
|
|
||||||
.readFile(filePath)
|
|
||||||
.then((res) => setContent(res.content))
|
|
||||||
.catch(() => setError('Failed to read file'))
|
|
||||||
.finally(() => setLoading(false));
|
|
||||||
}, [open, filePath]);
|
|
||||||
|
|
||||||
const handleDownload = () => {
|
|
||||||
const url = getRawUrl(filePath, root);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
a.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReadAloud = async () => {
|
|
||||||
if (ttsPlaying) {
|
|
||||||
if (ttsAudioRef.current) {
|
|
||||||
ttsAudioRef.current.pause();
|
|
||||||
ttsAudioRef.current = null;
|
|
||||||
}
|
|
||||||
if (ttsUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(ttsUrlRef.current);
|
|
||||||
ttsUrlRef.current = null;
|
|
||||||
}
|
|
||||||
setTtsPlaying(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setTtsLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch('http://alpha:9051/v1/audio/speech', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ model: 'kokoro', input: content, voice: 'af_heart', response_format: 'mp3' }),
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error('TTS request failed');
|
|
||||||
const blob = await res.blob();
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
ttsUrlRef.current = url;
|
|
||||||
const audio = new Audio(url);
|
|
||||||
ttsAudioRef.current = audio;
|
|
||||||
audio.addEventListener('ended', () => {
|
|
||||||
setTtsPlaying(false);
|
|
||||||
ttsAudioRef.current = null;
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
ttsUrlRef.current = null;
|
|
||||||
});
|
|
||||||
await audio.play();
|
|
||||||
setTtsPlaying(true);
|
|
||||||
} catch {
|
|
||||||
toast.error('Failed to generate speech audio');
|
|
||||||
if (ttsUrlRef.current) {
|
|
||||||
URL.revokeObjectURL(ttsUrlRef.current);
|
|
||||||
ttsUrlRef.current = null;
|
|
||||||
}
|
|
||||||
ttsAudioRef.current = null;
|
|
||||||
setTtsPlaying(false);
|
|
||||||
} finally {
|
|
||||||
setTtsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerIcon =
|
|
||||||
fileType === 'audio' ? (
|
|
||||||
<Music className="h-4 w-4 text-duck-teal shrink-0" />
|
|
||||||
) : fileType === 'video' ? (
|
|
||||||
<Film className="h-4 w-4 text-duck-orange shrink-0" />
|
|
||||||
) : fileType === 'image' ? (
|
|
||||||
<Image className="h-4 w-4 text-duck-yellow shrink-0" />
|
|
||||||
) : fileType === 'pdf' ? (
|
|
||||||
<FileType2 className="h-4 w-4 text-red-500 shrink-0" />
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex h-4 w-4 shrink-0" dangerouslySetInnerHTML={{ __html: getIcon(fileName).svg }} />
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoSrc =
|
|
||||||
fileType === 'video'
|
|
||||||
? needsTranscode(fileName)
|
|
||||||
? getTranscodeUrl(filePath, root)
|
|
||||||
: getRawUrl(filePath, root)
|
|
||||||
: '';
|
|
||||||
|
|
||||||
|
export const FileViewerView = ({ filePath, fileName, root, content, onClose }: FileViewerViewProps) => {
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<FileViewerProvider filePath={filePath} fileName={fileName} root={root} content={content}>
|
||||||
<DialogPortal>
|
<div className="h-full flex flex-col">
|
||||||
<DialogOverlay className="z-[600] bg-black/60 backdrop-blur-sm" />
|
<div className="shrink-0 flex items-center gap-2 px-4 py-1.5 border-b border-duck-dark/10 dark:border-foreground/10 bg-background/60 text-duck-dark/70 dark:text-foreground/70">
|
||||||
<DialogPrimitive.Content
|
<FileViewerHeader />
|
||||||
onOpenAutoFocus={(ev) => ev.preventDefault()}
|
{onClose && (
|
||||||
className={`fixed left-[50%] top-[50%] z-[600] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 ${
|
|
||||||
expanded ? 'w-[95vw] h-[95vh]' : 'w-[90vw] max-w-4xl h-[85vh]'
|
|
||||||
}`}
|
|
||||||
style={fileType === 'video' ? { backgroundColor: '#000' } : cardStyle()}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div
|
|
||||||
className={`shrink-0 flex items-center gap-3 px-5 py-3 border-b ${
|
|
||||||
fileType === 'video' ? 'border-white/10 bg-black/80' : 'border-duck-dark/10 bg-background/60'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{headerIcon}
|
|
||||||
<span
|
|
||||||
className={`text-sm font-semibold truncate flex-1 ${
|
|
||||||
fileType === 'video' ? 'text-white' : 'text-duck-dark'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{fileName}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={`text-[10px] font-mono uppercase tracking-wider shrink-0 ${
|
|
||||||
fileType === 'video' ? 'text-white/40' : 'text-duck-dark/40'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{fileType === 'code' ? getLang(fileName) : fileType}
|
|
||||||
</span>
|
|
||||||
{(fileType === 'markdown' || fileType === 'code' || fileType === 'text') && content && (
|
|
||||||
<button
|
<button
|
||||||
onClick={handleReadAloud}
|
onClick={onClose}
|
||||||
disabled={ttsLoading}
|
className="p-1 rounded hover:bg-duck-dark/10 dark:hover:bg-foreground/10 transition-colors cursor-pointer"
|
||||||
className="p-1.5 rounded-md text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5 disabled:opacity-40 transition-colors cursor-pointer"
|
|
||||||
title="Read Aloud"
|
|
||||||
>
|
>
|
||||||
{ttsLoading ? (
|
<X className="h-3.5 w-3.5" />
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : ttsPlaying ? (
|
|
||||||
<Square className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<Volume2 className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={handleDownload}
|
|
||||||
className={`p-1.5 rounded-md transition-colors cursor-pointer ${
|
|
||||||
fileType === 'video'
|
|
||||||
? 'text-white/40 hover:text-white hover:bg-white/10'
|
|
||||||
: 'text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5'
|
|
||||||
}`}
|
|
||||||
title="Download"
|
|
||||||
>
|
|
||||||
<Download className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setExpanded((e) => !e)}
|
|
||||||
className={`p-1.5 rounded-md transition-colors cursor-pointer ${
|
|
||||||
fileType === 'video'
|
|
||||||
? 'text-white/40 hover:text-white hover:bg-white/10'
|
|
||||||
: 'text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5'
|
|
||||||
}`}
|
|
||||||
title={expanded ? 'Collapse' : 'Expand'}
|
|
||||||
>
|
|
||||||
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
||||||
</button>
|
|
||||||
<DialogPrimitive.Close
|
|
||||||
className={`p-1.5 rounded-md transition-colors cursor-pointer ${
|
|
||||||
fileType === 'video'
|
|
||||||
? 'text-white/40 hover:text-white hover:bg-white/10'
|
|
||||||
: 'text-duck-dark/40 hover:text-duck-dark hover:bg-duck-dark/5'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</DialogPrimitive.Close>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
{/* Content */}
|
<FileViewerBody />
|
||||||
<div ref={scrollRef} className="flex-1 min-h-0 overflow-auto">
|
|
||||||
{loading ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<Loader2 className="h-6 w-6 text-duck-teal animate-spin" />
|
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
|
||||||
<div className="flex items-center justify-center h-full">
|
|
||||||
<span className="text-sm text-red-500">{error}</span>
|
|
||||||
</div>
|
</div>
|
||||||
) : fileType === 'pdf' ? (
|
</FileViewerProvider>
|
||||||
<PdfRenderer src={getRawUrl(filePath, root)} />
|
|
||||||
) : fileType === 'image' ? (
|
|
||||||
<ImageRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
|
||||||
) : fileType === 'video' ? (
|
|
||||||
<VideoRenderer src={videoSrc} fileName={fileName} />
|
|
||||||
) : fileType === 'audio' ? (
|
|
||||||
<AudioRenderer src={getRawUrl(filePath, root)} fileName={fileName} />
|
|
||||||
) : content !== null ? (
|
|
||||||
fileType === 'code' ? (
|
|
||||||
<div>
|
|
||||||
<CodeRenderer content={content} lang={getLang(fileName)} />
|
|
||||||
<ScrollToTopButton scrollContainer={scrollRef} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="px-8 py-6">
|
|
||||||
{fileType === 'markdown' ? (
|
|
||||||
<MarkdownRenderer content={content} scrollContainer={scrollRef} />
|
|
||||||
) : (
|
|
||||||
<TextRenderer content={content} />
|
|
||||||
)}
|
|
||||||
<ScrollToTopButton scrollContainer={scrollRef} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</DialogPrimitive.Content>
|
|
||||||
</DialogPortal>
|
|
||||||
</Dialog>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import { getHeaders } from 'hooks/useClient';
|
||||||
|
import { config } from 'config';
|
||||||
|
|
||||||
|
export type FileType = 'markdown' | 'audio' | 'video' | 'image' | 'pdf' | 'code' | 'archive' | 'text';
|
||||||
|
|
||||||
|
export const ARCHIVE_EXTS = ['zip', 'tar', 'gz', 'tgz', 'bz2', 'tbz2', 'xz', 'txz', 'zst', '7z', 'rar'];
|
||||||
|
export const AUDIO_EXTS = ['mp3', 'wav', 'flac', 'ogg', 'oga', 'opus', 'aac', 'm4a', 'wma'];
|
||||||
|
export const NATIVE_VIDEO_EXTS = ['mp4', 'm4v', 'webm', 'ogv', 'mov'];
|
||||||
|
export const TRANSCODE_VIDEO_EXTS = [
|
||||||
|
'mkv',
|
||||||
|
'avi',
|
||||||
|
'wmv',
|
||||||
|
'flv',
|
||||||
|
'ts',
|
||||||
|
'mts',
|
||||||
|
'm2ts',
|
||||||
|
'3gp',
|
||||||
|
'3g2',
|
||||||
|
'vob',
|
||||||
|
'divx',
|
||||||
|
'asf',
|
||||||
|
'f4v',
|
||||||
|
'rm',
|
||||||
|
'rmvb',
|
||||||
|
];
|
||||||
|
export const ALL_VIDEO_EXTS = [...NATIVE_VIDEO_EXTS, ...TRANSCODE_VIDEO_EXTS];
|
||||||
|
export const IMAGE_EXTS = [
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'svg',
|
||||||
|
'bmp',
|
||||||
|
'ico',
|
||||||
|
'tiff',
|
||||||
|
'tif',
|
||||||
|
'avif',
|
||||||
|
'heic',
|
||||||
|
'heif',
|
||||||
|
'jfif',
|
||||||
|
'apng',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const EXT_TO_LANG: Record<string, string> = {
|
||||||
|
js: 'javascript',
|
||||||
|
mjs: 'javascript',
|
||||||
|
cjs: 'javascript',
|
||||||
|
jsx: 'jsx',
|
||||||
|
ts: 'typescript',
|
||||||
|
mts: 'typescript',
|
||||||
|
cts: 'typescript',
|
||||||
|
tsx: 'tsx',
|
||||||
|
json: 'json',
|
||||||
|
jsonc: 'jsonc',
|
||||||
|
html: 'html',
|
||||||
|
htm: 'html',
|
||||||
|
css: 'css',
|
||||||
|
scss: 'scss',
|
||||||
|
sass: 'sass',
|
||||||
|
less: 'less',
|
||||||
|
py: 'python',
|
||||||
|
rb: 'ruby',
|
||||||
|
rs: 'rust',
|
||||||
|
go: 'go',
|
||||||
|
java: 'java',
|
||||||
|
kt: 'kotlin',
|
||||||
|
kts: 'kotlin',
|
||||||
|
swift: 'swift',
|
||||||
|
c: 'c',
|
||||||
|
h: 'c',
|
||||||
|
cpp: 'cpp',
|
||||||
|
cc: 'cpp',
|
||||||
|
cxx: 'cpp',
|
||||||
|
hpp: 'cpp',
|
||||||
|
cs: 'csharp',
|
||||||
|
php: 'php',
|
||||||
|
sh: 'bash',
|
||||||
|
bash: 'bash',
|
||||||
|
zsh: 'bash',
|
||||||
|
sql: 'sql',
|
||||||
|
yaml: 'yaml',
|
||||||
|
yml: 'yaml',
|
||||||
|
toml: 'toml',
|
||||||
|
xml: 'xml',
|
||||||
|
xsl: 'xml',
|
||||||
|
lua: 'lua',
|
||||||
|
r: 'r',
|
||||||
|
dart: 'dart',
|
||||||
|
vue: 'vue',
|
||||||
|
svelte: 'svelte',
|
||||||
|
graphql: 'graphql',
|
||||||
|
gql: 'graphql',
|
||||||
|
dockerfile: 'dockerfile',
|
||||||
|
makefile: 'makefile',
|
||||||
|
zig: 'zig',
|
||||||
|
elixir: 'elixir',
|
||||||
|
ex: 'elixir',
|
||||||
|
exs: 'elixir',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFileType(name: string): FileType {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const baseName = name.toLowerCase();
|
||||||
|
if (['md', 'mdx', 'markdown'].includes(ext)) return 'markdown';
|
||||||
|
if (ext === 'pdf') return 'pdf';
|
||||||
|
if (EXT_TO_LANG[ext]) return 'code';
|
||||||
|
if (AUDIO_EXTS.includes(ext)) return 'audio';
|
||||||
|
if (ALL_VIDEO_EXTS.includes(ext)) return 'video';
|
||||||
|
if (IMAGE_EXTS.includes(ext)) return 'image';
|
||||||
|
if (baseName.endsWith('.tar.gz') || baseName.endsWith('.tar.bz2') || baseName.endsWith('.tar.xz') || baseName.endsWith('.tar.zst')) return 'archive';
|
||||||
|
if (ARCHIVE_EXTS.includes(ext)) return 'archive';
|
||||||
|
if (baseName === 'dockerfile' || baseName === 'makefile') return 'code';
|
||||||
|
return 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLang(name: string): string {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
const baseName = name.toLowerCase();
|
||||||
|
if (baseName === 'dockerfile') return 'dockerfile';
|
||||||
|
if (baseName === 'makefile') return 'makefile';
|
||||||
|
return EXT_TO_LANG[ext] ?? 'text';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getExt(name: string): string {
|
||||||
|
return name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function needsTranscode(name: string): boolean {
|
||||||
|
return TRANSCODE_VIDEO_EXTS.includes(getExt(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTime(s: number): string {
|
||||||
|
if (!isFinite(s) || isNaN(s)) return '0:00';
|
||||||
|
const h = Math.floor(s / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
const sec = Math.floor(s % 60);
|
||||||
|
if (h > 0) return `${h}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||||
|
return `${m}:${sec.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRawUrl(filePath: string, root?: string): string {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
|
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||||
|
return `${config.API_URL}/file-browser/raw?path=${encodeURIComponent(filePath)}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTranscodeUrl(filePath: string, root?: string, t = '0'): string {
|
||||||
|
const headers = getHeaders();
|
||||||
|
const token = headers['Authorization']?.replace('Bearer ', '') ?? '';
|
||||||
|
const rootParam = root && root !== 'home' ? `&root=${encodeURIComponent(root)}` : '';
|
||||||
|
return `${config.API_URL}/file-browser/transcode?path=${encodeURIComponent(filePath)}&t=${encodeURIComponent(t)}&token=${encodeURIComponent(token)}${rootParam}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getArchiveBaseName(name: string): string {
|
||||||
|
const lower = name.toLowerCase();
|
||||||
|
for (const compound of ['.tar.gz', '.tar.bz2', '.tar.xz', '.tar.zst']) {
|
||||||
|
if (lower.endsWith(compound)) return name.slice(0, -compound.length);
|
||||||
|
}
|
||||||
|
const dotIdx = name.lastIndexOf('.');
|
||||||
|
return dotIdx > 0 ? name.slice(0, dotIdx) : name;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { FileViewerView } from './FileViewerView';
|
||||||
|
export { FileViewerProvider, FileViewerHeader, FileViewerBody } from './FileViewerContext';
|
||||||
|
export { getFileType, getLang, getExt, getArchiveBaseName, ARCHIVE_EXTS, type FileType } from './file-types';
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
"./FileBrowser": "./FileBrowser/index.ts",
|
"./FileBrowser": "./FileBrowser/index.ts",
|
||||||
"./ChatHistory": "./ChatHistory/index.ts",
|
"./ChatHistory": "./ChatHistory/index.ts",
|
||||||
"./Chat": "./Chat/index.ts",
|
"./Chat": "./Chat/index.ts",
|
||||||
"./CodeEditor": "./CodeEditor/index.ts"
|
"./CodeEditor": "./CodeEditor/index.ts",
|
||||||
|
"./FileViewer": "./FileViewer/index.ts"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Copy, Check, Play } from 'lucide-react';
|
||||||
|
|
||||||
|
type CommandBlockProps = {
|
||||||
|
label?: string;
|
||||||
|
command: string;
|
||||||
|
onRun?: (command: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CommandBlock = ({ label, command, onRun }: CommandBlockProps) => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copy = () => {
|
||||||
|
navigator.clipboard.writeText(command);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="inline-flex flex-col gap-1">
|
||||||
|
{label && <span className="text-xs text-duck-dark/50">{label}</span>}
|
||||||
|
<div className="inline-flex items-center gap-1 bg-[#1a1a2e] rounded-lg pl-3 pr-1 py-1.5">
|
||||||
|
<code className="text-sm text-[#e0e0e0] font-mono whitespace-nowrap">{command}</code>
|
||||||
|
{onRun && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onRun(command)}
|
||||||
|
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||||
|
title="Run in terminal"
|
||||||
|
>
|
||||||
|
<Play className="h-3.5 w-3.5 text-duck-teal" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={copy}
|
||||||
|
className="shrink-0 p-1.5 rounded hover:bg-white/10 cursor-pointer transition-colors"
|
||||||
|
title="Copy command"
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5 text-white/40" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ArrowLeftRight } from 'lucide-react';
|
import type { ComponentType } from 'react';
|
||||||
import type { LayoutPanel, AppRegistry, PanelComponents } from './types';
|
import { ArrowLeftRight, X } from 'lucide-react';
|
||||||
|
import type { LayoutPanel, AppRegistry, PanelComponents, PanelComponentEntry } from './types';
|
||||||
import { useWorkspace } from './WorkspaceContext';
|
import { useWorkspace } from './WorkspaceContext';
|
||||||
import { Card } from '../Card';
|
import { Card } from '../Card';
|
||||||
import { AppPicker } from './AppPicker';
|
import { AppPicker } from './AppPicker';
|
||||||
@@ -32,6 +33,9 @@ type PanelContextMenuProps = {
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPanelEntry = (v: ComponentType | PanelComponentEntry): v is PanelComponentEntry =>
|
||||||
|
typeof v === 'object' && v !== null && 'component' in v;
|
||||||
|
|
||||||
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
const PanelContextMenu = ({ panelId, hasApp, isLastPanel, onSplit, onRemove, onClearApp, children }: PanelContextMenuProps) => {
|
||||||
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
const { swapSourceId, setSwapSourceId } = useWorkspace();
|
||||||
|
|
||||||
@@ -116,10 +120,18 @@ const SwapSourceIndicator = ({ panelId }: { panelId: string }) => {
|
|||||||
// };
|
// };
|
||||||
|
|
||||||
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
export const PanelSlot = ({ panel, registry, components, interactive, isLastPanel, onSetApp, onSplit, onRemove }: PanelSlotProps) => {
|
||||||
const PanelComponent = components?.[panel.id];
|
const rawPanelComponent = components?.[panel.id];
|
||||||
|
const panelEntry = rawPanelComponent && isPanelEntry(rawPanelComponent) ? rawPanelComponent : null;
|
||||||
|
const PanelComponent = panelEntry ? panelEntry.component : (rawPanelComponent as ComponentType | undefined);
|
||||||
|
|
||||||
const entry = panel.appType ? registry[panel.appType] : null;
|
const entry = panel.appType ? registry[panel.appType] : null;
|
||||||
const AppComponent = PanelComponent ?? entry?.component;
|
const AppComponent = PanelComponent ?? entry?.component;
|
||||||
|
|
||||||
|
// Resolve header, provider, onClose from PanelComponentEntry or registry
|
||||||
|
const HeaderComponent = panelEntry?.header ?? entry?.header;
|
||||||
|
const ProviderComponent = panelEntry?.provider ?? entry?.provider;
|
||||||
|
const onClose = panelEntry?.onClose;
|
||||||
|
|
||||||
const contextMenu = interactive
|
const contextMenu = interactive
|
||||||
? (content: React.ReactNode) => (
|
? (content: React.ReactNode) => (
|
||||||
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
<PanelContextMenu panelId={panel.id} hasApp={!!AppComponent} isLastPanel={isLastPanel} onSplit={onSplit} onRemove={onRemove} onClearApp={() => onSetApp(panel.id, null)}>
|
||||||
@@ -168,6 +180,56 @@ export const PanelSlot = ({ panel, registry, components, interactive, isLastPane
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App with header — render header chrome + body
|
||||||
|
if (HeaderComponent) {
|
||||||
|
const headerBar = (
|
||||||
|
<div className="shrink-0 flex items-center gap-2 px-3 py-1.5 border-b border-black/10 text-black font-semibold">
|
||||||
|
<HeaderComponent panelId={panel.id} />
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded hover:bg-black/10 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<Card className="h-full w-full overflow-hidden p-0 rounded-none border-0 shadow-none">
|
||||||
|
<AppComponent panelId={panel.id} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const inner = ProviderComponent ? (
|
||||||
|
<ProviderComponent panelId={panel.id}>
|
||||||
|
{headerBar}
|
||||||
|
{body}
|
||||||
|
</ProviderComponent>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{headerBar}
|
||||||
|
{body}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
return contextMenu(
|
||||||
|
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||||
|
<div
|
||||||
|
className="relative h-full w-full overflow-hidden rounded-lg border backdrop-blur-xl p-2 flex flex-col"
|
||||||
|
style={{ backgroundColor: 'rgba(255, 255, 255, 0.12)', borderColor: 'rgba(255, 255, 255, 0.2)' }}
|
||||||
|
>
|
||||||
|
{inner}
|
||||||
|
</div>
|
||||||
|
{overlays}
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: no header
|
||||||
return contextMenu(
|
return contextMenu(
|
||||||
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
<div data-panel-id={panel.id} className="group/panel relative h-full w-full p-1">
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents } from './types';
|
export type { LayoutNode, LayoutGroup, LayoutPanel, WorkspaceDefinition, AppRegistry, AppRegistryEntry, PanelComponents, PanelComponentEntry } from './types';
|
||||||
export type { DropPosition } from './layout-utils';
|
export type { DropPosition } from './layout-utils';
|
||||||
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
export { createDefaultLayout, splitPanel, removePanel, setApp, updateSizes, swapPanels, movePanel, pruneEmptyPanels, countPanels, hasAnyApp } from './layout-utils';
|
||||||
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
export { WorkspaceProvider, useWorkspace } from './WorkspaceContext';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ComponentType } from 'react';
|
import type { ComponentType, ReactNode } from 'react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
export type LayoutGroup = {
|
export type LayoutGroup = {
|
||||||
@@ -28,6 +28,8 @@ export type AppRegistryEntry = {
|
|||||||
name: string;
|
name: string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
component: ComponentType<{ panelId: string }>;
|
component: ComponentType<{ panelId: string }>;
|
||||||
|
header?: ComponentType<{ panelId: string }>;
|
||||||
|
provider?: ComponentType<{ panelId: string; children: ReactNode }>;
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
fixedHeight?: number;
|
fixedHeight?: number;
|
||||||
widget?: boolean;
|
widget?: boolean;
|
||||||
@@ -35,4 +37,11 @@ export type AppRegistryEntry = {
|
|||||||
|
|
||||||
export type AppRegistry = Record<string, AppRegistryEntry>;
|
export type AppRegistry = Record<string, AppRegistryEntry>;
|
||||||
|
|
||||||
export type PanelComponents = Record<string, ComponentType>;
|
export type PanelComponentEntry = {
|
||||||
|
component: ComponentType;
|
||||||
|
header?: ComponentType;
|
||||||
|
provider?: ComponentType<{ children: ReactNode }>;
|
||||||
|
onClose?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PanelComponents = Record<string, ComponentType | PanelComponentEntry>;
|
||||||
|
|||||||
Reference in New Issue
Block a user