Add --add/--new to control CLI behaviour (#9202)

When neither is specified, if you open a directory you get a new
workspace, otherwise files are added to your existing workspace.

With --new files are always opened in a new workspace
With --add directories are always added to an existing workspace

Fixes #9076
Fixes #4861
Fixes #5370

Release Notes:

- Added `-n/--new` and `-a/--add` to the zed CLI. When neither is
specified, if you open a directory you get a new workspace, otherwise
files are added to your existing workspace. With `--new` files are
always opened in a new workspace, with `--add` directories are always
added to an existing workspace.
([#9076](https://github.com/zed-industries/zed/issues/9096),
[#4861](https://github.com/zed-industries/zed/issues/4861),
[#5370](https://github.com/zed-industries/zed/issues/5370)).
This commit is contained in:
Conrad Irwin 2024-03-12 14:27:58 -06:00 committed by GitHub
parent 89c67fb1ab
commit 05dfe96f0c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 369 additions and 103 deletions

View file

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

View file

@ -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();

View file

@ -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();