'use client'; import type { FileResponse, FileTreeNode, } from '@/components/agent-workspace/types'; import { useCallback, useEffect, useState } from 'react'; import { CodeEditor } from '@/components/agent-workspace/code-editor'; import { FileTabs } from '@/components/agent-workspace/file-tabs'; import { FileTree } from '@/components/agent-workspace/file-tree'; import { XtermSession } from '@/components/agent-workspace/xterm-session'; import { useQuery } from 'convex/react'; import { FolderTree, SquareTerminal } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@spoon/ui'; import type { BoxStatus, LifecycleAction } from './box-status-card'; import { BoxStatusCard } from './box-status-card'; const STATUS_POLL_MS = 10_000; type OpenFileState = { path: string; content: string; savedContent: string; loading: boolean; saving: boolean; }; export const MachineShell = () => { const environment = useQuery(api.userEnvironment.getMine, {}); const username = environment?.username ?? ''; const firstName = environment?.firstName; const [status, setStatus] = useState(undefined); const [pendingAction, setPendingAction] = useState( null, ); const [tree, setTree] = useState(null); const [files, setFiles] = useState>({}); const [openFilePaths, setOpenFilePaths] = useState([]); const [activeFilePath, setActiveFilePath] = useState(); const [expandedDirectoryPaths, setExpandedDirectoryPaths] = useState< string[] >([]); const [vimEnabled, setVimEnabled] = useState(false); const [activeTab, setActiveTab] = useState<'files' | 'terminal'>('files'); const refreshStatus = useCallback(async () => { try { const response = await fetch('/api/box/status'); if (!response.ok) throw new Error(await response.text()); const data = (await response.json()) as { status: BoxStatus }; setStatus(data.status); } catch (error) { console.error(error); setStatus((current) => current ?? null); } }, []); const loadTree = useCallback(async () => { try { const response = await fetch('/api/box/tree'); if (!response.ok) throw new Error(await response.text()); const data = (await response.json()) as { tree: FileTreeNode | null }; setTree(data.tree); } catch (error) { console.error(error); } }, []); useEffect(() => { void refreshStatus(); void loadTree(); const interval = window.setInterval(() => { void refreshStatus(); }, STATUS_POLL_MS); return () => window.clearInterval(interval); }, [refreshStatus, loadTree]); const runLifecycle = useCallback( (action: LifecycleAction) => { if (pendingAction) return; setPendingAction(action); void (async () => { try { const response = await fetch('/api/box/lifecycle', { method: 'POST', body: JSON.stringify({ action }), }); if (!response.ok) throw new Error(await response.text()); const data = (await response.json()) as { status: BoxStatus }; setStatus(data.status); toast.success( action === 'stop' ? 'Machine stopped.' : action === 'restart' ? 'Machine restarted.' : 'Machine started.', ); void loadTree(); } catch (error) { console.error(error); toast.error(`Could not ${action} your machine.`); void refreshStatus(); } finally { setPendingAction(null); } })(); }, [pendingAction, loadTree, refreshStatus], ); const loadFile = useCallback(async (path: string) => { setFiles((current) => ({ ...current, [path]: current[path] ?? { path, content: '', savedContent: '', loading: true, saving: false, }, })); const response = await fetch( `/api/box/file?path=${encodeURIComponent(path)}`, ); if (!response.ok) throw new Error(await response.text()); const data = (await response.json()) as FileResponse; setFiles((current) => ({ ...current, [data.path]: { path: data.path, content: data.content, savedContent: data.content, loading: false, saving: false, }, })); }, []); const openFile = useCallback( (path: string) => { setOpenFilePaths((current) => current.includes(path) ? current : [...current, path], ); setActiveFilePath(path); if (!files[path]) { void loadFile(path).catch((error) => { console.error(error); setFiles((current) => { const next = { ...current }; delete next[path]; return next; }); setOpenFilePaths((current) => current.filter((filePath) => filePath !== path), ); toast.error('Could not load file.'); }); } }, [files, loadFile], ); const writeFileContent = async (path: string, content: string) => { setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, savedContent: '', loading: false }), content, saving: true, }, })); const response = await fetch('/api/box/file', { method: 'PUT', body: JSON.stringify({ path, content }), }); if (!response.ok) { setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, content, savedContent: '', loading: false, }), saving: false, }, })); toast.error('Could not save file.'); throw new Error(await response.text()); } setFiles((current) => ({ ...current, [path]: { ...(current[path] ?? { path, loading: false }), content, savedContent: content, saving: false, }, })); toast.success('File saved.'); }; const saveFile = async (content: string) => { if (!activeFilePath) return; await writeFileContent(activeFilePath, content); }; const closeFile = (path: string) => { const index = openFilePaths.indexOf(path); const nextOpen = openFilePaths.filter((filePath) => filePath !== path); setOpenFilePaths(nextOpen); setFiles((current) => { const next = { ...current }; delete next[path]; return next; }); if (activeFilePath === path) { setActiveFilePath(nextOpen[index - 1] ?? nextOpen[index] ?? undefined); } }; const toggleDirectory = (path: string) => { setExpandedDirectoryPaths((current) => current.includes(path) ? current.filter((directoryPath) => directoryPath !== path) : [...current, path], ); }; const activeFile = activeFilePath ? files[activeFilePath] : undefined; const running = Boolean(status?.running); return (

{firstName ? `${firstName}'s Machine` : 'My Machine'}

Your always-available dev box — a persistent home, a terminal, and a file browser rooted at ~.

setActiveTab(value as 'files' | 'terminal')} className='border-border bg-muted/20 flex h-[calc(100vh-8.5rem)] min-h-[600px] flex-col overflow-hidden rounded-md border' > Files Terminal
({ path, dirty: files[path] ? files[path].content !== files[path].savedContent : false, }))} activePath={activeFilePath} onActivate={setActiveFilePath} onClose={closeFile} /> { if (!activeFilePath) return; setFiles((current) => ({ ...current, [activeFilePath]: { ...(current[activeFilePath] ?? { path: activeFilePath, savedContent: '', loading: false, saving: false, }), content, }, })); }} />
); };