Spaces:
Running
Running
| 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 & 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 & 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> | |
| ); | |
| } | |