diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs index 5e948a79..da095146 100644 --- a/crates/ide-workspace/src/window_groups.rs +++ b/crates/ide-workspace/src/window_groups.rs @@ -1,6 +1,7 @@ //! Window grouping is independent of a window's project or workspace. use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; +use std::path::PathBuf; /// Platform adapters supply the system's tab preference and active project state. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -117,6 +118,43 @@ impl WindowGroups { } } +/// One entry of the window session reopened at the next start (`window-groups.json`): a +/// window group, or a single project window (one project). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SavedWindows { + pub projects: Vec, + /// Index into `projects` of the group's selected tab. + pub selected: usize, +} + +/// The project windows open now, to reopen at the next start: every group with its selected +/// tab, then each window outside a group, in the order of `windows`. `project_of` gives a +/// window's project (`None` for the welcome screen or LightEdit). `None` while a grouped +/// window has no project yet (a restored member still opening): keep the previous session. +pub fn session( + groups: &[WindowGroup], + windows: &[String], + project_of: impl Fn(&str) -> Option, +) -> Option> { + let mut saved = Vec::new(); + for group in groups { + let projects: Option> = group.members.iter().map(|label| project_of(label)).collect(); + let selected = group.members.iter().position(|l| l == &group.selected).unwrap_or(0); + saved.push(SavedWindows { projects: projects?, selected }); + } + for label in windows { + if groups.iter().any(|g| g.members.contains(label)) { + continue; + } + if let Some(project) = project_of(label) + && !saved.iter().any(|s| s.projects.contains(&project)) + { + saved.push(SavedWindows { projects: vec![project], selected: 0 }); + } + } + Some(saved) +} + #[cfg(test)] mod tests { use super::*; @@ -186,4 +224,28 @@ mod tests { groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap(); assert_eq!(groups.group("a").unwrap().members[0], "d"); } + + #[test] + fn the_session_keeps_groups_and_every_ungrouped_project_window() { + let groups = vec![WindowGroup { members: members(&["project-1", "project-2"]), selected: "project-2".into() }]; + let windows = members(&["main", "project-1", "project-2", "project-3", "project-4"]); + let project_of = |label: &str| match label { + "main" => Some(PathBuf::from("/a")), + "project-1" => Some(PathBuf::from("/b")), + "project-2" => Some(PathBuf::from("/c")), + "project-3" => Some(PathBuf::from("/d")), + // The welcome screen or LightEdit. + _ => None, + }; + assert_eq!( + session(&groups, &windows, project_of).unwrap(), + [ + SavedWindows { projects: vec!["/b".into(), "/c".into()], selected: 1 }, + SavedWindows { projects: vec!["/a".into()], selected: 0 }, + SavedWindows { projects: vec!["/d".into()], selected: 0 }, + ] + ); + // A grouped window still opening its project: keep the previous session. + assert_eq!(session(&groups, &windows, |l| (l != "project-2").then(|| PathBuf::from(l))), None); + } } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index afed18d5..7c578337 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -85,10 +85,8 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult().sink.emit(ide_core::IdeEvent::WindowGroupsChanged); - let grouped = window.state::().groups.lock().group(window.label()).is_some(); - if grouped { - crate::window_groups::persist(window.app_handle()).await?; - } + // Every project window is part of the session reopened at the next start. + crate::window_groups::persist(window.app_handle()).await?; Ok(info) } diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 44e70c2d..5cdb52a8 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -433,44 +433,36 @@ fn close_next(app: &AppHandle) { } } -#[derive(Serialize, Deserialize)] -struct SavedGroup { - projects: Vec, - selected: usize, +/// Window labels in creation order: `main`, then `project-1`, `project-2`, …. +fn labels_in_order(app: &AppHandle) -> Vec { + let mut labels: Vec = app.webview_windows().into_keys().collect(); + labels.sort_by_key(|l| (l != "main", l.rsplit('-').next().and_then(|n| n.parse::().ok()), l.clone())); + labels } +/// Saves the open project windows (groups and single windows) for "Reopen projects on startup". +/// Nothing is written while quitting, so the windows open at quit are the ones reopened. pub async fn persist(app: &AppHandle) -> IdeResult<()> { let state = app.state::(); - let _write = state.groups_persist.lock().await; + let mut previous = state.groups_persist.lock().await; if state.quitting() { return Ok(()); } - let groups = state.groups.lock().groups().to_vec(); let host = app.state::(); - let saved: Vec<_> = groups - .iter() - .filter_map(|group| { - let projects: Vec<_> = group - .members - .iter() - .filter_map(|label| { - host.project(label) - .ok() - .map(|p| p.root.clone()) - .or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into)) - }) - .collect(); - (projects.len() == group.members.len()).then(|| SavedGroup { - projects, - selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0), - }) - }) - .collect(); - // A restored member may have taken its launch but not finished opening yet. - if saved.len() != groups.len() { - return Ok(()); - } let path = host.config_dir.join("window-groups.json"); + if previous.is_none() { + *previous = Some(read_session(path.clone()).await); + } + let groups = state.groups.lock().groups().to_vec(); + let windows = labels_in_order(app); + let project_of = |label: &str| match host.project(label) { + Ok(p) => (!p.light).then(|| p.root.clone()), + // A restored window may have taken its launch but not finished opening yet. + Err(_) => state.pending(label).and_then(|l| l.project).map(Into::into), + }; + let Some(saved) = ide_workspace::window_groups::session(&groups, &windows, project_of) else { + return Ok(()); + }; tauri::async_runtime::spawn_blocking(move || { let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?; ide_vfs::write_atomic(&path, &bytes).map(|_| ()) @@ -479,6 +471,22 @@ pub async fn persist(app: &AppHandle) -> IdeResult<()> { .map_err(|e| IdeError::internal(e.to_string()))? } +/// The saved session; empty when there is none or it can't be read. +async fn read_session(path: PathBuf) -> Vec { + let read = tauri::async_runtime::spawn_blocking(move || match std::fs::read(&path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(format!("{}: {e}", path.display())), + }) + .await + .map_err(|e| e.to_string()) + .and_then(|r| r); + read.unwrap_or_else(|e| { + tracing::warn!("cannot read the saved windows: {e}"); + Vec::new() + }) +} + pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { if caller != "main" { return Ok(()); @@ -489,41 +497,51 @@ pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) { return Ok(()); } - let path = host.config_dir.join("window-groups.json"); - let saved: Vec = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> { - match std::fs::read(path) { - Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), - Err(e) => Err(e.into()), + let saved = { + let mut previous = state.groups_persist.lock().await; + if previous.is_none() { + *previous = Some(read_session(host.config_dir.join("window-groups.json")).await); } - }) - .await - .map_err(|e| IdeError::internal(e.to_string()))??; - if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) { + previous.clone().unwrap_or_default() + }; + // The caller's own group first (it already shows one of its projects), then every other + // group and single window that was open at quit. Missing folders are skipped. + let order = saved + .iter() + .filter(|g| g.projects.contains(&project.root)) + .chain(saved.iter().filter(|g| !g.projects.contains(&project.root))); + for group in order { let mut labels = Vec::new(); - let mut selected = caller.to_owned(); + let mut selected = None; for path in &group.projects { let label = if path == &project.root { Some(caller.to_owned()) } else if path.is_dir() { let window = match windows::find_project_window(app, path) { - Some(w) => w, - None => windows::create_restored_window(app, path.clone())?, + Some(w) => Ok(w), + None => windows::create_restored_window(app, path.clone()), }; - Some(window.label().to_owned()) + match window { + Ok(w) => Some(w.label().to_owned()), + Err(e) => { + tracing::warn!("cannot reopen {}: {e}", path.display()); + None + } + } } else { None }; if let Some(label) = label { if group.projects.get(group.selected) == Some(path) { - selected = label.clone(); + selected = Some(label.clone()); } labels.push(label); } } if labels.len() > 1 { - state.groups.lock().merge(labels, caller)?; - select(app, caller, &selected)?; + let selected = selected.unwrap_or_else(|| labels[0].clone()); + state.groups.lock().merge(labels, &selected)?; + select(app, &selected, &selected)?; } } Ok(()) diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 2bcaa63b..6a0690ba 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -120,7 +120,9 @@ pub fn wsl_launch(path: &Path) -> Option { pub struct WindowsState { pub groups: Mutex, pub groups_restored: AtomicBool, - pub groups_persist: tokio::sync::Mutex<()>, + /// Serialises writes of the saved session. Holds the previous run's session once read: it is + /// read before this run first overwrites the file, so restoring still sees it. + pub groups_persist: tokio::sync::Mutex>>, launches: Mutex>, /// Caret positions asked for on the command line, by canonical file path, until a window takes the file. positions: Mutex>,