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:
@@ -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;
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user