feat: implementa persistência de sessão com opções para salvar e carregar estado do projeto
This commit is contained in:
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
# Progresso de Implementação
|
# Progresso de Implementação
|
||||||
|
|
||||||
**Data:** 28/02/2026
|
**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
|
**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 |
|
| 4 | Adapters (FFmpeg + filesystem) | ✅ Concluída |
|
||||||
| 5 | Infrastructure (processo async) | ✅ Concluída |
|
| 5 | Infrastructure (processo async) | ✅ Concluída |
|
||||||
| 6 | UI (eframe/egui) | ✅ 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] 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<String>`; `start_generation` cria tokio Runtime + thread encaminhadora; cada linha de stderr do FFmpeg aparece no log antes do término
|
- [x] Progresso em tempo real via `run_ffmpeg_async` — `run_ffmpeg_async` usa `std::sync::mpsc::Sender<String>`; `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
|
- [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<BatchItem>` em `App`; processamento sequencial (um por vez); novo componente `BatchPanel` — ver DEVELOPMENT_PLAN.md Fase 8
|
- [ ] **Fase 8 — Modo Lote via Abas** — navegação por abas (`Projeto Único` / `Lote`); `Vec<BatchItem>` em `App`; processamento sequencial (um por vez); novo componente `BatchPanel` — ver DEVELOPMENT_PLAN.md Fase 8
|
||||||
|
|
||||||
### Qualidade
|
### Qualidade
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod persistence;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
|
|||||||
@@ -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<Project> {
|
||||||
|
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::<Project>(&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."
|
||||||
|
})
|
||||||
|
}
|
||||||
+106
-7
@@ -50,6 +50,9 @@ pub struct App {
|
|||||||
|
|
||||||
// Mensagem de erro global (ex: dependências ausentes)
|
// Mensagem de erro global (ex: dependências ausentes)
|
||||||
global_error: Option<String>,
|
global_error: Option<String>,
|
||||||
|
|
||||||
|
// Mensagem de feedback de persistência (sucesso ou erro)
|
||||||
|
session_msg: Option<(String, bool)>, // (texto, é_erro)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -59,6 +62,17 @@ impl App {
|
|||||||
.err()
|
.err()
|
||||||
.map(|e| e.to_string());
|
.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 {
|
App {
|
||||||
project: None,
|
project: None,
|
||||||
pending_source: None,
|
pending_source: None,
|
||||||
@@ -73,6 +87,7 @@ impl App {
|
|||||||
bg_rx: None,
|
bg_rx: None,
|
||||||
cancel_tx: None,
|
cancel_tx: None,
|
||||||
global_error,
|
global_error,
|
||||||
|
session_msg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,22 +222,106 @@ impl eframe::App for App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Cabeçalho (fixo no topo) ──────────────────────────────────────────
|
// ── 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")
|
egui::TopBottomPanel::top("header_panel")
|
||||||
.exact_height(if self.global_error.is_some() {
|
.exact_height(header_height)
|
||||||
58.0
|
|
||||||
} else {
|
|
||||||
38.0
|
|
||||||
})
|
|
||||||
.show(ctx, |ui| {
|
.show(ctx, |ui| {
|
||||||
ui.add_space(6.0);
|
ui.add_space(4.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.heading("Editor de Faixas de Mídia");
|
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 {
|
if let Some(ref err) = self.global_error {
|
||||||
ui.add_space(2.0);
|
|
||||||
ui.colored_label(
|
ui.colored_label(
|
||||||
egui::Color32::RED,
|
egui::Color32::RED,
|
||||||
format!("⚠ Dependência ausente: {}", err),
|
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é) ───────────────────────────────
|
// ── Painel de execução (fixo no rodapé) ───────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user