Detailed changes
@@ -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",
@@ -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" ] }
@@ -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<SharedString> {
+ 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<String>,
+ 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<ActiveView>,
background_threads: HashMap<acp::SessionId, Entity<ConversationView>>,
new_thread_menu_handle: PopoverMenuHandle<ContextMenu>,
- start_thread_in_menu_handle: PopoverMenuHandle<ContextMenu>,
+ start_thread_in_menu_handle: PopoverMenuHandle<ThreadWorktreePicker>,
+ thread_branch_menu_handle: PopoverMenuHandle<ThreadBranchPicker>,
agent_panel_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu_handle: PopoverMenuHandle<ContextMenu>,
agent_navigation_menu: Option<Entity<ContextMenu>>,
@@ -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::<AgentV2FeatureFlag>();
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<Self>,
) {
- if matches!(action, StartThreadIn::NewWorktree) && !cx.has_flag::<AgentV2FeatureFlag>() {
- return;
- }
-
- let new_target = match *action {
+ let new_target = match action {
StartThreadIn::LocalProject => StartThreadIn::LocalProject,
- StartThreadIn::NewWorktree => {
+ StartThreadIn::NewWorktree { .. } => {
+ if !cx.has_flag::<AgentV2FeatureFlag>() {
+ 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::<AgentV2FeatureFlag>() {
+ 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<Self>) {
- 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<Self>,
) {
- 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<String>,
+ occupied_branches: &HashSet<String>,
+ ) -> Result<(String, bool, Option<String>)> {
+ let generate_branch_name = || -> Result<String> {
+ 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<project::git_store::Repository>],
+ worktree_name: Option<String>,
branch_name: &str,
+ use_existing_branch: bool,
+ start_point: Option<String>,
worktree_directory_setting: &str,
cx: &mut Context<Self>,
) -> 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<acp::ContentBlock>,
+ args: WorktreeCreationArgs,
window: &mut Window,
cx: &mut Context<Self>,
) {
@@ -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::<Vec<_>>(),
+ ),
+ Some(
+ git_repos
+ .iter()
+ .map(|repo| repo.update(cx, |repo, _cx| repo.worktrees()))
+ .collect::<Vec<_>>(),
+ ),
+ 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<Self>,
all_paths: Vec<PathBuf>,
app_state: Arc<workspace::AppState>,
@@ -3149,25 +3346,15 @@ impl AgentPanel {
}
fn render_start_thread_in_selector(&self, cx: &mut Context<Self>) -> 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<Self>) -> 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.
@@ -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<String>,
+ },
+}
+
/// 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<String>,
+ #[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.
@@ -869,7 +869,10 @@ impl ThreadView {
.upgrade()
.and_then(|workspace| workspace.read(cx).panel::<AgentPanel>(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 {
@@ -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<Picker<ThreadBranchPickerDelegate>>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadBranchPicker {
+ pub fn new(
+ project: Entity<Project>,
+ current_target: &StartThreadIn,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let project_worktree_paths: HashSet<PathBuf> = 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<DismissEvent> for ThreadBranchPicker {}
+
+impl Render for ThreadBranchPicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<usize>,
+ occupied_reason: Option<String>,
+ },
+ CreateNamed {
+ name: String,
+ },
+}
+
+pub(crate) struct ThreadBranchPickerDelegate {
+ matches: Vec<ThreadBranchEntry>,
+ all_branches: Option<Vec<GitBranch>>,
+ occupied_branches: Option<HashMap<String, String>>,
+ selected_index: usize,
+ worktree_name: Option<String>,
+ branch_target: NewWorktreeBranchTarget,
+ project_worktree_paths: HashSet<PathBuf>,
+ current_branch_name: String,
+ default_branch_name: Option<String>,
+ 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<ThreadBranchEntry> {
+ 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<SharedString> {
+ 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<str> {
+ "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<Picker<Self>>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> 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<Picker<Self>>) {
+ 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<Picker<Self>>) {}
+
+ fn separators_after_indices(&self) -> Vec<usize> {
+ 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<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ 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<usize> =
+ 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<SharedString> {
+ None
+ }
+}
@@ -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<Picker<ThreadWorktreePickerDelegate>>,
+ focus_handle: FocusHandle,
+ _subscription: gpui::Subscription,
+}
+
+impl ThreadWorktreePicker {
+ pub fn new(
+ project: Entity<Project>,
+ current_target: &StartThreadIn,
+ fs: Arc<dyn Fs>,
+ window: &mut Window,
+ cx: &mut Context<Self>,
+ ) -> Self {
+ let project_worktree_paths: Vec<PathBuf> = 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<DismissEvent> for ThreadWorktreePicker {}
+
+impl Render for ThreadWorktreePicker {
+ fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<usize>,
+ },
+ CreateNamed {
+ name: String,
+ disabled_reason: Option<String>,
+ },
+}
+
+pub(crate) struct ThreadWorktreePickerDelegate {
+ matches: Vec<ThreadWorktreeEntry>,
+ all_worktrees: Vec<(RepositoryId, Arc<[GitWorktree]>)>,
+ project_worktree_paths: Vec<PathBuf>,
+ selected_index: usize,
+ preserved_branch_target: NewWorktreeBranchTarget,
+ project: Entity<Project>,
+ fs: Arc<dyn Fs>,
+}
+
+impl ThreadWorktreePickerDelegate {
+ fn new_worktree_action(&self, worktree_name: Option<String>) -> 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<str> {
+ "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<Picker<Self>>,
+ ) {
+ self.selected_index = ix;
+ }
+
+ fn separators_after_indices(&self) -> Vec<usize> {
+ if self.matches.len() > 2 {
+ vec![1]
+ } else {
+ Vec::new()
+ }
+ }
+
+ fn update_matches(
+ &mut self,
+ query: String,
+ window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> 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<Picker<Self>>) {
+ 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<Picker<Self>>) {}
+
+ fn render_match(
+ &self,
+ ix: usize,
+ selected: bool,
+ _window: &mut Window,
+ cx: &mut Context<Picker<Self>>,
+ ) -> Option<Self::ListItem> {
+ 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<SharedString> {
+ None
+ }
+}
@@ -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,
@@ -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,
)
})
})
@@ -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()),
)
})
})
@@ -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.))
@@ -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
@@ -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<Picker<Self>>,
) -> Task<()> {
- let raw_query = raw_query.replace(' ', "");
let raw_query = raw_query.trim();
let raw_query = match &raw_query.get(0..2) {
@@ -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",
+ );
+ });
+}
@@ -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<String>,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option<String>,
) -> 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/<name>/ 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()
@@ -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();
@@ -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"]}
@@ -0,0 +1 @@
+../../LICENSE-GPL
@@ -0,0 +1,5 @@
+mod matcher;
+mod paths;
+pub use paths::{
+ PathMatch, PathMatchCandidate, PathMatchCandidateSet, match_fixed_path_set, match_path_sets,
+};
@@ -0,0 +1,39 @@
+use std::sync::Mutex;
+
+static MATCHERS: Mutex<Vec<nucleo::Matcher>> = 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<nucleo::Matcher> {
+ 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<nucleo::Matcher>) {
+ MATCHERS.lock().unwrap().append(&mut matchers);
+}
@@ -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<usize>,
+ pub worktree_id: usize,
+ pub path: Arc<RelPath>,
+ pub path_prefix: Arc<RelPath>,
+ 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<Item = PathMatchCandidate<'a>>;
+ 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<RelPath>;
+ 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<Ordering> {
+ 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<Atom> {
+ 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<Item = PathMatchCandidate<'a>>,
+ results: &mut Vec<PathMatch>,
+ worktree_id: usize,
+ path_prefix: &Arc<RelPath>,
+ root_is_file: bool,
+ relative_to: &Option<Arc<RelPath>>,
+ 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<u32> = 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<usize> = 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<PathMatchCandidate>,
+ worktree_id: usize,
+ worktree_root_name: Option<Arc<RelPath>>,
+ query: &str,
+ smart_case: bool,
+ max_results: usize,
+ path_style: PathStyle,
+) -> Vec<PathMatch> {
+ 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<Arc<RelPath>>,
+ smart_case: bool,
+ max_results: usize,
+ cancel_flag: &AtomicBool,
+ executor: BackgroundExecutor,
+) -> Vec<PathMatch> {
+ 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::<Vec<_>>();
+ 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
+}
@@ -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<String>,
+ },
+ /// 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<String>,
+ },
+}
+
+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<String>,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option<String>,
) -> 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<String>,
+ target: CreateWorktreeTarget,
path: PathBuf,
- from_commit: Option<String>,
) -> 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();
@@ -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??;
@@ -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
@@ -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<TextAlign>,
+ pub contents: HtmlParagraph,
+}
+
impl ParsedHtmlElement {
pub fn source_range(&self) -> Option<Range<usize>> {
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<usize>,
pub level: HeadingLevel,
pub contents: HtmlParagraph,
+ pub text_align: Option<TextAlign>,
}
#[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<String, String>) -> Option<HtmlHi
}
}
+fn parse_text_align(value: &str) -> Option<TextAlign> {
+ 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<String, String>) -> Option<TextAlign> {
+ styles
+ .get("text-align")
+ .and_then(|value| parse_text_align(value))
+}
+
+fn text_align_from_attributes(
+ attrs: &RefCell<Vec<Attribute>>,
+ styles: &HashMap<String, String>,
+) -> Option<TextAlign> {
+ 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<Vec<Attribute>>) -> HashMap<String, String> {
let mut styles = HashMap::new();
@@ -770,6 +807,7 @@ fn extract_html_table(node: &Node, source_range: Range<usize>) -> Option<ParsedH
#[cfg(test)]
mod tests {
use super::*;
+ use gpui::TextAlign;
#[test]
fn parses_html_styled_text() {
@@ -783,7 +821,7 @@ mod tests {
let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
panic!("expected paragraph");
};
- let HtmlParagraphChunk::Text(text) = ¶graph[0] else {
+ let HtmlParagraphChunk::Text(text) = ¶graph.contents[0] else {
panic!("expected text chunk");
};
@@ -851,7 +889,7 @@ mod tests {
let ParsedHtmlElement::Paragraph(paragraph) = &first_item.content[0] else {
panic!("expected first item paragraph");
};
- let HtmlParagraphChunk::Text(text) = ¶graph[0] else {
+ let HtmlParagraphChunk::Text(text) = ¶graph.contents[0] else {
panic!("expected first item text");
};
assert_eq!(text.contents.as_ref(), "parent");
@@ -866,7 +904,7 @@ mod tests {
else {
panic!("expected nested item paragraph");
};
- let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph[0] else {
+ let HtmlParagraphChunk::Text(nested_text) = &nested_paragraph.contents[0] else {
panic!("expected nested item text");
};
assert_eq!(nested_text.contents.as_ref(), "child");
@@ -875,9 +913,58 @@ mod tests {
let ParsedHtmlElement::Paragraph(second_paragraph) = &second_item.content[0] else {
panic!("expected second item paragraph");
};
- let HtmlParagraphChunk::Text(second_text) = &second_paragraph[0] else {
+ let HtmlParagraphChunk::Text(second_text) = &second_paragraph.contents[0] else {
panic!("expected second item text");
};
assert_eq!(second_text.contents.as_ref(), "sibling");
}
+
+ #[test]
+ fn parses_paragraph_text_align_from_style() {
+ let parsed = parse_html_block("<p style=\"text-align: center\">x</p>", 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("<h2 style=\"text-align: right\">Title</h2>", 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("<p align=\"center\">x</p>", 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("<h2 align=\"right\">Title</h2>", 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(
+ "<p align=\"left\" style=\"text-align: center\">x</p>",
+ 0..50,
+ )
+ .unwrap();
+ let ParsedHtmlElement::Paragraph(paragraph) = &parsed.children[0] else {
+ panic!("expected paragraph");
+ };
+ assert_eq!(paragraph.text_align, Some(TextAlign::Center));
+ }
}
@@ -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,
@@ -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<DefiniteLength>,
height: Option<DefiniteLength>,
) {
+ 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<usize>,
markdown_end: usize,
+ text_align_override: Option<TextAlign>,
) {
- 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<usize>,
markdown_end: usize,
+ text_align_override: Option<TextAlign>,
) {
+ 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);
@@ -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
@@ -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<SharedString>,
}
-#[derive(Clone, Debug)]
-enum CreateWorktreeStartPoint {
- Detached,
- Branched { name: String },
-}
-
pub struct Repository {
this: WeakEntity<Self>,
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<String>,
) -> oneshot::Receiver<Result<()>> {
- 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<String>,
- ) -> oneshot::Receiver<Result<()>> {
- 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<Result<()>> {
- self.create_worktree_with_start_point(
- CreateWorktreeStartPoint::Detached,
+ self.create_worktree(
+ CreateWorktreeTarget::Detached {
+ base_sha: Some(commit),
+ },
path,
- Some(commit),
)
}
@@ -6204,6 +6204,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<RelPath> {
+ 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::Item> {
+ self.traversal
+ .next()
+ .map(|entry| fuzzy_nucleo::PathMatchCandidate {
+ is_dir: entry.kind.is_dir(),
+ path: &entry.path,
+ })
+ }
+}
+
impl EventEmitter<Event> for Project {}
impl<'a> From<&'a ProjectPath> for SettingsLocation<'a> {
@@ -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,
)
})
})
@@ -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);
@@ -594,6 +594,7 @@ message GitCreateWorktree {
string name = 3;
string directory = 4;
optional string commit = 5;
+ bool use_existing_branch = 6;
}
message GitCreateCheckpoint {
@@ -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(
@@ -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(
@@ -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 {
@@ -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::<RevealInProjectPanel>();
+ 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| {
@@ -3374,7 +3374,7 @@ fn run_start_thread_in_selector_visual_tests(
cx: &mut VisualTestAppContext,
update_baseline: bool,
) -> Result<TestResult> {
- 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| {
@@ -3695,7 +3695,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();
@@ -3768,7 +3774,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();