feat: Implement comprehensive security enhancements and deployment guide for AmayoWeb

- Added a detailed deployment guide (DEPLOYMENT_GUIDE.md) for frontend and backend setup.
- Created an index documentation (INDEX.md) summarizing changes and available resources.
- Established Nginx security configuration (NGINX_SECURITY_CONFIG.md) to protect backend IP and enforce rate limiting.
- Developed a backend security guide (SECURITY_BACKEND_GUIDE.md) outlining security measures and best practices.
- Introduced middleware for security, including rate limiting, CORS, and Cloudflare validation.
- Updated frontend components and services to improve security and user experience.
- Implemented logging and monitoring strategies for better security oversight.
This commit is contained in:
Shni
2025-11-06 23:44:44 -06:00
parent b25885d87f
commit 781f4398a4
36 changed files with 7830 additions and 57 deletions

View File

@@ -17,6 +17,7 @@ dependencies = [
"reqwest 0.11.27",
"serde",
"serde_json",
"sha2",
"tauri",
"tauri-build",
"tauri-plugin-dialog",

View File

@@ -28,4 +28,5 @@ regex = "1"
reqwest = { version = "0.11", features = ["json", "blocking"] }
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4"] }
sha2 = "0.10"

View File

@@ -0,0 +1,72 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub id: String,
#[serde(rename = "type")]
pub entry_type: String,
pub action: String,
pub file: String,
pub timestamp: u64,
pub lines: Option<usize>,
pub details: Option<String>,
pub user: Option<String>,
pub diff: Option<String>,
}
pub struct ActivityLog {
log_file: PathBuf,
entries: Vec<LogEntry>,
}
impl ActivityLog {
pub fn new(app_dir: &Path) -> Result<Self, String> {
let log_file = app_dir.join("activity_log.json");
let entries = if log_file.exists() {
let content = fs::read_to_string(&log_file)
.map_err(|e| format!("Failed to read log file: {}", e))?;
serde_json::from_str(&content)
.unwrap_or_else(|_| Vec::new())
} else {
Vec::new()
};
Ok(ActivityLog { log_file, entries })
}
pub fn add_entry(&mut self, entry: LogEntry) -> Result<(), String> {
self.entries.insert(0, entry);
// Limitar a 1000 entradas
if self.entries.len() > 1000 {
self.entries.truncate(1000);
}
self.save()?;
Ok(())
}
pub fn get_entries(&self) -> &Vec<LogEntry> {
&self.entries
}
pub fn clear(&mut self) -> Result<(), String> {
self.entries.clear();
self.save()?;
Ok(())
}
fn save(&self) -> Result<(), String> {
let json = serde_json::to_string_pretty(&self.entries)
.map_err(|e| format!("Failed to serialize log: {}", e))?;
fs::write(&self.log_file, json)
.map_err(|e| format!("Failed to write log file: {}", e))?;
Ok(())
}
}

View File

@@ -0,0 +1,208 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use sha2::{Sha256, Digest};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupFile {
pub path: String,
pub content: String,
pub hash: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Backup {
pub id: String,
pub name: Option<String>,
pub description: Option<String>,
pub timestamp: u64,
#[serde(rename = "type")]
pub backup_type: String,
pub file_count: usize,
pub size: usize,
pub files: Vec<BackupFile>,
}
pub struct BackupManager {
backups_dir: PathBuf,
backups: HashMap<String, Backup>,
}
impl BackupManager {
pub fn new(app_dir: &Path) -> Result<Self, String> {
let backups_dir = app_dir.join("backups");
fs::create_dir_all(&backups_dir)
.map_err(|e| format!("Failed to create backups directory: {}", e))?;
let mut manager = BackupManager {
backups_dir,
backups: HashMap::new(),
};
manager.load_backups()?;
Ok(manager)
}
fn load_backups(&mut self) -> Result<(), String> {
let entries = fs::read_dir(&self.backups_dir)
.map_err(|e| format!("Failed to read backups directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("json") {
let content = fs::read_to_string(&path)
.map_err(|e| format!("Failed to read backup file: {}", e))?;
let backup: Backup = serde_json::from_str(&content)
.map_err(|e| format!("Failed to parse backup: {}", e))?;
self.backups.insert(backup.id.clone(), backup);
}
}
Ok(())
}
pub fn create_backup(
&mut self,
project_path: &Path,
name: Option<String>,
description: Option<String>,
backup_type: &str,
) -> Result<Backup, String> {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
let id = format!("backup_{}", timestamp);
let mut files = Vec::new();
let mut total_size = 0;
// Recopilar archivos del proyecto
self.collect_files(project_path, &mut files, &mut total_size)?;
let backup = Backup {
id: id.clone(),
name,
description,
timestamp,
backup_type: backup_type.to_string(),
file_count: files.len(),
size: total_size,
files,
};
// Guardar backup en disco
let backup_path = self.backups_dir.join(format!("{}.json", id));
let backup_json = serde_json::to_string_pretty(&backup)
.map_err(|e| format!("Failed to serialize backup: {}", e))?;
fs::write(&backup_path, backup_json)
.map_err(|e| format!("Failed to write backup file: {}", e))?;
self.backups.insert(id.clone(), backup.clone());
Ok(backup)
}
fn collect_files(
&self,
dir: &Path,
files: &mut Vec<BackupFile>,
total_size: &mut usize,
) -> Result<(), String> {
let entries = fs::read_dir(dir)
.map_err(|e| format!("Failed to read directory: {}", e))?;
for entry in entries {
let entry = entry.map_err(|e| format!("Failed to read entry: {}", e))?;
let path = entry.path();
// Ignorar directorios comunes
let file_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
if file_name.starts_with('.') ||
file_name == "node_modules" ||
file_name == "target" ||
file_name == "dist" ||
file_name == "build" {
continue;
}
if path.is_file() {
if let Ok(content) = fs::read_to_string(&path) {
let mut hasher = Sha256::new();
hasher.update(&content);
let hash = format!("{:x}", hasher.finalize());
*total_size += content.len();
files.push(BackupFile {
path: path.display().to_string(),
content,
hash,
});
}
} else if path.is_dir() {
self.collect_files(&path, files, total_size)?;
}
}
Ok(())
}
pub fn restore_backup(&self, backup_id: &str) -> Result<(), String> {
let backup = self.backups
.get(backup_id)
.ok_or_else(|| format!("Backup not found: {}", backup_id))?;
for file in &backup.files {
let file_path = Path::new(&file.path);
// Crear directorio padre si no existe
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create directory: {}", e))?;
}
fs::write(file_path, &file.content)
.map_err(|e| format!("Failed to restore file: {}", e))?;
}
Ok(())
}
pub fn delete_backup(&mut self, backup_id: &str) -> Result<(), String> {
self.backups.remove(backup_id)
.ok_or_else(|| format!("Backup not found: {}", backup_id))?;
let backup_path = self.backups_dir.join(format!("{}.json", backup_id));
fs::remove_file(&backup_path)
.map_err(|e| format!("Failed to delete backup file: {}", e))?;
Ok(())
}
pub fn get_backups(&self) -> Vec<Backup> {
self.backups.values().cloned().collect()
}
pub fn compare_backup(&self, backup_id: &str, current_path: &Path) -> Result<(String, String), String> {
let backup = self.backups
.get(backup_id)
.ok_or_else(|| format!("Backup not found: {}", backup_id))?;
// Obtener primer archivo del backup para comparar
let backup_file = backup.files.first()
.ok_or_else(|| "Backup has no files".to_string())?;
let current_content = fs::read_to_string(current_path)
.unwrap_or_else(|_| String::from("File not found"));
Ok((current_content, backup_file.content.clone()))
}
}

