From 24a6008e5c0bc691a8ee68f4cbfafd919367efde Mon Sep 17 00:00:00 2001
From: MostlyK <135974627+MostlyKIGuess@users.noreply.github.com>
Date: Tue, 7 Apr 2026 11:12:50 +0530
Subject: [PATCH 1/9] repl: Improve iopub connection error messages (#53014)
Coming from #51834, these would be more helpful than just that it
failed!
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
- N/A
---
crates/repl/src/kernels/ssh_kernel.rs | 2 +-
crates/repl/src/kernels/wsl_kernel.rs | 3 ++-
2 files changed, 3 insertions(+), 2 deletions(-)
diff --git a/crates/repl/src/kernels/ssh_kernel.rs b/crates/repl/src/kernels/ssh_kernel.rs
index 53be6622379cfcbf3ceeb6db425eeede9b226860..797b111a14345267e01c60c6803787c8f1d0f6a2 100644
--- a/crates/repl/src/kernels/ssh_kernel.rs
+++ b/crates/repl/src/kernels/ssh_kernel.rs
@@ -215,7 +215,7 @@ impl SshRunningKernel {
&session_id,
)
.await
- .context("failed to create iopub connection")?;
+ .context("Failed to create iopub connection. Is `ipykernel` installed in the remote environment? Try running `pip install ipykernel` on the remote host.")?;
let peer_identity = runtimelib::peer_identity_for_session(&session_id)?;
let shell_socket = runtimelib::create_client_shell_connection_with_identity(
diff --git a/crates/repl/src/kernels/wsl_kernel.rs b/crates/repl/src/kernels/wsl_kernel.rs
index d9ac05c5fc8c2cb756898ff449d6714b78cb7997..be76d7ddccb7f199a368b76a1f21bf65fe6f2902 100644
--- a/crates/repl/src/kernels/wsl_kernel.rs
+++ b/crates/repl/src/kernels/wsl_kernel.rs
@@ -354,7 +354,8 @@ impl WslRunningKernel {
"",
&session_id,
)
- .await?;
+ .await
+ .context("Failed to create iopub connection. Is `ipykernel` installed in the WSL environment? Try running `pip install ipykernel` inside your WSL distribution.")?;
let peer_identity = runtimelib::peer_identity_for_session(&session_id)?;
let shell_socket = runtimelib::create_client_shell_connection_with_identity(
From 6f7fab1d68f1fa4945c7717a595b9e9776a14521 Mon Sep 17 00:00:00 2001
From: Smit Barmase
Date: Tue, 7 Apr 2026 11:19:21 +0530
Subject: [PATCH 2/9] http_client: Fix GitHub download unpack failures on some
filesystems (#53286)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Disable mtime preservation when unpacking tar archives, as some
filesystems error when asked to set it. Follows how
[cargo](https://github.com/rust-lang/cargo/blob/1ad92f77a819953bcef75a24019b66681ff28b1c/src/cargo/ops/cargo_package/verify.rs#L59
) and
[uv](https://github.com/astral-sh/uv/blob/0da0cd8b4310d3ac4be96223bd1e24ada109af9e/crates/uv-extract/src/stream.rs#L658)
handle it.
> Caused by:
0: extracting
https://github.com/microsoft/vscode-eslint/archive/refs/tags/release%2F3.0.24.tar.gz
to "/Users/user-name-here/Library/Application
Support/Zed/languages/eslint/.tmp-github-download-pYkrYP"
1: failed to unpack `/Users/user-name-here/Library/Application
Support/Zed/languages/eslint/.tmp-github-download-pYkrYP/vscode-eslint-release-3.0.24/package-lock.json`
2: failed to set mtime for
`/Users/user-name-here/Library/Application
Support/Zed/languages/eslint/.tmp-github-download-pYkrYP/vscode-eslint-release-3.0.24/package-lock.json`
3: No such file or directory (os error 2)
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Release Notes:
- N/A
---
crates/http_client/src/github_download.rs | 22 ++++++++++++++++------
1 file changed, 16 insertions(+), 6 deletions(-)
diff --git a/crates/http_client/src/github_download.rs b/crates/http_client/src/github_download.rs
index 47ae2c2b36b1ab37b56ab70735c2ce018bc5e275..5d11f3e11b7ea951c6bc9c143c266d8802f88cc3 100644
--- a/crates/http_client/src/github_download.rs
+++ b/crates/http_client/src/github_download.rs
@@ -207,11 +207,7 @@ async fn extract_tar_gz(
from: impl AsyncRead + Unpin,
) -> Result<(), anyhow::Error> {
let decompressed_bytes = GzipDecoder::new(BufReader::new(from));
- let archive = async_tar::Archive::new(decompressed_bytes);
- archive
- .unpack(&destination_path)
- .await
- .with_context(|| format!("extracting {url} to {destination_path:?}"))?;
+ unpack_tar_archive(destination_path, url, decompressed_bytes).await?;
Ok(())
}
@@ -221,7 +217,21 @@ async fn extract_tar_bz2(
from: impl AsyncRead + Unpin,
) -> Result<(), anyhow::Error> {
let decompressed_bytes = BzDecoder::new(BufReader::new(from));
- let archive = async_tar::Archive::new(decompressed_bytes);
+ unpack_tar_archive(destination_path, url, decompressed_bytes).await?;
+ Ok(())
+}
+
+async fn unpack_tar_archive(
+ destination_path: &Path,
+ url: &str,
+ archive_bytes: impl AsyncRead + Unpin,
+) -> Result<(), anyhow::Error> {
+ // We don't need to set the modified time. It's irrelevant to downloaded
+ // archive verification, and some filesystems return errors when asked to
+ // apply it after extraction.
+ let archive = async_tar::ArchiveBuilder::new(archive_bytes)
+ .set_preserve_mtime(false)
+ .build();
archive
.unpack(&destination_path)
.await
From 818991db7781db11bd8b1dea9eb27179713156f1 Mon Sep 17 00:00:00 2001
From: Saketh <126517689+SAKETH11111@users.noreply.github.com>
Date: Tue, 7 Apr 2026 01:14:00 -0500
Subject: [PATCH 3/9] tasks_ui: Fix previously used task tooltip (#53104)
Closes #52941
## Summary
- update the task picker delete button tooltip to describe the recently
used task entry it removes
- keep the change scoped to the inaccurate user-facing copy in the tasks
modal
## Testing
- cargo test -p tasks_ui
Release Notes:
- N/A
---
crates/tasks_ui/src/modal.rs | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/crates/tasks_ui/src/modal.rs b/crates/tasks_ui/src/modal.rs
index 285a07c9562849b26b4cbba3de3979614384d875..3b7edef415f10f8723ab041e5a81ac672d603371 100644
--- a/crates/tasks_ui/src/modal.rs
+++ b/crates/tasks_ui/src/modal.rs
@@ -566,9 +566,7 @@ impl PickerDelegate for TasksModalDelegate {
.checked_sub(1);
picker.refresh(window, cx);
}))
- .tooltip(|_, cx| {
- Tooltip::simple("Delete Previously Scheduled Task", cx)
- }),
+ .tooltip(|_, cx| Tooltip::simple("Delete from Recent Tasks", cx)),
);
item.end_slot_on_hover(delete_button)
} else {
From ee6495dce4012019ccc235486afa800da443d680 Mon Sep 17 00:00:00 2001
From: Cameron Mcloughlin
Date: Tue, 7 Apr 2026 09:02:40 +0100
Subject: [PATCH 4/9] collab: Fix UI font size scaling (#53290)
---
crates/collab_ui/src/collab_panel.rs | 7 -------
1 file changed, 7 deletions(-)
diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs
index 8d0cdf351163dadf0ac8cbf6a8dc04886f30f583..1e1aab3b9d4aa0e48ad4a84ec77bdc6dff51c7f5 100644
--- a/crates/collab_ui/src/collab_panel.rs
+++ b/crates/collab_ui/src/collab_panel.rs
@@ -1181,7 +1181,6 @@ impl CollabPanel {
.into();
ListItem::new(project_id as usize)
- .height(px(24.))
.toggle_state(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.workspace
@@ -1222,7 +1221,6 @@ impl CollabPanel {
let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize);
ListItem::new(("screen", id))
- .height(px(24.))
.toggle_state(is_selected)
.start_slot(
h_flex()
@@ -1269,7 +1267,6 @@ impl CollabPanel {
let has_channel_buffer_changed = channel_store.has_channel_buffer_changed(channel_id);
ListItem::new("channel-notes")
- .height(px(24.))
.toggle_state(is_selected)
.on_click(cx.listener(move |this, _, window, cx| {
this.open_channel_notes(channel_id, window, cx);
@@ -3210,12 +3207,9 @@ impl CollabPanel {
(IconName::Star, Color::Default, "Add to Favorites")
};
- let height = px(24.);
-
h_flex()
.id(ix)
.group("")
- .h(height)
.w_full()
.overflow_hidden()
.when(!channel.is_root_channel(), |el| {
@@ -3245,7 +3239,6 @@ impl CollabPanel {
)
.child(
ListItem::new(ix)
- .height(height)
// Add one level of depth for the disclosure arrow.
.indent_level(depth + 1)
.indent_step_size(px(20.))
From 614f67ed2aa7378e5f11359ea01ba873b6a2a103 Mon Sep 17 00:00:00 2001
From: "Angel P."
Date: Tue, 7 Apr 2026 05:00:22 -0400
Subject: [PATCH 5/9] markdown_preview: Fix HTML alignment styles not being
applied (#53196)
## What This PR Does
This PR adds support for HTML alignment styles to be applied to
Paragraph and Heading elements and their children. Here is what this
looks like before vs after this PR (both images use the same markdown
below):
```markdown
```
**BEFORE:**
**AFTER:**
## Notes
I used `style="text-align: center|left|right;"` instead of
`align="center|right|left"` since `align` has been [deprecated in
HTML5](https://www.w3.org/TR/2011/WD-html5-author-20110809/obsolete.html)
for block-level elements. The issue this PR solves mentioned that github
supports the `align="center|right|left"` attribute, so I'm unsure if the
Zed team would want to have parity there. Feel free to let me know if
that would be something that should be added, however for now I've
decided to follow the HTML5 standard.
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Closes https://github.com/zed-industries/zed/issues/51062
Release Notes:
- Fixed HTML alignment styles not being applied in markdown previews
---------
Co-authored-by: Smit Barmase
---
crates/markdown/src/html/html_parser.rs | 117 ++++++++++++++++++---
crates/markdown/src/html/html_rendering.rs | 18 +++-
crates/markdown/src/markdown.rs | 69 +++++++++---
3 files changed, 172 insertions(+), 32 deletions(-)
diff --git a/crates/markdown/src/html/html_parser.rs b/crates/markdown/src/html/html_parser.rs
index 20338ec2abef2314b7cd6ca91e45ee05be909745..8aa5da0cea7ea160721875fa889a720fe4c8bed1 100644
--- a/crates/markdown/src/html/html_parser.rs
+++ b/crates/markdown/src/html/html_parser.rs
@@ -1,6 +1,6 @@
use std::{cell::RefCell, collections::HashMap, mem, ops::Range};
-use gpui::{DefiniteLength, FontWeight, SharedString, px, relative};
+use gpui::{DefiniteLength, FontWeight, SharedString, TextAlign, px, relative};
use html5ever::{
Attribute, LocalName, ParseOpts, local_name, parse_document, tendril::TendrilSink,
};
@@ -24,10 +24,17 @@ pub(crate) enum ParsedHtmlElement {
List(ParsedHtmlList),
Table(ParsedHtmlTable),
BlockQuote(ParsedHtmlBlockQuote),
- Paragraph(HtmlParagraph),
+ Paragraph(ParsedHtmlParagraph),
Image(HtmlImage),
}
+#[derive(Debug, Clone)]
+#[cfg_attr(test, derive(PartialEq))]
+pub(crate) struct ParsedHtmlParagraph {
+ pub text_align: Option,
+ pub contents: HtmlParagraph,
+}
+
impl ParsedHtmlElement {
pub fn source_range(&self) -> Option> {
Some(match self {
@@ -35,7 +42,7 @@ impl ParsedHtmlElement {
Self::List(list) => list.source_range.clone(),
Self::Table(table) => table.source_range.clone(),
Self::BlockQuote(block_quote) => block_quote.source_range.clone(),
- Self::Paragraph(text) => match text.first()? {
+ Self::Paragraph(paragraph) => match paragraph.contents.first()? {
HtmlParagraphChunk::Text(text) => text.source_range.clone(),
HtmlParagraphChunk::Image(image) => image.source_range.clone(),
},
@@ -83,6 +90,7 @@ pub(crate) struct ParsedHtmlHeading {
pub source_range: Range,
pub level: HeadingLevel,
pub contents: HtmlParagraph,
+ pub text_align: Option,
}
#[derive(Debug, Clone)]
@@ -236,20 +244,21 @@ fn parse_html_node(
consume_children(source_range, node, elements, context);
}
NodeData::Text { contents } => {
- elements.push(ParsedHtmlElement::Paragraph(vec![
- HtmlParagraphChunk::Text(ParsedHtmlText {
+ elements.push(ParsedHtmlElement::Paragraph(ParsedHtmlParagraph {
+ text_align: None,
+ contents: vec![HtmlParagraphChunk::Text(ParsedHtmlText {
source_range,
highlights: Vec::default(),
links: Vec::default(),
contents: contents.borrow().to_string().into(),
- }),
- ]));
+ })],
+ }));
}
NodeData::Comment { .. } => {}
NodeData::Element { name, attrs, .. } => {
- let mut styles = if let Some(styles) =
- html_style_from_html_styles(extract_styles_from_attributes(attrs))
- {
+ let styles_map = extract_styles_from_attributes(attrs);
+ let text_align = text_align_from_attributes(attrs, &styles_map);
+ let mut styles = if let Some(styles) = html_style_from_html_styles(styles_map) {
vec![styles]
} else {
Vec::default()
@@ -270,7 +279,10 @@ fn parse_html_node(
);
if !paragraph.is_empty() {
- elements.push(ParsedHtmlElement::Paragraph(paragraph));
+ elements.push(ParsedHtmlElement::Paragraph(ParsedHtmlParagraph {
+ text_align,
+ contents: paragraph,
+ }));
}
} else if matches!(
name.local,
@@ -303,6 +315,7 @@ fn parse_html_node(
_ => unreachable!(),
},
contents: paragraph,
+ text_align,
}));
}
} else if name.local == local_name!("ul") || name.local == local_name!("ol") {
@@ -589,6 +602,30 @@ fn html_style_from_html_styles(styles: HashMap) -> Option Option {
+ match value.trim().to_ascii_lowercase().as_str() {
+ "left" => Some(TextAlign::Left),
+ "center" => Some(TextAlign::Center),
+ "right" => Some(TextAlign::Right),
+ _ => None,
+ }
+}
+
+fn text_align_from_styles(styles: &HashMap) -> Option {
+ styles
+ .get("text-align")
+ .and_then(|value| parse_text_align(value))
+}
+
+fn text_align_from_attributes(
+ attrs: &RefCell>,
+ styles: &HashMap,
+) -> Option {
+ text_align_from_styles(styles).or_else(|| {
+ attr_value(attrs, local_name!("align")).and_then(|value| parse_text_align(&value))
+ })
+}
+
fn extract_styles_from_attributes(attrs: &RefCell>) -> HashMap {
let mut styles = HashMap::new();
@@ -770,6 +807,7 @@ fn extract_html_table(node: &Node, source_range: Range) -> Optionx
", 0..40).unwrap();
+ let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
+ panic!("expected paragraph");
+ };
+ assert_eq!(paragraph.text_align, Some(TextAlign::Center));
+ }
+
+ #[test]
+ fn parses_heading_text_align_from_style() {
+ let parsed = parse_html_block("Title
", 0..45).unwrap();
+ let ParsedHtmlElement::Heading(heading) = &parsed.children[0] else {
+ panic!("expected heading");
+ };
+ assert_eq!(heading.text_align, Some(TextAlign::Right));
+ }
+
+ #[test]
+ fn parses_paragraph_text_align_from_align_attribute() {
+ let parsed = parse_html_block("x
", 0..24).unwrap();
+ let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
+ panic!("expected paragraph");
+ };
+ assert_eq!(paragraph.text_align, Some(TextAlign::Center));
+ }
+
+ #[test]
+ fn parses_heading_text_align_from_align_attribute() {
+ let parsed = parse_html_block("Title
", 0..30).unwrap();
+ let ParsedHtmlElement::Heading(heading) = &parsed.children[0] else {
+ panic!("expected heading");
+ };
+ assert_eq!(heading.text_align, Some(TextAlign::Right));
+ }
+
+ #[test]
+ fn prefers_style_text_align_over_align_attribute() {
+ let parsed = parse_html_block(
+ "x
",
+ 0..50,
+ )
+ .unwrap();
+ let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
+ panic!("expected paragraph");
+ };
+ assert_eq!(paragraph.text_align, Some(TextAlign::Center));
+ }
}
diff --git a/crates/markdown/src/html/html_rendering.rs b/crates/markdown/src/html/html_rendering.rs
index 103e2a6accb7dce9bc429419aafd27cbdf5080ce..6ae25eff0b4ba2ec8dedde8118ebd8d60e8fce7d 100644
--- a/crates/markdown/src/html/html_rendering.rs
+++ b/crates/markdown/src/html/html_rendering.rs
@@ -79,9 +79,20 @@ impl MarkdownElement {
match element {
ParsedHtmlElement::Paragraph(paragraph) => {
- self.push_markdown_paragraph(builder, &source_range, markdown_end);
- self.render_html_paragraph(paragraph, source_allocator, builder, cx, markdown_end);
- builder.pop_div();
+ self.push_markdown_paragraph(
+ builder,
+ &source_range,
+ markdown_end,
+ paragraph.text_align,
+ );
+ self.render_html_paragraph(
+ ¶graph.contents,
+ source_allocator,
+ builder,
+ cx,
+ markdown_end,
+ );
+ self.pop_markdown_paragraph(builder);
}
ParsedHtmlElement::Heading(heading) => {
self.push_markdown_heading(
@@ -89,6 +100,7 @@ impl MarkdownElement {
heading.level,
&heading.source_range,
markdown_end,
+ heading.text_align,
);
self.render_html_paragraph(
&heading.contents,
diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs
index 247c082d223005a7e0bd6d57696751ce76cc4d86..e6ad1b1f2ac9154eaabc6d18dbcb9c8695ae019d 100644
--- a/crates/markdown/src/markdown.rs
+++ b/crates/markdown/src/markdown.rs
@@ -36,8 +36,8 @@ use gpui::{
FocusHandle, Focusable, FontStyle, FontWeight, GlobalElementId, Hitbox, Hsla, Image,
ImageFormat, ImageSource, KeyContext, Length, MouseButton, MouseDownEvent, MouseEvent,
MouseMoveEvent, MouseUpEvent, Point, ScrollHandle, Stateful, StrikethroughStyle,
- StyleRefinement, StyledText, Task, TextLayout, TextRun, TextStyle, TextStyleRefinement,
- actions, img, point, quad,
+ StyleRefinement, StyledText, Task, TextAlign, TextLayout, TextRun, TextStyle,
+ TextStyleRefinement, actions, img, point, quad,
};
use language::{CharClassifier, Language, LanguageRegistry, Rope};
use parser::CodeBlockMetadata;
@@ -1025,8 +1025,17 @@ impl MarkdownElement {
width: Option,
height: Option,
) {
+ let align = builder.text_style().text_align;
builder.modify_current_div(|el| {
- el.items_center().flex().flex_row().child(
+ let mut image_container = el.flex().flex_row().items_center();
+
+ image_container = match align {
+ TextAlign::Left => image_container.justify_start(),
+ TextAlign::Center => image_container.justify_center(),
+ TextAlign::Right => image_container.justify_end(),
+ };
+
+ image_container.child(
img(source)
.max_w_full()
.when_some(height, |this, height| this.h(height))
@@ -1041,14 +1050,29 @@ impl MarkdownElement {
builder: &mut MarkdownElementBuilder,
range: &Range,
markdown_end: usize,
+ text_align_override: Option,
) {
- builder.push_div(
- div().when(!self.style.height_is_multiple_of_line_height, |el| {
- el.mb_2().line_height(rems(1.3))
- }),
- range,
- markdown_end,
- );
+ let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
+ let mut paragraph = div().when(!self.style.height_is_multiple_of_line_height, |el| {
+ el.mb_2().line_height(rems(1.3))
+ });
+
+ paragraph = match align {
+ TextAlign::Center => paragraph.text_center(),
+ TextAlign::Left => paragraph.text_left(),
+ TextAlign::Right => paragraph.text_right(),
+ };
+
+ builder.push_text_style(TextStyleRefinement {
+ text_align: Some(align),
+ ..Default::default()
+ });
+ builder.push_div(paragraph, range, markdown_end);
+ }
+
+ fn pop_markdown_paragraph(&self, builder: &mut MarkdownElementBuilder) {
+ builder.pop_div();
+ builder.pop_text_style();
}
fn push_markdown_heading(
@@ -1057,15 +1081,26 @@ impl MarkdownElement {
level: pulldown_cmark::HeadingLevel,
range: &Range,
markdown_end: usize,
+ text_align_override: Option,
) {
+ let align = text_align_override.unwrap_or(self.style.base_text_style.text_align);
let mut heading = div().mb_2();
heading = apply_heading_style(heading, level, self.style.heading_level_styles.as_ref());
+ heading = match align {
+ TextAlign::Center => heading.text_center(),
+ TextAlign::Left => heading.text_left(),
+ TextAlign::Right => heading.text_right(),
+ };
+
let mut heading_style = self.style.heading.clone();
let heading_text_style = heading_style.text_style().clone();
heading.style().refine(&heading_style);
- builder.push_text_style(heading_text_style);
+ builder.push_text_style(TextStyleRefinement {
+ text_align: Some(align),
+ ..heading_text_style
+ });
builder.push_div(heading, range, markdown_end);
}
@@ -1571,10 +1606,16 @@ impl Element for MarkdownElement {
}
}
MarkdownTag::Paragraph => {
- self.push_markdown_paragraph(&mut builder, range, markdown_end);
+ self.push_markdown_paragraph(&mut builder, range, markdown_end, None);
}
MarkdownTag::Heading { level, .. } => {
- self.push_markdown_heading(&mut builder, *level, range, markdown_end);
+ self.push_markdown_heading(
+ &mut builder,
+ *level,
+ range,
+ markdown_end,
+ None,
+ );
}
MarkdownTag::BlockQuote => {
self.push_markdown_block_quote(&mut builder, range, markdown_end);
@@ -1826,7 +1867,7 @@ impl Element for MarkdownElement {
current_img_block_range.take();
}
MarkdownTagEnd::Paragraph => {
- builder.pop_div();
+ self.pop_markdown_paragraph(&mut builder);
}
MarkdownTagEnd::Heading(_) => {
self.pop_markdown_heading(&mut builder);
From ccb9e60a6258d57104cc56db87fe03024dd231ef Mon Sep 17 00:00:00 2001
From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com>
Date: Tue, 7 Apr 2026 05:21:47 -0400
Subject: [PATCH 6/9] agent_panel: Add new thread git worktree/branch pickers
(#52979)
This PR allows users to create a new thread based off a git worktree
that already exists or has a custom name. User's can also choose what
branch they want the newly generated worktree to be based off of.
The UI still needs some polish, but I'm merging this early to get the
team using this before our preview launch. I'll be active today and
tomorrow before launch to fix any nits we have with the UI.
Functionality of this feature works! And I have a basic test to prevent
regressions
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Closes #ISSUE
Release Notes:
- N/A or Added/Fixed/Improved ...
---------
Co-authored-by: cameron
---
crates/agent_ui/src/agent_panel.rs | 673 +++++++++++------
crates/agent_ui/src/agent_ui.rs | 37 +-
.../src/conversation_view/thread_view.rs | 5 +-
crates/agent_ui/src/thread_branch_picker.rs | 695 ++++++++++++++++++
crates/agent_ui/src/thread_worktree_picker.rs | 485 ++++++++++++
crates/collab/tests/integration/git_tests.rs | 12 +-
.../remote_editing_collaboration_tests.rs | 6 +-
crates/fs/src/fake_git_repo.rs | 113 ++-
crates/fs/tests/integration/fake_git_repo.rs | 12 +-
crates/git/src/repository.rs | 120 ++-
crates/git_ui/src/worktree_picker.rs | 9 +-
crates/project/src/git_store.rs | 102 ++-
crates/project/tests/integration/git_store.rs | 12 +-
crates/proto/proto/git.proto | 1 +
crates/zed/src/visual_test_runner.rs | 18 +-
15 files changed, 1941 insertions(+), 359 deletions(-)
create mode 100644 crates/agent_ui/src/thread_branch_picker.rs
create mode 100644 crates/agent_ui/src/thread_worktree_picker.rs
diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs
index 41900e71e5d3ad7e5327ee7e04f73cb05eed5a5b..8f456e0e955b823a5bbaf2815df3b409441bb0af 100644
--- a/crates/agent_ui/src/agent_panel.rs
+++ b/crates/agent_ui/src/agent_panel.rs
@@ -28,21 +28,20 @@ use zed_actions::agent::{
use crate::thread_metadata_store::ThreadMetadataStore;
use crate::{
AddContextServer, AgentDiffPane, ConversationView, CopyThreadToClipboard, CycleStartThreadIn,
- Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, OpenActiveThreadAsMarkdown,
- OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell, StartThreadIn,
- ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
+ Follow, InlineAssistant, LoadThreadFromClipboard, NewThread, NewWorktreeBranchTarget,
+ OpenActiveThreadAsMarkdown, OpenAgentDiff, OpenHistory, ResetTrialEndUpsell, ResetTrialUpsell,
+ StartThreadIn, ToggleNavigationMenu, ToggleNewThreadMenu, ToggleOptionsMenu,
agent_configuration::{AgentConfiguration, AssistantConfigurationEvent},
conversation_view::{AcpThreadViewEvent, ThreadView},
+ thread_branch_picker::ThreadBranchPicker,
+ thread_worktree_picker::ThreadWorktreePicker,
ui::EndTrialUpsell,
};
use crate::{
Agent, AgentInitialContent, ExternalSourcePrompt, NewExternalAgentThread,
NewNativeAgentThreadFromSummary,
};
-use crate::{
- DEFAULT_THREAD_TITLE,
- ui::{AcpOnboardingModal, HoldForDefault},
-};
+use crate::{DEFAULT_THREAD_TITLE, ui::AcpOnboardingModal};
use crate::{ExpandMessageEditor, ThreadHistoryView};
use crate::{ManageProfiles, ThreadHistoryViewEvent};
use crate::{ThreadHistory, agent_connection_store::AgentConnectionStore};
@@ -73,8 +72,8 @@ use terminal::terminal_settings::TerminalSettings;
use terminal_view::{TerminalView, terminal_panel::TerminalPanel};
use theme_settings::ThemeSettings;
use ui::{
- Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, DocumentationSide,
- PopoverMenu, PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
+ Button, Callout, CommonAnimationExt, ContextMenu, ContextMenuEntry, PopoverMenu,
+ PopoverMenuHandle, Tab, Tooltip, prelude::*, utils::WithRemSize,
};
use util::{ResultExt as _, debug_panic};
use workspace::{
@@ -620,7 +619,31 @@ impl StartThreadIn {
fn label(&self) -> SharedString {
match self {
Self::LocalProject => "Current Worktree".into(),
- Self::NewWorktree => "New Git Worktree".into(),
+ Self::NewWorktree {
+ worktree_name: Some(worktree_name),
+ ..
+ } => format!("New: {worktree_name}").into(),
+ Self::NewWorktree { .. } => "New Git Worktree".into(),
+ Self::LinkedWorktree { display_name, .. } => format!("From: {}", &display_name).into(),
+ }
+ }
+
+ fn worktree_branch_label(&self, default_branch_label: SharedString) -> Option {
+ match self {
+ Self::NewWorktree { branch_target, .. } => match branch_target {
+ NewWorktreeBranchTarget::CurrentBranch => Some(default_branch_label),
+ NewWorktreeBranchTarget::ExistingBranch { name } => {
+ Some(format!("From: {name}").into())
+ }
+ NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
+ if let Some(from_ref) = from_ref {
+ Some(format!("From: {from_ref}").into())
+ } else {
+ Some(format!("From: {name}").into())
+ }
+ }
+ },
+ _ => None,
}
}
}
@@ -632,6 +655,17 @@ pub enum WorktreeCreationStatus {
Error(SharedString),
}
+#[derive(Clone, Debug)]
+enum WorktreeCreationArgs {
+ New {
+ worktree_name: Option,
+ branch_target: NewWorktreeBranchTarget,
+ },
+ Linked {
+ worktree_path: PathBuf,
+ },
+}
+
impl ActiveView {
pub fn which_font_size_used(&self) -> WhichFontSize {
match self {
@@ -662,7 +696,8 @@ pub struct AgentPanel {
previous_view: Option,
background_threads: HashMap>,
new_thread_menu_handle: PopoverMenuHandle,
- start_thread_in_menu_handle: PopoverMenuHandle,
+ start_thread_in_menu_handle: PopoverMenuHandle,
+ thread_branch_menu_handle: PopoverMenuHandle,
agent_panel_menu_handle: PopoverMenuHandle,
agent_navigation_menu_handle: PopoverMenuHandle,
agent_navigation_menu: Option>,
@@ -689,7 +724,7 @@ impl AgentPanel {
};
let selected_agent = self.selected_agent.clone();
- let start_thread_in = Some(self.start_thread_in);
+ let start_thread_in = Some(self.start_thread_in.clone());
let last_active_thread = self.active_agent_thread(cx).map(|thread| {
let thread = thread.read(cx);
@@ -794,18 +829,21 @@ impl AgentPanel {
} else if let Some(agent) = global_fallback {
panel.selected_agent = agent;
}
- if let Some(start_thread_in) = serialized_panel.start_thread_in {
+ if let Some(ref start_thread_in) = serialized_panel.start_thread_in {
let is_worktree_flag_enabled =
cx.has_flag::();
let is_valid = match &start_thread_in {
StartThreadIn::LocalProject => true,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
let project = panel.project.read(cx);
is_worktree_flag_enabled && !project.is_via_collab()
}
+ StartThreadIn::LinkedWorktree { path, .. } => {
+ is_worktree_flag_enabled && path.exists()
+ }
};
if is_valid {
- panel.start_thread_in = start_thread_in;
+ panel.start_thread_in = start_thread_in.clone();
} else {
log::info!(
"deserialized start_thread_in {:?} is no longer valid, falling back to LocalProject",
@@ -979,6 +1017,7 @@ impl AgentPanel {
background_threads: HashMap::default(),
new_thread_menu_handle: PopoverMenuHandle::default(),
start_thread_in_menu_handle: PopoverMenuHandle::default(),
+ thread_branch_menu_handle: PopoverMenuHandle::default(),
agent_panel_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu_handle: PopoverMenuHandle::default(),
agent_navigation_menu: None,
@@ -1948,24 +1987,43 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
- if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::() {
- return;
- }
-
- let new_target = match *action {
+ let new_target = match action {
StartThreadIn::LocalProject => StartThreadIn::LocalProject,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
+ if !cx.has_flag::() {
+ return;
+ }
+ if !self.project_has_git_repository(cx) {
+ log::error!(
+ "set_start_thread_in: cannot use worktree mode without a git repository"
+ );
+ return;
+ }
+ if self.project.read(cx).is_via_collab() {
+ log::error!(
+ "set_start_thread_in: cannot use worktree mode in a collab project"
+ );
+ return;
+ }
+ action.clone()
+ }
+ StartThreadIn::LinkedWorktree { .. } => {
+ if !cx.has_flag::() {
+ return;
+ }
if !self.project_has_git_repository(cx) {
log::error!(
- "set_start_thread_in: cannot use NewWorktree without a git repository"
+ "set_start_thread_in: cannot use LinkedWorktree without a git repository"
);
return;
}
if self.project.read(cx).is_via_collab() {
- log::error!("set_start_thread_in: cannot use NewWorktree in a collab project");
+ log::error!(
+ "set_start_thread_in: cannot use LinkedWorktree in a collab project"
+ );
return;
}
- StartThreadIn::NewWorktree
+ action.clone()
}
};
self.start_thread_in = new_target;
@@ -1977,9 +2035,14 @@ impl AgentPanel {
}
fn cycle_start_thread_in(&mut self, window: &mut Window, cx: &mut Context) {
- let next = match self.start_thread_in {
- StartThreadIn::LocalProject => StartThreadIn::NewWorktree,
- StartThreadIn::NewWorktree => StartThreadIn::LocalProject,
+ let next = match &self.start_thread_in {
+ StartThreadIn::LocalProject => StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ StartThreadIn::NewWorktree { .. } | StartThreadIn::LinkedWorktree { .. } => {
+ StartThreadIn::LocalProject
+ }
};
self.set_start_thread_in(&next, window, cx);
}
@@ -1991,7 +2054,10 @@ impl AgentPanel {
NewThreadLocation::LocalProject => StartThreadIn::LocalProject,
NewThreadLocation::NewWorktree => {
if self.project_has_git_repository(cx) {
- StartThreadIn::NewWorktree
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ }
} else {
StartThreadIn::LocalProject
}
@@ -2219,15 +2285,39 @@ impl AgentPanel {
window: &mut Window,
cx: &mut Context,
) {
- if self.start_thread_in == StartThreadIn::NewWorktree {
- self.handle_worktree_creation_requested(content, window, cx);
- } else {
- cx.defer_in(window, move |_this, window, cx| {
- thread_view.update(cx, |thread_view, cx| {
- let editor = thread_view.message_editor.clone();
- thread_view.send_impl(editor, window, cx);
+ match &self.start_thread_in {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target,
+ } => {
+ self.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::New {
+ worktree_name: worktree_name.clone(),
+ branch_target: branch_target.clone(),
+ },
+ window,
+ cx,
+ );
+ }
+ StartThreadIn::LinkedWorktree { path, .. } => {
+ self.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::Linked {
+ worktree_path: path.clone(),
+ },
+ window,
+ cx,
+ );
+ }
+ StartThreadIn::LocalProject => {
+ cx.defer_in(window, move |_this, window, cx| {
+ thread_view.update(cx, |thread_view, cx| {
+ let editor = thread_view.message_editor.clone();
+ thread_view.send_impl(editor, window, cx);
+ });
});
- });
+ }
}
}
@@ -2289,6 +2379,33 @@ impl AgentPanel {
(git_repos, non_git_paths)
}
+ fn resolve_worktree_branch_target(
+ branch_target: &NewWorktreeBranchTarget,
+ existing_branches: &HashSet,
+ occupied_branches: &HashSet,
+ ) -> Result<(String, bool, Option)> {
+ let generate_branch_name = || -> Result {
+ let refs: Vec<&str> = existing_branches.iter().map(|s| s.as_str()).collect();
+ let mut rng = rand::rng();
+ crate::branch_names::generate_branch_name(&refs, &mut rng)
+ .ok_or_else(|| anyhow!("Failed to generate a unique branch name"))
+ };
+
+ match branch_target {
+ NewWorktreeBranchTarget::CreateBranch { name, from_ref } => {
+ Ok((name.clone(), false, from_ref.clone()))
+ }
+ NewWorktreeBranchTarget::ExistingBranch { name } => {
+ if occupied_branches.contains(name) {
+ Ok((generate_branch_name()?, false, Some(name.clone())))
+ } else {
+ Ok((name.clone(), true, None))
+ }
+ }
+ NewWorktreeBranchTarget::CurrentBranch => Ok((generate_branch_name()?, false, None)),
+ }
+ }
+
/// Kicks off an async git-worktree creation for each repository. Returns:
///
/// - `creation_infos`: a vec of `(repo, new_path, receiver)` tuples—the
@@ -2297,7 +2414,10 @@ impl AgentPanel {
/// later to remap open editor tabs into the new workspace.
fn start_worktree_creations(
git_repos: &[Entity],
+ worktree_name: Option,
branch_name: &str,
+ use_existing_branch: bool,
+ start_point: Option,
worktree_directory_setting: &str,
cx: &mut Context,
) -> Result<(
@@ -2311,12 +2431,27 @@ impl AgentPanel {
let mut creation_infos = Vec::new();
let mut path_remapping = Vec::new();
+ let worktree_name = worktree_name.unwrap_or_else(|| branch_name.to_string());
+
for repo in git_repos {
let (work_dir, new_path, receiver) = repo.update(cx, |repo, _cx| {
let new_path =
- repo.path_for_new_linked_worktree(branch_name, worktree_directory_setting)?;
- let receiver =
- repo.create_worktree(branch_name.to_string(), new_path.clone(), None);
+ repo.path_for_new_linked_worktree(&worktree_name, worktree_directory_setting)?;
+ let target = if use_existing_branch {
+ debug_assert!(
+ git_repos.len() == 1,
+ "use_existing_branch should only be true for a single repo"
+ );
+ git::repository::CreateWorktreeTarget::ExistingBranch {
+ branch_name: branch_name.to_string(),
+ }
+ } else {
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: branch_name.to_string(),
+ base_sha: start_point.clone(),
+ }
+ };
+ let receiver = repo.create_worktree(target, new_path.clone());
let work_dir = repo.work_directory_abs_path.clone();
anyhow::Ok((work_dir, new_path, receiver))
})?;
@@ -2419,9 +2554,10 @@ impl AgentPanel {
cx.notify();
}
- fn handle_worktree_creation_requested(
+ fn handle_worktree_requested(
&mut self,
content: Vec,
+ args: WorktreeCreationArgs,
window: &mut Window,
cx: &mut Context,
) {
@@ -2437,7 +2573,7 @@ impl AgentPanel {
let (git_repos, non_git_paths) = self.classify_worktrees(cx);
- if git_repos.is_empty() {
+ if matches!(args, WorktreeCreationArgs::New { .. }) && git_repos.is_empty() {
self.set_worktree_creation_error(
"No git repositories found in the project".into(),
window,
@@ -2446,17 +2582,31 @@ impl AgentPanel {
return;
}
- // Kick off branch listing as early as possible so it can run
- // concurrently with the remaining synchronous setup work.
- let branch_receivers: Vec<_> = git_repos
- .iter()
- .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
- .collect();
-
- let worktree_directory_setting = ProjectSettings::get_global(cx)
- .git
- .worktree_directory
- .clone();
+ let (branch_receivers, worktree_receivers, worktree_directory_setting) =
+ if matches!(args, WorktreeCreationArgs::New { .. }) {
+ (
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.branches()))
+ .collect::>(),
+ ),
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
+ .collect::>(),
+ ),
+ Some(
+ ProjectSettings::get_global(cx)
+ .git
+ .worktree_directory
+ .clone(),
+ ),
+ )
+ } else {
+ (None, None, None)
+ };
let active_file_path = self.workspace.upgrade().and_then(|workspace| {
let workspace = workspace.read(cx);
@@ -2476,77 +2626,124 @@ impl AgentPanel {
let selected_agent = self.selected_agent();
let task = cx.spawn_in(window, async move |this, cx| {
- // Await the branch listings we kicked off earlier.
- let mut existing_branches = Vec::new();
- for result in futures::future::join_all(branch_receivers).await {
- match result {
- Ok(Ok(branches)) => {
- for branch in branches {
- existing_branches.push(branch.name().to_string());
+ let (all_paths, path_remapping, has_non_git) = match args {
+ WorktreeCreationArgs::New {
+ worktree_name,
+ branch_target,
+ } => {
+ let branch_receivers = branch_receivers
+ .expect("branch receivers must be prepared for new worktree creation");
+ let worktree_receivers = worktree_receivers
+ .expect("worktree receivers must be prepared for new worktree creation");
+ let worktree_directory_setting = worktree_directory_setting
+ .expect("worktree directory must be prepared for new worktree creation");
+
+ let mut existing_branches = HashSet::default();
+ for result in futures::future::join_all(branch_receivers).await {
+ match result {
+ Ok(Ok(branches)) => {
+ for branch in branches {
+ existing_branches.insert(branch.name().to_string());
+ }
+ }
+ Ok(Err(err)) => {
+ Err::<(), _>(err).log_err();
+ }
+ Err(_) => {}
}
}
- Ok(Err(err)) => {
- Err::<(), _>(err).log_err();
+
+ let mut occupied_branches = HashSet::default();
+ for result in futures::future::join_all(worktree_receivers).await {
+ match result {
+ Ok(Ok(worktrees)) => {
+ for worktree in worktrees {
+ if let Some(branch_name) = worktree.branch_name() {
+ occupied_branches.insert(branch_name.to_string());
+ }
+ }
+ }
+ Ok(Err(err)) => {
+ Err::<(), _>(err).log_err();
+ }
+ Err(_) => {}
+ }
}
- Err(_) => {}
- }
- }
- let existing_branch_refs: Vec<&str> =
- existing_branches.iter().map(|s| s.as_str()).collect();
- let mut rng = rand::rng();
- let branch_name =
- match crate::branch_names::generate_branch_name(&existing_branch_refs, &mut rng) {
- Some(name) => name,
- None => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- "Failed to generate a unique branch name".into(),
- window,
+ let (branch_name, use_existing_branch, start_point) =
+ match Self::resolve_worktree_branch_target(
+ &branch_target,
+ &existing_branches,
+ &occupied_branches,
+ ) {
+ Ok(target) => target,
+ Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ err.to_string().into(),
+ window,
+ cx,
+ );
+ })?;
+ return anyhow::Ok(());
+ }
+ };
+
+ let (creation_infos, path_remapping) =
+ match this.update_in(cx, |_this, _window, cx| {
+ Self::start_worktree_creations(
+ &git_repos,
+ worktree_name,
+ &branch_name,
+ use_existing_branch,
+ start_point,
+ &worktree_directory_setting,
cx,
- );
- })?;
- return anyhow::Ok(());
- }
- };
+ )
+ }) {
+ Ok(Ok(result)) => result,
+ Ok(Err(err)) | Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("Failed to validate worktree directory: {err}")
+ .into(),
+ window,
+ cx,
+ );
+ })
+ .log_err();
+ return anyhow::Ok(());
+ }
+ };
- let (creation_infos, path_remapping) = match this.update_in(cx, |_this, _window, cx| {
- Self::start_worktree_creations(
- &git_repos,
- &branch_name,
- &worktree_directory_setting,
- cx,
- )
- }) {
- Ok(Ok(result)) => result,
- Ok(Err(err)) | Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(
- format!("Failed to validate worktree directory: {err}").into(),
- window,
- cx,
- );
- })
- .log_err();
- return anyhow::Ok(());
- }
- };
+ let created_paths =
+ match Self::await_and_rollback_on_failure(creation_infos, cx).await {
+ Ok(paths) => paths,
+ Err(err) => {
+ this.update_in(cx, |this, window, cx| {
+ this.set_worktree_creation_error(
+ format!("{err}").into(),
+ window,
+ cx,
+ );
+ })?;
+ return anyhow::Ok(());
+ }
+ };
- let created_paths = match Self::await_and_rollback_on_failure(creation_infos, cx).await
- {
- Ok(paths) => paths,
- Err(err) => {
- this.update_in(cx, |this, window, cx| {
- this.set_worktree_creation_error(format!("{err}").into(), window, cx);
- })?;
- return anyhow::Ok(());
+ let mut all_paths = created_paths;
+ let has_non_git = !non_git_paths.is_empty();
+ all_paths.extend(non_git_paths.iter().cloned());
+ (all_paths, path_remapping, has_non_git)
+ }
+ WorktreeCreationArgs::Linked { worktree_path } => {
+ let mut all_paths = vec![worktree_path];
+ let has_non_git = !non_git_paths.is_empty();
+ all_paths.extend(non_git_paths.iter().cloned());
+ (all_paths, Vec::new(), has_non_git)
}
};
- let mut all_paths = created_paths;
- let has_non_git = !non_git_paths.is_empty();
- all_paths.extend(non_git_paths.iter().cloned());
-
let app_state = match workspace.upgrade() {
Some(workspace) => cx.update(|_, cx| workspace.read(cx).app_state().clone())?,
None => {
@@ -2562,7 +2759,7 @@ impl AgentPanel {
};
let this_for_error = this.clone();
- if let Err(err) = Self::setup_new_workspace(
+ if let Err(err) = Self::open_worktree_workspace_and_start_thread(
this,
all_paths,
app_state,
@@ -2595,7 +2792,7 @@ impl AgentPanel {
}));
}
- async fn setup_new_workspace(
+ async fn open_worktree_workspace_and_start_thread(
this: WeakEntity,
all_paths: Vec,
app_state: Arc,
@@ -3149,25 +3346,15 @@ impl AgentPanel {
}
fn render_start_thread_in_selector(&self, cx: &mut Context) -> impl IntoElement {
- use settings::{NewThreadLocation, Settings};
-
let focus_handle = self.focus_handle(cx);
- let has_git_repo = self.project_has_git_repository(cx);
- let is_via_collab = self.project.read(cx).is_via_collab();
- let fs = self.fs.clone();
let is_creating = matches!(
self.worktree_creation_status,
Some(WorktreeCreationStatus::Creating)
);
- let current_target = self.start_thread_in;
let trigger_label = self.start_thread_in.label();
- let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
- let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
- let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
-
let icon = if self.start_thread_in_menu_handle.is_deployed() {
IconName::ChevronUp
} else {
@@ -3178,13 +3365,9 @@ impl AgentPanel {
.end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
.disabled(is_creating);
- let dock_position = AgentSettings::get_global(cx).dock;
- let documentation_side = match dock_position {
- settings::DockPosition::Left => DocumentationSide::Right,
- settings::DockPosition::Bottom | settings::DockPosition::Right => {
- DocumentationSide::Left
- }
- };
+ let project = self.project.clone();
+ let current_target = self.start_thread_in.clone();
+ let fs = self.fs.clone();
PopoverMenu::new("thread-target-selector")
.trigger_with_tooltip(trigger_button, {
@@ -3198,89 +3381,66 @@ impl AgentPanel {
}
})
.menu(move |window, cx| {
- let is_local_selected = current_target == StartThreadIn::LocalProject;
- let is_new_worktree_selected = current_target == StartThreadIn::NewWorktree;
let fs = fs.clone();
+ Some(cx.new(|cx| {
+ ThreadWorktreePicker::new(project.clone(), ¤t_target, fs, window, cx)
+ }))
+ })
+ .with_handle(self.start_thread_in_menu_handle.clone())
+ .anchor(Corner::TopLeft)
+ .offset(gpui::Point {
+ x: px(1.0),
+ y: px(1.0),
+ })
+ }
- Some(ContextMenu::build(window, cx, move |menu, _window, _cx| {
- let new_worktree_disabled = !has_git_repo || is_via_collab;
+ fn render_new_worktree_branch_selector(&self, cx: &mut Context) -> impl IntoElement {
+ let is_creating = matches!(
+ self.worktree_creation_status,
+ Some(WorktreeCreationStatus::Creating)
+ );
+ let default_branch_label = if self.project.read(cx).repositories(cx).len() > 1 {
+ SharedString::from("From: current branches")
+ } else {
+ self.project
+ .read(cx)
+ .active_repository(cx)
+ .and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| SharedString::from(format!("From: {}", branch.name())))
+ })
+ .unwrap_or_else(|| SharedString::from("From: HEAD"))
+ };
+ let trigger_label = self
+ .start_thread_in
+ .worktree_branch_label(default_branch_label)
+ .unwrap_or_else(|| SharedString::from("From: HEAD"));
+ let icon = if self.thread_branch_menu_handle.is_deployed() {
+ IconName::ChevronUp
+ } else {
+ IconName::ChevronDown
+ };
+ let trigger_button = Button::new("thread-branch-trigger", trigger_label)
+ .start_icon(
+ Icon::new(IconName::GitBranch)
+ .size(IconSize::Small)
+ .color(Color::Muted),
+ )
+ .end_icon(Icon::new(icon).size(IconSize::XSmall).color(Color::Muted))
+ .disabled(is_creating);
+ let project = self.project.clone();
+ let current_target = self.start_thread_in.clone();
- menu.header("Start Thread In…")
- .item(
- ContextMenuEntry::new("Current Worktree")
- .toggleable(IconPosition::End, is_local_selected)
- .documentation_aside(documentation_side, move |_| {
- HoldForDefault::new(is_local_default)
- .more_content(false)
- .into_any_element()
- })
- .handler({
- let fs = fs.clone();
- move |window, cx| {
- if window.modifiers().secondary() {
- update_settings_file(fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(
- NewThreadLocation::LocalProject,
- );
- });
- }
- window.dispatch_action(
- Box::new(StartThreadIn::LocalProject),
- cx,
- );
- }
- }),
- )
- .item({
- let entry = ContextMenuEntry::new("New Git Worktree")
- .toggleable(IconPosition::End, is_new_worktree_selected)
- .disabled(new_worktree_disabled)
- .handler({
- let fs = fs.clone();
- move |window, cx| {
- if window.modifiers().secondary() {
- update_settings_file(fs.clone(), cx, |settings, _| {
- settings
- .agent
- .get_or_insert_default()
- .set_new_thread_location(
- NewThreadLocation::NewWorktree,
- );
- });
- }
- window.dispatch_action(
- Box::new(StartThreadIn::NewWorktree),
- cx,
- );
- }
- });
-
- if new_worktree_disabled {
- entry.documentation_aside(documentation_side, move |_| {
- let reason = if !has_git_repo {
- "No git repository found in this project."
- } else {
- "Not available for remote/collab projects yet."
- };
- Label::new(reason)
- .color(Color::Muted)
- .size(LabelSize::Small)
- .into_any_element()
- })
- } else {
- entry.documentation_aside(documentation_side, move |_| {
- HoldForDefault::new(is_new_worktree_default)
- .more_content(false)
- .into_any_element()
- })
- }
- })
+ PopoverMenu::new("thread-branch-selector")
+ .trigger_with_tooltip(trigger_button, Tooltip::text("Choose Worktree Branch…"))
+ .menu(move |window, cx| {
+ Some(cx.new(|cx| {
+ ThreadBranchPicker::new(project.clone(), ¤t_target, window, cx)
}))
})
- .with_handle(self.start_thread_in_menu_handle.clone())
+ .with_handle(self.thread_branch_menu_handle.clone())
.anchor(Corner::TopLeft)
.offset(gpui::Point {
x: px(1.0),
@@ -3621,6 +3781,14 @@ impl AgentPanel {
.when(
has_visible_worktrees && self.project_has_git_repository(cx),
|this| this.child(self.render_start_thread_in_selector(cx)),
+ )
+ .when(
+ has_v2_flag
+ && matches!(
+ self.start_thread_in,
+ StartThreadIn::NewWorktree { .. }
+ ),
+ |this| this.child(self.render_new_worktree_branch_selector(cx)),
),
)
.child(
@@ -5265,13 +5433,23 @@ mod tests {
// Change thread target to NewWorktree.
panel.update_in(cx, |panel, window, cx| {
- panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
+ panel.set_start_thread_in(
+ &StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
- StartThreadIn::NewWorktree,
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
"thread target should be NewWorktree after set_thread_target"
);
});
@@ -5289,7 +5467,10 @@ mod tests {
loaded_panel.read_with(cx, |panel, _cx| {
assert_eq!(
*panel.start_thread_in(),
- StartThreadIn::NewWorktree,
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
"thread target should survive serialization round-trip"
);
});
@@ -5420,6 +5601,53 @@ mod tests {
);
}
+ #[test]
+ fn test_resolve_worktree_branch_target() {
+ let existing_branches = HashSet::from_iter([
+ "main".to_string(),
+ "feature".to_string(),
+ "origin/main".to_string(),
+ ]);
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::CreateBranch {
+ name: "new-branch".to_string(),
+ from_ref: Some("main".to_string()),
+ },
+ &existing_branches,
+ &HashSet::from_iter(["main".to_string()]),
+ )
+ .unwrap();
+ assert_eq!(
+ resolved,
+ ("new-branch".to_string(), false, Some("main".to_string()))
+ );
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::ExistingBranch {
+ name: "feature".to_string(),
+ },
+ &existing_branches,
+ &HashSet::default(),
+ )
+ .unwrap();
+ assert_eq!(resolved, ("feature".to_string(), true, None));
+
+ let resolved = AgentPanel::resolve_worktree_branch_target(
+ &NewWorktreeBranchTarget::ExistingBranch {
+ name: "main".to_string(),
+ },
+ &existing_branches,
+ &HashSet::from_iter(["main".to_string()]),
+ )
+ .unwrap();
+ assert_eq!(resolved.1, false);
+ assert_eq!(resolved.2, Some("main".to_string()));
+ assert_ne!(resolved.0, "main");
+ assert!(existing_branches.contains("main"));
+ assert!(!existing_branches.contains(&resolved.0));
+ }
+
#[gpui::test]
async fn test_worktree_creation_preserves_selected_agent(cx: &mut TestAppContext) {
init_test(cx);
@@ -5513,7 +5741,14 @@ mod tests {
panel.selected_agent = Agent::Custom {
id: CODEX_ID.into(),
};
- panel.set_start_thread_in(&StartThreadIn::NewWorktree, window, cx);
+ panel.set_start_thread_in(
+ &StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
// Verify the panel has the Codex agent selected.
@@ -5532,7 +5767,15 @@ mod tests {
"Hello from test",
))];
panel.update_in(cx, |panel, window, cx| {
- panel.handle_worktree_creation_requested(content, window, cx);
+ panel.handle_worktree_requested(
+ content,
+ WorktreeCreationArgs::New {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ window,
+ cx,
+ );
});
// Let the async worktree creation + workspace setup complete.
diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs
index 5cff5bfc38d4512d659d919c6e7c4ff02fcc0caf..9daa7c6cd83c276aa99adc9e3aae3e6c82c5ba88 100644
--- a/crates/agent_ui/src/agent_ui.rs
+++ b/crates/agent_ui/src/agent_ui.rs
@@ -28,13 +28,16 @@ mod terminal_codegen;
mod terminal_inline_assistant;
#[cfg(any(test, feature = "test-support"))]
pub mod test_support;
+mod thread_branch_picker;
mod thread_history;
mod thread_history_view;
mod thread_import;
pub mod thread_metadata_store;
+mod thread_worktree_picker;
pub mod threads_archive_view;
mod ui;
+use std::path::PathBuf;
use std::rc::Rc;
use std::sync::Arc;
@@ -314,16 +317,42 @@ impl Agent {
}
}
+/// Describes which branch to use when creating a new git worktree.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
+#[serde(rename_all = "snake_case", tag = "kind")]
+pub enum NewWorktreeBranchTarget {
+ /// Create a new randomly named branch from the current HEAD.
+ /// Will match worktree name if the newly created worktree was also randomly named.
+ #[default]
+ CurrentBranch,
+ /// Check out an existing branch, or create a new branch from it if it's
+ /// already occupied by another worktree.
+ ExistingBranch { name: String },
+ /// Create a new branch with an explicit name, optionally from a specific ref.
+ CreateBranch {
+ name: String,
+ #[serde(default)]
+ from_ref: Option,
+ },
+}
+
/// Sets where new threads will run.
-#[derive(
- Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action,
-)]
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum StartThreadIn {
#[default]
LocalProject,
- NewWorktree,
+ NewWorktree {
+ /// When this is None, Zed will randomly generate a worktree name
+ /// otherwise, the provided name will be used.
+ #[serde(default)]
+ worktree_name: Option,
+ #[serde(default)]
+ branch_target: NewWorktreeBranchTarget,
+ },
+ /// A linked worktree that already exists on disk.
+ LinkedWorktree { path: PathBuf, display_name: String },
}
/// Content to initialize new external agent with.
diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs
index 685621eb3c93632f1e7410bbbad22b623d5e18c7..ff3dab1170064e058c0ebb44505c0906349517ee 100644
--- a/crates/agent_ui/src/conversation_view/thread_view.rs
+++ b/crates/agent_ui/src/conversation_view/thread_view.rs
@@ -869,7 +869,10 @@ impl ThreadView {
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::(cx))
.is_some_and(|panel| {
- panel.read(cx).start_thread_in() == &StartThreadIn::NewWorktree
+ !matches!(
+ panel.read(cx).start_thread_in(),
+ StartThreadIn::LocalProject
+ )
});
if intercept_first_send {
diff --git a/crates/agent_ui/src/thread_branch_picker.rs b/crates/agent_ui/src/thread_branch_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d69cbb4a60054ad83d767928c880f3a43caef4f1
--- /dev/null
+++ b/crates/agent_ui/src/thread_branch_picker.rs
@@ -0,0 +1,695 @@
+use std::collections::{HashMap, HashSet};
+
+use collections::HashSet as CollectionsHashSet;
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use fuzzy::StringMatchCandidate;
+use git::repository::Branch as GitBranch;
+use gpui::{
+ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
+ ParentElement, Render, SharedString, Styled, Task, Window, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::Project;
+use ui::{
+ HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
+ prelude::*,
+};
+use util::ResultExt as _;
+
+use crate::{NewWorktreeBranchTarget, StartThreadIn};
+
+pub(crate) struct ThreadBranchPicker {
+ picker: Entity>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadBranchPicker {
+ pub fn new(
+ project: Entity,
+ current_target: &StartThreadIn,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let project_worktree_paths: HashSet = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ let has_multiple_repositories = project.read(cx).repositories(cx).len() > 1;
+ let current_branch_name = project
+ .read(cx)
+ .active_repository(cx)
+ .and_then(|repo| {
+ repo.read(cx)
+ .branch
+ .as_ref()
+ .map(|branch| branch.name().to_string())
+ })
+ .unwrap_or_else(|| "HEAD".to_string());
+
+ let repository = if has_multiple_repositories {
+ None
+ } else {
+ project.read(cx).active_repository(cx)
+ };
+ let branches_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.branches()));
+ let default_branch_request = repository
+ .clone()
+ .map(|repo| repo.update(cx, |repo, _| repo.default_branch(false)));
+ let worktrees_request = repository.map(|repo| repo.update(cx, |repo, _| repo.worktrees()));
+
+ let (worktree_name, branch_target) = match current_target {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target,
+ } => (worktree_name.clone(), branch_target.clone()),
+ _ => (None, NewWorktreeBranchTarget::default()),
+ };
+
+ let delegate = ThreadBranchPickerDelegate {
+ matches: vec![ThreadBranchEntry::CurrentBranch],
+ all_branches: None,
+ occupied_branches: None,
+ selected_index: 0,
+ worktree_name,
+ branch_target,
+ project_worktree_paths,
+ current_branch_name,
+ default_branch_name: None,
+ has_multiple_repositories,
+ };
+
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
+ });
+
+ let focus_handle = picker.focus_handle(cx);
+
+ if let (Some(branches_request), Some(default_branch_request), Some(worktrees_request)) =
+ (branches_request, default_branch_request, worktrees_request)
+ {
+ let picker_handle = picker.downgrade();
+ cx.spawn_in(window, async move |_this, cx| {
+ let branches = branches_request.await??;
+ let default_branch = default_branch_request.await.ok().and_then(Result::ok).flatten();
+ let worktrees = worktrees_request.await??;
+
+ let remote_upstreams: CollectionsHashSet<_> = branches
+ .iter()
+ .filter_map(|branch| {
+ branch
+ .upstream
+ .as_ref()
+ .filter(|upstream| upstream.is_remote())
+ .map(|upstream| upstream.ref_name.clone())
+ })
+ .collect();
+
+ let mut occupied_branches = HashMap::new();
+ for worktree in worktrees {
+ let Some(branch_name) = worktree.branch_name().map(ToOwned::to_owned) else {
+ continue;
+ };
+
+ let reason = if picker_handle
+ .read_with(cx, |picker, _| {
+ picker
+ .delegate
+ .project_worktree_paths
+ .contains(&worktree.path)
+ })
+ .unwrap_or(false)
+ {
+ format!(
+ "This branch is already checked out in the current project worktree at {}.",
+ worktree.path.display()
+ )
+ } else {
+ format!(
+ "This branch is already checked out in a linked worktree at {}.",
+ worktree.path.display()
+ )
+ };
+
+ occupied_branches.insert(branch_name, reason);
+ }
+
+ let mut all_branches: Vec<_> = branches
+ .into_iter()
+ .filter(|branch| !remote_upstreams.contains(&branch.ref_name))
+ .collect();
+ all_branches.sort_by_key(|branch| {
+ (
+ branch.is_remote(),
+ !branch.is_head,
+ branch
+ .most_recent_commit
+ .as_ref()
+ .map(|commit| 0 - commit.commit_timestamp),
+ )
+ });
+
+ picker_handle.update_in(cx, |picker, window, cx| {
+ picker.delegate.all_branches = Some(all_branches);
+ picker.delegate.occupied_branches = Some(occupied_branches);
+ picker.delegate.default_branch_name = default_branch.map(|branch| branch.to_string());
+ picker.refresh(window, cx);
+ })?;
+
+ anyhow::Ok(())
+ })
+ .detach_and_log_err(cx);
+ }
+
+ let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ picker,
+ focus_handle,
+ _subscription: subscription,
+ }
+ }
+}
+
+impl Focusable for ThreadBranchPicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for ThreadBranchPicker {}
+
+impl Render for ThreadBranchPicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .w(rems(22.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+#[derive(Clone)]
+enum ThreadBranchEntry {
+ CurrentBranch,
+ DefaultBranch,
+ ExistingBranch {
+ branch: GitBranch,
+ positions: Vec,
+ occupied_reason: Option,
+ },
+ CreateNamed {
+ name: String,
+ },
+}
+
+pub(crate) struct ThreadBranchPickerDelegate {
+ matches: Vec,
+ all_branches: Option>,
+ occupied_branches: Option>,
+ selected_index: usize,
+ worktree_name: Option,
+ branch_target: NewWorktreeBranchTarget,
+ project_worktree_paths: HashSet,
+ current_branch_name: String,
+ default_branch_name: Option,
+ has_multiple_repositories: bool,
+}
+
+impl ThreadBranchPickerDelegate {
+ fn new_worktree_action(&self, branch_target: NewWorktreeBranchTarget) -> StartThreadIn {
+ StartThreadIn::NewWorktree {
+ worktree_name: self.worktree_name.clone(),
+ branch_target,
+ }
+ }
+
+ fn selected_entry_name(&self) -> Option<&str> {
+ match &self.branch_target {
+ NewWorktreeBranchTarget::CurrentBranch => None,
+ NewWorktreeBranchTarget::ExistingBranch { name } => Some(name),
+ NewWorktreeBranchTarget::CreateBranch {
+ from_ref: Some(from_ref),
+ ..
+ } => Some(from_ref),
+ NewWorktreeBranchTarget::CreateBranch { name, .. } => Some(name),
+ }
+ }
+
+ fn prefer_create_entry(&self) -> bool {
+ matches!(
+ &self.branch_target,
+ NewWorktreeBranchTarget::CreateBranch { from_ref: None, .. }
+ )
+ }
+
+ fn fixed_matches(&self) -> Vec {
+ let mut matches = vec![ThreadBranchEntry::CurrentBranch];
+ if !self.has_multiple_repositories
+ && self
+ .default_branch_name
+ .as_ref()
+ .is_some_and(|default_branch_name| default_branch_name != &self.current_branch_name)
+ {
+ matches.push(ThreadBranchEntry::DefaultBranch);
+ }
+ matches
+ }
+
+ fn current_branch_label(&self) -> SharedString {
+ if self.has_multiple_repositories {
+ SharedString::from("New branch from: current branches")
+ } else {
+ SharedString::from(format!("New branch from: {}", self.current_branch_name))
+ }
+ }
+
+ fn default_branch_label(&self) -> Option {
+ let default_branch_name = self
+ .default_branch_name
+ .as_ref()
+ .filter(|name| *name != &self.current_branch_name)?;
+ let is_occupied = self
+ .occupied_branches
+ .as_ref()
+ .is_some_and(|occupied| occupied.contains_key(default_branch_name));
+ let prefix = if is_occupied {
+ "New branch from"
+ } else {
+ "From"
+ };
+ Some(SharedString::from(format!(
+ "{prefix}: {default_branch_name}"
+ )))
+ }
+
+ fn branch_label_prefix(&self, branch_name: &str) -> &'static str {
+ let is_occupied = self
+ .occupied_branches
+ .as_ref()
+ .is_some_and(|occupied| occupied.contains_key(branch_name));
+ if is_occupied {
+ "New branch from: "
+ } else {
+ "From: "
+ }
+ }
+
+ fn sync_selected_index(&mut self) {
+ let selected_entry_name = self.selected_entry_name().map(ToOwned::to_owned);
+ let prefer_create = self.prefer_create_entry();
+
+ if prefer_create {
+ if let Some(ref selected_entry_name) = selected_entry_name {
+ if let Some(index) = self.matches.iter().position(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::CreateNamed { name } if name == selected_entry_name
+ )
+ }) {
+ self.selected_index = index;
+ return;
+ }
+ }
+ } else if let Some(ref selected_entry_name) = selected_entry_name {
+ if selected_entry_name == &self.current_branch_name {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadBranchEntry::CurrentBranch))
+ {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if self
+ .default_branch_name
+ .as_ref()
+ .is_some_and(|default_branch_name| default_branch_name == selected_entry_name)
+ {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadBranchEntry::DefaultBranch))
+ {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if let Some(index) = self.matches.iter().position(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::ExistingBranch { branch, .. }
+ if branch.name() == selected_entry_name.as_str()
+ )
+ }) {
+ self.selected_index = index;
+ return;
+ }
+ }
+
+ if self.matches.len() > 1
+ && self
+ .matches
+ .iter()
+ .skip(1)
+ .all(|entry| matches!(entry, ThreadBranchEntry::CreateNamed { .. }))
+ {
+ self.selected_index = 1;
+ return;
+ }
+
+ self.selected_index = 0;
+ }
+}
+
+impl PickerDelegate for ThreadBranchPickerDelegate {
+ type ListItem = ListItem;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc {
+ "Search branches…".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Task<()> {
+ if self.has_multiple_repositories {
+ let mut matches = self.fixed_matches();
+
+ if query.is_empty() {
+ if let Some(name) = self.selected_entry_name().map(ToOwned::to_owned) {
+ if self.prefer_create_entry() {
+ matches.push(ThreadBranchEntry::CreateNamed { name });
+ }
+ }
+ } else {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: query.replace(' ', "-"),
+ });
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+ return Task::ready(());
+ }
+
+ let Some(all_branches) = self.all_branches.clone() else {
+ self.matches = self.fixed_matches();
+ self.selected_index = 0;
+ return Task::ready(());
+ };
+ let occupied_branches = self.occupied_branches.clone().unwrap_or_default();
+
+ if query.is_empty() {
+ let mut matches = self.fixed_matches();
+ for branch in all_branches.into_iter().filter(|branch| {
+ branch.name() != self.current_branch_name
+ && self
+ .default_branch_name
+ .as_ref()
+ .is_none_or(|default_branch_name| branch.name() != default_branch_name)
+ }) {
+ matches.push(ThreadBranchEntry::ExistingBranch {
+ occupied_reason: occupied_branches.get(branch.name()).cloned(),
+ branch,
+ positions: Vec::new(),
+ });
+ }
+
+ if let Some(selected_entry_name) = self.selected_entry_name().map(ToOwned::to_owned) {
+ let has_existing = matches.iter().any(|entry| {
+ matches!(
+ entry,
+ ThreadBranchEntry::ExistingBranch { branch, .. }
+ if branch.name() == selected_entry_name
+ )
+ });
+ if self.prefer_create_entry() && !has_existing {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: selected_entry_name,
+ });
+ }
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+ return Task::ready(());
+ }
+
+ let candidates: Vec<_> = all_branches
+ .iter()
+ .enumerate()
+ .map(|(ix, branch)| StringMatchCandidate::new(ix, branch.name()))
+ .collect();
+ let executor = cx.background_executor().clone();
+ let query_clone = query.clone();
+ let normalized_query = query.replace(' ', "-");
+
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query_clone,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
+
+ let all_branches_clone = all_branches;
+ cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
+
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut matches = picker.delegate.fixed_matches();
+
+ for candidate in &fuzzy_matches {
+ let branch = all_branches_clone[candidate.candidate_id].clone();
+ if branch.name() == picker.delegate.current_branch_name
+ || picker.delegate.default_branch_name.as_ref().is_some_and(
+ |default_branch_name| branch.name() == default_branch_name,
+ )
+ {
+ continue;
+ }
+ let occupied_reason = occupied_branches.get(branch.name()).cloned();
+ matches.push(ThreadBranchEntry::ExistingBranch {
+ branch,
+ positions: candidate.positions.clone(),
+ occupied_reason,
+ });
+ }
+
+ if fuzzy_matches.is_empty() {
+ matches.push(ThreadBranchEntry::CreateNamed {
+ name: normalized_query.clone(),
+ });
+ }
+
+ picker.delegate.matches = matches;
+ if let Some(index) =
+ picker.delegate.matches.iter().position(|entry| {
+ matches!(entry, ThreadBranchEntry::ExistingBranch { .. })
+ })
+ {
+ picker.delegate.selected_index = index;
+ } else if !fuzzy_matches.is_empty() {
+ picker.delegate.selected_index = 0;
+ } else if let Some(index) =
+ picker.delegate.matches.iter().position(|entry| {
+ matches!(entry, ThreadBranchEntry::CreateNamed { .. })
+ })
+ {
+ picker.delegate.selected_index = index;
+ } else {
+ picker.delegate.sync_selected_index();
+ }
+ cx.notify();
+ })
+ .log_err();
+ })
+ }
+
+ fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match entry {
+ ThreadBranchEntry::CurrentBranch => {
+ window.dispatch_action(
+ Box::new(self.new_worktree_action(NewWorktreeBranchTarget::CurrentBranch)),
+ cx,
+ );
+ }
+ ThreadBranchEntry::DefaultBranch => {
+ let Some(default_branch_name) = self.default_branch_name.clone() else {
+ return;
+ };
+ window.dispatch_action(
+ Box::new(
+ self.new_worktree_action(NewWorktreeBranchTarget::ExistingBranch {
+ name: default_branch_name,
+ }),
+ ),
+ cx,
+ );
+ }
+ ThreadBranchEntry::ExistingBranch { branch, .. } => {
+ let branch_target = if branch.is_remote() {
+ let branch_name = branch
+ .ref_name
+ .as_ref()
+ .strip_prefix("refs/remotes/")
+ .and_then(|stripped| stripped.split_once('/').map(|(_, name)| name))
+ .unwrap_or(branch.name())
+ .to_string();
+ NewWorktreeBranchTarget::CreateBranch {
+ name: branch_name,
+ from_ref: Some(branch.name().to_string()),
+ }
+ } else {
+ NewWorktreeBranchTarget::ExistingBranch {
+ name: branch.name().to_string(),
+ }
+ };
+ window.dispatch_action(Box::new(self.new_worktree_action(branch_target)), cx);
+ }
+ ThreadBranchEntry::CreateNamed { name } => {
+ window.dispatch_action(
+ Box::new(
+ self.new_worktree_action(NewWorktreeBranchTarget::CreateBranch {
+ name: name.clone(),
+ from_ref: None,
+ }),
+ ),
+ cx,
+ );
+ }
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {}
+
+ fn separators_after_indices(&self) -> Vec {
+ let fixed_count = self.fixed_matches().len();
+ if self.matches.len() > fixed_count {
+ vec![fixed_count - 1]
+ } else {
+ Vec::new()
+ }
+ }
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(ix)?;
+
+ match entry {
+ ThreadBranchEntry::CurrentBranch => Some(
+ ListItem::new("current-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
+ .child(Label::new(self.current_branch_label())),
+ ),
+ ThreadBranchEntry::DefaultBranch => Some(
+ ListItem::new("default-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
+ .child(Label::new(self.default_branch_label()?)),
+ ),
+ ThreadBranchEntry::ExistingBranch {
+ branch,
+ positions,
+ occupied_reason,
+ } => {
+ let prefix = self.branch_label_prefix(branch.name());
+ let branch_name = branch.name().to_string();
+ let full_label = format!("{prefix}{branch_name}");
+ let adjusted_positions: Vec =
+ positions.iter().map(|&p| p + prefix.len()).collect();
+
+ let item = ListItem::new(SharedString::from(format!("branch-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitBranch).color(Color::Muted))
+ .child(HighlightedLabel::new(full_label, adjusted_positions).truncate());
+
+ Some(if let Some(reason) = occupied_reason.clone() {
+ item.tooltip(Tooltip::text(reason))
+ } else if branch.is_remote() {
+ item.tooltip(Tooltip::text(
+ "Create a new local branch from this remote branch",
+ ))
+ } else {
+ item
+ })
+ }
+ ThreadBranchEntry::CreateNamed { name } => Some(
+ ListItem::new("create-named-branch")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::Plus).color(Color::Accent))
+ .child(Label::new(format!("Create Branch: \"{name}\"…"))),
+ ),
+ }
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option {
+ None
+ }
+}
diff --git a/crates/agent_ui/src/thread_worktree_picker.rs b/crates/agent_ui/src/thread_worktree_picker.rs
new file mode 100644
index 0000000000000000000000000000000000000000..47a6a12d71822e13ab3523a3a6b0bb1ee57c7b4b
--- /dev/null
+++ b/crates/agent_ui/src/thread_worktree_picker.rs
@@ -0,0 +1,485 @@
+use std::path::PathBuf;
+use std::sync::Arc;
+
+use agent_settings::AgentSettings;
+use fs::Fs;
+use fuzzy::StringMatchCandidate;
+use git::repository::Worktree as GitWorktree;
+use gpui::{
+ App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, IntoElement,
+ ParentElement, Render, SharedString, Styled, Task, Window, rems,
+};
+use picker::{Picker, PickerDelegate, PickerEditorPosition};
+use project::{Project, git_store::RepositoryId};
+use settings::{NewThreadLocation, Settings, update_settings_file};
+use ui::{
+ HighlightedLabel, Icon, IconName, Label, LabelCommon, ListItem, ListItemSpacing, Tooltip,
+ prelude::*,
+};
+use util::ResultExt as _;
+
+use crate::ui::HoldForDefault;
+use crate::{NewWorktreeBranchTarget, StartThreadIn};
+
+pub(crate) struct ThreadWorktreePicker {
+ picker: Entity>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadWorktreePicker {
+ pub fn new(
+ project: Entity,
+ current_target: &StartThreadIn,
+ fs: Arc,
+ window: &mut Window,
+ cx: &mut Context,
+ ) -> Self {
+ let project_worktree_paths: Vec = project
+ .read(cx)
+ .visible_worktrees(cx)
+ .map(|wt| wt.read(cx).abs_path().to_path_buf())
+ .collect();
+
+ let preserved_branch_target = match current_target {
+ StartThreadIn::NewWorktree { branch_target, .. } => branch_target.clone(),
+ _ => NewWorktreeBranchTarget::default(),
+ };
+
+ let delegate = ThreadWorktreePickerDelegate {
+ matches: vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ],
+ all_worktrees: project
+ .read(cx)
+ .repositories(cx)
+ .iter()
+ .map(|(repo_id, repo)| (*repo_id, repo.read(cx).linked_worktrees.clone()))
+ .collect(),
+ project_worktree_paths,
+ selected_index: match current_target {
+ StartThreadIn::LocalProject => 0,
+ StartThreadIn::NewWorktree { .. } => 1,
+ _ => 0,
+ },
+ project: project.clone(),
+ preserved_branch_target,
+ fs,
+ };
+
+ let picker = cx.new(|cx| {
+ Picker::list(delegate, window, cx)
+ .list_measure_all()
+ .modal(false)
+ .max_height(Some(rems(20.).into()))
+ });
+
+ let subscription = cx.subscribe(&picker, |_, _, _, cx| {
+ cx.emit(DismissEvent);
+ });
+
+ Self {
+ focus_handle: picker.focus_handle(cx),
+ picker,
+ _subscription: subscription,
+ }
+ }
+}
+
+impl Focusable for ThreadWorktreePicker {
+ fn focus_handle(&self, _cx: &App) -> FocusHandle {
+ self.focus_handle.clone()
+ }
+}
+
+impl EventEmitter for ThreadWorktreePicker {}
+
+impl Render for ThreadWorktreePicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement {
+ v_flex()
+ .w(rems(20.))
+ .elevation_3(cx)
+ .child(self.picker.clone())
+ .on_mouse_down_out(cx.listener(|_, _, _, cx| {
+ cx.emit(DismissEvent);
+ }))
+ }
+}
+
+#[derive(Clone)]
+enum ThreadWorktreeEntry {
+ CurrentWorktree,
+ NewWorktree,
+ LinkedWorktree {
+ worktree: GitWorktree,
+ positions: Vec,
+ },
+ CreateNamed {
+ name: String,
+ disabled_reason: Option,
+ },
+}
+
+pub(crate) struct ThreadWorktreePickerDelegate {
+ matches: Vec,
+ all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
+ project_worktree_paths: Vec,
+ selected_index: usize,
+ preserved_branch_target: NewWorktreeBranchTarget,
+ project: Entity,
+ fs: Arc,
+}
+
+impl ThreadWorktreePickerDelegate {
+ fn new_worktree_action(&self, worktree_name: Option) -> StartThreadIn {
+ StartThreadIn::NewWorktree {
+ worktree_name,
+ branch_target: self.preserved_branch_target.clone(),
+ }
+ }
+
+ fn sync_selected_index(&mut self) {
+ if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::LinkedWorktree { .. }))
+ {
+ self.selected_index = index;
+ } else if let Some(index) = self
+ .matches
+ .iter()
+ .position(|entry| matches!(entry, ThreadWorktreeEntry::CreateNamed { .. }))
+ {
+ self.selected_index = index;
+ } else {
+ self.selected_index = 0;
+ }
+ }
+}
+
+impl PickerDelegate for ThreadWorktreePickerDelegate {
+ type ListItem = ListItem;
+
+ fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc {
+ "Search or create worktrees…".into()
+ }
+
+ fn editor_position(&self) -> PickerEditorPosition {
+ PickerEditorPosition::Start
+ }
+
+ fn match_count(&self) -> usize {
+ self.matches.len()
+ }
+
+ fn selected_index(&self) -> usize {
+ self.selected_index
+ }
+
+ fn set_selected_index(
+ &mut self,
+ ix: usize,
+ _window: &mut Window,
+ _cx: &mut Context>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn separators_after_indices(&self) -> Vec {
+ if self.matches.len() > 2 {
+ vec![1]
+ } else {
+ Vec::new()
+ }
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context>,
+ ) -> Task<()> {
+ let has_multiple_repositories = self.all_worktrees.len() > 1;
+
+ let linked_worktrees: Vec<_> = if has_multiple_repositories {
+ Vec::new()
+ } else {
+ self.all_worktrees
+ .iter()
+ .flat_map(|(_, worktrees)| worktrees.iter())
+ .filter(|worktree| {
+ !self
+ .project_worktree_paths
+ .iter()
+ .any(|project_path| project_path == &worktree.path)
+ })
+ .cloned()
+ .collect()
+ };
+
+ let normalized_query = query.replace(' ', "-");
+ let has_named_worktree = self.all_worktrees.iter().any(|(_, worktrees)| {
+ worktrees
+ .iter()
+ .any(|worktree| worktree.display_name() == normalized_query)
+ });
+ let create_named_disabled_reason = if has_multiple_repositories {
+ Some("Cannot create a named worktree in a project with multiple repositories".into())
+ } else if has_named_worktree {
+ Some("A worktree with this name already exists".into())
+ } else {
+ None
+ };
+
+ let mut matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ if query.is_empty() {
+ for worktree in &linked_worktrees {
+ matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: worktree.clone(),
+ positions: Vec::new(),
+ });
+ }
+ } else if linked_worktrees.is_empty() {
+ matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query,
+ disabled_reason: create_named_disabled_reason,
+ });
+ } else {
+ let candidates: Vec<_> = linked_worktrees
+ .iter()
+ .enumerate()
+ .map(|(ix, worktree)| StringMatchCandidate::new(ix, worktree.display_name()))
+ .collect();
+
+ let executor = cx.background_executor().clone();
+ let query_clone = query.clone();
+
+ let task = cx.background_executor().spawn(async move {
+ fuzzy::match_strings(
+ &candidates,
+ &query_clone,
+ true,
+ true,
+ 10000,
+ &Default::default(),
+ executor,
+ )
+ .await
+ });
+
+ let linked_worktrees_clone = linked_worktrees;
+ return cx.spawn_in(window, async move |picker, cx| {
+ let fuzzy_matches = task.await;
+
+ picker
+ .update_in(cx, |picker, _window, cx| {
+ let mut new_matches = vec![
+ ThreadWorktreeEntry::CurrentWorktree,
+ ThreadWorktreeEntry::NewWorktree,
+ ];
+
+ for candidate in &fuzzy_matches {
+ new_matches.push(ThreadWorktreeEntry::LinkedWorktree {
+ worktree: linked_worktrees_clone[candidate.candidate_id].clone(),
+ positions: candidate.positions.clone(),
+ });
+ }
+
+ let has_exact_match = linked_worktrees_clone
+ .iter()
+ .any(|worktree| worktree.display_name() == query);
+
+ if !has_exact_match {
+ new_matches.push(ThreadWorktreeEntry::CreateNamed {
+ name: normalized_query.clone(),
+ disabled_reason: create_named_disabled_reason.clone(),
+ });
+ }
+
+ picker.delegate.matches = new_matches;
+ picker.delegate.sync_selected_index();
+
+ cx.notify();
+ })
+ .log_err();
+ });
+ }
+
+ self.matches = matches;
+ self.sync_selected_index();
+
+ Task::ready(())
+ }
+
+ fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) {
+ let Some(entry) = self.matches.get(self.selected_index) else {
+ return;
+ };
+
+ match entry {
+ ThreadWorktreeEntry::CurrentWorktree => {
+ if secondary {
+ update_settings_file(self.fs.clone(), cx, |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_new_thread_location(NewThreadLocation::LocalProject);
+ });
+ }
+ window.dispatch_action(Box::new(StartThreadIn::LocalProject), cx);
+ }
+ ThreadWorktreeEntry::NewWorktree => {
+ if secondary {
+ update_settings_file(self.fs.clone(), cx, |settings, _| {
+ settings
+ .agent
+ .get_or_insert_default()
+ .set_new_thread_location(NewThreadLocation::NewWorktree);
+ });
+ }
+ window.dispatch_action(Box::new(self.new_worktree_action(None)), cx);
+ }
+ ThreadWorktreeEntry::LinkedWorktree { worktree, .. } => {
+ window.dispatch_action(
+ Box::new(StartThreadIn::LinkedWorktree {
+ path: worktree.path.clone(),
+ display_name: worktree.display_name().to_string(),
+ }),
+ cx,
+ );
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ disabled_reason: None,
+ } => {
+ window.dispatch_action(Box::new(self.new_worktree_action(Some(name.clone()))), cx);
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ disabled_reason: Some(_),
+ ..
+ } => {
+ return;
+ }
+ }
+
+ cx.emit(DismissEvent);
+ }
+
+ fn dismissed(&mut self, _window: &mut Window, _cx: &mut Context>) {}
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context>,
+ ) -> Option {
+ let entry = self.matches.get(ix)?;
+ let project = self.project.read(cx);
+ let is_new_worktree_disabled =
+ project.repositories(cx).is_empty() || project.is_via_collab();
+ let new_thread_location = AgentSettings::get_global(cx).new_thread_location;
+ let is_local_default = new_thread_location == NewThreadLocation::LocalProject;
+ let is_new_worktree_default = new_thread_location == NewThreadLocation::NewWorktree;
+
+ match entry {
+ ThreadWorktreeEntry::CurrentWorktree => Some(
+ ListItem::new("current-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::Folder).color(Color::Muted))
+ .child(Label::new("Current Worktree"))
+ .end_slot(HoldForDefault::new(is_local_default).more_content(false))
+ .tooltip(Tooltip::text("Use the current project worktree")),
+ ),
+ ThreadWorktreeEntry::NewWorktree => {
+ let item = ListItem::new("new-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .disabled(is_new_worktree_disabled)
+ .start_slot(
+ Icon::new(IconName::Plus).color(if is_new_worktree_disabled {
+ Color::Disabled
+ } else {
+ Color::Muted
+ }),
+ )
+ .child(
+ Label::new("New Git Worktree").color(if is_new_worktree_disabled {
+ Color::Disabled
+ } else {
+ Color::Default
+ }),
+ );
+
+ Some(if is_new_worktree_disabled {
+ item.tooltip(Tooltip::text("Requires a Git repository in the project"))
+ } else {
+ item.end_slot(HoldForDefault::new(is_new_worktree_default).more_content(false))
+ .tooltip(Tooltip::text("Start a thread in a new Git worktree"))
+ })
+ }
+ ThreadWorktreeEntry::LinkedWorktree {
+ worktree,
+ positions,
+ } => {
+ let display_name = worktree.display_name();
+ let first_line = display_name.lines().next().unwrap_or(display_name);
+ let positions: Vec<_> = positions
+ .iter()
+ .copied()
+ .filter(|&pos| pos < first_line.len())
+ .collect();
+
+ Some(
+ ListItem::new(SharedString::from(format!("linked-worktree-{ix}")))
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .start_slot(Icon::new(IconName::GitWorktree).color(Color::Muted))
+ .child(HighlightedLabel::new(first_line.to_owned(), positions).truncate()),
+ )
+ }
+ ThreadWorktreeEntry::CreateNamed {
+ name,
+ disabled_reason,
+ } => {
+ let is_disabled = disabled_reason.is_some();
+ let item = ListItem::new("create-named-worktree")
+ .inset(true)
+ .spacing(ListItemSpacing::Sparse)
+ .toggle_state(selected)
+ .disabled(is_disabled)
+ .start_slot(Icon::new(IconName::Plus).color(if is_disabled {
+ Color::Disabled
+ } else {
+ Color::Accent
+ }))
+ .child(Label::new(format!("Create Worktree: \"{name}\"…")).color(
+ if is_disabled {
+ Color::Disabled
+ } else {
+ Color::Default
+ },
+ ));
+
+ Some(if let Some(reason) = disabled_reason.clone() {
+ item.tooltip(Tooltip::text(reason))
+ } else {
+ item
+ })
+ }
+ }
+ }
+
+ fn no_matches_text(&self, _window: &mut Window, _cx: &mut App) -> Option {
+ None
+ }
+}
diff --git a/crates/collab/tests/integration/git_tests.rs b/crates/collab/tests/integration/git_tests.rs
index 2fa67b072f1c3d49ef5ca1b90056fd08d57df1ba..c273005264d0a53b6a083a4013f7597a56919016 100644
--- a/crates/collab/tests/integration/git_tests.rs
+++ b/crates/collab/tests/integration/git_tests.rs
@@ -269,9 +269,11 @@ async fn test_remote_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_directory.join("feature-branch"),
- Some("abc123".to_string()),
)
})
})
@@ -323,9 +325,11 @@ async fn test_remote_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repository, _| {
repository.create_worktree(
- "bugfix-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "bugfix-branch".to_string(),
+ base_sha: None,
+ },
worktree_directory.join("bugfix-branch"),
- None,
)
})
})
diff --git a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
index 0796323fc5b3d8f6b1cbcb0e108a7d573240f446..d478402a9d66ca9fba4e8f9517cb62898754e677 100644
--- a/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
+++ b/crates/collab/tests/integration/remote_editing_collaboration_tests.rs
@@ -473,9 +473,11 @@ async fn test_ssh_collaboration_git_worktrees(
cx_b.update(|cx| {
repo_b.update(cx, |repo, _| {
repo.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_directory.join("feature-branch"),
- Some("abc123".to_string()),
)
})
})
diff --git a/crates/fs/src/fake_git_repo.rs b/crates/fs/src/fake_git_repo.rs
index 751796fb83164b78dc5d6789f0ae7870eff16ce1..fbebeabf0ac15dde80016958eb358f792f46dd50 100644
--- a/crates/fs/src/fake_git_repo.rs
+++ b/crates/fs/src/fake_git_repo.rs
@@ -6,9 +6,10 @@ use git::{
Oid, RunHook,
blame::Blame,
repository::{
- AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions, FetchOptions,
- GRAPH_CHUNK_SIZE, GitRepository, GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder,
- LogSource, PushOptions, Remote, RepoPath, ResetMode, SearchCommitArgs, Worktree,
+ AskPassDelegate, Branch, CommitDataReader, CommitDetails, CommitOptions,
+ CreateWorktreeTarget, FetchOptions, GRAPH_CHUNK_SIZE, GitRepository,
+ GitRepositoryCheckpoint, InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote,
+ RepoPath, ResetMode, SearchCommitArgs, Worktree,
},
stash::GitStash,
status::{
@@ -540,9 +541,8 @@ impl GitRepository for FakeGitRepository {
fn create_worktree(
&self,
- branch_name: Option,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option,
) -> BoxFuture<'_, Result<()>> {
let fs = self.fs.clone();
let executor = self.executor.clone();
@@ -550,30 +550,82 @@ impl GitRepository for FakeGitRepository {
let common_dir_path = self.common_dir_path.clone();
async move {
executor.simulate_random_delay().await;
- // Check for simulated error and duplicate branch before any side effects.
- fs.with_git_state(&dot_git_path, false, |state| {
- if let Some(message) = &state.simulated_create_worktree_error {
- anyhow::bail!("{message}");
- }
- if let Some(ref name) = branch_name {
- if state.branches.contains(name) {
- bail!("a branch named '{}' already exists", name);
+
+ let branch_name = target.branch_name().map(ToOwned::to_owned);
+ let create_branch_ref = matches!(target, CreateWorktreeTarget::NewBranch { .. });
+
+ // Check for simulated error and validate branch state before any side effects.
+ fs.with_git_state(&dot_git_path, false, {
+ let branch_name = branch_name.clone();
+ move |state| {
+ if let Some(message) = &state.simulated_create_worktree_error {
+ anyhow::bail!("{message}");
}
+
+ match (create_branch_ref, branch_name.as_ref()) {
+ (true, Some(branch_name)) => {
+ if state.branches.contains(branch_name) {
+ bail!("a branch named '{}' already exists", branch_name);
+ }
+ }
+ (false, Some(branch_name)) => {
+ if !state.branches.contains(branch_name) {
+ bail!("no branch named '{}' exists", branch_name);
+ }
+ }
+ (false, None) => {}
+ (true, None) => bail!("branch name is required to create a branch"),
+ }
+
+ Ok(())
}
- Ok(())
})??;
+ let (branch_name, sha, create_branch_ref) = match target {
+ CreateWorktreeTarget::ExistingBranch { branch_name } => {
+ let ref_name = format!("refs/heads/{branch_name}");
+ let sha = fs.with_git_state(&dot_git_path, false, {
+ move |state| {
+ Ok::<_, anyhow::Error>(
+ state
+ .refs
+ .get(&ref_name)
+ .cloned()
+ .unwrap_or_else(|| "fake-sha".to_string()),
+ )
+ }
+ })??;
+ (Some(branch_name), sha, false)
+ }
+ CreateWorktreeTarget::NewBranch {
+ branch_name,
+ base_sha: start_point,
+ } => (
+ Some(branch_name),
+ start_point.unwrap_or_else(|| "fake-sha".to_string()),
+ true,
+ ),
+ CreateWorktreeTarget::Detached {
+ base_sha: start_point,
+ } => (
+ None,
+ start_point.unwrap_or_else(|| "fake-sha".to_string()),
+ false,
+ ),
+ };
+
// Create the worktree checkout directory.
fs.create_dir(&path).await?;
// Create .git/worktrees// directory with HEAD, commondir, gitdir.
- let worktree_entry_name = branch_name
- .as_deref()
- .unwrap_or_else(|| path.file_name().unwrap().to_str().unwrap());
+ let worktree_entry_name = branch_name.as_deref().unwrap_or_else(|| {
+ path.file_name()
+ .and_then(|name| name.to_str())
+ .unwrap_or("detached")
+ });
let worktrees_entry_dir = common_dir_path.join("worktrees").join(worktree_entry_name);
fs.create_dir(&worktrees_entry_dir).await?;
- let sha = from_commit.unwrap_or_else(|| "fake-sha".to_string());
let head_content = if let Some(ref branch_name) = branch_name {
let ref_name = format!("refs/heads/{branch_name}");
format!("ref: {ref_name}")
@@ -604,15 +656,22 @@ impl GitRepository for FakeGitRepository {
false,
)?;
- // Update git state: add ref and branch.
- fs.with_git_state(&dot_git_path, true, move |state| {
- if let Some(branch_name) = branch_name {
- let ref_name = format!("refs/heads/{branch_name}");
- state.refs.insert(ref_name, sha);
- state.branches.insert(branch_name);
- }
- Ok::<(), anyhow::Error>(())
- })??;
+ // Update git state for newly created branches.
+ if create_branch_ref {
+ fs.with_git_state(&dot_git_path, true, {
+ let branch_name = branch_name.clone();
+ let sha = sha.clone();
+ move |state| {
+ if let Some(branch_name) = branch_name {
+ let ref_name = format!("refs/heads/{branch_name}");
+ state.refs.insert(ref_name, sha);
+ state.branches.insert(branch_name);
+ }
+ Ok::<(), anyhow::Error>(())
+ }
+ })??;
+ }
+
Ok(())
}
.boxed()
diff --git a/crates/fs/tests/integration/fake_git_repo.rs b/crates/fs/tests/integration/fake_git_repo.rs
index f4192a22bb42f88f8769ef59f817b2bf2a288fb9..3be81ad7301e6fc4ee6f4529ce8bb587de3b4565 100644
--- a/crates/fs/tests/integration/fake_git_repo.rs
+++ b/crates/fs/tests/integration/fake_git_repo.rs
@@ -24,9 +24,11 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
// Create a worktree
let worktree_1_dir = worktrees_dir.join("feature-branch");
repo.create_worktree(
- Some("feature-branch".to_string()),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_1_dir.clone(),
- Some("abc123".to_string()),
)
.await
.unwrap();
@@ -48,9 +50,11 @@ async fn test_fake_worktree_lifecycle(cx: &mut TestAppContext) {
// Create a second worktree (without explicit commit)
let worktree_2_dir = worktrees_dir.join("bugfix-branch");
repo.create_worktree(
- Some("bugfix-branch".to_string()),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "bugfix-branch".to_string(),
+ base_sha: None,
+ },
worktree_2_dir.clone(),
- None,
)
.await
.unwrap();
diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs
index c42d2e28cf041e40404c1b8276ddcf5d10ca5f01..ba717d00c5e40374f5315d3ee8bc12e671f09552 100644
--- a/crates/git/src/repository.rs
+++ b/crates/git/src/repository.rs
@@ -241,20 +241,57 @@ pub struct Worktree {
pub is_main: bool,
}
+/// Describes how a new worktree should choose or create its checked-out HEAD.
+#[derive(Clone, Debug, Hash, PartialEq, Eq)]
+pub enum CreateWorktreeTarget {
+ /// Check out an existing local branch in the new worktree.
+ ExistingBranch {
+ /// The existing local branch to check out.
+ branch_name: String,
+ },
+ /// Create a new local branch for the new worktree.
+ NewBranch {
+ /// The new local branch to create and check out.
+ branch_name: String,
+ /// The commit or ref to create the branch from. Uses `HEAD` when `None`.
+ base_sha: Option,
+ },
+ /// Check out a commit or ref in detached HEAD state.
+ Detached {
+ /// The commit or ref to check out. Uses `HEAD` when `None`.
+ base_sha: Option,
+ },
+}
+
+impl CreateWorktreeTarget {
+ pub fn branch_name(&self) -> Option<&str> {
+ match self {
+ Self::ExistingBranch { branch_name } | Self::NewBranch { branch_name, .. } => {
+ Some(branch_name)
+ }
+ Self::Detached { .. } => None,
+ }
+ }
+}
+
impl Worktree {
+ /// Returns the branch name if the worktree is attached to a branch.
+ pub fn branch_name(&self) -> Option<&str> {
+ self.ref_name.as_ref().map(|ref_name| {
+ ref_name
+ .strip_prefix("refs/heads/")
+ .or_else(|| ref_name.strip_prefix("refs/remotes/"))
+ .unwrap_or(ref_name)
+ })
+ }
+
/// Returns a display name for the worktree, suitable for use in the UI.
///
/// If the worktree is attached to a branch, returns the branch name.
/// Otherwise, returns the short SHA of the worktree's HEAD commit.
pub fn display_name(&self) -> &str {
- match self.ref_name {
- Some(ref ref_name) => ref_name
- .strip_prefix("refs/heads/")
- .or_else(|| ref_name.strip_prefix("refs/remotes/"))
- .unwrap_or(ref_name),
- // Detached HEAD — show the short SHA as a fallback.
- None => &self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)],
- }
+ self.branch_name()
+ .unwrap_or(&self.sha[..self.sha.len().min(SHORT_SHA_LENGTH)])
}
}
@@ -716,9 +753,8 @@ pub trait GitRepository: Send + Sync {
fn create_worktree(
&self,
- branch_name: Option,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option,
) -> BoxFuture<'_, Result<()>>;
fn remove_worktree(&self, path: PathBuf, force: bool) -> BoxFuture<'_, Result<()>>;
@@ -1667,24 +1703,36 @@ impl GitRepository for RealGitRepository {
fn create_worktree(
&self,
- branch_name: Option,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option,
) -> BoxFuture<'_, Result<()>> {
let git_binary = self.git_binary();
let mut args = vec![OsString::from("worktree"), OsString::from("add")];
- if let Some(branch_name) = &branch_name {
- args.push(OsString::from("-b"));
- args.push(OsString::from(branch_name.as_str()));
- } else {
- args.push(OsString::from("--detach"));
- }
- args.push(OsString::from("--"));
- args.push(OsString::from(path.as_os_str()));
- if let Some(from_commit) = from_commit {
- args.push(OsString::from(from_commit));
- } else {
- args.push(OsString::from("HEAD"));
+
+ match &target {
+ CreateWorktreeTarget::ExistingBranch { branch_name } => {
+ args.push(OsString::from("--"));
+ args.push(OsString::from(path.as_os_str()));
+ args.push(OsString::from(branch_name));
+ }
+ CreateWorktreeTarget::NewBranch {
+ branch_name,
+ base_sha: start_point,
+ } => {
+ args.push(OsString::from("-b"));
+ args.push(OsString::from(branch_name));
+ args.push(OsString::from("--"));
+ args.push(OsString::from(path.as_os_str()));
+ args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
+ }
+ CreateWorktreeTarget::Detached {
+ base_sha: start_point,
+ } => {
+ args.push(OsString::from("--detach"));
+ args.push(OsString::from("--"));
+ args.push(OsString::from(path.as_os_str()));
+ args.push(OsString::from(start_point.as_deref().unwrap_or("HEAD")));
+ }
}
self.executor
@@ -4054,9 +4102,11 @@ mod tests {
// Create a new worktree
repo.create_worktree(
- Some("test-branch".to_string()),
+ CreateWorktreeTarget::NewBranch {
+ branch_name: "test-branch".to_string(),
+ base_sha: Some("HEAD".to_string()),
+ },
worktree_path.clone(),
- Some("HEAD".to_string()),
)
.await
.unwrap();
@@ -4113,9 +4163,11 @@ mod tests {
// Create a worktree
let worktree_path = worktrees_dir.join("worktree-to-remove");
repo.create_worktree(
- Some("to-remove".to_string()),
+ CreateWorktreeTarget::NewBranch {
+ branch_name: "to-remove".to_string(),
+ base_sha: Some("HEAD".to_string()),
+ },
worktree_path.clone(),
- Some("HEAD".to_string()),
)
.await
.unwrap();
@@ -4137,9 +4189,11 @@ mod tests {
// Create a worktree
let worktree_path = worktrees_dir.join("dirty-wt");
repo.create_worktree(
- Some("dirty-wt".to_string()),
+ CreateWorktreeTarget::NewBranch {
+ branch_name: "dirty-wt".to_string(),
+ base_sha: Some("HEAD".to_string()),
+ },
worktree_path.clone(),
- Some("HEAD".to_string()),
)
.await
.unwrap();
@@ -4207,9 +4261,11 @@ mod tests {
// Create a worktree
let old_path = worktrees_dir.join("old-worktree-name");
repo.create_worktree(
- Some("old-name".to_string()),
+ CreateWorktreeTarget::NewBranch {
+ branch_name: "old-name".to_string(),
+ base_sha: Some("HEAD".to_string()),
+ },
old_path.clone(),
- Some("HEAD".to_string()),
)
.await
.unwrap();
diff --git a/crates/git_ui/src/worktree_picker.rs b/crates/git_ui/src/worktree_picker.rs
index 1b4497be1f4ea96bd4f0431c97bb538eda9faa57..bd1d694fa30bb914569fbb5e6e3c67de3e3d86a0 100644
--- a/crates/git_ui/src/worktree_picker.rs
+++ b/crates/git_ui/src/worktree_picker.rs
@@ -318,8 +318,13 @@ impl WorktreeListDelegate {
.clone();
let new_worktree_path =
repo.path_for_new_linked_worktree(&branch, &worktree_directory_setting)?;
- let receiver =
- repo.create_worktree(branch.clone(), new_worktree_path.clone(), commit);
+ let receiver = repo.create_worktree(
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: branch.clone(),
+ base_sha: commit,
+ },
+ new_worktree_path.clone(),
+ );
anyhow::Ok((receiver, new_worktree_path))
})?;
receiver.await??;
diff --git a/crates/project/src/git_store.rs b/crates/project/src/git_store.rs
index e7e84ffe673881d898a56b64892887b9c8d6c809..8da5a14e41d9cb97865d78f4dfc2ed79f76faebd 100644
--- a/crates/project/src/git_store.rs
+++ b/crates/project/src/git_store.rs
@@ -32,10 +32,10 @@ use git::{
blame::Blame,
parse_git_remote_url,
repository::{
- Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, DiffType, FetchOptions,
- GitRepository, GitRepositoryCheckpoint, GraphCommitData, InitialGraphCommitData, LogOrder,
- LogSource, PushOptions, Remote, RemoteCommandOutput, RepoPath, ResetMode, SearchCommitArgs,
- UpstreamTrackingStatus, Worktree as GitWorktree,
+ Branch, CommitDetails, CommitDiff, CommitFile, CommitOptions, CreateWorktreeTarget,
+ DiffType, FetchOptions, GitRepository, GitRepositoryCheckpoint, GraphCommitData,
+ InitialGraphCommitData, LogOrder, LogSource, PushOptions, Remote, RemoteCommandOutput,
+ RepoPath, ResetMode, SearchCommitArgs, UpstreamTrackingStatus, Worktree as GitWorktree,
},
stash::{GitStash, StashEntry},
status::{
@@ -329,12 +329,6 @@ pub struct GraphDataResponse<'a> {
pub error: Option,
}
-#[derive(Clone, Debug)]
-enum CreateWorktreeStartPoint {
- Detached,
- Branched { name: String },
-}
-
pub struct Repository {
this: WeakEntity,
snapshot: RepositorySnapshot,
@@ -2414,18 +2408,23 @@ impl GitStore {
let repository_id = RepositoryId::from_proto(envelope.payload.repository_id);
let repository_handle = Self::repository_for_request(&this, repository_id, &mut cx)?;
let directory = PathBuf::from(envelope.payload.directory);
- let start_point = if envelope.payload.name.is_empty() {
- CreateWorktreeStartPoint::Detached
+ let name = envelope.payload.name;
+ let commit = envelope.payload.commit;
+ let use_existing_branch = envelope.payload.use_existing_branch;
+ let target = if name.is_empty() {
+ CreateWorktreeTarget::Detached { base_sha: commit }
+ } else if use_existing_branch {
+ CreateWorktreeTarget::ExistingBranch { branch_name: name }
} else {
- CreateWorktreeStartPoint::Branched {
- name: envelope.payload.name,
+ CreateWorktreeTarget::NewBranch {
+ branch_name: name,
+ base_sha: commit,
}
};
- let commit = envelope.payload.commit;
repository_handle
.update(&mut cx, |repository_handle, _| {
- repository_handle.create_worktree_with_start_point(start_point, directory, commit)
+ repository_handle.create_worktree(target, directory)
})
.await??;
@@ -6004,50 +6003,43 @@ impl Repository {
})
}
- fn create_worktree_with_start_point(
+ pub fn create_worktree(
&mut self,
- start_point: CreateWorktreeStartPoint,
+ target: CreateWorktreeTarget,
path: PathBuf,
- commit: Option,
) -> oneshot::Receiver> {
- if matches!(
- &start_point,
- CreateWorktreeStartPoint::Branched { name } if name.is_empty()
- ) {
- let (sender, receiver) = oneshot::channel();
- sender
- .send(Err(anyhow!("branch name cannot be empty")))
- .ok();
- return receiver;
- }
-
let id = self.id;
- let message = match &start_point {
- CreateWorktreeStartPoint::Detached => "git worktree add (detached)".into(),
- CreateWorktreeStartPoint::Branched { name } => {
- format!("git worktree add: {name}").into()
- }
+ let job_description = match target.branch_name() {
+ Some(branch_name) => format!("git worktree add: {branch_name}"),
+ None => "git worktree add (detached)".to_string(),
};
-
- self.send_job(Some(message), move |repo, _cx| async move {
- let branch_name = match start_point {
- CreateWorktreeStartPoint::Detached => None,
- CreateWorktreeStartPoint::Branched { name } => Some(name),
- };
- let remote_name = branch_name.clone().unwrap_or_default();
-
+ self.send_job(Some(job_description.into()), move |repo, _cx| async move {
match repo {
RepositoryState::Local(LocalRepositoryState { backend, .. }) => {
- backend.create_worktree(branch_name, path, commit).await
+ backend.create_worktree(target, path).await
}
RepositoryState::Remote(RemoteRepositoryState { project_id, client }) => {
+ let (name, commit, use_existing_branch) = match target {
+ CreateWorktreeTarget::ExistingBranch { branch_name } => {
+ (branch_name, None, true)
+ }
+ CreateWorktreeTarget::NewBranch {
+ branch_name,
+ base_sha: start_point,
+ } => (branch_name, start_point, false),
+ CreateWorktreeTarget::Detached {
+ base_sha: start_point,
+ } => (String::new(), start_point, false),
+ };
+
client
.request(proto::GitCreateWorktree {
project_id: project_id.0,
repository_id: id.to_proto(),
- name: remote_name,
+ name,
directory: path.to_string_lossy().to_string(),
commit,
+ use_existing_branch,
})
.await?;
@@ -6057,28 +6049,16 @@ impl Repository {
})
}
- pub fn create_worktree(
- &mut self,
- branch_name: String,
- path: PathBuf,
- commit: Option,
- ) -> oneshot::Receiver> {
- self.create_worktree_with_start_point(
- CreateWorktreeStartPoint::Branched { name: branch_name },
- path,
- commit,
- )
- }
-
pub fn create_worktree_detached(
&mut self,
path: PathBuf,
commit: String,
) -> oneshot::Receiver> {
- self.create_worktree_with_start_point(
- CreateWorktreeStartPoint::Detached,
+ self.create_worktree(
+ CreateWorktreeTarget::Detached {
+ base_sha: Some(commit),
+ },
path,
- Some(commit),
)
}
diff --git a/crates/project/tests/integration/git_store.rs b/crates/project/tests/integration/git_store.rs
index 02f752b28b24a8135e2cba9307a5eacdc16f0fa3..bbe5c64d7cf7f5b2ffa9160df6130cd88ddc5d69 100644
--- a/crates/project/tests/integration/git_store.rs
+++ b/crates/project/tests/integration/git_store.rs
@@ -1267,9 +1267,11 @@ mod git_worktrees {
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.create_worktree(
- "feature-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "feature-branch".to_string(),
+ base_sha: Some("abc123".to_string()),
+ },
worktree_1_directory.clone(),
- Some("abc123".to_string()),
)
})
})
@@ -1297,9 +1299,11 @@ mod git_worktrees {
cx.update(|cx| {
repository.update(cx, |repository, _| {
repository.create_worktree(
- "bugfix-branch".to_string(),
+ git::repository::CreateWorktreeTarget::NewBranch {
+ branch_name: "bugfix-branch".to_string(),
+ base_sha: None,
+ },
worktree_2_directory.clone(),
- None,
)
})
})
diff --git a/crates/proto/proto/git.proto b/crates/proto/proto/git.proto
index 9324feb21b1f50ac1041ed0afc8b59cb9b7fe2c6..d0a594a2817ec50d9d35383587619e311f2950d8 100644
--- a/crates/proto/proto/git.proto
+++ b/crates/proto/proto/git.proto
@@ -594,6 +594,7 @@ message GitCreateWorktree {
string name = 3;
string directory = 4;
optional string commit = 5;
+ bool use_existing_branch = 6;
}
message GitCreateCheckpoint {
diff --git a/crates/zed/src/visual_test_runner.rs b/crates/zed/src/visual_test_runner.rs
index b59123a1a159487f802210f3916e16856daf8e61..9f69cd3458c194228f37cfdeedcf0c9023b9b7bd 100644
--- a/crates/zed/src/visual_test_runner.rs
+++ b/crates/zed/src/visual_test_runner.rs
@@ -3080,7 +3080,7 @@ fn run_start_thread_in_selector_visual_tests(
cx: &mut VisualTestAppContext,
update_baseline: bool,
) -> Result {
- use agent_ui::{AgentPanel, StartThreadIn, WorktreeCreationStatus};
+ use agent_ui::{AgentPanel, NewWorktreeBranchTarget, StartThreadIn, WorktreeCreationStatus};
// Enable feature flags so the thread target selector renders
cx.update(|cx| {
@@ -3401,7 +3401,13 @@ edition = "2021"
cx.update_window(workspace_window.into(), |_, _window, cx| {
panel.update(cx, |panel, cx| {
- panel.set_start_thread_in_for_tests(StartThreadIn::NewWorktree, cx);
+ panel.set_start_thread_in_for_tests(
+ StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ },
+ cx,
+ );
});
})?;
cx.run_until_parked();
@@ -3474,7 +3480,13 @@ edition = "2021"
cx.run_until_parked();
cx.update_window(workspace_window.into(), |_, window, cx| {
- window.dispatch_action(Box::new(StartThreadIn::NewWorktree), cx);
+ window.dispatch_action(
+ Box::new(StartThreadIn::NewWorktree {
+ worktree_name: None,
+ branch_target: NewWorktreeBranchTarget::default(),
+ }),
+ cx,
+ );
})?;
cx.run_until_parked();
From 9c731640c7f5a4d91a94b3e68fa92eb8bc5e38ee Mon Sep 17 00:00:00 2001
From: Shardul Vaidya <31039336+5herlocked@users.noreply.github.com>
Date: Tue, 7 Apr 2026 05:59:12 -0400
Subject: [PATCH 7/9] bedrock: Add new Bedrock models (NVIDIA, Z.AI, Mistral,
MiniMax) (#53043)
Add 9 new models across 3 new providers (NVIDIA, Z.AI) and expanded
coverage for existing providers (Mistral, MiniMax):
- NVIDIA Nemotron Super 3 120B, Nemotron Nano 3 30B
- Mistral Devstral 2 123B, Ministral 14B
- MiniMax M2.1, M2.5
- Z.AI GLM 5, GLM 4.7, GLM 4.7 Flash
Self-Review Checklist:
- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable
Closes #ISSUE
Release Notes:
- bedrock: Added 9 new models across 3 new providers (NVIDIA, Z.AI) and
expanded coverage for existing providers (Mistral, MiniMax)
---
crates/bedrock/src/models.rs | 64 ++++++++++++++++++++++++++++++++++--
1 file changed, 61 insertions(+), 3 deletions(-)
diff --git a/crates/bedrock/src/models.rs b/crates/bedrock/src/models.rs
index 8b6113e4d5521fb3c7e27a7f2f6547c7a9db86ce..7c1e6e0e4e6ef873345c30c0af4c9e8842699c77 100644
--- a/crates/bedrock/src/models.rs
+++ b/crates/bedrock/src/models.rs
@@ -113,6 +113,10 @@ pub enum Model {
MistralLarge3,
#[serde(rename = "pixtral-large")]
PixtralLarge,
+ #[serde(rename = "devstral-2-123b")]
+ Devstral2_123B,
+ #[serde(rename = "ministral-14b")]
+ Ministral14B,
// Qwen models
#[serde(rename = "qwen3-32b")]
@@ -146,9 +150,27 @@ pub enum Model {
#[serde(rename = "gpt-oss-120b")]
GptOss120B,
+ // NVIDIA Nemotron models
+ #[serde(rename = "nemotron-super-3-120b")]
+ NemotronSuper3_120B,
+ #[serde(rename = "nemotron-nano-3-30b")]
+ NemotronNano3_30B,
+
// MiniMax models
#[serde(rename = "minimax-m2")]
MiniMaxM2,
+ #[serde(rename = "minimax-m2-1")]
+ MiniMaxM2_1,
+ #[serde(rename = "minimax-m2-5")]
+ MiniMaxM2_5,
+
+ // Z.AI GLM models
+ #[serde(rename = "glm-5")]
+ GLM5,
+ #[serde(rename = "glm-4-7")]
+ GLM4_7,
+ #[serde(rename = "glm-4-7-flash")]
+ GLM4_7Flash,
// Moonshot models
#[serde(rename = "kimi-k2-thinking")]
@@ -217,6 +239,8 @@ impl Model {
Self::MagistralSmall => "magistral-small",
Self::MistralLarge3 => "mistral-large-3",
Self::PixtralLarge => "pixtral-large",
+ Self::Devstral2_123B => "devstral-2-123b",
+ Self::Ministral14B => "ministral-14b",
Self::Qwen3_32B => "qwen3-32b",
Self::Qwen3VL235B => "qwen3-vl-235b",
Self::Qwen3_235B => "qwen3-235b",
@@ -230,7 +254,14 @@ impl Model {
Self::Nova2Lite => "nova-2-lite",
Self::GptOss20B => "gpt-oss-20b",
Self::GptOss120B => "gpt-oss-120b",
+ Self::NemotronSuper3_120B => "nemotron-super-3-120b",
+ Self::NemotronNano3_30B => "nemotron-nano-3-30b",
Self::MiniMaxM2 => "minimax-m2",
+ Self::MiniMaxM2_1 => "minimax-m2-1",
+ Self::MiniMaxM2_5 => "minimax-m2-5",
+ Self::GLM5 => "glm-5",
+ Self::GLM4_7 => "glm-4-7",
+ Self::GLM4_7Flash => "glm-4-7-flash",
Self::KimiK2Thinking => "kimi-k2-thinking",
Self::KimiK2_5 => "kimi-k2-5",
Self::DeepSeekR1 => "deepseek-r1",
@@ -257,6 +288,8 @@ impl Model {
Self::MagistralSmall => "mistral.magistral-small-2509",
Self::MistralLarge3 => "mistral.mistral-large-3-675b-instruct",
Self::PixtralLarge => "mistral.pixtral-large-2502-v1:0",
+ Self::Devstral2_123B => "mistral.devstral-2-123b",
+ Self::Ministral14B => "mistral.ministral-3-14b-instruct",
Self::Qwen3VL235B => "qwen.qwen3-vl-235b-a22b",
Self::Qwen3_32B => "qwen.qwen3-32b-v1:0",
Self::Qwen3_235B => "qwen.qwen3-235b-a22b-2507-v1:0",
@@ -270,7 +303,14 @@ impl Model {
Self::Nova2Lite => "amazon.nova-2-lite-v1:0",
Self::GptOss20B => "openai.gpt-oss-20b-1:0",
Self::GptOss120B => "openai.gpt-oss-120b-1:0",
+ Self::NemotronSuper3_120B => "nvidia.nemotron-super-3-120b",
+ Self::NemotronNano3_30B => "nvidia.nemotron-nano-3-30b",
Self::MiniMaxM2 => "minimax.minimax-m2",
+ Self::MiniMaxM2_1 => "minimax.minimax-m2.1",
+ Self::MiniMaxM2_5 => "minimax.minimax-m2.5",
+ Self::GLM5 => "zai.glm-5",
+ Self::GLM4_7 => "zai.glm-4.7",
+ Self::GLM4_7Flash => "zai.glm-4.7-flash",
Self::KimiK2Thinking => "moonshot.kimi-k2-thinking",
Self::KimiK2_5 => "moonshotai.kimi-k2.5",
Self::DeepSeekR1 => "deepseek.r1-v1:0",
@@ -297,6 +337,8 @@ impl Model {
Self::MagistralSmall => "Magistral Small",
Self::MistralLarge3 => "Mistral Large 3",
Self::PixtralLarge => "Pixtral Large",
+ Self::Devstral2_123B => "Devstral 2 123B",
+ Self::Ministral14B => "Ministral 14B",
Self::Qwen3VL235B => "Qwen3 VL 235B",
Self::Qwen3_32B => "Qwen3 32B",
Self::Qwen3_235B => "Qwen3 235B",
@@ -310,7 +352,14 @@ impl Model {
Self::Nova2Lite => "Amazon Nova 2 Lite",
Self::GptOss20B => "GPT OSS 20B",
Self::GptOss120B => "GPT OSS 120B",
+ Self::NemotronSuper3_120B => "Nemotron Super 3 120B",
+ Self::NemotronNano3_30B => "Nemotron Nano 3 30B",
Self::MiniMaxM2 => "MiniMax M2",
+ Self::MiniMaxM2_1 => "MiniMax M2.1",
+ Self::MiniMaxM2_5 => "MiniMax M2.5",
+ Self::GLM5 => "GLM 5",
+ Self::GLM4_7 => "GLM 4.7",
+ Self::GLM4_7Flash => "GLM 4.7 Flash",
Self::KimiK2Thinking => "Kimi K2 Thinking",
Self::KimiK2_5 => "Kimi K2.5",
Self::DeepSeekR1 => "DeepSeek R1",
@@ -338,6 +387,7 @@ impl Model {
Self::Llama4Scout17B | Self::Llama4Maverick17B => 128_000,
Self::Gemma3_4B | Self::Gemma3_12B | Self::Gemma3_27B => 128_000,
Self::MagistralSmall | Self::MistralLarge3 | Self::PixtralLarge => 128_000,
+ Self::Devstral2_123B | Self::Ministral14B => 256_000,
Self::Qwen3_32B
| Self::Qwen3VL235B
| Self::Qwen3_235B
@@ -349,7 +399,9 @@ impl Model {
Self::NovaPremier => 1_000_000,
Self::Nova2Lite => 300_000,
Self::GptOss20B | Self::GptOss120B => 128_000,
- Self::MiniMaxM2 => 128_000,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => 262_000,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => 196_000,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => 203_000,
Self::KimiK2Thinking | Self::KimiK2_5 => 128_000,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => 128_000,
Self::Custom { max_tokens, .. } => *max_tokens,
@@ -373,6 +425,7 @@ impl Model {
| Self::MagistralSmall
| Self::MistralLarge3
| Self::PixtralLarge => 8_192,
+ Self::Devstral2_123B | Self::Ministral14B => 131_000,
Self::Qwen3_32B
| Self::Qwen3VL235B
| Self::Qwen3_235B
@@ -382,7 +435,9 @@ impl Model {
| Self::Qwen3Coder480B => 8_192,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => 5_000,
Self::GptOss20B | Self::GptOss120B => 16_000,
- Self::MiniMaxM2 => 16_000,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => 131_000,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => 98_000,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => 101_000,
Self::KimiK2Thinking | Self::KimiK2_5 => 16_000,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => 16_000,
Self::Custom {
@@ -419,6 +474,7 @@ impl Model {
| Self::ClaudeSonnet4_6 => true,
Self::NovaLite | Self::NovaPro | Self::NovaPremier | Self::Nova2Lite => true,
Self::MistralLarge3 | Self::PixtralLarge | Self::MagistralSmall => true,
+ Self::Devstral2_123B | Self::Ministral14B => true,
// Gemma accepts toolConfig without error but produces unreliable tool
// calls -- malformed JSON args, hallucinated tool names, dropped calls.
Self::Qwen3_32B
@@ -428,7 +484,9 @@ impl Model {
| Self::Qwen3Coder30B
| Self::Qwen3CoderNext
| Self::Qwen3Coder480B => true,
- Self::MiniMaxM2 => true,
+ Self::MiniMaxM2 | Self::MiniMaxM2_1 | Self::MiniMaxM2_5 => true,
+ Self::NemotronSuper3_120B | Self::NemotronNano3_30B => true,
+ Self::GLM5 | Self::GLM4_7 | Self::GLM4_7Flash => true,
Self::KimiK2Thinking | Self::KimiK2_5 => true,
Self::DeepSeekR1 | Self::DeepSeekV3_1 | Self::DeepSeekV3_2 => true,
_ => false,
From 93438829c75f7f73dc14bba3c79b4626709a4b4e Mon Sep 17 00:00:00 2001
From: Bhuminjay Soni
Date: Tue, 7 Apr 2026 15:35:02 +0530
Subject: [PATCH 8/9] Add fuzzy_nucleo crate for order independent file finder
search (#51164)
Closes #14428
Before you mark this PR as ready for review, make sure that you have:
- [ ] Added a solid test coverage and/or screenshots from doing manual
testing
https://github.com/user-attachments/assets/7e0d67ff-cc4e-4609-880d-5c1794c64dcf
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
Release Notes:
- Adds a new `fuzzy_nucleo` crate that implements order independent path
matching using the `nucleo` library. currently integrated for file
finder.
---------
Signed-off-by: Bhuminjay
Signed-off-by: 11happy
---
Cargo.lock | 32 ++
Cargo.toml | 3 +
crates/file_finder/Cargo.toml | 1 +
crates/file_finder/src/file_finder.rs | 69 ++--
crates/file_finder/src/file_finder_tests.rs | 230 +++++++++++++
crates/fuzzy_nucleo/Cargo.toml | 21 ++
crates/fuzzy_nucleo/LICENSE-GPL | 1 +
crates/fuzzy_nucleo/src/fuzzy_nucleo.rs | 5 +
crates/fuzzy_nucleo/src/matcher.rs | 39 +++
crates/fuzzy_nucleo/src/paths.rs | 352 ++++++++++++++++++++
crates/project/Cargo.toml | 1 +
crates/project/src/project.rs | 70 ++++
12 files changed, 774 insertions(+), 50 deletions(-)
create mode 100644 crates/fuzzy_nucleo/Cargo.toml
create mode 120000 crates/fuzzy_nucleo/LICENSE-GPL
create mode 100644 crates/fuzzy_nucleo/src/fuzzy_nucleo.rs
create mode 100644 crates/fuzzy_nucleo/src/matcher.rs
create mode 100644 crates/fuzzy_nucleo/src/paths.rs
diff --git a/Cargo.lock b/Cargo.lock
index 97412711a55667a4976a35313eb6c0388acc74ef..cbc494f9dc0fc1858a846fabe168b3538de4dbe5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -6183,6 +6183,7 @@ dependencies = [
"file_icons",
"futures 0.3.32",
"fuzzy",
+ "fuzzy_nucleo",
"gpui",
"menu",
"open_path_prompt",
@@ -6740,6 +6741,15 @@ dependencies = [
"thread_local",
]
+[[package]]
+name = "fuzzy_nucleo"
+version = "0.1.0"
+dependencies = [
+ "gpui",
+ "nucleo",
+ "util",
+]
+
[[package]]
name = "gaoya"
version = "0.2.0"
@@ -11063,6 +11073,27 @@ dependencies = [
"windows-sys 0.61.2",
]
+[[package]]
+name = "nucleo"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5262af4c94921c2646c5ac6ff7900c2af9cbb08dc26a797e18130a7019c039d4"
+dependencies = [
+ "nucleo-matcher",
+ "parking_lot",
+ "rayon",
+]
+
+[[package]]
+name = "nucleo-matcher"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf33f538733d1a5a3494b836ba913207f14d9d4a1d3cd67030c5061bdd2cac85"
+dependencies = [
+ "memchr",
+ "unicode-segmentation",
+]
+
[[package]]
name = "num"
version = "0.4.3"
@@ -13203,6 +13234,7 @@ dependencies = [
"fs",
"futures 0.3.32",
"fuzzy",
+ "fuzzy_nucleo",
"git",
"git2",
"git_hosting_providers",
diff --git a/Cargo.toml b/Cargo.toml
index 5cb5b991b645ec1b78b16f48493c7c8dc1426344..4c75dafae5df4d63815e0da5cabb95ccdad25e9d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -78,6 +78,7 @@ members = [
"crates/fs",
"crates/fs_benchmarks",
"crates/fuzzy",
+ "crates/fuzzy_nucleo",
"crates/git",
"crates/git_graph",
"crates/git_hosting_providers",
@@ -325,6 +326,7 @@ file_finder = { path = "crates/file_finder" }
file_icons = { path = "crates/file_icons" }
fs = { path = "crates/fs" }
fuzzy = { path = "crates/fuzzy" }
+fuzzy_nucleo = { path = "crates/fuzzy_nucleo" }
git = { path = "crates/git" }
git_graph = { path = "crates/git_graph" }
git_hosting_providers = { path = "crates/git_hosting_providers" }
@@ -609,6 +611,7 @@ naga = { version = "29.0", features = ["wgsl-in"] }
nanoid = "0.4"
nbformat = "1.2.0"
nix = "0.29"
+nucleo = "0.5"
num-format = "0.4.4"
objc = "0.2"
objc2-app-kit = { version = "0.3", default-features = false, features = [ "NSGraphics" ] }
diff --git a/crates/file_finder/Cargo.toml b/crates/file_finder/Cargo.toml
index 5eb36f0f5150263629b407dbe07dc73b6eff31cf..67ebab62295e8db90a12f99cbc05e9b9e56c2c6b 100644
--- a/crates/file_finder/Cargo.toml
+++ b/crates/file_finder/Cargo.toml
@@ -21,6 +21,7 @@ editor.workspace = true
file_icons.workspace = true
futures.workspace = true
fuzzy.workspace = true
+fuzzy_nucleo.workspace = true
gpui.workspace = true
menu.workspace = true
open_path_prompt.workspace = true
diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs
index 4302669ddc11c94f7df128534217d00c27ef083a..a4d9ea042dea898b9dd9db7d40354cf960d210d5 100644
--- a/crates/file_finder/src/file_finder.rs
+++ b/crates/file_finder/src/file_finder.rs
@@ -9,7 +9,8 @@ use client::ChannelId;
use collections::HashMap;
use editor::Editor;
use file_icons::FileIcons;
-use fuzzy::{CharBag, PathMatch, PathMatchCandidate, StringMatch, StringMatchCandidate};
+use fuzzy::{StringMatch, StringMatchCandidate};
+use fuzzy_nucleo::{PathMatch, PathMatchCandidate};
use gpui::{
Action, AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
KeyContext, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task, WeakEntity,
@@ -663,15 +664,6 @@ impl Matches {
// For file-vs-file matches, use the existing detailed comparison.
if let (Some(a_panel), Some(b_panel)) = (a.panel_match(), b.panel_match()) {
- let a_in_filename = Self::is_filename_match(a_panel);
- let b_in_filename = Self::is_filename_match(b_panel);
-
- match (a_in_filename, b_in_filename) {
- (true, false) => return cmp::Ordering::Greater,
- (false, true) => return cmp::Ordering::Less,
- _ => {}
- }
-
return a_panel.cmp(b_panel);
}
@@ -691,32 +683,6 @@ impl Matches {
Match::CreateNew(_) => 0.0,
}
}
-
- /// Determines if the match occurred within the filename rather than in the path
- fn is_filename_match(panel_match: &ProjectPanelOrdMatch) -> bool {
- if panel_match.0.positions.is_empty() {
- return false;
- }
-
- if let Some(filename) = panel_match.0.path.file_name() {
- let path_str = panel_match.0.path.as_unix_str();
-
- if let Some(filename_pos) = path_str.rfind(filename)
- && panel_match.0.positions[0] >= filename_pos
- {
- let mut prev_position = panel_match.0.positions[0];
- for p in &panel_match.0.positions[1..] {
- if *p != prev_position + 1 {
- return false;
- }
- prev_position = *p;
- }
- return true;
- }
- }
-
- false
- }
}
fn matching_history_items<'a>(
@@ -731,25 +697,16 @@ fn matching_history_items<'a>(
let history_items_by_worktrees = history_items
.into_iter()
.chain(currently_opened)
- .filter_map(|found_path| {
+ .map(|found_path| {
let candidate = PathMatchCandidate {
is_dir: false, // You can't open directories as project items
path: &found_path.project.path,
// Only match history items names, otherwise their paths may match too many queries, producing false positives.
// E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
// it would be shown first always, despite the latter being a better match.
- char_bag: CharBag::from_iter(
- found_path
- .project
- .path
- .file_name()?
- .to_string()
- .to_lowercase()
- .chars(),
- ),
};
candidates_paths.insert(&found_path.project, found_path);
- Some((found_path.project.worktree_id, candidate))
+ (found_path.project.worktree_id, candidate)
})
.fold(
HashMap::default(),
@@ -767,8 +724,9 @@ fn matching_history_items<'a>(
let worktree_root_name = worktree_name_by_id
.as_ref()
.and_then(|w| w.get(&worktree).cloned());
+
matching_history_paths.extend(
- fuzzy::match_fixed_path_set(
+ fuzzy_nucleo::match_fixed_path_set(
candidates,
worktree.to_usize(),
worktree_root_name,
@@ -778,6 +736,18 @@ fn matching_history_items<'a>(
path_style,
)
.into_iter()
+ // filter matches where at least one matched position is in filename portion, to prevent directory matches, nucleo scores them higher as history items are matched against their full path
+ .filter(|path_match| {
+ if let Some(filename) = path_match.path.file_name() {
+ let filename_start = path_match.path.as_unix_str().len() - filename.len();
+ path_match
+ .positions
+ .iter()
+ .any(|&pos| pos >= filename_start)
+ } else {
+ true
+ }
+ })
.filter_map(|path_match| {
candidates_paths
.remove_entry(&ProjectPath {
@@ -940,7 +910,7 @@ impl FileFinderDelegate {
self.cancel_flag = Arc::new(AtomicBool::new(false));
let cancel_flag = self.cancel_flag.clone();
cx.spawn_in(window, async move |picker, cx| {
- let matches = fuzzy::match_path_sets(
+ let matches = fuzzy_nucleo::match_path_sets(
candidate_sets.as_slice(),
query.path_query(),
&relative_to,
@@ -1452,7 +1422,6 @@ impl PickerDelegate for FileFinderDelegate {
window: &mut Window,
cx: &mut Context>,
) -> Task<()> {
- let raw_query = raw_query.replace(' ', "");
let raw_query = raw_query.trim();
let raw_query = match &raw_query.get(0..2) {
diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs
index cd9cdeee1ff266717d380aeaecf7cbeb66ec8309..7a17202a5e4ba96b001ea46ed310518d02baf1ff 100644
--- a/crates/file_finder/src/file_finder_tests.rs
+++ b/crates/file_finder/src/file_finder_tests.rs
@@ -4161,3 +4161,233 @@ async fn test_clear_navigation_history(cx: &mut TestAppContext) {
"Should have no history items after clearing"
);
}
+
+#[gpui::test]
+async fn test_order_independent_search(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "internal": {
+ "auth": {
+ "login.rs": "",
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ // forward order
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("auth internal"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert_eq!(matches.len(), 1);
+ assert_eq!(matches[0].path.as_unix_str(), "internal/auth/login.rs");
+ });
+
+ // reverse order should give same result
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("internal auth"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert_eq!(matches.len(), 1);
+ assert_eq!(matches[0].path.as_unix_str(), "internal/auth/login.rs");
+ });
+}
+
+#[gpui::test]
+async fn test_filename_preferred_over_directory_match(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "crates": {
+ "settings_ui": {
+ "src": {
+ "pages": {
+ "audio_test_window.rs": "",
+ "audio_input_output_setup.rs": "",
+ }
+ }
+ },
+ "audio": {
+ "src": {
+ "audio_settings.rs": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("settings audio"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "crates/audio/src/audio_settings.rs"
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_start_of_word_preferred_over_scattered_match(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "crates": {
+ "livekit_client": {
+ "src": {
+ "livekit_client": {
+ "playback.rs": "",
+ }
+ }
+ },
+ "vim": {
+ "test_data": {
+ "test_record_replay_interleaved.json": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("live pla"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "crates/livekit_client/src/livekit_client/playback.rs",
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_exact_filename_stem_preferred(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "assets": {
+ "icons": {
+ "file_icons": {
+ "nix.svg": "",
+ }
+ }
+ },
+ "crates": {
+ "zed": {
+ "resources": {
+ "app-icon-nightly@2x.png": "",
+ "app-icon-preview@2x.png": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("nix icon"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "assets/icons/file_icons/nix.svg",
+ );
+ });
+}
+
+#[gpui::test]
+async fn test_exact_filename_with_directory_token(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "crates": {
+ "agent_servers": {
+ "src": {
+ "acp.rs": "",
+ "agent_server.rs": "",
+ "custom.rs": "",
+ }
+ }
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (picker, _, cx) = build_find_picker(project, cx);
+
+ picker
+ .update_in(cx, |picker, window, cx| {
+ picker
+ .delegate
+ .spawn_search(test_path_position("acp server"), window, cx)
+ })
+ .await;
+ picker.update(cx, |picker, _| {
+ let matches = collect_search_matches(picker).search_matches_only();
+ assert!(!matches.is_empty(),);
+ assert_eq!(
+ matches[0].path.as_unix_str(),
+ "crates/agent_servers/src/acp.rs",
+ );
+ });
+}
diff --git a/crates/fuzzy_nucleo/Cargo.toml b/crates/fuzzy_nucleo/Cargo.toml
new file mode 100644
index 0000000000000000000000000000000000000000..59e8b642524777f449f79edba85093eef069ebff
--- /dev/null
+++ b/crates/fuzzy_nucleo/Cargo.toml
@@ -0,0 +1,21 @@
+[package]
+name = "fuzzy_nucleo"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/fuzzy_nucleo.rs"
+doctest = false
+
+[dependencies]
+nucleo.workspace = true
+gpui.workspace = true
+util.workspace = true
+
+[dev-dependencies]
+util = {workspace = true, features = ["test-support"]}
diff --git a/crates/fuzzy_nucleo/LICENSE-GPL b/crates/fuzzy_nucleo/LICENSE-GPL
new file mode 120000
index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4
--- /dev/null
+++ b/crates/fuzzy_nucleo/LICENSE-GPL
@@ -0,0 +1 @@
+../../LICENSE-GPL
\ No newline at end of file
diff --git a/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ddaa5c3489cf55d41d31440f037214b1dce0358c
--- /dev/null
+++ b/crates/fuzzy_nucleo/src/fuzzy_nucleo.rs
@@ -0,0 +1,5 @@
+mod matcher;
+mod paths;
+pub use paths::{
+ PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets,
+};
diff --git a/crates/fuzzy_nucleo/src/matcher.rs b/crates/fuzzy_nucleo/src/matcher.rs
new file mode 100644
index 0000000000000000000000000000000000000000..b31da011106341420095bcffbfd012f40014ad6c
--- /dev/null
+++ b/crates/fuzzy_nucleo/src/matcher.rs
@@ -0,0 +1,39 @@
+use std::sync::Mutex;
+
+static MATCHERS: Mutex> = Mutex::new(Vec::new());
+
+pub const LENGTH_PENALTY: f64 = 0.01;
+
+pub fn get_matcher(config: nucleo::Config) -> nucleo::Matcher {
+ let mut matchers = MATCHERS.lock().unwrap();
+ match matchers.pop() {
+ Some(mut matcher) => {
+ matcher.config = config;
+ matcher
+ }
+ None => nucleo::Matcher::new(config),
+ }
+}
+
+pub fn return_matcher(matcher: nucleo::Matcher) {
+ MATCHERS.lock().unwrap().push(matcher);
+}
+
+pub fn get_matchers(n: usize, config: nucleo::Config) -> Vec {
+ let mut matchers: Vec<_> = {
+ let mut pool = MATCHERS.lock().unwrap();
+ let available = pool.len().min(n);
+ pool.drain(..available)
+ .map(|mut matcher| {
+ matcher.config = config.clone();
+ matcher
+ })
+ .collect()
+ };
+ matchers.resize_with(n, || nucleo::Matcher::new(config.clone()));
+ matchers
+}
+
+pub fn return_matchers(mut matchers: Vec) {
+ MATCHERS.lock().unwrap().append(&mut matchers);
+}
diff --git a/crates/fuzzy_nucleo/src/paths.rs b/crates/fuzzy_nucleo/src/paths.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ac766622c9d12c6e2a119fbcd7dd7fe7a3b5a90d
--- /dev/null
+++ b/crates/fuzzy_nucleo/src/paths.rs
@@ -0,0 +1,352 @@
+use gpui::BackgroundExecutor;
+use std::{
+ cmp::Ordering,
+ sync::{
+ Arc,
+ atomic::{self, AtomicBool},
+ },
+};
+use util::{paths::PathStyle, rel_path::RelPath};
+
+use nucleo::Utf32Str;
+use nucleo::pattern::{Atom, AtomKind, CaseMatching, Normalization};
+
+use crate::matcher::{self, LENGTH_PENALTY};
+
+#[derive(Clone, Debug)]
+pub struct PathMatchCandidate<'a> {
+ pub is_dir: bool,
+ pub path: &'a RelPath,
+}
+
+#[derive(Clone, Debug)]
+pub struct PathMatch {
+ pub score: f64,
+ pub positions: Vec,
+ pub worktree_id: usize,
+ pub path: Arc,
+ pub path_prefix: Arc,
+ pub is_dir: bool,
+ /// Number of steps removed from a shared parent with the relative path
+ /// Used to order closer paths first in the search list
+ pub distance_to_relative_ancestor: usize,
+}
+
+pub trait PathMatchCandidateSet<'a>: Send + Sync {
+ type Candidates: Iterator- >;
+ fn id(&self) -> usize;
+ fn len(&self) -> usize;
+ fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+ fn root_is_file(&self) -> bool;
+ fn prefix(&self) -> Arc;
+ fn candidates(&'a self, start: usize) -> Self::Candidates;
+ fn path_style(&self) -> PathStyle;
+}
+
+impl PartialEq for PathMatch {
+ fn eq(&self, other: &Self) -> bool {
+ self.cmp(other).is_eq()
+ }
+}
+
+impl Eq for PathMatch {}
+
+impl PartialOrd for PathMatch {
+ fn partial_cmp(&self, other: &Self) -> Option {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for PathMatch {
+ fn cmp(&self, other: &Self) -> Ordering {
+ self.score
+ .partial_cmp(&other.score)
+ .unwrap_or(Ordering::Equal)
+ .then_with(|| self.worktree_id.cmp(&other.worktree_id))
+ .then_with(|| {
+ other
+ .distance_to_relative_ancestor
+ .cmp(&self.distance_to_relative_ancestor)
+ })
+ .then_with(|| self.path.cmp(&other.path))
+ }
+}
+
+fn make_atoms(query: &str, smart_case: bool) -> Vec {
+ let case = if smart_case {
+ CaseMatching::Smart
+ } else {
+ CaseMatching::Ignore
+ };
+ query
+ .split_whitespace()
+ .map(|word| Atom::new(word, case, Normalization::Smart, AtomKind::Fuzzy, false))
+ .collect()
+}
+
+pub(crate) fn distance_between_paths(path: &RelPath, relative_to: &RelPath) -> usize {
+ let mut path_components = path.components();
+ let mut relative_components = relative_to.components();
+
+ while path_components
+ .next()
+ .zip(relative_components.next())
+ .map(|(path_component, relative_component)| path_component == relative_component)
+ .unwrap_or_default()
+ {}
+ path_components.count() + relative_components.count() + 1
+}
+
+fn get_filename_match_bonus(
+ candidate_buf: &str,
+ query_atoms: &[Atom],
+ matcher: &mut nucleo::Matcher,
+) -> f64 {
+ let filename = match std::path::Path::new(candidate_buf).file_name() {
+ Some(f) => f.to_str().unwrap_or(""),
+ None => return 0.0,
+ };
+ if filename.is_empty() || query_atoms.is_empty() {
+ return 0.0;
+ }
+ let mut buf = Vec::new();
+ let haystack = Utf32Str::new(filename, &mut buf);
+ let mut total_score = 0u32;
+ for atom in query_atoms {
+ if let Some(score) = atom.score(haystack, matcher) {
+ total_score = total_score.saturating_add(score as u32);
+ }
+ }
+ total_score as f64 / filename.len().max(1) as f64
+}
+struct Cancelled;
+
+fn path_match_helper<'a>(
+ matcher: &mut nucleo::Matcher,
+ atoms: &[Atom],
+ candidates: impl Iterator
- >,
+ results: &mut Vec,
+ worktree_id: usize,
+ path_prefix: &Arc,
+ root_is_file: bool,
+ relative_to: &Option>,
+ path_style: PathStyle,
+ cancel_flag: &AtomicBool,
+) -> Result<(), Cancelled> {
+ let mut candidate_buf = if !path_prefix.is_empty() && !root_is_file {
+ let mut s = path_prefix.display(path_style).to_string();
+ s.push_str(path_style.primary_separator());
+ s
+ } else {
+ String::new()
+ };
+ let path_prefix_len = candidate_buf.len();
+ let mut buf = Vec::new();
+ let mut matched_chars: Vec = Vec::new();
+ let mut atom_matched_chars = Vec::new();
+ for candidate in candidates {
+ buf.clear();
+ matched_chars.clear();
+ if cancel_flag.load(atomic::Ordering::Relaxed) {
+ return Err(Cancelled);
+ }
+
+ candidate_buf.truncate(path_prefix_len);
+ if root_is_file {
+ candidate_buf.push_str(path_prefix.as_unix_str());
+ } else {
+ candidate_buf.push_str(candidate.path.as_unix_str());
+ }
+
+ let haystack = Utf32Str::new(&candidate_buf, &mut buf);
+
+ let mut total_score: u32 = 0;
+ let mut all_matched = true;
+
+ for atom in atoms {
+ atom_matched_chars.clear();
+ if let Some(score) = atom.indices(haystack, matcher, &mut atom_matched_chars) {
+ total_score = total_score.saturating_add(score as u32);
+ matched_chars.extend_from_slice(&atom_matched_chars);
+ } else {
+ all_matched = false;
+ break;
+ }
+ }
+
+ if all_matched && !atoms.is_empty() {
+ matched_chars.sort_unstable();
+ matched_chars.dedup();
+
+ let length_penalty = candidate_buf.len() as f64 * LENGTH_PENALTY;
+ let filename_bonus = get_filename_match_bonus(&candidate_buf, atoms, matcher);
+ let adjusted_score = total_score as f64 + filename_bonus - length_penalty;
+ let mut positions: Vec = candidate_buf
+ .char_indices()
+ .enumerate()
+ .filter_map(|(char_offset, (byte_offset, _))| {
+ matched_chars
+ .contains(&(char_offset as u32))
+ .then_some(byte_offset)
+ })
+ .collect();
+ positions.sort_unstable();
+
+ results.push(PathMatch {
+ score: adjusted_score,
+ positions,
+ worktree_id,
+ path: if root_is_file {
+ Arc::clone(path_prefix)
+ } else {
+ candidate.path.into()
+ },
+ path_prefix: if root_is_file {
+ RelPath::empty().into()
+ } else {
+ Arc::clone(path_prefix)
+ },
+ is_dir: candidate.is_dir,
+ distance_to_relative_ancestor: relative_to
+ .as_ref()
+ .map_or(usize::MAX, |relative_to| {
+ distance_between_paths(candidate.path, relative_to.as_ref())
+ }),
+ });
+ }
+ }
+ Ok(())
+}
+
+pub fn match_fixed_path_set(
+ candidates: Vec,
+ worktree_id: usize,
+ worktree_root_name: Option>,
+ query: &str,
+ smart_case: bool,
+ max_results: usize,
+ path_style: PathStyle,
+) -> Vec {
+ let mut config = nucleo::Config::DEFAULT;
+ config.set_match_paths();
+ let mut matcher = matcher::get_matcher(config);
+
+ let atoms = make_atoms(query, smart_case);
+
+ let root_is_file = worktree_root_name.is_some() && candidates.iter().all(|c| c.path.is_empty());
+
+ let path_prefix = worktree_root_name.unwrap_or_else(|| RelPath::empty().into());
+
+ let mut results = Vec::new();
+
+ path_match_helper(
+ &mut matcher,
+ &atoms,
+ candidates.into_iter(),
+ &mut results,
+ worktree_id,
+ &path_prefix,
+ root_is_file,
+ &None,
+ path_style,
+ &AtomicBool::new(false),
+ )
+ .ok();
+ util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a));
+ matcher::return_matcher(matcher);
+ results
+}
+
+pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
+ candidate_sets: &'a [Set],
+ query: &str,
+ relative_to: &Option>,
+ smart_case: bool,
+ max_results: usize,
+ cancel_flag: &AtomicBool,
+ executor: BackgroundExecutor,
+) -> Vec {
+ let path_count: usize = candidate_sets.iter().map(|s| s.len()).sum();
+ if path_count == 0 {
+ return Vec::new();
+ }
+
+ let path_style = candidate_sets[0].path_style();
+
+ let query = if path_style.is_windows() {
+ query.replace('\\', "/")
+ } else {
+ query.to_owned()
+ };
+
+ let atoms = make_atoms(&query, smart_case);
+
+ let num_cpus = executor.num_cpus().min(path_count);
+ let segment_size = path_count.div_ceil(num_cpus);
+ let mut segment_results = (0..num_cpus)
+ .map(|_| Vec::with_capacity(max_results))
+ .collect::>();
+ let mut config = nucleo::Config::DEFAULT;
+ config.set_match_paths();
+ let mut matchers = matcher::get_matchers(num_cpus, config);
+ executor
+ .scoped(|scope| {
+ for (segment_idx, (results, matcher)) in segment_results
+ .iter_mut()
+ .zip(matchers.iter_mut())
+ .enumerate()
+ {
+ let atoms = atoms.clone();
+ let relative_to = relative_to.clone();
+ scope.spawn(async move {
+ let segment_start = segment_idx * segment_size;
+ let segment_end = segment_start + segment_size;
+
+ let mut tree_start = 0;
+ for candidate_set in candidate_sets {
+ let tree_end = tree_start + candidate_set.len();
+
+ if tree_start < segment_end && segment_start < tree_end {
+ let start = tree_start.max(segment_start) - tree_start;
+ let end = tree_end.min(segment_end) - tree_start;
+ let candidates = candidate_set.candidates(start).take(end - start);
+
+ if path_match_helper(
+ matcher,
+ &atoms,
+ candidates,
+ results,
+ candidate_set.id(),
+ &candidate_set.prefix(),
+ candidate_set.root_is_file(),
+ &relative_to,
+ path_style,
+ cancel_flag,
+ )
+ .is_err()
+ {
+ break;
+ }
+ }
+
+ if tree_end >= segment_end {
+ break;
+ }
+ tree_start = tree_end;
+ }
+ });
+ }
+ })
+ .await;
+
+ matcher::return_matchers(matchers);
+ if cancel_flag.load(atomic::Ordering::Acquire) {
+ return Vec::new();
+ }
+
+ let mut results = segment_results.concat();
+ util::truncate_to_bottom_n_sorted_by(&mut results, max_results, &|a, b| b.cmp(a));
+ results
+}
diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml
index cd037786a399eb979fd5d9053c57efe3100dd473..628e979aab939a74bb4838477ae3e3657e2c91bc 100644
--- a/crates/project/Cargo.toml
+++ b/crates/project/Cargo.toml
@@ -52,6 +52,7 @@ fancy-regex.workspace = true
fs.workspace = true
futures.workspace = true
fuzzy.workspace = true
+fuzzy_nucleo.workspace = true
git.workspace = true
git_hosting_providers.workspace = true
globset.workspace = true
diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs
index 0ec3366ca8f9f6c6e4e3cbd411e1894de4d0f2b8..b90972b3489c25f8a2bf10d7dbdb6d6cfe0c4c6c 100644
--- a/crates/project/src/project.rs
+++ b/crates/project/src/project.rs
@@ -6186,6 +6186,76 @@ impl<'a> Iterator for PathMatchCandidateSetIter<'a> {
}
}
+impl<'a> fuzzy_nucleo::PathMatchCandidateSet<'a> for PathMatchCandidateSet {
+ type Candidates = PathMatchCandidateSetNucleoIter<'a>;
+ fn id(&self) -> usize {
+ self.snapshot.id().to_usize()
+ }
+ fn len(&self) -> usize {
+ match self.candidates {
+ Candidates::Files => {
+ if self.include_ignored {
+ self.snapshot.file_count()
+ } else {
+ self.snapshot.visible_file_count()
+ }
+ }
+ Candidates::Directories => {
+ if self.include_ignored {
+ self.snapshot.dir_count()
+ } else {
+ self.snapshot.visible_dir_count()
+ }
+ }
+ Candidates::Entries => {
+ if self.include_ignored {
+ self.snapshot.entry_count()
+ } else {
+ self.snapshot.visible_entry_count()
+ }
+ }
+ }
+ }
+ fn prefix(&self) -> Arc {
+ if self.snapshot.root_entry().is_some_and(|e| e.is_file()) || self.include_root_name {
+ self.snapshot.root_name().into()
+ } else {
+ RelPath::empty().into()
+ }
+ }
+ fn root_is_file(&self) -> bool {
+ self.snapshot.root_entry().is_some_and(|f| f.is_file())
+ }
+ fn path_style(&self) -> PathStyle {
+ self.snapshot.path_style()
+ }
+ fn candidates(&'a self, start: usize) -> Self::Candidates {
+ PathMatchCandidateSetNucleoIter {
+ traversal: match self.candidates {
+ Candidates::Directories => self.snapshot.directories(self.include_ignored, start),
+ Candidates::Files => self.snapshot.files(self.include_ignored, start),
+ Candidates::Entries => self.snapshot.entries(self.include_ignored, start),
+ },
+ }
+ }
+}
+
+pub struct PathMatchCandidateSetNucleoIter<'a> {
+ traversal: Traversal<'a>,
+}
+
+impl<'a> Iterator for PathMatchCandidateSetNucleoIter<'a> {
+ type Item = fuzzy_nucleo::PathMatchCandidate<'a>;
+ fn next(&mut self) -> Option {
+ self.traversal
+ .next()
+ .map(|entry| fuzzy_nucleo::PathMatchCandidate {
+ is_dir: entry.kind.is_dir(),
+ path: &entry.path,
+ })
+ }
+}
+
impl EventEmitter for Project {}
impl<'a> From<&'a ProjectPath> for SettingsLocation<'a> {
From 1dc3bb90e96be26cab72e7392c4042e1e5d0d71a Mon Sep 17 00:00:00 2001
From: Pratik Karki
Date: Tue, 7 Apr 2026 17:10:55 +0545
Subject: [PATCH 9/9] Fix pane::RevealInProjectPanel to focus/open project
panel for non-project buffers (#51246)
Update how `workspace::pane::Pane` handles the `RevealInProjectPanel`
action so as to display a notification when the user attempts to reveal
an unsaved buffer or a file that does not belong to any of the open
projects.
Closes #23967
Release Notes:
- Update `pane: reveal in project panel` to display a notification when
the user attempts to use it with an unsaved buffer or a file that is not
part of the open projects
---------
Signed-off-by: Pratik Karki
Co-authored-by: dino
---
.../project_panel/src/project_panel_tests.rs | 146 +++++++++++++++++-
crates/workspace/src/pane.rs | 66 +++++++-
2 files changed, 203 insertions(+), 9 deletions(-)
diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs
index 55b53cde8b6252f8b9732cf4effc35ea53c073e0..603cfd892a218d866383f485d058296ad179da05 100644
--- a/crates/project_panel/src/project_panel_tests.rs
+++ b/crates/project_panel/src/project_panel_tests.rs
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
use util::{path, paths::PathStyle, rel_path::rel_path};
use workspace::{
AppState, ItemHandle, MultiWorkspace, Pane, Workspace,
- item::{Item, ProjectItem},
+ item::{Item, ProjectItem, test::TestItem},
register_project_item,
};
@@ -6015,6 +6015,150 @@ async fn test_explicit_reveal(cx: &mut gpui::TestAppContext) {
);
}
+#[gpui::test]
+async fn test_reveal_in_project_panel_notifications(cx: &mut gpui::TestAppContext) {
+ init_test_with_editor(cx);
+ let fs = FakeFs::new(cx.background_executor.clone());
+ fs.insert_tree(
+ "/workspace",
+ json!({
+ "README.md": ""
+ }),
+ )
+ .await;
+
+ let project = Project::test(fs.clone(), ["/workspace".as_ref()], cx).await;
+ let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
+ let workspace = window
+ .read_with(cx, |mw, _| mw.workspace().clone())
+ .unwrap();
+ let cx = &mut VisualTestContext::from_window(window.into(), cx);
+ let panel = workspace.update_in(cx, ProjectPanel::new);
+ cx.run_until_parked();
+
+ // Ensure that, attempting to run `pane: reveal in project panel` without
+ // any active item does nothing, i.e., does not focus the project panel but
+ // it also does not show a notification.
+ cx.dispatch_action(workspace::RevealInProjectPanel::default());
+ cx.run_until_parked();
+
+ panel.update_in(cx, |panel, window, cx| {
+ assert!(
+ !panel.focus_handle(cx).is_focused(window),
+ "Project panel should not be focused after attempting to reveal an invisible worktree entry"
+ );
+
+ panel.workspace.update(cx, |workspace, cx| {
+ assert!(
+ workspace.active_item(cx).is_none(),
+ "Workspace should not have an active item"
+ );
+ assert_eq!(
+ workspace.notification_ids(),
+ vec![],
+ "No notification should be shown when there's no active item"
+ );
+ }).unwrap();
+ });
+
+ // Create a file in a different folder than the one in the project so we can
+ // later open it and ensure that, attempting to reveal it in the project
+ // panel shows a notification and does not focus the project panel.
+ fs.insert_tree(
+ "/external",
+ json!({
+ "file.txt": "External File",
+ }),
+ )
+ .await;
+
+ let (worktree, _) = project
+ .update(cx, |project, cx| {
+ project.find_or_create_worktree("/external/file.txt", false, cx)
+ })
+ .await
+ .unwrap();
+
+ workspace
+ .update_in(cx, |workspace, window, cx| {
+ let worktree_id = worktree.read(cx).id();
+ let path = rel_path("").into();
+ let project_path = ProjectPath { worktree_id, path };
+
+ workspace.open_path(project_path, None, true, window, cx)
+ })
+ .await
+ .unwrap();
+ cx.run_until_parked();
+
+ cx.dispatch_action(workspace::RevealInProjectPanel::default());
+ cx.run_until_parked();
+
+ panel.update_in(cx, |panel, window, cx| {
+ assert!(
+ !panel.focus_handle(cx).is_focused(window),
+ "Project panel should not be focused after attempting to reveal an invisible worktree entry"
+ );
+
+ panel.workspace.update(cx, |workspace, cx| {
+ assert!(
+ workspace.active_item(cx).is_some(),
+ "Workspace should have an active item"
+ );
+
+ let notification_ids = workspace.notification_ids();
+ assert_eq!(
+ notification_ids.len(),
+ 1,
+ "A notification should be shown when trying to reveal an invisible worktree entry"
+ );
+
+ workspace.dismiss_notification(¬ification_ids[0], cx);
+ assert_eq!(
+ workspace.notification_ids().len(),
+ 0,
+ "No notifications should be left after dismissing"
+ );
+ }).unwrap();
+ });
+
+ // Create an empty buffer so we can ensure that, attempting to reveal it in
+ // the project panel shows a notification and does not focus the project
+ // panel.
+ let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone());
+ pane.update_in(cx, |pane, window, cx| {
+ let item = cx.new(|cx| TestItem::new(cx).with_label("Unsaved buffer"));
+ pane.add_item(Box::new(item), false, false, None, window, cx);
+ });
+
+ cx.dispatch_action(workspace::RevealInProjectPanel::default());
+ cx.run_until_parked();
+
+ panel.update_in(cx, |panel, window, cx| {
+ assert!(
+ !panel.focus_handle(cx).is_focused(window),
+ "Project panel should not be focused after attempting to reveal an unsaved buffer"
+ );
+
+ panel
+ .workspace
+ .update(cx, |workspace, cx| {
+ assert!(
+ workspace.active_item(cx).is_some(),
+ "Workspace should have an active item"
+ );
+
+ let notification_ids = workspace.notification_ids();
+ assert_eq!(
+ notification_ids.len(),
+ 1,
+ "A notification should be shown when trying to reveal an unsaved buffer"
+ );
+ })
+ .unwrap();
+ });
+}
+
#[gpui::test]
async fn test_creating_excluded_entries(cx: &mut gpui::TestAppContext) {
init_test(cx);
diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs
index 27cc96ae80a010db2dd5357a9a0bc037ca762875..a09ba73add7e94fbe6910eb400b1364bd21cd313 100644
--- a/crates/workspace/src/pane.rs
+++ b/crates/workspace/src/pane.rs
@@ -10,7 +10,10 @@ use crate::{
TabContentParams, TabTooltipContent, WeakItemHandle,
},
move_item,
- notifications::NotifyResultExt,
+ notifications::{
+ NotificationId, NotifyResultExt, show_app_notification,
+ simple_message_notification::MessageNotification,
+ },
toolbar::Toolbar,
workspace_settings::{AutosaveSetting, FocusFollowsMouse, TabBarSettings, WorkspaceSettings},
};
@@ -4400,17 +4403,64 @@ impl Render for Pane {
))
.on_action(
cx.listener(|pane: &mut Self, action: &RevealInProjectPanel, _, cx| {
+ let Some(active_item) = pane.active_item() else {
+ return;
+ };
+
let entry_id = action
.entry_id
.map(ProjectEntryId::from_proto)
- .or_else(|| pane.active_item()?.project_entry_ids(cx).first().copied());
- if let Some(entry_id) = entry_id {
- pane.project
- .update(cx, |_, cx| {
- cx.emit(project::Event::RevealInProjectPanel(entry_id))
- })
- .ok();
+ .or_else(|| active_item.project_entry_ids(cx).first().copied());
+
+ let show_reveal_error_toast = |display_name: &str, cx: &mut App| {
+ let notification_id = NotificationId::unique::();
+ let message = SharedString::from(format!(
+ "\"{display_name}\" is not part of any open projects."
+ ));
+
+ show_app_notification(notification_id, cx, move |cx| {
+ let message = message.clone();
+ cx.new(|cx| MessageNotification::new(message, cx))
+ });
+ };
+
+ let Some(entry_id) = entry_id else {
+ // When working with an unsaved buffer, display a toast
+ // informing the user that the buffer is not present in
+ // any of the open projects and stop execution, as we
+ // don't want to open the project panel.
+ let display_name = active_item
+ .tab_tooltip_text(cx)
+ .unwrap_or_else(|| active_item.tab_content_text(0, cx));
+
+ return show_reveal_error_toast(&display_name, cx);
+ };
+
+ // We'll now check whether the entry belongs to a visible
+ // worktree and, if that's not the case, it means the user
+ // is interacting with a file that does not belong to any of
+ // the open projects, so we'll show a toast informing them
+ // of this and stop execution.
+ let display_name = pane
+ .project
+ .read_with(cx, |project, cx| {
+ project
+ .worktree_for_entry(entry_id, cx)
+ .filter(|worktree| !worktree.read(cx).is_visible())
+ .map(|worktree| worktree.read(cx).root_name_str().to_string())
+ })
+ .ok()
+ .flatten();
+
+ if let Some(display_name) = display_name {
+ return show_reveal_error_toast(&display_name, cx);
}
+
+ pane.project
+ .update(cx, |_, cx| {
+ cx.emit(project::Event::RevealInProjectPanel(entry_id))
+ })
+ .log_err();
}),
)
.on_action(cx.listener(|_, _: &menu::Cancel, window, cx| {