cli: Add first-run prompt for default open behavior and abstract IPC transport (#53663)

This PR adds the `cli_default_open_behavior` setting and a first-run TUI
prompt
that appears when `zed <path>` is invoked without flags while existing
windows are
open and the setting hasn't been configured yet.

## What it does

### Setting and prompt

- Adds a new `cli_default_open_behavior` workspace setting with two
values:
  `existing_window` (default) and `new_window`.
- When the user runs `zed <path>` for the first time with existing Zed
windows
open, a `dialoguer::Select` prompt in the CLI asks them to choose their
  preferred behavior. The choice is persisted to `settings.json`.
- The prompt is skipped when:
  - An explicit flag (`-n`, `-e`, `-a`) is given
  - No existing Zed windows are open
  - The setting is already configured in `settings.json`
- The paths being opened are already contained in an existing workspace

### IPC transport abstraction

- Introduces a `CliResponseSink` trait in the `cli` crate that abstracts
`IpcSender<CliResponse>`, with an implementation for the real IPC
sender.
- Replaces `IpcSender<CliResponse>` with `Box<dyn CliResponseSink>` /
  `&dyn CliResponseSink` across all signatures in `open_listener.rs`:
  `OpenRequestKind::CliConnection`, `handle_cli_connection`,
`maybe_prompt_open_behavior`, `open_workspaces`, `open_local_workspace`.
- Extracts the inline CLI response loop from `main.rs` into a testable
  `cli::run_cli_response_loop` function.
- Switches the request channel from bounded `mpsc::channel(16)` to
`mpsc::unbounded()`, eliminating `smol::block_on` in the bridge thread.

### End-to-end tests

Seven new tests exercise both the CLI-side response loop and the
Zed-side
handler connected through in-memory channels, using `allow_parking()` so
the
real `cli::run_cli_response_loop` runs on an OS thread while the GPUI
executor
drives the Zed handler:

- No flags, no windows → no prompt, opens new window
- No flags, existing windows, user picks "existing window" → prompt,
setting persisted
- No flags, existing windows, user picks "new window" → prompt, setting
persisted
- Setting already configured → no prompt
- Paths already in existing workspace → no prompt
- Explicit `-e` flag → no prompt
- Explicit `-n` flag → no prompt

Existing tests that previously used `ipc::channel()` now use a
`DiscardResponseSink`, removing OS-level IPC from all tests.

Release Notes:

- Added a first-run prompt when using `zed <path>` to choose between
opening
in an existing window or a new window. The choice is saved to settings
and
  can be changed later via the `cli_default_open_behavior` setting.

---------

Co-authored-by: Nathan Sobo <nathan@zed.dev>
This commit is contained in:
Eric HolkandNathan Sobo authored and GitHub committed 2026-04-10 21:37:43 -07:00
1 parent 52aa990dcd
commit afed924a56
16 files changed
+972 -85

No files matched your search

Generated
+42 -12
View File
@@ -2876,8 +2876,10 @@ dependencies = [
"askpass",
"clap",
"collections",
"console",
"core-foundation 0.10.0",
"core-services",
"dialoguer",
"exec",
"fork",
"ipc-channel",
@@ -3418,6 +3420,18 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "console"
version = "0.16.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87"
dependencies = [
"encode_unicode",
"libc",
"unicode-width",
"windows-sys 0.61.2",
]
[[package]]
name = "console_error_panic_hook"
version = "0.1.7"
@@ -4839,6 +4853,16 @@ dependencies = [
"zlog",
]
[[package]]
name = "dialoguer"
version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96"
dependencies = [
"console",
"shell-words",
]
[[package]]
name = "diff"
version = "0.1.13"
@@ -4925,7 +4949,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users 0.5.2",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -5520,6 +5544,12 @@ dependencies = [
"phf 0.11.3",
]
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -5691,7 +5721,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -7116,7 +7146,7 @@ dependencies = [
"gobject-sys",
"libc",
"system-deps 7.0.7",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -8445,7 +8475,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.62.2",
]
[[package]]
@@ -11089,7 +11119,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -13535,7 +13565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4"
dependencies = [
"bytes 1.11.1",
"heck 0.4.1",
"heck 0.5.0",
"itertools 0.12.1",
"log",
"multimap",
@@ -13841,7 +13871,7 @@ dependencies = [
"once_cell",
"socket2 0.6.1",
"tracing",
"windows-sys 0.52.0",
"windows-sys 0.60.2",
]
[[package]]
@@ -15033,7 +15063,7 @@ dependencies = [
"errno 0.3.14",
"libc",
"linux-raw-sys 0.11.0",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -16286,7 +16316,7 @@ version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451"
dependencies = [
"heck 0.4.1",
"heck 0.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@@ -17500,7 +17530,7 @@ dependencies = [
"getrandom 0.3.4",
"once_cell",
"rustix 1.1.2",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -18419,7 +18449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f"
dependencies = [
"cc",
"windows-targets 0.48.5",
"windows-targets 0.52.6",
]
[[package]]
@@ -20531,7 +20561,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.48.0",
"windows-sys 0.61.2",
]
[[package]]
+2
View File
@@ -295,6 +295,7 @@ command_palette_hooks = { path = "crates/command_palette_hooks" }
compliance = { path = "tooling/compliance" }
component = { path = "crates/component" }
component_preview = { path = "crates/component_preview" }
console = "0.16"
context_server = { path = "crates/context_server" }
copilot = { path = "crates/copilot" }
copilot_chat = { path = "crates/copilot_chat" }
@@ -313,6 +314,7 @@ deepseek = { path = "crates/deepseek" }
derive_refineable = { path = "crates/refineable/derive_refineable" }
dev_container = { path = "crates/dev_container" }
diagnostics = { path = "crates/diagnostics" }
dialoguer = { version = "0.12", default-features = false }
editor = { path = "crates/editor" }
encoding_selector = { path = "crates/encoding_selector" }
env_var = { path = "crates/env_var" }
+9
View File
@@ -134,6 +134,15 @@
// 3. Do not restore previous workspaces
// "restore_on_startup": "none",
"restore_on_startup": "last_session",
// The default behavior when opening paths from the CLI without
// an explicit `-e` (existing window) or `-n` (new window) flag.
//
// May take 2 values:
// 1. Add to the existing Zed window
// "cli_default_open_behavior": "existing_window"
// 2. Open a new Zed window
// "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.
// The state is stored per pane.
// When disabled, defaults are applied instead of the state restoration.
+2
View File
@@ -25,6 +25,8 @@ anyhow.workspace = true
askpass.workspace = true
clap.workspace = true
collections.workspace = true
console.workspace = true
dialoguer.workspace = true
ipc-channel = "0.19"
parking_lot.workspace = true
paths.workspace = true
+28
View File
@@ -1,3 +1,4 @@
use anyhow::Result;
use collections::HashMap;
pub use ipc_channel::ipc;
use serde::{Deserialize, Serialize};
@@ -8,6 +9,13 @@ pub struct IpcHandshake {
pub responses: ipc::IpcReceiver<CliResponse>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CliOpenBehavior {
ExistingWindow,
NewWindow,
}
#[derive(Debug, Serialize, Deserialize)]
pub enum CliRequest {
Open {
@@ -18,11 +26,16 @@ pub enum CliRequest {
wsl: Option<String>,
wait: bool,
open_new_workspace: Option<bool>,
#[serde(default)]
force_existing_window: bool,
reuse: bool,
env: Option<HashMap<String, String>>,
user_data_dir: Option<String>,
dev_container: bool,
},
SetOpenBehavior {
behavior: CliOpenBehavior,
},
}
#[derive(Debug, Serialize, Deserialize)]
@@ -31,6 +44,7 @@ pub enum CliResponse {
Stdout { message: String },
Stderr { message: String },
Exit { status: i32 },
PromptOpenBehavior,
}
/// When Zed started not as an *.app but as a binary (e.g. local development),
@@ -39,3 +53,17 @@ pub enum CliResponse {
/// Note that in the main zed binary, this variable is unset after it's read for the first time,
/// therefore it should always be accessed through the `FORCE_CLI_MODE` static.
pub const FORCE_CLI_MODE_ENV_VAR_NAME: &str = "ZED_FORCE_CLI_MODE";
/// Abstracts the transport for sending CLI responses (Zed → CLI).
///
/// Production code uses `IpcSender<CliResponse>`. Tests can provide in-memory
/// implementations to avoid OS-level IPC.
pub trait CliResponseSink: Send + 'static {
fn send(&self, response: CliResponse) -> Result<()>;
}
impl CliResponseSink for ipc::IpcSender<CliResponse> {
fn send(&self, response: CliResponse) -> Result<()> {
ipc::IpcSender::send(self, response).map_err(|error| anyhow::anyhow!("{error}"))
}
}
+52 -6
View File
@@ -25,7 +25,6 @@ use tempfile::{NamedTempFile, TempDir};
use util::paths::PathWithPosition;
use walkdir::WalkDir;
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
use std::io::IsTerminal;
const URL_PREFIX: [&'static str; 5] = ["zed://", "http://", "https://", "file://", "ssh://"];
@@ -68,14 +67,17 @@ struct Args {
#[arg(short, long)]
wait: bool,
/// Add files to the currently open workspace
#[arg(short, long, overrides_with_all = ["new", "reuse"])]
#[arg(short, long, overrides_with_all = ["new", "reuse", "existing"])]
add: bool,
/// Create a new workspace
#[arg(short, long, overrides_with_all = ["add", "reuse"])]
#[arg(short, long, overrides_with_all = ["add", "reuse", "existing"])]
new: bool,
/// Reuse an existing window, replacing its workspace
#[arg(short, long, overrides_with_all = ["add", "new"])]
#[arg(short, long, overrides_with_all = ["add", "new", "existing"])]
reuse: bool,
/// Open in existing Zed window
#[arg(short = 'e', long = "existing", overrides_with_all = ["add", "new", "reuse"])]
existing: 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`.")]
@@ -544,6 +546,8 @@ fn main() -> Result<()> {
None
};
let force_existing_window = args.existing;
let env = {
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
{
@@ -665,7 +669,7 @@ fn main() -> Result<()> {
#[cfg(not(target_os = "windows"))]
let wsl = None;
tx.send(CliRequest::Open {
let open_request = CliRequest::Open {
paths,
urls,
diff_paths,
@@ -673,11 +677,14 @@ fn main() -> Result<()> {
wsl,
wait: args.wait,
open_new_workspace,
force_existing_window,
reuse: args.reuse,
env,
user_data_dir: user_data_dir_for_thread,
dev_container: args.dev_container,
})?;
};
tx.send(open_request)?;
while let Ok(response) = rx.recv() {
match response {
@@ -688,6 +695,11 @@ fn main() -> Result<()> {
exit_status.lock().replace(status);
return Ok(());
}
CliResponse::PromptOpenBehavior => {
let behavior = prompt_open_behavior()
.unwrap_or(cli::CliOpenBehavior::ExistingWindow);
tx.send(CliRequest::SetOpenBehavior { behavior })?;
}
}
}
@@ -781,6 +793,40 @@ 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> {
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")),
];
let prompt = format!(
"Configure default behavior for {}\n{}",
blue.apply_to("zed <path>"),
console::style("You can change this later in Zed settings"),
);
let selection = dialoguer::Select::new()
.with_prompt(&prompt)
.items(&items)
.default(0)
.interact()
.ok()?;
Some(if selection == 0 {
cli::CliOpenBehavior::ExistingWindow
} else {
cli::CliOpenBehavior::NewWindow
})
}
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
mod linux {
use std::{
+55 -1
View File
@@ -159,6 +159,7 @@ pub struct StyledText {
text: SharedString,
runs: Option<Vec<TextRun>>,
delayed_highlights: Option<Vec<(Range<usize>, HighlightStyle)>>,
delayed_font_family_overrides: Option<Vec<(Range<usize>, SharedString)>>,
layout: TextLayout,
}
@@ -169,6 +170,7 @@ impl StyledText {
text: text.into(),
runs: None,
delayed_highlights: None,
delayed_font_family_overrides: None,
layout: TextLayout::default(),
}
}
@@ -242,6 +244,51 @@ impl StyledText {
runs
}
/// Override the font family for specific byte ranges of the text.
///
/// This is resolved lazily at layout time, so the overrides are applied
/// on top of the inherited text style from the parent element.
/// Can be combined with [`with_highlights`](Self::with_highlights).
///
/// The overrides must be sorted by range start and non-overlapping.
/// Each override range must fall on character boundaries.
pub fn with_font_family_overrides(
mut self,
overrides: impl IntoIterator<Item = (Range<usize>, SharedString)>,
) -> Self {
self.delayed_font_family_overrides = Some(
overrides
.into_iter()
.inspect(|(range, _)| {
debug_assert!(self.text.is_char_boundary(range.start));
debug_assert!(self.text.is_char_boundary(range.end));
})
.collect(),
);
self
}
fn apply_font_family_overrides(
runs: &mut [TextRun],
overrides: &[(Range<usize>, SharedString)],
) {
let mut byte_offset = 0;
let mut override_idx = 0;
for run in runs.iter_mut() {
let run_end = byte_offset + run.len;
while override_idx < overrides.len() && overrides[override_idx].0.end <= byte_offset {
override_idx += 1;
}
if override_idx < overrides.len() {
let (ref range, ref family) = overrides[override_idx];
if byte_offset >= range.start && run_end <= range.end {
run.font.family = family.clone();
}
}
byte_offset = run_end;
}
}
/// Set the text runs for this piece of text.
pub fn with_runs(mut self, runs: Vec<TextRun>) -> Self {
let mut text = &**self.text;
@@ -278,12 +325,19 @@ impl Element for StyledText {
window: &mut Window,
cx: &mut App,
) -> (LayoutId, Self::RequestLayoutState) {
let runs = self.runs.take().or_else(|| {
let font_family_overrides = self.delayed_font_family_overrides.take();
let mut runs = self.runs.take().or_else(|| {
self.delayed_highlights.take().map(|delayed_highlights| {
Self::compute_runs(&self.text, &window.text_style(), delayed_highlights)
})
});
if let Some(ref overrides) = font_family_overrides {
let runs =
runs.get_or_insert_with(|| vec![window.text_style().to_run(self.text.len())]);
Self::apply_font_family_overrides(runs, overrides);
}
let layout_id = self.layout.layout(self.text.clone(), runs, window, cx);
(layout_id, ())
}
+1
View File
@@ -975,6 +975,7 @@ impl VsCodeSettings {
}),
bottom_dock_layout: None,
centered_layout: None,
cli_default_open_behavior: None,
close_on_file_delete: None,
close_panel_on_toggle: None,
command_aliases: Default::default(),
+30
View File
@@ -49,6 +49,11 @@ pub struct WorkspaceSettingsContent {
/// Values: empty_tab, last_workspace, last_session, launchpad
/// Default: last_session
pub restore_on_startup: Option<RestoreOnStartupBehavior>,
/// The default behavior when opening paths from the CLI without
/// an explicit `-e` or `-n` flag.
///
/// Default: existing_window
pub cli_default_open_behavior: Option<CliDefaultOpenBehavior>,
/// Whether to attempt to restore previous file's state when opening it again.
/// The state is stored per pane.
/// When disabled, defaults are applied instead of the state restoration.
@@ -379,6 +384,31 @@ impl CloseWindowWhenNoItems {
}
}
#[derive(
Copy,
Clone,
PartialEq,
Eq,
Default,
Serialize,
Deserialize,
JsonSchema,
MergeFrom,
Debug,
strum::VariantArray,
strum::VariantNames,
)]
#[serde(rename_all = "snake_case")]
pub enum CliDefaultOpenBehavior {
/// Add to the existing Zed window as a new workspace.
#[default]
#[strum(serialize = "Add to Existing Window")]
ExistingWindow,
/// Open a new Zed window.
#[strum(serialize = "Open a New Window")]
NewWindow,
}
#[derive(
Copy,
Clone,
+22 -1
View File
@@ -80,7 +80,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec<SettingsPage> {
}
fn general_page() -> SettingsPage {
fn general_settings_section() -> [SettingsPageItem; 8] {
fn general_settings_section() -> [SettingsPageItem; 9] {
[
SettingsPageItem::SectionHeader("General Settings"),
SettingsPageItem::SettingItem(SettingItem {
@@ -140,6 +140,27 @@ fn general_page() -> SettingsPage {
metadata: None,
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "CLI Default Open Behavior",
description: "How `zed <path>` opens directories when no `-e` or `-n` flag is specified.",
field: Box::new(SettingField {
json_path: Some("cli_default_open_behavior"),
pick: |settings_content| {
settings_content
.workspace
.cli_default_open_behavior
.as_ref()
},
write: |settings_content, value| {
settings_content.workspace.cli_default_open_behavior = value;
},
}),
metadata: Some(Box::new(SettingsFieldMetadata {
should_do_titlecase: Some(false),
..Default::default()
})),
files: USER,
}),
SettingsPageItem::SettingItem(SettingItem {
title: "Use System Path Prompts",
description: "Use native OS dialogs for 'Open' and 'Save As'.",
+3 -1
View File
@@ -458,6 +458,7 @@ fn init_renderers(cx: &mut App) {
.add_basic_renderer::<settings::RestoreOnStartupBehavior>(render_dropdown)
.add_basic_renderer::<settings::BottomDockLayout>(render_dropdown)
.add_basic_renderer::<settings::OnLastWindowClosed>(render_dropdown)
.add_basic_renderer::<settings::CliDefaultOpenBehavior>(render_dropdown)
.add_basic_renderer::<settings::CloseWindowWhenNoItems>(render_dropdown)
.add_basic_renderer::<settings::TextRenderingMode>(render_dropdown)
.add_basic_renderer::<settings::FontFamilyName>(render_font_picker)
@@ -1213,7 +1214,8 @@ fn render_settings_item(
.child(
Label::new(SharedString::new_static(setting_item.description))
.size(LabelSize::Small)
.color(Color::Muted),
.color(Color::Muted)
.render_code_spans(),
),
)
.child(control)
+111 -2
View File
@@ -1,5 +1,7 @@
use std::ops::Range;
use crate::{LabelLike, prelude::*};
use gpui::StyleRefinement;
use gpui::{HighlightStyle, StyleRefinement, StyledText};
/// A struct representing a label element in the UI.
///
@@ -33,6 +35,7 @@ use gpui::StyleRefinement;
pub struct Label {
base: LabelLike,
label: SharedString,
render_code_spans: bool,
}
impl Label {
@@ -49,9 +52,17 @@ impl Label {
Self {
base: LabelLike::new(),
label: label.into(),
render_code_spans: false,
}
}
/// When enabled, text wrapped in backticks (e.g. `` `code` ``) will be
/// rendered in the buffer (monospace) font.
pub fn render_code_spans(mut self) -> Self {
self.render_code_spans = true;
self
}
/// Sets the text of the [`Label`].
pub fn set_text(&mut self, text: impl Into<SharedString>) {
self.label = text.into();
@@ -233,11 +244,109 @@ impl LabelCommon for Label {
}
impl RenderOnce for Label {
fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
if self.render_code_spans {
if let Some((stripped, code_ranges)) = parse_backtick_spans(&self.label) {
let buffer_font_family = theme::theme_settings(cx).buffer_font(cx).family.clone();
let background_color = cx.theme().colors().element_background;
let highlights = code_ranges.iter().map(|range| {
(
range.clone(),
HighlightStyle {
background_color: Some(background_color),
..Default::default()
},
)
});
let font_overrides = code_ranges
.iter()
.map(|range| (range.clone(), buffer_font_family.clone()));
return self.base.child(
StyledText::new(stripped)
.with_highlights(highlights)
.with_font_family_overrides(font_overrides),
);
}
}
self.base.child(self.label)
}
}
/// Parses backtick-delimited code spans from a string.
///
/// Returns `None` if there are no matched backtick pairs.
/// Otherwise returns the text with backticks stripped and the byte ranges
/// of the code spans in the stripped string.
fn parse_backtick_spans(text: &str) -> Option<(SharedString, Vec<Range<usize>>)> {
if !text.contains('`') {
return None;
}
let mut stripped = String::with_capacity(text.len());
let mut code_ranges = Vec::new();
let mut in_code = false;
let mut code_start = 0;
for ch in text.chars() {
if ch == '`' {
if in_code {
code_ranges.push(code_start..stripped.len());
} else {
code_start = stripped.len();
}
in_code = !in_code;
} else {
stripped.push(ch);
}
}
if code_ranges.is_empty() {
return None;
}
Some((SharedString::from(stripped), code_ranges))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_backtick_spans_no_backticks() {
assert_eq!(parse_backtick_spans("plain text"), None);
}
#[test]
fn test_parse_backtick_spans_single_span() {
let (text, ranges) = parse_backtick_spans("use `zed` to open").unwrap();
assert_eq!(text.as_ref(), "use zed to open");
assert_eq!(ranges, vec![4..7]);
}
#[test]
fn test_parse_backtick_spans_multiple_spans() {
let (text, ranges) = parse_backtick_spans("flags `-e` or `-n`").unwrap();
assert_eq!(text.as_ref(), "flags -e or -n");
assert_eq!(ranges, vec![6..8, 12..14]);
}
#[test]
fn test_parse_backtick_spans_unmatched_backtick() {
// A trailing unmatched backtick should not produce a code range
assert_eq!(parse_backtick_spans("trailing `backtick"), None);
}
#[test]
fn test_parse_backtick_spans_empty_span() {
let (text, ranges) = parse_backtick_spans("empty `` span").unwrap();
assert_eq!(text.as_ref(), "empty span");
assert_eq!(ranges, vec![6..6]);
}
}
impl Component for Label {
fn scope() -> ComponentScope {
ComponentScope::Typography
+34 -22
View File
@@ -9317,6 +9317,7 @@ pub struct OpenOptions {
pub visible: Option<OpenVisible>,
pub focus: Option<bool>,
pub open_new_workspace: Option<bool>,
pub force_existing_window: bool,
pub wait: bool,
pub requesting_window: Option<WindowHandle<MultiWorkspace>>,
pub open_mode: OpenMode,
@@ -9501,31 +9502,42 @@ pub fn open_paths(
}
// Fallback for directories: when no flag is specified and no existing
// workspace matched, add the directory as a new workspace in the
// active window's MultiWorkspace (instead of opening a new window).
// workspace matched, check the user's setting to decide whether to add
// the directory as a new workspace in the active window's MultiWorkspace
// or open a new window.
if open_options.open_new_workspace.is_none() && existing.is_none() {
let target_window = cx.update(|cx| {
let windows = workspace_windows_for_location(
&SerializedWorkspaceLocation::Local,
cx,
);
let window = cx
.active_window()
.and_then(|window| window.downcast::<MultiWorkspace>())
.filter(|window| windows.contains(window))
.or_else(|| windows.into_iter().next());
window.filter(|window| {
window.read(cx).is_ok_and(|mw| mw.multi_workspace_enabled(cx))
})
});
let use_existing_window = open_options.force_existing_window
|| cx.update(|cx| {
WorkspaceSettings::get_global(cx).cli_default_open_behavior
== settings::CliDefaultOpenBehavior::ExistingWindow
});
if let Some(window) = target_window {
open_options.requesting_window = Some(window);
window
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
if use_existing_window {
let target_window = cx.update(|cx| {
let windows = workspace_windows_for_location(
&SerializedWorkspaceLocation::Local,
cx,
);
let window = cx
.active_window()
.and_then(|window| window.downcast::<MultiWorkspace>())
.filter(|window| windows.contains(window))
.or_else(|| windows.into_iter().next());
window.filter(|window| {
window
.read(cx)
.is_ok_and(|mw| mw.multi_workspace_enabled(cx))
})
.log_err();
});
if let Some(window) = target_window {
open_options.requesting_window = Some(window);
window
.update(cx, |multi_workspace, _, cx| {
multi_workspace.open_sidebar(cx);
})
.log_err();
}
}
}
@@ -20,6 +20,7 @@ pub struct WorkspaceSettings {
pub show_call_status_icon: bool,
pub autosave: AutosaveSetting,
pub restore_on_startup: settings::RestoreOnStartupBehavior,
pub cli_default_open_behavior: settings::CliDefaultOpenBehavior,
pub restore_on_file_reopen: bool,
pub drop_target_size: f32,
pub use_system_path_prompts: bool,
@@ -99,6 +100,7 @@ impl Settings for WorkspaceSettings {
show_call_status_icon: workspace.show_call_status_icon.unwrap(),
autosave: workspace.autosave.unwrap(),
restore_on_startup: workspace.restore_on_startup.unwrap(),
cli_default_open_behavior: workspace.cli_default_open_behavior.unwrap(),
restore_on_file_reopen: workspace.restore_on_file_reopen.unwrap(),
drop_target_size: workspace.drop_target_size.unwrap(),
use_system_path_prompts: workspace.use_system_path_prompts.unwrap(),
+573 -40
View File
@@ -2,7 +2,7 @@ use crate::handle_open_request;
use crate::restore_or_create_workspace;
use agent_ui::ExternalSourcePrompt;
use anyhow::{Context as _, Result, anyhow};
use cli::{CliRequest, CliResponse, ipc::IpcSender};
use cli::{CliRequest, CliResponse, CliResponseSink};
use cli::{IpcHandshake, ipc};
use client::{ZedLink, parse_zed_link};
use db::kvp::KeyValueStore;
@@ -12,7 +12,7 @@ use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
use futures::channel::{mpsc, oneshot};
use futures::future;
use futures::{FutureExt, SinkExt, StreamExt};
use futures::{FutureExt, StreamExt};
use git_ui::{file_diff_view::FileDiffView, multi_diff_view::MultiDiffView};
use gpui::{App, AsyncApp, Global, WindowHandle};
use onboarding::FIRST_OPEN;
@@ -26,6 +26,7 @@ use std::thread;
use std::time::Duration;
use ui::SharedString;
use util::ResultExt;
use util::debug_panic;
use util::paths::PathWithPosition;
use workspace::PathList;
use workspace::item::ItemHandle;
@@ -43,9 +44,13 @@ pub struct OpenRequest {
pub remote_connection: Option<RemoteConnectionOptions>,
}
#[derive(Debug)]
pub enum OpenRequestKind {
CliConnection((mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)),
CliConnection(
(
mpsc::UnboundedReceiver<CliRequest>,
Box<dyn CliResponseSink>,
),
),
Extension {
extension_id: String,
},
@@ -73,6 +78,45 @@ pub enum OpenRequestKind {
},
}
impl std::fmt::Debug for OpenRequestKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::CliConnection(_) => write!(f, "CliConnection(..)"),
Self::Extension { extension_id } => f
.debug_struct("Extension")
.field("extension_id", extension_id)
.finish(),
Self::AgentPanel {
external_source_prompt,
} => f
.debug_struct("AgentPanel")
.field("external_source_prompt", external_source_prompt)
.finish(),
Self::SharedAgentThread { session_id } => f
.debug_struct("SharedAgentThread")
.field("session_id", session_id)
.finish(),
Self::DockMenuAction { index } => f
.debug_struct("DockMenuAction")
.field("index", index)
.finish(),
Self::BuiltinJsonSchema { schema_path } => f
.debug_struct("BuiltinJsonSchema")
.field("schema_path", schema_path)
.finish(),
Self::Setting { setting_path } => f
.debug_struct("Setting")
.field("setting_path", setting_path)
.finish(),
Self::GitClone { repo_url } => f
.debug_struct("GitClone")
.field("repo_url", repo_url)
.finish(),
Self::GitCommit { sha } => f.debug_struct("GitCommit").field("sha", sha).finish(),
}
}
}
impl OpenRequest {
pub fn parse(request: RawOpenRequest, cx: &App) -> Result<Self> {
let mut this = Self::default();
@@ -305,8 +349,11 @@ pub fn listen_for_cli_connections(opener: OpenListener) -> Result<()> {
fn connect_to_cli(
server_name: &str,
) -> Result<(mpsc::Receiver<CliRequest>, IpcSender<CliResponse>)> {
let handshake_tx = cli::ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
) -> Result<(
mpsc::UnboundedReceiver<CliRequest>,
Box<dyn CliResponseSink>,
)> {
let handshake_tx = ipc::IpcSender::<IpcHandshake>::connect(server_name.to_string())
.context("error connecting to cli")?;
let (request_tx, request_rx) = ipc::channel::<CliRequest>()?;
let (response_tx, response_rx) = ipc::channel::<CliResponse>()?;
@@ -318,18 +365,17 @@ fn connect_to_cli(
})
.context("error sending ipc handshake")?;
let (mut async_request_tx, async_request_rx) =
futures::channel::mpsc::channel::<CliRequest>(16);
let (async_request_tx, async_request_rx) = futures::channel::mpsc::unbounded::<CliRequest>();
thread::spawn(move || {
while let Ok(cli_request) = request_rx.recv() {
if smol::block_on(async_request_tx.send(cli_request)).is_err() {
if async_request_tx.unbounded_send(cli_request).is_err() {
break;
}
}
anyhow::Ok(())
});
Ok((async_request_rx, response_tx))
Ok((async_request_rx, Box::new(response_tx)))
}
pub async fn open_paths_with_positions(
@@ -399,7 +445,10 @@ pub async fn open_paths_with_positions(
}
pub async fn handle_cli_connection(
(mut requests, responses): (mpsc::Receiver<CliRequest>, IpcSender<CliResponse>),
(mut requests, responses): (
mpsc::UnboundedReceiver<CliRequest>,
Box<dyn CliResponseSink>,
),
app_state: Arc<AppState>,
cx: &mut AsyncApp,
) {
@@ -412,7 +461,8 @@ pub async fn handle_cli_connection(
diff_all,
wait,
wsl,
open_new_workspace,
mut open_new_workspace,
mut force_existing_window,
reuse,
env,
user_data_dir: _,
@@ -447,13 +497,36 @@ pub async fn handle_cli_connection(
return;
}
if let Some(behavior) = maybe_prompt_open_behavior(
open_new_workspace,
force_existing_window,
reuse,
&paths,
&app_state,
responses.as_ref(),
&mut requests,
cx,
)
.await
{
match behavior {
settings::CliDefaultOpenBehavior::ExistingWindow => {
force_existing_window = true;
}
settings::CliDefaultOpenBehavior::NewWindow => {
open_new_workspace = Some(true);
}
}
}
let open_workspace_result = open_workspaces(
paths,
diff_paths,
diff_all,
open_new_workspace,
force_existing_window,
reuse,
&responses,
responses.as_ref(),
wait,
dev_container,
app_state.clone(),
@@ -465,17 +538,127 @@ pub async fn handle_cli_connection(
let status = if open_workspace_result.is_err() { 1 } else { 0 };
responses.send(CliResponse::Exit { status }).log_err();
}
CliRequest::SetOpenBehavior { .. } => {
// We handle this case in a situation-specific way in
// maybe_prompt_open_behavior
debug_panic!("unexpected SetOpenBehavior message");
}
}
}
}
/// Checks whether the CLI user should be prompted to configure their default
/// open behavior. Sends `CliResponse::PromptOpenBehavior` and waits for the
/// CLI's response if all of these are true:
/// - No explicit flag was given (`-n`, `-e`, `-a`)
/// - There is at least one existing Zed window
/// - The user has not yet configured `cli_default_open_behavior` in settings
///
/// Returns the user's choice, or `None` if no prompt was needed or the CLI
/// didn't respond.
async fn maybe_prompt_open_behavior(
open_new_workspace: Option<bool>,
force_existing_window: bool,
reuse: bool,
paths: &[String],
app_state: &Arc<AppState>,
responses: &dyn CliResponseSink,
requests: &mut mpsc::UnboundedReceiver<CliRequest>,
cx: &mut AsyncApp,
) -> Option<settings::CliDefaultOpenBehavior> {
if open_new_workspace.is_some() || force_existing_window || reuse {
return None;
}
let has_existing_windows = cx.update(|cx| {
cx.windows()
.iter()
.any(|window| window.downcast::<MultiWorkspace>().is_some())
});
if !has_existing_windows {
return None;
}
if !paths.is_empty() {
let paths_as_pathbufs: Vec<PathBuf> = paths.iter().map(PathBuf::from).collect();
let paths_in_existing_workspace = cx.update(|cx| {
for window in cx.windows() {
if let Some(multi_workspace) = window.downcast::<MultiWorkspace>() {
if let Ok(multi_workspace) = multi_workspace.read(cx) {
for workspace in multi_workspace.workspaces() {
let project = workspace.read(cx).project().read(cx);
if project
.visibility_for_paths(&paths_as_pathbufs, false, cx)
.is_some()
{
return true;
}
}
}
}
}
false
});
if paths_in_existing_workspace {
return None;
}
}
if !paths.is_empty() {
let has_directory =
futures::future::join_all(paths.iter().map(|p| app_state.fs.is_dir(Path::new(p))))
.await
.into_iter()
.any(|is_dir| is_dir);
if !has_directory {
return None;
}
}
let settings_text = app_state
.fs
.load(paths::settings_file())
.await
.unwrap_or_default();
if settings_text.contains("cli_default_open_behavior") {
return None;
}
responses.send(CliResponse::PromptOpenBehavior).log_err()?;
if let Some(CliRequest::SetOpenBehavior { behavior }) = requests.next().await {
let behavior = match behavior {
cli::CliOpenBehavior::ExistingWindow => {
settings::CliDefaultOpenBehavior::ExistingWindow
}
cli::CliOpenBehavior::NewWindow => settings::CliDefaultOpenBehavior::NewWindow,
};
let fs = app_state.fs.clone();
cx.update(|cx| {
settings::update_settings_file(fs, cx, move |content, _cx| {
content.workspace.cli_default_open_behavior = Some(behavior);
});
});
return Some(behavior);
}
None
}
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,
responses: &IpcSender<CliResponse>,
responses: &dyn CliResponseSink,
wait: bool,
dev_container: bool,
app_state: Arc<AppState>,
@@ -536,6 +719,7 @@ async fn open_workspaces(
};
let open_options = workspace::OpenOptions {
open_new_workspace,
force_existing_window,
requesting_window: replace_window,
wait,
env: env.clone(),
@@ -600,7 +784,7 @@ async fn open_local_workspace(
diff_paths: Vec<[String; 2]>,
diff_all: bool,
open_options: workspace::OpenOptions,
responses: &IpcSender<CliResponse>,
responses: &dyn CliResponseSink,
app_state: &Arc<AppState>,
cx: &mut AsyncApp,
) -> bool {
@@ -742,10 +926,7 @@ pub async fn derive_paths_with_position(
mod tests {
use super::*;
use crate::zed::{open_listener::open_local_workspace, tests::init_test};
use cli::{
CliResponse,
ipc::{self},
};
use cli::CliResponse;
use editor::Editor;
use futures::poll;
use gpui::{AppContext as _, TestAppContext};
@@ -757,6 +938,24 @@ mod tests {
use util::path;
use workspace::{AppState, MultiWorkspace};
struct DiscardResponseSink;
impl CliResponseSink for DiscardResponseSink {
fn send(&self, _response: CliResponse) -> anyhow::Result<()> {
Ok(())
}
}
struct SyncResponseSender(std::sync::mpsc::Sender<CliResponse>);
impl CliResponseSink for SyncResponseSender {
fn send(&self, response: CliResponse) -> anyhow::Result<()> {
self.0
.send(response)
.map_err(|error| anyhow::anyhow!("{error}"))
}
}
#[gpui::test]
fn test_parse_ssh_url(cx: &mut TestAppContext) {
let _app_state = init_test(cx);
@@ -1072,7 +1271,7 @@ mod tests {
)
.await;
let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
let response_sink = DiscardResponseSink;
let workspace_paths = vec![path!("/root/dir1").to_owned()];
let (done_tx, mut done_rx) = futures::channel::oneshot::channel();
@@ -1087,7 +1286,7 @@ mod tests {
wait: true,
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1171,7 +1370,7 @@ mod tests {
app_state: Arc<AppState>,
cx: &TestAppContext,
) {
let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
let response_sink = DiscardResponseSink;
let workspace_paths = vec![path.to_owned()];
@@ -1185,7 +1384,7 @@ mod tests {
open_new_workspace,
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1243,20 +1442,19 @@ mod tests {
.unwrap();
// First, open a workspace normally
let (response_tx, _response_rx) = ipc::channel::<CliResponse>().unwrap();
let response_sink = DiscardResponseSink;
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![],
false,
workspace::OpenOptions::default(),
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1282,8 +1480,8 @@ mod tests {
let errored_reuse = cx
.spawn({
let app_state = app_state.clone();
let response_tx = response_tx.clone();
|mut cx| async move {
let response_sink = DiscardResponseSink;
open_local_workspace(
workspace_paths_reuse,
vec![],
@@ -1292,7 +1490,7 @@ mod tests {
requesting_window: Some(window_to_replace),
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1426,21 +1624,19 @@ mod tests {
.await
.unwrap();
let (response_tx, _response_rx) = ipc::channel::<CliResponse>().unwrap();
// Open first workspace
let workspace_paths_1 = 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 {
let response_sink = DiscardResponseSink;
open_local_workspace(
workspace_paths_1,
Vec::new(),
false,
workspace::OpenOptions::default(),
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1457,8 +1653,8 @@ mod tests {
let _errored = cx
.spawn({
let app_state = app_state.clone();
let response_tx = response_tx.clone();
|mut cx| async move {
let response_sink = DiscardResponseSink;
open_local_workspace(
workspace_paths_2,
Vec::new(),
@@ -1467,7 +1663,7 @@ mod tests {
open_new_workspace: Some(true), // Force new window
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1503,8 +1699,8 @@ mod tests {
let _errored = cx
.spawn({
let app_state = app_state.clone();
let response_tx = response_tx.clone();
|mut cx| async move {
let response_sink = DiscardResponseSink;
open_local_workspace(
workspace_paths_add,
Vec::new(),
@@ -1513,7 +1709,7 @@ mod tests {
open_new_workspace: Some(false), // --add flag
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1564,11 +1760,11 @@ mod tests {
)
.await;
let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
let errored = cx
.spawn({
let app_state = app_state.clone();
|mut cx| async move {
let response_sink = DiscardResponseSink;
open_local_workspace(
vec![path!("/project").to_owned()],
vec![],
@@ -1577,7 +1773,7 @@ mod tests {
open_in_dev_container: true,
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1618,11 +1814,11 @@ mod tests {
)
.await;
let (response_tx, _) = ipc::channel::<CliResponse>().unwrap();
let errored = cx
.spawn({
let app_state = app_state.clone();
|mut cx| async move {
let response_sink = DiscardResponseSink;
open_local_workspace(
vec![path!("/project").to_owned()],
vec![],
@@ -1631,7 +1827,7 @@ mod tests {
open_in_dev_container: true,
..Default::default()
},
&response_tx,
&response_sink,
&app_state,
&mut cx,
)
@@ -1661,4 +1857,341 @@ mod tests {
})
.unwrap();
}
fn make_cli_open_request(
paths: Vec<String>,
open_new_workspace: Option<bool>,
force_existing_window: bool,
) -> CliRequest {
CliRequest::Open {
paths,
urls: vec![],
diff_paths: vec![],
diff_all: false,
wsl: None,
wait: false,
open_new_workspace,
force_existing_window,
reuse: false,
env: None,
user_data_dir: None,
dev_container: false,
}
}
/// Runs the real [`cli::run_cli_response_loop`] on an OS thread against
/// the Zed-side `handle_cli_connection` on the GPUI foreground executor,
/// using `allow_parking` so the test scheduler tolerates cross-thread
/// wakeups.
///
/// Returns `(exit_status, prompt_was_shown)`.
fn run_cli_with_zed_handler(
cx: &mut TestAppContext,
app_state: Arc<AppState>,
open_request: CliRequest,
prompt_response: Option<cli::CliOpenBehavior>,
) -> (i32, bool) {
cx.executor().allow_parking();
let (request_tx, request_rx) = mpsc::unbounded::<CliRequest>();
let (response_tx, response_rx) = std::sync::mpsc::channel::<CliResponse>();
let response_sink: Box<dyn CliResponseSink> = Box::new(SyncResponseSender(response_tx));
cx.spawn(|mut cx| async move {
handle_cli_connection((request_rx, response_sink), app_state, &mut cx).await;
})
.detach();
let prompt_called = Arc::new(std::sync::atomic::AtomicBool::new(false));
let prompt_called_for_thread = prompt_called.clone();
let cli_thread = std::thread::spawn(move || -> anyhow::Result<i32> {
request_tx
.unbounded_send(open_request)
.map_err(|error| anyhow::anyhow!("{error}"))?;
while let Ok(response) = response_rx.recv() {
match response {
CliResponse::Ping => {}
CliResponse::Stdout { .. } | CliResponse::Stderr { .. } => {}
CliResponse::Exit { status } => return Ok(status),
CliResponse::PromptOpenBehavior => {
prompt_called_for_thread.store(true, std::sync::atomic::Ordering::SeqCst);
let behavior =
prompt_response.unwrap_or(cli::CliOpenBehavior::ExistingWindow);
request_tx
.unbounded_send(CliRequest::SetOpenBehavior { behavior })
.map_err(|error| anyhow::anyhow!("{error}"))?;
}
}
}
anyhow::bail!("CLI response channel closed without Exit")
});
while !cli_thread.is_finished() {
cx.run_until_parked();
std::thread::sleep(std::time::Duration::from_millis(1));
}
let exit_status = cli_thread.join().unwrap().expect("CLI loop failed");
let prompt_shown = prompt_called.load(std::sync::atomic::Ordering::SeqCst);
// Flush any remaining async work (e.g. settings file writes).
cx.run_until_parked();
(exit_status, prompt_shown)
}
#[gpui::test]
async fn test_e2e_no_flags_no_windows_no_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/project"), json!({ "file.txt": "content" }))
.await;
assert_eq!(cx.windows().len(), 0);
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),
None,
);
assert_eq!(status, 0);
assert!(
!prompt_shown,
"no prompt should be shown when no windows exist"
);
assert_eq!(cx.windows().len(), 1);
}
#[gpui::test]
async fn test_e2e_prompt_user_picks_existing_window(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/project_a"), json!({ "file.txt": "content" }))
.await;
app_state
.fs
.as_fake()
.insert_tree(path!("/project_b"), json!({ "file.txt": "content" }))
.await;
// Create an existing window so the prompt triggers
open_workspace_file(path!("/project_a"), None, 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),
);
assert_eq!(status, 0);
assert!(prompt_shown, "prompt should be shown");
assert_eq!(cx.windows().len(), 1);
let settings_text = app_state
.fs
.load(paths::settings_file())
.await
.unwrap_or_default();
assert!(
settings_text.contains("existing_window"),
"settings should contain 'existing_window', got: {settings_text}"
);
}
#[gpui::test]
async fn test_e2e_prompt_user_picks_new_window(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/project_a"), json!({ "file.txt": "content" }))
.await;
app_state
.fs
.as_fake()
.insert_tree(path!("/project_b"), json!({ "file.txt": "content" }))
.await;
// Create an existing window with project_a
open_workspace_file(path!("/project_a"), None, 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),
);
assert_eq!(status, 0);
assert!(prompt_shown, "prompt should be shown");
assert_eq!(cx.windows().len(), 2);
let settings_text = app_state
.fs
.load(paths::settings_file())
.await
.unwrap_or_default();
assert!(
settings_text.contains("new_window"),
"settings should contain 'new_window', got: {settings_text}"
);
}
#[gpui::test]
async fn test_e2e_setting_already_configured_no_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/project"), json!({ "file.txt": "content" }))
.await;
// Pre-configure the setting in settings.json
app_state
.fs
.as_fake()
.insert_tree(
paths::config_dir(),
json!({
"settings.json": r#"{"cli_default_open_behavior": "existing_window"}"#
}),
)
.await;
// Create an existing window
open_workspace_file(path!("/project"), None, 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),
None,
);
assert_eq!(status, 0);
assert!(
!prompt_shown,
"no prompt should be shown when setting already configured"
);
}
#[gpui::test]
async fn test_e2e_explicit_existing_flag_no_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/project"), json!({ "file.txt": "content" }))
.await;
// Create an existing window
open_workspace_file(path!("/project"), None, 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,
true, // -e flag: force existing window
),
None,
);
assert_eq!(status, 0);
assert!(!prompt_shown, "no prompt should be shown with -e flag");
assert_eq!(cx.windows().len(), 1);
}
#[gpui::test]
async fn test_e2e_explicit_new_flag_no_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(path!("/project_a"), json!({ "file.txt": "content" }))
.await;
app_state
.fs
.as_fake()
.insert_tree(path!("/project_b"), json!({ "file.txt": "content" }))
.await;
// Create an existing window
open_workspace_file(path!("/project_a"), None, 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_b/file.txt").to_string()],
Some(true), // -n flag: force new window
false,
),
None,
);
assert_eq!(status, 0);
assert!(!prompt_shown, "no prompt should be shown with -n flag");
assert_eq!(cx.windows().len(), 2);
}
#[gpui::test]
async fn test_e2e_paths_in_existing_workspace_no_prompt(cx: &mut TestAppContext) {
let app_state = init_test(cx);
app_state
.fs
.as_fake()
.insert_tree(
path!("/project"),
json!({
"src": {
"main.rs": "fn main() {}",
}
}),
)
.await;
// Open the project directory as a workspace
open_workspace_file(path!("/project"), None, 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),
None,
);
assert_eq!(status, 0);
assert!(
!prompt_shown,
"no prompt should be shown when paths are in an existing workspace"
);
// File opened in existing window
assert_eq!(cx.windows().len(), 1);
}
}
@@ -159,6 +159,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
wait: false,
wsl: args.wsl.clone(),
open_new_workspace: None,
force_existing_window: false,
reuse: false,
env: None,
user_data_dir: args.user_data_dir.clone(),
@@ -186,6 +187,11 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
exit_status.lock().replace(status);
return Ok(());
}
CliResponse::PromptOpenBehavior => {
tx.send(CliRequest::SetOpenBehavior {
behavior: cli::CliOpenBehavior::ExistingWindow,
})?;
}
}
}
Ok(())