cli: Add `--reuse` flag for replacing workspace in existing window (#38131)

Mateo Noel Rabines created

Closes #ISSUE 

it is was still in
[discussion](https://github.com/zed-industries/zed/discussions/37983)

Release Notes:

- Added: `--reuse` (`-r`) CLI flag to replace the workspace in an
existing window instead of opening a new one

This PR adds a new `--reuse` (`-r`) CLI flag that allows users to
replace the workspace in an existing Zed window instead of opening a new
one or adding files to the current workspace.

### What it does

The `--reuse` flag finds an available local workspace window and
replaces its workspace with the newly specified paths. This provides a
third workspace opening mode alongside the existing `--add` and `--new`
flags.

### Implementation Details

- **CLI Flag**: Added `--reuse` (`-r`) flag with proper mutual exclusion
with `--add` and `--new`
- **Window Replacement**: Uses the existing `replace_window` option in
`workspace::OpenOptions`
- **Window Selection**: Reuses the first available local workspace
window
- **Fallback Behavior**: When no existing windows are found, creates a
new window
- **Test Coverage**: Added comprehensive test for the reuse
functionality

### Behavior

- `zed -r file.txt` - Replaces the workspace in an available window with
`file.txt`
- If no windows are open, creates a new window (same as default
behavior)
- Mutually exclusive with `-a/--add` and `-n/--new` flags
- Works with multiple files and directories

### Files Changed

- `crates/cli/src/cli.rs` - Added `reuse` field to `CliRequest::Open`
- `crates/cli/src/main.rs` - Added CLI argument definition and parsing
- `crates/zed/src/zed/open_listener.rs` - Implemented reuse logic and
added tests
- `crates/zed/src/zed/windows_only_instance.rs` - Updated for Windows
compatibility

### Testing

- ✅ Unit tests pass
- ✅ Manual testing confirms expected behavior:
  - Works when no windows are open
  - Replaces workspace in existing window
  - Maintains compatibility with existing `-a` and `-n` flags
  - Proper help text display


## Manual testing

#### In this first video we do a couple of tests: 

* **1**: What happens if we use the -r flag when there are no windows
open?
        - works as expected. It opens the files in a new window.
        
* **2**: Does it work as expected if there is already a window open.
Does it overrides the workspace?
- yes it does. When opening a different file it overrides the current
window instead of creating a new one.
        
* **3**: Does the -n flag still works as expected?
        - yes, it creates the project in a new window

* **4**: What about the -a flag?
       - yes, on the last accessed page 
       
* **5**: we do the replace command. It overrides the first opened
window, do we want this behavior?
- It is good enough that it overrides one of the opened windows with the
new project. It still makes the user automatically go to the window with
the specified files

* **6**: we use the -r command again replacing the workspace with a new
one.
       - this indeed worked as expected


https://github.com/user-attachments/assets/f1cd7f4b-f4af-4da2-a755-c0be7ce96c0d


#### In here the we check how the --help flag now displays the new
command. (Description was later updated)


https://github.com/user-attachments/assets/a8a7a288-d926-431b-a9f9-a8c3d909a2ec

Change summary

crates/cli/src/cli.rs                       |   1 
crates/cli/src/main.rs                      |   8 +
crates/zed/src/zed/open_listener.rs         | 126 ++++++++++++++++++++++
crates/zed/src/zed/windows_only_instance.rs |   1 
4 files changed, 133 insertions(+), 3 deletions(-)

Detailed changes

crates/cli/src/cli.rs 🔗

@@ -17,6 +17,7 @@ pub enum CliRequest {
         wsl: Option<String>,
         wait: bool,
         open_new_workspace: Option<bool>,
+        reuse: bool,
         env: Option<HashMap<String, String>>,
         user_data_dir: Option<String>,
     },

crates/cli/src/main.rs 🔗

@@ -62,11 +62,14 @@ struct Args {
     #[arg(short, long)]
     wait: bool,
     /// Add files to the currently open workspace
-    #[arg(short, long, overrides_with = "new")]
+    #[arg(short, long, overrides_with_all = ["new", "reuse"])]
     add: bool,
     /// Create a new workspace
-    #[arg(short, long, overrides_with = "add")]
+    #[arg(short, long, overrides_with_all = ["add", "reuse"])]
     new: bool,
+    /// Reuse an existing window, replacing its workspace
+    #[arg(short, long, overrides_with_all = ["add", "new"])]
+    reuse: 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`.")]
@@ -374,6 +377,7 @@ fn main() -> Result<()> {
                     wsl,
                     wait: args.wait,
                     open_new_workspace,
+                    reuse: args.reuse,
                     env,
                     user_data_dir: user_data_dir_for_thread,
                 })?;

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

@@ -328,6 +328,7 @@ pub async fn handle_cli_connection(
                 wait,
                 wsl,
                 open_new_workspace,
+                reuse,
                 env,
                 user_data_dir: _,
             } => {
@@ -363,6 +364,7 @@ pub async fn handle_cli_connection(
                     paths,
                     diff_paths,
                     open_new_workspace,
+                    reuse,
                     &responses,
                     wait,
                     app_state.clone(),
@@ -382,6 +384,7 @@ async fn open_workspaces(
     paths: Vec<String>,
     diff_paths: Vec<[String; 2]>,
     open_new_workspace: Option<bool>,
+    reuse: bool,
     responses: &IpcSender<CliResponse>,
     wait: bool,
     app_state: Arc<AppState>,
@@ -441,6 +444,7 @@ async fn open_workspaces(
                         workspace_paths,
                         diff_paths.clone(),
                         open_new_workspace,
+                        reuse,
                         wait,
                         responses,
                         env.as_ref(),
@@ -487,6 +491,7 @@ async fn open_local_workspace(
     workspace_paths: Vec<String>,
     diff_paths: Vec<[String; 2]>,
     open_new_workspace: Option<bool>,
+    reuse: bool,
     wait: bool,
     responses: &IpcSender<CliResponse>,
     env: Option<&HashMap<String, String>>,
@@ -497,12 +502,30 @@ async fn open_local_workspace(
 
     let paths_with_position =
         derive_paths_with_position(app_state.fs.as_ref(), workspace_paths).await;
+
+    // Handle reuse flag by finding existing window to replace
+    let replace_window = if reuse {
+        cx.update(|cx| workspace::local_workspace_windows(cx).into_iter().next())
+            .ok()
+            .flatten()
+    } else {
+        None
+    };
+
+    // For reuse, force new workspace creation but with replace_window set
+    let effective_open_new_workspace = if reuse {
+        Some(true)
+    } else {
+        open_new_workspace
+    };
+
     match open_paths_with_positions(
         &paths_with_position,
         &diff_paths,
         app_state.clone(),
         workspace::OpenOptions {
-            open_new_workspace,
+            open_new_workspace: effective_open_new_workspace,
+            replace_window,
             env: env.cloned(),
             ..Default::default()
         },
@@ -614,7 +637,9 @@ mod tests {
     };
     use editor::Editor;
     use gpui::TestAppContext;
+    use language::LineEnding;
     use remote::SshConnectionOptions;
+    use rope::Rope;
     use serde_json::json;
     use std::sync::Arc;
     use util::path;
@@ -780,6 +805,7 @@ mod tests {
                     vec![],
                     open_new_workspace,
                     false,
+                    false,
                     &response_tx,
                     None,
                     &app_state,
@@ -791,4 +817,102 @@ mod tests {
 
         assert!(!errored);
     }
+
+    #[gpui::test]
+    async fn test_reuse_flag_functionality(cx: &mut TestAppContext) {
+        let app_state = init_test(cx);
+
+        let root_dir = if cfg!(windows) { "C:\\root" } else { "/root" };
+        let file1_path = if cfg!(windows) {
+            "C:\\root\\file1.txt"
+        } else {
+            "/root/file1.txt"
+        };
+        let file2_path = if cfg!(windows) {
+            "C:\\root\\file2.txt"
+        } else {
+            "/root/file2.txt"
+        };
+
+        app_state.fs.create_dir(Path::new(root_dir)).await.unwrap();
+        app_state
+            .fs
+            .create_file(Path::new(file1_path), Default::default())
+            .await
+            .unwrap();
+        app_state
+            .fs
+            .save(
+                Path::new(file1_path),
+                &Rope::from("content1"),
+                LineEnding::Unix,
+            )
+            .await
+            .unwrap();
+        app_state
+            .fs
+            .create_file(Path::new(file2_path), Default::default())
+            .await
+            .unwrap();
+        app_state
+            .fs
+            .save(
+                Path::new(file2_path),
+                &Rope::from("content2"),
+                LineEnding::Unix,
+            )
+            .await
+            .unwrap();
+
+        // First, open a workspace normally
+        let (response_tx, _response_rx) = ipc::channel::<CliResponse>().unwrap();
+        let workspace_paths = vec![file1_path.to_string()];
+
+        let _errored = cx
+            .spawn({
+                let app_state = app_state.clone();
+                let response_tx = response_tx.clone();
+                |mut cx| async move {
+                    open_local_workspace(
+                        workspace_paths,
+                        vec![],
+                        None,
+                        false,
+                        false,
+                        &response_tx,
+                        None,
+                        &app_state,
+                        &mut cx,
+                    )
+                    .await
+                }
+            })
+            .await;
+
+        // Now test the reuse functionality - should replace the existing workspace
+        let workspace_paths_reuse = vec![file1_path.to_string()];
+
+        let errored_reuse = cx
+            .spawn({
+                let app_state = app_state.clone();
+                let response_tx = response_tx.clone();
+                |mut cx| async move {
+                    open_local_workspace(
+                        workspace_paths_reuse,
+                        vec![],
+                        None, // open_new_workspace will be overridden by reuse logic
+                        true, // reuse = true
+                        false,
+                        &response_tx,
+                        None,
+                        &app_state,
+                        &mut cx,
+                    )
+                    .await
+                }
+            })
+            .await;
+
+        assert!(!errored_reuse);
+    }
 }

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

@@ -158,6 +158,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
             wait: false,
             wsl: args.wsl.clone(),
             open_new_workspace: None,
+            reuse: false,
             env: None,
             user_data_dir: args.user_data_dir.clone(),
         }