diff --git a/Cargo.lock b/Cargo.lock index 5280fc72d074c22414b603a9b7092f2005f07a85..ca2e15a8ecf4d648e2e48e6c2e7e7feddd4c8fbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 9825c8319a7bb3440782b155d9952619096bdfd5..fea8ba56761ea76d2ec37381e498a2711efa0d43 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/assets/settings/default.json b/assets/settings/default.json index 8e8c93c5088338af63a2daed8c87fe031d500727..2fd6120ba0d79add35903117e17a43caa02ef619 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -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. diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index c7a71f036a350b5ab4b8a7eb49fd1ba0aa7d7272..cfd807c0356aa2a11e018c60db033b42471bf876 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -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 diff --git a/crates/cli/src/cli.rs b/crates/cli/src/cli.rs index d8da78c53210230597dab49ce297d9fa694e62f1..ea7e42beb4e22d7743bc5caade972f8f9f889925 100644 --- a/crates/cli/src/cli.rs +++ b/crates/cli/src/cli.rs @@ -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, } +#[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, wait: bool, open_new_workspace: Option, + #[serde(default)] + force_existing_window: bool, reuse: bool, env: Option>, user_data_dir: Option, 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`. 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 { + fn send(&self, response: CliResponse) -> Result<()> { + ipc::IpcSender::send(self, response).map_err(|error| anyhow::anyhow!("{error}")) + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 41f2d14c1908ac18e7ea297eef19d8d9bd1cf8b5..49129532603625b48ac86ba506ad3ff1014f30d3 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -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 { } } +/// Shows an interactive prompt asking the user to choose the default open +/// behavior for `zed `. Returns `None` if the prompt cannot be shown +/// (e.g. stdin is not a terminal) or the user cancels. +fn prompt_open_behavior() -> Option { + 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 "), + 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::{ diff --git a/crates/gpui/src/elements/text.rs b/crates/gpui/src/elements/text.rs index 49036abfec1cb3145ce72d2aabe7683e308f1ed0..847acb6e287b0165faa75889ae01e688b504295e 100644 --- a/crates/gpui/src/elements/text.rs +++ b/crates/gpui/src/elements/text.rs @@ -159,6 +159,7 @@ pub struct StyledText { text: SharedString, runs: Option>, delayed_highlights: Option, HighlightStyle)>>, + delayed_font_family_overrides: Option, 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, 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, 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) -> 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, ()) } diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 042b3a7c71c77d8aaa02cec559a943608ee87859..40565fc4616d3b71c61729743d36b8479c3e590f 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -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(), diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 02ec229cb37bfa39aded1764f0f1c5235e081ba6..cebc73550f268f0be5385b5eb41928898db67585 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -49,6 +49,11 @@ pub struct WorkspaceSettingsContent { /// Values: empty_tab, last_workspace, last_session, launchpad /// Default: last_session pub restore_on_startup: Option, + /// 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, /// 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, diff --git a/crates/settings_ui/src/page_data.rs b/crates/settings_ui/src/page_data.rs index cef65431a459126ac14054dee5bc5ffe68e2419c..a14a831452a423baf5f75ec2698ee86c34ae042d 100644 --- a/crates/settings_ui/src/page_data.rs +++ b/crates/settings_ui/src/page_data.rs @@ -80,7 +80,7 @@ pub(crate) fn settings_data(cx: &App) -> Vec { } 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 ` 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'.", diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs index 9a5a7dafea4708205569b53e7aa460510a9fbb1e..c5df1910a4d6a2d0b660c73cb31b936f67a9d76b 100644 --- a/crates/settings_ui/src/settings_ui.rs +++ b/crates/settings_ui/src/settings_ui.rs @@ -458,6 +458,7 @@ fn init_renderers(cx: &mut App) { .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) + .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(render_dropdown) .add_basic_renderer::(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) diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index 405948ea06c7e86fcb3dec217186596bdaaf0aeb..871f53fbe4d00828850017c6d90f395762262631 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -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) { 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>)> { + 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 diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b25e9c4128b7ecfa428f328c59d3344ed634b293..857db0795bbac8cfe5ee3040971d071c05ed38cd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -9317,6 +9317,7 @@ pub struct OpenOptions { pub visible: Option, pub focus: Option, pub open_new_workspace: Option, + pub force_existing_window: bool, pub wait: bool, pub requesting_window: Option>, 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::()) - .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::()) + .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(); + } } } diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index ee0e80336d744cadaecdf0201525deddb8d5eec9..f097f381d16a51f32e3079968334fa65e264498d 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -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(), diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 16d220c56093a2645db9f5bdc3114e16814138ac..7094a6a6a7addcfdb5c373258a95b2f2b02d5c2b 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -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, } -#[derive(Debug)] pub enum OpenRequestKind { - CliConnection((mpsc::Receiver, IpcSender)), + CliConnection( + ( + mpsc::UnboundedReceiver, + Box, + ), + ), 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 { 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, IpcSender)> { - let handshake_tx = cli::ipc::IpcSender::::connect(server_name.to_string()) +) -> Result<( + mpsc::UnboundedReceiver, + Box, +)> { + let handshake_tx = ipc::IpcSender::::connect(server_name.to_string()) .context("error connecting to cli")?; let (request_tx, request_rx) = ipc::channel::()?; let (response_tx, response_rx) = ipc::channel::()?; @@ -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::(16); + let (async_request_tx, async_request_rx) = futures::channel::mpsc::unbounded::(); 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, IpcSender), + (mut requests, responses): ( + mpsc::UnboundedReceiver, + Box, + ), app_state: Arc, 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,8 +538,117 @@ 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, + force_existing_window: bool, + reuse: bool, + paths: &[String], + app_state: &Arc, + responses: &dyn CliResponseSink, + requests: &mut mpsc::UnboundedReceiver, + cx: &mut AsyncApp, +) -> Option { + 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::().is_some()) + }); + + if !has_existing_windows { + return None; + } + + if !paths.is_empty() { + let paths_as_pathbufs: Vec = 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::() { + 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( @@ -474,8 +656,9 @@ async fn open_workspaces( diff_paths: Vec<[String; 2]>, diff_all: bool, open_new_workspace: Option, + force_existing_window: bool, reuse: bool, - responses: &IpcSender, + responses: &dyn CliResponseSink, wait: bool, dev_container: bool, app_state: Arc, @@ -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, + responses: &dyn CliResponseSink, app_state: &Arc, 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); + + 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::().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, cx: &TestAppContext, ) { - let (response_tx, _) = ipc::channel::().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::().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::().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::().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::().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, + open_new_workspace: Option, + 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, + open_request: CliRequest, + prompt_response: Option, + ) -> (i32, bool) { + cx.executor().allow_parking(); + + let (request_tx, request_rx) = mpsc::unbounded::(); + let (response_tx, response_rx) = std::sync::mpsc::channel::(); + let response_sink: Box = 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 { + 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); + } } diff --git a/crates/zed/src/zed/windows_only_instance.rs b/crates/zed/src/zed/windows_only_instance.rs index f22f49e26a982cb8cb68e21645033819e059de36..efc0e9e999d05d4d2dfe4969f82679e909f3ea06 100644 --- a/crates/zed/src/zed/windows_only_instance.rs +++ b/crates/zed/src/zed/windows_only_instance.rs @@ -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(())