incognitolm
no more apple products
0b487f4
Raw
History Blame Contribute Delete
19.1 kB
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
import { useEditorStore } from '../../store/editorStore';
import { useCollaborationStore } from '../../store/collaborationStore';
import { ProjectFile } from '../../types/blocks';
import { FrameworkType } from '../../types';
import {
File, Folder, FolderOpen, FileCode, FileJson, FileType,
Trash2, Edit3, ChevronRight, ChevronDown, FilePlus, FolderPlus,
Link, Unlink, Link2, Globe, X, Copy, PanelRight, Play
} from 'lucide-react';
import { v4 as uuidv4 } from 'uuid';
import { getColor } from '../Collaboration/CollaboratorAvatars';
import { getSocket } from '../../hooks/useWebSocket';
interface FileTreeProps {
files: ProjectFile[];
activeFileId: string | null;
onFileSelect: (id: string) => void;
framework: FrameworkType;
expanded?: boolean;
}
const FILE_ICONS: Record<string, any> = {
html: Globe, css: FileType, js: FileCode, ts: FileCode,
typescript: FileCode, json: File, xaml: FileType,
csharp: FileCode, env: FileType, jsx: FileCode, tsx: FileCode,
};
function getFileIcon(type: string) { return FILE_ICONS[type] || File; }
function flattenFiles(files: ProjectFile[]): ProjectFile[] {
const result: ProjectFile[] = [];
for (const f of files) { result.push(f); if (f.children) result.push(...flattenFiles(f.children)); }
return result;
}
function findFile(files: ProjectFile[], id: string): ProjectFile | null {
for (const f of files) {
if (f.id === id) return f;
if (f.children) { const found = findFile(f.children, id); if (found) return found; }
}
return null;
}
// ===== LINKED FILES MODAL =====
function ManageLinksModal({ file, allFiles, onClose, onToggleLink }: {
file: ProjectFile; allFiles: ProjectFile[];
onClose: () => void; onToggleLink: (fileId: string, linkedId: string) => void;
}) {
const flat = flattenFiles(allFiles).filter(f => f.type !== 'folder' && f.id !== file.id);
const linkedSet = new Set(file.linkedFiles || []);
const linked = flat.filter(f => linkedSet.has(f.id));
const unlinked = flat.filter(f => !linkedSet.has(f.id) && (f.type === 'css' || f.type === 'js'));
const [tab, setTab] = useState<'linked' | 'add'>('linked');
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-surface-900 border border-surface-700 rounded-xl shadow-2xl p-5 w-full max-w-lg max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-semibold text-surface-200">Linked Scripts &amp; Styles</h3>
<p className="text-[11px] text-surface-400 mt-0.5">
Manage files linked to <span className="text-primary-400">{file.name}</span>
</p>
</div>
<button onClick={onClose} className="p-1 text-surface-400 hover:text-white"><X className="w-4 h-4" /></button>
</div>
<div className="flex gap-1 bg-surface-800 rounded-lg p-0.5 mb-3">
<button onClick={() => setTab('linked')}
className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${tab === 'linked' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}>
Linked ({linked.length})
</button>
<button onClick={() => setTab('add')}
className={`flex-1 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${tab === 'add' ? 'bg-primary-600 text-white' : 'text-surface-400'}`}>
Add Files ({unlinked.length})
</button>
</div>
<div className="flex-1 overflow-y-auto min-h-[200px]">
{tab === 'linked' ? (
linked.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-surface-500">
<Link2 className="w-8 h-8 mb-2 opacity-30" />
<p className="text-xs">No linked files</p>
<p className="text-[10px] mt-1">Switch to "Add Files" to link CSS/JS</p>
</div>
) : (
<div className="space-y-1">
{linked.map(lf => (
<div key={lf.id} className="flex items-center gap-2 px-3 py-2 bg-surface-800/50 rounded-lg border border-surface-700/50">
{lf.type === 'css'
? <FileType className="w-4 h-4 text-amber-400" />
: <FileCode className="w-4 h-4 text-emerald-400" />}
<span className="flex-1 text-xs text-surface-300">{lf.name}</span>
<span className="text-[10px] text-surface-500 px-1.5 py-0.5 bg-surface-700 rounded">{lf.type.toUpperCase()}</span>
<button onClick={() => onToggleLink(file.id, lf.id)}
className="p-1 text-red-400 hover:text-red-300 hover:bg-surface-700 rounded" title="Unlink">
<Unlink className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)
) : (
unlinked.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-surface-500">
<Link2 className="w-8 h-8 mb-2 opacity-30" />
<p className="text-xs">No files available to link</p>
<p className="text-[10px] mt-1">Create CSS or JS files first</p>
</div>
) : (
<div className="space-y-1">
{unlinked.map(uf => (
<div key={uf.id}
className="flex items-center gap-2 px-3 py-2 bg-surface-800/50 rounded-lg border border-surface-700/50 hover:bg-surface-800 transition-colors cursor-pointer"
onClick={() => onToggleLink(file.id, uf.id)}>
{uf.type === 'css'
? <FileType className="w-4 h-4 text-amber-400" />
: <FileCode className="w-4 h-4 text-emerald-400" />}
<span className="flex-1 text-xs text-surface-300">{uf.name}</span>
<span className="text-[10px] text-surface-500 px-1.5 py-0.5 bg-surface-700 rounded">{uf.type.toUpperCase()}</span>
<Link className="w-3.5 h-3.5 text-primary-400" />
</div>
))}
</div>
)
)}
</div>
</div>
</div>
);
}
// ===== LINKED FILE ROW =====
function LinkedFileRow({ file, activeFileId, onSelect, type }: { file: ProjectFile; activeFileId: string | null; onSelect: (id: string) => void; type: string }) {
return (
<div
className={`flex items-center gap-1.5 py-0.5 px-2 rounded-md cursor-pointer group text-xs transition-colors ${
activeFileId === file.id ? 'bg-primary-500/20 text-primary-300' : 'text-surface-400 hover:text-surface-200 hover:bg-surface-800/50'
}`}
style={{ paddingLeft: '20px' }}
onClick={() => onSelect(file.id)}
>
{type === 'css'
? <FileType className="w-3 h-3 text-amber-400" />
: <FileCode className="w-3 h-3 text-emerald-400" />}
<span className="truncate text-[11px]">{file.name}</span>
<span className="ml-auto text-[9px] px-1 py-0.5 rounded bg-surface-700/50 text-surface-500">{type === 'css' ? 'CSS' : 'JS'}</span>
</div>
);
}
// ===== FILE ITEM =====
interface FileItemProps {
file: ProjectFile;
depth: number;
activeFileId: string | null;
onSelect: (id: string) => void;
onRename: (id: string, name: string) => void;
onDelete: (id: string) => void;
onAddChild: (parentId: string, isFolder: boolean) => void;
allFiles: ProjectFile[];
onOpenLinksModal: (file: ProjectFile) => void;
}
function FileItem({ file, depth, activeFileId, onSelect, onRename, onDelete, onAddChild, allFiles, onOpenLinksModal }: FileItemProps) {
const [expanded, setExpanded] = useState(true);
const [editing, setEditing] = useState(false);
const [editName, setEditName] = useState(file.name);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null);
const cmRef = useRef<HTMLDivElement>(null);
const Icon = file.type === 'folder' ? (expanded ? FolderOpen : Folder) : getFileIcon(file.type);
const isActive = activeFileId === file.id;
const isFile = file.type !== 'folder';
const linkedIds = file.linkedFiles || [];
const collaborators = useCollaborationStore((s) => s.collaborators);
const connected = useCollaborationStore((s) => s.connected);
const collabOnFile = useMemo(() => collaborators.filter((c) => c.activeFileId === file.id), [collaborators, file.id]);
// Get linked files
const linked = linkedIds.map(id => findFile(allFiles, id)).filter(Boolean) as ProjectFile[];
const css = linked.filter(f => f.type === 'css');
const js = linked.filter(f => f.type === 'js');
const hasLinks = css.length + js.length > 0;
useEffect(() => {
const handler = (e: MouseEvent) => {
if (cmRef.current && !cmRef.current.contains(e.target as Node)) setContextMenu(null);
};
if (contextMenu) document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [contextMenu]);
const handleRename = () => {
if (editName.trim() && editName !== file.name) onRename(file.id, editName.trim());
setEditing(false);
};
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
setContextMenu({ x: e.clientX, y: e.clientY });
};
const isHtmlFile = file.type === 'html';
const isJsOrCss = file.type === 'js' || file.type === 'css';
return (
<div>
<div
className={`flex items-center gap-1 py-1 px-2 rounded-md cursor-pointer group text-sm transition-colors ${
isActive ? 'bg-primary-500/20 text-primary-300' : 'text-surface-300 hover:bg-surface-800 hover:text-white'
}`}
style={{ paddingLeft: `${8 + depth * 16}px` }}
onClick={() => { if (file.type === 'folder') setExpanded(!expanded); else onSelect(file.id); }}
onContextMenu={handleContextMenu}
>
{file.type === 'folder' && (
<span className="text-surface-400">
{expanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
</span>
)}
<Icon className={`w-4 h-4 flex-shrink-0 ${file.type === 'folder' ? 'text-amber-400' : hasLinks ? 'text-primary-400' : 'text-surface-400'}`} />
{editing ? (
<input className="flex-1 bg-surface-700 text-white px-1 py-0.5 rounded text-xs outline-none"
value={editName} onChange={(e) => setEditName(e.target.value)}
onBlur={handleRename}
onKeyDown={(e) => { if (e.key === 'Enter') handleRename(); if (e.key === 'Escape') setEditing(false); }}
onClick={(e) => e.stopPropagation()} autoFocus />
) : (
<span className="truncate text-xs">{file.name}</span>
)}
{hasLinks && isFile && (
<span className="text-[9px] text-primary-400/60 bg-primary-500/10 px-1 rounded-full flex-shrink-0">{linkedIds.length}</span>
)}
{connected && collabOnFile.length > 0 && (
<div className="flex items-center -space-x-1 ml-auto">
{collabOnFile.slice(0, 3).map((c) => (
<div
key={c.userId}
className="w-4 h-4 rounded-full flex items-center justify-center text-[7px] font-bold text-white ring-1 ring-surface-900"
style={{ backgroundColor: getColor(c.userId) }}
title={`${c.username} (${c.permission})`}
>
{c.username.charAt(0).toUpperCase()}
</div>
))}
</div>
)}
<div className="ml-auto flex items-center gap-0.5 opacity-0 group-hover:opacity-100">
{file.type === 'folder' && (
<>
<button onClick={(e) => { e.stopPropagation(); onAddChild(file.id, false); }} className="p-0.5 hover:text-white" title="Add file"><FilePlus className="w-3 h-3" /></button>
<button onClick={(e) => { e.stopPropagation(); onAddChild(file.id, true); }} className="p-0.5 hover:text-white" title="Add folder"><FolderPlus className="w-3 h-3" /></button>
</>
)}
{isFile && (
<button onClick={(e) => { e.stopPropagation(); onOpenLinksModal(file); }} className="p-0.5 hover:text-white" title="Manage linked files">
<Link2 className="w-3 h-3" />
</button>
)}
<button onClick={(e) => { e.stopPropagation(); setEditing(true); setEditName(file.name); }} className="p-0.5 hover:text-white"><Edit3 className="w-3 h-3" /></button>
<button onClick={(e) => { e.stopPropagation(); onDelete(file.id); }} className="p-0.5 hover:text-red-400"><Trash2 className="w-3 h-3" /></button>
</div>
</div>
{/* Linked files shown as nested indented children */}
{hasLinks && isFile && (
<div className="border-l border-primary-500/20 ml-4 mt-0.5 mb-0.5">
{css.map(lf => (
<LinkedFileRow key={lf.id} file={lf} activeFileId={activeFileId} onSelect={onSelect} type="css" />
))}
{js.map(lf => (
<LinkedFileRow key={lf.id} file={lf} activeFileId={activeFileId} onSelect={onSelect} type="js" />
))}
</div>
)}
{/* Folder children */}
{file.type === 'folder' && expanded && file.children && (
<div>
{file.children.map((child: ProjectFile) => (
<FileItem key={child.id} file={child} depth={depth + 1} activeFileId={activeFileId}
onSelect={onSelect} onRename={onRename} onDelete={onDelete}
onAddChild={onAddChild} allFiles={allFiles} onOpenLinksModal={onOpenLinksModal} />
))}
</div>
)}
{/* Context Menu */}
{contextMenu && (
<div ref={cmRef}
className="fixed z-50 bg-surface-900 border border-surface-700 rounded-lg shadow-xl py-1 min-w-[200px]"
style={{ left: contextMenu.x, top: contextMenu.y }}>
<button onClick={() => { onSelect(file.id); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700">
<PanelRight className="w-3.5 h-3.5" /> Open
</button>
{isHtmlFile && (
<>
<div className="h-px bg-surface-700 my-1" />
<button onClick={() => { onOpenLinksModal(file); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700">
<Link2 className="w-3.5 h-3.5" /> Manage Scripts &amp; Styles
</button>
</>
)}
{isJsOrCss && (
<>
<div className="h-px bg-surface-700 my-1" />
<button onClick={() => { onOpenLinksModal(file); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700">
<Link2 className="w-3.5 h-3.5" /> Manage Linked Files
</button>
</>
)}
<div className="h-px bg-surface-700 my-1" />
<button onClick={() => { setEditing(true); setEditName(file.name); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-surface-300 hover:bg-surface-700">
<Edit3 className="w-3.5 h-3.5" /> Rename
</button>
<button onClick={() => { onDelete(file.id); setContextMenu(null); }} className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 hover:bg-surface-700">
<Trash2 className="w-3.5 h-3.5" /> Delete
</button>
</div>
)}
</div>
);
}
// ===== MAIN COMPONENT =====
export default function FileTreePanel({ files, activeFileId, onFileSelect, framework }: FileTreeProps) {
const { addFile, removeFile, updateFile } = useEditorStore();
const [linksModalFile, setLinksModalFile] = useState<ProjectFile | null>(null);
const wsEmit = useCallback((event: string, data: any) => {
const socket = getSocket();
if (socket?.connected) {
socket.emit(event, data);
}
}, []);
const handleAddFile = useCallback((parentId?: string) => {
const file: ProjectFile = {
id: uuidv4(),
name: `new-file.${framework === 'maui' ? 'cs' : 'js'}`,
type: framework === 'maui' ? 'csharp' : 'js',
content: '',
};
addFile(file, parentId);
wsEmit('file_added', { file, parentId });
}, [addFile, framework, wsEmit]);
const handleAddFolder = useCallback((parentId?: string) => {
const folder: ProjectFile = { id: uuidv4(), name: 'new-folder', type: 'folder', children: [] };
addFile(folder, parentId);
wsEmit('file_added', { file: folder, parentId });
}, [addFile, wsEmit]);
const handleRename = useCallback((id: string, name: string) => {
updateFile(id, { name });
wsEmit('file_renamed', { fileId: id, name });
}, [updateFile, wsEmit]);
const handleDelete = useCallback((id: string) => {
removeFile(id);
wsEmit('file_deleted', { fileId: id });
}, [removeFile, wsEmit]);
const handleToggleLink = useCallback((fileId: string, linkedId: string) => {
const file = findFile(files, fileId);
if (!file) return;
const currentLinks = file.linkedFiles || [];
const newLinks = currentLinks.includes(linkedId)
? currentLinks.filter(id => id !== linkedId)
: [...currentLinks, linkedId];
updateFile(fileId, { linkedFiles: newLinks });
setLinksModalFile(prev => prev ? { ...prev, linkedFiles: newLinks } : null);
}, [files, updateFile]);
return (
<div className="p-3">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold text-surface-400 uppercase tracking-wider">Files</h3>
<div className="flex items-center gap-1">
<button onClick={() => handleAddFile()} className="btn-ghost p-1" title="New file"><FilePlus className="w-3.5 h-3.5" /></button>
<button onClick={() => handleAddFolder()} className="btn-ghost p-1" title="New folder"><FolderPlus className="w-3.5 h-3.5" /></button>
</div>
</div>
<div className="space-y-0.5">
{files.map((file) => (
<FileItem key={file.id} file={file} depth={0} activeFileId={activeFileId}
onSelect={onFileSelect} onRename={handleRename} onDelete={removeFile}
onAddChild={(parentId, isFolder) => { if (isFolder) handleAddFolder(parentId); else handleAddFile(parentId); }}
allFiles={files} onOpenLinksModal={setLinksModalFile} />
))}
</div>
{linksModalFile && (
<ManageLinksModal file={linksModalFile} allFiles={files}
onClose={() => setLinksModalFile(null)}
onToggleLink={handleToggleLink} />
)}
</div>
);
}