Workspaces in Workspaces all around

This commit is contained in:
2026-02-19 01:49:15 +00:00
parent 72bca6cd42
commit dd8ab84df5
84 changed files with 3047 additions and 749 deletions
@@ -15,6 +15,7 @@ type FileGridProps = {
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onDownload: (entry: DirEntry) => void;
onSelect: (names: Set<string>) => void;
onCut: () => void;
onCopy: () => void;
@@ -63,6 +64,7 @@ export const FileGrid = ({
onDelete,
onRename,
onChat,
onDownload,
onSelect,
onCut,
onCopy,
@@ -143,6 +145,7 @@ export const FileGrid = ({
onDelete={onDelete}
onRename={onRename}
onChat={onChat}
onDownload={onDownload}
onSelect={handleSelect}
onCut={onCut}
onCopy={onCopy}
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play } from 'lucide-react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
@@ -35,6 +35,7 @@ export type FileItemProps = {
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onDownload: (entry: DirEntry) => void;
onSelect: (entry: DirEntry, ev: React.MouseEvent) => void;
onCut: () => void;
onCopy: () => void;
@@ -64,6 +65,7 @@ type MenuItemsProps = {
onDelete: (e: DirEntry) => void;
onStartRename: () => void;
onChat: (e: DirEntry) => void;
onDownload: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
matchingTasks: TaskSummary[];
@@ -76,6 +78,7 @@ const DropdownMenuItems = ({
onDelete,
onStartRename,
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
@@ -86,6 +89,10 @@ const DropdownMenuItems = ({
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
@@ -130,6 +137,7 @@ const ContextMenuItems = ({
onDelete,
onStartRename,
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
@@ -140,6 +148,10 @@ const ContextMenuItems = ({
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
@@ -237,7 +249,7 @@ const InlineRenameInput = ({
if (ev.key === 'Escape') onCancel();
}}
onClick={(ev) => ev.stopPropagation()}
className="text-sm font-medium text-duck-dark bg-white border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
/>
);
};
@@ -257,7 +269,7 @@ const Checkbox = ({
onClick(ev);
}}
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-white/80 hover:border-duck-teal/50'
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
>
{checked && <Check className="h-3 w-3" />}
@@ -280,6 +292,7 @@ export const FileItem = ({
onDelete,
onRename,
onChat,
onDownload,
onSelect,
onCut,
onCopy,
@@ -358,6 +371,7 @@ export const FileItem = ({
onDelete,
onStartRename: () => setRenaming(true),
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
@@ -401,7 +401,7 @@ const MarkdownRenderer = ({
// ── Text renderer ──
const TextRenderer = ({ content }: { content: string }) => (
<pre className="whitespace-pre-wrap font-mono text-sm text-duck-dark 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 ──
@@ -851,7 +851,7 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
{/* Image area */}
<div
ref={containerRef}
className={`flex-1 min-h-0 flex items-center justify-center overflow-hidden bg-[repeating-conic-gradient(#e5e7eb_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] ${
zoom > 1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in'
}`}
onWheel={onWheel}
@@ -880,7 +880,7 @@ const ImageRenderer = ({ src, fileName }: { src: string; fileName: string }) =>
</div>
{/* Toolbar */}
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-white/80">
<div className="shrink-0 flex items-center justify-center gap-1 py-2 border-t border-duck-dark/10 bg-background/80">
<button
onClick={zoomOut}
disabled={zoom <= minZoom}
@@ -1050,22 +1050,12 @@ export const FileViewer = ({ open, onOpenChange, filePath, fileName, root }: Fil
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({
backgroundColor: 'rgba(255, 255, 255, 0.97)',
backgroundImage: `
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
`,
})
}
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-white/60'
fileType === 'video' ? 'border-white/10 bg-black/80' : 'border-duck-dark/10 bg-background/60'
}`}
>
{headerIcon}
@@ -104,17 +104,21 @@ type TaskRunnerModalProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
task: TaskSummary;
entryName: string;
entryType: 'file' | 'directory';
cwd: { root?: string; path: string };
entryName?: string;
entryType?: 'file' | 'directory';
cwd?: { root?: string; path: string };
promptOverride?: string;
};
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd }: TaskRunnerModalProps) => {
export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType, cwd = { path: '' }, promptOverride }: TaskRunnerModalProps) => {
const { settings } = useSettings();
const taskSettings = settings.tasks;
const [provider, setProvider] = useState<'claude' | 'opencode'>(taskSettings.defaultProvider);
const defaultInput = `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`;
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName, entryType };
const defaultInput = promptOverride
?? (entryName && entryType
? `Read the task instructions at ${task.filePath} and execute them on the ${entryType}: ${entryName}`
: `Read the task instructions at ${task.filePath} and execute them`);
const taskInfo: TaskInfo = { taskName: task.name, taskDirName: task.dirName, entryName: entryName ?? '', entryType: entryType ?? 'file' };
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -123,16 +127,10 @@ export const TaskRunnerModal = ({ open, onOpenChange, task, entryName, entryType
<DialogPrimitive.Content
onOpenAutoFocus={(ev) => ev.preventDefault()}
className="fixed left-[50%] top-[50%] z-[700] translate-x-[-50%] translate-y-[-50%] flex flex-col overflow-hidden rounded-xl border-2 border-duck-dark/30 shadow-2xl w-[90vw] max-w-3xl h-[80vh] duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
style={cardStyle({
backgroundColor: 'rgba(255, 255, 255, 0.97)',
backgroundImage: `
linear-gradient(to right, rgba(20, 83, 45, 0.04) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20, 83, 45, 0.04) 1px, transparent 1px)
`,
})}
style={cardStyle()}
>
{/* Header */}
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-white/60">
<div className="shrink-0 flex items-center gap-3 px-5 py-3 border-b border-duck-dark/10 bg-background/60">
<div className="flex-1 min-w-0">
<span className="text-sm font-semibold text-duck-dark truncate block">{task.name}</span>
<span className="text-xs text-duck-dark/50 truncate block">{entryName}</span>
@@ -5,8 +5,7 @@ import {
Loader2,
LayoutGrid,
List,
Maximize2,
Minimize2,
ClipboardPaste,
FolderPlus,
Search,
@@ -43,7 +42,7 @@ export const Files = () => {
const [entries, setEntries] = useState<DirEntry[]>([]);
const [loading, setLoading] = useState(true);
const [viewMode, setViewMode] = useUserState<'grid' | 'list'>('files/viewMode', 'grid');
const [fullscreen, setFullscreen] = useUserState<boolean>('files/fullscreen', false);
const [showHidden, setShowHidden] = useUserState<boolean>('files/showHidden', false);
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -240,6 +239,25 @@ export const Files = () => {
});
};
const handleDownload = async (entry: DirEntry) => {
const path = entryPath(entry.name);
try {
await files.download([path]);
} catch {
toast.error('Failed to download');
}
};
const handleDownloadSelected = async () => {
const paths = selectedPaths();
if (paths.length === 0) return;
try {
await files.download(paths);
} catch {
toast.error('Failed to download');
}
};
const handleRunTask = (task: TaskSummary, entry: DirEntry) => {
setRunningTask({ task, entry });
};
@@ -378,6 +396,7 @@ export const Files = () => {
{/* Toolbar */}
<div className="shrink-0 flex items-center gap-2 md:gap-3 px-3 md:px-4 h-12 md:h-14 border-b border-duck-dark/10 overflow-hidden">
<Toolbar
onRefresh={refresh}
onCreateDir={handleCreateDir}
onUpload={handleUpload}
selectionCount={selected.size}
@@ -385,6 +404,7 @@ export const Files = () => {
onCut={handleCut}
onCopy={handleCopy}
onPaste={handlePaste}
onDownloadSelected={handleDownloadSelected}
onDeleteSelected={handleDeleteSelected}
onClearSelection={() => setSelected(new Set())}
/>
@@ -402,7 +422,7 @@ export const Files = () => {
value={cloneUrl}
onChange={(ev) => setCloneUrl(ev.target.value)}
placeholder="https://github.com/user/repo.git"
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
className="h-8 w-40 md:w-64 text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50 px-2"
disabled={cloning}
onKeyDown={(ev) => {
if (ev.key === 'Escape') {
@@ -454,7 +474,7 @@ export const Files = () => {
}
}}
placeholder="Search files..."
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-white/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
className="h-8 w-28 focus:w-40 md:w-40 md:focus:w-56 transition-all pl-8 pr-7 text-base md:text-sm rounded-md border border-duck-dark/20 bg-background/60 text-duck-dark placeholder:text-duck-dark/40 outline-none focus:border-duck-teal/50"
/>
{searchQuery && (
<button
@@ -486,12 +506,6 @@ export const Files = () => {
<List className="h-4 w-4" />
</button>
</div>
<button
onClick={() => setFullscreen((f) => !f)}
className="hidden md:block p-1.5 rounded-md text-duck-dark/50 hover:bg-duck-dark/5 cursor-pointer transition-colors"
>
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
{/* Home dir selector (Super Admin only) */}
@@ -603,6 +617,7 @@ export const Files = () => {
onDelete={handleDelete}
onRename={handleRename}
onChat={handleChat}
onDownload={handleDownload}
onSelect={setSelected}
onCut={handleCut}
onCopy={handleCopy}
@@ -96,7 +96,7 @@ export const FileBrowser = () => {
value={searchQuery}
onChange={(ev) => setSearchQuery(ev.target.value)}
placeholder="Search..."
className="w-full rounded-lg border border-duck-dark/20 bg-white pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
className="w-full rounded-lg border border-duck-dark/20 bg-background pl-8 pr-8 py-1 text-xs text-duck-dark placeholder:text-duck-dark/30 focus:outline-none focus:ring-2 focus:ring-duck-teal/30 focus:border-duck-teal/50"
/>
{searchQuery && (
<button
@@ -1,14 +1,30 @@
import { Card } from '@/components/Card';
import { useMemo } from 'react';
import type { LayoutNode, PanelComponents } from '@/components/Workspace';
import { WorkspaceLayout } from '@/components/Workspace';
import { appRegistry } from '../Workspaces/app-registry';
import { Files as FilesInner } from './Screen';
export { FilesInner as Files };
export const FilesPage = () => (
<div className="flex items-center justify-center h-full p-0 md:p-4">
<Card className="flex flex-col overflow-hidden w-full h-full md:w-[90vw] md:h-[90%] lg:w-[50vw]">
<FilesInner />
</Card>
</div>
);
const layout: LayoutNode = {
type: 'panel',
id: 'files-main',
appType: null,
};
export const FilesPage = () => {
const panelComponents: PanelComponents = useMemo(
() => ({
'files-main': FilesInner,
}),
[],
);
return (
<div className="h-full w-full pt-2">
<WorkspaceLayout layout={layout} onLayoutChange={() => {}} registry={appRegistry} components={panelComponents} />
</div>
);
};
export { FileBrowser as FileBrowserApp } from './Widget';