'use client'; import type { FileTreeNode } from '@/components/agent-workspace/types'; import { useEffect, useMemo, useRef, useState } from 'react'; import { CodeEditor } from '@/components/agent-workspace/code-editor'; import { FileTree } from '@/components/agent-workspace/file-tree'; import { useAction, useMutation, useQuery } from 'convex/react'; import { FilePlus, FolderUp, Trash2, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { api } from '@spoon/backend/convex/_generated/api.js'; import { Button, Card, Input, Label } from '@spoon/ui'; type DotfileMeta = { _id: string; path: string; size: number; isExecutable: boolean; updatedAt: number; }; type UploadFile = { path: string; content: string; isExecutable?: boolean }; // Minimal typed surface of the drag-and-drop FileSystem entry API. type FsEntry = { isFile: boolean; isDirectory: boolean; name: string; file?: (cb: (f: File) => void, err: (e: unknown) => void) => void; createReader?: () => { readEntries: ( cb: (e: FsEntry[]) => void, err: (e: unknown) => void, ) => void; }; }; const buildTree = (files: DotfileMeta[], rootLabel: string): FileTreeNode => { const root: FileTreeNode = { name: rootLabel, path: '', type: 'directory', children: [], }; for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) { const segments = file.path.split('/'); let node = root; segments.forEach((segment, index) => { const isLeaf = index === segments.length - 1; const childPath = segments.slice(0, index + 1).join('/'); node.children ??= []; let child = node.children.find((c) => c.path === childPath); if (!child) { child = { name: segment, path: childPath, type: isLeaf ? 'file' : 'directory', children: isLeaf ? undefined : [], }; node.children.push(child); } node = child; }); } return root; }; const readAllEntries = (reader: { readEntries: (cb: (e: FsEntry[]) => void, err: (e: unknown) => void) => void; }) => new Promise((resolve, reject) => { const all: FsEntry[] = []; const next = () => reader.readEntries((batch) => { if (batch.length === 0) resolve(all); else { all.push(...batch); next(); } }, reject); next(); }); const collectEntry = async ( entry: FsEntry, prefix: string, out: UploadFile[], ) => { if (entry.isFile && entry.file) { const file = await new Promise((res, rej) => entry.file?.(res, rej)); out.push({ path: `${prefix}${entry.name}`, content: await file.text() }); } else if (entry.isDirectory && entry.createReader) { const entries = await readAllEntries(entry.createReader()); for (const child of entries) { await collectEntry(child, `${prefix}${entry.name}/`, out); } } }; export const DotfilesManager = () => { const settings = useQuery(api.userEnvironment.getMine); const filesQuery = useQuery(api.userDotfiles.listMine); const files = useMemo( () => (filesQuery ?? []) as DotfileMeta[], [filesQuery], ); const getFileContent = useAction(api.userDotfilesNode.getFileContent); const putFile = useAction(api.userDotfilesNode.putFile); const importFiles = useAction(api.userDotfilesNode.importFiles); const removeFile = useMutation(api.userDotfiles.remove); const updateEnv = useMutation(api.userEnvironment.updateMine); const [selected, setSelected] = useState(); const [content, setContent] = useState(''); const [savedContent, setSavedContent] = useState(''); const [expandedOverride, setExpandedOverride] = useState( null, ); const [dragOver, setDragOver] = useState(false); const folderInputRef = useRef(null); const filesInputRef = useRef(null); const firstName = settings?.firstName ?? 'you'; const tree = useMemo( () => buildTree(files, `home/${firstName}`), [files, firstName], ); // Directories default to expanded; once the user toggles, their choice wins. const allDirs = useMemo( () => files .flatMap((f) => { const segs = f.path.split('/'); return segs .slice(0, -1) .map((_, i) => segs.slice(0, i + 1).join('/')); }) .filter((v, i, a) => a.indexOf(v) === i), [files], ); const expanded = expandedOverride ?? allDirs; const openFile = async (path: string) => { const file = files.find((f) => f.path === path); if (!file) return; // directory setSelected(file); setContent(''); setSavedContent(''); try { const { content: text } = await getFileContent({ fileId: file._id as never, }); setContent(text); setSavedContent(text); } catch { toast.error('Could not open file.'); } }; const saveSelected = async (next: string) => { if (!selected) return; try { await putFile({ path: selected.path, content: next, isExecutable: selected.isExecutable, }); setSavedContent(next); toast.success('Saved.'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Save failed.'); } }; const importAll = async (incoming: UploadFile[]) => { const valid = incoming.filter((f) => f.path.trim()); if (valid.length === 0) return; try { await importFiles({ files: valid }); toast.success(`Imported ${valid.length} file(s).`); } catch (error) { toast.error(error instanceof Error ? error.message : 'Import failed.'); } }; const onDrop = async (event: React.DragEvent) => { event.preventDefault(); setDragOver(false); const out: UploadFile[] = []; const entries: FsEntry[] = []; for (const item of Array.from(event.dataTransfer.items)) { const entry = item.webkitGetAsEntry() as FsEntry | null; if (entry) entries.push(entry); } if (entries.length > 0) { for (const entry of entries) await collectEntry(entry, '', out); } else { for (const file of Array.from(event.dataTransfer.files)) { out.push({ path: file.name, content: await file.text() }); } } await importAll(out); }; const onPickFiles = async ( event: React.ChangeEvent, stripFirstSegment: boolean, ) => { const picked = Array.from(event.target.files ?? []); const out: UploadFile[] = []; for (const file of picked) { const relative = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name; const path = stripFirstSegment ? relative.split('/').slice(1).join('/') : relative; out.push({ path, content: await file.text() }); } event.target.value = ''; await importAll(out); }; const newFile = async () => { const path = window.prompt('New file path (relative to home):', '.bashrc'); if (!path?.trim()) return; try { await putFile({ path: path.trim(), content: '' }); toast.success('Created.'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Could not create.'); } }; const deleteSelected = async () => { if (!selected) return; try { await removeFile({ fileId: selected._id as never }); setSelected(undefined); setContent(''); setSavedContent(''); toast.success('Deleted.'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Delete failed.'); } }; return (
{selected ? ( ) : null} void onPickFiles(e, true)} /> void onPickFiles(e, false)} />
{ e.preventDefault(); setDragOver(true); }} onDragLeave={() => setDragOver(false)} onDrop={(e) => void onDrop(e)} className={`border-border min-h-0 overflow-auto border-b md:border-r md:border-b-0 ${ dragOver ? 'bg-primary/10' : '' }`} > void openFile(path)} onToggleDirectory={(path) => setExpandedOverride( expanded.includes(path) ? expanded.filter((p) => p !== path) : [...expanded, path], ) } /> {files.length === 0 ? (

Drag files or folders here, or use the buttons above. They land relative to your home directory.

) : null}
{selected ? ( undefined} /> ) : (
Select a file to edit, or add files to get started.
)}
{ try { await updateEnv(values); toast.success('Saved.'); } catch (error) { toast.error(error instanceof Error ? error.message : 'Save failed.'); } }} />

