From 2cd03b6a835bde17ecf44819484b701ff4f3c53c Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Wed, 24 Jun 2026 09:57:11 -0400 Subject: [PATCH] Settings: Dotfiles file-browser workspace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Settings → Dotfiles section: a mini-workspace rooted at home/{firstName} reusing FileTree + the Monaco CodeEditor - Drag-and-drop files/folders (FileSystem entries API) or upload a folder (webkitdirectory) / files; edit in-place; new file; delete - Files stored relative to HOME via the encrypted userDotfiles API - Repo & setup panel (public repo URL + ref + setup script path) writing userEnvironment; secrets nudge toward the Secrets feature --- .../src/app/(app)/settings/dotfiles/page.tsx | 22 + apps/next/src/app/(app)/settings/layout.tsx | 3 +- .../settings/dotfiles/dotfiles-manager.tsx | 453 ++++++++++++++++++ 3 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 apps/next/src/app/(app)/settings/dotfiles/page.tsx create mode 100644 apps/next/src/components/settings/dotfiles/dotfiles-manager.tsx diff --git a/apps/next/src/app/(app)/settings/dotfiles/page.tsx b/apps/next/src/app/(app)/settings/dotfiles/page.tsx new file mode 100644 index 0000000..a356493 --- /dev/null +++ b/apps/next/src/app/(app)/settings/dotfiles/page.tsx @@ -0,0 +1,22 @@ +'use server'; + +import { DotfilesManager } from '@/components/settings/dotfiles/dotfiles-manager'; + +const SettingsDotfilesPage = () => { + return ( +
+
+

Dotfiles

+

+ Your personal shell, editor, and tool config — applied to the + workspace terminal in every thread. Files are placed relative to your + home directory (e.g. .bashrc,{' '} + .config/nvim/init.lua). +

+
+ +
+ ); +}; + +export default SettingsDotfilesPage; diff --git a/apps/next/src/app/(app)/settings/layout.tsx b/apps/next/src/app/(app)/settings/layout.tsx index 1a8d026..218d154 100644 --- a/apps/next/src/app/(app)/settings/layout.tsx +++ b/apps/next/src/app/(app)/settings/layout.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { Brain, Github, ServerCog, Shield, User } from 'lucide-react'; +import { Brain, FileCog, Github, ServerCog, Shield, User } from 'lucide-react'; import { cn } from '@spoon/ui'; @@ -11,6 +11,7 @@ const settingsItems = [ { href: '/settings/profile', label: 'Profile', icon: User }, { href: '/settings/integrations', label: 'Integrations', icon: Github }, { href: '/settings/ai-providers', label: 'AI providers', icon: Brain }, + { href: '/settings/dotfiles', label: 'Dotfiles', icon: FileCog }, { href: '/settings/worker', label: 'Worker', icon: ServerCog }, { href: '/settings/security', label: 'Security', icon: Shield }, ]; diff --git a/apps/next/src/components/settings/dotfiles/dotfiles-manager.tsx b/apps/next/src/components/settings/dotfiles/dotfiles-manager.tsx new file mode 100644 index 0000000..58e22e8 --- /dev/null +++ b/apps/next/src/components/settings/dotfiles/dotfiles-manager.tsx @@ -0,0 +1,453 @@ +'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; + await putFile({ + path: selected.path, + content: next, + isExecutable: selected.isExecutable, + }); + setSavedContent(next); + toast.success('Saved.'); + }; + + 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; + await removeFile({ fileId: selected._id as never }); + setSelected(undefined); + setContent(''); + setSavedContent(''); + toast.success('Deleted.'); + }; + + 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. +
+ )} +
+
+
+ + { + await updateEnv(values); + toast.success('Saved.'); + }} + /> + +

+ 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)} + /> +
+ +
+ ); +};