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 @@ +