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

@ -1025,3 +1025,119 @@ impl Workspace {
Ok(Self { windows })
}
}
/// Current persisted schema version for OS window state.
pub const WINDOW_STATE_SNAPSHOT_VERSION: u32 = 1;
/// Persisted kind of an IdeA OS window.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum PersistedWindowKind {
/// The primary IdeA window.
Main,
/// A detached project view window.
View,
}
/// Physical top-left position of a window or monitor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedWindowPosition {
/// X coordinate in physical pixels.
pub x: i32,
/// Y coordinate in physical pixels.
pub y: i32,
}
/// Physical size of a window or monitor.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedWindowSize {
/// Width in physical pixels.
pub width: u32,
/// Height in physical pixels.
pub height: u32,
}
/// Best-effort monitor descriptor captured with a window.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedMonitorState {
/// Human-readable monitor name when the platform exposes one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
/// Scale factor reported by the windowing system.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scale_factor: Option<f64>,
/// Monitor origin in the global desktop coordinate space.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<PersistedWindowPosition>,
/// Monitor physical size.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub size: Option<PersistedWindowSize>,
}
/// Persisted state of one IdeA webview window.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PersistedWindowState {
/// Stable Tauri label. For detached views this is
/// `view-<panel>-<projectId-simple>`.
pub label: String,
/// Window kind.
pub kind: PersistedWindowKind,
/// Detached view panel id. Present only for [`PersistedWindowKind::View`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub panel: Option<String>,
/// Detached view project id. Present only for [`PersistedWindowKind::View`].
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<crate::ids::ProjectId>,
/// App URL loaded in the window.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
/// Whether the window was visible.
pub visible: bool,
/// Whether the window was maximized.
pub maximized: bool,
/// Whether the window was fullscreen.
pub fullscreen: bool,
/// Outer window position.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_position: Option<PersistedWindowPosition>,
/// Outer window size.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub outer_size: Option<PersistedWindowSize>,
/// Current monitor at snapshot time.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub monitor: Option<PersistedMonitorState>,
/// Optional focus recency, left unset until focus tracking exists.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_focused_at: Option<u64>,
}
/// Persisted snapshot of the IdeA OS windows.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct WindowStateSnapshot {
/// Schema version.
pub version: u32,
/// Captured windows.
pub windows: Vec<PersistedWindowState>,
}
impl WindowStateSnapshot {
/// Builds a versioned snapshot from captured windows.
#[must_use]
pub fn new(windows: Vec<PersistedWindowState>) -> Self {
Self {
version: WINDOW_STATE_SNAPSHOT_VERSION,
windows,
}
}
}
impl Default for WindowStateSnapshot {
fn default() -> Self {
Self::new(Vec::new())
}
}

View File

@ -168,7 +168,9 @@ pub use git::GitRepository;
pub use layout::{
Direction, GridCell, GridContainer, LayoutError, LayoutNode, LayoutTree, LeafCell,
SplitContainer, Tab, WeightedChild, Window, Workspace,
PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize,
PersistedWindowState, SplitContainer, Tab, WeightedChild, Window, WindowStateSnapshot,
Workspace, WINDOW_STATE_SNAPSHOT_VERSION,
};
pub use events::{DomainEvent, OrchestrationSource};
@ -201,4 +203,5 @@ pub use ports::{
OutputStream, PermissionStore, PreparedContext, ProcessError, ProcessSpawner, ProfileStore,
ProjectStore, PtyError, PtyHandle, PtyPort, RemoteError, RemoteHost, RemotePath, RuntimeError,
ScheduledTask, Scheduler, SpawnSpec, SprintStore, SprintStoreError, StoreError, TemplateStore,
WindowStateStore,
};

View File

@ -1417,6 +1417,26 @@ pub trait ProjectStore: Send + Sync {
async fn load_workspace(&self) -> Result<crate::layout::Workspace, StoreError>;
}
/// Machine-local persistence of open OS window state.
#[async_trait]
pub trait WindowStateStore: Send + Sync {
/// Saves the latest open-window snapshot.
///
/// # Errors
/// [`StoreError`] on persistence failure.
async fn save_window_state(
&self,
snapshot: &crate::layout::WindowStateSnapshot,
) -> Result<(), StoreError>;
/// Loads the latest open-window snapshot.
///
/// # Errors
/// [`StoreError`] on unexpected I/O or deserialisation failure. A missing
/// snapshot is represented as an empty default snapshot.
async fn load_window_state(&self) -> Result<crate::layout::WindowStateSnapshot, StoreError>;
}
/// CRUD for the configured [`AgentProfile`]s in the global IDE store
/// (`profiles.json`, ARCHITECTURE §9.2). Profiles are the *data* that drives the
/// single generic [`AgentRuntime`] adapter (Open/Closed).

View File

@ -3,8 +3,9 @@
//! window is dropped; an active moved tab hands activity back to a sibling.
use domain::{
LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, ProjectId, Tab, TabId, Window, WindowId,
Workspace,
LayoutError, LayoutNode, LayoutTree, LeafCell, NodeId, PersistedMonitorState,
PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, PersistedWindowState,
ProjectId, Tab, TabId, Window, WindowId, WindowStateSnapshot, Workspace,
};
use uuid::Uuid;
@ -94,3 +95,43 @@ fn move_non_active_tab_leaves_source_active_unchanged() {
assert_eq!(source.active_tab, tid(1), "active tab unchanged");
assert_eq!(source.tabs.len(), 1);
}
#[test]
fn window_state_snapshot_round_trips_with_camel_case_schema() {
let snapshot = WindowStateSnapshot::new(vec![PersistedWindowState {
label: "view-tickets-0000000000000000000000000000002a".to_owned(),
kind: PersistedWindowKind::View,
panel: Some("tickets".to_owned()),
project_id: Some(ProjectId::from_uuid(Uuid::from_u128(42))),
url: Some(
"index.html?panel=tickets&project=00000000-0000-0000-0000-00000000002a".to_owned(),
),
visible: true,
maximized: false,
fullscreen: false,
outer_position: Some(PersistedWindowPosition { x: 12, y: 34 }),
outer_size: Some(PersistedWindowSize {
width: 1200,
height: 800,
}),
monitor: Some(PersistedMonitorState {
name: Some("HDMI-1".to_owned()),
scale_factor: Some(1.25),
position: Some(PersistedWindowPosition { x: 0, y: 0 }),
size: Some(PersistedWindowSize {
width: 1920,
height: 1080,
}),
}),
last_focused_at: Some(123),
}]);
let json = serde_json::to_string(&snapshot).unwrap();
assert!(json.contains("\"projectId\""), "json was {json}");
assert!(json.contains("\"outerPosition\""), "json was {json}");
assert!(json.contains("\"scaleFactor\""), "json was {json}");
assert!(!json.contains("project_id"), "no snake_case leak: {json}");
let decoded: WindowStateSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, snapshot);
}