Files
maestro/ui/src/components/userfolder/UserFolderTab.tsx
T
oss-sync d061ad08d8
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

461 lines
19 KiB
TypeScript

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.<key>, 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<FileEntry[]> {
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<FileEntry[]> {
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<string> {
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<void> {
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<void> {
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<SubdirId>(['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<SubdirId>(['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<SubdirId | null>('browser-macros');
const [selectedFile, setSelectedFile] = useState<string | null>(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/<folder>/<file>.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<FileEntry[]>({
queryKey: ['userfolder', 'list', 'notes'],
queryFn: () => apiNotesList(),
staleTime: 10_000,
});
const subdirFilesMap: Partial<Record<SubdirId, { subdir: SubdirId; files: FileEntry[]; loading: boolean }>> = 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<string>({
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 (
<div className="flex h-full gap-2 p-2 overflow-hidden">
{/* Left: file tree */}
<div
className="bg-canvas border border-hairline rounded-md overflow-hidden flex flex-col"
style={{ width: 'clamp(200px, 22vw, 280px)', flexShrink: 0 }}
>
<div className="flex-shrink-0 px-3 py-2.5 border-b border-hairline">
<span className="text-2xs font-semibold text-slate-500 uppercase tracking-wide">
User Folder
</span>
</div>
<div className="flex-1 min-h-0 overflow-y-auto">
<FileTree
subdirData={subdirQueries}
selectedSubdir={selectedSubdir}
selectedFile={selectedFile}
onSelectSubdir={handleSelectSubdir}
onSelectFile={handleSelectFile}
onDeleteFile={handleDelete}
/>
</div>
</div>
{/* Right: editor / virtual panel */}
<div className="flex-1 min-w-0 bg-canvas border border-hairline rounded-md overflow-hidden flex flex-col">
{/* agents-md virtual pane */}
{isVirtualSelected && selectedSubdir === 'agents-md' && (
<AgentsMdPanel onDirtyChange={setEditorDirty} />
)}
{/* browser-sessions virtual pane */}
{isVirtualSelected && selectedSubdir === 'browser-sessions' && (
<BrowserSessionsPanel />
)}
{/* mcp virtual pane */}
{isVirtualSelected && selectedSubdir === 'mcp' && (
<McpPanel showToast={showToast} />
)}
{/* skills virtual pane */}
{isVirtualSelected && selectedSubdir === 'skills' && (
<SkillsPanel />
)}
{/* memory virtual pane */}
{isVirtualSelected && selectedSubdir === 'memory' && (
<MemoryPanel />
)}
{/* pets virtual pane */}
{isVirtualSelected && selectedSubdir === 'pets' && (
<PetsPanel showToast={showToast} />
)}
{/* ssh-connections virtual pane */}
{isVirtualSelected && selectedSubdir === 'ssh-connections' && (
<SshConnectionsPanel showToast={showToast} />
)}
{/* subscribed-notes virtual pane */}
{isVirtualSelected && selectedSubdir === 'subscribed-notes' && (
<SubscriptionsPanel currentUserId={currentUserId} />
)}
{/* notes/ pane — uses discover API for listing (depth 2) + NotesPanel editor */}
{selectedSubdir === 'notes' && (
<NotesPanel
filePath={selectedFile}
onSaved={() => {
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') && (
<div className="flex-shrink-0 flex items-center gap-2 px-4 py-2 border-b border-hairline bg-surface-2/50">
<span className="text-2xs text-slate-500 flex-1">
Recording: <span className="font-mono">{selectedFile}</span>
</span>
<button
type="button"
onClick={() => setSaveAsDialogOpen(true)}
className="px-3 py-1 rounded-md text-2xs font-semibold bg-accent text-accent-fg hover:bg-accent-deep transition-colors"
>
Save as Script
</button>
</div>
)}
<div className="flex-1 min-h-0 overflow-hidden">
{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') ? (
<ScriptDiffReview
scriptName={selectedFile.slice(0, -'.next.js'.length)}
showToast={showToast}
onClose={(acceptedScript) => {
if (acceptedScript) {
setSelectedFile(acceptedScript);
} else {
setSelectedFile(null);
}
}}
/>
) : fileQuery.isLoading ? (
<div className="h-full flex items-center justify-center text-[13px] text-slate-400">
Loading
</div>
) : fileQuery.isError ? (
<div className="h-full flex items-center justify-center text-[13px] text-red-500">
Failed to load file.
</div>
) : (
<MonacoFileEditor
subdir={selectedSubdir}
filename={selectedFile}
content={fileQuery.data ?? ''}
mtime={selectedFileMeta?.mtime ?? ''}
size={selectedFileMeta?.size ?? 0}
onSave={handleSave}
onDirtyChange={setEditorDirty}
/>
)
) : (
<div className="h-full overflow-y-auto">
<div className="max-w-2xl mx-auto px-6 py-8">
{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 (
<>
<div className="mb-6 flex gap-3">
<span className="text-2xl leading-none mt-0.5 select-none" aria-hidden>
{info.icon}
</span>
<div className="flex-1 min-w-0">
<h2 className="text-base font-semibold text-slate-900">{t(`root.subdirs.${info.key}.title`)}</h2>
<p className="text-[13px] text-slate-500 mt-1 leading-relaxed">{t(`root.subdirs.${info.key}.desc`)}</p>
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{t(`root.subdirs.${info.key}.agency`)}</p>
</div>
</div>
{files.length > 0 && (
<div className="mb-4 text-xs text-slate-500">
{t('root.fileCount', { count: files.length })}
</div>
)}
<NewFileForm
subdir={selectedSubdir as 'browser-macros'}
existingFilenames={files.map(f => 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) */
<>
<div className="mb-6">
<h2 className="text-base font-semibold text-slate-900 mb-1">{t('root.title')}</h2>
<p className="text-[13px] text-slate-500 leading-relaxed">
{t('root.intro')}
</p>
</div>
<ul className="space-y-5">
{SUBDIR_INFO.map(({ id, icon, key }) => (
<li key={id} className="flex gap-3">
<span className="text-xl leading-none mt-0.5 select-none" aria-hidden>
{icon}
</span>
<div className="flex-1 min-w-0">
<div className="text-[13px] font-semibold text-slate-900">{t(`root.subdirs.${key}.title`)}</div>
<p className="text-[13px] text-slate-600 mt-1 leading-relaxed">{t(`root.subdirs.${key}.desc`)}</p>
<p className="text-2xs text-slate-400 mt-1 uppercase tracking-wide">{t(`root.subdirs.${key}.agency`)}</p>
</div>
</li>
))}
</ul>
</>
)}
</div>
</div>
)}
</div>
</>
)}
</div>
{/* Save as Script dialog — navigates to browser-macros on success */}
{saveAsDialogOpen && selectedFile?.endsWith('.json') && (
<SaveAsScriptDialog
recordingName={selectedFile.endsWith('.json') ? selectedFile.slice(0, -5) : selectedFile}
onClose={() => setSaveAsDialogOpen(false)}
onSuccess={(scriptName) => {
setSaveAsDialogOpen(false);
// Navigate to the new macro in browser-macros/
setSelectedSubdir('browser-macros');
setSelectedFile(scriptName);
}}
/>
)}
</div>
);
}