1411 lines
66 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useCallback, useRef } from 'react';
import CodeMirror from '@uiw/react-codemirror';
import { json } from '@codemirror/lang-json';
import { markdown } from '@codemirror/lang-markdown';
import { yaml } from '@codemirror/lang-yaml';
import { oneDark } from '@codemirror/theme-one-dark';
import type {
OpenClawStatus, OpenClawAgent, OpenClawSkill, OpenClawLogs,
OpenClawModels, FileTreeNode, ReadFileResult, AgentMemoryResult
} from '../api/client';
import { api } from '../api/client';
// ═════════════════════════════════════════════════════════════════════════════════
// Tab definitions
// ═════════════════════════════════════════════════════════════════════════════════
const TABS = [
{ id: 'status', label: 'Status', icon: '🔋' },
{ id: 'filesystem', label: 'Arborescence', icon: '📂' },
{ id: 'config', label: 'Configuration', icon: '⚙️' },
{ id: 'agents', label: 'Agents', icon: '🤖' },
{ id: 'skills', label: 'Skills', icon: '🧩' },
{ id: 'logs', label: 'Logs', icon: '📟' },
{ id: 'models', label: 'Modèles', icon: '🧠' },
] as const;
type TabId = (typeof TABS)[number]['id'];
// ═════════════════════════════════════════════════════════════════════════════════
// Main Page
// ═════════════════════════════════════════════════════════════════════════════════
export default function OpenClawPage() {
const [activeTab, setActiveTab] = useState<TabId>('status');
const [status, setStatus] = useState<OpenClawStatus | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
api.openclawStatus()
.then(s => { setStatus(s); setLoading(false); })
.catch(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-center">
<div className="text-5xl mb-3 animate-spin-slow">🐙</div>
<div className="text-gray-500 text-sm">Connexion à OpenClaw</div>
</div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-2xl bg-gradient-to-br from-purple-600/30 to-indigo-600/30 border border-purple-500/20 flex items-center justify-center text-2xl">
🐙
</div>
<div>
<h1 className="text-xl font-bold text-white tracking-tight">OpenClaw</h1>
<div className="flex items-center gap-2 text-xs text-gray-500">
<span className={`w-2 h-2 rounded-full ${status?.gateway_online ? 'bg-green-400 shadow-[0_0_8px_rgba(74,222,128,0.5)]' : 'bg-red-400 shadow-[0_0_8px_rgba(248,113,113,0.5)]'}`}></span>
{status?.gateway_online ? 'Gateway opérationnel' : 'Gateway hors ligne'}
<span className="text-gray-700"></span>
<span className="uppercase tracking-wider">{status?.openclaw_type}</span>
</div>
</div>
</div>
</div>
{/* Tab Bar */}
<div className="flex gap-1 p-1 rounded-2xl bg-surface-800/60 border border-glass-border overflow-x-auto">
{TABS.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2.5 rounded-xl text-sm font-medium transition-all duration-200 whitespace-nowrap ${
activeTab === tab.id
? 'bg-gradient-to-r from-purple-600/20 to-indigo-600/20 text-white border border-purple-500/20 shadow-[0_0_12px_rgba(147,51,234,0.1)]'
: 'text-gray-500 hover:text-gray-300 hover:bg-glass'
}`}
>
<span className="text-base">{tab.icon}</span>
<span>{tab.label}</span>
{tab.id === 'agents' && status && (
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-purple-500/20 text-purple-300 text-[10px] font-bold">{status.agent_count}</span>
)}
{tab.id === 'skills' && status && (
<span className="ml-1 px-1.5 py-0.5 rounded-full bg-indigo-500/20 text-indigo-300 text-[10px] font-bold">{status.skill_count}</span>
)}
</button>
))}
</div>
{/* Tab Content */}
<div className="animate-fade-in">
{activeTab === 'status' && <StatusTab status={status} />}
{activeTab === 'filesystem' && <FilesystemTab />}
{activeTab === 'config' && <ConfigTab />}
{activeTab === 'agents' && <AgentsTab />}
{activeTab === 'skills' && <SkillsTab />}
{activeTab === 'logs' && <LogsTab />}
{activeTab === 'models' && <ModelsTab />}
</div>
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 1 — Status
// ═════════════════════════════════════════════════════════════════════════════════
function StatusTab({ status }: { status: OpenClawStatus | null }) {
if (!status) return <EmptyState message="Impossible de charger le status" />;
const metrics = [
{ label: 'Gateway', value: status.gateway_online ? 'En ligne' : 'Hors ligne', accent: status.gateway_online ? 'green' : 'red', icon: '🔌' },
{ label: 'Mode', value: status.openclaw_type, accent: 'purple', icon: '⚡' },
{ label: 'Bind', value: status.gateway_bind, accent: 'blue', icon: '🌐' },
{ label: 'Port', value: String(status.gateway_port), accent: 'indigo', icon: '🔗' },
{ label: 'Agents', value: String(status.agent_count), accent: 'amber', icon: '🤖' },
{ label: 'Skills', value: String(status.skill_count), accent: 'teal', icon: '🧩' },
];
return (
<div className="space-y-6">
{/* Hero health card */}
<div className={`glass-card p-6 relative overflow-hidden ${status.gateway_online ? 'border-green-500/20' : 'border-red-500/20'}`}>
<div className="absolute -top-20 -right-20 w-60 h-60 rounded-full blur-3xl opacity-10"
style={{ background: status.gateway_online ? 'radial-gradient(circle, #22C55E, transparent)' : 'radial-gradient(circle, #EF4444, transparent)' }}
/>
<div className="flex items-center gap-6 relative z-10">
<div className={`w-20 h-20 rounded-3xl flex items-center justify-center text-4xl ${
status.gateway_online
? 'bg-gradient-to-br from-green-500/20 to-emerald-600/10 border border-green-500/30'
: 'bg-gradient-to-br from-red-500/20 to-rose-600/10 border border-red-500/30'
}`} style={status.gateway_online ? { animation: 'pulse-glow 3s ease-in-out infinite' } : undefined}>
{status.gateway_online ? '✅' : '❌'}
</div>
<div>
<h2 className="text-2xl font-bold text-white">
{status.gateway_online ? 'Gateway Opérationnel' : 'Gateway Hors Ligne'}
</h2>
<p className="text-gray-400 text-sm mt-1">
{status.foxy_home} Port {status.gateway_port}
</p>
<div className="flex items-center gap-3 mt-2">
<span className={`badge ${status.gateway_online ? 'badge-completed' : 'badge-failed'}`}>
{status.gateway_mode}
</span>
<span className="badge bg-purple-500/15 text-purple-300">
{status.openclaw_type}
</span>
{status.config_exists && (
<span className="badge bg-blue-500/15 text-blue-300">config </span>
)}
</div>
</div>
</div>
</div>
{/* Metrics grid */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3">
{metrics.map(m => (
<MetricCard key={m.label} {...m} />
))}
</div>
</div>
);
}
function MetricCard({ label, value, accent, icon }: { label: string; value: string; accent: string; icon: string }) {
const colorMap: Record<string, { bg: string; text: string }> = {
green: { bg: 'from-green-500/10 to-green-600/5', text: 'text-green-400' },
red: { bg: 'from-red-500/10 to-red-600/5', text: 'text-red-400' },
purple: { bg: 'from-purple-500/10 to-purple-600/5', text: 'text-purple-400' },
blue: { bg: 'from-blue-500/10 to-blue-600/5', text: 'text-blue-400' },
indigo: { bg: 'from-indigo-500/10 to-indigo-600/5', text: 'text-indigo-400' },
amber: { bg: 'from-amber-500/10 to-amber-600/5', text: 'text-amber-400' },
teal: { bg: 'from-teal-500/10 to-teal-600/5', text: 'text-teal-400' },
};
const c = colorMap[accent] || colorMap.purple;
return (
<div className={`glass-card p-4 bg-gradient-to-br ${c.bg}`}>
<div className="text-lg mb-1">{icon}</div>
<div className="text-[10px] text-gray-500 uppercase tracking-wider">{label}</div>
<div className={`text-lg font-extrabold ${c.text} mt-0.5`}>{value}</div>
</div>
);
}
function TreeNode({ node, depth, onSelect }: { node: FileTreeNode; depth: number, onSelect?: (n: FileTreeNode) => void }) {
const [open, setOpen] = useState(depth < 1);
const isDir = node.type === 'directory';
const indent = depth * 20;
return (
<div>
<div
className={`flex items-center gap-2 py-1 px-2 rounded-lg transition-colors cursor-pointer ${
isDir ? 'hover:bg-surface-700/50' : 'hover:bg-surface-700/50 text-gray-400'
}`}
style={{ paddingLeft: `${indent + 8}px` }}
onClick={() => {
if (isDir) setOpen(!open);
else onSelect?.(node);
}}
>
{isDir ? (
<span className="text-yellow-400 w-4 text-center">{open ? '📂' : '📁'}</span>
) : (
<span className="text-gray-500 w-4 text-center">📄</span>
)}
<span className={isDir ? 'text-white font-medium' : 'text-gray-400'}>{node.name}</span>
{node.size != null && !isDir && (
<span className="text-gray-600 ml-auto text-[10px]">{formatSize(node.size)}</span>
)}
</div>
{isDir && open && node.children?.map(child => (
<TreeNode key={child.path} node={child} depth={depth + 1} onSelect={onSelect} />
))}
</div>
);
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 2 — Configuration
// ═════════════════════════════════════════════════════════════════════════════════
function ConfigTab() {
const [config, setConfig] = useState<Record<string, unknown> | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const fetchConfig = () => {
setLoading(true);
api.openclawConfig()
.then(d => { setConfig(d.config); setLoading(false); })
.catch(() => setLoading(false));
};
useEffect(() => { fetchConfig(); }, []);
if (loading) return <LoadingSpinner />;
if (!config) return <EmptyState message="openclaw.json introuvable ou illisible" />;
const sections = Object.entries(config);
return (
<div className="space-y-6">
{editing && <ConfigFileEditor onClose={() => setEditing(false)} onSave={() => { setEditing(false); fetchConfig(); }} />}
{/* Premium Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between glass-card p-6 border-l-4 border-l-purple-500 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-full bg-gradient-to-l from-purple-500/10 to-transparent opacity-50 group-hover:opacity-100 transition-opacity"></div>
<div className="relative z-10 mb-4 sm:mb-0">
<h2 className="text-xl font-bold text-white flex items-center gap-3">
<span></span> Configuration Globale
</h2>
<p className="text-gray-400 text-sm mt-1.5 flex items-center gap-2">
Paramètres système chargés depuis <code className="px-1.5 py-0.5 rounded bg-surface-800 text-purple-300 font-mono text-xs">openclaw.json</code>
</p>
</div>
<div className="relative z-10 shrink-0">
<button
onClick={() => setEditing(true)}
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm font-semibold hover:from-purple-500 hover:to-indigo-500 transition-all shadow-[0_0_15px_rgba(147,51,234,0.3)] hover:scale-105 flex items-center gap-2"
>
<span></span> Modifier config
</button>
</div>
</div>
{/* Grid of config sections */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-5">
{sections.map(([key, value]) => {
const isObject = typeof value === 'object' && value !== null;
return (
<div key={key} className="glass-card flex flex-col h-full border border-surface-700/50 hover:border-purple-500/30 transition-all group overflow-hidden">
<div className="bg-surface-800/80 p-4 border-b border-surface-700/50 flex justify-between items-center group-hover:bg-surface-700/50 transition-colors">
<h3 className="font-bold text-white text-sm tracking-wide flex items-center gap-2">
<span className="text-purple-400 text-lg"></span> {key.toUpperCase()}
</h3>
{isObject && (
<span className="px-2 py-0.5 rounded-md bg-surface-900/50 text-gray-400 text-[10px] font-mono border border-surface-700">
{Array.isArray(value) ? `${(value as unknown[]).length} items` : `${Object.keys(value as object).length} clés`}
</span>
)}
</div>
<div className="p-4 bg-[#1e222a] flex-1 overflow-auto max-h-96 custom-scrollbar shrink-0">
{isObject ? (
<pre className="text-[11px] font-mono text-[#abb2bf] whitespace-pre-wrap leading-relaxed">
{JSON.stringify(value, null, 2)}
</pre>
) : (
<div className="h-full flex items-center">
<code className="text-[13px] text-green-300 font-mono bg-surface-900/50 px-3 py-2 rounded-lg break-all border border-green-500/10 w-full text-center">
{renderValue(value)}
</code>
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
function renderValue(v: unknown): string {
if (v === null) return 'null';
if (typeof v === 'boolean') return v ? 'true' : 'false';
if (typeof v === 'number') return String(v);
if (typeof v === 'string') return v.length > 80 ? v.slice(0, 77) + '…' : v;
return String(v);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 3 — Agents (with Memory Viewer)
// ═════════════════════════════════════════════════════════════════════════════════
function AgentMemoryViewer({ agentName }: { agentName: string }) {
const [memory, setMemory] = useState<AgentMemoryResult | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let active = true;
api.openclawAgentMemory(agentName)
.then(res => {
if (active) {
setMemory(res);
setLoading(false);
}
})
.catch(err => {
console.error(err);
if (active) setLoading(false);
});
return () => { active = false; };
}, [agentName]);
if (loading) {
return (
<div className="flex flex-col items-center justify-center p-8 text-gray-400">
<span className="text-3xl animate-spin-slow mb-4"></span>
<p className="animate-pulse">Connexion à la mémoire QMD de l'agent...</p>
</div>
);
}
if (!memory || !memory.has_db) {
return (
<div className="bg-surface-800/50 border border-glass-border rounded-xl p-8 text-center flex flex-col items-center justify-center">
<span className="text-5xl opacity-30 grayscale mb-4">🗄️</span>
<h3 className="text-white font-semibold mb-2">Mémoire Introuvable</h3>
<p className="text-gray-400 text-sm max-w-md mx-auto">
Aucune base de données SQLite QMD n'a é trouvée pour cet agent dans le workspace.
Lumina ne dispose pas (encore) d'une trame chronologique ici.
</p>
<p className="text-xs text-gray-500 font-mono mt-4">Astuce : Vérifiez la configuration du dossier workspace sous FOXY_HOME.</p>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center px-4 py-3 bg-gradient-to-r from-blue-900/20 to-indigo-900/20 rounded-xl border border-blue-500/10">
<div>
<h4 className="text-blue-300 font-bold flex items-center gap-2">
<span>🧠</span> Labyrinth Memoire QMD
</h4>
<p className="text-xs text-blue-300/60 font-mono mt-1">{memory.db_path} ({formatSize(memory.size || 0)})</p>
</div>
<div className="text-right">
<div className="text-2xl font-bold text-white">{memory.stats.total_thoughts}</div>
<div className="text-[10px] text-gray-400 uppercase tracking-wider">Pensées / Actions</div>
</div>
</div>
<div className="relative pl-6 before:absolute before:inset-0 before:left-[11px] before:w-0.5 before:bg-gradient-to-b before:from-blue-500/50 before:to-transparent pt-2">
{memory.timeline.length === 0 ? (
<div className="text-gray-500 text-sm py-4">La mémoire existe mais elle est actuellement vide.</div>
) : (
<div className="space-y-6">
{memory.timeline.map((item: Record<string, any>, idx: number) => (
<div key={idx} className="relative group">
{/* Node dot */}
<div className="absolute -left-[31px] top-1.5 w-4 h-4 rounded-full bg-surface-900 border-2 border-blue-500/50 group-hover:bg-blue-500 group-hover:shadow-[0_0_10px_rgba(59,130,246,0.6)] transition-all z-10"></div>
{/* Content Card */}
<div className="glass-card p-4 border border-surface-700/50 hover:border-blue-500/30 transition-colors">
<div className="flex items-start justify-between mb-2">
<span className="text-[10px] font-mono text-gray-500 bg-surface-800 px-2 py-0.5 rounded">
{item.timestamp || item.created_at || 'Inconnu'}
</span>
{(item.type || item.event_type || item.role) && (
<span className="text-[10px] uppercase font-bold text-indigo-300 bg-indigo-500/10 px-2 py-0.5 rounded">
{item.type || item.event_type || item.role}
</span>
)}
</div>
<pre className="text-sm text-gray-300 whitespace-pre-wrap font-sans leading-relaxed">
{item.content || item.message || JSON.stringify(item, null, 2)}
</pre>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
function AgentsTab() {
const [agents, setAgents] = useState<OpenClawAgent[]>([]);
const [loading, setLoading] = useState(true);
const [expandedAgent, setExpandedAgent] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'identity' | 'memory'>('identity');
const [deleting, setDeleting] = useState<string | null>(null);
const [activeFile, setActiveFile] = useState<{agent: string, file: string} | null>(null);
const [fileContent, setFileContent] = useState('');
const [fileLoading, setFileLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const loadAgents = () => {
setLoading(true);
api.openclawAgents()
.then(d => { setAgents(d.agents); setLoading(false); })
.catch(() => setLoading(false));
};
useEffect(() => { loadAgents(); }, []);
const handleDelete = async (e: React.MouseEvent, name: string) => {
e.stopPropagation();
if (!confirm(`Voulez-vous vraiment supprimer l'agent "${name}" ?\n\n Cette action supprimera définitivement son dossier de configuration.`)) return;
setDeleting(name);
try {
await api.openclawDeleteAgent(name);
if (expandedAgent === name) setExpandedAgent(null);
loadAgents();
} catch(err) {
console.error(err);
alert('Erreur lors de la suppression: ' + String(err));
} finally {
setDeleting(null);
}
};
const openFile = async (agentName: string, filename: string) => {
if (activeFile?.agent === agentName && activeFile?.file === filename) {
setActiveFile(null); // toggle close
setIsFullscreen(false);
return;
}
setActiveFile({ agent: agentName, file: filename });
setIsFullscreen(false);
setFileLoading(true);
try {
const data = await api.openclawReadAgentFile(agentName, filename);
setFileContent(data.content || '');
} catch(err) {
console.error(err);
setFileContent('Erreur de chargement du fichier.');
} finally {
setFileLoading(false);
}
};
const saveFile = async () => {
if (!activeFile) return;
setSaving(true);
try {
await api.openclawWriteAgentFile(activeFile.agent, activeFile.file, fileContent);
loadAgents(); // refresh file sizes
setActiveFile(null); // close the editor after saving
} catch(err) {
console.error(err);
alert('Erreur lors de la sauvegarde: ' + String(err));
} finally {
setSaving(false);
}
};
if (loading && agents.length === 0) return <LoadingSpinner />;
if (agents.length === 0) return <EmptyState message="Aucun agent trouvé dans le répertoire agents/" />;
return (
<div className="space-y-4">
{/* Agent count header */}
<div className="glass-card p-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-lg">🤖</span>
<span className="text-white font-semibold">{agents.length} agents</span>
<span className="text-gray-500 text-sm hidden sm:inline">détectés dans le répertoire OpenClaw</span>
</div>
<button onClick={loadAgents} className="text-gray-400 hover:text-white transition-colors" title="Actualiser">🔄</button>
</div>
{/* Agent grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{agents.map(agent => (
<div
key={agent.name}
className={`glass-card overflow-hidden transition-all duration-300 ${
expandedAgent === agent.name ? 'ring-1 ring-purple-500/30' : ''
}`}
>
<div
className={`p-5 cursor-pointer hover:bg-glass-hover transition-colors ${deleting === agent.name ? 'opacity-50 pointer-events-none' : ''}`}
onClick={() => {
setExpandedAgent(expandedAgent === agent.name ? null : agent.name);
setActiveFile(null);
setIsFullscreen(false);
setActiveView('identity');
}}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/10 border border-amber-500/20 flex items-center justify-center text-xl shrink-0">
🤖
</div>
<div className="min-w-0">
<h3 className="text-white font-semibold text-sm truncate pr-2">{agent.name}</h3>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className="badge bg-surface-600 text-gray-300 text-[10px]">{agent.type}</span>
{agent.model && (
<span className="badge bg-purple-500/15 text-purple-300 text-[10px] truncate max-w-[150px]">{agent.model}</span>
)}
{agent.has_workspace && (
<span className="badge bg-green-500/15 text-green-300 text-[10px]" title="A un dossier workspace défini">ws ✓</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={(e) => handleDelete(e, agent.name)}
className="p-1.5 rounded-lg text-gray-500 hover:text-red-400 hover:bg-red-400/10 transition-colors"
title="Supprimer l'agent"
>
🗑️
</button>
<span className="text-gray-600 text-sm ml-2 w-4 text-center">
{expandedAgent === agent.name ? '▲' : '▼'}
</span>
</div>
</div>
</div>
{/* Expanded details */}
{expandedAgent === agent.name && (
<div className="border-t border-glass-border p-5 bg-surface-900/40 space-y-6">
{/* View Tabs */}
<div className="flex gap-2 border-b border-surface-700/50 pb-3 -mx-2 px-2">
<button
onClick={() => setActiveView('identity')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${activeView === 'identity' ? 'bg-surface-800 text-white border border-surface-600 shadow-md' : 'text-gray-500 hover:text-gray-300 hover:bg-surface-800/50'}`}
>
<span>🗂️</span> Paramètres & Fichiers
</button>
<button
onClick={() => setActiveView('memory')}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-all flex items-center gap-2 ${activeView === 'memory' ? 'bg-indigo-900/30 text-indigo-300 border border-indigo-500/20 shadow-[0_0_15px_rgba(79,70,229,0.1)]' : 'text-gray-500 hover:text-indigo-400 hover:bg-indigo-900/10'}`}
>
<span>🧠</span> Mémoires d'agent
</button>
</div>
{activeView === 'memory' ? (
<AgentMemoryViewer agentName={agent.name} />
) : (
<>
{/* Agent Identity Files Manager */}
{agent.has_workspace && agent.identity_files && agent.identity_files.length > 0 && (
<div>
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-3 flex items-center gap-2">
<span>⚙️</span> Fichiers d'identité
</h4>
<div className="flex gap-2 flex-wrap mb-3">
{agent.identity_files.map(file => {
const isActive = activeFile?.agent === agent.name && activeFile?.file === file.name;
return (
<button
key={file.name}
onClick={() => openFile(agent.name, file.name)}
className={`px-3 py-1.5 rounded-lg text-xs font-mono transition-all ${
isActive
? 'bg-gradient-to-r from-purple-600 to-indigo-600 text-white shadow-[0_0_10px_rgba(147,51,234,0.3)]'
: file.size > 0
? 'bg-surface-800 text-gray-300 border border-surface-600 hover:border-purple-500/50 hover:bg-surface-700'
: 'bg-surface-800 text-gray-600 border border-surface-700/50 opacity-60 hover:opacity-100 hover:text-gray-400'
}`}
>
{file.name}
{file.size > 0 && <span className="ml-1.5 opacity-60 text-[9px]">{formatSize(file.size)}</span>}
</button>
);
})}
</div>
{/* Identity File Editor */}
{activeFile?.agent === agent.name && (
<div className={`border border-glass-border bg-[#282c34] overflow-hidden shadow-inner mt-4 animate-fade-in flex flex-col ${
isFullscreen ? 'fixed inset-0 w-screen h-screen z-[9999] m-0 p-0 rounded-none' : 'h-[500px] rounded-xl relative'
}`}>
<div className={`flex items-center justify-between px-3 py-2 bg-surface-900/80 border-b border-glass-border shrink-0 ${isFullscreen ? 'h-14 px-6' : ''}`}>
<span className="text-xs font-mono text-purple-300 truncate pr-4">workspace/{agent.name}/{activeFile.file}</span>
<div className="flex gap-2 shrink-0">
<button onClick={() => setIsFullscreen(!isFullscreen)} className="px-2 py-1 rounded text-[10px] text-gray-400 hover:bg-surface-700 hover:text-white transition-colors">
{isFullscreen ? '🗗 Réduire' : '⛶ Plein écran'}
</button>
<button onClick={() => { setActiveFile(null); setIsFullscreen(false); }} className="px-2 py-1 rounded text-[10px] text-gray-400 hover:bg-surface-700 hover:text-white transition-colors">
Fermer
</button>
<button onClick={saveFile} disabled={saving} className="px-3 py-1 rounded bg-purple-600/80 hover:bg-purple-500 text-white text-[10px] font-medium transition-colors">
{saving ? '...' : 'Sauvegarder'}
</button>
</div>
</div>
{fileLoading ? (
<div className="flex-1 flex items-center justify-center text-gray-500 text-sm animate-pulse">Chargement…</div>
) : (
<div className="flex-1 overflow-hidden relative min-h-0 bg-[#282c34]">
<div className="absolute inset-0 custom-scrollbar [&_.cm-editor]:h-full [&_.cm-editor]:bg-transparent [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-xs pb-10">
<CodeMirror
value={fileContent}
height="100%"
theme={oneDark}
extensions={[markdown()]}
onChange={(val) => setFileContent(val)}
basicSetup={{ lineNumbers: true, foldGutter: false, highlightActiveLine: true, tabSize: 2 }}
/>
</div>
</div>
)}
</div>
)}
</div>
)}
{/* System Prompt (Read-only overlay for old config style) */}
{agent.system_prompt && !activeFile && (
<div>
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-2">Instructions principales</h4>
<div className="px-4 py-3 rounded-xl bg-surface-800/80 text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed custom-scrollbar border border-surface-700/50">
{agent.system_prompt}
</div>
</div>
)}
{/* Configuration (Read-only) */}
{agent.config && !activeFile && activeView === 'identity' && (
<div>
<h4 className="text-xs text-gray-400 font-bold uppercase tracking-wider mb-2 flex justify-between items-center">
<span>Configuration JSON/YAML</span>
<span className="font-mono lowercase text-[10px] text-gray-500">{agent.config_files?.join(', ')}</span>
</h4>
<pre className="px-4 py-3 rounded-xl bg-surface-800/80 text-[11px] text-gray-400 font-mono whitespace-pre-wrap max-h-64 overflow-y-auto leading-relaxed custom-scrollbar border border-surface-700/50">
{JSON.stringify(agent.config, null, 2)}
</pre>
</div>
)}
</>
)}
</div>
)}
</div>
))}
</div>
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 4 — Skills
// ═════════════════════════════════════════════════════════════════════════════════
function SkillsTab() {
const [skills, setSkills] = useState<OpenClawSkill[]>([]);
const [loading, setLoading] = useState(true);
const [expandedSkill, setExpandedSkill] = useState<string | null>(null);
useEffect(() => {
api.openclawSkills()
.then(d => { setSkills(d.skills); setLoading(false); })
.catch(() => setLoading(false));
}, []);
if (loading) return <LoadingSpinner />;
if (skills.length === 0) return <EmptyState message="Aucun skill trouvé" />;
return (
<div className="space-y-4">
<div className="glass-card p-4 flex items-center gap-3">
<span className="text-lg">🧩</span>
<span className="text-white font-semibold">{skills.length} skills</span>
<span className="text-gray-500 text-sm">disponibles</span>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{skills.map(skill => (
<div
key={`${skill.source}/${skill.name}`}
className="glass-card overflow-hidden"
>
<div
className="p-5 cursor-pointer hover:bg-glass-hover transition-colors"
onClick={() => setExpandedSkill(expandedSkill === skill.name ? null : skill.name)}
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-indigo-500/20 to-blue-600/10 border border-indigo-500/20 flex items-center justify-center text-xl">
🧩
</div>
<div>
<h3 className="text-white font-semibold text-sm">{skill.name}</h3>
<div className="flex items-center gap-2 mt-1">
<span className="badge bg-surface-600 text-gray-300 text-[10px]">{skill.type}</span>
<span className="text-[10px] text-gray-600 font-mono">{skill.source}</span>
</div>
</div>
</div>
</div>
{skill.description_short && (
<p className="text-xs text-gray-400 mt-3 leading-relaxed">{skill.description_short}</p>
)}
{skill.subdirs && skill.subdirs.length > 0 && (
<div className="flex gap-1.5 mt-3 flex-wrap">
{skill.subdirs.map(d => (
<span key={d} className="px-2 py-0.5 rounded-md bg-surface-700 text-gray-400 text-[10px] font-mono">📁 {d}</span>
))}
</div>
)}
<div className="flex items-center gap-3 mt-3 text-[10px] text-gray-600">
{skill.file_count != null && <span>{skill.file_count} fichiers</span>}
{skill.size != null && <span>{formatSize(skill.size)}</span>}
</div>
</div>
{expandedSkill === skill.name && skill.description && (
<div className="border-t border-glass-border p-4 bg-surface-900/40">
<h4 className="text-xs text-gray-500 uppercase tracking-wider mb-2">Description</h4>
<pre className="text-xs text-gray-300 font-mono whitespace-pre-wrap max-h-48 overflow-y-auto leading-relaxed">
{skill.description}
</pre>
</div>
)}
</div>
))}
</div>
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 5 — Logs (Terminal style)
// ═════════════════════════════════════════════════════════════════════════════════
function LogsTab() {
const [logs, setLogs] = useState<OpenClawLogs | null>(null);
const [loading, setLoading] = useState(true);
const [levelFilter, setLevelFilter] = useState<string>('');
const [lineCount, setLineCount] = useState(200);
const [autoRefresh, setAutoRefresh] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const fetchLogs = useCallback(async () => {
try {
const params: { lines: number; level?: string } = { lines: lineCount };
if (levelFilter) params.level = levelFilter;
const data = await api.openclawLogs(params);
setLogs(data);
} catch { /* ignore */ }
setLoading(false);
}, [lineCount, levelFilter]);
useEffect(() => { fetchLogs(); }, [fetchLogs]);
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(fetchLogs, 5000);
return () => clearInterval(interval);
}, [autoRefresh, fetchLogs]);
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [logs]);
function getLineStyle(line: string): string {
const upper = line.toUpperCase();
if (upper.includes('ERROR') || upper.includes('FATAL') || upper.includes('PANIC')) return 'text-red-400';
if (upper.includes('WARN')) return 'text-yellow-400';
if (upper.includes('DEBUG') || upper.includes('TRACE')) return 'text-gray-600';
if (upper.includes('INFO')) return 'text-green-300';
return 'text-gray-400';
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="glass-card p-4 flex items-center gap-3 flex-wrap">
<span className="text-lg">📟</span>
<span className="text-white font-semibold text-sm">Gateway Logs</span>
<div className="flex-1" />
{/* Level filter */}
<div className="flex gap-1">
{['', 'INFO', 'WARNING', 'ERROR'].map(lvl => (
<button
key={lvl}
onClick={() => setLevelFilter(lvl)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
levelFilter === lvl
? 'bg-purple-500/20 text-purple-300 border border-purple-500/30'
: 'text-gray-500 hover:text-gray-300 bg-surface-700/50'
}`}
>
{lvl || 'Tous'}
</button>
))}
</div>
{/* Line count */}
<select
value={lineCount}
onChange={e => setLineCount(Number(e.target.value))}
className="input !w-auto !py-1.5 text-xs"
>
<option value={50}>50 lignes</option>
<option value={200}>200 lignes</option>
<option value={500}>500 lignes</option>
<option value={1000}>1000 lignes</option>
</select>
{/* Auto-refresh toggle */}
<button
onClick={() => setAutoRefresh(!autoRefresh)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
autoRefresh
? 'bg-green-500/20 text-green-300 border border-green-500/30'
: 'text-gray-500 bg-surface-700/50'
}`}
>
{autoRefresh ? '⏸ Auto' : '▶ Auto'}
</button>
{/* Refresh button */}
<button onClick={fetchLogs} className="btn btn-ghost text-xs !py-1.5">
🔄 Refresh
</button>
</div>
{/* Terminal */}
{loading ? <LoadingSpinner /> : (
<div className="rounded-2xl overflow-hidden border border-glass-border">
{/* Terminal header */}
<div className="flex items-center gap-3 px-4 py-2.5 bg-surface-800 border-b border-glass-border">
<div className="flex gap-1.5">
<span className="w-3 h-3 rounded-full bg-red-500/60"></span>
<span className="w-3 h-3 rounded-full bg-yellow-500/60"></span>
<span className="w-3 h-3 rounded-full bg-green-500/60"></span>
</div>
<span className="text-xs text-gray-500 font-mono flex-1">
{logs?.log_path || 'gateway.log'}
</span>
{logs && (
<span className="text-[10px] text-gray-600">
{logs.showing} / {logs.total_lines} lignes
</span>
)}
</div>
{/* Terminal content */}
<div className="p-4 max-h-[600px] overflow-y-auto font-mono text-xs leading-5"
style={{ background: '#0a0e17' }}
>
{logs?.error && (
<div className="text-red-400 mb-3 p-2 rounded-lg bg-red-500/10">{logs.error}</div>
)}
{(!logs?.lines || logs.lines.length === 0) ? (
<div className="text-gray-600">Aucune ligne de log</div>
) : (
logs.lines.map((line, i) => (
<div key={i} className={`hover:bg-white/[0.02] px-1 rounded ${getLineStyle(line)}`}>
<span className="text-gray-700 select-none mr-3 inline-block w-10 text-right">{i + 1}</span>
{line}
</div>
))
)}
<div ref={logEndRef} />
</div>
</div>
)}
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 6 — Models & Providers
// ═════════════════════════════════════════════════════════════════════════════════
function ModelsTab() {
const [data, setData] = useState<OpenClawModels | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const fetchModels = () => {
setLoading(true);
api.openclawModels()
.then(d => { setData(d); setLoading(false); })
.catch(() => setLoading(false));
};
useEffect(() => { fetchModels(); }, []);
if (loading) return <LoadingSpinner />;
if (!data || data.error) return <EmptyState message={data?.error || 'Impossible de charger les modèles'} />;
return (
<div className="space-y-6 animate-fade-in">
{editing && <ConfigFileEditor onClose={() => setEditing(false)} onSave={() => { setEditing(false); fetchModels(); }} />}
{/* Premium Header */}
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between glass-card p-6 border-l-4 border-l-cyan-500 relative overflow-hidden group">
<div className="absolute top-0 right-0 w-64 h-full bg-gradient-to-l from-cyan-500/10 to-transparent opacity-50 group-hover:opacity-100 transition-opacity"></div>
<div className="relative z-10 mb-4 sm:mb-0">
<h2 className="text-xl font-bold text-white flex items-center gap-3">
<span>🧠</span> Intelligence Artificielle (LLMs & MCP)
</h2>
<p className="text-gray-400 text-sm mt-1.5 flex items-center gap-2">
Fournisseurs, serveurs MCP et modèles configurés
</p>
</div>
<div className="relative z-10 shrink-0">
<button
onClick={() => setEditing(true)}
className="px-5 py-2.5 rounded-xl bg-gradient-to-r from-cyan-600 to-blue-600 text-white text-sm font-semibold hover:from-cyan-500 hover:to-blue-500 transition-all shadow-[0_0_15px_rgba(6,182,212,0.3)] hover:scale-105 flex items-center gap-2"
>
<span>✏️</span> Configurer Modèles
</button>
</div>
</div>
{/* Providers Grid */}
{data.providers.length > 0 && (
<div className="glass-card overflow-hidden">
<div className="p-5 bg-surface-800/50 border-b border-glass-border">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<span className="text-cyan-400">🔌</span> Providers / MCP Servers
</h2>
</div>
<div className="p-5 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{data.providers.map((p, i) => (
<div key={i} className="p-5 rounded-xl bg-surface-900/60 border border-surface-700/50 hover:border-cyan-500/30 transition-all hover:bg-surface-800/80 group">
<div className="flex items-center gap-3 mb-3 relative">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-cyan-500/20 to-blue-600/10 border border-cyan-500/20 flex items-center justify-center text-lg shadow-inner group-hover:scale-110 transition-transform">
{p.type === 'mcp' ? '🧱' : '💬'}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-white font-bold text-sm truncate pr-2" title={String(p.name || `Provider ${i + 1}`)}>
{String(p.name || `Provider ${i + 1}`)}
</h3>
<div className="flex items-center gap-2 mt-1">
{Boolean(p.type) && (
<span className="px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-bold bg-surface-700 text-gray-300">
{String(p.type)}
</span>
)}
{p.enabled !== undefined && (
<span className={`px-2 py-0.5 rounded text-[9px] uppercase tracking-wider font-bold ${p.enabled ? 'bg-emerald-500/20 text-emerald-300' : 'bg-red-500/20 text-red-300'}`}>
{p.enabled ? 'actif' : 'inactif'}
</span>
)}
</div>
</div>
</div>
{Boolean(p.model) && (
<div className="text-xs text-blue-300 font-mono mt-2 bg-blue-500/10 px-3 py-1.5 rounded-lg border border-blue-500/10 inline-block truncate max-w-full">
<span className="opacity-50 mr-1">modèle:</span> {String(p.model)}
</div>
)}
{Boolean(p.endpoint) && (
<div className="text-[11px] text-gray-500 font-mono mt-2 bg-surface-800 px-3 py-1.5 rounded-lg truncate max-w-full border border-surface-700">
{String(p.endpoint)}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Models Table */}
{data.models.length > 0 && (
<div className="glass-card overflow-hidden">
<div className="p-5 bg-surface-800/50 border-b border-glass-border">
<h2 className="text-base font-bold text-white flex items-center gap-2">
<span className="text-purple-400">🤖</span> Modèles Configurés
</h2>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-surface-900/80">
<tr>
<th className="text-left text-[11px] text-gray-400 font-bold uppercase tracking-wider py-4 px-6 border-b border-surface-700/50 w-1/4">Identifiant Modèle</th>
<th className="text-left text-[11px] text-gray-400 font-bold uppercase tracking-wider py-4 px-6 border-b border-surface-700/50">Détails configuration (JSON)</th>
</tr>
</thead>
<tbody className="divide-y divide-surface-700/50">
{data.models.map((m, i) => (
<tr key={i} className="hover:bg-surface-800/40 transition-colors">
<td className="py-4 px-6 align-top">
<div className="flex items-center gap-3">
<span className="text-lg opacity-80">🏷️</span>
<span className="text-white font-bold bg-surface-800 px-3 py-1.5 rounded-lg border border-surface-600 shadow-sm inline-block">
{String(m.name || `Model ${i + 1}`)}
</span>
</div>
</td>
<td className="py-4 px-6">
<pre className="text-[11px] text-[#abb2bf] font-mono whitespace-pre-wrap leading-relaxed bg-[#1e222a] p-4 rounded-xl border border-surface-700 shadow-inner">
{JSON.stringify(m, null, 2)}
</pre>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Raw config keys */}
{data.raw_keys && data.raw_keys.length > 0 && (
<div className="glass-card p-5 flex items-center gap-4 flex-wrap">
<h2 className="text-xs font-bold text-gray-500 uppercase tracking-wider flex items-center gap-2 shrink-0">
<span>🔑</span> Clés racines détectées :
</h2>
<div className="flex gap-2 flex-wrap">
{data.raw_keys.map(k => (
<span key={k} className="px-2 py-1 rounded bg-surface-800 text-gray-400 text-[10px] font-mono border border-surface-700">{k}</span>
))}
</div>
</div>
)}
{data.providers.length === 0 && data.models.length === 0 && (
<EmptyState message="Aucun modèle ou provider détecté dans la configuration" />
)}
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Shared Components
// ═════════════════════════════════════════════════════════════════════════════════
function LoadingSpinner() {
return (
<div className="flex items-center justify-center h-48">
<div className="text-center">
<div className="text-3xl animate-spin-slow mb-2">🐙</div>
<div className="text-gray-600 text-xs">Chargement…</div>
</div>
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="glass-card p-12 text-center">
<div className="text-4xl mb-3 opacity-40">🐙</div>
<p className="text-gray-500 text-sm">{message}</p>
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Shared Config Editor Overlay
// ═════════════════════════════════════════════════════════════════════════════════
function ConfigFileEditor({ onClose, onSave }: { onClose: () => void, onSave: () => void }) {
const [content, setContent] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
useEffect(() => {
// Lock body scroll
document.body.style.overflow = 'hidden';
api.openclawReadFile('openclaw.json')
.then(res => {
setContent(res.pretty || res.content || '');
setLoading(false);
})
.catch(err => {
console.error(err);
setContent('Erreur de chargement du fichier openclaw.json');
setLoading(false);
});
return () => {
// Restore scroll when unmounting
document.body.style.overflow = '';
};
}, []);
const handleSave = async () => {
setSaving(true);
try {
await api.openclawWriteFile('openclaw.json', content);
onSave();
} catch (err) {
console.error(err);
alert('Erreur lors de la sauvegarde : ' + String(err));
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 w-screen h-screen z-[9999] m-0 p-0 rounded-none glass-card flex flex-col overflow-hidden border border-glass-border shadow-[0_40px_80px_rgb(0,0,0,0.9)] bg-surface-900/95 backdrop-blur-xl animate-fade-in">
<div className="flex items-center justify-between px-6 py-4 bg-surface-800 border-b border-glass-border shrink-0 h-16">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-amber-500/20 to-orange-600/10 border border-amber-500/20 flex items-center justify-center text-xl">
✏️
</div>
<div>
<h3 className="text-white font-bold text-base flex items-center gap-2">
Édition directe
</h3>
<div className="text-xs text-gray-400 font-mono mt-0.5">openclaw.json</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={onClose}
disabled={saving}
className="px-4 py-2 rounded-lg bg-surface-700 text-gray-300 text-sm font-medium hover:bg-surface-600 transition-colors"
>
Annuler
</button>
<button
onClick={handleSave}
disabled={saving}
className={`px-6 py-2 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-sm font-bold transition-all shadow-[0_0_15px_rgba(16,185,129,0.4)] flex items-center gap-2 ${saving ? 'opacity-50 scale-95 cursor-wait' : 'hover:scale-105 hover:from-emerald-500 hover:to-teal-500'}`}
>
{saving ? 'Sauvegarde...' : '💾 Sauvegarder .json'}
</button>
</div>
</div>
<div className="flex-1 overflow-hidden relative bg-[#282c34]">
{loading ? (
<div className="absolute inset-0 flex items-center justify-center flex-col gap-4">
<span className="text-4xl animate-spin-slow opacity-50">⚙️</span>
<span className="text-gray-400 font-mono">Lecture de la configuration brute...</span>
</div>
) : (
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px] custom-scrollbar pb-10">
<CodeMirror
value={content}
height="100%"
theme={oneDark}
extensions={[json()]}
onChange={(val) => setContent(val)}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
tabSize: 2,
}}
/>
</div>
)}
</div>
</div>
);
}
// ═════════════════════════════════════════════════════════════════════════════════
// Tab 7 — Filesystem (Arborescence)
// ═════════════════════════════════════════════════════════════════════════════════
function FilesystemTab() {
const [tree, setTree] = useState<FileTreeNode[]>([]);
const [treeLoading, setTreeLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<ReadFileResult | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [editContent, setEditContent] = useState('');
const [saving, setSaving] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
loadTree();
}, []);
const loadTree = () => {
setTreeLoading(true);
api.openclawFilesystem()
.then(d => { setTree(d.tree); setTreeLoading(false); })
.catch(() => setTreeLoading(false));
};
const handleSelectFile = async (node: FileTreeNode) => {
if (node.type !== 'file') return;
setFileLoading(true);
setEditMode(false);
try {
const data = await api.openclawReadFile(node.path);
setSelectedFile(data);
setEditContent(data.pretty || data.content || '');
} catch (err) {
console.error(err);
} finally {
setFileLoading(false);
}
};
const handleSave = async () => {
if (!selectedFile) return;
setSaving(true);
try {
await api.openclawWriteFile(selectedFile.path, editContent);
const data = await api.openclawReadFile(selectedFile.path);
setSelectedFile(data);
setEditMode(false);
} catch (err) {
console.error(err);
} finally {
setSaving(false);
}
};
const getExtensions = () => {
if (!selectedFile) return [];
const lang = selectedFile.language;
if (lang === 'json') return [json()];
if (lang === 'markdown') return [markdown()];
if (lang === 'yaml') return [yaml()];
return [];
};
return (
<div className="flex gap-6 h-[calc(100vh-250px)] min-h-[600px]">
{/* Sidebar Tree */}
<div className="glass-card w-1/3 p-4 flex flex-col border border-glass-border">
<div className="flex items-center justify-between mb-4 px-2">
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<span>📂</span> Arborescence
</h2>
<button onClick={loadTree} className="p-1.5 rounded-lg text-gray-400 hover:text-white hover:bg-surface-700 transition-colors" title="Actualiser">🔄</button>
</div>
<div className="flex-1 overflow-y-auto font-mono text-[13px] pr-2 custom-scrollbar">
{treeLoading ? (
<div className="text-gray-500 animate-pulse px-2">Chargement de l'arborescence…</div>
) : tree.length === 0 ? (
<p className="text-gray-500 px-2">Aucun fichier trouvé</p>
) : (
<div className="space-y-0.5">
{tree.map(node => (
<TreeNode key={node.path} node={node} depth={0} onSelect={handleSelectFile} />
))}
</div>
)}
</div>
</div>
{/* Editor Panel */}
<div className={isFullscreen
? "fixed inset-0 w-screen h-screen z-[9999] m-0 p-0 rounded-none glass-card flex flex-col overflow-hidden border border-glass-border bg-surface-900 shadow-[0_30px_60px_rgb(0,0,0,0.9)] min-h-0"
: "glass-card w-2/3 flex flex-col overflow-hidden border border-glass-border shadow-[0_8px_30px_rgb(0,0,0,0.5)] min-h-0"
}>
{fileLoading ? (
<div className="flex-1 flex items-center justify-center bg-surface-900/40">
<div className="text-gray-400 flex flex-col items-center gap-3">
<span className="text-3xl animate-spin-slow">⏳</span>
<span className="text-sm tracking-wide">Ouverture du fichier…</span>
</div>
</div>
) : !selectedFile ? (
<div className="flex-1 flex items-center justify-center text-gray-500 flex-col gap-4 bg-surface-900/40">
<span className="text-5xl opacity-30 grayscale">📄</span>
<p className="tracking-wide">Sélectionnez un fichier pour le visualiser</p>
</div>
) : (
<div className="flex flex-col h-full">
{/* Editor Header */}
<div className={`p-4 bg-surface-800 border-b border-glass-border flex items-center justify-between z-10 shrink-0 ${isFullscreen ? 'h-16 px-6' : ''}`}>
<div className="min-w-0 pr-4">
<h3 className="text-white font-medium flex items-center gap-2 text-base truncate">
<span className="text-xl">📄</span> {selectedFile.path.split('/').pop()}
</h3>
<div className="flex items-center gap-3 mt-1 text-[11px] text-gray-400 font-mono">
<span className="truncate max-w-[200px] md:max-w-xs" title={selectedFile.path}>{selectedFile.path}</span>
<span className="opacity-50">•</span>
<span>{formatSize(selectedFile.size)}</span>
<span className="opacity-50">•</span>
<span className="uppercase text-purple-300 bg-purple-500/10 px-1.5 py-0.5 rounded">{selectedFile.language}</span>
</div>
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="px-3 py-1.5 rounded-lg bg-surface-700 text-gray-300 text-sm hover:bg-surface-600 transition-colors flex items-center justify-center font-bold"
title={isFullscreen ? "Réduire" : "Plein écran"}
>
{isFullscreen ? "🗗" : "⛶"}
</button>
{!selectedFile.binary && (
<>
{editMode ? (
<>
<button onClick={() => setEditMode(false)} disabled={saving} className="px-3.5 py-1.5 rounded-lg bg-surface-700 text-gray-300 text-sm hover:bg-surface-600 transition-colors">
Annuler
</button>
<button onClick={handleSave} disabled={saving} className={`px-4 py-1.5 rounded-lg bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-sm font-medium hover:from-emerald-500 hover:to-teal-500 transition-all shadow-[0_0_12px_rgba(16,185,129,0.3)] flex items-center gap-2 ${saving ? 'opacity-50 cursor-not-allowed scale-95' : 'hover:scale-105'}`}>
{saving ? 'Sauvegarde…' : '💾 Sauvegarder'}
</button>
</>
) : (
<button onClick={() => { setEditMode(true); setEditContent(selectedFile.pretty || selectedFile.content || ''); }} className="px-4 py-1.5 rounded-lg bg-gradient-to-r from-purple-600 to-indigo-600 text-white text-sm font-medium hover:from-purple-500 hover:to-indigo-500 transition-all shadow-[0_0_12px_rgba(147,51,234,0.3)] hover:scale-105 flex items-center gap-2">
<span>✏️</span> Modifier
</button>
)}
</>
)}
</div>
</div>
{/* Editor Body */}
<div className="flex-1 overflow-hidden relative bg-[#282c34]">
{selectedFile.binary ? (
<div className="absolute inset-0 flex items-center justify-center text-gray-500 bg-surface-900/60">
<div className="text-center">
<div className="text-5xl mb-3 opacity-30">📦</div>
<p className="tracking-wide">Fichier binaire. Aperçu non disponible.</p>
</div>
</div>
) : editMode ? (
<div className="h-full w-full absolute inset-0 [&_.cm-editor]:h-full [&_.cm-scroller]:h-full [&_.cm-scroller]:overflow-auto [&_.cm-scroller]:font-mono [&_.cm-scroller]:text-[13px] pb-10">
<CodeMirror
value={editContent}
height="100%"
theme={oneDark}
extensions={getExtensions()}
onChange={(val) => setEditContent(val)}
basicSetup={{
lineNumbers: true,
foldGutter: true,
highlightActiveLine: true,
tabSize: 2,
}}
/>
</div>
) : (
<div className="h-full overflow-y-auto w-full custom-scrollbar absolute inset-0">
<div className="p-4 min-h-full">
<pre className="text-[13px] text-[#abb2bf] font-mono whitespace-pre-wrap leading-relaxed">
{selectedFile.pretty || selectedFile.content}
</pre>
</div>
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}