Detailed changes
@@ -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<String>, wait: bool },
+ Open {
+ paths: Vec<String>,
+ wait: bool,
+ open_new_workspace: Option<bool>,
+ },
}
#[derive(Debug, Serialize, Deserialize)]
@@ -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::<Result<_>>()?,
wait: args.wait,
+ open_new_workspace,
})?;
while let Ok(response) = rx.recv() {
@@ -102,7 +102,14 @@ pub fn new_journal_entry(app_state: Arc<AppState>, 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
@@ -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<bool> {
+ 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<bool> {
+ 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(
@@ -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::<Workspace>().unwrap());
@@ -262,7 +262,8 @@ pub fn init(app_state: Arc<AppState>, 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<WindowHandle<Workspace>> {
cx.update(|cx| {
+ if let Some(workspace_window) = cx
+ .active_window()
+ .and_then(|window| window.downcast::<Workspace>())
+ {
+ return Some(workspace_window);
+ }
+
for window in cx.windows() {
if let Some(workspace_window) = window.downcast::<Workspace>() {
workspace_window
@@ -4375,11 +4393,17 @@ fn activate_any_workspace_window(cx: &mut AsyncAppContext) -> Option<WindowHandl
.flatten()
}
+#[derive(Default)]
+pub struct OpenOptions {
+ pub open_new_workspace: Option<bool>,
+ pub replace_window: Option<WindowHandle<Workspace>>,
+}
+
#[allow(clippy::type_complexity)]
pub fn open_paths(
abs_paths: &[PathBuf],
app_state: Arc<AppState>,
- requesting_window: Option<WindowHandle<Workspace>>,
+ 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::<Workspace>() 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
}
@@ -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<AppState>, 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<String> {
.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)
@@ -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<Vec<String>>,
- pub triggered: AtomicBool,
}
struct GlobalOpenListener(Arc<OpenListener>);
@@ -107,17 +105,10 @@ impl OpenListener {
pub fn new() -> (Self, UnboundedReceiver<Vec<String>>) {
let (tx, rx) = mpsc::unbounded();
- (
- OpenListener {
- tx,
- triggered: AtomicBool::new(false),
- },
- rx,
- )
+ (OpenListener { tx }, rx)
}
pub fn open_urls(&self, urls: Vec<String>) {
- 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<PathLikeWithPosition<PathBuf>>,
app_state: Arc<AppState>,
+ open_options: workspace::OpenOptions,
cx: &mut AsyncAppContext,
) -> Result<(
WindowHandle<Workspace>,
@@ -180,7 +172,7 @@ pub async fn open_paths_with_positions(
.collect::<Vec<_>>();
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::<Vec<_>>()
- })
- .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::<Vec<_>>()
+ })
+ .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();
@@ -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::<Workspace>())
@@ -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::<Vec<_>>(),
- &[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::<Workspace>().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();