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

@ -5,13 +5,19 @@ use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::ids::{ProjectId, TabId, WindowId};
use domain::layout::{LayoutNode, LayoutTree, LeafCell, Tab, Window, Workspace};
use domain::ports::{IdGenerator, ProjectStore, StoreError};
use domain::project::Project;
use domain::NodeId;
use domain::layout::{
LayoutNode, LayoutTree, LeafCell, PersistedWindowKind, PersistedWindowState, Tab, Window,
WindowStateSnapshot, Workspace,
};
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore};
use domain::project::{Project, ProjectPath};
use domain::{NodeId, RemoteRef};
use uuid::Uuid;
use application::{MoveTabToNewWindow, MoveTabToNewWindowInput};
use application::{
MoveTabToNewWindow, MoveTabToNewWindowInput, RestoreOpenWindows, SnapshotOpenWindows,
SnapshotOpenWindowsInput,
};
/// A `ProjectStore` fake that only implements the workspace persistence the use
/// case needs (the project methods are never called here).
@ -47,6 +53,67 @@ impl IdGenerator for SeqIds {
}
}
#[derive(Clone, Default)]
struct FakeWindowStateStore(Arc<Mutex<WindowStateSnapshot>>);
#[async_trait]
impl WindowStateStore for FakeWindowStateStore {
async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> {
*self.0.lock().unwrap() = snapshot.clone();
Ok(())
}
async fn load_window_state(&self) -> Result<WindowStateSnapshot, StoreError> {
Ok(self.0.lock().unwrap().clone())
}
}
#[derive(Clone)]
struct FakeProjectRegistry {
existing: Arc<Mutex<Vec<ProjectId>>>,
}
impl FakeProjectRegistry {
fn new(existing: Vec<ProjectId>) -> Self {
Self {
existing: Arc::new(Mutex::new(existing)),
}
}
}
#[async_trait]
impl ProjectStore for FakeProjectRegistry {
async fn list_projects(&self) -> Result<Vec<Project>, StoreError> {
unreachable!()
}
async fn load_project(&self, id: ProjectId) -> Result<Project, StoreError> {
if !self.existing.lock().unwrap().contains(&id) {
return Err(StoreError::NotFound);
}
Ok(Project {
id,
name: format!("project-{id}"),
root: ProjectPath::new(format!("/tmp/{id}")).unwrap(),
remote: RemoteRef::Local,
created_at: 0,
})
}
async fn save_project(&self, _p: &Project) -> Result<(), StoreError> {
unreachable!()
}
async fn save_workspace(&self, _ws: &Workspace) -> Result<(), StoreError> {
unreachable!()
}
async fn load_workspace(&self) -> Result<Workspace, StoreError> {
unreachable!()
}
}
fn tid(n: u128) -> TabId {
TabId::from_uuid(Uuid::from_u128(n))
}
@ -75,6 +142,40 @@ fn seeded() -> FakeStore {
FakeStore(Arc::new(Mutex::new(ws)))
}
fn persisted_main() -> PersistedWindowState {
PersistedWindowState {
label: "main".to_owned(),
kind: PersistedWindowKind::Main,
panel: None,
project_id: None,
url: None,
visible: true,
maximized: false,
fullscreen: false,
outer_position: None,
outer_size: None,
monitor: None,
last_focused_at: None,
}
}
fn persisted_view(label: &str, project_id: ProjectId) -> PersistedWindowState {
PersistedWindowState {
label: label.to_owned(),
kind: PersistedWindowKind::View,
panel: Some("tickets".to_owned()),
project_id: Some(project_id),
url: Some(format!("index.html?panel=tickets&project={project_id}")),
visible: true,
maximized: false,
fullscreen: false,
outer_position: None,
outer_size: None,
monitor: None,
last_focused_at: None,
}
}
#[tokio::test]
async fn detaches_tab_and_persists_workspace() {
let store = seeded();
@ -112,3 +213,50 @@ async fn unknown_tab_is_not_found() {
.unwrap_err();
assert_eq!(err.code(), "NOT_FOUND", "got {err:?}");
}
#[tokio::test]
async fn snapshot_open_windows_persists_the_supplied_snapshot() {
let store = FakeWindowStateStore::default();
let uc = SnapshotOpenWindows::new(Arc::new(store.clone()));
let windows = vec![persisted_main()];
uc.execute(SnapshotOpenWindowsInput {
windows: windows.clone(),
})
.await
.unwrap();
let saved = store.load_window_state().await.unwrap();
assert_eq!(saved.version, domain::WINDOW_STATE_SNAPSHOT_VERSION);
assert_eq!(saved.windows, windows);
}
#[tokio::test]
async fn restore_open_windows_deduplicates_and_filters_missing_projects() {
let existing = ProjectId::from_uuid(Uuid::from_u128(42));
let missing = ProjectId::from_uuid(Uuid::from_u128(43));
let valid_label = "view-tickets-0000000000000000000000000000002a";
let mut incomplete = persisted_view("view-tickets-0000000000000000000000000000002c", existing);
incomplete.url = None;
let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
persisted_main(),
persisted_main(),
persisted_view(valid_label, existing),
persisted_view(valid_label, existing),
persisted_view("view-tickets-0000000000000000000000000000002b", missing),
incomplete,
]))));
let projects = FakeProjectRegistry::new(vec![existing]);
let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects));
let out = uc.execute().await.unwrap();
assert_eq!(
out.windows
.iter()
.map(|w| w.label.as_str())
.collect::<Vec<_>>(),
vec!["main", valid_label]
);
}