diff --git a/crates/app-tauri/src/commands.rs b/crates/app-tauri/src/commands.rs index 9bcbb3d..054721e 100644 --- a/crates/app-tauri/src/commands.rs +++ b/crates/app-tauri/src/commands.rs @@ -2341,7 +2341,7 @@ pub struct ViewWindowLifecycleEventDto { } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ViewPanel { +pub(crate) enum ViewPanel { Projects, Context, Work, @@ -2355,7 +2355,7 @@ enum ViewPanel { } impl ViewPanel { - fn parse(raw: &str) -> Result { + pub(crate) fn parse(raw: &str) -> Result { match raw { "projects" => Ok(Self::Projects), "context" => Ok(Self::Context), @@ -2374,7 +2374,7 @@ impl ViewPanel { } } - const fn as_str(self) -> &'static str { + pub(crate) const fn as_str(self) -> &'static str { match self { Self::Projects => "projects", Self::Context => "context", @@ -2389,7 +2389,7 @@ impl ViewPanel { } } - const fn title(self) -> &'static str { + pub(crate) const fn title(self) -> &'static str { match self { Self::Projects => "Projects", Self::Context => "Project context", @@ -2405,15 +2405,15 @@ impl ViewPanel { } } -fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String { +pub(crate) fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String { format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple()) } -fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String { +pub(crate) fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String { format!("index.html?panel={}&project={}", panel.as_str(), project_id) } -fn emit_view_window_lifecycle( +pub(crate) fn emit_view_window_lifecycle( app: &AppHandle, kind: &'static str, panel: ViewPanel, diff --git a/crates/app-tauri/src/lib.rs b/crates/app-tauri/src/lib.rs index 42ac4c0..684be79 100644 --- a/crates/app-tauri/src/lib.rs +++ b/crates/app-tauri/src/lib.rs @@ -26,7 +26,15 @@ pub mod tickets; use std::process::ExitCode; -use tauri::Manager; +use application::SnapshotOpenWindowsInput; +use domain::{ + PersistedMonitorState, PersistedWindowKind, PersistedWindowPosition, PersistedWindowSize, + PersistedWindowState, ProjectId, +}; +use tauri::{ + Manager, PhysicalPosition, PhysicalSize, WebviewUrl, WebviewWindow, WebviewWindowBuilder, +}; +use uuid::Uuid; use state::AppState; @@ -99,6 +107,9 @@ pub fn run() { window.on_window_event(move |event| { if let tauri::WindowEvent::CloseRequested { .. } = event { if let Some(state) = handle.try_state::() { + let open_windows = snapshot_open_webview_windows(&handle); + let window_snapshot = + std::sync::Arc::clone(&state.snapshot_open_windows); let pty = std::sync::Arc::clone(&state.pty_port); // ORDER IS CRITICAL: freeze `agent_was_running` on every // agent leaf of every open project FIRST, reading the live @@ -111,6 +122,11 @@ pub fn run() { let open_projects = state.open_project_ids(); let handles = state.terminal_sessions.handles(); tauri::async_runtime::block_on(async move { + let _ = window_snapshot + .execute(SnapshotOpenWindowsInput { + windows: open_windows, + }) + .await; for project_id in open_projects { let _ = snapshot .execute(application::SnapshotRunningAgentsInput { @@ -130,6 +146,8 @@ pub fn run() { }); } + restore_open_webview_windows(app.handle()); + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -270,9 +288,233 @@ fn should_close_with_main_window(label: &str) -> bool { label != "main" } +fn snapshot_open_webview_windows(handle: &tauri::AppHandle) -> Vec { + handle + .webview_windows() + .into_iter() + .filter_map(|(label, window)| snapshot_webview_window(&label, &window)) + .collect() +} + +fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option { + let (kind, panel, project_id, url) = persisted_window_identity(label)?; + let outer_position = window + .outer_position() + .ok() + .map(|p| PersistedWindowPosition { x: p.x, y: p.y }); + let outer_size = window.outer_size().ok().map(|s| PersistedWindowSize { + width: s.width, + height: s.height, + }); + let monitor = window.current_monitor().ok().flatten().map(|m| { + let position = m.position(); + let size = m.size(); + PersistedMonitorState { + name: m.name().cloned(), + scale_factor: Some(m.scale_factor()), + position: Some(PersistedWindowPosition { + x: position.x, + y: position.y, + }), + size: Some(PersistedWindowSize { + width: size.width, + height: size.height, + }), + } + }); + + Some(PersistedWindowState { + label: label.to_owned(), + kind, + panel, + project_id, + url, + visible: window.is_visible().unwrap_or(true), + maximized: window.is_maximized().unwrap_or(false), + fullscreen: window.is_fullscreen().unwrap_or(false), + outer_position, + outer_size, + monitor, + last_focused_at: None, + }) +} + +fn persisted_window_identity( + label: &str, +) -> Option<( + PersistedWindowKind, + Option, + Option, + Option, +)> { + if label == "main" { + return Some((PersistedWindowKind::Main, None, None, None)); + } + + let (panel, project_id) = persisted_view_identity_from_label(label)?; + Some(( + PersistedWindowKind::View, + Some(panel.as_str().to_owned()), + Some(project_id), + Some(commands::view_window_url(panel, project_id)), + )) +} + +fn persisted_view_identity_from_label(label: &str) -> Option<(commands::ViewPanel, ProjectId)> { + let rest = label.strip_prefix("view-")?; + let (panel, project_id) = rest.rsplit_once('-')?; + if project_id.len() != 32 { + return None; + } + + let panel = commands::ViewPanel::parse(panel).ok()?; + let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).ok()?); + Some((panel, project_id)) +} + +fn restore_open_webview_windows(handle: &tauri::AppHandle) { + let Some(state) = handle.try_state::() else { + return; + }; + let restore = std::sync::Arc::clone(&state.restore_open_windows); + let Ok(output) = tauri::async_runtime::block_on(async move { restore.execute().await }) else { + return; + }; + + for window_state in output.windows { + match window_state.kind { + PersistedWindowKind::Main => { + if let Some(window) = handle.get_webview_window("main") { + apply_persisted_window_state(handle, &window, &window_state); + } + } + PersistedWindowKind::View => { + restore_view_window(handle, &window_state); + } + } + } +} + +fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) { + if handle.get_webview_window(&state.label).is_some() { + return; + } + + let Some(panel) = state + .panel + .as_deref() + .and_then(|raw| commands::ViewPanel::parse(raw).ok()) + else { + return; + }; + let Some(project_id) = state.project_id else { + return; + }; + let expected_label = commands::view_window_label(panel, project_id); + if expected_label != state.label { + return; + } + let url = state + .url + .clone() + .unwrap_or_else(|| commands::view_window_url(panel, project_id)); + + let Ok(window) = WebviewWindowBuilder::new(handle, &state.label, WebviewUrl::App(url.into())) + .title(format!("IdeA - {}", panel.title())) + .inner_size(1120.0, 760.0) + .min_inner_size(720.0, 480.0) + .resizable(true) + .maximizable(true) + .minimizable(true) + .closable(true) + .decorations(true) + .visible(state.visible) + .build() + else { + return; + }; + + let event_app = handle.clone(); + let event_label = state.label.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { .. } = event { + commands::emit_view_window_lifecycle( + &event_app, + "closed", + panel, + project_id, + &event_label, + ); + } + }); + + apply_persisted_window_state(handle, &window, state); + commands::emit_view_window_lifecycle(handle, "opened", panel, project_id, &state.label); +} + +fn apply_persisted_window_state( + handle: &tauri::AppHandle, + window: &WebviewWindow, + state: &PersistedWindowState, +) { + if persisted_monitor_is_available(handle, state.monitor.as_ref()) { + if let Some(size) = state.outer_size { + let _ = window.set_size(PhysicalSize::new(size.width, size.height)); + } + if let Some(position) = state.outer_position { + let _ = window.set_position(PhysicalPosition::new(position.x, position.y)); + } + } else { + let _ = window.center(); + } + + if state.fullscreen { + let _ = window.set_fullscreen(true); + } else if state.maximized { + let _ = window.maximize(); + } + + if state.visible { + let _ = window.show(); + } else { + let _ = window.hide(); + } +} + +fn persisted_monitor_is_available( + handle: &tauri::AppHandle, + monitor: Option<&PersistedMonitorState>, +) -> bool { + let Some(saved) = monitor else { + return true; + }; + let Ok(monitors) = handle.available_monitors() else { + return false; + }; + + monitors.into_iter().any(|current| { + if let Some(saved_name) = saved.name.as_deref() { + if current.name().is_some_and(|name| name == saved_name) { + return true; + } + } + + let position_matches = saved.position.is_some_and(|p| { + let current_position = current.position(); + p.x == current_position.x && p.y == current_position.y + }); + let size_matches = saved.size.is_some_and(|s| { + let current_size = current.size(); + s.width == current_size.width && s.height == current_size.height + }); + position_matches && size_matches + }) +} + #[cfg(test)] mod tests { - use super::should_close_with_main_window; + use super::{persisted_view_identity_from_label, persisted_window_identity}; + use super::{should_close_with_main_window, PersistedWindowKind}; #[test] fn main_window_close_does_not_target_main_again() { @@ -290,4 +532,33 @@ mod tests { fn main_window_close_targets_other_auxiliary_windows() { assert!(should_close_with_main_window("settings")); } + + #[test] + fn persisted_identity_accepts_main_and_stable_view_labels() { + let (kind, panel, project_id, url) = persisted_window_identity("main").unwrap(); + assert_eq!(kind, PersistedWindowKind::Main); + assert!(panel.is_none()); + assert!(project_id.is_none()); + assert!(url.is_none()); + + let (panel, project_id) = + persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a") + .unwrap(); + + assert_eq!(panel.as_str(), "tickets"); + assert_eq!( + project_id.to_string(), + "00000000-0000-0000-0000-00000000002a" + ); + } + + #[test] + fn persisted_identity_filters_unknown_or_headless_labels() { + assert!(persisted_window_identity("mcp-server").is_none()); + assert!(persisted_window_identity("settings").is_none()); + assert!( + persisted_window_identity("view-unknown-0000000000000000000000000000002a").is_none() + ); + assert!(persisted_window_identity("view-tickets-not-a-project").is_none()); + } } diff --git a/crates/app-tauri/src/state.rs b/crates/app-tauri/src/state.rs index 1ba18fe..ccdff37 100644 --- a/crates/app-tauri/src/state.rs +++ b/crates/app-tauri/src/state.rs @@ -33,13 +33,14 @@ use application::{ ReadMemoryIndex, ReadProjectContext, ReadSkill, RecallMemory, ReconcileLayouts, ReconcileLiveState, ReconcileLiveStateInput, RecordTurn, RecordTurnProvider, ReferenceProfiles, RenameLayout, RenameSprint, ReorderSprints, ResizeTerminal, ResolveAgentPermissions, - ResolveMemoryLinks, RetryBackgroundTask, RotateConversationLog, SaveEmbedderProfile, - SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, SnapshotRunningAgents, - SpawnBackgroundCommand, StopLiveAgent, StructuredSessions, SuggestedThisSession, - SyncAgentWithTemplate, TerminalSessions, UnassignSkillFromAgent, UnassignTicketFromSprint, - UnlinkIssues, UpdateAgentContext, UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, - UpdateLiveState, UpdateMemory, UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, - UpdateTemplate, WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, + ResolveMemoryLinks, RestoreOpenWindows, RetryBackgroundTask, RotateConversationLog, + SaveEmbedderProfile, SaveModelServer, SaveProfile, SessionLimitService, SetActiveLayout, + SnapshotOpenWindows, SnapshotRunningAgents, SpawnBackgroundCommand, StopLiveAgent, + StructuredSessions, SuggestedThisSession, SyncAgentWithTemplate, TerminalSessions, + UnassignSkillFromAgent, UnassignTicketFromSprint, UnlinkIssues, UpdateAgentContext, + UpdateAgentPermissions, UpdateIssue, UpdateIssueCarnet, UpdateLiveState, UpdateMemory, + UpdateProjectContext, UpdateProjectPermissions, UpdateSkill, UpdateTemplate, + WakeSessionProvider, WriteMemory, WriteToTerminal, AGENT_MEMORY_RECALL_BUDGET, }; use async_trait::async_trait; use domain::ports::{ @@ -49,7 +50,7 @@ use domain::ports::{ EmbedderPromptStore, EventBus, FileSystem, GitPort, IdGenerator, IssueNumberAllocator, IssueStore, MemoryRecall, MemoryStore, PermissionStore, ProcessSpawner, ProfileStore, ProjectStore, PtyPort, ScheduledTask, Scheduler, SkillStore, SprintStore, TemplateStore, - ToolInvoker, WakeError, WakeReason, + ToolInvoker, WakeError, WakeReason, WindowStateStore, }; use domain::profile::{ AgentProfile, ContextInjection, McpConfigStrategy, McpTransport, StructuredAdapter, @@ -71,13 +72,14 @@ use infrastructure::{ FsEmbedderPromptStore, FsHandoffStore, FsIssueNumberAllocator, FsIssueStore, FsLiveStateStore, FsMemoryStore, FsModelServerRegistry, FsOrchestratorWatcher, FsPermissionStore, FsProfileStore, FsProjectStore, FsProviderSessionStore, FsSkillStore, FsSprintStore, FsTemplateStore, - Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, IdeaiContextStore, - InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, LocalFileSystem, - LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, NaiveMemoryRecall, - OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, StructuredSessionFactory, - SystemClock, SystemMillisClock, TicketToolProvider, TokioBroadcastEventBus, TokioScheduler, - ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, - ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, + FsWindowStateStore, Git2Repository, HeuristicHandoffSummarizer, HttpOpenAiCompatibleProbe, + IdeaiContextStore, InMemoryConversationRegistry, InMemoryMailbox, LlamaCppRuntime, + LocalFileSystem, LocalManagedProcess, LocalProcessSpawner, McpServer, MediatedInbox, + NaiveMemoryRecall, OrchestratorWatchHandle, PortablePtyAdapter, RwFileGuard, + StructuredSessionFactory, SystemClock, SystemMillisClock, TicketToolProvider, + TokioBroadcastEventBus, TokioScheduler, ToolPolicyRegistry, UuidGenerator, VectorMemoryRecall, + DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, + VECTOR_ONNX_ENABLED, }; use crate::chat::ChatBridge; @@ -900,6 +902,10 @@ pub struct AppState { // --- Windows (L10) --- /// Detach a tab into a new OS window (persists the workspace topology). pub move_tab: Arc, + /// Persist open Tauri webview-window state on main-window shutdown. + pub snapshot_open_windows: Arc, + /// Load restorable Tauri webview-window state on startup. + pub restore_open_windows: Arc, // --- Background tasks (B8) --- /// Spawn a command-backed first-class background task. pub spawn_background_command: Arc, @@ -1054,10 +1060,15 @@ impl AppState { Arc::clone(&fs) as Arc, app_data_dir.to_string_lossy().into_owned(), )); + let window_state_store = Arc::new(FsWindowStateStore::new( + Arc::clone(&fs) as Arc, + app_data_dir.to_string_lossy().into_owned(), + )); // Port-typed handles for injection. let fs_port = Arc::clone(&fs) as Arc; let store_port = Arc::clone(&store) as Arc; + let window_state_port = Arc::clone(&window_state_store) as Arc; let events_port = Arc::clone(&event_bus) as Arc; // --- Use cases (ports injected as Arc) --- @@ -2211,6 +2222,12 @@ impl AppState { Arc::clone(&store_port), Arc::clone(&ids) as Arc, )); + let snapshot_open_windows = + Arc::new(SnapshotOpenWindows::new(Arc::clone(&window_state_port))); + let restore_open_windows = Arc::new(RestoreOpenWindows::new( + Arc::clone(&window_state_port), + Arc::clone(&store_port), + )); Self { health, @@ -2340,6 +2357,8 @@ impl AppState { fs_port: Arc::clone(&fs_port), home_dir, move_tab, + snapshot_open_windows, + restore_open_windows, spawn_background_command, cancel_background_task, retry_background_task, diff --git a/crates/application/src/lib.rs b/crates/application/src/lib.rs index 2b36d12..18ead57 100644 --- a/crates/application/src/lib.rs +++ b/crates/application/src/lib.rs @@ -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, diff --git a/crates/application/src/window/mod.rs b/crates/application/src/window/mod.rs index bec4513..9902118 100644 --- a/crates/application/src/window/mod.rs +++ b/crates/application/src/window/mod.rs @@ -3,4 +3,7 @@ mod usecases; -pub use usecases::{MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput}; +pub use usecases::{ + MoveTabToNewWindow, MoveTabToNewWindowInput, MoveTabToNewWindowOutput, RestoreOpenWindows, + RestoreOpenWindowsOutput, SnapshotOpenWindows, SnapshotOpenWindowsInput, +}; diff --git a/crates/application/src/window/usecases.rs b/crates/application/src/window/usecases.rs index 9970ba6..2a38ce0 100644 --- a/crates/application/src/window/usecases.rs +++ b/crates/application/src/window/usecases.rs @@ -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, +} + +/// Persists the latest open OS window snapshot. +pub struct SnapshotOpenWindows { + store: Arc, +} + +impl SnapshotOpenWindows { + /// Builds the use case from its store port. + #[must_use] + pub fn new(store: Arc) -> 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, +} + +/// Loads and filters the latest persisted OS window snapshot. +pub struct RestoreOpenWindows { + windows: Arc, + projects: Arc, +} + +impl RestoreOpenWindows { + /// Builds the use case from its ports. + #[must_use] + pub fn new(windows: Arc, projects: Arc) -> 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 { + 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 }) + } +} diff --git a/crates/application/tests/window_usecases.rs b/crates/application/tests/window_usecases.rs index 300a2a2..603ab2a 100644 --- a/crates/application/tests/window_usecases.rs +++ b/crates/application/tests/window_usecases.rs @@ -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>); + +#[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 { + Ok(self.0.lock().unwrap().clone()) + } +} + +#[derive(Clone)] +struct FakeProjectRegistry { + existing: Arc>>, +} + +impl FakeProjectRegistry { + fn new(existing: Vec) -> Self { + Self { + existing: Arc::new(Mutex::new(existing)), + } + } +} + +#[async_trait] +impl ProjectStore for FakeProjectRegistry { + async fn list_projects(&self) -> Result, StoreError> { + unreachable!() + } + + async fn load_project(&self, id: ProjectId) -> Result { + 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 { + 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!["main", valid_label] + ); +} diff --git a/crates/domain/src/layout.rs b/crates/domain/src/layout.rs index f5dbec5..191d5d3 100644 --- a/crates/domain/src/layout.rs +++ b/crates/domain/src/layout.rs @@ -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, + /// Scale factor reported by the windowing system. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scale_factor: Option, + /// Monitor origin in the global desktop coordinate space. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub position: Option, + /// Monitor physical size. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// 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--`. + 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, + /// Detached view project id. Present only for [`PersistedWindowKind::View`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// App URL loaded in the window. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub url: Option, + /// 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, + /// Outer window size. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub outer_size: Option, + /// Current monitor at snapshot time. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub monitor: Option, + /// Optional focus recency, left unset until focus tracking exists. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_focused_at: Option, +} + +/// 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, +} + +impl WindowStateSnapshot { + /// Builds a versioned snapshot from captured windows. + #[must_use] + pub fn new(windows: Vec) -> Self { + Self { + version: WINDOW_STATE_SNAPSHOT_VERSION, + windows, + } + } +} + +impl Default for WindowStateSnapshot { + fn default() -> Self { + Self::new(Vec::new()) + } +} diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 3b76b10..50d0648 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -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, }; diff --git a/crates/domain/src/ports.rs b/crates/domain/src/ports.rs index eee0b6a..6f62457 100644 --- a/crates/domain/src/ports.rs +++ b/crates/domain/src/ports.rs @@ -1417,6 +1417,26 @@ pub trait ProjectStore: Send + Sync { async fn load_workspace(&self) -> Result; } +/// 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; +} + /// 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). diff --git a/crates/domain/tests/window.rs b/crates/domain/tests/window.rs index c299c93..1ffc02b 100644 --- a/crates/domain/tests/window.rs +++ b/crates/domain/tests/window.rs @@ -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); +} diff --git a/crates/infrastructure/src/lib.rs b/crates/infrastructure/src/lib.rs index 3a4e1ba..c6668c0 100644 --- a/crates/infrastructure/src/lib.rs +++ b/crates/infrastructure/src/lib.rs @@ -96,8 +96,8 @@ pub use store::{ embedder_from_profile, index_token_size, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall, BackgroundTaskReconcileReport, EmbedderEnvProbe, FsBackgroundTaskStore, FsEmbedderProfileStore, FsEmbedderPromptStore, FsLiveStateStore, FsMemoryStore, - FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, HashEmbedder, - IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, StubEmbedder, VectorMemoryRecall, - DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, - VECTOR_ONNX_ENABLED, + FsPermissionStore, FsProfileStore, FsProjectStore, FsSkillStore, FsTemplateStore, + FsWindowStateStore, HashEmbedder, IdeaiContextStore, NaiveMemoryRecall, OnnxModelInfo, + StubEmbedder, VectorMemoryRecall, DEFAULT_OLLAMA_BASE_URL, ONNX_CACHE_SUBDIR, + RECOMMENDED_ONNX_MODELS, VECTOR_HTTP_ENABLED, VECTOR_ONNX_ENABLED, }; diff --git a/crates/infrastructure/src/store/mod.rs b/crates/infrastructure/src/store/mod.rs index 24300e3..24ce547 100644 --- a/crates/infrastructure/src/store/mod.rs +++ b/crates/infrastructure/src/store/mod.rs @@ -15,6 +15,7 @@ mod project; mod skill; mod template; mod vector; +mod window_state; pub use background_task::{BackgroundTaskReconcileReport, FsBackgroundTaskStore}; pub use context::IdeaiContextStore; @@ -35,3 +36,4 @@ pub use project::FsProjectStore; pub use skill::FsSkillStore; pub use template::FsTemplateStore; pub use vector::{should_use_vector, AdaptiveMemoryRecall, VectorMemoryRecall}; +pub use window_state::FsWindowStateStore; diff --git a/crates/infrastructure/src/store/window_state.rs b/crates/infrastructure/src/store/window_state.rs new file mode 100644 index 0000000..2bae7a4 --- /dev/null +++ b/crates/infrastructure/src/store/window_state.rs @@ -0,0 +1,68 @@ +//! JSON-file implementation of the [`WindowStateStore`] port. +//! +//! The snapshot is machine-local and lives under the app-data directory: +//! `/windows.json`. + +use std::sync::Arc; + +use async_trait::async_trait; + +use domain::layout::WindowStateSnapshot; +use domain::ports::{FileSystem, RemotePath, StoreError, WindowStateStore}; + +/// File name of the persisted OS window-state snapshot. +const WINDOWS_FILE: &str = "windows.json"; + +/// JSON-file implementation of [`WindowStateStore`]. +#[derive(Clone)] +pub struct FsWindowStateStore { + fs: Arc, + app_data_dir: String, +} + +impl FsWindowStateStore { + /// Builds the store from an injected filesystem and app-data directory. + #[must_use] + pub fn new(fs: Arc, app_data_dir: impl Into) -> Self { + Self { + fs, + app_data_dir: app_data_dir.into(), + } + } + + fn path(&self) -> RemotePath { + let base = self.app_data_dir.trim_end_matches(['/', '\\']); + RemotePath::new(format!("{base}/{WINDOWS_FILE}")) + } + + async fn ensure_dir(&self) -> Result<(), StoreError> { + let dir = RemotePath::new(self.app_data_dir.trim_end_matches(['/', '\\']).to_owned()); + self.fs + .create_dir_all(&dir) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } +} + +#[async_trait] +impl WindowStateStore for FsWindowStateStore { + async fn save_window_state(&self, snapshot: &WindowStateSnapshot) -> Result<(), StoreError> { + self.ensure_dir().await?; + let bytes = serde_json::to_vec_pretty(snapshot) + .map_err(|e| StoreError::Serialization(e.to_string()))?; + self.fs + .write(&self.path(), &bytes) + .await + .map_err(|e| StoreError::Io(e.to_string())) + } + + async fn load_window_state(&self) -> Result { + match self.fs.read(&self.path()).await { + Ok(bytes) => { + serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string())) + } + Err(domain::ports::FsError::NotFound(_)) => Ok(WindowStateSnapshot::default()), + Err(e) => Err(StoreError::Io(e.to_string())), + } + } +} diff --git a/crates/infrastructure/tests/window_state_store.rs b/crates/infrastructure/tests/window_state_store.rs new file mode 100644 index 0000000..807d3dc --- /dev/null +++ b/crates/infrastructure/tests/window_state_store.rs @@ -0,0 +1,60 @@ +//! Integration tests for [`FsWindowStateStore`] against a real temp directory. + +use std::path::PathBuf; +use std::sync::Arc; + +use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot}; +use domain::ports::{FileSystem, WindowStateStore}; +use infrastructure::{FsWindowStateStore, LocalFileSystem}; +use uuid::Uuid; + +struct TempDir(PathBuf); + +impl TempDir { + fn new() -> Self { + let path = std::env::temp_dir().join(format!("idea-window-store-{}", Uuid::new_v4())); + std::fs::create_dir_all(&path).unwrap(); + Self(path) + } + + fn app_data_dir(&self) -> String { + self.0.to_string_lossy().into_owned() + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[tokio::test] +async fn window_state_save_then_load_roundtrips() { + let tmp = TempDir::new(); + let fs: Arc = Arc::new(LocalFileSystem::new()); + let store = FsWindowStateStore::new(fs, tmp.app_data_dir()); + + assert_eq!( + store.load_window_state().await.unwrap(), + WindowStateSnapshot::default() + ); + + let snapshot = WindowStateSnapshot::new(vec![PersistedWindowState { + label: "main".to_owned(), + kind: PersistedWindowKind::Main, + panel: None, + project_id: None, + url: None, + visible: true, + maximized: true, + fullscreen: false, + outer_position: None, + outer_size: None, + monitor: None, + last_focused_at: None, + }]); + + store.save_window_state(&snapshot).await.unwrap(); + assert_eq!(store.load_window_state().await.unwrap(), snapshot); + assert!(tmp.0.join("windows.json").exists()); +}