Newer
Older
cortex-hub / ui / client-app / src / components / FileSystemNavigator.js
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;