From 17ef9a367ff036b9f156ad4b0329c744c10ec6f5 Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Tue, 23 Jul 2024 19:44:02 +0200 Subject: [PATCH] zed: Add ability to restore last session w/ multiple windows (#14965) This adds the ability for Zed to restore multiple windows after restarting. It's now the default behavior. Release Notes: - Added ability to restore all windows that were open when Zed was quit. Previously only the last used workspace was restored. This is now the default behavior. To get back the old behavior, add the following to your settings: `{"restore_on_startup": "last_workspace"}` (Part of [#4985](https://github.com/zed-industries/zed/issues/4985) and [#4683](https://github.com/zed-industries/zed/issues/4683)) Demo: https://github.com/user-attachments/assets/57a375ec-0c6a-4724-97c4-3fea8f18bc2d --------- Co-authored-by: Nathan --- Cargo.lock | 12 + Cargo.toml | 2 + assets/settings/default.json | 2 +- crates/collab/Cargo.toml | 1 + crates/collab/src/tests/test_server.rs | 3 + crates/session/Cargo.toml | 23 + crates/session/LICENSE-GPL | 1 + crates/session/src/session.rs | 44 ++ crates/workspace/Cargo.toml | 3 + crates/workspace/src/persistence.rs | 115 ++++- crates/workspace/src/persistence/model.rs | 2 + crates/workspace/src/workspace.rs | 42 +- crates/workspace/src/workspace_settings.rs | 8 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 76 +++- crates/zed/src/zed.rs | 3 +- crates/zed/src/zed/open_listener.rs | 493 +++++++++++++++------ crates/zed/src/zed/session.rs | 1 + 18 files changed, 660 insertions(+), 172 deletions(-) create mode 100644 crates/session/Cargo.toml create mode 120000 crates/session/LICENSE-GPL create mode 100644 crates/session/src/session.rs create mode 100644 crates/zed/src/zed/session.rs diff --git a/Cargo.lock b/Cargo.lock index 0eb17fffd1..404214707b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2539,6 +2539,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", + "session", "settings", "sha2 0.10.7", "sqlx", @@ -9711,6 +9712,15 @@ dependencies = [ "serde", ] +[[package]] +name = "session" +version = "0.1.0" +dependencies = [ + "db", + "util", + "uuid", +] + [[package]] name = "settings" version = "0.1.0" @@ -13470,6 +13480,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "session", "settings", "smallvec", "sqlez", @@ -13819,6 +13830,7 @@ dependencies = [ "search", "serde", "serde_json", + "session", "settings", "settings_ui", "simplelog", diff --git a/Cargo.toml b/Cargo.toml index 92d4c2a3ce..9e9500aef6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ members = [ "crates/search", "crates/semantic_index", "crates/semantic_version", + "crates/session", "crates/settings", "crates/settings_ui", "crates/snippet", @@ -248,6 +249,7 @@ rpc = { path = "crates/rpc" } search = { path = "crates/search" } semantic_index = { path = "crates/semantic_index" } semantic_version = { path = "crates/semantic_version" } +session = { path = "crates/session" } settings = { path = "crates/settings" } settings_ui = { path = "crates/settings_ui" } snippet = { path = "crates/snippet" } diff --git a/assets/settings/default.json b/assets/settings/default.json index c1dc82c63f..d3e0f43ed1 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -82,7 +82,7 @@ // Whether to confirm before quitting Zed. "confirm_quit": false, // Whether to restore last closed project when fresh Zed instance is opened. - "restore_on_startup": "last_workspace", + "restore_on_startup": "last_session", // Size of the drop target in the editor. "drop_target_size": 0.2, // Whether the window should be closed when using 'close active item' on a window with no tabs. diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 9709762e94..06269b2948 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -106,6 +106,7 @@ dev_server_projects.workspace = true rpc = { workspace = true, features = ["test-support"] } sea-orm = { version = "0.12.x", features = ["sqlx-sqlite"] } serde_json.workspace = true +session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } sqlx = { version = "0.7", features = ["sqlite"] } theme.workspace = true diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 61c0a8239d..cd5ef9ff64 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -32,6 +32,7 @@ use rpc::{ }; use semantic_version::SemanticVersion; use serde_json::json; +use session::Session; use settings::SettingsStore; use std::{ cell::{Ref, RefCell, RefMut}, @@ -276,6 +277,7 @@ impl TestServer { fs: fs.clone(), build_window_options: |_, _| Default::default(), node_runtime: FakeNodeRuntime::new(), + session: Session::test(), }); let os_keymap = "keymaps/default-macos.json"; @@ -403,6 +405,7 @@ impl TestServer { fs: fs.clone(), build_window_options: |_, _| Default::default(), node_runtime: FakeNodeRuntime::new(), + session: Session::test(), }); cx.update(|cx| { diff --git a/crates/session/Cargo.toml b/crates/session/Cargo.toml new file mode 100644 index 0000000000..2366912d66 --- /dev/null +++ b/crates/session/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "session" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/session.rs" +doctest = false + +[features] +test-support = [ + "db/test-support", +] + +[dependencies] +db.workspace = true +uuid.workspace = true +util.workspace = true diff --git a/crates/session/LICENSE-GPL b/crates/session/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/session/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/session/src/session.rs b/crates/session/src/session.rs new file mode 100644 index 0000000000..e7df28f6ef --- /dev/null +++ b/crates/session/src/session.rs @@ -0,0 +1,44 @@ +use db::kvp::KEY_VALUE_STORE; +use util::ResultExt; +use uuid::Uuid; + +#[derive(Clone, Debug)] +pub struct Session { + session_id: String, + old_session_id: Option, +} + +impl Session { + pub async fn new() -> Self { + let key_name = "session_id".to_string(); + + let old_session_id = KEY_VALUE_STORE.read_kvp(&key_name).ok().flatten(); + + let session_id = Uuid::new_v4().to_string(); + + KEY_VALUE_STORE + .write_kvp(key_name, session_id.clone()) + .await + .log_err(); + + Self { + session_id, + old_session_id, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn test() -> Self { + Self { + session_id: Uuid::new_v4().to_string(), + old_session_id: None, + } + } + + pub fn id(&self) -> &str { + &self.session_id + } + pub fn last_session_id(&self) -> Option<&str> { + self.old_session_id.as_deref() + } +} diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 2b178d50dd..b2ab2b2ef7 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -20,6 +20,7 @@ test-support = [ "http/test-support", "db/test-support", "project/test-support", + "session/test-support", "settings/test-support", "gpui/test-support", "fs/test-support", @@ -53,6 +54,7 @@ task.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true +session.workspace = true settings.workspace = true smallvec.workspace = true sqlez.workspace = true @@ -69,5 +71,6 @@ env_logger.workspace = true fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } +session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } http = { workspace = true, features = ["test-support"] } diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 49cd16fca7..fa21f8102e 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -170,6 +170,7 @@ define_connection! { // display: Option, // Display id // fullscreen: Option, // Is the window fullscreen? // centered_layout: Option, // Is the Centered Layout mode activated? + // session_id: Option, // Session id // ) // // pane_groups( @@ -344,6 +345,9 @@ define_connection! { sql!( ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB; ), + sql!( + ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL; + ), ]; } @@ -443,6 +447,7 @@ impl WorkspaceDb { centered_layout: centered_layout.unwrap_or(false), display, docks, + session_id: None, }) } @@ -536,6 +541,7 @@ impl WorkspaceDb { centered_layout: centered_layout.unwrap_or(false), display, docks, + session_id: None, }) } @@ -572,9 +578,10 @@ impl WorkspaceDb { bottom_dock_visible, bottom_dock_active_panel, bottom_dock_zoom, + session_id, timestamp ) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, CURRENT_TIMESTAMP) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, CURRENT_TIMESTAMP) ON CONFLICT DO UPDATE SET local_paths = ?2, @@ -588,8 +595,9 @@ impl WorkspaceDb { bottom_dock_visible = ?10, bottom_dock_active_panel = ?11, bottom_dock_zoom = ?12, + session_id = ?13, timestamp = CURRENT_TIMESTAMP - ))?((workspace.id, &local_paths, &local_paths_order, workspace.docks)) + ))?((workspace.id, &local_paths, &local_paths_order, workspace.docks, workspace.session_id)) .context("Updating workspace")?; } SerializedWorkspaceLocation::DevServer(dev_server_project) => { @@ -675,6 +683,15 @@ impl WorkspaceDb { } } + query! { + fn session_workspace_locations(session_id: String) -> Result> { + SELECT local_paths + FROM workspaces + WHERE session_id = ?1 AND dev_server_project_id IS NULL + ORDER BY timestamp DESC + } + } + query! { fn dev_server_projects() -> Result> { SELECT id, path, dev_server_name @@ -770,6 +787,23 @@ impl WorkspaceDb { .next()) } + pub fn last_session_workspace_locations( + &self, + last_session_id: &str, + ) -> Result> { + let mut result = Vec::new(); + + for location in self.session_workspace_locations(last_session_id.to_owned())? { + if location.paths().iter().all(|path| path.exists()) + && location.paths().iter().any(|path| path.is_dir()) + { + result.push(location); + } + } + + Ok(result) + } + fn get_center_pane_group(&self, workspace_id: WorkspaceId) -> Result { Ok(self .get_pane_group(workspace_id, None)? @@ -983,6 +1017,7 @@ impl WorkspaceDb { #[cfg(test)] mod tests { + use super::*; use db::open_test_db; use gpui; @@ -1065,6 +1100,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, }; let workspace_2 = SerializedWorkspace { @@ -1075,6 +1111,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, }; db.save_workspace(workspace_1.clone()).await; @@ -1177,6 +1214,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, }; db.save_workspace(workspace.clone()).await; @@ -1209,6 +1247,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, }; let mut workspace_2 = SerializedWorkspace { @@ -1219,6 +1258,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, }; db.save_workspace(workspace_1.clone()).await; @@ -1259,6 +1299,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, }; db.save_workspace(workspace_3.clone()).await; @@ -1279,6 +1320,75 @@ mod tests { ); } + #[gpui::test] + async fn test_session_workspace_locations() { + env_logger::try_init().ok(); + + let db = WorkspaceDb(open_test_db("test_serializing_workspaces_session_id").await); + + let workspace_1 = SerializedWorkspace { + id: WorkspaceId(1), + location: SerializedWorkspaceLocation::from_local_paths(["/tmp1"]), + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: Some("session-id-1".to_owned()), + }; + + let workspace_2 = SerializedWorkspace { + id: WorkspaceId(2), + location: SerializedWorkspaceLocation::from_local_paths(["/tmp2"]), + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: Some("session-id-1".to_owned()), + }; + + let workspace_3 = SerializedWorkspace { + id: WorkspaceId(3), + location: SerializedWorkspaceLocation::from_local_paths(["/tmp3"]), + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: Some("session-id-2".to_owned()), + }; + + let workspace_4 = SerializedWorkspace { + id: WorkspaceId(4), + location: SerializedWorkspaceLocation::from_local_paths(["/tmp4"]), + center_group: Default::default(), + window_bounds: Default::default(), + display: Default::default(), + docks: Default::default(), + centered_layout: false, + session_id: None, + }; + + db.save_workspace(workspace_1.clone()).await; + db.save_workspace(workspace_2.clone()).await; + db.save_workspace(workspace_3.clone()).await; + db.save_workspace(workspace_4.clone()).await; + + let locations = db + .session_workspace_locations("session-id-1".to_owned()) + .unwrap(); + assert_eq!(locations.len(), 2); + assert_eq!(locations[0], LocalPaths::new(["/tmp1"])); + assert_eq!(locations[1], LocalPaths::new(["/tmp2"])); + + let locations = db + .session_workspace_locations("session-id-2".to_owned()) + .unwrap(); + assert_eq!(locations.len(), 1); + assert_eq!(locations[0], LocalPaths::new(["/tmp3"])); + } + use crate::persistence::model::SerializedWorkspace; use crate::persistence::model::{SerializedItem, SerializedPane, SerializedPaneGroup}; @@ -1294,6 +1404,7 @@ mod tests { display: Default::default(), docks: Default::default(), centered_layout: false, + session_id: None, } } diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index 4fb5d48749..4795d76cfc 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -58,6 +58,7 @@ impl Column for LocalPaths { fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> { let path_blob = statement.column_blob(start_index)?; let paths: Arc> = if path_blob.is_empty() { + println!("path blog is empty"); Default::default() } else { bincode::deserialize(path_blob).context("Bincode deserialization of paths failed")? @@ -214,6 +215,7 @@ pub(crate) struct SerializedWorkspace { pub(crate) centered_layout: bool, pub(crate) display: Option, pub(crate) docks: DockStructure, + pub(crate) session_id: Option, } #[derive(Debug, PartialEq, Clone, Default)] diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4e606c19d3..b93af61cd5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -58,6 +58,7 @@ pub use persistence::{ use postage::stream::Stream; use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, Worktree, WorktreeId}; use serde::Deserialize; +use session::Session; use settings::Settings; use shared_screen::SharedScreen; use sqlez::{ @@ -536,6 +537,7 @@ pub struct AppState { pub fs: Arc, pub build_window_options: fn(Option, &mut AppContext) -> WindowOptions, pub node_runtime: Arc, + pub session: Session, } struct GlobalAppState(Weak); @@ -569,6 +571,7 @@ impl AppState { #[cfg(any(test, feature = "test-support"))] pub fn test(cx: &mut AppContext) -> Arc { use node_runtime::FakeNodeRuntime; + use session::Session; use settings::SettingsStore; use ui::Context as _; @@ -582,6 +585,7 @@ impl AppState { let clock = Arc::new(clock::FakeSystemClock::default()); let http_client = http::FakeHttpClient::with_404_response(); let client = Client::new(clock, http_client.clone(), cx); + let session = Session::test(); let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx)); let workspace_store = cx.new_model(|cx| WorkspaceStore::new(client.clone(), cx)); @@ -597,6 +601,7 @@ impl AppState { workspace_store, node_runtime: FakeNodeRuntime::new(), build_window_options: |_, _| Default::default(), + session, }) } } @@ -664,6 +669,7 @@ pub enum Event { ZoomChanged, } +#[derive(Debug)] pub enum OpenVisible { All, None, @@ -730,6 +736,7 @@ pub struct Workspace { Option) -> AnyElement>>, serializable_items_tx: UnboundedSender>, _items_serializer: Task>, + session_id: Option, } impl EventEmitter for Workspace {} @@ -908,6 +915,8 @@ impl Workspace { let modal_layer = cx.new_view(|_| ModalLayer::new()); + let session_id = app_state.session.id().to_owned(); + let mut active_call = None; if let Some(call) = ActiveCall::try_global(cx) { let call = call.clone(); @@ -1023,6 +1032,7 @@ impl Workspace { render_disconnected_overlay: None, serializable_items_tx, _items_serializer, + session_id: Some(session_id), } } @@ -1654,10 +1664,20 @@ impl Workspace { } } - this.update(&mut cx, |this, cx| { - this.save_all_internal(SaveIntent::Close, cx) - })? - .await + let save_result = this + .update(&mut cx, |this, cx| { + this.save_all_internal(SaveIntent::Close, cx) + })? + .await; + + // If we're not quitting, but closing, we remove the workspace from + // the current session. + if !quitting && save_result.as_ref().map_or(false, |&res| res) { + this.update(&mut cx, |this, cx| this.remove_from_session(cx))? + .await; + } + + save_result }) } @@ -3838,6 +3858,11 @@ impl Workspace { } } + fn remove_from_session(&mut self, cx: &mut WindowContext) -> Task<()> { + self.session_id.take(); + self.serialize_workspace_internal(cx) + } + fn force_remove_pane(&mut self, pane: &View, cx: &mut ViewContext) { self.panes.retain(|p| p != pane); self.panes @@ -3992,7 +4017,6 @@ impl Workspace { None }; - // don't save workspace state for the empty workspace. if let Some(location) = location { let center_group = build_serialized_pane_group(&self.center.root, cx); let docks = build_serialized_docks(self, cx); @@ -4005,6 +4029,7 @@ impl Workspace { display: Default::default(), docks, centered_layout: self.centered_layout, + session_id: self.session_id.clone(), }; return cx.spawn(|_| persistence::DB.save_workspace(serialized_workspace)); } @@ -4258,6 +4283,7 @@ impl Workspace { #[cfg(any(test, feature = "test-support"))] pub fn test_new(project: Model, cx: &mut ViewContext) -> Self { use node_runtime::FakeNodeRuntime; + use session::Session; let client = project.read(cx).client(); let user_store = project.read(cx).user_store(); @@ -4272,6 +4298,7 @@ impl Workspace { fs: project.read(cx).fs().clone(), build_window_options: |_, _| Default::default(), node_runtime: FakeNodeRuntime::new(), + session: Session::test(), }); let workspace = Self::new(Default::default(), project, app_state, cx); workspace.active_pane.update(cx, |pane, cx| pane.focus(cx)); @@ -4873,6 +4900,11 @@ pub async fn last_opened_workspace_paths() -> Option { DB.last_workspace().await.log_err().flatten() } +pub fn last_session_workspace_locations(last_session_id: &str) -> Option> { + DB.last_session_workspace_locations(last_session_id) + .log_err() +} + actions!(collab, [OpenChannelNotes]); actions!(zed, [OpenLog]); diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 9d195b3389..dfc2de8184 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -47,8 +47,10 @@ pub enum RestoreOnStartupBehaviour { /// Always start with an empty editor None, /// Restore the workspace that was closed last. - #[default] LastWorkspace, + /// Restore all workspaces that were open when quitting Zed. + #[default] + LastSession, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -74,8 +76,8 @@ pub struct WorkspaceSettingsContent { /// Default: off pub autosave: Option, /// Controls previous session restoration in freshly launched Zed instance. - /// Values: none, last_workspace - /// Default: last_workspace + /// Values: none, last_workspace, last_session + /// Default: last_session pub restore_on_startup: Option, /// The size of the workspace split drop targets on the outer edges. /// Given as a fraction that will be multiplied by the smaller dimension of the workspace. diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index f6fcf55101..f33cf26aa6 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -85,6 +85,7 @@ rope.workspace = true search.workspace = true serde.workspace = true serde_json.workspace = true +session.workspace = true settings.workspace = true settings_ui.workspace = true simplelog.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index f5dc0fed42..a508e571f8 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -28,6 +28,7 @@ use assets::Assets; use node_runtime::RealNodeRuntime; use parking_lot::Mutex; use release_channel::{AppCommitSha, AppVersion}; +use session::Session; use settings::{handle_settings_file_changes, watch_config_file, Settings, SettingsStore}; use simplelog::ConfigBuilder; use smol::process::Command; @@ -307,10 +308,15 @@ fn main() { .block(installation_id()) .ok() .unzip(); - let session_id = Uuid::new_v4().to_string(); + + let session = app.background_executor().block(Session::new()); let app_version = AppVersion::init(env!("CARGO_PKG_VERSION")); - reliability::init_panic_hook(installation_id.clone(), app_version, session_id.clone()); + reliability::init_panic_hook( + installation_id.clone(), + app_version, + session.id().to_owned(), + ); let (open_listener, mut open_rx) = OpenListener::new(); @@ -422,7 +428,7 @@ fn main() { client::init(&client, cx); language::init(cx); let telemetry = client.telemetry(); - telemetry.start(installation_id.clone(), session_id, cx); + telemetry.start(installation_id.clone(), session.id().to_owned(), cx); telemetry.report_app_event( match existing_installation_id_found { Some(false) => "first open", @@ -438,6 +444,7 @@ fn main() { build_window_options, workspace_store, node_runtime: node_runtime.clone(), + session, }); AppState::set_global(Arc::downgrade(&app_state), cx); @@ -657,23 +664,18 @@ async fn restore_or_create_workspace( app_state: Arc, cx: &mut AsyncAppContext, ) -> Result<()> { - let restore_behaviour = cx.update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup)?; - let location = match restore_behaviour { - workspace::RestoreOnStartupBehaviour::LastWorkspace => { - workspace::last_opened_workspace_paths().await + if let Some(locations) = restorable_workspace_locations(cx, &app_state).await { + for location in locations { + cx.update(|cx| { + workspace::open_paths( + location.paths().as_ref(), + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + })? + .await?; } - _ => None, - }; - if let Some(location) = location { - cx.update(|cx| { - workspace::open_paths( - location.paths().as_ref(), - app_state, - workspace::OpenOptions::default(), - cx, - ) - })? - .await?; } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { cx.update(|cx| show_welcome_view(app_state, cx))?.await?; } else { @@ -688,6 +690,42 @@ async fn restore_or_create_workspace( Ok(()) } +pub(crate) async fn restorable_workspace_locations( + cx: &mut AsyncAppContext, + app_state: &Arc, +) -> Option> { + let mut restore_behaviour = cx + .update(|cx| WorkspaceSettings::get(None, cx).restore_on_startup) + .ok()?; + + let last_session_id = app_state.session.last_session_id(); + if last_session_id.is_none() + && matches!( + restore_behaviour, + workspace::RestoreOnStartupBehaviour::LastSession + ) + { + restore_behaviour = workspace::RestoreOnStartupBehaviour::LastWorkspace; + } + + match restore_behaviour { + workspace::RestoreOnStartupBehaviour::LastWorkspace => { + workspace::last_opened_workspace_paths() + .await + .map(|location| vec![location]) + } + workspace::RestoreOnStartupBehaviour::LastSession => { + if let Some(last_session_id) = last_session_id { + workspace::last_session_workspace_locations(last_session_id) + .filter(|locations| !locations.is_empty()) + } else { + None + } + } + _ => None, + } +} + fn init_paths() -> anyhow::Result<()> { for path in [ paths::config_dir(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 7cdfbaeda0..11499e554f 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5,6 +5,7 @@ pub(crate) mod linux_prompts; #[cfg(not(target_os = "linux"))] pub(crate) mod only_instance; mod open_listener; +pub(crate) mod session; mod ssh_connection_modal; pub use app_menus::*; @@ -3404,7 +3405,7 @@ mod tests { .unwrap(); } - fn init_test(cx: &mut TestAppContext) -> Arc { + pub(crate) fn init_test(cx: &mut TestAppContext) -> Arc { init_test_with_state(cx, cx.update(|cx| AppState::test(cx))) } diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index f3fa2e95d4..0e1040fc17 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -1,3 +1,4 @@ +use crate::restorable_workspace_locations; use crate::{ handle_open_request, init_headless, init_ui, zed::ssh_connection_modal::SshConnectionModal, }; @@ -528,149 +529,359 @@ pub async fn handle_cli_connection( return; } - let paths = if paths.is_empty() { - if open_new_workspace == Some(true) { - vec![] - } else { - workspace::last_opened_workspace_paths() - .await - .map(|location| { - location - .paths() - .iter() - .map(|path| PathLikeWithPosition { - path_like: path.clone(), - row: None, - column: None, - }) - .collect::>() - }) - .unwrap_or_default() - } - } else { - paths - .into_iter() - .map(|path_with_position_string| { - PathLikeWithPosition::parse_str( - &path_with_position_string, - |_, path_str| { - Ok::<_, std::convert::Infallible>( - Path::new(path_str).to_path_buf(), - ) - }, - ) - .expect("Infallible") - }) - .collect() - }; + let open_workspace_result = open_workspaces( + paths, + open_new_workspace, + &responses, + wait, + app_state.clone(), + &mut cx, + ) + .await; - let mut errored = false; - - if !paths.is_empty() { - match open_paths_with_positions( - &paths, - app_state, - workspace::OpenOptions { - open_new_workspace, - ..Default::default() - }, - &mut cx, - ) - .await - { - Ok((workspace, items)) => { - let mut item_release_futures = Vec::new(); - - for (item, path) in items.into_iter().zip(&paths) { - match item { - Some(Ok(item)) => { - cx.update(|cx| { - let released = oneshot::channel(); - item.on_release( - cx, - Box::new(move |_| { - let _ = released.0.send(()); - }), - ) - .detach(); - item_release_futures.push(released.1); - }) - .log_err(); - } - Some(Err(err)) => { - responses - .send(CliResponse::Stderr { - message: format!("error opening {path:?}: {err}"), - }) - .log_err(); - errored = true; - } - None => {} - } - } - - if wait { - let background = cx.background_executor().clone(); - let wait = async move { - if paths.is_empty() { - let (done_tx, done_rx) = oneshot::channel(); - let _subscription = workspace.update(&mut cx, |_, cx| { - cx.on_release(move |_, _, _| { - let _ = done_tx.send(()); - }) - }); - let _ = done_rx.await; - } else { - let _ = futures::future::try_join_all(item_release_futures) - .await; - }; - } - .fuse(); - futures::pin_mut!(wait); - - loop { - // Repeatedly check if CLI is still open to avoid wasting resources - // waiting for files or workspaces to close. - let mut timer = background.timer(Duration::from_secs(1)).fuse(); - futures::select_biased! { - _ = wait => break, - _ = timer => { - if responses.send(CliResponse::Ping).is_err() { - break; - } - } - } - } - } - } - Err(error) => { - errored = true; - responses - .send(CliResponse::Stderr { - message: format!("error opening {paths:?}: {error}"), - }) - .log_err(); - } - } - } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { - cx.update(|cx| show_welcome_view(app_state, cx).detach()) - .log_err(); - } else { - cx.update(|cx| { - workspace::open_new(app_state, cx, |workspace, cx| { - Editor::new_file(workspace, &Default::default(), cx) - }) - .detach(); - }) - .log_err(); - } - - responses - .send(CliResponse::Exit { - status: i32::from(errored), - }) - .log_err(); + let status = if open_workspace_result.is_err() { 1 } else { 0 }; + responses.send(CliResponse::Exit { status }).log_err(); } } } } + +async fn open_workspaces( + paths: Vec, + open_new_workspace: Option, + responses: &IpcSender, + wait: bool, + app_state: Arc, + mut cx: &mut AsyncAppContext, +) -> Result<()> { + let grouped_paths = if paths.is_empty() { + // If no paths are provided, restore from previous workspaces unless a new workspace is requested with -n + if open_new_workspace == Some(true) { + Vec::new() + } else { + let locations = restorable_workspace_locations(&mut cx, &app_state).await; + locations + .into_iter() + .flat_map(|locations| { + locations + .into_iter() + .map(|location| { + location + .paths() + .iter() + .map(|path| PathLikeWithPosition { + path_like: path.clone(), + row: None, + column: None, + }) + .collect::>() + }) + .collect::>() + }) + .collect() + } + } else { + // If paths are provided, parse them (they include positions) + let paths_with_position = paths + .into_iter() + .map(|path_with_position_string| { + PathLikeWithPosition::parse_str(&path_with_position_string, |_, path_str| { + Ok::<_, std::convert::Infallible>(Path::new(path_str).to_path_buf()) + }) + .expect("Infallible") + }) + .collect(); + vec![paths_with_position] + }; + + if grouped_paths.is_empty() { + // If we have no paths to open, show the welcome screen if this is the first launch + if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { + cx.update(|cx| show_welcome_view(app_state, cx).detach()) + .log_err(); + } + // If not the first launch, show an empty window with empty editor + else { + cx.update(|cx| { + workspace::open_new(app_state, cx, |workspace, cx| { + Editor::new_file(workspace, &Default::default(), cx) + }) + .detach(); + }) + .log_err(); + } + } else { + // If there are paths to open, open a workspace for each grouping of paths + let mut errored = false; + + for workspace_paths in grouped_paths { + let workspace_failed_to_open = open_workspace( + workspace_paths, + open_new_workspace, + wait, + responses, + &app_state, + &mut cx, + ) + .await; + + if workspace_failed_to_open { + errored = true + } + } + + if errored { + return Err(anyhow!("failed to open a workspace")); + } + } + + Ok(()) +} + +async fn open_workspace( + workspace_paths: Vec>, + open_new_workspace: Option, + wait: bool, + responses: &IpcSender, + app_state: &Arc, + cx: &mut AsyncAppContext, +) -> bool { + let mut errored = false; + + match open_paths_with_positions( + &workspace_paths, + app_state.clone(), + workspace::OpenOptions { + open_new_workspace, + ..Default::default() + }, + cx, + ) + .await + { + Ok((workspace, items)) => { + let mut item_release_futures = Vec::new(); + + for (item, path) in items.into_iter().zip(&workspace_paths) { + match item { + Some(Ok(item)) => { + cx.update(|cx| { + let released = oneshot::channel(); + item.on_release( + cx, + Box::new(move |_| { + let _ = released.0.send(()); + }), + ) + .detach(); + item_release_futures.push(released.1); + }) + .log_err(); + } + Some(Err(err)) => { + responses + .send(CliResponse::Stderr { + message: format!("error opening {path:?}: {err}"), + }) + .log_err(); + errored = true; + } + None => {} + } + } + + if wait { + let background = cx.background_executor().clone(); + let wait = async move { + if workspace_paths.is_empty() { + let (done_tx, done_rx) = oneshot::channel(); + let _subscription = workspace.update(cx, |_, cx| { + cx.on_release(move |_, _, _| { + let _ = done_tx.send(()); + }) + }); + let _ = done_rx.await; + } else { + let _ = futures::future::try_join_all(item_release_futures).await; + }; + } + .fuse(); + + futures::pin_mut!(wait); + + loop { + // Repeatedly check if CLI is still open to avoid wasting resources + // waiting for files or workspaces to close. + let mut timer = background.timer(Duration::from_secs(1)).fuse(); + futures::select_biased! { + _ = wait => break, + _ = timer => { + if responses.send(CliResponse::Ping).is_err() { + break; + } + } + } + } + } + } + Err(error) => { + errored = true; + responses + .send(CliResponse::Stderr { + message: format!("error opening {workspace_paths:?}: {error}"), + }) + .log_err(); + } + } + errored +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, sync::Arc}; + + use cli::{ + ipc::{self}, + CliResponse, + }; + use editor::Editor; + use gpui::TestAppContext; + use serde_json::json; + use util::paths::PathLikeWithPosition; + use workspace::{AppState, Workspace}; + + use crate::zed::{open_listener::open_workspace, tests::init_test}; + + #[gpui::test] + async fn test_open_workspace_with_directory(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state + .fs + .as_fake() + .insert_tree( + "/root", + json!({ + "dir1": { + "file1.txt": "content1", + "file2.txt": "content2", + }, + }), + ) + .await; + + assert_eq!(cx.windows().len(), 0); + + // First open the workspace directory + open_workspace_file("/root/dir1", None, app_state.clone(), cx).await; + + assert_eq!(cx.windows().len(), 1); + let workspace = cx.windows()[0].downcast::().unwrap(); + workspace + .update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_none()) + }) + .unwrap(); + + // Now open a file inside that workspace + open_workspace_file("/root/dir1/file1.txt", None, app_state.clone(), cx).await; + + assert_eq!(cx.windows().len(), 1); + workspace + .update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + }) + .unwrap(); + + // Now open a file inside that workspace, but tell Zed to open a new window + open_workspace_file("/root/dir1/file1.txt", Some(true), app_state.clone(), cx).await; + + assert_eq!(cx.windows().len(), 2); + + let workspace_2 = cx.windows()[1].downcast::().unwrap(); + workspace_2 + .update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()); + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 1, "Workspace should have two items"); + }) + .unwrap(); + } + + #[gpui::test] + async fn test_open_workspace_with_nonexistent_files(cx: &mut TestAppContext) { + let app_state = init_test(cx); + + app_state.fs.as_fake().insert_tree("/root", json!({})).await; + + assert_eq!(cx.windows().len(), 0); + + // Test case 1: Open a single file that does not exist yet + open_workspace_file("/root/file5.txt", None, app_state.clone(), cx).await; + + assert_eq!(cx.windows().len(), 1); + let workspace_1 = cx.windows()[0].downcast::().unwrap(); + workspace_1 + .update(cx, |workspace, cx| { + assert!(workspace.active_item_as::(cx).is_some()) + }) + .unwrap(); + + // Test case 2: Open a single file that does not exist yet, + // but tell Zed to add it to the current workspace + open_workspace_file("/root/file6.txt", Some(false), app_state.clone(), cx).await; + + assert_eq!(cx.windows().len(), 1); + workspace_1 + .update(cx, |workspace, cx| { + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 2, "Workspace should have two items"); + }) + .unwrap(); + + // Test case 3: Open a single file that does not exist yet, + // but tell Zed to NOT add it to the current workspace + open_workspace_file("/root/file7.txt", Some(true), app_state.clone(), cx).await; + + assert_eq!(cx.windows().len(), 2); + let workspace_2 = cx.windows()[1].downcast::().unwrap(); + workspace_2 + .update(cx, |workspace, cx| { + let items = workspace.items(cx).collect::>(); + assert_eq!(items.len(), 1, "Workspace should have two items"); + }) + .unwrap(); + } + + async fn open_workspace_file( + path: &str, + open_new_workspace: Option, + app_state: Arc, + cx: &mut TestAppContext, + ) { + let (response_tx, _) = ipc::channel::().unwrap(); + + let path_like = PathBuf::from(path); + let workspace_paths = vec![PathLikeWithPosition { + path_like, + row: None, + column: None, + }]; + + let errored = cx + .spawn(|mut cx| async move { + open_workspace( + workspace_paths, + open_new_workspace, + false, + &response_tx, + &app_state, + &mut cx, + ) + .await + }) + .await; + + assert!(!errored); + } +} diff --git a/crates/zed/src/zed/session.rs b/crates/zed/src/zed/session.rs new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/crates/zed/src/zed/session.rs @@ -0,0 +1 @@ +