cli: Fix -n behavior and refactor open options (#53939)

Eric Holk created

This fixes a regression where `zed -n .` in a subdirectory of an
already-open
project would redirect to the parent window instead of creating a new
one.
The root cause was that commit 66d2cb20c9 ("Adjust `zed -n` behavior")
made
`-n` run the worktree matching loop with subdirectory matching enabled,
when
previously `-n` skipped matching entirely.

## Changes

### Bug fix
- **Restore `-n` to always create a new window.** No worktree matching,
no
exceptions. This matches the behavior from when `-n` was first
introduced.

### New `--classic` flag
- Adds a hidden `--classic` CLI flag that explicitly selects the
pre-sidebar
default behavior: new window for directories, reuse existing window for
  files already in an open worktree.
- The `cli_default_open_behavior` setting now toggles between `-e` (add
to
sidebar) and `--classic` behavior. When set to `new_window`, the classic
  logic is used instead of unconditionally opening a new window.

### Refactor CLI open options
Replaces the old grab-bag of `open_new_workspace: Option<bool>`,
`force_existing_window: bool`, `classic: bool`, and `reuse: bool` with:

- **`cli::CliOpenBehavior` enum** — a single enum on the IPC boundary
with
variants `Default`, `AlwaysNew`, `Add`, `ExistingWindow`, `Classic`, and
  `Reuse`.
- **`workspace::WorkspaceMatching` enum** — describes how to match paths
against existing worktrees (`None`, `MatchExact`, `MatchSubdirectory`).
- **`workspace::OpenOptions`** — uses `WorkspaceMatching` plus a simple
  `add_dirs_to_sidebar: bool` instead of overlapping boolean flags.

The translation from CLI enum to workspace options happens in
`open_listener.rs`, keeping both layers clean and independent.

Release Notes:

- N/A

Change summary

assets/settings/default.json                |   5 
crates/cli/src/cli.rs                       |  43 ++++
crates/cli/src/main.rs                      |  46 +++--
crates/settings_content/src/workspace.rs    |   5 
crates/settings_ui/src/page_data.rs         |   2 
crates/workspace/src/workspace.rs           | 102 +++++++----
crates/zed/src/zed.rs                       |  28 +-
crates/zed/src/zed/open_listener.rs         | 201 ++++++++++++++--------
crates/zed/src/zed/windows_only_instance.rs |   6 
9 files changed, 282 insertions(+), 156 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -145,9 +145,10 @@
   // an explicit `-e` (existing window) or `-n` (new window) flag.
   //
   // May take 2 values:
-  //  1. Add to the existing Zed window
+  //  1. Open directories as a new workspace in the current Zed window's sidebar
   //         "cli_default_open_behavior": "existing_window"
-  //  2. Open a new Zed window
+  //  2. Open directories in a new window (reuse existing windows for files
+  //     that are already part of an open project)
   //         "cli_default_open_behavior": "new_window"
   "cli_default_open_behavior": "existing_window",
   // Whether to attempt to restore previous file's state when opening it again.

crates/cli/src/cli.rs 🔗

@@ -9,10 +9,45 @@ pub struct IpcHandshake {
     pub responses: ipc::IpcReceiver<CliResponse>,
 }
 
+/// Controls how CLI paths are opened — whether to reuse existing windows,
+/// create new ones, or add to the sidebar.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
+#[serde(rename_all = "snake_case")]
+pub enum OpenBehavior {
+    /// Consult the user's `cli_default_open_behavior` setting to choose between
+    /// `ExistingWindow` or `Classic`.
+    #[default]
+    Default,
+    /// Always create a new window. No matching against existing worktrees.
+    /// Corresponds to `zed -n`.
+    AlwaysNew,
+    /// Match broadly including subdirectories, and fall back to any existing
+    /// window if no worktree matched. Corresponds to `zed -a`.
+    Add,
+    /// Open directories as a new workspace in the current Zed window's sidebar.
+    /// Reuse existing windows for files in open worktrees.
+    /// Corresponds to `zed -e`.
+    ExistingWindow,
+    /// New window for directories, reuse existing window for files in open
+    /// worktrees. The classic pre-sidebar behavior.
+    /// Corresponds to `zed --classic`.
+    Classic,
+    /// Replace the content of an existing window with a new workspace.
+    /// Corresponds to `zed -r`.
+    Reuse,
+}
+
+/// The setting-level enum for configuring default behavior. This only has
+/// two values because the other modes are always explicitly requested via
+/// CLI flags.
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
 #[serde(rename_all = "snake_case")]
-pub enum CliOpenBehavior {
+pub enum CliBehaviorSetting {
+    /// Open directories as a new workspace in the current Zed window's sidebar.
     ExistingWindow,
+    /// Classic behavior: open directories in a new window, but reuse an
+    /// existing window when opening files that are already part of an open
+    /// project.
     NewWindow,
 }
 
@@ -25,16 +60,14 @@ pub enum CliRequest {
         diff_all: bool,
         wsl: Option<String>,
         wait: bool,
-        open_new_workspace: Option<bool>,
         #[serde(default)]
-        force_existing_window: bool,
-        reuse: bool,
+        open_behavior: OpenBehavior,
         env: Option<HashMap<String, String>>,
         user_data_dir: Option<String>,
         dev_container: bool,
     },
     SetOpenBehavior {
-        behavior: CliOpenBehavior,
+        behavior: CliBehaviorSetting,
     },
 }
 

crates/cli/src/main.rs 🔗

@@ -67,17 +67,20 @@ struct Args {
     #[arg(short, long)]
     wait: bool,
     /// Add files to the currently open workspace
-    #[arg(short, long, overrides_with_all = ["new", "reuse", "existing"])]
+    #[arg(short, long, overrides_with_all = ["new", "reuse", "existing", "classic"])]
     add: bool,
     /// Create a new workspace
-    #[arg(short, long, overrides_with_all = ["add", "reuse", "existing"])]
+    #[arg(short, long, overrides_with_all = ["add", "reuse", "existing", "classic"])]
     new: bool,
     /// Reuse an existing window, replacing its workspace
-    #[arg(short, long, overrides_with_all = ["add", "new", "existing"], hide = true)]
+    #[arg(short, long, overrides_with_all = ["add", "new", "existing", "classic"], hide = true)]
     reuse: bool,
     /// Open in existing Zed window
-    #[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse"])]
+    #[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse", "classic"])]
     existing: bool,
+    /// Use the classic open behavior: new window for directories, reuse for files
+    #[arg(long, hide = true, overrides_with_all = ["add", "new", "reuse", "existing"])]
+    classic: bool,
     /// Sets a custom directory for all user data (e.g., database, extensions, logs).
     /// This overrides the default platform-specific data directory location:
     #[cfg_attr(target_os = "macos", doc = "`~/Library/Application Support/Zed`.")]
@@ -538,16 +541,20 @@ fn main() -> Result<()> {
         IpcOneShotServer::<IpcHandshake>::new().context("Handshake before Zed spawn")?;
     let url = format!("zed-cli://{server_name}");
 
-    let open_new_workspace = if args.new {
-        Some(true)
+    let open_behavior = if args.new {
+        cli::OpenBehavior::AlwaysNew
     } else if args.add {
-        Some(false)
+        cli::OpenBehavior::Add
+    } else if args.existing {
+        cli::OpenBehavior::ExistingWindow
+    } else if args.classic {
+        cli::OpenBehavior::Classic
+    } else if args.reuse {
+        cli::OpenBehavior::Reuse
     } else {
-        None
+        cli::OpenBehavior::Default
     };
 
-    let force_existing_window = args.existing;
-
     let env = {
         #[cfg(any(target_os = "linux", target_os = "freebsd"))]
         {
@@ -676,9 +683,7 @@ fn main() -> Result<()> {
                     diff_all: diff_all_mode,
                     wsl,
                     wait: args.wait,
-                    open_new_workspace,
-                    force_existing_window,
-                    reuse: args.reuse,
+                    open_behavior,
                     env,
                     user_data_dir: user_data_dir_for_thread,
                     dev_container: args.dev_container,
@@ -697,7 +702,7 @@ fn main() -> Result<()> {
                         }
                         CliResponse::PromptOpenBehavior => {
                             let behavior = prompt_open_behavior()
-                                .unwrap_or(cli::CliOpenBehavior::ExistingWindow);
+                                .unwrap_or(cli::CliBehaviorSetting::ExistingWindow);
                             tx.send(CliRequest::SetOpenBehavior { behavior })?;
                         }
                     }
@@ -796,15 +801,18 @@ fn anonymous_fd(path: &str) -> Option<fs::File> {
 /// Shows an interactive prompt asking the user to choose the default open
 /// behavior for `zed <path>`. Returns `None` if the prompt cannot be shown
 /// (e.g. stdin is not a terminal) or the user cancels.
-fn prompt_open_behavior() -> Option<cli::CliOpenBehavior> {
+fn prompt_open_behavior() -> Option<cli::CliBehaviorSetting> {
     if !std::io::stdin().is_terminal() {
         return None;
     }
 
     let blue = console::Style::new().blue();
     let items = [
-        format!("Add to existing Zed window ({})", blue.apply_to("zed -e")),
-        format!("Open a new window ({})", blue.apply_to("zed -n")),
+        format!(
+            "Add to existing Zed window ({})",
+            blue.apply_to("zed --existing")
+        ),
+        format!("Open a new window ({})", blue.apply_to("zed --classic")),
     ];
 
     let prompt = format!(
@@ -821,9 +829,9 @@ fn prompt_open_behavior() -> Option<cli::CliOpenBehavior> {
         .ok()?;
 
     Some(if selection == 0 {
-        cli::CliOpenBehavior::ExistingWindow
+        cli::CliBehaviorSetting::ExistingWindow
     } else {
-        cli::CliOpenBehavior::NewWindow
+        cli::CliBehaviorSetting::NewWindow
     })
 }
 

crates/settings_content/src/workspace.rs 🔗

@@ -400,11 +400,12 @@ impl CloseWindowWhenNoItems {
 )]
 #[serde(rename_all = "snake_case")]
 pub enum CliDefaultOpenBehavior {
-    /// Add to the existing Zed window as a new workspace.
+    /// Open directories as a new workspace in the current Zed window's sidebar.
     #[default]
     #[strum(serialize = "Add to Existing Window")]
     ExistingWindow,
-    /// Open a new Zed window.
+    /// Open directories in a new window, but reuse an existing window when
+    /// opening files that are already part of an open project.
     #[strum(serialize = "Open a New Window")]
     NewWindow,
 }

crates/settings_ui/src/page_data.rs 🔗

@@ -143,7 +143,7 @@ fn general_page() -> SettingsPage {
             }),
             SettingsPageItem::SettingItem(SettingItem {
                 title: "CLI Default Open Behavior",
-                description: "How `zed <path>` opens directories when no `-e` or `-n` flag is specified.",
+                description: "How `zed <path>` opens directories when no flag is specified.",
                 field: Box::new(SettingField {
                     json_path: Some("cli_default_open_behavior"),
                     pick: |settings_content| {

crates/workspace/src/workspace.rs 🔗

@@ -9259,35 +9259,31 @@ pub async fn find_existing_workspace(
     let mut open_visible = OpenVisible::All;
     let mut best_match = None;
 
-    cx.update(|cx| {
-        for window in workspace_windows_for_location(location, cx) {
-            if let Ok(multi_workspace) = window.read(cx) {
-                for workspace in multi_workspace.workspaces() {
-                    let project = workspace.read(cx).project.read(cx);
-                    let m = project.visibility_for_paths(
-                        abs_paths,
-                        open_options.open_new_workspace == None,
-                        cx,
-                    );
-                    if m > best_match {
-                        existing = Some((window, workspace.clone()));
-                        best_match = m;
-                    } else if best_match.is_none() && open_options.open_new_workspace == Some(false)
-                    {
-                        existing = Some((window, workspace.clone()))
+    if open_options.workspace_matching != WorkspaceMatching::None {
+        cx.update(|cx| {
+            for window in workspace_windows_for_location(location, cx) {
+                if let Ok(multi_workspace) = window.read(cx) {
+                    for workspace in multi_workspace.workspaces() {
+                        let project = workspace.read(cx).project.read(cx);
+                        let m = project.visibility_for_paths(
+                            abs_paths,
+                            open_options.workspace_matching != WorkspaceMatching::MatchSubdirectory,
+                            cx,
+                        );
+                        if m > best_match {
+                            existing = Some((window, workspace.clone()));
+                            best_match = m;
+                        } else if best_match.is_none()
+                            && open_options.workspace_matching
+                                == WorkspaceMatching::MatchSubdirectory
+                        {
+                            existing = Some((window, workspace.clone()))
+                        }
                     }
                 }
             }
-        }
-    });
-
-    // With -n, only reuse a window if the path is genuinely contained
-    // within an existing worktree (don't fall back to any arbitrary window).
-    if open_options.open_new_workspace == Some(true) && best_match.is_none() {
-        existing = None;
-    }
+        });
 
-    if open_options.open_new_workspace != Some(true) {
         let all_paths_are_files = existing
             .as_ref()
             .and_then(|(_, target_workspace)| {
@@ -9310,11 +9306,7 @@ pub async fn find_existing_workspace(
             })
             .unwrap_or(false);
 
-        if open_options.open_new_workspace.is_none()
-            && existing.is_some()
-            && open_options.wait
-            && all_paths_are_files
-        {
+        if open_options.wait && existing.is_some() && all_paths_are_files {
             cx.update(|cx| {
                 let windows = workspace_windows_for_location(location, cx);
                 let window = cx
@@ -9335,12 +9327,32 @@ pub async fn find_existing_workspace(
     (existing, open_visible)
 }
 
-#[derive(Default, Clone)]
+/// Controls whether to reuse an existing workspace whose worktrees contain the
+/// given paths, and how broadly to match.
+#[derive(Clone, Debug, Default, PartialEq, Eq)]
+pub enum WorkspaceMatching {
+    /// Always open a new workspace. No matching against existing worktrees.
+    None,
+    /// Match paths against existing worktree roots and files within them.
+    #[default]
+    MatchExact,
+    /// Match paths against existing worktrees including subdirectories, and
+    /// fall back to any existing window if no worktree matched.
+    ///
+    /// For example, `zed -a foo/bar` will activate the `bar` workspace if it
+    /// exists, otherwise it will open a new window with `foo/bar` as the root.
+    MatchSubdirectory,
+}
+
+#[derive(Clone)]
 pub struct OpenOptions {
     pub visible: Option<OpenVisible>,
     pub focus: Option<bool>,
-    pub open_new_workspace: Option<bool>,
-    pub force_existing_window: bool,
+    pub workspace_matching: WorkspaceMatching,
+    /// Whether to add unmatched directories to the existing window's sidebar
+    /// rather than opening a new window. Defaults to true, matching the default
+    /// `cli_default_open_behavior` setting.
+    pub add_dirs_to_sidebar: bool,
     pub wait: bool,
     pub requesting_window: Option<WindowHandle<MultiWorkspace>>,
     pub open_mode: OpenMode,
@@ -9348,9 +9360,25 @@ pub struct OpenOptions {
     pub open_in_dev_container: bool,
 }
 
+impl Default for OpenOptions {
+    fn default() -> Self {
+        Self {
+            visible: None,
+            focus: None,
+            workspace_matching: WorkspaceMatching::default(),
+            add_dirs_to_sidebar: true,
+            wait: false,
+            requesting_window: None,
+            open_mode: OpenMode::default(),
+            env: None,
+            open_in_dev_container: false,
+        }
+    }
+}
+
 impl OpenOptions {
     fn should_reuse_existing_window(&self) -> bool {
-        self.open_new_workspace.is_none() && self.open_mode != OpenMode::NewWindow
+        self.workspace_matching != WorkspaceMatching::None && self.open_mode != OpenMode::NewWindow
     }
 }
 
@@ -9541,11 +9569,7 @@ pub fn open_paths(
             && existing.is_none()
             && open_options.requesting_window.is_none()
         {
-            let use_existing_window = open_options.force_existing_window
-                || cx.update(|cx| {
-                    WorkspaceSettings::get_global(cx).cli_default_open_behavior
-                        == settings::CliDefaultOpenBehavior::ExistingWindow
-                });
+            let use_existing_window = open_options.add_dirs_to_sidebar;
 
             if use_existing_window {
                 let target_window = cx.update(|cx| {

crates/zed/src/zed.rs 🔗

@@ -2022,7 +2022,7 @@ pub fn open_new_ssh_project_from_project(
             paths,
             app_state,
             workspace::OpenOptions {
-                open_new_workspace: Some(true),
+                workspace_matching: workspace::WorkspaceMatching::None,
                 ..Default::default()
             },
             cx,
@@ -2583,13 +2583,13 @@ mod tests {
             })
             .unwrap();
 
-        // Opening with -n (open_new_workspace: Some(true)) still creates a new window.
+        // Opening with -n (reuse_worktrees: false) still creates a new window.
         cx.update(|cx| {
             open_paths(
                 &[PathBuf::from(path!("/root/e"))],
                 app_state,
                 workspace::OpenOptions {
-                    open_new_workspace: Some(true),
+                    workspace_matching: workspace::WorkspaceMatching::None,
                     ..Default::default()
                 },
                 cx,
@@ -2630,7 +2630,7 @@ mod tests {
                 &[PathBuf::from(path!("/root/a"))],
                 app_state.clone(),
                 workspace::OpenOptions {
-                    open_new_workspace: Some(false),
+                    workspace_matching: workspace::WorkspaceMatching::MatchSubdirectory,
                     ..Default::default()
                 },
                 cx,
@@ -2640,13 +2640,13 @@ mod tests {
         .unwrap();
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
 
-        // Opening a file inside the existing worktree with -n reuses the window.
+        // Opening a file inside the existing worktree with -n creates a new window.
         cx.update(|cx| {
             open_paths(
                 &[PathBuf::from(path!("/root/dir/c"))],
                 app_state.clone(),
                 workspace::OpenOptions {
-                    open_new_workspace: Some(true),
+                    workspace_matching: workspace::WorkspaceMatching::None,
                     ..Default::default()
                 },
                 cx,
@@ -2654,7 +2654,7 @@ mod tests {
         })
         .await
         .unwrap();
-        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
+        assert_eq!(cx.update(|cx| cx.windows().len()), 2);
 
         // Opening a path NOT in any existing worktree with -n creates a new window.
         cx.update(|cx| {
@@ -2662,7 +2662,7 @@ mod tests {
                 &[PathBuf::from(path!("/root/b"))],
                 app_state.clone(),
                 workspace::OpenOptions {
-                    open_new_workspace: Some(true),
+                    workspace_matching: workspace::WorkspaceMatching::None,
                     ..Default::default()
                 },
                 cx,
@@ -2670,7 +2670,7 @@ mod tests {
         })
         .await
         .unwrap();
-        assert_eq!(cx.update(|cx| cx.windows().len()), 2);
+        assert_eq!(cx.update(|cx| cx.windows().len()), 3);
     }
 
     #[gpui::test]
@@ -2723,13 +2723,13 @@ mod tests {
         .unwrap();
         assert_eq!(cx.update(|cx| cx.windows().len()), 1);
 
-        // Opening a directory already in a worktree with -n reuses the window.
+        // Opening a directory already in a worktree with -n creates a new window.
         cx.update(|cx| {
             open_paths(
                 &[PathBuf::from(path!("/root/dir2"))],
                 app_state.clone(),
                 workspace::OpenOptions {
-                    open_new_workspace: Some(true),
+                    workspace_matching: workspace::WorkspaceMatching::None,
                     ..Default::default()
                 },
                 cx,
@@ -2737,7 +2737,7 @@ mod tests {
         })
         .await
         .unwrap();
-        assert_eq!(cx.update(|cx| cx.windows().len()), 1);
+        assert_eq!(cx.update(|cx| cx.windows().len()), 2);
 
         // Opening a directory NOT in any worktree with -n creates a new window.
         cx.update(|cx| {
@@ -2745,7 +2745,7 @@ mod tests {
                 &[PathBuf::from(path!("/root"))],
                 app_state.clone(),
                 workspace::OpenOptions {
-                    open_new_workspace: Some(true),
+                    workspace_matching: workspace::WorkspaceMatching::None,
                     ..Default::default()
                 },
                 cx,
@@ -2753,7 +2753,7 @@ mod tests {
         })
         .await
         .unwrap();
-        assert_eq!(cx.update(|cx| cx.windows().len()), 2);
+        assert_eq!(cx.update(|cx| cx.windows().len()), 3);
     }
 
     #[gpui::test]

crates/zed/src/zed/open_listener.rs 🔗

@@ -462,9 +462,7 @@ pub async fn handle_cli_connection(
                 diff_all,
                 wait,
                 wsl,
-                mut open_new_workspace,
-                mut force_existing_window,
-                reuse,
+                mut open_behavior,
                 env,
                 user_data_dir: _,
                 dev_container,
@@ -499,7 +497,7 @@ pub async fn handle_cli_connection(
                     return;
                 }
 
-                if open_new_workspace.is_none() && !force_existing_window && !reuse {
+                if open_behavior == cli::OpenBehavior::Default {
                     match resolve_open_behavior(
                         &paths,
                         &app_state,
@@ -510,10 +508,10 @@ pub async fn handle_cli_connection(
                     .await
                     {
                         Some(settings::CliDefaultOpenBehavior::ExistingWindow) => {
-                            force_existing_window = true;
+                            open_behavior = cli::OpenBehavior::ExistingWindow;
                         }
                         Some(settings::CliDefaultOpenBehavior::NewWindow) => {
-                            open_new_workspace = Some(true);
+                            open_behavior = cli::OpenBehavior::Classic;
                         }
                         None => {}
                     }
@@ -525,9 +523,7 @@ pub async fn handle_cli_connection(
                     paths,
                     diff_paths,
                     diff_all,
-                    open_new_workspace,
-                    force_existing_window,
-                    reuse,
+                    open_behavior,
                     responses.as_ref(),
                     wait,
                     dev_container,
@@ -629,10 +625,10 @@ async fn resolve_open_behavior(
 
     if let Some(CliRequest::SetOpenBehavior { behavior }) = requests.next().await {
         let behavior = match behavior {
-            cli::CliOpenBehavior::ExistingWindow => {
+            cli::CliBehaviorSetting::ExistingWindow => {
                 settings::CliDefaultOpenBehavior::ExistingWindow
             }
-            cli::CliOpenBehavior::NewWindow => settings::CliDefaultOpenBehavior::NewWindow,
+            cli::CliBehaviorSetting::NewWindow => settings::CliDefaultOpenBehavior::NewWindow,
         };
 
         let fs = app_state.fs.clone();
@@ -652,9 +648,7 @@ async fn open_workspaces(
     paths: Vec<String>,
     diff_paths: Vec<[String; 2]>,
     diff_all: bool,
-    open_new_workspace: Option<bool>,
-    force_existing_window: bool,
-    reuse: bool,
+    open_behavior: cli::OpenBehavior,
     responses: &dyn CliResponseSink,
     wait: bool,
     dev_container: bool,
@@ -662,7 +656,7 @@ async fn open_workspaces(
     env: Option<collections::HashMap<String, String>>,
     cx: &mut AsyncApp,
 ) -> Result<()> {
-    if paths.is_empty() && diff_paths.is_empty() && open_new_workspace != Some(true) {
+    if paths.is_empty() && diff_paths.is_empty() && open_behavior != cli::OpenBehavior::AlwaysNew {
         return restore_or_create_workspace(app_state, cx).await;
     }
 
@@ -702,21 +696,33 @@ async fn open_workspaces(
 
     for (location, workspace_paths) in grouped_locations {
         // If reuse flag is passed, open a new workspace in an existing window.
-        let (open_new_workspace, replace_window) = if reuse {
-            (
-                Some(true),
-                cx.update(|cx| {
-                    workspace::workspace_windows_for_location(&location, cx)
-                        .into_iter()
-                        .next()
-                }),
-            )
+        let replace_window = if open_behavior == cli::OpenBehavior::Reuse {
+            cx.update(|cx| {
+                workspace::workspace_windows_for_location(&location, cx)
+                    .into_iter()
+                    .next()
+            })
         } else {
-            (open_new_workspace, None)
+            None
         };
         let open_options = workspace::OpenOptions {
-            open_new_workspace,
-            force_existing_window,
+            workspace_matching: match open_behavior {
+                cli::OpenBehavior::AlwaysNew | cli::OpenBehavior::Reuse => {
+                    workspace::WorkspaceMatching::None
+                }
+                cli::OpenBehavior::Add => workspace::WorkspaceMatching::MatchSubdirectory,
+                _ => workspace::WorkspaceMatching::MatchExact,
+            },
+            add_dirs_to_sidebar: match open_behavior {
+                cli::OpenBehavior::ExistingWindow => true,
+                // For the default value, we consult the settings to decide
+                // whether to open in a new window or existing window.
+                cli::OpenBehavior::Default => cx.update(|cx| {
+                    workspace::WorkspaceSettings::get_global(cx).cli_default_open_behavior
+                        == settings::CliDefaultOpenBehavior::ExistingWindow
+                }),
+                _ => false,
+            },
             requesting_window: replace_window,
             wait,
             env: env.clone(),
@@ -1215,7 +1221,7 @@ mod tests {
         assert_eq!(cx.windows().len(), 0);
 
         // First open the workspace directory
-        open_workspace_file(path!("/root/dir1"), None, app_state.clone(), cx).await;
+        open_workspace_file(path!("/root/dir1"), <_>::default(), app_state.clone(), cx).await;
 
         assert_eq!(cx.windows().len(), 1);
         let multi_workspace = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
@@ -1228,7 +1234,13 @@ mod tests {
             .unwrap();
 
         // Now open a file inside that workspace
-        open_workspace_file(path!("/root/dir1/file1.txt"), None, app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/root/dir1/file1.txt"),
+            <_>::default(),
+            app_state.clone(),
+            cx,
+        )
+        .await;
 
         assert_eq!(cx.windows().len(), 1);
         multi_workspace
@@ -1239,16 +1251,19 @@ mod tests {
             })
             .unwrap();
 
-        // Opening a file inside the existing worktree with -n reuses the window.
+        // Opening a file inside the existing worktree with -n creates a new window.
         open_workspace_file(
             path!("/root/dir1/file1.txt"),
-            Some(true),
+            workspace::OpenOptions {
+                workspace_matching: workspace::WorkspaceMatching::None,
+                ..Default::default()
+            },
             app_state.clone(),
             cx,
         )
         .await;
 
-        assert_eq!(cx.windows().len(), 1);
+        assert_eq!(cx.windows().len(), 2);
     }
 
     #[gpui::test]
@@ -1319,7 +1334,13 @@ mod tests {
         assert_eq!(cx.windows().len(), 0);
 
         // Test case 1: Open a single file that does not exist yet
-        open_workspace_file(path!("/root/file5.txt"), None, app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/root/file5.txt"),
+            <_>::default(),
+            app_state.clone(),
+            cx,
+        )
+        .await;
 
         assert_eq!(cx.windows().len(), 1);
         let multi_workspace_1 = cx.windows()[0].downcast::<MultiWorkspace>().unwrap();
@@ -1333,7 +1354,16 @@ mod tests {
 
         // 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(path!("/root/file6.txt"), Some(false), app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/root/file6.txt"),
+            workspace::OpenOptions {
+                workspace_matching: workspace::WorkspaceMatching::MatchSubdirectory,
+                ..Default::default()
+            },
+            app_state.clone(),
+            cx,
+        )
+        .await;
 
         assert_eq!(cx.windows().len(), 1);
         multi_workspace_1
@@ -1347,7 +1377,16 @@ mod tests {
 
         // 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(path!("/root/file7.txt"), Some(true), app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/root/file7.txt"),
+            workspace::OpenOptions {
+                workspace_matching: workspace::WorkspaceMatching::None,
+                ..Default::default()
+            },
+            app_state.clone(),
+            cx,
+        )
+        .await;
 
         assert_eq!(cx.windows().len(), 2);
         let multi_workspace_2 = cx.windows()[1].downcast::<MultiWorkspace>().unwrap();
@@ -1363,7 +1402,7 @@ mod tests {
 
     async fn open_workspace_file(
         path: &str,
-        open_new_workspace: Option<bool>,
+        open_options: workspace::OpenOptions,
         app_state: Arc<AppState>,
         cx: &TestAppContext,
     ) {
@@ -1377,10 +1416,7 @@ mod tests {
                     workspace_paths,
                     vec![],
                     false,
-                    workspace::OpenOptions {
-                        open_new_workspace,
-                        ..Default::default()
-                    },
+                    open_options,
                     &response_sink,
                     &app_state,
                     &mut cx,
@@ -1657,7 +1693,7 @@ mod tests {
                         Vec::new(),
                         false,
                         workspace::OpenOptions {
-                            open_new_workspace: Some(true), // Force new window
+                            workspace_matching: workspace::WorkspaceMatching::None, // Force new window
                             ..Default::default()
                         },
                         &response_sink,
@@ -1679,7 +1715,7 @@ mod tests {
             })
             .unwrap();
 
-        // Now use --add flag (open_new_workspace = Some(false)) to add a new file
+        // Now use --add flag (open_behavior = OpenBehavior::Add) to add a new file
         // It should open in the focused window (window2), not an arbitrary window
         let new_file_path = if cfg!(windows) {
             "C:\\root\\new_file.txt"
@@ -1703,7 +1739,7 @@ mod tests {
                         Vec::new(),
                         false,
                         workspace::OpenOptions {
-                            open_new_workspace: Some(false), // --add flag
+                            workspace_matching: workspace::WorkspaceMatching::MatchSubdirectory, // --add flag
                             ..Default::default()
                         },
                         &response_sink,
@@ -1855,11 +1891,7 @@ mod tests {
             .unwrap();
     }
 
-    fn make_cli_open_request(
-        paths: Vec<String>,
-        open_new_workspace: Option<bool>,
-        force_existing_window: bool,
-    ) -> CliRequest {
+    fn make_cli_open_request(paths: Vec<String>, open_behavior: cli::OpenBehavior) -> CliRequest {
         CliRequest::Open {
             paths,
             urls: vec![],
@@ -1867,9 +1899,7 @@ mod tests {
             diff_all: false,
             wsl: None,
             wait: false,
-            open_new_workspace,
-            force_existing_window,
-            reuse: false,
+            open_behavior,
             env: None,
             user_data_dir: None,
             dev_container: false,
@@ -1886,7 +1916,7 @@ mod tests {
         cx: &mut TestAppContext,
         app_state: Arc<AppState>,
         open_request: CliRequest,
-        prompt_response: Option<cli::CliOpenBehavior>,
+        prompt_response: Option<cli::CliBehaviorSetting>,
     ) -> (i32, bool) {
         cx.executor().allow_parking();
 
@@ -1915,7 +1945,7 @@ mod tests {
                     CliResponse::PromptOpenBehavior => {
                         prompt_called_for_thread.store(true, std::sync::atomic::Ordering::SeqCst);
                         let behavior =
-                            prompt_response.unwrap_or(cli::CliOpenBehavior::ExistingWindow);
+                            prompt_response.unwrap_or(cli::CliBehaviorSetting::ExistingWindow);
                         request_tx
                             .unbounded_send(CliRequest::SetOpenBehavior { behavior })
                             .map_err(|error| anyhow::anyhow!("{error}"))?;
@@ -1955,7 +1985,10 @@ mod tests {
         let (status, prompt_shown) = run_cli_with_zed_handler(
             cx,
             app_state,
-            make_cli_open_request(vec![path!("/project/file.txt").to_string()], None, false),
+            make_cli_open_request(
+                vec![path!("/project/file.txt").to_string()],
+                cli::OpenBehavior::Default,
+            ),
             None,
         );
 
@@ -1983,14 +2016,23 @@ mod tests {
             .await;
 
         // Create an existing window so the prompt triggers
-        open_workspace_file(path!("/project_a"), None, app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/project_a"),
+            Default::default(),
+            app_state.clone(),
+            cx,
+        )
+        .await;
         assert_eq!(cx.windows().len(), 1);
 
         let (status, prompt_shown) = run_cli_with_zed_handler(
             cx,
             app_state.clone(),
-            make_cli_open_request(vec![path!("/project_b").to_string()], None, false),
-            Some(cli::CliOpenBehavior::ExistingWindow),
+            make_cli_open_request(
+                vec![path!("/project_b").to_string()],
+                cli::OpenBehavior::Default,
+            ),
+            Some(cli::CliBehaviorSetting::ExistingWindow),
         );
 
         assert_eq!(status, 0);
@@ -2024,14 +2066,23 @@ mod tests {
             .await;
 
         // Create an existing window with project_a
-        open_workspace_file(path!("/project_a"), None, app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/project_a"),
+            Default::default(),
+            app_state.clone(),
+            cx,
+        )
+        .await;
         assert_eq!(cx.windows().len(), 1);
 
         let (status, prompt_shown) = run_cli_with_zed_handler(
             cx,
             app_state.clone(),
-            make_cli_open_request(vec![path!("/project_b").to_string()], None, false),
-            Some(cli::CliOpenBehavior::NewWindow),
+            make_cli_open_request(
+                vec![path!("/project_b").to_string()],
+                cli::OpenBehavior::Default,
+            ),
+            Some(cli::CliBehaviorSetting::NewWindow),
         );
 
         assert_eq!(status, 0);
@@ -2072,13 +2123,16 @@ mod tests {
             .await;
 
         // Create an existing window
-        open_workspace_file(path!("/project"), None, app_state.clone(), cx).await;
+        open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await;
         assert_eq!(cx.windows().len(), 1);
 
         let (status, prompt_shown) = run_cli_with_zed_handler(
             cx,
             app_state,
-            make_cli_open_request(vec![path!("/project/file.txt").to_string()], None, false),
+            make_cli_open_request(
+                vec![path!("/project/file.txt").to_string()],
+                cli::OpenBehavior::Default,
+            ),
             None,
         );
 
@@ -2100,7 +2154,7 @@ mod tests {
             .await;
 
         // Create an existing window
-        open_workspace_file(path!("/project"), None, app_state.clone(), cx).await;
+        open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await;
         assert_eq!(cx.windows().len(), 1);
 
         let (status, prompt_shown) = run_cli_with_zed_handler(
@@ -2108,8 +2162,7 @@ mod tests {
             app_state,
             make_cli_open_request(
                 vec![path!("/project/file.txt").to_string()],
-                None,
-                true, // -e flag: force existing window
+                cli::OpenBehavior::ExistingWindow, // -e flag: force existing window
             ),
             None,
         );
@@ -2135,7 +2188,13 @@ mod tests {
             .await;
 
         // Create an existing window
-        open_workspace_file(path!("/project_a"), None, app_state.clone(), cx).await;
+        open_workspace_file(
+            path!("/project_a"),
+            Default::default(),
+            app_state.clone(),
+            cx,
+        )
+        .await;
         assert_eq!(cx.windows().len(), 1);
 
         let (status, prompt_shown) = run_cli_with_zed_handler(
@@ -2143,8 +2202,7 @@ mod tests {
             app_state,
             make_cli_open_request(
                 vec![path!("/project_b/file.txt").to_string()],
-                Some(true), // -n flag: force new window
-                false,
+                cli::OpenBehavior::AlwaysNew, // -n flag: force new window
             ),
             None,
         );
@@ -2172,14 +2230,17 @@ mod tests {
             .await;
 
         // Open the project directory as a workspace
-        open_workspace_file(path!("/project"), None, app_state.clone(), cx).await;
+        open_workspace_file(path!("/project"), Default::default(), app_state.clone(), cx).await;
         assert_eq!(cx.windows().len(), 1);
 
         // Opening a file inside the already-open workspace should not prompt
         let (status, prompt_shown) = run_cli_with_zed_handler(
             cx,
             app_state,
-            make_cli_open_request(vec![path!("/project/src/main.rs").to_string()], None, false),
+            make_cli_open_request(
+                vec![path!("/project/src/main.rs").to_string()],
+                cli::OpenBehavior::Default,
+            ),
             None,
         );
 

crates/zed/src/zed/windows_only_instance.rs 🔗

@@ -158,9 +158,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
             diff_all: false,
             wait: false,
             wsl: args.wsl.clone(),
-            open_new_workspace: None,
-            force_existing_window: false,
-            reuse: false,
+            open_behavior: Default::default(),
             env: None,
             user_data_dir: args.user_data_dir.clone(),
             dev_container: args.dev_container,
@@ -189,7 +187,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
                         }
                         CliResponse::PromptOpenBehavior => {
                             tx.send(CliRequest::SetOpenBehavior {
-                                behavior: cli::CliOpenBehavior::ExistingWindow,
+                                behavior: cli::CliBehaviorSetting::ExistingWindow,
                             })?;
                         }
                     }