From 5d8e92f33f1830903c952c8ce584eb0ff9de1a1d Mon Sep 17 00:00:00 2001 From: Felipe Canin Novaes Date: Sat, 28 Feb 2026 19:44:45 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20implementa=20persist=C3=AAncia=20de=20s?= =?UTF-8?q?ess=C3=A3o=20com=20op=C3=A7=C3=B5es=20para=20salvar=20e=20carre?= =?UTF-8?q?gar=20estado=20do=20projeto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROGRESS.md | 5 +- src/infrastructure/mod.rs | 1 + src/infrastructure/persistence/mod.rs | 70 ++++++++++++++++ src/ui/app.rs | 115 ++++++++++++++++++++++++-- 4 files changed, 181 insertions(+), 10 deletions(-) create mode 100644 src/infrastructure/persistence/mod.rs diff --git a/PROGRESS.md b/PROGRESS.md index 600e201..4eb4d7c 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -1,7 +1,7 @@ # Progresso de Implementação **Data:** 28/02/2026 -**Status:** Fases 1–6 concluídas + exportação de faixa + exibição/remoção de faixas adicionadas + layout responsivo + progresso em tempo real + cancelamento de geração — compilando, 33 testes passando, zero warnings do projeto, aplicação executável +**Status:** Fases 1–7 concluídas — compilando, 33 testes passando, zero warnings do projeto, aplicação executável **Referência:** DEVELOPMENT_PLAN.md v1.3 --- @@ -33,6 +33,7 @@ Após a conclusão das fases, foram implementadas funcionalidades adicionais: | 4 | Adapters (FFmpeg + filesystem) | ✅ Concluída | | 5 | Infrastructure (processo async) | ✅ Concluída | | 6 | UI (eframe/egui) | ✅ Concluída | +| 7 | Persistência do estado | ✅ Concluída | --- @@ -207,7 +208,7 @@ rfd = "0.14" - [x] Exibição de faixas externas adicionadas com opção de remover - [x] Progresso em tempo real via `run_ffmpeg_async` — `run_ffmpeg_async` usa `std::sync::mpsc::Sender`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término - [x] Cancelamento de geração — botão "Cancelar" visível durante `Running`; sinal via `tokio::sync::oneshot`; `child.kill().await` no `tokio::select!`; estado `ExecutionState::Cancelled` exibido em amarelo -- [ ] **Fase 7 — Persistência do Estado** — salvar/carregar `Project` em JSON (`~/.config/...`); botões "Salvar sessão" / "Carregar sessão" no header; novo módulo `infrastructure/persistence` — ver DEVELOPMENT_PLAN.md Fase 7 +- [x] **Fase 7 — Persistência do Estado** — novo módulo `infrastructure/persistence` com `save_session`/`load_session`/`session_exists`; JSON em `~/.config/simple-mkv-editor/session.json`; botões "💾 Salvar sessão" / "📂 Carregar sessão" no header com feedback visual (verde/vermelho + botão ✕) - [ ] **Fase 8 — Modo Lote via Abas** — navegação por abas (`Projeto Único` / `Lote`); `Vec` em `App`; processamento sequencial (um por vez); novo componente `BatchPanel` — ver DEVELOPMENT_PLAN.md Fase 8 ### Qualidade diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index 80fe812..8f37d7f 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -1 +1,2 @@ +pub mod persistence; pub mod process; diff --git a/src/infrastructure/persistence/mod.rs b/src/infrastructure/persistence/mod.rs new file mode 100644 index 0000000..d4f3181 --- /dev/null +++ b/src/infrastructure/persistence/mod.rs @@ -0,0 +1,70 @@ +use crate::domain::entities::Project; +use anyhow::{Context, Result}; +use std::path::PathBuf; + +/// Retorna o caminho do arquivo de sessão de acordo com o SO. +/// +/// - Linux / macOS : `$HOME/.config/simple-mkv-editor/session.json` +/// - Windows : `%APPDATA%\simple-mkv-editor\session.json` +fn session_path() -> PathBuf { + #[cfg(target_os = "windows")] + let base = std::env::var("APPDATA") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(".")); + + #[cfg(not(target_os = "windows"))] + let base = std::env::var("HOME") + .map(|h| PathBuf::from(h).join(".config")) + .unwrap_or_else(|_| PathBuf::from(".")); + + base.join("simple-mkv-editor").join("session.json") +} + +/// Verifica se existe um arquivo de sessão gravado em disco. +pub fn session_exists() -> bool { + session_path().exists() +} + +/// Serializa o `Project` para JSON e grava no arquivo de sessão. +/// +/// Cria o diretório pai automaticamente se ele não existir. +pub fn save_session(project: &Project) -> Result<()> { + let path = session_path(); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).with_context(|| { + format!( + "Não foi possível criar o diretório de sessão: {}", + parent.display() + ) + })?; + } + + let json = + serde_json::to_string_pretty(project).context("Falha ao serializar o estado do projeto")?; + + std::fs::write(&path, json) + .with_context(|| format!("Falha ao gravar sessão em: {}", path.display()))?; + + Ok(()) +} + +/// Lê e desserializa o `Project` do arquivo de sessão. +/// +/// Retorna erro amigável se o arquivo estiver ausente, corrompido ou em +/// versão incompatível com a estrutura atual do domínio. +pub fn load_session() -> Result { + let path = session_path(); + + let json = std::fs::read_to_string(&path).with_context(|| { + format!( + "Não foi possível ler o arquivo de sessão: {}", + path.display() + ) + })?; + + serde_json::from_str::(&json).with_context(|| { + "Falha ao carregar a sessão. O arquivo pode estar corrompido ou em versão incompatível. \ + Delete o arquivo de sessão e tente novamente." + }) +} diff --git a/src/ui/app.rs b/src/ui/app.rs index f914fde..d80db1f 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -50,6 +50,9 @@ pub struct App { // Mensagem de erro global (ex: dependências ausentes) global_error: Option, + + // Mensagem de feedback de persistência (sucesso ou erro) + session_msg: Option<(String, bool)>, // (texto, é_erro) } impl App { @@ -59,6 +62,17 @@ impl App { .err() .map(|e| e.to_string()); + // Notifica o usuário se existe sessão salva ao iniciar + let session_msg = if crate::infrastructure::persistence::session_exists() { + Some(( + "Sessão anterior encontrada. Clique em \"Carregar sessão\" para restaurar." + .to_string(), + false, + )) + } else { + None + }; + App { project: None, pending_source: None, @@ -73,6 +87,7 @@ impl App { bg_rx: None, cancel_tx: None, global_error, + session_msg, } } @@ -207,22 +222,106 @@ impl eframe::App for App { } // ── Cabeçalho (fixo no topo) ────────────────────────────────────────── + let header_height = 42.0 + + if self.global_error.is_some() { 22.0 } else { 0.0 } + + if self.session_msg.is_some() { 22.0 } else { 0.0 }; egui::TopBottomPanel::top("header_panel") - .exact_height(if self.global_error.is_some() { - 58.0 - } else { - 38.0 - }) + .exact_height(header_height) .show(ctx, |ui| { - ui.add_space(6.0); - ui.heading("Editor de Faixas de Mídia"); + ui.add_space(4.0); + ui.horizontal(|ui| { + ui.heading("Editor de Faixas de Mídia"); + + ui.with_layout( + egui::Layout::right_to_left(egui::Align::Center), + |ui| { + // Botão "Salvar sessão" + let can_save = self.project.is_some(); + if ui + .add_enabled( + can_save, + egui::Button::new("💾 Salvar sessão"), + ) + .on_disabled_hover_text( + "Abra um projeto para poder salvar a sessão.", + ) + .clicked() + { + if let Some(ref project) = self.project { + match crate::infrastructure::persistence::save_session( + project, + ) { + Ok(_) => { + self.session_msg = Some(( + "Sessão salva com sucesso.".to_string(), + false, + )); + } + Err(e) => { + self.session_msg = + Some((format!("Erro ao salvar: {}", e), true)); + } + } + } + } + + // Botão "Carregar sessão" + let can_load = + crate::infrastructure::persistence::session_exists(); + if ui + .add_enabled( + can_load, + egui::Button::new("📂 Carregar sessão"), + ) + .on_disabled_hover_text("Nenhuma sessão salva encontrada.") + .clicked() + { + match crate::infrastructure::persistence::load_session() { + Ok(project) => { + self.pending_source = + Some(project.source.path.clone()); + self.pending_output = + Some(project.output.path.clone()); + self.existing_track_list + .sync_tracks(&project.existing_tracks); + self.project = Some(project); + self.session_msg = Some(( + "Sessão carregada com sucesso.".to_string(), + false, + )); + } + Err(e) => { + self.session_msg = Some(( + format!("Erro ao carregar sessão: {}", e), + true, + )); + } + } + } + }, + ); + }); + if let Some(ref err) = self.global_error { - ui.add_space(2.0); ui.colored_label( egui::Color32::RED, format!("⚠ Dependência ausente: {}", err), ); } + + if let Some((msg, is_err)) = self.session_msg.clone() { + let color = if is_err { + egui::Color32::RED + } else { + egui::Color32::from_rgb(0, 180, 80) + }; + ui.horizontal(|ui| { + ui.colored_label(color, &msg); + if ui.small_button("✕").clicked() { + self.session_msg = None; + } + }); + } }); // ── Painel de execução (fixo no rodapé) ───────────────────────────────