Dotfiles are encrypted at rest. For real API keys or tokens, use the Secrets feature on a Spoon instead — those are injected as environment variables.

); }; const RepoPanel = ({ settings, onSave, }: { settings: | { dotfilesRepoUrl?: string; dotfilesRepoRef?: string; setupCommand?: string; } | undefined; onSave: (values: { dotfilesRepoUrl?: string; dotfilesRepoRef?: string; setupCommand?: string; }) => Promise; }) => { const [repoUrl, setRepoUrl] = useState(''); const [repoRef, setRepoRef] = useState(''); const [setupCommand, setSetupCommand] = useState(''); const [saving, setSaving] = useState(false); const [hydrated, setHydrated] = useState(false); useEffect(() => { if (!settings || hydrated) return; const timeout = window.setTimeout(() => { setRepoUrl(settings.dotfilesRepoUrl ?? ''); setRepoRef(settings.dotfilesRepoRef ?? ''); setSetupCommand(settings.setupCommand ?? ''); setHydrated(true); }, 0); return () => window.clearTimeout(timeout); }, [settings, hydrated]); return (

Dotfiles repo (optional)

A public git repo cloned to ~/.dotfiles on start. The setup command runs in the container afterwards (e.g.{' '} install to symlink, like a dotfiles bootstrap). Your edited files above are applied on top.

setRepoUrl(e.target.value)} />
setRepoRef(e.target.value)} />
setSetupCommand(e.target.value)} />
); };