Detailed changes
@@ -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]]
@@ -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" }
@@ -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.
@@ -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
@@ -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}"))
+ }
+}
@@ -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::{
@@ -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, ())
}
@@ -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(),
@@ -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,
@@ -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'.",
@@ -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)
@@ -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
@@ -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(),
@@ -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,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<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(
@@ -474,8 +656,9 @@ async fn open_workspaces(
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(())