461 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|