import { useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useQueries, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { FileTree, type SubdirId, type FileEntry, FILE_SUBDIRS } from './FileTree'; import { MonacoFileEditor } from './MonacoFileEditor'; import { SaveAsScriptDialog } from './SaveAsScriptDialog'; import { ScriptDiffReview } from './ScriptDiffReview'; import { BrowserSessionsPanel } from './BrowserSessionsPanel'; import { McpPanel } from './McpPanel'; import { AgentsMdPanel } from './AgentsMdPanel'; import { NewFileForm } from './NewFileForm'; import { PetsPanel } from './PetsPanel'; import { SshConnectionsPanel } from './SshConnectionsPanel'; import { NotesPanel } from './NotesPanel'; import { SubscriptionsPanel } from './SubscriptionsPanel'; import { SkillsPanel } from './SkillsPanel'; import { MemoryPanel } from './MemoryPanel'; /** All subdirs shown in the tree β€” both real file-based and virtual. */ const ALL_SUBDIRS: SubdirId[] = ['agents-md', 'browser-macros', 'recordings', 'notes', 'subscribed-notes', 'pets', 'browser-sessions', 'mcp', 'skills', 'ssh-connections', 'trash', 'memory']; // title/desc/agency are i18n keys under root.subdirs., translated at render. const SUBDIR_INFO: { id: SubdirId; icon: string; key: string }[] = [ { id: 'agents-md', icon: 'πŸ“–', key: 'agentsMd' }, { id: 'browser-macros', icon: 'πŸ€–', key: 'browserMacros' }, { id: 'recordings', icon: '🎬', key: 'recordings' }, { id: 'pets', icon: 'β—‰', key: 'pets' }, { id: 'browser-sessions', icon: '🌐', key: 'browserSessions' }, { id: 'trash', icon: 'πŸ—‘', key: 'trash' }, { id: 'memory', icon: '🧠', key: 'memory' }, { id: 'mcp', icon: 'πŸ”Œ', key: 'mcp' }, { id: 'skills', icon: 'πŸ“š', key: 'skills' }, { id: 'ssh-connections', icon: 'πŸ”', key: 'sshConnections' }, { id: 'notes', icon: 'πŸ“', key: 'notes' }, { id: 'subscribed-notes', icon: 'πŸ””', key: 'subscribedNotes' }, ]; interface FolderListResponse { files: FileEntry[]; } async function apiFolderList(subdir: SubdirId): Promise { const res = await fetch(`/api/users/me/folder/list?subdir=${subdir}`, { credentials: 'include', }); if (!res.ok) throw new Error(`List failed: ${res.status}`); const data: FolderListResponse = await res.json(); return data.files ?? []; } interface NoteDiscoverRow { folder: string; file_name: string; updated_at: number; content_size: number; } /** Fetch all own notes via the discover API (unlimited depth, returns folder/file pairs). */ async function apiNotesList(): Promise { const res = await fetch('/api/notes/discover?owner_id=me&limit=200', { credentials: 'include', }); if (!res.ok) throw new Error(`Notes list failed: ${res.status}`); const data: { rows: NoteDiscoverRow[] } = await res.json(); return (data.rows ?? []).map((r) => ({ // Use "folder/file.md" as the virtual file name so FileTree shows the full path name: `${r.folder}/${r.file_name}`, size: r.content_size, mtime: new Date(r.updated_at).toISOString(), })); } async function apiFolderGet(subdir: SubdirId, path: string): Promise { const res = await fetch( `/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`, { credentials: 'include' }, ); if (!res.ok) throw new Error(`Fetch failed: ${res.status}`); return res.text(); } async function apiFolderPut(subdir: SubdirId, path: string, body: string): Promise { const res = await fetch( `/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`, { method: 'PUT', credentials: 'include', headers: { 'Content-Type': 'text/plain; charset=utf-8' }, body, }, ); if (!res.ok) throw new Error(`Save failed: ${res.status}`); } async function apiFolderDelete(subdir: SubdirId, path: string): Promise { const res = await fetch( `/api/users/me/folder/file?subdir=${subdir}&path=${encodeURIComponent(path)}`, { method: 'DELETE', credentials: 'include' }, ); if (!res.ok) throw new Error(`Delete failed: ${res.status}`); } /** Virtual subdirs don't have real files on disk */ const VIRTUAL_SUBDIRS = new Set(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes', 'memory']); /** Subdirs where users can create new files from the UI */ const WRITABLE_USER_SUBDIRS = new Set(['browser-macros']); type ShowToast = (message: string, variant?: 'success' | 'error') => void; interface UserFolderTabProps { showToast?: ShowToast; } export function UserFolderTab({ showToast }: UserFolderTabProps = {}) { const { t } = useTranslation('userfolder'); const [selectedSubdir, setSelectedSubdir] = useState('browser-macros'); const [selectedFile, setSelectedFile] = useState(null); const [editorDirty, setEditorDirty] = useState(false); const [saveAsDialogOpen, setSaveAsDialogOpen] = useState(false); const qc = useQueryClient(); // Fetch the current user (needed for SubscriptionsPanel) const meQuery = useQuery<{ id: string }>({ queryKey: ['auth', 'me'], queryFn: async () => { const res = await fetch('/api/auth/me'); if (!res.ok) throw new Error(`${res.status}`); return res.json(); }, staleTime: 60_000, }); const currentUserId = meQuery.data?.id ?? ''; // Only file-based subdirs are fetched; notes uses a separate discover endpoint // because notes live at depth 2 (notes//.md) and folder/list only shows depth 1. const fileSubdirs = FILE_SUBDIRS.filter((s) => s !== 'notes'); const subdirResults = useQueries({ queries: fileSubdirs.map(subdir => ({ queryKey: ['userfolder', 'list', subdir], queryFn: () => apiFolderList(subdir), staleTime: 10_000, })), }); // Separate query for notes that uses the discover API instead of the folder-list API const notesListQuery = useQuery({ queryKey: ['userfolder', 'list', 'notes'], queryFn: () => apiNotesList(), staleTime: 10_000, }); const subdirFilesMap: Partial> = Object.fromEntries( fileSubdirs.map((subdir, i) => [ subdir, { subdir, files: subdirResults[i]!.data ?? [], loading: subdirResults[i]!.isLoading, }, ]) ); // Inject notes separately using the discover-based listing (depth-2 aware) subdirFilesMap['notes'] = { subdir: 'notes', files: notesListQuery.data ?? [], loading: notesListQuery.isLoading, }; // Build the tree data: real subdirs get files, virtual ones get empty placeholders const SUBDIRS = ALL_SUBDIRS; const subdirQueries = SUBDIRS.map(subdir => { if (VIRTUAL_SUBDIRS.has(subdir)) { return { subdir, files: [], loading: false }; } return subdirFilesMap[subdir] ?? { subdir, files: [], loading: false }; }); // File content query β€” only when a file is selected (and not virtual subdir) const fileQuery = useQuery({ queryKey: ['userfolder', 'file', selectedSubdir, selectedFile], queryFn: () => apiFolderGet(selectedSubdir!, selectedFile!), enabled: !!(selectedSubdir && selectedFile && !VIRTUAL_SUBDIRS.has(selectedSubdir)), staleTime: 30_000, refetchOnWindowFocus: false, }); const deleteMutation = useMutation({ mutationFn: ({ subdir, file }: { subdir: SubdirId; file: string }) => apiFolderDelete(subdir, file), onSuccess: (_data, { subdir, file }) => { qc.invalidateQueries({ queryKey: ['userfolder', 'list', subdir] }); if (selectedSubdir === subdir && selectedFile === file) { setSelectedFile(null); } }, onError: (err, { subdir, file }) => { const msg = err instanceof Error ? err.message : 'Unknown error'; const label = t('delete.failed', { path: `${subdir}/${file}` }); if (showToast) showToast(`${label}: ${msg}`, 'error'); else console.error(`${label}: ${msg}`); }, }); const selectedSubdirData = subdirQueries.find(q => q.subdir === selectedSubdir); const selectedFileMeta = selectedSubdirData?.files.find(f => f.name === selectedFile); const handleSave = async (content: string) => { if (!selectedSubdir || !selectedFile) return; await apiFolderPut(selectedSubdir, selectedFile, content); qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] }); qc.setQueryData( ['userfolder', 'file', selectedSubdir, selectedFile], content, ); }; const handleDelete = (subdir: SubdirId, file: string) => { if (!window.confirm(`Delete ${subdir}/${file}?`)) return; deleteMutation.mutate({ subdir, file }); }; function handleSelectSubdir(subdir: SubdirId) { if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return; if (selectedSubdir === subdir) { setSelectedSubdir(null); setSelectedFile(null); } else { setSelectedSubdir(subdir); setSelectedFile(null); } } function handleSelectFile(subdir: SubdirId, file: string) { if (editorDirty && !window.confirm('You have unsaved changes. Discard them?')) return; setSelectedSubdir(subdir); setSelectedFile(file); } // Determine right-pane content const isVirtualSelected = selectedSubdir !== null && VIRTUAL_SUBDIRS.has(selectedSubdir); return (
{/* Left: file tree */}
User Folder
{/* Right: editor / virtual panel */}
{/* agents-md virtual pane */} {isVirtualSelected && selectedSubdir === 'agents-md' && ( )} {/* browser-sessions virtual pane */} {isVirtualSelected && selectedSubdir === 'browser-sessions' && ( )} {/* mcp virtual pane */} {isVirtualSelected && selectedSubdir === 'mcp' && ( )} {/* skills virtual pane */} {isVirtualSelected && selectedSubdir === 'skills' && ( )} {/* memory virtual pane */} {isVirtualSelected && selectedSubdir === 'memory' && ( )} {/* pets virtual pane */} {isVirtualSelected && selectedSubdir === 'pets' && ( )} {/* ssh-connections virtual pane */} {isVirtualSelected && selectedSubdir === 'ssh-connections' && ( )} {/* subscribed-notes virtual pane */} {isVirtualSelected && selectedSubdir === 'subscribed-notes' && ( )} {/* notes/ pane β€” uses discover API for listing (depth 2) + NotesPanel editor */} {selectedSubdir === 'notes' && ( { qc.invalidateQueries({ queryKey: ['userfolder', 'list', 'notes'] }); }} onSelectFile={(path) => { setSelectedFile(path); }} /> )} {/* File-based content (non-notes subdirs) */} {!isVirtualSelected && selectedSubdir !== 'notes' && ( <> {/* Save as Script toolbar β€” shown only in recordings/ when a .json file is selected */} {selectedSubdir === 'recordings' && selectedFile?.endsWith('.json') && (
Recording: {selectedFile}
)}
{selectedSubdir && selectedFile ? ( /* If a .next.js patch file is selected in browser-macros/, show the diff review pane */ selectedSubdir === 'browser-macros' && selectedFile.endsWith('.next.js') ? ( { if (acceptedScript) { setSelectedFile(acceptedScript); } else { setSelectedFile(null); } }} /> ) : fileQuery.isLoading ? (
Loading…
) : fileQuery.isError ? (
Failed to load file.
) : ( ) ) : (
{selectedSubdir && WRITABLE_USER_SUBDIRS.has(selectedSubdir) ? ( /* Focused view for a selected writable subdir: info + new-file form */ (() => { const info = SUBDIR_INFO.find(i => i.id === selectedSubdir); if (!info) return null; const files = selectedSubdirData?.files ?? []; return ( <>
{info.icon}

{t(`root.subdirs.${info.key}.title`)}

{t(`root.subdirs.${info.key}.desc`)}

{t(`root.subdirs.${info.key}.agency`)}

{files.length > 0 && (
{t('root.fileCount', { count: files.length })}
)} f.name)} onCreate={async (filename, skeleton) => { await apiFolderPut(selectedSubdir, filename, skeleton); qc.invalidateQueries({ queryKey: ['userfolder', 'list', selectedSubdir] }); setSelectedFile(filename); }} /> ); })() ) : ( /* Full overview when no subdir is selected (or non-writable subdir selected without a file) */ <>

{t('root.title')}

{t('root.intro')}

    {SUBDIR_INFO.map(({ id, icon, key }) => (
  • {icon}
    {t(`root.subdirs.${key}.title`)}

    {t(`root.subdirs.${key}.desc`)}

    {t(`root.subdirs.${key}.agency`)}

  • ))}
)}
)}
)}
{/* Save as Script dialog β€” navigates to browser-macros on success */} {saveAsDialogOpen && selectedFile?.endsWith('.json') && ( setSaveAsDialogOpen(false)} onSuccess={(scriptName) => { setSaveAsDialogOpen(false); // Navigate to the new macro in browser-macros/ setSelectedSubdir('browser-macros'); setSelectedFile(scriptName); }} /> )}
); }