152 lines
5.7 KiB
TypeScript
152 lines
5.7 KiB
TypeScript
import { useState } from 'react';
|
|
|
|
// 'agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections' are virtual subdirs (not raw file editor directories)
|
|
// ('scripts' / 'templates' were retired 2026-06 — superseded by Skills + the Bash tool)
|
|
export type SubdirId = 'agents-md' | 'browser-macros' | 'recordings' | 'trash' | 'memory' | 'browser-sessions' | 'mcp' | 'skills' | 'pets' | 'ssh-connections' | 'notes' | 'subscribed-notes';
|
|
|
|
/** True for subdirs that have actual files on disk */
|
|
export const FILE_SUBDIRS: SubdirId[] = ['browser-macros', 'recordings', 'trash', 'memory', 'notes'];
|
|
|
|
export interface FileEntry {
|
|
name: string;
|
|
size: number;
|
|
mtime: string;
|
|
}
|
|
|
|
export interface SubdirFiles {
|
|
subdir: SubdirId;
|
|
files: FileEntry[];
|
|
loading: boolean;
|
|
}
|
|
|
|
interface FileTreeProps {
|
|
subdirData: SubdirFiles[];
|
|
selectedSubdir: SubdirId | null;
|
|
selectedFile: string | null;
|
|
onSelectSubdir: (subdir: SubdirId) => void;
|
|
onSelectFile: (subdir: SubdirId, file: string) => void;
|
|
onDeleteFile: (subdir: SubdirId, file: string) => void;
|
|
}
|
|
|
|
const SUBDIR_LABELS: Record<SubdirId, string> = {
|
|
'agents-md': 'AGENTS.md',
|
|
'browser-macros': 'browser-macros',
|
|
recordings: 'recordings',
|
|
trash: 'trash',
|
|
memory: 'memory',
|
|
'browser-sessions': 'browser-sessions',
|
|
mcp: 'MCP',
|
|
skills: 'Skills',
|
|
pets: 'pets',
|
|
'ssh-connections': 'ssh-connections',
|
|
notes: 'Notes (共有)',
|
|
'subscribed-notes': 'Subscribed Notes',
|
|
};
|
|
|
|
const SUBDIR_ICONS: Record<SubdirId, string> = {
|
|
'agents-md': '📖',
|
|
'browser-macros': '🤖',
|
|
recordings: '🎬',
|
|
trash: '🗑',
|
|
memory: '🧠',
|
|
'browser-sessions': '🌐',
|
|
mcp: '🔌',
|
|
skills: '📚',
|
|
pets: '◉',
|
|
'ssh-connections': '🔐',
|
|
notes: '📝',
|
|
'subscribed-notes': '🔔',
|
|
};
|
|
|
|
/** Virtual subdirs that don't show a file list (they render custom panel content instead). */
|
|
const VIRTUAL_SUBDIRS = new Set<SubdirId>(['agents-md', 'browser-sessions', 'mcp', 'skills', 'pets', 'ssh-connections', 'subscribed-notes']);
|
|
|
|
export function FileTree({
|
|
subdirData,
|
|
selectedSubdir,
|
|
selectedFile,
|
|
onSelectSubdir,
|
|
onSelectFile,
|
|
onDeleteFile,
|
|
}: FileTreeProps) {
|
|
const [hoveredFile, setHoveredFile] = useState<string | null>(null);
|
|
|
|
return (
|
|
<div className="flex flex-col h-full overflow-y-auto">
|
|
{subdirData.map(({ subdir, files, loading }) => {
|
|
const isOpen = selectedSubdir === subdir;
|
|
const isVirtual = VIRTUAL_SUBDIRS.has(subdir);
|
|
return (
|
|
<div key={subdir}>
|
|
{/* Subdir header */}
|
|
<button
|
|
type="button"
|
|
onClick={() => onSelectSubdir(subdir)}
|
|
className={`w-full flex items-center gap-2 px-3 py-2 text-xs font-semibold transition-colors hover:bg-surface-2 ${
|
|
isOpen ? 'bg-surface-2 text-slate-900' : 'text-slate-600'
|
|
}`}
|
|
>
|
|
<span className="text-2xs">{isOpen ? '▾' : '▸'}</span>
|
|
<span>{SUBDIR_ICONS[subdir]}</span>
|
|
<span className="flex-1 text-left">{SUBDIR_LABELS[subdir]}{subdir !== 'agents-md' ? '/' : ''}</span>
|
|
{!isVirtual && (
|
|
<span className="text-[10px] font-mono text-slate-400 tabular-nums">
|
|
{loading ? '…' : files.length}
|
|
</span>
|
|
)}
|
|
</button>
|
|
|
|
{/* File list — only for non-virtual subdirs */}
|
|
{isOpen && !isVirtual && (
|
|
<div className="ml-4 border-l border-hairline pl-2 pb-1">
|
|
{loading && (
|
|
<div className="text-2xs text-slate-400 px-2 py-1.5">Loading…</div>
|
|
)}
|
|
{!loading && files.length === 0 && (
|
|
<div className="text-2xs text-slate-400 px-2 py-1.5">Empty</div>
|
|
)}
|
|
{!loading && files.map(file => {
|
|
const fileKey = `${subdir}/${file.name}`;
|
|
const isSelected = selectedSubdir === subdir && selectedFile === file.name;
|
|
return (
|
|
<div
|
|
key={file.name}
|
|
className={`group flex items-center gap-1 px-2 py-1 rounded text-2xs cursor-pointer transition-colors ${
|
|
isSelected
|
|
? 'bg-accent text-accent-fg'
|
|
: 'text-slate-700 hover:bg-surface-2'
|
|
}`}
|
|
onMouseEnter={() => setHoveredFile(fileKey)}
|
|
onMouseLeave={() => setHoveredFile(null)}
|
|
onClick={() => onSelectFile(subdir, file.name)}
|
|
>
|
|
<span className="flex-1 truncate font-mono">{file.name}</span>
|
|
{(hoveredFile === fileKey || isSelected) && (
|
|
<button
|
|
type="button"
|
|
aria-label={`Delete ${file.name}`}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onDeleteFile(subdir, file.name);
|
|
}}
|
|
className={`flex-shrink-0 w-4 h-4 flex items-center justify-center rounded hover:bg-red-100 dark:hover:bg-red-500/15 hover:text-red-600 transition-colors ${
|
|
isSelected ? 'text-accent-fg/70' : 'text-slate-400'
|
|
}`}
|
|
>
|
|
<svg viewBox="0 0 16 16" className="w-2.5 h-2.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
|
<path d="M4 4l8 8M12 4l-8 8" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|