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:
@ -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::<AppState>() {
|
||||
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<PersistedWindowState> {
|
||||
handle
|
||||
.webview_windows()
|
||||
.into_iter()
|
||||
.filter_map(|(label, window)| snapshot_webview_window(&label, &window))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn snapshot_webview_window(label: &str, window: &WebviewWindow) -> Option<PersistedWindowState> {
|
||||
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<String>,
|
||||
Option<ProjectId>,
|
||||
Option<String>,
|
||||
)> {
|
||||
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::<AppState>() 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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user