View File

@@ -0,0 +1,142 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticError {
pub id: String,
pub severity: String,
pub message: String,
pub file: String,
pub line: usize,
pub column: usize,
pub code: Option<String>,
pub suggestion: Option<String>,
pub fixable: Option<bool>,
pub source: Option<String>,
}
pub struct DiagnosticsManager {
errors: Vec<DiagnosticError>,
}
impl DiagnosticsManager {
pub fn new() -> Self {
DiagnosticsManager {
errors: Vec::new(),
}
}
pub fn add_error(&mut self, error: DiagnosticError) {
// Evitar duplicados
if !self.errors.iter().any(|e| {
e.file == error.file &&
e.line == error.line &&
e.column == error.column &&
e.message == error.message
}) {
self.errors.push(error);
}
}
pub fn clear_file_errors(&mut self, file_path: &str) {
self.errors.retain(|e| e.file != file_path);
}
pub fn get_errors(&self) -> &Vec<DiagnosticError> {
&self.errors
}
pub fn clear(&mut self) {
self.errors.clear();
}
// Análisis básico de errores comunes de JavaScript/TypeScript
pub fn analyze_file(&mut self, file_path: &str, content: &str) {
self.clear_file_errors(file_path);
let lines: Vec<&str> = content.lines().collect();
for (line_num, line) in lines.iter().enumerate() {
let line_number = line_num + 1;
// Detectar console.log (warning)
if line.contains("console.log") {
self.add_error(DiagnosticError {
id: format!("{}-{}-console", file_path, line_number),
severity: "warning".to_string(),
message: "Uso de console.log() detectado".to_string(),
file: file_path.to_string(),
line: line_number,
column: line.find("console.log").unwrap_or(0) + 1,
code: Some("no-console".to_string()),
suggestion: Some("Considera usar un logger apropiado".to_string()),
fixable: Some(true),
source: Some("aeditor".to_string()),
});
}
// Detectar var (warning)
if line.trim_start().starts_with("var ") {
self.add_error(DiagnosticError {
id: format!("{}-{}-var", file_path, line_number),
severity: "warning".to_string(),
message: "Uso de 'var' está desaconsejado".to_string(),
file: file_path.to_string(),
line: line_number,
column: line.find("var").unwrap_or(0) + 1,
code: Some("no-var".to_string()),
suggestion: Some("Usa 'const' o 'let' en su lugar".to_string()),
fixable: Some(true),
source: Some("aeditor".to_string()),
});
}
// Detectar == en lugar de === (warning)
if line.contains(" == ") && !line.contains("===") {
self.add_error(DiagnosticError {
id: format!("{}-{}-eqeq", file_path, line_number),
severity: "warning".to_string(),
message: "Usa '===' en lugar de '=='".to_string(),
file: file_path.to_string(),
line: line_number,
column: line.find(" == ").unwrap_or(0) + 1,
code: Some("eqeqeq".to_string()),
suggestion: Some("Usa '===' para comparación estricta".to_string()),
fixable: Some(true),
source: Some("aeditor".to_string()),
});
}
// Detectar funciones sin punto y coma (info)
if line.trim().ends_with(")") && !line.trim().ends_with(";") && !line.trim().ends_with("{") {
self.add_error(DiagnosticError {
id: format!("{}-{}-semi", file_path, line_number),
severity: "info".to_string(),
message: "Falta punto y coma".to_string(),
file: file_path.to_string(),
line: line_number,
column: line.len(),
code: Some("semi".to_string()),
suggestion: None,
fixable: Some(true),
source: Some("aeditor".to_string()),
});
}
// Detectar TODO/FIXME comments (info)
if line.contains("TODO") || line.contains("FIXME") {
self.add_error(DiagnosticError {
id: format!("{}-{}-todo", file_path, line_number),
severity: "info".to_string(),
message: "Comentario TODO pendiente".to_string(),
file: file_path.to_string(),
line: line_number,
column: line.find("TODO").or_else(|| line.find("FIXME")).unwrap_or(0) + 1,
code: Some("no-warning-comments".to_string()),
suggestion: None,
fixable: Some(false),
source: Some("aeditor".to_string()),
});
}
}
}
}

View File

