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:
1
AEditor/src-tauri/Cargo.lock
generated
1
AEditor/src-tauri/Cargo.lock
generated
@@ -17,6 +17,7 @@ dependencies = [
|
||||
"reqwest 0.11.27",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
72
AEditor/src-tauri/src/activity_log.rs
Normal file
72
AEditor/src-tauri/src/activity_log.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
208
AEditor/src-tauri/src/backup.rs
Normal file
208
AEditor/src-tauri/src/backup.rs
Normal 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()))
|
||||
}
|
||||
}
|
||||
142
AEditor/src-tauri/src/diagnostics.rs
Normal file
142
AEditor/src-tauri/src/diagnostics.rs
Normal 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()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t_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, ¤t_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");
|
||||
|
||||
483
AEditor/src/components/ActivityLog.vue
Normal file
483
AEditor/src/components/ActivityLog.vue
Normal 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>
|
||||
666
AEditor/src/components/BackupManager.vue
Normal file
666
AEditor/src/components/BackupManager.vue
Normal 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>
|
||||
481
AEditor/src/components/ErrorPanel.vue
Normal file
481
AEditor/src/components/ErrorPanel.vue
Normal 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>
|
||||
Reference in New Issue
Block a user