Settings: Dotfiles file-browser workspace

- 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
This commit is contained in:
Gabriel Brown
2026-06-24 09:57:11 -04:00
parent 4c0de2cbf3
commit 2cd03b6a83
3 changed files with 477 additions and 1 deletions
@@ -0,0 +1,22 @@
'use server';
import { DotfilesManager } from '@/components/settings/dotfiles/dotfiles-manager';
const SettingsDotfilesPage = () => {
return (
<section className='space-y-4'>
<div>
<h2 className='text-xl font-semibold'>Dotfiles</h2>
<p className='text-muted-foreground mt-1 text-sm'>
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. <code>.bashrc</code>,{' '}
<code>.config/nvim/init.lua</code>).
</p>
</div>
<DotfilesManager />
</section>
);
};
export default SettingsDotfilesPage;
+2 -1
View File
@@ -3,7 +3,7 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; 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'; import { cn } from '@spoon/ui';
@@ -11,6 +11,7 @@ const settingsItems = [
{ href: '/settings/profile', label: 'Profile', icon: User }, { href: '/settings/profile', label: 'Profile', icon: User },
{ href: '/settings/integrations', label: 'Integrations', icon: Github }, { href: '/settings/integrations', label: 'Integrations', icon: Github },
{ href: '/settings/ai-providers', label: 'AI providers', icon: Brain }, { 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/worker', label: 'Worker', icon: ServerCog },
{ href: '/settings/security', label: 'Security', icon: Shield }, { href: '/settings/security', label: 'Security', icon: Shield },
]; ];
@@ -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<FsEntry[]>((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<File>((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<DotfileMeta>();
const [content, setContent] = useState('');
const [savedContent, setSavedContent] = useState('');
const [expandedOverride, setExpandedOverride] = useState<string[] | null>(
null,
);
const [dragOver, setDragOver] = useState(false);
const folderInputRef = useRef<HTMLInputElement>(null);
const filesInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>,
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 (
<div className='space-y-4'>
<Card className='gap-0 overflow-hidden p-0 shadow-none'>
<div className='border-border flex flex-wrap items-center gap-2 border-b p-2'>
<Button type='button' variant='outline' size='sm' onClick={newFile}>
<FilePlus className='size-4' /> New file
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => folderInputRef.current?.click()}
>
<FolderUp className='size-4' /> Upload folder
</Button>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => filesInputRef.current?.click()}
>
<Upload className='size-4' /> Upload files
</Button>
{selected ? (
<Button
type='button'
variant='outline'
size='sm'
className='text-destructive ml-auto'
onClick={() => void deleteSelected()}
>
<Trash2 className='size-4' /> Delete
</Button>
) : null}
<input
ref={folderInputRef}
type='file'
// @ts-expect-error non-standard but widely supported folder picker
webkitdirectory=''
multiple
hidden
onChange={(e) => void onPickFiles(e, true)}
/>
<input
ref={filesInputRef}
type='file'
multiple
hidden
onChange={(e) => void onPickFiles(e, false)}
/>
</div>
<div className='grid min-h-[28rem] grid-cols-1 md:grid-cols-[16rem_1fr]'>
<div
onDragOver={(e) => {
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' : ''
}`}
>
<FileTree
tree={tree}
selectedPath={selected?.path}
expandedPaths={expanded}
onSelect={(path) => void openFile(path)}
onToggleDirectory={(path) =>
setExpandedOverride(
expanded.includes(path)
? expanded.filter((p) => p !== path)
: [...expanded, path],
)
}
/>
{files.length === 0 ? (
<p className='text-muted-foreground p-4 text-center text-xs'>
Drag files or folders here, or use the buttons above. They land
relative to your home directory.
</p>
) : null}
</div>
<div className='min-h-0'>
{selected ? (
<CodeEditor
path={selected.path}
content={content}
savedContent={savedContent}
readOnly={false}
vimEnabled={false}
onSave={saveSelected}
onChange={setContent}
onVimEnabledChange={() => undefined}
/>
) : (
<div className='text-muted-foreground flex h-full items-center justify-center p-6 text-sm'>
Select a file to edit, or add files to get started.
</div>
)}
</div>
</div>
</Card>
<RepoPanel
settings={settings}
onSave={async (values) => {
await updateEnv(values);
toast.success('Saved.');
}}
/>
<p className='text-muted-foreground text-xs'>
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.
</p>
</div>
);
};
const RepoPanel = ({
settings,
onSave,
}: {
settings:
| {
dotfilesRepoUrl?: string;
dotfilesRepoRef?: string;
setupCommand?: string;
}
| undefined;
onSave: (values: {
dotfilesRepoUrl?: string;
dotfilesRepoRef?: string;
setupCommand?: string;
}) => Promise<void>;
}) => {
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 (
<Card className='space-y-3 p-4 shadow-none'>
<div>
<h3 className='font-medium'>Dotfiles repo (optional)</h3>
<p className='text-muted-foreground text-xs'>
A public git repo cloned to <code>~/.dotfiles</code> on start. The
setup command runs in the container afterwards (e.g.{' '}
<code>install</code> to symlink, like a dotfiles bootstrap). Your
edited files above are applied on top.
</p>
</div>
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-1'>
<Label htmlFor='repoUrl'>Public repo URL</Label>
<Input
id='repoUrl'
placeholder='https://github.com/you/dotfiles'
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
/>
</div>
<div className='space-y-1'>
<Label htmlFor='repoRef'>Branch / ref (optional)</Label>
<Input
id='repoRef'
placeholder='main'
value={repoRef}
onChange={(e) => setRepoRef(e.target.value)}
/>
</div>
</div>
<div className='space-y-1'>
<Label htmlFor='setupCommand'>Setup script path (optional)</Label>
<Input
id='setupCommand'
placeholder='install.sh'
value={setupCommand}
onChange={(e) => setSetupCommand(e.target.value)}
/>
</div>
<Button
type='button'
size='sm'
disabled={saving}
onClick={() => {
setSaving(true);
void onSave({
dotfilesRepoUrl: repoUrl,
dotfilesRepoRef: repoRef,
setupCommand,
}).finally(() => setSaving(false));
}}
>
{saving ? 'Saving…' : 'Save repo settings'}
</Button>
</Card>
);
};