feat(windows): persistance et restauration des fenêtres au redémarrage (#40)

Persiste l'état des fenêtres (layout/position) et le restaure au
relancement de l'application. Découpage hexagonal :
- domaine : modèle et port d'état des fenêtres (layout, ports)
- application : use cases de persistance/restauration
- infrastructure : store window_state (adapter de persistance)
- présentation : câblage app-tauri (state, commands, lib)
Couvert par des tests ciblés domaine/application/infrastructure.

Depend de #39 (fermeture des fenêtres auxiliaires).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 08:00:20 +02:00
parent 55087b5d9b
commit 2eb51d3335
15 changed files with 887 additions and 40 deletions

View File

@ -162,7 +162,10 @@ pub use ticket_assistant::{
CloseTicketAssistant, CloseTicketAssistantInput, OpenTicketAssistant, OpenTicketAssistantInput,
OpenTicketAssistantOutput,
};
pub use window::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
pub use window::{
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};
pub use workstate::{
AgentBackgroundTaskState, AgentTicketState, AgentWorkState, AttachLiveAgent,
AttachLiveAgentInput, AttachLiveAgentOutput, BackgroundTaskKindLabel, ConversationLogProvider,

View File

@ -3,4 +3,7 @@
mod usecases;
pub use usecases::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
pub use usecases::{
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
};

View File

@ -7,8 +7,10 @@
use std::sync::Arc;
use domain::ids::{TabId, WindowId};
use domain::layout::Workspace;
use domain::ports::{IdGenerator, ProjectStore};
use std::collections::HashSet;
use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace};
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore};
use crate::error::AppError;
@ -71,3 +73,94 @@ impl MoveTabToNewWindow {
})
}
}
/// Input for [`SnapshotOpenWindows::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct SnapshotOpenWindowsInput {
/// Open windows captured by the presentation adapter.
pub windows: Vec<PersistedWindowState>,
}
/// Persists the latest open OS window snapshot.
pub struct SnapshotOpenWindows {
store: Arc<dyn WindowStateStore>,
}
impl SnapshotOpenWindows {
/// Builds the use case from its store port.
#[must_use]
pub fn new(store: Arc<dyn WindowStateStore>) -> Self {
Self { store }
}
/// Saves a versioned window snapshot.
///
/// # Errors
/// [`AppError::Store`] on persistence failure.
pub async fn execute(&self, input: SnapshotOpenWindowsInput) -> Result<(), AppError> {
self.store
.save_window_state(&WindowStateSnapshot::new(input.windows))
.await?;
Ok(())
}
}
/// Output of [`RestoreOpenWindows::execute`].
#[derive(Debug, Clone, PartialEq)]
pub struct RestoreOpenWindowsOutput {
/// Restorable windows, deduplicated by stable label.
pub windows: Vec<PersistedWindowState>,
}
/// Loads and filters the latest persisted OS window snapshot.
pub struct RestoreOpenWindows {
windows: Arc<dyn WindowStateStore>,
projects: Arc<dyn ProjectStore>,
}
impl RestoreOpenWindows {
/// Builds the use case from its ports.
#[must_use]
pub fn new(windows: Arc<dyn WindowStateStore>, projects: Arc<dyn ProjectStore>) -> Self {
Self { windows, projects }
}
/// Loads restorable windows.
///
/// Views are ignored if their project no longer exists or if their persisted
/// identity is incomplete. Duplicate labels are ignored after the first
/// occurrence.
///
/// # Errors
/// [`AppError::Store`] on snapshot read failure.
pub async fn execute(&self) -> Result<RestoreOpenWindowsOutput, AppError> {
let snapshot = self.windows.load_window_state().await?;
let mut seen = HashSet::new();
let mut windows = Vec::new();
for window in snapshot.windows {
if window.label.is_empty() || !seen.insert(window.label.clone()) {
continue;
}
match window.kind {
PersistedWindowKind::Main => windows.push(window),
PersistedWindowKind::View => {
let Some(project_id) = window.project_id else {
continue;
};
if window.panel.is_none() || window.url.is_none() {
continue;
}
match self.projects.load_project(project_id).await {
Ok(_) => windows.push(window),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
}
}
}
Ok(RestoreOpenWindowsOutput { windows })
}
}