@@ -5,8 +5,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use discord_rich_presence::{activity, DiscordIpc, DiscordIpcClient};
// Módulos nuevos
mod activity_log;
mod backup;
mod diagnostics;
use activity_log::{ActivityLog, LogEntry};
use backup::{Backup, BackupManager};
use diagnostics::{DiagnosticsManager, DiagnosticError};
// Cliente Discord RPC global
static DISCORD_CLIENT: Mutex<Option<DiscordIpcClient>> = Mutex::new(None);
static ACTIVITY_LOG: Mutex<Option<ActivityLog>> = Mutex::new(None);
static BACKUP_MANAGER: Mutex<Option<BackupManager>> = Mutex::new(None);
static DIAGNOSTICS: Mutex<Option<DiagnosticsManager>> = Mutex::new(None);
// Structs para Codeium API
#[derive(Debug, Serialize, Deserialize)]
@@ -1163,6 +1175,173 @@ fn load_gemini_config(app_data_dir: String) -> Result<String, String> {
Ok(content)
}
// ============================================
// ACTIVITY LOG COMMANDS
// ============================================
#[tauri::command]
fn save_activity_log(entry: LogEntry) -> Result<(), String> {
let mut log_lock = ACTIVITY_LOG.lock().unwrap();
if let Some(log) = log_lock.as_mut() {
log.add_entry(entry)?;
}
Ok(())
}
#[tauri::command]
fn get_activity_logs() -> Result<Vec<LogEntry>, String> {
let log_lock = ACTIVITY_LOG.lock().unwrap();
if let Some(log) = log_lock.as_ref() {
Ok(log.get_entries().clone())
} else {
Ok(Vec::new())
}
}
#[tauri::command]
fn clear_activity_log() -> Result<(), String> {
let mut log_lock = ACTIVITY_LOG.lock().unwrap();
if let Some(log) = log_lock.as_mut() {
log.clear()?;
}
Ok(())
}
// ============================================
// BACKUP COMMANDS
// ============================================
#[tauri::command]
fn create_backup(
name: Option<String>,
description: Option<String>,
backup_type: String,
) -> Result<Backup, String> {
let mut manager_lock = BACKUP_MANAGER.lock().unwrap();
if let Some(manager) = manager_lock.as_mut() {
// Obtener el proyecto actual (esto debería venir de un estado global)
let current_dir = std::env::current_dir().map_err(|e| e.to_string())?;
manager.create_backup(&current_dir, name, description, &backup_type)
} else {
Err("Backup manager no inicializado".to_string())
}
}
#[tauri::command]
fn get_backups() -> Result<Vec<Backup>, String> {
let manager_lock = BACKUP_MANAGER.lock().unwrap();
if let Some(manager) = manager_lock.as_ref() {
Ok(manager.get_backups())
} else {
Ok(Vec::new())
}
}
#[tauri::command]
fn restore_backup(backup_id: String) -> Result<(), String> {
let manager_lock = BACKUP_MANAGER.lock().unwrap();
if let Some(manager) = manager_lock.as_ref() {
manager.restore_backup(&backup_id)
} else {
Err("Backup manager no inicializado".to_string())
}
}
#[tauri::command]
fn delete_backup(backup_id: String) -> Result<(), String> {
let mut manager_lock = BACKUP_MANAGER.lock().unwrap();
if let Some(manager) = manager_lock.as_mut() {
manager.delete_backup(&backup_id)
} else {
Err("Backup manager no inicializado".to_string())
}
}
#[tauri::command]
fn compare_backup(backup_id: String) -> Result<(String, String), String> {
let manager_lock = BACKUP_MANAGER.lock().unwrap();
if let Some(manager) = manager_lock.as_ref() {
let current_dir = std::env::current_dir().map_err(|e| e.to_string())?;
manager.compare_backup(&backup_id, &current_dir)
} else {
Err("Backup manager no inicializado".to_string())
}
}
// ============================================
// DIAGNOSTICS COMMANDS
// ============================================
#[tauri::command]
fn get_diagnostics() -> Result<Vec<DiagnosticError>, String> {
let diagnostics_lock = DIAGNOSTICS.lock().unwrap();
if let Some(diagnostics) = diagnostics_lock.as_ref() {
Ok(diagnostics.get_errors().clone())
} else {
Ok(Vec::new())
}
}
#[tauri::command]
fn analyze_file_diagnostics(file_path: String, content: String) -> Result<(), String> {
let mut diagnostics_lock = DIAGNOSTICS.lock().unwrap();
if let Some(diagnostics) = diagnostics_lock.as_mut() {
diagnostics.analyze_file(&file_path, &content);
}
Ok(())
}
#[tauri::command]
fn clear_file_diagnostics(file_path: String) -> Result<(), String> {
let mut diagnostics_lock = DIAGNOSTICS.lock().unwrap();
if let Some(diagnostics) = diagnostics_lock.as_mut() {
diagnostics.clear_file_errors(&file_path);
}
Ok(())
}
#[tauri::command]
fn apply_quick_fix(error: DiagnosticError) -> Result<(), String> {
// Implementar lógica de quick fixes
println!("Aplicando fix para error: {:?}", error);
Ok(())
}
// Inicializar managers al inicio
#[tauri::command]
fn init_managers(app_data_dir: String) -> Result<(), String> {
let app_dir = Path::new(&app_data_dir);
// Inicializar Activity Log
let activity_log = ActivityLog::new(app_dir)?;
*ACTIVITY_LOG.lock().unwrap() = Some(activity_log);
// Inicializar Backup Manager
let backup_manager = BackupManager::new(app_dir)?;
*BACKUP_MANAGER.lock().unwrap() = Some(backup_manager);
// Inicializar Diagnostics
let diagnostics = DiagnosticsManager::new();
*DIAGNOSTICS.lock().unwrap() = Some(diagnostics);
Ok(())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
@@ -1196,7 +1375,21 @@ pub fn run() {
get_gemini_completion,
ask_gemini,
save_gemini_config,
load_gemini_config
load_gemini_config,
// Nuevos comandos
init_managers,
save_activity_log,
get_activity_logs,
clear_activity_log,
create_backup,
get_backups,
restore_backup,
delete_backup,
compare_backup,
get_diagnostics,
analyze_file_diagnostics,
clear_file_diagnostics,
apply_quick_fix
])
.run(tauri::generate_context!())
.expect("error while running tauri application");

