merge conflict resolved

This commit is contained in:
2026-02-19 18:09:02 +00:00
14 changed files with 402 additions and 57 deletions
@@ -23,6 +23,7 @@ type FileGridProps = {
onRenamingChange: (name: string | null) => void;
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
scrollRef: React.RefObject<HTMLDivElement | null>;
};
@@ -72,6 +73,7 @@ export const FileGrid = ({
onRenamingChange,
getMatchingTasks,
onRunTask,
onCreateWorkspace,
scrollRef,
}: FileGridProps) => {
const lastClickedIdx = useRef<number>(-1);
@@ -153,6 +155,7 @@ export const FileGrid = ({
onRenamingChange={onRenamingChange}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask}
onCreateWorkspace={onCreateWorkspace}
/>
);
@@ -1,5 +1,5 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download, LayoutGrid } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
@@ -43,6 +43,7 @@ export type FileItemProps = {
onRenamingChange: (name: string | null) => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (entry: DirEntry) => void;
};
function formatSize(bytes: number): string {
@@ -70,6 +71,7 @@ type MenuItemsProps = {
onCopy: () => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
onCreateWorkspace: (e: DirEntry) => void;
};
const DropdownMenuItems = ({
@@ -83,6 +85,7 @@ const DropdownMenuItems = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => (
<>
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
@@ -108,6 +111,15 @@ const DropdownMenuItems = ({
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
{entry.type === 'directory' && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</DropdownMenuItem>
</>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
@@ -142,6 +154,7 @@ const ContextMenuItems = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: MenuItemsProps) => (
<>
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
@@ -167,6 +180,15 @@ const ContextMenuItems = ({
</ContextMenuSubContent>
</ContextMenuSub>
)}
{entry.type === 'directory' && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onCreateWorkspace(entry)} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
</>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
@@ -300,6 +322,7 @@ export const FileItem = ({
onRenamingChange,
matchingTasks,
onRunTask,
onCreateWorkspace,
}: FileItemProps) => {
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -376,6 +399,7 @@ export const FileItem = ({
onCopy,
matchingTasks,
onRunTask,
onCreateWorkspace,
};
const cutOpacity = isCut ? 'opacity-50' : '';
@@ -33,7 +33,11 @@ type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type HomeRoot = 'home' | '~' | 'officer.dev';
export const Files = () => {
type FilesProps = {
basePath?: string;
};
export const Files = ({ basePath = '/' }: FilesProps) => {
const { user } = useAuth();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
@@ -86,6 +90,10 @@ export const Files = () => {
}, []);
useEffect(() => {
if (basePath !== '/' && !currentPath.startsWith(basePath)) {
setCurrentPath(basePath);
return;
}
refresh();
}, [currentPath, homeRoot]);
@@ -137,6 +145,10 @@ export const Files = () => {
const selectedPaths = () => Array.from(selected).map(entryPath);
const handleNavigate = (path: string) => {
if (basePath !== '/' && !path.startsWith(basePath)) {
setCurrentPath(basePath);
return;
}
setCurrentPath(path);
};
@@ -262,6 +274,18 @@ export const Files = () => {
setRunningTask({ task, entry });
};
const handleCreateWorkspace = (entry: DirEntry) => {
const folderPath = currentPath === '/' ? `/${entry.name}` : `${currentPath}/${entry.name}`;
navigate(`/workspaces/new?name=${encodeURIComponent(entry.name)}&cwd=${encodeURIComponent(folderPath)}`);
};
const handleCreateWorkspaceHere = () => {
const dirName = currentPath === '/' ? '' : currentPath.split('/').pop()!;
const params = new URLSearchParams({ cwd: currentPath });
if (dirName) params.set('name', dirName);
navigate(`/workspaces/new?${params}`);
};
const handleGitClone = async () => {
const url = cloneUrl.trim();
if (!url) return;
@@ -509,7 +533,7 @@ export const Files = () => {
</div>
{/* Home dir selector (Super Admin only) */}
{user?.role === 'Super Admin' && (
{basePath === '/' && user?.role === 'Super Admin' && (
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<RadioGroup
value={homeRoot}
@@ -539,7 +563,7 @@ export const Files = () => {
{/* Breadcrumb */}
<div className="shrink-0 border-b border-duck-dark/10 px-4 py-2">
<Breadcrumb path={currentPath} onNavigate={handleNavigate} />
<Breadcrumb path={currentPath} onNavigate={handleNavigate} basePath={basePath} />
</div>
{/* Upload progress */}
@@ -625,6 +649,7 @@ export const Files = () => {
onRenamingChange={setRenamingName}
getMatchingTasks={getMatchingTasks}
onRunTask={handleRunTask}
onCreateWorkspace={handleCreateWorkspace}
scrollRef={fileScrollRef}
/>
)}
@@ -645,6 +670,10 @@ export const Files = () => {
<FolderPlus className="mr-2 h-4 w-4" />
New folder
</ContextMenuItem>
<ContextMenuItem onClick={handleCreateWorkspaceHere} className="cursor-pointer">
<LayoutGrid className="mr-2 h-4 w-4" />
Create Workspace here
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
@@ -5,8 +5,19 @@ import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
import { generateSlug } from 'helpers/slug';
import { useUserState } from '@/state/useUserState';
import { useWorkspacesState } from '@/state/useWorkspacesState';
import type { WorkspaceDefinition } from '@/components/Workspace';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
SELECTED_WORKSPACE_KEY,
CREATING_WORKSPACE_KEY,
@@ -28,8 +39,10 @@ export const WorkspaceListApp = () => {
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
const [search, setSearch] = useState('');
const [deleting, setDeleting] = useState<WorkspaceDefinition | null>(null);
const isWorkspacesPage = location.pathname === '/workspaces';
const filtered = search
? workspaces.filter((ws) => {
@@ -46,18 +59,26 @@ export const WorkspaceListApp = () => {
setName(ws.name);
setDescription(ws.description ?? '');
setTemplateIdx(ws.templateIdx ?? 0);
const cwdPath = !ws.cwd || ws.cwd === '~' ? '/' : ws.cwd.replace(/^~\//, '/');
setFilePath(cwdPath);
};
const handleDelete = (ev: React.MouseEvent, ws: WorkspaceDefinition) => {
ev.stopPropagation();
setWorkspaces((prev) => prev.filter((w) => w.id !== ws.id));
if (selected === ws.id) setSelected(null);
setDeleting(ws);
};
const layoutKey = `ws-layout-${ws.id}`;
const confirmDelete = () => {
if (!deleting) return;
setWorkspaces((prev) => prev.filter((w) => w.id !== deleting.id));
if (selected === deleting.id) setSelected(null);
const layoutKey = `ws-layout-${deleting.id}`;
const currentState = queryClient.getQueryData<Record<string, unknown>>(['WORKSPACES_STATE']) ?? {};
const { [layoutKey]: _, ...rest } = currentState;
queryClient.setQueryData(['WORKSPACES_STATE'], rest);
client.patch('/user/workspaces-state', { [layoutKey]: null }).catch(() => {});
setDeleting(null);
};
const handleClick = (ws: WorkspaceDefinition) => {
@@ -88,6 +109,7 @@ export const WorkspaceListApp = () => {
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setFilePath('/');
setCreating(true);
if (!isWorkspacesPage) navigate('/workspaces');
}}
@@ -146,6 +168,23 @@ export const WorkspaceListApp = () => {
</p>
)}
</div>
<AlertDialog open={deleting !== null} onOpenChange={(open) => { if (!open) setDeleting(null); }}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete workspace</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete <strong>{deleting?.name}</strong>? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmDelete} className="bg-red-600 hover:bg-red-700">
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useNavigate, useSearchParams } from 'react-router';
import { LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
import { FolderOpen, LayoutGrid, Plus, ArrowRight, Type, Rocket, FileText, Layout } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { useGlobal } from 'hooks/useGlobal';
import { useClient } from 'hooks/useClient';
@@ -9,6 +10,7 @@ import { WorkspaceLayout, WorkspaceView, createDefaultLayout } from '@/component
import type { LayoutNode, WorkspaceDefinition } from '@/components/Workspace';
import { Button } from '@/components/ui/button';
import { generateSlug, slugify } from 'helpers/slug';
import { Files } from '../Files';
import { appRegistry } from './app-registry';
import { SELECTED_WORKSPACE_KEY, CREATING_WORKSPACE_KEY, EDITING_WORKSPACE_KEY, NEW_WS_NAME_KEY, NEW_WS_DESC_KEY, NEW_WS_TEMPLATE_KEY } from './constants';
@@ -389,6 +391,14 @@ const CreatePanel = () => {
);
};
// --- CwdFileBrowser (basePath-constrained file browser for workspace form) ---
const CwdFileBrowser = () => {
const [currentPath] = useUserState<string>('files/currentPath', '/');
const [basePath] = useState(currentPath);
return <Files basePath={basePath} />;
};
// --- New Workspace Layout ---
const newWsLayout: LayoutNode = {
@@ -403,7 +413,7 @@ const newWsLayout: LayoutNode = {
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'new-ws-name', appType: 'new-ws-name' }, size: 30 },
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser' }, size: 70 },
{ node: { type: 'panel', id: 'new-ws-dir', appType: 'file-browser-cwd' }, size: 70 },
],
},
size: 50,
@@ -425,6 +435,7 @@ const newWsLayout: LayoutNode = {
const newWsRegistry = {
...appRegistry,
'file-browser-cwd': { name: 'File Browser', icon: FolderOpen, component: CwdFileBrowser },
'new-ws-name': { name: 'Name', icon: Type, component: NamePanel },
'new-ws-template': { name: 'Template', icon: Layout, component: TemplatePanel },
'new-ws-create': { name: 'Create', icon: Rocket, component: CreatePanel },
@@ -440,6 +451,7 @@ const WorkspacePreviewEmpty = () => {
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
const [, setTemplateIdx] = useGlobal<number>(NEW_WS_TEMPLATE_KEY, 0);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
if (creating || editingId) return <NewWorkspaceForm />;
@@ -447,6 +459,7 @@ const WorkspacePreviewEmpty = () => {
setName(generateSlug());
setDescription('');
setTemplateIdx(0);
setFilePath('/');
setCreating(true);
};
@@ -505,6 +518,7 @@ export const WorkspaceListScreen = () => {
};
export const NewWorkspaceRedirect = () => {
const navigate = useNavigate();
const [params] = useSearchParams();
const [, setName] = useGlobal<string>(NEW_WS_NAME_KEY, '');
const [, setDescription] = useGlobal<string>(NEW_WS_DESC_KEY, '');
@@ -514,16 +528,16 @@ export const NewWorkspaceRedirect = () => {
const [, setSelected] = useGlobal<string | null>(SELECTED_WORKSPACE_KEY, null);
const [, setFilePath] = useUserState<string>('files/currentPath', '/');
const name = params.get('name');
const cwd = params.get('cwd');
useEffect(() => {
setSelected(null);
setEditing(null);
setName(params.get('name') || generateSlug());
setDescription('');
setTemplateIdx(0);
setFilePath(params.get('cwd') || '/');
setCreating(true);
navigate('/workspaces', { replace: true });
}, []);
setSelected(null);
setEditing(null);
setName(name || generateSlug());
setDescription('');
setTemplateIdx(0);
setCreating(true);
if (cwd) setFilePath(cwd);
return <Navigate to="/workspaces" replace />;
return null;
};
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { MessageSquare, FolderOpen, History, Music, Code, TerminalSquare, Monitor, LayoutGrid, LayoutDashboard, Sparkles } from 'lucide-react';
import { useAuth } from 'hooks/useAuth';
import type { AppRegistry } from '@/components/Workspace';
@@ -48,12 +48,24 @@ const PiMonoChatWidget = ({ onProviderChange }: { onProviderChange: (p: 'claude'
return <ChatPanel chat={piMono} provider="pi-mono" availableModels={models} onProviderChange={onProviderChange} />;
};
const cwdToPath = (cwd: string) => (cwd === '~' ? '/' : cwd.slice(1));
const FileBrowserWrapper = () => {
const { cwd } = useWorkspace();
const basePath = cwdToPath(cwd);
return <Files basePath={basePath} />;
};
const CodeEditorWrapper = () => <CodeEditorView className="h-full w-full" />;
const EMPTY_TERMINALS: Record<string, string> = {};
const TerminalWrapper = ({ panelId }: { panelId: string }) => {
const { workspaceId } = useWorkspace();
const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-terminals-${workspaceId}` : 'ws-terminals-default';
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {});
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId];
@@ -63,16 +75,27 @@ const TerminalWrapper = ({ panelId }: { panelId: string }) => {
}
}, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} />;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} cwd={cwd} />;
};
const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
const { user } = useAuth();
const { workspaceId } = useWorkspace();
const { workspaceId, cwd } = useWorkspace();
const stateKey = workspaceId ? `ws-host-terminals-${workspaceId}` : 'ws-host-terminals-default';
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, {});
const [terminals, setTerminals] = useWorkspacesState<Record<string, string>>(stateKey, EMPTY_TERMINALS);
const setTerminalsRef = useRef(setTerminals);
setTerminalsRef.current = setTerminals;
const sessionId = terminals[panelId];
@@ -82,6 +105,15 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
}
}, [panelId, sessionId, setTerminals]);
useEffect(() => {
return () => {
setTerminalsRef.current((prev) => {
const { [panelId]: _, ...rest } = prev;
return rest;
});
};
}, [panelId]);
if (user?.role !== 'Super Admin') {
return (
<div className="flex h-full w-full items-center justify-center text-sm text-muted-foreground">
@@ -92,12 +124,12 @@ const HostTerminalWrapper = ({ panelId }: { panelId: string }) => {
if (!sessionId) return null;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} />;
return <TerminalView className="h-full w-full p-2" sessionId={sessionId} sandboxed={false} cwd={cwd} />;
};
export const appRegistry: AppRegistry = {
'chat': { name: 'Chat', icon: MessageSquare, component: ChatWidget },
'file-browser': { name: 'File Browser', icon: FolderOpen, component: () => <Files /> },
'file-browser': { name: 'File Browser', icon: FolderOpen, component: FileBrowserWrapper },
'chat-history': { name: 'Chat History', icon: History, component: () => <ChatHistory /> },
'sound-library': { name: 'Sound Library', icon: Music, component: () => <Catalog /> },
'code-editor': { name: 'Code Editor', icon: Code, component: CodeEditorWrapper },