import React, { useState, useEffect, useCallback } from 'react';
import { nodeFsList, nodeFsCat, nodeFsTouch, nodeFsRm } from '../services/apiService';
/**
* A modular File Navigator component similar to VS Code's side panel.
* Displays a tree structure from an Agent Node's filesystem.
*/
const FileSystemNavigator = ({ nodeId, initialPath = "." }) => {
const [tree, setTree] = useState([]);
const [loading, setLoading] = useState(false);
const [expanded, setExpanded] = useState({}); // { [path]: boolean }
const [error, setError] = useState(null);
const [selectedFile, setSelectedFile] = useState(null); // { path, content }
const [isEditing, setIsEditing] = useState(false);
const fetchLevel = useCallback(async (path) => {
try {
const data = await nodeFsList(nodeId, path);
return data.files || [];
} catch (err) {
console.error(`Failed to fetch FS level ${path}:`, err);
return [];
}
}, [nodeId]);
const loadRoot = useCallback(async () => {
setLoading(true);
setError(null);
try {
const files = await fetchLevel(initialPath);
setTree(files);
} catch (err) {
setError("Failed to connect to node filesystem.");
} finally {
setLoading(false);
}
}, [initialPath, fetchLevel]);
useEffect(() => {
if (nodeId) loadRoot();
}, [nodeId, loadRoot]);
const toggleFolder = async (path) => {
const isExpanded = expanded[path];
if (!isExpanded) {
// Loading child depth...
const children = await fetchLevel(path);
setExpanded(prev => ({ ...prev, [path]: true }));
// We'll update the tree to include these children
// For a flat list manifest, we might need a more sophisticated tree mapper.
// But if the server returns EVERYTHING for the root, we don't need this.
// Currently, ls returns all files in the root_path.
} else {
setExpanded(prev => ({ ...prev, [path]: false }));
}
};
const handleCreate = async (parentPath, isDir) => {
const name = prompt(`Enter ${isDir ? 'folder' : 'file'} name:`);
if (!name) return;
const fullPath = parentPath === "." ? name : `${parentPath}/${name}`;
try {
await nodeFsTouch(nodeId, fullPath, "", isDir);
loadRoot(); // Refresh
} catch (err) {
alert(`Failed to create: ${err.message}`);
}
};
const handleDelete = async (path) => {
if (!window.confirm(`Delete ${path}?`)) return;
try {
await nodeFsRm(nodeId, path);
loadRoot(); // Refresh
} catch (err) {
alert(`Failed to delete: ${err.message}`);
}
};
const handleView = async (path) => {
try {
const res = await nodeFsCat(nodeId, path);
setSelectedFile({ path, content: res.content });
setIsEditing(false);
} catch (err) {
alert(`Failed to read file: ${err.message}`);
}
};
const handleSave = async () => {
try {
await nodeFsTouch(nodeId, selectedFile.path, selectedFile.content, false);
alert("File saved!");
setSelectedFile(null);
} catch (err) {
alert(`Failed to save: ${err.message}`);
}
};
// Helper to render tree recursively
const renderNode = (nodes, depth = 0) => {
// Sort: Folders first, then Alphabetical
const sorted = [...nodes].sort((a, b) => {
if (a.is_dir !== b.is_dir) return b.is_dir ? 1 : -1;
return a.name.localeCompare(b.name);
});
// Filter for hierarchical view (if needed)
// Since we currently get a flat list from ls for the specific root,
// we can just render them directly if we are browsing one level at a time.
return sorted.map(node => (
<div key={node.path} style={{ paddingLeft: `${depth * 12}px` }} className="group">
<div className="flex items-center py-1 px-2 hover:bg-gray-100 dark:hover:bg-gray-700/50 rounded cursor-pointer transition-colors text-xs">
<span
className="mr-2 text-gray-400"
onClick={() => node.is_dir ? toggleFolder(node.path) : handleView(node.path)}
>
{node.is_dir ? (
<svg className={`w-3.5 h-3.5 transform transition-transform ${expanded[node.path] ? 'rotate-90' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z" /></svg>
)}
</span>
<span
className={`flex-1 truncate ${node.is_dir ? 'font-bold text-gray-700 dark:text-gray-300' : 'text-gray-600 dark:text-gray-400'}`}
onClick={() => node.is_dir ? toggleFolder(node.path) : handleView(node.path)}
>
{node.name}
</span>
<div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity">
{node.is_dir && (
<button onClick={() => handleCreate(node.path, false)} className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-400 hover:text-indigo-500" title="New File">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" /></svg>
</button>
)}
<button onClick={() => handleDelete(node.path)} className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-400 hover:text-red-500" title="Delete">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
</div>
{/* Hierarchical expansion would go here if we fetch deeper levels */}
</div>
));
};
return (
<div className="flex flex-col h-full bg-white dark:bg-gray-800 rounded-xl border dark:border-gray-700 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-3 border-b dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/20">
<h3 className="text-[10px] font-black uppercase tracking-widest text-gray-500 dark:text-gray-400 flex items-center">
<svg className="w-3 h-3 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
File Explorer
</h3>
<div className="flex space-x-2">
<button onClick={() => handleCreate(".", false)} className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" title="New File">
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
</button>
<button onClick={() => handleCreate(".", true)} className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" title="New Folder">
<svg className="w-3.5 h-3.5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 13h6m-3-3v6m-9 1V7a2 2 0 012-2h6l2 2h6a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2z" /></svg>
</button>
<button onClick={loadRoot} className="p-1 hover:bg-gray-200 dark:hover:bg-gray-700 rounded transition-colors" title="Refresh">
<svg className={`w-3.5 h-3.5 text-gray-500 ${loading ? 'animate-spin' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" /></svg>
</button>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto p-2 scrollbar-thin scrollbar-thumb-gray-300 dark:scrollbar-thumb-gray-600">
{error && <div className="p-4 text-xs text-red-500 bg-red-50 dark:bg-red-900/20 rounded-lg">{error}</div>}
{!loading && tree.length === 0 && !error && (
<div className="flex flex-col items-center justify-center h-48 text-gray-400">
<svg className="w-8 h-8 mb-2 opacity-20" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" /></svg>
<p className="text-[10px]">No files found or node offline</p>
</div>
)}
{renderNode(tree)}
</div>
{/* Editor Modal */}
{selectedFile && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm">
<div className="bg-white dark:bg-gray-900 rounded-2xl w-full max-w-4xl max-h-[80vh] flex flex-col shadow-2xl overflow-hidden border dark:border-gray-700">
<div className="p-4 border-b dark:border-gray-800 flex justify-between items-center bg-gray-50 dark:bg-gray-800/50">
<div>
<h4 className="text-sm font-bold text-gray-900 dark:text-white truncate max-w-md">{selectedFile.path}</h4>
<p className="text-[10px] text-gray-500 uppercase font-mono mt-0.5">{isEditing ? 'Editing Mode' : 'Read-Only View'}</p>
</div>
<div className="flex space-x-2">
{!isEditing ? (
<button onClick={() => setIsEditing(true)} className="px-3 py-1.5 bg-indigo-600 text-white rounded-lg text-xs font-bold hover:bg-indigo-700 transition-colors">
Edit Content
</button>
) : (
<button onClick={handleSave} className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-xs font-bold hover:bg-green-700 transition-colors">
Save Changes
</button>
)}
<button onClick={() => setSelectedFile(null)} className="p-1.5 hover:bg-gray-200 dark:hover:bg-gray-700 rounded-lg transition-colors">
<svg className="w-5 h-5 text-gray-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
</div>
<div className="flex-1 p-0 overflow-hidden">
<textarea
className="w-full h-full p-6 text-sm font-mono bg-white dark:bg-gray-950 text-gray-800 dark:text-gray-200 resize-none focus:outline-none"
value={selectedFile.content}
readOnly={!isEditing}
onChange={(e) => setSelectedFile({ ...selectedFile, content: e.target.value })}
placeholder="Empty file..."
/>
</div>
</div>
</div>
)}
</div>
);
};
export default FileSystemNavigator;