View File

@@ -0,0 +1,483 @@
<template>
<div class="activity-log">
<div class="log-header">
<h2>📋 Registro de Actividad</h2>
<div class="log-actions">
<button @click="clearLog" class="btn-clear">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
Limpiar
</button>
<button @click="exportLog" class="btn-export">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3"/>
</svg>
Exportar
</button>
</div>
</div>
<div class="log-filters">
<button
v-for="type in actionTypes"
:key="type.id"
:class="['filter-btn', { active: activeFilter === type.id }]"
@click="activeFilter = type.id"
>
{{ type.icon }} {{ type.label }}
</button>
</div>
<div class="log-timeline">
<div
v-for="entry in filteredLogs"
:key="entry.id"
:class="['log-entry', `log-${entry.type}`]"
@click="showDetails(entry)"
>
<div class="log-icon">
<span>{{ getIcon(entry.type) }}</span>
</div>
<div class="log-content">
<div class="log-title">
<strong>{{ entry.action }}</strong>
<span class="log-file">{{ entry.file }}</span>
</div>
<div class="log-meta">
<span class="log-time">{{ formatTime(entry.timestamp) }}</span>
<span v-if="entry.lines" class="log-lines">{{ entry.lines }} líneas</span>
</div>
<div v-if="entry.details" class="log-details">
{{ entry.details }}
</div>
</div>
<button class="log-expand" @click.stop="toggleExpand(entry.id)">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 18l6-6-6-6"/>
</svg>
</button>
</div>
<div v-if="filteredLogs.length === 0" class="log-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<circle cx="12" cy="12" r="10"/>
<path d="M12 6v6l4 2"/>
</svg>
<p>No hay actividad registrada</p>
</div>
</div>
<!-- Modal de Detalles -->
<div v-if="selectedEntry" class="log-modal" @click.self="selectedEntry = null">
<div class="modal-content">
<div class="modal-header">
<h3>{{ selectedEntry.action }}</h3>
<button @click="selectedEntry = null" class="btn-close"></button>
</div>
<div class="modal-body">
<div class="detail-row">
<label>Archivo:</label>
<code>{{ selectedEntry.file }}</code>
</div>
<div class="detail-row">
<label>Fecha:</label>
<span>{{ new Date(selectedEntry.timestamp).toLocaleString() }}</span>
</div>
<div v-if="selectedEntry.user" class="detail-row">
<label>Usuario:</label>
<span>{{ selectedEntry.user }}</span>
</div>
<div v-if="selectedEntry.diff" class="detail-row diff-view">
<label>Cambios:</label>
<pre>{{ selectedEntry.diff }}</pre>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
interface LogEntry {
id: string;
type: 'create' | 'edit' | 'delete' | 'save' | 'open';
action: string;
file: string;
timestamp: number;
lines?: number;
details?: string;
user?: string;
diff?: string;
}
const logs = ref<LogEntry[]>([]);
const activeFilter = ref('all');
const selectedEntry = ref<LogEntry | null>(null);
const expandedIds = ref<Set<string>>(new Set());
const actionTypes = [
{ id: 'all', label: 'Todos', icon: '📋' },
{ id: 'create', label: 'Crear', icon: '' },
{ id: 'edit', label: 'Editar', icon: '✏️' },
{ id: 'save', label: 'Guardar', icon: '💾' },
{ id: 'delete', label: 'Eliminar', icon: '🗑️' },
{ id: 'open', label: 'Abrir', icon: '📂' },
];
const filteredLogs = computed(() => {
if (activeFilter.value === 'all') return logs.value;
return logs.value.filter(log => log.type === activeFilter.value);
});
const getIcon = (type: string): string => {
const icons: Record<string, string> = {
create: '',
edit: '✏️',
save: '💾',
delete: '🗑️',
open: '📂'
};
return icons[type] || '📄';
};
const formatTime = (timestamp: number): string => {
const now = Date.now();
const diff = now - timestamp;
if (diff < 60000) return 'Hace un momento';
if (diff < 3600000) return `Hace ${Math.floor(diff / 60000)} min`;
if (diff < 86400000) return `Hace ${Math.floor(diff / 3600000)} h`;
return new Date(timestamp).toLocaleDateString();
};
const showDetails = (entry: LogEntry) => {
selectedEntry.value = entry;
};
const toggleExpand = (id: string) => {
if (expandedIds.value.has(id)) {
expandedIds.value.delete(id);
} else {
expandedIds.value.add(id);
}
};
const clearLog = async () => {
if (confirm('¿Estás seguro de que quieres limpiar el registro?')) {
logs.value = [];
await invoke('clear_activity_log');
}
};
const exportLog = async () => {
const data = JSON.stringify(logs.value, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `activity-log-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
};
// Función para añadir nueva entrada (llamada desde otros componentes)
const addLogEntry = (entry: Omit<LogEntry, 'id' | 'timestamp'>) => {
const newEntry: LogEntry = {
...entry,
id: Date.now().toString(),
timestamp: Date.now()
};
logs.value.unshift(newEntry);
// Guardar en Tauri backend
invoke('save_activity_log', { entry: newEntry });
};
// Cargar logs existentes
onMounted(async () => {
try {
const savedLogs = await invoke<LogEntry[]>('get_activity_logs');
logs.value = savedLogs;
} catch (error) {
console.error('Error cargando logs:', error);
}
});
// Exponer función para que otros componentes puedan añadir logs
defineExpose({ addLogEntry });
</script>
<style scoped>
.activity-log {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
color: #d4d4d4;
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
}
.log-header h2 {
font-size: 1.2rem;
margin: 0;
}
.log-actions {
display: flex;
gap: 8px;
}
.btn-clear, .btn-export {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 4px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-clear:hover {
background: #d32f2f;
border-color: #d32f2f;
}
.btn-export:hover {
background: #1976d2;
border-color: #1976d2;
}
.log-filters {
display: flex;
gap: 8px;
padding: 12px 20px;
border-bottom: 1px solid #333;
overflow-x: auto;
}
.filter-btn {
padding: 6px 14px;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 16px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
transition: all 0.2s;
}
.filter-btn:hover {
background: #3d3d3d;
}
.filter-btn.active {
background: #007acc;
border-color: #007acc;
color: white;
}
.log-timeline {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.log-entry {
display: flex;
gap: 12px;
padding: 12px;
margin-bottom: 8px;
background: #252525;
border: 1px solid #333;
border-left: 3px solid #007acc;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.log-entry:hover {
background: #2d2d2d;
border-left-width: 4px;
}
.log-entry.log-create { border-left-color: #4caf50; }
.log-entry.log-edit { border-left-color: #ff9800; }
.log-entry.log-delete { border-left-color: #f44336; }
.log-entry.log-save { border-left-color: #2196f3; }
.log-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.log-content {
flex: 1;
min-width: 0;
}
.log-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.log-title strong {
color: #fff;
}
.log-file {
color: #858585;
font-size: 0.85rem;
font-family: 'Consolas', monospace;
}
.log-meta {
display: flex;
gap: 12px;
font-size: 0.8rem;
color: #858585;
}
.log-details {
margin-top: 8px;
padding: 8px;
background: #1e1e1e;
border-radius: 4px;
font-size: 0.85rem;
color: #a0a0a0;
}
.log-expand {
background: none;
border: none;
color: #858585;
cursor: pointer;
padding: 4px;
border-radius: 4px;
transition: all 0.2s;
}
.log-expand:hover {
background: #3d3d3d;
color: #fff;
}
.log-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #858585;
gap: 16px;
}
/* Modal */
.log-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #252525;
border: 1px solid #444;
border-radius: 8px;
width: 90%;
max-width: 600px;
max-height: 80vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
color: #fff;
}
.btn-close {
background: none;
border: none;
color: #858585;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-close:hover {
background: #3d3d3d;
color: #fff;
}
.modal-body {
padding: 20px;
overflow-y: auto;
}
.detail-row {
margin-bottom: 16px;
}
.detail-row label {
display: block;
color: #858585;
font-size: 0.85rem;
margin-bottom: 4px;
}
.detail-row code {
background: #1e1e1e;
padding: 8px 12px;
border-radius: 4px;
display: inline-block;
color: #4ec9b0;
font-family: 'Consolas', monospace;
}
.diff-view pre {
background: #1e1e1e;
padding: 12px;
border-radius: 4px;
overflow-x: auto;
font-family: 'Consolas', monospace;
font-size: 0.85rem;
line-height: 1.5;
}
</style>

View File

@@ -0,0 +1,666 @@
<template>
<div class="backup-manager">
<div class="manager-header">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" y1="15" x2="12" y2="3"/>
</svg>
Respaldos
</h2>
<div class="manager-actions">
<button @click="createBackup" class="btn-backup">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21H5a2 2 0 01-2-2V5a2 2 0 012-2h11l5 5v11a2 2 0 01-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
Crear Respaldo
</button>
<button @click="autoBackupEnabled = !autoBackupEnabled" :class="['btn-auto', { active: autoBackupEnabled }]">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0118.8-4.3M22 12.5a10 10 0 01-18.8 4.2"/>
</svg>
Auto-guardar: {{ autoBackupEnabled ? 'ON' : 'OFF' }}
</button>
</div>
</div>
<div class="backup-config">
<div class="config-item">
<label>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
Intervalo de respaldo automático
</label>
<select v-model="backupInterval">
<option value="1">1 minuto</option>
<option value="5">5 minutos</option>
<option value="10">10 minutos</option>
<option value="30">30 minutos</option>
</select>
</div>
<div class="config-item">
<label>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/>
</svg>
Número máximo de respaldos
</label>
<input type="number" v-model="maxBackups" min="5" max="100" />
</div>
</div>
<div class="backup-list">
<div class="list-header">
<h3>Historial de Respaldos</h3>
<span class="backup-count">{{ backups.length }} respaldos</span>
</div>
<div
v-for="backup in sortedBackups"
:key="backup.id"
:class="['backup-item', { selected: selectedBackup?.id === backup.id }]"
@click="selectBackup(backup)"
>
<div class="backup-info">
<div class="backup-title">
<svg v-if="backup.type === 'manual'" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6"/>
</svg>
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0118.8-4.3M22 12.5a10 10 0 01-18.8 4.2"/>
</svg>
<strong>{{ backup.name || formatDate(backup.timestamp) }}</strong>
</div>
<div class="backup-meta">
<span class="backup-time">{{ formatRelativeTime(backup.timestamp) }}</span>
<span class="backup-files">{{ backup.fileCount }} archivos</span>
<span class="backup-size">{{ formatSize(backup.size) }}</span>
</div>
<div v-if="backup.description" class="backup-desc">
{{ backup.description }}
</div>
</div>
<div class="backup-actions">
<button @click.stop="restoreBackup(backup)" class="btn-restore" title="Restaurar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 102.13-9.36L1 10"/>
</svg>
</button>
<button @click.stop="compareBackup(backup)" class="btn-compare" title="Comparar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="16 18 22 12 16 6"/>
<polyline points="8 6 2 12 8 18"/>
</svg>
</button>
<button @click.stop="deleteBackup(backup)" class="btn-delete" title="Eliminar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>
</svg>
</button>
</div>
</div>
<div v-if="backups.length === 0" class="backup-empty">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" y1="3" x2="12" y2="15"/>
</svg>
<p>No hay respaldos disponibles</p>
<span>Crea tu primer respaldo para guardar el estado actual del proyecto</span>
</div>
</div>
<!-- Modal de Comparación -->
<div v-if="showCompareModal" class="compare-modal" @click.self="showCompareModal = false">
<div class="modal-content">
<div class="modal-header">
<h3>Comparar Cambios</h3>
<button @click="showCompareModal = false" class="btn-close"></button>
</div>
<div class="modal-body">
<div class="compare-view">
<div class="compare-pane">
<h4>Versión Actual</h4>
<pre>{{ currentContent }}</pre>
</div>
<div class="compare-pane">
<h4>Respaldo: {{ selectedBackup?.name }}</h4>
<pre>{{ backupContent }}</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue';
import { invoke } from '@tauri-apps/api/core';
interface Backup {
id: string;
name?: string;
description?: string;
timestamp: number;
type: 'manual' | 'auto';
fileCount: number;
size: number;
files: Array<{
path: string;
content: string;
hash: string;
}>;
}
const backups = ref<Backup[]>([]);
const selectedBackup = ref<Backup | null>(null);
const autoBackupEnabled = ref(true);
const backupInterval = ref('5');
const maxBackups = ref(20);
const showCompareModal = ref(false);
const currentContent = ref('');
const backupContent = ref('');
let autoBackupTimer: number | null = null;
const sortedBackups = computed(() => {
return [...backups.value].sort((a, b) => b.timestamp - a.timestamp);
});
const formatDate = (timestamp: number): string => {
return new Date(timestamp).toLocaleString('es-ES', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const formatRelativeTime = (timestamp: number): string => {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'Ahora mismo';
if (minutes < 60) return `Hace ${minutes} min`;
if (hours < 24) return `Hace ${hours} h`;
return `Hace ${days} días`;
};
const formatSize = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(1)} MB`;
};
const createBackup = async () => {
const name = prompt('Nombre del respaldo (opcional):');
const description = prompt('Descripción (opcional):');
try {
const backup = await invoke<Backup>('create_backup', {
name: name || undefined,
description: description || undefined,
type: 'manual'
});
backups.value.unshift(backup);
// Limitar cantidad de respaldos
if (backups.value.length > maxBackups.value) {
const toRemove = backups.value.slice(maxBackups.value);
for (const b of toRemove) {
await invoke('delete_backup', { backupId: b.id });
}
backups.value = backups.value.slice(0, maxBackups.value);
}
} catch (error) {
console.error('Error creando respaldo:', error);
alert('Error al crear respaldo');
}
};
const autoBackup = async () => {
if (!autoBackupEnabled.value) return;
try {
const backup = await invoke<Backup>('create_backup', {
type: 'auto',
name: `Auto-respaldo ${formatDate(Date.now())}`
});
backups.value.unshift(backup);
// Limpiar respaldos automáticos viejos
const autoBackups = backups.value.filter(b => b.type === 'auto');
if (autoBackups.length > maxBackups.value / 2) {
const toRemove = autoBackups.slice(Math.floor(maxBackups.value / 2));
for (const b of toRemove) {
await invoke('delete_backup', { backupId: b.id });
backups.value = backups.value.filter(backup => backup.id !== b.id);
}
}
} catch (error) {
console.error('Error en auto-respaldo:', error);
}
};
const restoreBackup = async (backup: Backup) => {
if (!confirm(`¿Restaurar el respaldo "${backup.name || formatDate(backup.timestamp)}"?\n\nEsto sobrescribirá el contenido actual de los archivos.`)) {
return;
}
try {
await invoke('restore_backup', { backupId: backup.id });
alert('Respaldo restaurado correctamente');
} catch (error) {
console.error('Error restaurando respaldo:', error);
alert('Error al restaurar respaldo');
}
};
const compareBackup = async (backup: Backup) => {
try {
const comparison = await invoke<{ current: string; backup: string }>('compare_backup', {
backupId: backup.id
});
currentContent.value = comparison.current;
backupContent.value = comparison.backup;
selectedBackup.value = backup;
showCompareModal.value = true;
} catch (error) {
console.error('Error comparando respaldo:', error);
}
};
const deleteBackup = async (backup: Backup) => {
if (!confirm(`¿Eliminar el respaldo "${backup.name || formatDate(backup.timestamp)}"?`)) {
return;
}
try {
await invoke('delete_backup', { backupId: backup.id });
backups.value = backups.value.filter(b => b.id !== backup.id);
if (selectedBackup.value?.id === backup.id) {
selectedBackup.value = null;
}
} catch (error) {
console.error('Error eliminando respaldo:', error);
}
};
const selectBackup = (backup: Backup) => {
selectedBackup.value = backup;
};
// Iniciar auto-respaldo
watch([autoBackupEnabled, backupInterval], ([enabled, interval]) => {
if (autoBackupTimer) {
clearInterval(autoBackupTimer);
autoBackupTimer = null;
}
if (enabled) {
autoBackupTimer = window.setInterval(autoBackup, parseInt(interval) * 60 * 1000);
}
});
onMounted(async () => {
try {
const savedBackups = await invoke<Backup[]>('get_backups');
backups.value = savedBackups;
} catch (error) {
console.error('Error cargando respaldos:', error);
}
// Iniciar auto-respaldo si está habilitado
if (autoBackupEnabled.value) {
autoBackupTimer = window.setInterval(autoBackup, parseInt(backupInterval.value) * 60 * 1000);
}
});
</script>
<style scoped>
.backup-manager {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
color: #d4d4d4;
}
.manager-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
}
.manager-header h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 1.2rem;
}
.manager-actions {
display: flex;
gap: 8px;
}
.btn-backup, .btn-auto {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 4px;
color: #d4d4d4;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.btn-backup:hover {
background: #007acc;
border-color: #007acc;
}
.btn-auto {
border-color: #666;
}
.btn-auto.active {
background: #4caf50;
border-color: #4caf50;
color: white;
}
.backup-config {
display: flex;
gap: 16px;
padding: 16px 20px;
background: #252525;
border-bottom: 1px solid #333;
}
.config-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 8px;
}
.config-item label {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.85rem;
color: #858585;
}
.config-item select,
.config-item input {
padding: 8px 12px;
background: #1e1e1e;
border: 1px solid #444;
border-radius: 4px;
color: #d4d4d4;
font-size: 0.9rem;
}
.backup-list {
flex: 1;
overflow-y: auto;
padding: 12px;
}
.list-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding: 0 8px;
}
.list-header h3 {
margin: 0;
font-size: 1rem;
color: #858585;
}
.backup-count {
font-size: 0.85rem;
color: #858585;
}
.backup-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 14px;
margin-bottom: 8px;
background: #252525;
border: 1px solid #333;
border-left: 3px solid #007acc;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.backup-item:hover {
background: #2d2d2d;
border-left-width: 4px;
}
.backup-item.selected {
background: #2d2d2d;
border-color: #007acc;
}
.backup-info {
flex: 1;
min-width: 0;
}
.backup-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.backup-title strong {
color: #fff;
font-size: 0.95rem;
}
.backup-meta {
display: flex;
gap: 12px;
font-size: 0.8rem;
color: #858585;
flex-wrap: wrap;
}
.backup-desc {
margin-top: 8px;
font-size: 0.85rem;
color: #a0a0a0;
font-style: italic;
}
.backup-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.btn-restore, .btn-compare, .btn-delete {
background: none;
border: 1px solid #444;
color: #858585;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-restore:hover {
background: #4caf50;
border-color: #4caf50;
color: white;
}
.btn-compare:hover {
background: #2196f3;
border-color: #2196f3;
color: white;
}
.btn-delete:hover {
background: #d32f2f;
border-color: #d32f2f;
color: white;
}
.backup-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #858585;
gap: 16px;
text-align: center;
padding: 40px 20px;
}
.backup-empty p {
margin: 0;
font-size: 1.1rem;
color: #d4d4d4;
}
.backup-empty span {
font-size: 0.9rem;
max-width: 400px;
}
/* Modal */
.compare-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal-content {
background: #1e1e1e;
border: 1px solid #444;
border-radius: 8px;
width: 95%;
max-width: 1200px;
max-height: 90vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid #333;
}
.modal-header h3 {
margin: 0;
color: #fff;
}
.btn-close {
background: none;
border: none;
color: #858585;
font-size: 1.5rem;
cursor: pointer;
padding: 0;
width: 32px;
height: 32px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-close:hover {
background: #3d3d3d;
color: #fff;
}
.modal-body {
flex: 1;
overflow: auto;
padding: 20px;
}
.compare-view {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
height: 100%;
}
.compare-pane {
display: flex;
flex-direction: column;
background: #252525;
border: 1px solid #333;
border-radius: 6px;
overflow: hidden;
}
.compare-pane h4 {
margin: 0;
padding: 12px 16px;
background: #2d2d2d;
border-bottom: 1px solid #333;
font-size: 0.9rem;
color: #d4d4d4;
}
.compare-pane pre {
flex: 1;
margin: 0;
padding: 16px;
overflow: auto;
font-family: 'Consolas', monospace;
font-size: 0.85rem;
line-height: 1.5;
color: #d4d4d4;
}
</style>

View File

@@ -0,0 +1,481 @@
<template>
<div class="error-panel">
<div class="panel-header">
<h2>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
Problemas
<span class="error-count" v-if="totalErrors > 0">{{ totalErrors }}</span>
</h2>
<div class="panel-actions">
<button @click="refreshErrors" class="btn-refresh" title="Refrescar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.5 2v6h-6M2.5 22v-6h6M2 11.5a10 10 0 0118.8-4.3M22 12.5a10 10 0 01-18.8 4.2"/>
</svg>
</button>
<button @click="clearErrors" class="btn-clear" title="Limpiar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
</div>
<div class="panel-tabs">
<button
v-for="tab in tabs"
:key="tab.id"
:class="['tab-btn', { active: activeTab === tab.id }]"
@click="activeTab = tab.id"
>
<span :class="`icon-${tab.id}`">{{ tab.icon }}</span>
{{ tab.label }}
<span v-if="tab.count > 0" class="tab-count">{{ tab.count }}</span>
</button>
</div>
<div class="error-list">
<div
v-for="error in filteredErrors"
:key="error.id"
:class="['error-item', `severity-${error.severity}`]"
@click="navigateToError(error)"
>
<div class="error-icon">
<svg v-if="error.severity === 'error'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<line x1="15" y1="9" x2="9" y2="15"/>
<line x1="9" y1="9" x2="15" y2="15"/>
</svg>
<svg v-else-if="error.severity === 'warning'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/>
<line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<path d="M12 16v-4M12 8h.01"/>
</svg>
</div>
<div class="error-content">
<div class="error-message">{{ error.message }}</div>
<div class="error-meta">
<span class="error-file">{{ error.file }}</span>
<span class="error-location">[{{ error.line }}:{{ error.column }}]</span>
<span v-if="error.code" class="error-code">{{ error.code }}</span>
</div>
<div v-if="error.suggestion" class="error-suggestion">
💡 {{ error.suggestion }}
</div>
</div>
<button class="error-action" @click.stop="quickFix(error)" v-if="error.fixable">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z"/>
</svg>
</button>
</div>
<div v-if="filteredErrors.length === 0" class="error-empty">
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1">
<path d="M22 11.08V12a10 10 0 11-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
<p>No hay problemas</p>
<span>Tu código está limpio </span>
</div>
</div>
<!-- Panel de Estadísticas -->
<div class="panel-stats" v-if="totalErrors > 0">
<div class="stat-item">
<span class="stat-label">Errores:</span>
<span class="stat-value error">{{ errorCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Advertencias:</span>
<span class="stat-value warning">{{ warningCount }}</span>
</div>
<div class="stat-item">
<span class="stat-label">Información:</span>
<span class="stat-value info">{{ infoCount }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import { invoke } from '@tauri-apps/api/core';
interface DiagnosticError {
id: string;
severity: 'error' | 'warning' | 'info';
message: string;
file: string;
line: number;
column: number;
code?: string;
suggestion?: string;
fixable?: boolean;
source?: string;
}
const errors = ref<DiagnosticError[]>([]);
const activeTab = ref('all');
const tabs = computed(() => [
{ id: 'all', label: 'Todos', icon: '📋', count: totalErrors.value },
{ id: 'error', label: 'Errores', icon: '❌', count: errorCount.value },
{ id: 'warning', label: 'Advertencias', icon: '⚠️', count: warningCount.value },
{ id: 'info', label: 'Información', icon: '', count: infoCount.value },
]);
const filteredErrors = computed(() => {
if (activeTab.value === 'all') return errors.value;
return errors.value.filter(e => e.severity === activeTab.value);
});
const totalErrors = computed(() => errors.value.length);
const errorCount = computed(() => errors.value.filter(e => e.severity === 'error').length);
const warningCount = computed(() => errors.value.filter(e => e.severity === 'warning').length);
const infoCount = computed(() => errors.value.filter(e => e.severity === 'info').length);
const emit = defineEmits<{
navigateToError: [error: DiagnosticError]
}>();
const navigateToError = (error: DiagnosticError) => {
emit('navigateToError', error);
};
const quickFix = async (error: DiagnosticError) => {
try {
await invoke('apply_quick_fix', { error });
// Remover error de la lista después de aplicar fix
errors.value = errors.value.filter(e => e.id !== error.id);
} catch (err) {
console.error('Error aplicando quick fix:', err);
}
};
const refreshErrors = async () => {
try {
const diagnostics = await invoke<DiagnosticError[]>('get_diagnostics');
errors.value = diagnostics;
} catch (err) {
console.error('Error obteniendo diagnósticos:', err);
}
};
const clearErrors = () => {
errors.value = [];
};
// Función para añadir errores desde Monaco Editor
const addError = (error: Omit<DiagnosticError, 'id'>) => {
const newError: DiagnosticError = {
...error,
id: `${error.file}-${error.line}-${error.column}-${Date.now()}`
};
// Evitar duplicados
const exists = errors.value.some(
e => e.file === newError.file &&
e.line === newError.line &&
e.column === newError.column &&
e.message === newError.message
);
if (!exists) {
errors.value.push(newError);
}
};
// Función para limpiar errores de un archivo específico
const clearFileErrors = (filePath: string) => {
errors.value = errors.value.filter(e => e.file !== filePath);
};
onMounted(async () => {
await refreshErrors();
// Actualizar cada 5 segundos
setInterval(refreshErrors, 5000);
});
defineExpose({ addError, clearFileErrors, refreshErrors });
</script>
<style scoped>
.error-panel {
display: flex;
flex-direction: column;
height: 100%;
background: #1e1e1e;
color: #d4d4d4;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #333;
}
.panel-header h2 {
display: flex;
align-items: center;
gap: 8px;
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.error-count {
background: #d32f2f;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 700;
}
.panel-actions {
display: flex;
gap: 4px;
}
.btn-refresh, .btn-clear {
background: none;
border: none;
color: #858585;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: all 0.2s;
}
.btn-refresh:hover, .btn-clear:hover {
background: #2d2d2d;
color: #fff;
}
.panel-tabs {
display: flex;
gap: 4px;
padding: 8px 12px;
border-bottom: 1px solid #333;
overflow-x: auto;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #858585;
cursor: pointer;
font-size: 0.85rem;
white-space: nowrap;
transition: all 0.2s;
}
.tab-btn:hover {
color: #d4d4d4;
background: #2d2d2d;
border-radius: 4px 4px 0 0;
}
.tab-btn.active {
color: #fff;
border-bottom-color: #007acc;
}
.tab-count {
background: #2d2d2d;
padding: 2px 6px;
border-radius: 10px;
font-size: 0.75rem;
font-weight: 600;
}
.error-list {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.error-item {
display: flex;
gap: 12px;
padding: 12px;
margin-bottom: 4px;
background: #252525;
border-left: 3px solid #d32f2f;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.error-item:hover {
background: #2d2d2d;
}
.error-item.severity-error {
border-left-color: #d32f2f;
}
.error-item.severity-error .error-icon {
color: #d32f2f;
}
.error-item.severity-warning {
border-left-color: #ff9800;
}
.error-item.severity-warning .error-icon {
color: #ff9800;
}
.error-item.severity-info {
border-left-color: #2196f3;
}
.error-item.severity-info .error-icon {
color: #2196f3;
}
.error-icon {
flex-shrink: 0;
}
.error-content {
flex: 1;
min-width: 0;
}
.error-message {
color: #fff;
margin-bottom: 6px;
line-height: 1.4;
}
.error-meta {
display: flex;
gap: 12px;
font-size: 0.8rem;
color: #858585;
flex-wrap: wrap;
}
.error-file {
font-family: 'Consolas', monospace;
}
.error-location {
color: #4ec9b0;
}
.error-code {
background: #2d2d2d;
padding: 2px 6px;
border-radius: 3px;
}
.error-suggestion {
margin-top: 8px;
padding: 8px;
background: #2d2d2d;
border-radius: 4px;
font-size: 0.85rem;
color: #dcdcaa;
}
.error-action {
background: none;
border: 1px solid #444;
color: #858585;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: all 0.2s;
flex-shrink: 0;
}
.error-action:hover {
background: #007acc;
border-color: #007acc;
color: #fff;
}
.error-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: #858585;
gap: 12px;
text-align: center;
}
.error-empty p {
margin: 0;
font-size: 1.1rem;
color: #d4d4d4;
}
.error-empty span {
font-size: 0.9rem;
}
.panel-stats {
display: flex;
gap: 20px;
padding: 12px 16px;
border-top: 1px solid #333;
background: #252525;
}
.stat-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
}
.stat-label {
color: #858585;
}
.stat-value {
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
}
.stat-value.error {
background: rgba(211, 47, 47, 0.2);
color: #d32f2f;
}
.stat-value.warning {
background: rgba(255, 152, 0, 0.2);
color: #ff9800;
}
.stat-value.info {
background: rgba(33, 150, 243, 0.2);
color: #2196f3;
}
</style>