diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index 3a0abbaec7..03201a1ddf 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -9,12 +9,11 @@ pub struct IpcHandshake { #[derive(Debug, Serialize, Deserialize)] pub enum CliRequest { - // The filed is named `path` for compatibility, but now CLI can request - // opening a path at a certain row and/or column: `some/path:123` and `some/path:123:456`. - // - // Since Zed CLI has to be installed separately, there can be situations when old CLI is - // querying new Zed editors, support both formats by using `String` here and parsing it on Zed side later. - Open { paths: Vec, wait: bool }, + Open { + paths: Vec, + wait: bool, + open_new_workspace: Option, + }, } #[derive(Debug, Serialize, Deserialize)] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index e6ec1e559d..121d9ef611 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -12,12 +12,18 @@ use std::{ }; use util::paths::PathLikeWithPosition; -#[derive(Parser)] +#[derive(Parser, Debug)] #[clap(name = "zed", global_setting(clap::AppSettings::NoAutoVersion))] struct Args { /// Wait for all of the given paths to be opened/closed before exiting. #[clap(short, long)] wait: bool, + /// Add files to the currently open workspace + #[clap(short, long, overrides_with = "new")] + add: bool, + /// Create a new workspace + #[clap(short, long, overrides_with = "add")] + new: bool, /// A sequence of space-separated paths that you want to open. /// /// Use `path:line:row` syntax to open a file at a specific location. @@ -67,6 +73,13 @@ fn main() -> Result<()> { } let (tx, rx) = bundle.launch()?; + let open_new_workspace = if args.new { + Some(true) + } else if args.add { + Some(false) + } else { + None + }; tx.send(CliRequest::Open { paths: args @@ -81,6 +94,7 @@ fn main() -> Result<()> { }) .collect::>()?, wait: args.wait, + open_new_workspace, })?; while let Ok(response) = rx.recv() { diff --git a/crates/journal/src/journal.rs b/crates/journal/src/journal.rs index a78d74a55d..43191817bf 100644 --- a/crates/journal/src/journal.rs +++ b/crates/journal/src/journal.rs @@ -102,7 +102,14 @@ pub fn new_journal_entry(app_state: Arc, cx: &mut WindowContext) { cx.spawn(|mut cx| async move { let (journal_dir, entry_path) = create_entry.await?; let (workspace, _) = cx - .update(|cx| workspace::open_paths(&[journal_dir], app_state, None, cx))? + .update(|cx| { + workspace::open_paths( + &[journal_dir], + app_state, + workspace::OpenOptions::default(), + cx, + ) + })? .await?; let opened = workspace diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 8c883b3479..06fcc68db6 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1145,18 +1145,24 @@ impl Project { .map(|worktree| worktree.read(cx).id()) } - pub fn contains_paths(&self, paths: &[PathBuf], cx: &AppContext) -> bool { - paths.iter().all(|path| self.contains_path(path, cx)) + pub fn visibility_for_paths(&self, paths: &[PathBuf], cx: &AppContext) -> Option { + paths + .iter() + .map(|path| self.visibility_for_path(path, cx)) + .max() + .flatten() } - pub fn contains_path(&self, path: &Path, cx: &AppContext) -> bool { - for worktree in self.worktrees() { - let worktree = worktree.read(cx).as_local(); - if worktree.map_or(false, |w| w.contains_abs_path(path)) { - return true; - } - } - false + pub fn visibility_for_path(&self, path: &Path, cx: &AppContext) -> Option { + self.worktrees() + .filter_map(|worktree| { + let worktree = worktree.read(cx); + worktree + .as_local()? + .contains_abs_path(path) + .then(|| worktree.is_visible()) + }) + .max() } pub fn create_entry( diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index a5e8c2df32..0be20c0f43 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -493,9 +493,16 @@ mod tests { }), ) .await; - cx.update(|cx| open_paths(&[PathBuf::from("/dir/main.ts")], app_state, None, cx)) - .await - .unwrap(); + cx.update(|cx| { + open_paths( + &[PathBuf::from("/dir/main.ts")], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); let workspace = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 2524854576..b319f46440 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -262,7 +262,8 @@ pub fn init(app_state: Arc, cx: &mut AppContext) { cx.spawn(move |cx| async move { if let Some(paths) = paths.await.log_err().flatten() { cx.update(|cx| { - open_paths(&paths, app_state, None, cx).detach_and_log_err(cx) + open_paths(&paths, app_state, OpenOptions::default(), cx) + .detach_and_log_err(cx) }) .ok(); } @@ -1414,8 +1415,18 @@ impl Workspace { let app_state = self.app_state.clone(); cx.spawn(|_, mut cx| async move { - cx.update(|cx| open_paths(&paths, app_state, window_to_replace, cx))? - .await?; + cx.update(|cx| { + open_paths( + &paths, + app_state, + OpenOptions { + replace_window: window_to_replace, + ..Default::default() + }, + cx, + ) + })? + .await?; Ok(()) }) } @@ -4361,6 +4372,13 @@ pub async fn get_any_active_workspace( fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option> { cx.update(|cx| { + if let Some(workspace_window) = cx + .active_window() + .and_then(|window| window.downcast::()) + { + return Some(workspace_window); + } + for window in cx.windows() { if let Some(workspace_window) = window.downcast::() { workspace_window @@ -4375,11 +4393,17 @@ fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option, + pub replace_window: Option>, +} + #[allow(clippy::type_complexity)] pub fn open_paths( abs_paths: &[PathBuf], app_state: Arc, - requesting_window: Option>, + open_options: OpenOptions, cx: &mut AppContext, ) -> Task< anyhow::Result<( @@ -4388,24 +4412,62 @@ pub fn open_paths( )>, > { let abs_paths = abs_paths.to_vec(); - // Open paths in existing workspace if possible - let existing = activate_workspace_for_project(cx, { - let abs_paths = abs_paths.clone(); - move |project, cx| project.contains_paths(&abs_paths, cx) - }); + let mut existing = None; + let mut best_match = None; + let mut open_visible = OpenVisible::All; + + if open_options.open_new_workspace != Some(true) { + for window in cx.windows() { + let Some(handle) = window.downcast::() else { + continue; + }; + if let Ok(workspace) = handle.read(cx) { + let m = workspace + .project() + .read(cx) + .visibility_for_paths(&abs_paths, cx); + if m > best_match { + existing = Some(handle); + best_match = m; + } else if best_match.is_none() && open_options.open_new_workspace == Some(false) { + existing = Some(handle) + } + } + } + } + cx.spawn(move |mut cx| async move { + if open_options.open_new_workspace.is_none() && existing.is_none() { + let all_files = abs_paths.iter().map(|path| app_state.fs.metadata(path)); + if futures::future::join_all(all_files) + .await + .into_iter() + .filter_map(|result| result.ok().flatten()) + .all(|file| !file.is_dir) + { + existing = activate_any_workspace_window(&mut cx); + open_visible = OpenVisible::None; + } + } + if let Some(existing) = existing { Ok(( existing, existing .update(&mut cx, |workspace, cx| { - workspace.open_paths(abs_paths, OpenVisible::All, None, cx) + cx.activate_window(); + workspace.open_paths(abs_paths, open_visible, None, cx) })? .await, )) } else { cx.update(move |cx| { - Workspace::new_local(abs_paths, app_state.clone(), requesting_window, cx) + Workspace::new_local( + abs_paths, + app_state.clone(), + open_options.replace_window, + cx, + ) })? .await } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index d42fadb69c..adfc0fd60d 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -264,24 +264,14 @@ fn main() { cx.set_menus(app_menus()); initialize_workspace(app_state.clone(), cx); - if stdout_is_a_pty() { - // todo(linux): unblock this - #[cfg(not(target_os = "linux"))] - upload_panics_and_crashes(http.clone(), cx); - cx.activate(true); - let urls = collect_url_args(cx); - if !urls.is_empty() { - listener.open_urls(urls) - } - } else { - upload_panics_and_crashes(http.clone(), cx); - // TODO Development mode that forces the CLI mode usually runs Zed binary as is instead - // of an *app, hence gets no specific callbacks run. Emulate them here, if needed. - if std::env::var(FORCE_CLI_MODE_ENV_VAR_NAME).ok().is_some() - && !listener.triggered.load(Ordering::Acquire) - { - listener.open_urls(collect_url_args(cx)) - } + // todo(linux): unblock this + upload_panics_and_crashes(http.clone(), cx); + + cx.activate(true); + + let urls = collect_url_args(cx); + if !urls.is_empty() { + listener.open_urls(urls) } let mut triggered_authentication = false; @@ -339,8 +329,13 @@ fn handle_open_request( if !request.open_paths.is_empty() { let app_state = app_state.clone(); task = Some(cx.spawn(|mut cx| async move { - let (_window, results) = - open_paths_with_positions(&request.open_paths, app_state, &mut cx).await?; + let (_window, results) = open_paths_with_positions( + &request.open_paths, + app_state, + workspace::OpenOptions::default(), + &mut cx, + ) + .await?; for result in results.into_iter().flatten() { if let Err(err) = result { log::error!("Error opening path: {err}",); @@ -441,9 +436,16 @@ async fn installation_id() -> Result<(String, bool)> { async fn restore_or_create_workspace(app_state: Arc, cx: AsyncAppContext) { async_maybe!({ if let Some(location) = workspace::last_opened_workspace_paths().await { - cx.update(|cx| workspace::open_paths(location.paths().as_ref(), app_state, None, cx))? - .await - .log_err(); + cx.update(|cx| { + workspace::open_paths( + location.paths().as_ref(), + app_state, + workspace::OpenOptions::default(), + cx, + ) + })? + .await + .log_err(); } else if matches!(KEY_VALUE_STORE.read_kvp(FIRST_OPEN), Ok(None)) { cx.update(|cx| show_welcome_view(app_state, cx)).log_err(); } else { @@ -901,7 +903,7 @@ fn collect_url_args(cx: &AppContext) -> Vec { .filter_map(|arg| match std::fs::canonicalize(Path::new(&arg)) { Ok(path) => Some(format!("file://{}", path.to_string_lossy())), Err(error) => { - if arg.starts_with("file://") { + if arg.starts_with("file://") || arg.starts_with("zed-cli://") { Some(arg) } else if let Some(_) = parse_zed_link(&arg, cx) { Some(arg) diff --git a/crates/zed/src/open_listener.rs b/crates/zed/src/open_listener.rs index a119623b0a..a820f0cc4b 100644 --- a/crates/zed/src/open_listener.rs +++ b/crates/zed/src/open_listener.rs @@ -11,11 +11,10 @@ use futures::{FutureExt, SinkExt, StreamExt}; use gpui::{AppContext, AsyncAppContext, Global, WindowHandle}; use language::{Bias, Point}; use std::path::Path; -use std::sync::atomic::Ordering; +use std::path::PathBuf; use std::sync::Arc; use std::thread; use std::time::Duration; -use std::{path::PathBuf, sync::atomic::AtomicBool}; use util::paths::PathLikeWithPosition; use util::ResultExt; use workspace::item::ItemHandle; @@ -89,7 +88,6 @@ impl OpenRequest { pub struct OpenListener { tx: UnboundedSender>, - pub triggered: AtomicBool, } struct GlobalOpenListener(Arc); @@ -107,17 +105,10 @@ impl OpenListener { pub fn new() -> (Self, UnboundedReceiver>) { let (tx, rx) = mpsc::unbounded(); - ( - OpenListener { - tx, - triggered: AtomicBool::new(false), - }, - rx, - ) + (OpenListener { tx }, rx) } pub fn open_urls(&self, urls: Vec) { - self.triggered.store(true, Ordering::Release); self.tx .unbounded_send(urls) .map_err(|_| anyhow!("no listener for open requests")) @@ -157,6 +148,7 @@ fn connect_to_cli( pub async fn open_paths_with_positions( path_likes: &Vec>, app_state: Arc, + open_options: workspace::OpenOptions, cx: &mut AsyncAppContext, ) -> Result<( WindowHandle, @@ -180,7 +172,7 @@ pub async fn open_paths_with_positions( .collect::>(); let (workspace, items) = cx - .update(|cx| workspace::open_paths(&paths, app_state, None, cx))? + .update(|cx| workspace::open_paths(&paths, app_state, open_options, cx))? .await?; for (item, path) in items.iter().zip(&paths) { @@ -215,22 +207,30 @@ pub async fn handle_cli_connection( ) { if let Some(request) = requests.next().await { match request { - CliRequest::Open { paths, wait } => { + CliRequest::Open { + paths, + wait, + open_new_workspace, + } => { let paths = if paths.is_empty() { - 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() + 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() @@ -250,7 +250,17 @@ pub async fn handle_cli_connection( let mut errored = false; - match open_paths_with_positions(&paths, app_state, &mut cx).await { + 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(); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index cef43b4be2..67b323a313 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -905,6 +905,10 @@ mod tests { "da": null, "db": null, }, + "e": { + "ea": null, + "eb": null, + } }), ) .await; @@ -913,7 +917,7 @@ mod tests { open_paths( &[PathBuf::from("/root/a"), PathBuf::from("/root/b")], app_state.clone(), - None, + workspace::OpenOptions::default(), cx, ) }) @@ -921,9 +925,16 @@ mod tests { .unwrap(); assert_eq!(cx.read(|cx| cx.windows().len()), 1); - cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], app_state.clone(), None, cx)) - .await - .unwrap(); + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/a")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); assert_eq!(cx.read(|cx| cx.windows().len()), 1); let workspace_1 = cx .read(|cx| cx.windows()[0].downcast::()) @@ -942,9 +953,9 @@ mod tests { cx.update(|cx| { open_paths( - &[PathBuf::from("/root/b"), PathBuf::from("/root/c")], + &[PathBuf::from("/root/c"), PathBuf::from("/root/d")], app_state.clone(), - None, + workspace::OpenOptions::default(), cx, ) }) @@ -958,9 +969,12 @@ mod tests { .unwrap(); cx.update(|cx| { open_paths( - &[PathBuf::from("/root/c"), PathBuf::from("/root/d")], + &[PathBuf::from("/root/e")], app_state, - Some(window), + workspace::OpenOptions { + replace_window: Some(window), + ..Default::default() + }, cx, ) }) @@ -978,7 +992,7 @@ mod tests { .worktrees(cx) .map(|w| w.read(cx).abs_path()) .collect::>(), - &[Path::new("/root/c").into(), Path::new("/root/d").into()] + &[Path::new("/root/e").into()] ); assert!(workspace.left_dock().read(cx).is_open()); assert!(workspace.active_pane().focus_handle(cx).is_focused(cx)); @@ -986,6 +1000,123 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_open_add_new(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree("/root", json!({"a": "hey", "b": "", "dir": {"c": "f"}})) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/dir")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/a")], + app_state.clone(), + workspace::OpenOptions { + open_new_workspace: Some(false), + ..Default::default() + }, + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/dir/c")], + app_state.clone(), + workspace::OpenOptions { + open_new_workspace: Some(true), + ..Default::default() + }, + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 2); + } + + #[gpui::test] + async fn test_open_file_in_many_spaces(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree("/root", json!({"dir1": {"a": "b"}, "dir2": {"c": "d"}})) + .await; + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/dir1/a")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + let window1 = cx.update(|cx| cx.active_window().unwrap()); + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/dir2/c")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 1); + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/dir2")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 2); + let window2 = cx.update(|cx| cx.active_window().unwrap()); + assert!(window1 != window2); + cx.update_window(window1, |_, cx| cx.activate_window()) + .unwrap(); + + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/dir2/c")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); + assert_eq!(cx.update(|cx| cx.windows().len()), 2); + // should have opened in window2 because that has dir2 visibly open (window1 has it open, but not in the project panel) + assert!(cx.update(|cx| cx.active_window().unwrap()) == window2); + } + #[gpui::test] async fn test_window_edit_state(cx: &mut TestAppContext) { let executor = cx.executor(); @@ -996,9 +1127,16 @@ mod tests { .insert_tree("/root", json!({"a": "hey"})) .await; - cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], app_state.clone(), None, cx)) - .await - .unwrap(); + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/a")], + app_state.clone(), + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); // When opening the workspace, the window is not in a edited state. @@ -1063,9 +1201,16 @@ mod tests { assert!(!window_is_edited(window, cx)); // Opening the buffer again doesn't impact the window's edited state. - cx.update(|cx| open_paths(&[PathBuf::from("/root/a")], app_state, None, cx)) - .await - .unwrap(); + cx.update(|cx| { + open_paths( + &[PathBuf::from("/root/a")], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); let editor = window .read_with(cx, |workspace, cx| { workspace @@ -1292,9 +1437,16 @@ mod tests { ) .await; - cx.update(|cx| open_paths(&[PathBuf::from("/dir1/")], app_state, None, cx)) - .await - .unwrap(); + cx.update(|cx| { + open_paths( + &[PathBuf::from("/dir1/")], + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) + .await + .unwrap(); assert_eq!(cx.update(|cx| cx.windows().len()), 1); let window = cx.update(|cx| cx.windows()[0].downcast::().unwrap()); let workspace = window.root(cx).unwrap(); @@ -1526,7 +1678,14 @@ mod tests { Path::new("/root/excluded_dir/ignored_subdir").to_path_buf(), ]; let (opened_workspace, new_items) = cx - .update(|cx| workspace::open_paths(&paths_to_open, app_state, None, cx)) + .update(|cx| { + workspace::open_paths( + &paths_to_open, + app_state, + workspace::OpenOptions::default(), + cx, + ) + }) .await .unwrap();