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:
@ -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,
|
||||
|
||||
@ -3,4 +3,7 @@
|
||||
|
||||
mod usecases;
|
||||
|
||||
pub use usecases::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput};
|
||||
pub use usecases::{
|
||||
MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows,
|
||||
RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput,
|
||||
};
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user