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 = ".",
    sessionId = "__fs_explorer__",
    showSyncStatus = false
}) => {
    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 [newItemModal, setNewItemModal] = useState(null); // { parentPath, isDir }
    const [deleteModal, setDeleteModal] = useState(null); // path
    const [operationLoading, setOperationLoading] = useState(false);
    const [folderLoading, setFolderLoading] = useState({}); // { [path]: boolean }

    const fetchLevel = useCallback(async (path) => {
        const data = await nodeFsList(nodeId, path, sessionId);
        return data.files || [];
    }, [nodeId, sessionId]);

    const loadRoot = useCallback(async () => {
        setLoading(true);
        setError(null);
        try {
            const files = await fetchLevel(initialPath);
            setTree(files);
        } catch (err) {
            setError(err.message || "Failed to connect to node filesystem.");
        } finally {
            setLoading(false);
        }
    }, [initialPath, fetchLevel]);

    useEffect(() => {
        if (nodeId) {
            loadRoot();

            // Poll for updates if we are in a specific session (Coding Assistant) 
            // or if explicitly requested via showSyncStatus
            const shouldPoll = showSyncStatus || (sessionId && sessionId !== "__fs_explorer__");

            if (shouldPoll) {
                const interval = setInterval(() => {
                    loadRoot();
                }, 5000);
                return () => clearInterval(interval);
            }
        }
    }, [nodeId, loadRoot, showSyncStatus, sessionId]);

    const toggleFolder = async (path) => {
        const isExpanded = expanded[path];
        if (!isExpanded) {
            setExpanded(prev => ({ ...prev, [path]: true }));

            // Normalize path for prefix check: e.g. "etc" -> "etc/"
            const target = (path === "/" || path === ".") ? "" : (path.startsWith("/") ? path.slice(1) : path);
            const prefix = target === "" ? "" : (target.endsWith("/") ? target : target + "/");

            const hasChildren = tree.some(node => {
                const nodeRel = node.path.startsWith("/") ? node.path.slice(1) : node.path;
                return nodeRel.startsWith(prefix) && nodeRel !== target;
            });

            if (!hasChildren) {
                setFolderLoading(prev => ({ ...prev, [path]: true }));
                try {
                    // Node expects relative path for subdirs, or "." for root
                    const fetchPath = (path === "/" || path === ".") ? "." : (path.startsWith("/") ? path.slice(1) : path);
                    const children = await fetchLevel(fetchPath);

                    setTree(prev => {
                        const existingPaths = new Set(prev.map(f => f.path));
                        const newOnes = children.filter(c => !existingPaths.has(c.path));
                        return [...prev, ...newOnes];
                    });
                } catch (err) {
                    console.error("Folder expansion failed:", err);
                    setError(`Failed to open folder: ${err.message}`);
                } finally {
                    setFolderLoading(prev => ({ ...prev, [path]: false }));
                }
            }
        } else {
            setExpanded(prev => ({ ...prev, [path]: false }));
        }
    };

    const handleCreateFinal = async (name) => {
        if (!name || !newItemModal) return;
        const { parentPath, isDir } = newItemModal;

        let fullPath;
        if (!parentPath || parentPath === "." || parentPath === "" || parentPath === "/") {
            fullPath = name;
        } else {
            fullPath = `${parentPath}/${name}`;
        }

        setOperationLoading(true);
        setError(null);
        try {
            await nodeFsTouch(nodeId, fullPath, "", isDir, sessionId);
            setNewItemModal(null);
            setTimeout(loadRoot, 500);
        } catch (err) {
            setError(`Failed to create: ${err.message}`);
        } finally {
            setOperationLoading(false);
        }
    };

    const handleDeleteFinal = async () => {
        if (!deleteModal) return;
        const path = deleteModal;
        setOperationLoading(true);
        setError(null);
        try {
            await nodeFsRm(nodeId, path, sessionId);
            setDeleteModal(null);
            setTimeout(loadRoot, 500);
        } catch (err) {
            setError(`Failed to delete: ${err.message}`);
        } finally {
            setOperationLoading(false);
        }
    };

    const isBinaryFile = (path) => {
        const ext = path.split('.').pop().toLowerCase();
        const binaryExts = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'ico', 'pdf', 'zip', 'gz', 'tar', 'exe', 'dll', 'so', 'bin', 'pyc', 'node', 'db', 'sqlite'];
        return binaryExts.includes(ext);
    };

    const handleView = async (path) => {
        if (isBinaryFile(path)) {
            setError(`Cannot view binary file: ${path}. Please download it instead.`);
            return;
        }

        setOperationLoading(true);
        setError(null);
        try {
            const res = await nodeFsCat(nodeId, path, sessionId);
            setSelectedFile({ path, content: res.content });
            setIsEditing(false);
        } catch (err) {
            setError(`Failed to read file: ${err.message}`);
        } finally {
            setOperationLoading(false);
        }
    };

    const handleSave = async () => {
        setOperationLoading(true);
        setError(null);
        try {
            await nodeFsTouch(nodeId, selectedFile.path, selectedFile.content, false, sessionId);
            setSelectedFile(null);
        } catch (err) {
            setError(`Failed to save: ${err.message}`);
        } finally {
            setOperationLoading(false);
        }
    };

    // Helper to render tree recursively
    const renderSubTree = (currentPath, depth = 0) => {
        // Normalize currentPath for filtering
        const normCurrent = (currentPath === "/" || currentPath === "." || currentPath === "")
            ? ""
            : (currentPath.startsWith("/") ? currentPath.slice(1) : currentPath);

        const children = tree.filter(node => {
            const nodePath = node.path.startsWith("/") ? node.path.slice(1) : node.path;

            if (normCurrent === "") {
                // Root level: paths without slashes
                return !nodePath.includes("/");
            }

            // Nested level: starts with "parent/" and has no further slashes
            const prefix = normCurrent.endsWith("/") ? normCurrent : normCurrent + "/";
            if (!nodePath.startsWith(prefix)) return false;
            if (nodePath === normCurrent) return false;

            const sub = nodePath.slice(prefix.length);
            return !sub.includes("/");
        });

        // Sort: Folders first, then Alphabetical
        const sorted = [...children].sort((a, b) => {
            if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
            return a.name.localeCompare(b.name);
        });

        return sorted.map(node => (
            <div key={node.path} className="group">
                <div style={{ paddingLeft: `${depth * 12}px` }} 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)}
                    >
                        {folderLoading[node.path] ? (
                            <div className="w-3.5 h-3.5 border-2 border-indigo-400 border-t-transparent rounded-full animate-spin" />
                        ) : 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 flex items-center ${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)}
                    >
                        {showSyncStatus && (
                            <span
                                className={`w-2 h-2 rounded-full mr-2 shrink-0 transition-all duration-500 shadow-sm ${node.is_synced ? 'bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.4)]' : 'bg-amber-400 animate-pulse shadow-[0_0_8px_rgba(251,191,36,0.4)]'}`}
                                title={node.is_synced ? "Synced to cloud" : "Syncing to cloud..."}
                            />
                        )}
                        {node.name}
                    </span>

                    <div className="flex space-x-1 opacity-0 group-hover:opacity-100 transition-opacity ml-2">
                        {node.is_dir && (
                            <>
                                <button onClick={(e) => { e.stopPropagation(); setNewItemModal({ parentPath: node.path, isDir: 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={(e) => { e.stopPropagation(); setNewItemModal({ parentPath: node.path, isDir: true }); }} className="p-0.5 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-400 hover:text-indigo-500" title="New Folder">
                                    <svg className="w-3 h-3" 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={(e) => { e.stopPropagation(); setDeleteModal(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>
                {node.is_dir && expanded[node.path] && (
                    <div className="animate-in slide-in-from-left-2 duration-200">
                        {renderSubTree(node.path, depth + 1)}
                    </div>
                )}
            </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={() => setNewItemModal({ parentPath: ".", isDir: 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={() => setNewItemModal({ parentPath: ".", isDir: 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>
                )}
                {renderSubTree(initialPath)}
            </div>

            {/* Create Item Modal */}
            {newItemModal && (
                <div className="fixed inset-0 z-[70] 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-md shadow-2xl border dark:border-gray-700 overflow-hidden">
                        <div className="p-4 border-b dark:border-gray-800 flex justify-between items-center bg-gray-50 dark:bg-gray-800/50">
                            <h4 className="text-sm font-bold text-gray-900 dark:text-white">
                                {newItemModal.isDir ? 'Create New Folder' : 'Create New File'}
                            </h4>
                            <button onClick={() => setNewItemModal(null)} className="p-1 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>
                        <form onSubmit={(e) => {
                            e.preventDefault();
                            handleCreateFinal(e.target.elements.name.value);
                        }} className="p-4">
                            <div className="mb-4">
                                <label className="block text-xs font-bold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Item Name</label>
                                <input
                                    name="name"
                                    type="text"
                                    autoFocus
                                    autoComplete="off"
                                    className="w-full p-2.5 bg-gray-50 dark:bg-gray-800 border dark:border-gray-700 rounded-xl text-sm focus:ring-2 focus:ring-indigo-500 focus:outline-none dark:text-white"
                                    placeholder={newItemModal.isDir ? "folder_name" : "file_name.txt"}
                                    required
                                />
                            </div>
                            <div className="flex justify-end space-x-3">
                                <button
                                    type="button"
                                    onClick={() => setNewItemModal(null)}
                                    className="px-4 py-2 text-xs font-bold text-gray-500 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
                                >
                                    Cancel
                                </button>
                                <button
                                    type="submit"
                                    disabled={operationLoading}
                                    className="px-6 py-2 bg-indigo-600 hover:bg-indigo-700 text-white rounded-xl text-xs font-black shadow-lg shadow-indigo-500/20 transition-all disabled:opacity-50"
                                >
                                    {operationLoading ? 'Creating...' : 'Create Item'}
                                </button>
                            </div>
                        </form>
                    </div>
                </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-5xl h-[85vh] max-h-[90vh] 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>
            )}

            {/* Delete Confirmation Modal */}
            {deleteModal && (
                <div className="fixed inset-0 z-[80] 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-sm shadow-2xl border dark:border-gray-700 overflow-hidden">
                        <div className="p-6 text-center">
                            <div className="w-12 h-12 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mx-auto mb-4 text-red-600">
                                <svg className="w-6 h-6" 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>
                            </div>
                            <h4 className="text-lg font-bold text-gray-900 dark:text-white mb-2">Confirm Delete</h4>
                            <p className="text-sm text-gray-500 dark:text-gray-400 break-all mb-6">
                                Are you sure you want to delete <span className="font-mono text-red-500">{deleteModal}</span>? This action cannot be undone.
                            </p>
                            <div className="flex space-x-3">
                                <button
                                    onClick={() => setDeleteModal(null)}
                                    className="flex-1 px-4 py-2 text-xs font-bold text-gray-500 bg-gray-100 dark:bg-gray-800 dark:text-gray-400 rounded-xl hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
                                >
                                    Cancel
                                </button>
                                <button
                                    onClick={handleDeleteFinal}
                                    disabled={operationLoading}
                                    className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-xl text-xs font-black shadow-lg shadow-red-500/20 transition-all disabled:opacity-50"
                                >
                                    {operationLoading ? 'Deleting...' : 'Delete Forever'}
                                </button>
                            </div>
                        </div>
                    </div>
                </div>
            )}
        </div>
    );
};

export default FileSystemNavigator;