fix(windows): restauration panel-only des fenêtres détachées suivant le projet en focus (#47)

Partie backend. Les fenêtres/panneaux détachés étaient restaurés avec un
project_id figé au moment du détachement, si bien qu'ils restaient collés à
un projet mort ou incohérent après redémarrage. Ils sont désormais restaurés
en mode panel-only, sans project_id figé, et suivent le projet en focus de la
fenêtre principale via un event focused-project exposé par la couche fenêtre.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-13 12:37:25 +02:00
parent 1e0cb16ead
commit 6387fac34f
6 changed files with 156 additions and 107 deletions

View File

@ -62,7 +62,7 @@ use crate::dto::{
UpdateTemplateRequestDto, WriteTerminalRequestDto, UpdateTemplateRequestDto, WriteTerminalRequestDto,
}; };
use crate::pty::{PtyBridge, PtyChunk}; use crate::pty::{PtyBridge, PtyChunk};
use crate::state::AppState; use crate::state::{AppState, FocusedProjectDto};
use domain::{SkillRef, SkillScope}; use domain::{SkillRef, SkillScope};
/// `health` — trivial command validating the full IPC pipeline /// `health` — trivial command validating the full IPC pipeline
@ -2303,12 +2303,14 @@ use application::MoveTabToNewWindowInput;
/// Event emitted for detached view-window lifecycle changes. /// Event emitted for detached view-window lifecycle changes.
pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle"; pub const VIEW_WINDOW_LIFECYCLE_EVENT: &str = "view-window://lifecycle";
/// Event emitted when the main-window focused project changes.
pub const FOCUSED_PROJECT_CHANGED_EVENT: &str = "focused-project://changed";
/// Response returned by `open_view_window`. /// Response returned by `open_view_window`.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct OpenViewWindowResponseDto { pub struct OpenViewWindowResponseDto {
/// Stable Tauri window label for this `(panel, projectId)`. /// Stable Tauri window label for this panel.
pub label: String, pub label: String,
/// App URL loaded by the panel-only window. /// App URL loaded by the panel-only window.
pub url: String, pub url: String,
@ -2320,7 +2322,7 @@ pub struct OpenViewWindowResponseDto {
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct CloseViewWindowResponseDto { pub struct CloseViewWindowResponseDto {
/// Stable Tauri window label for this `(panel, projectId)`. /// Stable Tauri window label for this panel.
pub label: String, pub label: String,
/// `true` when a live window was found and close was requested. /// `true` when a live window was found and close was requested.
pub closed: bool, pub closed: bool,
@ -2334,12 +2336,18 @@ pub struct ViewWindowLifecycleEventDto {
pub kind: &'static str, pub kind: &'static str,
/// Panel id rendered by the detached window. /// Panel id rendered by the detached window.
pub panel: String, pub panel: String,
/// Project id rendered by the detached window.
pub project_id: String,
/// Stable Tauri window label. /// Stable Tauri window label.
pub label: String, pub label: String,
} }
/// Payload emitted on `focused-project://changed`.
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FocusedProjectChangedEventDto {
/// Focused project, or `null` when no project is focused.
pub project: Option<FocusedProjectDto>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ViewPanel { pub(crate) enum ViewPanel {
Projects, Projects,
@ -2405,19 +2413,18 @@ impl ViewPanel {
} }
} }
pub(crate) fn view_window_label(panel: ViewPanel, project_id: domain::ProjectId) -> String { pub(crate) fn view_window_label(panel: ViewPanel) -> String {
format!("view-{}-{}", panel.as_str(), project_id.as_uuid().simple()) format!("view-{}", panel.as_str())
} }
pub(crate) fn view_window_url(panel: ViewPanel, project_id: domain::ProjectId) -> String { pub(crate) fn view_window_url(panel: ViewPanel) -> String {
format!("index.html?panel={}&project={}", panel.as_str(), project_id) format!("index.html?panel={}", panel.as_str())
} }
pub(crate) fn emit_view_window_lifecycle( pub(crate) fn emit_view_window_lifecycle(
app: &AppHandle, app: &AppHandle,
kind: &'static str, kind: &'static str,
panel: ViewPanel, panel: ViewPanel,
project_id: domain::ProjectId,
label: &str, label: &str,
) { ) {
let _ = app.emit( let _ = app.emit(
@ -2425,39 +2432,56 @@ pub(crate) fn emit_view_window_lifecycle(
ViewWindowLifecycleEventDto { ViewWindowLifecycleEventDto {
kind, kind,
panel: panel.as_str().to_owned(), panel: panel.as_str().to_owned(),
project_id: project_id.to_string(),
label: label.to_owned(), label: label.to_owned(),
}, },
); );
} }
/// `open_view_window` — open or focus a detached OS window for one project view. /// `set_focused_project` — update the backend focused-project state and notify panels.
#[tauri::command]
pub async fn set_focused_project(
app: AppHandle,
project: Option<FocusedProjectDto>,
state: State<'_, AppState>,
) -> Result<(), ErrorDto> {
state.set_focused_project(project.clone());
app.emit(
FOCUSED_PROJECT_CHANGED_EVENT,
FocusedProjectChangedEventDto { project },
)
.map_err(internal_window_error)
}
/// `get_focused_project` — read the backend focused-project state.
#[tauri::command]
pub async fn get_focused_project(
state: State<'_, AppState>,
) -> Result<Option<FocusedProjectDto>, ErrorDto> {
Ok(state.get_focused_project())
}
/// `open_view_window` — open or focus a detached OS window for one panel.
/// ///
/// The window is a normal decorated, resizable system window. Its webview loads /// The window is a normal decorated, resizable system window. Its webview loads
/// `index.html?panel=<panel>&project=<projectId>` so the frontend can boot a /// `index.html?panel=<panel>` so the frontend can boot a panel-only shell that
/// panel-only shell with the regular gateways and read models. /// follows the backend focused-project state.
/// ///
/// # Errors /// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project /// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// id, `NOT_FOUND`/`STORE` if the project cannot be loaded, `INTERNAL` if Tauri
/// fails to create/focus the window). /// fails to create/focus the window).
#[tauri::command] #[tauri::command]
pub async fn open_view_window( pub async fn open_view_window(
app: AppHandle, app: AppHandle,
panel: String, panel: String,
project_id: String,
state: State<'_, AppState>,
) -> Result<OpenViewWindowResponseDto, ErrorDto> { ) -> Result<OpenViewWindowResponseDto, ErrorDto> {
let panel = ViewPanel::parse(&panel)?; let panel = ViewPanel::parse(&panel)?;
let project = resolve_project(&project_id, &state).await?; let label = view_window_label(panel);
let project_id = project.id; let url = view_window_url(panel);
let label = view_window_label(panel, project_id);
let url = view_window_url(panel, project_id);
if let Some(window) = app.get_webview_window(&label) { if let Some(window) = app.get_webview_window(&label) {
window.show().map_err(internal_window_error)?; window.show().map_err(internal_window_error)?;
window.set_focus().map_err(internal_window_error)?; window.set_focus().map_err(internal_window_error)?;
emit_view_window_lifecycle(&app, "focused", panel, project_id, &label); emit_view_window_lifecycle(&app, "focused", panel, &label);
return Ok(OpenViewWindowResponseDto { return Ok(OpenViewWindowResponseDto {
label, label,
url, url,
@ -2481,10 +2505,10 @@ pub async fn open_view_window(
let event_label = label.clone(); let event_label = label.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let WindowEvent::CloseRequested { .. } = event { if let WindowEvent::CloseRequested { .. } = event {
emit_view_window_lifecycle(&event_app, "closed", panel, project_id, &event_label); emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
} }
}); });
emit_view_window_lifecycle(&app, "opened", panel, project_id, &label); emit_view_window_lifecycle(&app, "opened", panel, &label);
Ok(OpenViewWindowResponseDto { Ok(OpenViewWindowResponseDto {
label, label,
@ -2493,20 +2517,18 @@ pub async fn open_view_window(
}) })
} }
/// `close_view_window` — request closing the detached OS window for one project view. /// `close_view_window` — request closing the detached OS window for one panel.
/// ///
/// # Errors /// # Errors
/// Returns an [`ErrorDto`] (`INVALID` for an unknown panel or malformed project /// Returns an [`ErrorDto`] (`INVALID` for an unknown panel, `INTERNAL` if Tauri
/// id, `INTERNAL` if Tauri fails to close the window). /// fails to close the window).
#[tauri::command] #[tauri::command]
pub async fn close_view_window( pub async fn close_view_window(
app: AppHandle, app: AppHandle,
panel: String, panel: String,
project_id: String,
) -> Result<CloseViewWindowResponseDto, ErrorDto> { ) -> Result<CloseViewWindowResponseDto, ErrorDto> {
let panel = ViewPanel::parse(&panel)?; let panel = ViewPanel::parse(&panel)?;
let project_id = parse_project_id(&project_id)?; let label = view_window_label(panel);
let label = view_window_label(panel, project_id);
let Some(window) = app.get_webview_window(&label) else { let Some(window) = app.get_webview_window(&label) else {
return Ok(CloseViewWindowResponseDto { return Ok(CloseViewWindowResponseDto {
label, label,
@ -2531,22 +2553,13 @@ fn internal_window_error(error: impl std::fmt::Display) -> ErrorDto {
#[cfg(test)] #[cfg(test)]
mod view_window_tests { mod view_window_tests {
use super::*; use super::*;
use domain::ProjectId;
use uuid::Uuid;
#[test] #[test]
fn view_window_label_and_url_are_stable() { fn view_window_label_and_url_are_stable() {
let project_id = ProjectId::from_uuid(Uuid::from_u128(0x123));
let panel = ViewPanel::Tickets; let panel = ViewPanel::Tickets;
assert_eq!( assert_eq!(view_window_label(panel), "view-tickets");
view_window_label(panel, project_id), assert_eq!(view_window_url(panel), "index.html?panel=tickets");
"view-tickets-00000000000000000000000000000123"
);
assert_eq!(
view_window_url(panel, project_id),
"index.html?panel=tickets&project=00000000-0000-0000-0000-000000000123"
);
} }
#[test] #[test]
@ -2562,12 +2575,11 @@ mod view_window_tests {
let payload = ViewWindowLifecycleEventDto { let payload = ViewWindowLifecycleEventDto {
kind: "closed", kind: "closed",
panel: "tickets".to_owned(), panel: "tickets".to_owned(),
project_id: Uuid::from_u128(7).to_string(),
label: "view-tickets-7".to_owned(), label: "view-tickets-7".to_owned(),
}; };
let json = serde_json::to_string(&payload).unwrap(); let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("\"projectId\""), "json was {json}"); assert!(!json.contains("projectId"), "json was {json}");
assert!(!json.contains("project_id"), "no snake_case leak: {json}"); assert!(!json.contains("project_id"), "no snake_case leak: {json}");
} }
} }

View File

@ -262,6 +262,8 @@ pub fn run() {
commands::read_memory_index, commands::read_memory_index,
commands::recall_memory, commands::recall_memory,
commands::resolve_memory_links, commands::resolve_memory_links,
commands::set_focused_project,
commands::get_focused_project,
commands::open_view_window, commands::open_view_window,
commands::close_view_window, commands::close_view_window,
commands::move_tab_to_new_window, commands::move_tab_to_new_window,
@ -351,25 +353,26 @@ fn persisted_window_identity(
return Some((PersistedWindowKind::Main, None, None, None)); return Some((PersistedWindowKind::Main, None, None, None));
} }
let (panel, project_id) = persisted_view_identity_from_label(label)?; let panel = persisted_view_identity_from_label(label)?;
Some(( Some((
PersistedWindowKind::View, PersistedWindowKind::View,
Some(panel.as_str().to_owned()), Some(panel.as_str().to_owned()),
Some(project_id), None,
Some(commands::view_window_url(panel, project_id)), Some(commands::view_window_url(panel)),
)) ))
} }
fn persisted_view_identity_from_label(label: &str) -> Option<(commands::ViewPanel, ProjectId)> { fn persisted_view_identity_from_label(label: &str) -> Option<commands::ViewPanel> {
let rest = label.strip_prefix("view-")?; let rest = label.strip_prefix("view-")?;
let (panel, project_id) = rest.rsplit_once('-')?; if let Ok(panel) = commands::ViewPanel::parse(rest) {
if project_id.len() != 32 { return Some(panel);
return None;
} }
let panel = commands::ViewPanel::parse(panel).ok()?; let (panel, project_id) = rest.rsplit_once('-')?;
let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).ok()?); if project_id.len() != 32 || Uuid::parse_str(project_id).is_err() {
Some((panel, project_id)) return None;
}
commands::ViewPanel::parse(panel).ok()
} }
fn restore_open_webview_windows(handle: &tauri::AppHandle) { fn restore_open_webview_windows(handle: &tauri::AppHandle) {
@ -396,30 +399,21 @@ fn restore_open_webview_windows(handle: &tauri::AppHandle) {
} }
fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) { fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState) {
if handle.get_webview_window(&state.label).is_some() {
return;
}
let Some(panel) = state let Some(panel) = state
.panel .panel
.as_deref() .as_deref()
.and_then(|raw| commands::ViewPanel::parse(raw).ok()) .and_then(|raw| commands::ViewPanel::parse(raw).ok())
.or_else(|| persisted_view_identity_from_label(&state.label))
else { else {
return; return;
}; };
let Some(project_id) = state.project_id else { let label = commands::view_window_label(panel);
return; if handle.get_webview_window(&label).is_some() {
};
let expected_label = commands::view_window_label(panel, project_id);
if expected_label != state.label {
return; return;
} }
let url = state let url = commands::view_window_url(panel);
.url
.clone()
.unwrap_or_else(|| commands::view_window_url(panel, project_id));
let Ok(window) = WebviewWindowBuilder::new(handle, &state.label, WebviewUrl::App(url.into())) let Ok(window) = WebviewWindowBuilder::new(handle, &label, WebviewUrl::App(url.into()))
.title(format!("IdeA - {}", panel.title())) .title(format!("IdeA - {}", panel.title()))
.inner_size(1120.0, 760.0) .inner_size(1120.0, 760.0)
.min_inner_size(720.0, 480.0) .min_inner_size(720.0, 480.0)
@ -435,21 +429,15 @@ fn restore_view_window(handle: &tauri::AppHandle, state: &PersistedWindowState)
}; };
let event_app = handle.clone(); let event_app = handle.clone();
let event_label = state.label.clone(); let event_label = label.clone();
window.on_window_event(move |event| { window.on_window_event(move |event| {
if let tauri::WindowEvent::CloseRequested { .. } = event { if let tauri::WindowEvent::CloseRequested { .. } = event {
commands::emit_view_window_lifecycle( commands::emit_view_window_lifecycle(&event_app, "closed", panel, &event_label);
&event_app,
"closed",
panel,
project_id,
&event_label,
);
} }
}); });
apply_persisted_window_state(handle, &window, state); apply_persisted_window_state(handle, &window, state);
commands::emit_view_window_lifecycle(handle, "opened", panel, project_id, &state.label); commands::emit_view_window_lifecycle(handle, "opened", panel, &label);
} }
fn apply_persisted_window_state( fn apply_persisted_window_state(
@ -541,17 +529,30 @@ mod tests {
assert!(project_id.is_none()); assert!(project_id.is_none());
assert!(url.is_none()); assert!(url.is_none());
let (panel, project_id) = let (kind, panel, project_id, url) =
persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a") persisted_window_identity("view-tickets-0000000000000000000000000000002a").unwrap();
.unwrap();
assert_eq!(panel.as_str(), "tickets"); assert_eq!(kind, PersistedWindowKind::View);
assert_eq!(panel.as_deref(), Some("tickets"));
assert!(project_id.is_none());
assert_eq!(url.as_deref(), Some("index.html?panel=tickets"));
assert_eq!( assert_eq!(
project_id.to_string(), persisted_view_identity_from_label("view-tickets-0000000000000000000000000000002a")
"00000000-0000-0000-0000-00000000002a" .unwrap()
.as_str(),
"tickets"
); );
} }
#[test]
fn persisted_identity_accepts_new_panel_only_view_labels() {
let (kind, panel, project_id, url) = persisted_window_identity("view-agents").unwrap();
assert_eq!(kind, PersistedWindowKind::View);
assert_eq!(panel.as_deref(), Some("agents"));
assert!(project_id.is_none());
assert_eq!(url.as_deref(), Some("index.html?panel=agents"));
}
#[test] #[test]
fn persisted_identity_filters_unknown_or_headless_labels() { fn persisted_identity_filters_unknown_or_headless_labels() {
assert!(persisted_window_identity("mcp-server").is_none()); assert!(persisted_window_identity("mcp-server").is_none());

View File

@ -62,6 +62,7 @@ use domain::{
InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId, InboxError, InboxItem, InboxItemKind, InboxReceiptStatus, InboxSource, Project, ProjectId,
TaskId, TicketId, TaskId, TicketId,
}; };
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value}; use serde_json::{json, Map, Value};
use uuid::Uuid; use uuid::Uuid;
@ -89,6 +90,18 @@ use crate::openai_tools::{AppOpenAiToolInvoker, LateBoundOpenAiToolInvoker};
use crate::pty::PtyBridge; use crate::pty::PtyBridge;
use crate::tickets::AppTicketToolProvider; use crate::tickets::AppTicketToolProvider;
/// Focused project snapshot shared with detached panel windows.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FocusedProjectDto {
/// Project id as a string.
pub id: String,
/// Project display name.
pub name: String,
/// Absolute project root.
pub root: String,
}
use infrastructure::StdioTransport; use infrastructure::StdioTransport;
use interprocess::local_socket::tokio::Listener as LocalSocketListener; use interprocess::local_socket::tokio::Listener as LocalSocketListener;
@ -907,6 +920,8 @@ pub struct AppState {
pub snapshot_open_windows: Arc<SnapshotOpenWindows>, pub snapshot_open_windows: Arc<SnapshotOpenWindows>,
/// Load restorable Tauri webview-window state on startup. /// Load restorable Tauri webview-window state on startup.
pub restore_open_windows: Arc<RestoreOpenWindows>, pub restore_open_windows: Arc<RestoreOpenWindows>,
/// Project currently focused by the main window; panel-only windows follow it.
focused_project: Mutex<Option<FocusedProjectDto>>,
// --- Background tasks (B8) --- // --- Background tasks (B8) ---
/// Spawn a command-backed first-class background task. /// Spawn a command-backed first-class background task.
pub spawn_background_command: Arc<SpawnBackgroundCommand>, pub spawn_background_command: Arc<SpawnBackgroundCommand>,
@ -2376,6 +2391,7 @@ impl AppState {
move_tab, move_tab,
snapshot_open_windows, snapshot_open_windows,
restore_open_windows, restore_open_windows,
focused_project: Mutex::new(None),
spawn_background_command, spawn_background_command,
cancel_background_task, cancel_background_task,
retry_background_task, retry_background_task,
@ -2383,6 +2399,23 @@ impl AppState {
} }
} }
/// Updates the focused project state. `None` means no project is focused/open.
pub fn set_focused_project(&self, project: Option<FocusedProjectDto>) {
*self
.focused_project
.lock()
.expect("focused project mutex poisoned") = project;
}
/// Returns the current focused project, if any.
#[must_use]
pub fn get_focused_project(&self) -> Option<FocusedProjectDto> {
self.focused_project
.lock()
.expect("focused project mutex poisoned")
.clone()
}
/// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already /// Starts a [`FsOrchestratorWatcher`] for `project` if one is not already
/// running for it (idempotent). The watcher tails the project's /// running for it (idempotent). The watcher tails the project's
/// `.ideai/requests/` tree and dispatches each request through the shared /// `.ideai/requests/` tree and dispatches each request through the shared

View File

@ -10,7 +10,7 @@ use domain::ids::{TabId, WindowId};
use std::collections::HashSet; use std::collections::HashSet;
use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace}; use domain::layout::{PersistedWindowKind, PersistedWindowState, WindowStateSnapshot, Workspace};
use domain::ports::{IdGenerator, ProjectStore, StoreError, WindowStateStore}; use domain::ports::{IdGenerator, ProjectStore, WindowStateStore};
use crate::error::AppError; use crate::error::AppError;
@ -115,21 +115,24 @@ pub struct RestoreOpenWindowsOutput {
/// Loads and filters the latest persisted OS window snapshot. /// Loads and filters the latest persisted OS window snapshot.
pub struct RestoreOpenWindows { pub struct RestoreOpenWindows {
windows: Arc<dyn WindowStateStore>, windows: Arc<dyn WindowStateStore>,
projects: Arc<dyn ProjectStore>, _projects: Arc<dyn ProjectStore>,
} }
impl RestoreOpenWindows { impl RestoreOpenWindows {
/// Builds the use case from its ports. /// Builds the use case from its ports.
#[must_use] #[must_use]
pub fn new(windows: Arc<dyn WindowStateStore>, projects: Arc<dyn ProjectStore>) -> Self { pub fn new(windows: Arc<dyn WindowStateStore>, projects: Arc<dyn ProjectStore>) -> Self {
Self { windows, projects } Self {
windows,
_projects: projects,
}
} }
/// Loads restorable windows. /// Loads restorable windows.
/// ///
/// Views are ignored if their project no longer exists or if their persisted /// Views are panel-only: their persisted project id, including legacy
/// identity is incomplete. Duplicate labels are ignored after the first /// `view-<panel>-<project>` identities, is ignored. Duplicate labels are ignored
/// occurrence. /// after the first occurrence.
/// ///
/// # Errors /// # Errors
/// [`AppError::Store`] on snapshot read failure. /// [`AppError::Store`] on snapshot read failure.
@ -146,17 +149,10 @@ impl RestoreOpenWindows {
match window.kind { match window.kind {
PersistedWindowKind::Main => windows.push(window), PersistedWindowKind::Main => windows.push(window),
PersistedWindowKind::View => { PersistedWindowKind::View => {
let Some(project_id) = window.project_id else { if window.panel.is_none() {
continue;
};
if window.panel.is_none() || window.url.is_none() {
continue; continue;
} }
match self.projects.load_project(project_id).await { windows.push(window);
Ok(_) => windows.push(window),
Err(StoreError::NotFound) => {}
Err(e) => return Err(e.into()),
}
} }
} }
} }

View File

@ -232,12 +232,14 @@ async fn snapshot_open_windows_persists_the_supplied_snapshot() {
} }
#[tokio::test] #[tokio::test]
async fn restore_open_windows_deduplicates_and_filters_missing_projects() { async fn restore_open_windows_keeps_panel_views_without_reopening_projects() {
let existing = ProjectId::from_uuid(Uuid::from_u128(42)); let existing = ProjectId::from_uuid(Uuid::from_u128(42));
let missing = ProjectId::from_uuid(Uuid::from_u128(43)); let missing = ProjectId::from_uuid(Uuid::from_u128(43));
let valid_label = "view-tickets-0000000000000000000000000000002a"; let valid_label = "view-tickets-0000000000000000000000000000002a";
let mut incomplete = persisted_view("view-tickets-0000000000000000000000000000002c", existing); let mut url_missing = persisted_view("view-tickets-0000000000000000000000000000002c", existing);
incomplete.url = None; url_missing.url = None;
let mut no_panel = persisted_view("view-tickets-0000000000000000000000000000002d", existing);
no_panel.panel = None;
let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![ let store = FakeWindowStateStore(Arc::new(Mutex::new(WindowStateSnapshot::new(vec![
persisted_main(), persisted_main(),
@ -245,9 +247,10 @@ async fn restore_open_windows_deduplicates_and_filters_missing_projects() {
persisted_view(valid_label, existing), persisted_view(valid_label, existing),
persisted_view(valid_label, existing), persisted_view(valid_label, existing),
persisted_view("view-tickets-0000000000000000000000000000002b", missing), persisted_view("view-tickets-0000000000000000000000000000002b", missing),
incomplete, url_missing,
no_panel,
])))); ]))));
let projects = FakeProjectRegistry::new(vec![existing]); let projects = FakeProjectRegistry::new(vec![]);
let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects)); let uc = RestoreOpenWindows::new(Arc::new(store), Arc::new(projects));
let out = uc.execute().await.unwrap(); let out = uc.execute().await.unwrap();
@ -257,6 +260,11 @@ async fn restore_open_windows_deduplicates_and_filters_missing_projects() {
.iter() .iter()
.map(|w| w.label.as_str()) .map(|w| w.label.as_str())
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec!["main", valid_label] vec![
"main",
valid_label,
"view-tickets-0000000000000000000000000000002b",
"view-tickets-0000000000000000000000000000002c"
]
); );
} }

View File

@ -1081,15 +1081,14 @@ pub struct PersistedMonitorState {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]
pub struct PersistedWindowState { pub struct PersistedWindowState {
/// Stable Tauri label. For detached views this is /// Stable Tauri label. For detached views this is `view-<panel>`.
/// `view-<panel>-<projectId-simple>`.
pub label: String, pub label: String,
/// Window kind. /// Window kind.
pub kind: PersistedWindowKind, pub kind: PersistedWindowKind,
/// Detached view panel id. Present only for [`PersistedWindowKind::View`]. /// Detached view panel id. Present only for [`PersistedWindowKind::View`].
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub panel: Option<String>, pub panel: Option<String>,
/// Detached view project id. Present only for [`PersistedWindowKind::View`]. /// Legacy detached view project id. Ignored by panel-only restore.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub project_id: Option<crate::ids::ProjectId>, pub project_id: Option<crate::ids::ProjectId>,
/// App URL loaded in the window. /// App URL loaded in the window.