Improve slash commands (#16195)

Mikayla Maki created

This PR:

- Makes slash commands easier to compose by adding a concept,
`CompletionIntent`. When using `tab` on a completion in the assistant
panel, that completion item will be expanded but the associated command
will not be run. Using `enter` will still either run the completion item
or continue command composition as before.
- Fixes a bug where running `/diagnostics` on a project with no
diagnostics will delete the entire command, rather than rendering an
empty header.
- Improves the autocomplete rendering for files, showing when
directories are selected and re-arranging the results to have the file
name or trailing directory show first.

<img width="642" alt="Screenshot 2024-08-13 at 8 12 43 PM"
src="https://github.com/user-attachments/assets/97c96cd2-741f-4f15-ad03-7cf78129a71c">


Release Notes:

- N/A

Change summary

Cargo.lock                                                    |   1 
assets/keymaps/default-linux.json                             |   2 
assets/keymaps/default-macos.json                             |   2 
crates/assistant/src/slash_command.rs                         |  64 +-
crates/assistant/src/slash_command/diagnostics_command.rs     |  91 ++-
crates/assistant/src/slash_command/docs_command.rs            |  13 
crates/assistant/src/slash_command/file_command.rs            |  46 +
crates/assistant/src/slash_command/prompt_command.rs          |   2 
crates/assistant/src/slash_command/tabs_command.rs            |   4 
crates/assistant/src/slash_command/terminal_command.rs        |   2 
crates/assistant_slash_command/src/assistant_slash_command.rs |   2 
crates/editor/src/actions.rs                                  |   7 
crates/editor/src/editor.rs                                   |  23 
crates/editor/src/element.rs                                  |   7 
crates/extension/src/extension_slash_command.rs               |   2 
crates/file_finder/src/file_finder.rs                         |  13 
crates/file_finder/src/open_path_prompt.rs                    |   4 
crates/fuzzy/src/matcher.rs                                   |   2 
crates/fuzzy/src/paths.rs                                     |   4 
crates/language/src/language.rs                               |  16 
crates/project/Cargo.toml                                     |   1 
crates/project/src/project.rs                                 | 114 +---
crates/util/src/paths.rs                                      |  82 +++
23 files changed, 320 insertions(+), 184 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -8085,7 +8085,6 @@ dependencies = [
  "tempfile",
  "terminal",
  "text",
- "unicase",
  "unindent",
  "util",
  "which 6.0.2",

assets/keymaps/default-linux.json 🔗

@@ -437,7 +437,7 @@
     "context": "Editor && showing_completions",
     "bindings": {
       "enter": "editor::ConfirmCompletion",
-      "tab": "editor::ConfirmCompletion"
+      "tab": "editor::ComposeCompletion"
     }
   },
   {

assets/keymaps/default-macos.json 🔗

@@ -474,7 +474,7 @@
     "context": "Editor && showing_completions",
     "bindings": {
       "enter": "editor::ConfirmCompletion",
-      "tab": "editor::ConfirmCompletion"
+      "tab": "editor::ComposeCompletion"
     }
   },
   {

crates/assistant/src/slash_command.rs 🔗

@@ -6,6 +6,7 @@ use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{AppContext, Model, Task, ViewContext, WeakView, WindowContext};
 use language::{Anchor, Buffer, CodeLabel, Documentation, HighlightId, LanguageServerId, ToPoint};
 use parking_lot::{Mutex, RwLock};
+use project::CompletionIntent;
 use rope::Point;
 use std::{
     ops::Range,
@@ -106,20 +107,24 @@ impl SlashCommandCompletionProvider {
                                     let command_range = command_range.clone();
                                     let editor = editor.clone();
                                     let workspace = workspace.clone();
-                                    Arc::new(move |cx: &mut WindowContext| {
-                                        editor
-                                            .update(cx, |editor, cx| {
-                                                editor.run_command(
-                                                    command_range.clone(),
-                                                    &command_name,
-                                                    None,
-                                                    true,
-                                                    workspace.clone(),
-                                                    cx,
-                                                );
-                                            })
-                                            .ok();
-                                    }) as Arc<_>
+                                    Arc::new(
+                                        move |intent: CompletionIntent, cx: &mut WindowContext| {
+                                            if intent.is_complete() {
+                                                editor
+                                                    .update(cx, |editor, cx| {
+                                                        editor.run_command(
+                                                            command_range.clone(),
+                                                            &command_name,
+                                                            None,
+                                                            true,
+                                                            workspace.clone(),
+                                                            cx,
+                                                        );
+                                                    })
+                                                    .ok();
+                                            }
+                                        },
+                                    ) as Arc<_>
                                 })
                             },
                         );
@@ -151,7 +156,6 @@ impl SlashCommandCompletionProvider {
         let mut flag = self.cancel_flag.lock();
         flag.store(true, SeqCst);
         *flag = new_cancel_flag.clone();
-
         let commands = SlashCommandRegistry::global(cx);
         if let Some(command) = commands.command(command_name) {
             let completions = command.complete_argument(
@@ -177,19 +181,21 @@ impl SlashCommandCompletionProvider {
                                         let command_range = command_range.clone();
                                         let command_name = command_name.clone();
                                         let command_argument = command_argument.new_text.clone();
-                                        move |cx: &mut WindowContext| {
-                                            editor
-                                                .update(cx, |editor, cx| {
-                                                    editor.run_command(
-                                                        command_range.clone(),
-                                                        &command_name,
-                                                        Some(&command_argument),
-                                                        true,
-                                                        workspace.clone(),
-                                                        cx,
-                                                    );
-                                                })
-                                                .ok();
+                                        move |intent: CompletionIntent, cx: &mut WindowContext| {
+                                            if intent.is_complete() {
+                                                editor
+                                                    .update(cx, |editor, cx| {
+                                                        editor.run_command(
+                                                            command_range.clone(),
+                                                            &command_name,
+                                                            Some(&command_argument),
+                                                            true,
+                                                            workspace.clone(),
+                                                            cx,
+                                                        );
+                                                    })
+                                                    .ok();
+                                            }
                                         }
                                     }) as Arc<_>
                                 })
@@ -204,7 +210,7 @@ impl SlashCommandCompletionProvider {
 
                         project::Completion {
                             old_range: argument_range.clone(),
-                            label: CodeLabel::plain(command_argument.label, None),
+                            label: command_argument.label,
                             new_text,
                             documentation: None,
                             server_id: LanguageServerId(0),

crates/assistant/src/slash_command/diagnostics_command.rs 🔗

@@ -43,6 +43,7 @@ impl DiagnosticsSlashCommand {
                         worktree_id: entry.worktree_id.to_usize(),
                         path: entry.path.clone(),
                         path_prefix: path_prefix.clone(),
+                        is_dir: false, // Diagnostics can't be produced for directories
                         distance_to_relative_ancestor: 0,
                     })
                     .collect(),
@@ -146,7 +147,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
             Ok(matches
                 .into_iter()
                 .map(|completion| ArgumentCompletion {
-                    label: completion.clone(),
+                    label: completion.clone().into(),
                     new_text: completion,
                     run_command: true,
                 })
@@ -168,58 +169,66 @@ impl SlashCommand for DiagnosticsSlashCommand {
         let options = Options::parse(argument);
 
         let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
+
         cx.spawn(move |_| async move {
             let Some((text, sections)) = task.await? else {
-                return Ok(SlashCommandOutput::default());
+                return Ok(SlashCommandOutput {
+                    sections: vec![SlashCommandOutputSection {
+                        range: 0..1,
+                        icon: IconName::Library,
+                        label: "No Diagnostics".into(),
+                    }],
+                    text: "\n".to_string(),
+                    run_commands_in_text: true,
+                });
             };
 
-            Ok(SlashCommandOutput {
-                text,
-                sections: sections
-                    .into_iter()
-                    .map(|(range, placeholder_type)| SlashCommandOutputSection {
-                        range,
-                        icon: match placeholder_type {
-                            PlaceholderType::Root(_, _) => IconName::ExclamationTriangle,
-                            PlaceholderType::File(_) => IconName::File,
-                            PlaceholderType::Diagnostic(DiagnosticType::Error, _) => {
-                                IconName::XCircle
-                            }
-                            PlaceholderType::Diagnostic(DiagnosticType::Warning, _) => {
-                                IconName::ExclamationTriangle
+            let sections = sections
+                .into_iter()
+                .map(|(range, placeholder_type)| SlashCommandOutputSection {
+                    range,
+                    icon: match placeholder_type {
+                        PlaceholderType::Root(_, _) => IconName::ExclamationTriangle,
+                        PlaceholderType::File(_) => IconName::File,
+                        PlaceholderType::Diagnostic(DiagnosticType::Error, _) => IconName::XCircle,
+                        PlaceholderType::Diagnostic(DiagnosticType::Warning, _) => {
+                            IconName::ExclamationTriangle
+                        }
+                    },
+                    label: match placeholder_type {
+                        PlaceholderType::Root(summary, source) => {
+                            let mut label = String::new();
+                            label.push_str("Diagnostics");
+                            if let Some(source) = source {
+                                write!(label, " ({})", source).unwrap();
                             }
-                        },
-                        label: match placeholder_type {
-                            PlaceholderType::Root(summary, source) => {
-                                let mut label = String::new();
-                                label.push_str("Diagnostics");
-                                if let Some(source) = source {
-                                    write!(label, " ({})", source).unwrap();
-                                }
 
-                                if summary.error_count > 0 || summary.warning_count > 0 {
-                                    label.push(':');
-
-                                    if summary.error_count > 0 {
-                                        write!(label, " {} errors", summary.error_count).unwrap();
-                                        if summary.warning_count > 0 {
-                                            label.push_str(",");
-                                        }
-                                    }
+                            if summary.error_count > 0 || summary.warning_count > 0 {
+                                label.push(':');
 
+                                if summary.error_count > 0 {
+                                    write!(label, " {} errors", summary.error_count).unwrap();
                                     if summary.warning_count > 0 {
-                                        write!(label, " {} warnings", summary.warning_count)
-                                            .unwrap();
+                                        label.push_str(",");
                                     }
                                 }
 
-                                label.into()
+                                if summary.warning_count > 0 {
+                                    write!(label, " {} warnings", summary.warning_count).unwrap();
+                                }
                             }
-                            PlaceholderType::File(file_path) => file_path.into(),
-                            PlaceholderType::Diagnostic(_, message) => message.into(),
-                        },
-                    })
-                    .collect(),
+
+                            label.into()
+                        }
+                        PlaceholderType::File(file_path) => file_path.into(),
+                        PlaceholderType::Diagnostic(_, message) => message.into(),
+                    },
+                })
+                .collect();
+
+            Ok(SlashCommandOutput {
+                text,
+                sections,
                 run_commands_in_text: false,
             })
         })

crates/assistant/src/slash_command/docs_command.rs 🔗

@@ -182,7 +182,7 @@ impl SlashCommand for DocsSlashCommand {
                 items
                     .into_iter()
                     .map(|item| ArgumentCompletion {
-                        label: item.clone(),
+                        label: item.clone().into(),
                         new_text: format!("{provider} {item}"),
                         run_command: true,
                     })
@@ -194,7 +194,7 @@ impl SlashCommand for DocsSlashCommand {
                     let providers = indexed_docs_registry.list_providers();
                     if providers.is_empty() {
                         return Ok(vec![ArgumentCompletion {
-                            label: "No available docs providers.".to_string(),
+                            label: "No available docs providers.".into(),
                             new_text: String::new(),
                             run_command: false,
                         }]);
@@ -203,7 +203,7 @@ impl SlashCommand for DocsSlashCommand {
                     Ok(providers
                         .into_iter()
                         .map(|provider| ArgumentCompletion {
-                            label: provider.to_string(),
+                            label: provider.to_string().into(),
                             new_text: provider.to_string(),
                             run_command: false,
                         })
@@ -231,10 +231,10 @@ impl SlashCommand for DocsSlashCommand {
                         .filter(|package_name| {
                             !items
                                 .iter()
-                                .any(|item| item.label.as_str() == package_name.as_ref())
+                                .any(|item| item.label.text() == package_name.as_ref())
                         })
                         .map(|package_name| ArgumentCompletion {
-                            label: format!("{package_name} (unindexed)"),
+                            label: format!("{package_name} (unindexed)").into(),
                             new_text: format!("{provider} {package_name}"),
                             run_command: true,
                         })
@@ -246,7 +246,8 @@ impl SlashCommand for DocsSlashCommand {
                             label: format!(
                                 "Enter a {package_term} name.",
                                 package_term = package_term(&provider)
-                            ),
+                            )
+                            .into(),
                             new_text: provider.to_string(),
                             run_command: false,
                         }]);

crates/assistant/src/slash_command/file_command.rs 🔗

@@ -3,7 +3,7 @@ use anyhow::{anyhow, Result};
 use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
 use fuzzy::PathMatch;
 use gpui::{AppContext, Model, Task, View, WeakView};
-use language::{BufferSnapshot, LineEnding, LspAdapterDelegate};
+use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
 use project::{PathMatchCandidateSet, Project};
 use std::{
     fmt::Write,
@@ -29,11 +29,30 @@ impl FileSlashCommand {
             let workspace = workspace.read(cx);
             let project = workspace.project().read(cx);
             let entries = workspace.recent_navigation_history(Some(10), cx);
+
+            let entries = entries
+                .into_iter()
+                .map(|entries| (entries.0, false))
+                .chain(project.worktrees(cx).flat_map(|worktree| {
+                    let worktree = worktree.read(cx);
+                    let id = worktree.id();
+                    worktree.child_entries(Path::new("")).map(move |entry| {
+                        (
+                            project::ProjectPath {
+                                worktree_id: id,
+                                path: entry.path.clone(),
+                            },
+                            entry.kind.is_dir(),
+                        )
+                    })
+                }))
+                .collect::<Vec<_>>();
+
             let path_prefix: Arc<str> = Arc::default();
             Task::ready(
                 entries
                     .into_iter()
-                    .filter_map(|(entry, _)| {
+                    .filter_map(|(entry, is_dir)| {
                         let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
                         let mut full_path = PathBuf::from(worktree.read(cx).root_name());
                         full_path.push(&entry.path);
@@ -44,6 +63,7 @@ impl FileSlashCommand {
                             path: full_path.into(),
                             path_prefix: path_prefix.clone(),
                             distance_to_relative_ancestor: 0,
+                            is_dir,
                         })
                     })
                     .collect(),
@@ -54,6 +74,7 @@ impl FileSlashCommand {
                 .into_iter()
                 .map(|worktree| {
                     let worktree = worktree.read(cx);
+
                     PathMatchCandidateSet {
                         snapshot: worktree.snapshot(),
                         include_ignored: worktree
@@ -111,22 +132,35 @@ impl SlashCommand for FileSlashCommand {
         };
 
         let paths = self.search_paths(query, cancellation_flag, &workspace, cx);
+        let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
         cx.background_executor().spawn(async move {
             Ok(paths
                 .await
                 .into_iter()
-                .map(|path_match| {
+                .filter_map(|path_match| {
                     let text = format!(
                         "{}{}",
                         path_match.path_prefix,
                         path_match.path.to_string_lossy()
                     );
 
-                    ArgumentCompletion {
-                        label: text.clone(),
+                    let mut label = CodeLabel::default();
+                    let file_name = path_match.path.file_name()?.to_string_lossy();
+                    let label_text = if path_match.is_dir {
+                        format!("{}/ ", file_name)
+                    } else {
+                        format!("{} ", file_name)
+                    };
+
+                    label.push_str(label_text.as_str(), None);
+                    label.push_str(&text, comment_id);
+                    label.filter_range = 0..file_name.len();
+
+                    Some(ArgumentCompletion {
+                        label,
                         new_text: text,
                         run_command: true,
-                    }
+                    })
                 })
                 .collect())
         })

crates/assistant/src/slash_command/prompt_command.rs 🔗

@@ -42,7 +42,7 @@ impl SlashCommand for PromptSlashCommand {
                 .filter_map(|prompt| {
                     let prompt_title = prompt.title?.to_string();
                     Some(ArgumentCompletion {
-                        label: prompt_title.clone(),
+                        label: prompt_title.clone().into(),
                         new_text: prompt_title,
                         run_command: true,
                     })

crates/assistant/src/slash_command/tabs_command.rs 🔗

@@ -47,7 +47,7 @@ impl SlashCommand for TabsSlashCommand {
     ) -> Task<Result<Vec<ArgumentCompletion>>> {
         let all_tabs_completion_item = if ALL_TABS_COMPLETION_ITEM.contains(&query) {
             Some(ArgumentCompletion {
-                label: ALL_TABS_COMPLETION_ITEM.to_owned(),
+                label: ALL_TABS_COMPLETION_ITEM.into(),
                 new_text: ALL_TABS_COMPLETION_ITEM.to_owned(),
                 run_command: true,
             })
@@ -63,7 +63,7 @@ impl SlashCommand for TabsSlashCommand {
                     .filter_map(|(path, ..)| {
                         let path_string = path.as_deref()?.to_string_lossy().to_string();
                         Some(ArgumentCompletion {
-                            label: path_string.clone(),
+                            label: path_string.clone().into(),
                             new_text: path_string,
                             run_command: true,
                         })

crates/assistant/src/slash_command/terminal_command.rs 🔗

@@ -48,7 +48,7 @@ impl SlashCommand for TerminalSlashCommand {
         _cx: &mut WindowContext,
     ) -> Task<Result<Vec<ArgumentCompletion>>> {
         Task::ready(Ok(vec![ArgumentCompletion {
-            label: LINE_COUNT_ARG.to_string(),
+            label: LINE_COUNT_ARG.into(),
             new_text: LINE_COUNT_ARG.to_string(),
             run_command: true,
         }]))

crates/assistant_slash_command/src/assistant_slash_command.rs 🔗

@@ -18,7 +18,7 @@ pub fn init(cx: &mut AppContext) {
 #[derive(Debug)]
 pub struct ArgumentCompletion {
     /// The label to display for this completion.
-    pub label: String,
+    pub label: CodeLabel,
     /// The new text that should be inserted into the command when this completion is accepted.
     pub new_text: String,
     /// Whether the command should be run when accepting this completion.

crates/editor/src/actions.rs 🔗

@@ -64,6 +64,12 @@ pub struct ConfirmCompletion {
     pub item_ix: Option<usize>,
 }
 
+#[derive(PartialEq, Clone, Deserialize, Default)]
+pub struct ComposeCompletion {
+    #[serde(default)]
+    pub item_ix: Option<usize>,
+}
+
 #[derive(PartialEq, Clone, Deserialize, Default)]
 pub struct ConfirmCodeAction {
     #[serde(default)]
@@ -140,6 +146,7 @@ impl_actions!(
     [
         ConfirmCodeAction,
         ConfirmCompletion,
+        ComposeCompletion,
         ExpandExcerpts,
         ExpandExcerptsUp,
         ExpandExcerptsDown,

crates/editor/src/editor.rs 🔗

@@ -114,7 +114,7 @@ use ordered_float::OrderedFloat;
 use parking_lot::{Mutex, RwLock};
 use project::project_settings::{GitGutterSetting, ProjectSettings};
 use project::{
-    CodeAction, Completion, FormatTrigger, Item, Location, Project, ProjectPath,
+    CodeAction, Completion, CompletionIntent, FormatTrigger, Item, Location, Project, ProjectPath,
     ProjectTransaction, TaskSourceKind, WorktreeId,
 };
 use rand::prelude::*;
@@ -4213,6 +4213,23 @@ impl Editor {
         action: &ConfirmCompletion,
         cx: &mut ViewContext<Self>,
     ) -> Option<Task<Result<()>>> {
+        self.do_completion(action.item_ix, CompletionIntent::Complete, cx)
+    }
+
+    pub fn compose_completion(
+        &mut self,
+        action: &ComposeCompletion,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<Task<Result<()>>> {
+        self.do_completion(action.item_ix, CompletionIntent::Compose, cx)
+    }
+
+    fn do_completion(
+        &mut self,
+        item_ix: Option<usize>,
+        intent: CompletionIntent,
+        cx: &mut ViewContext<Editor>,
+    ) -> Option<Task<std::result::Result<(), anyhow::Error>>> {
         use language::ToOffset as _;
 
         let completions_menu = if let ContextMenu::Completions(menu) = self.hide_context_menu(cx)? {
@@ -4223,7 +4240,7 @@ impl Editor {
 
         let mat = completions_menu
             .matches
-            .get(action.item_ix.unwrap_or(completions_menu.selected_item))?;
+            .get(item_ix.unwrap_or(completions_menu.selected_item))?;
         let buffer_handle = completions_menu.buffer;
         let completions = completions_menu.completions.read();
         let completion = completions.get(mat.candidate_id)?;
@@ -4358,7 +4375,7 @@ impl Editor {
         });
 
         if let Some(confirm) = completion.confirm.as_ref() {
-            (confirm)(cx);
+            (confirm)(intent, cx);
         }
 
         if completion.show_new_completions_on_confirm {

crates/editor/src/element.rs 🔗

@@ -372,6 +372,13 @@ impl EditorElement {
                 cx.propagate();
             }
         });
+        register_action(view, cx, |editor, action, cx| {
+            if let Some(task) = editor.compose_completion(action, cx) {
+                task.detach_and_log_err(cx);
+            } else {
+                cx.propagate();
+            }
+        });
         register_action(view, cx, |editor, action, cx| {
             if let Some(task) = editor.confirm_code_action(action, cx) {
                 task.detach_and_log_err(cx);

crates/extension/src/extension_slash_command.rs 🔗

@@ -63,7 +63,7 @@ impl SlashCommand for ExtensionSlashCommand {
                                 completions
                                     .into_iter()
                                     .map(|completion| ArgumentCompletion {
-                                        label: completion.label,
+                                        label: completion.label.into(),
                                         new_text: completion.new_text,
                                         run_command: completion.run_command,
                                     })

crates/file_finder/src/file_finder.rs 🔗

@@ -318,6 +318,7 @@ fn matching_history_item_paths<'a>(
         .chain(currently_opened)
         .filter_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,
@@ -588,6 +589,7 @@ impl FileFinderDelegate {
                     positions: Vec::new(),
                     worktree_id: worktree_id.to_usize(),
                     path,
+                    is_dir: false, // File finder doesn't support directories
                     path_prefix: "".into(),
                     distance_to_relative_ancestor: usize::MAX,
                 };
@@ -688,6 +690,7 @@ impl FileFinderDelegate {
                                     worktree_id: worktree.read(cx).id().to_usize(),
                                     path: Arc::from(relative_path),
                                     path_prefix: "".into(),
+                                    is_dir: false, // File finder doesn't support directories
                                     distance_to_relative_ancestor: usize::MAX,
                                 }));
                             }
@@ -1001,6 +1004,7 @@ mod tests {
                 path: Arc::from(Path::new("b0.5")),
                 path_prefix: Arc::default(),
                 distance_to_relative_ancestor: 0,
+                is_dir: false,
             }),
             ProjectPanelOrdMatch(PathMatch {
                 score: 1.0,
@@ -1009,6 +1013,7 @@ mod tests {
                 path: Arc::from(Path::new("c1.0")),
                 path_prefix: Arc::default(),
                 distance_to_relative_ancestor: 0,
+                is_dir: false,
             }),
             ProjectPanelOrdMatch(PathMatch {
                 score: 1.0,
@@ -1017,6 +1022,7 @@ mod tests {
                 path: Arc::from(Path::new("a1.0")),
                 path_prefix: Arc::default(),
                 distance_to_relative_ancestor: 0,
+                is_dir: false,
             }),
             ProjectPanelOrdMatch(PathMatch {
                 score: 0.5,
@@ -1025,6 +1031,7 @@ mod tests {
                 path: Arc::from(Path::new("a0.5")),
                 path_prefix: Arc::default(),
                 distance_to_relative_ancestor: 0,
+                is_dir: false,
             }),
             ProjectPanelOrdMatch(PathMatch {
                 score: 1.0,
@@ -1033,6 +1040,7 @@ mod tests {
                 path: Arc::from(Path::new("b1.0")),
                 path_prefix: Arc::default(),
                 distance_to_relative_ancestor: 0,
+                is_dir: false,
             }),
         ];
         file_finder_sorted_output.sort_by(|a, b| b.cmp(a));
@@ -1047,6 +1055,7 @@ mod tests {
                     path: Arc::from(Path::new("a1.0")),
                     path_prefix: Arc::default(),
                     distance_to_relative_ancestor: 0,
+                    is_dir: false,
                 }),
                 ProjectPanelOrdMatch(PathMatch {
                     score: 1.0,
@@ -1055,6 +1064,7 @@ mod tests {
                     path: Arc::from(Path::new("b1.0")),
                     path_prefix: Arc::default(),
                     distance_to_relative_ancestor: 0,
+                    is_dir: false,
                 }),
                 ProjectPanelOrdMatch(PathMatch {
                     score: 1.0,
@@ -1063,6 +1073,7 @@ mod tests {
                     path: Arc::from(Path::new("c1.0")),
                     path_prefix: Arc::default(),
                     distance_to_relative_ancestor: 0,
+                    is_dir: false,
                 }),
                 ProjectPanelOrdMatch(PathMatch {
                     score: 0.5,
@@ -1071,6 +1082,7 @@ mod tests {
                     path: Arc::from(Path::new("a0.5")),
                     path_prefix: Arc::default(),
                     distance_to_relative_ancestor: 0,
+                    is_dir: false,
                 }),
                 ProjectPanelOrdMatch(PathMatch {
                     score: 0.5,
@@ -1079,6 +1091,7 @@ mod tests {
                     path: Arc::from(Path::new("b0.5")),
                     path_prefix: Arc::default(),
                     distance_to_relative_ancestor: 0,
+                    is_dir: false,
                 }),
             ]
         );

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -1,7 +1,7 @@
 use futures::channel::oneshot;
 use fuzzy::StringMatchCandidate;
 use picker::{Picker, PickerDelegate};
-use project::{compare_paths, DirectoryLister};
+use project::DirectoryLister;
 use std::{
     path::{Path, PathBuf},
     sync::{
@@ -11,7 +11,7 @@ use std::{
 };
 use ui::{prelude::*, LabelLike, ListItemSpacing};
 use ui::{ListItem, ViewContext};
-use util::maybe;
+use util::{maybe, paths::compare_paths};
 use workspace::Workspace;
 
 pub(crate) struct OpenPathPrompt;

crates/fuzzy/src/matcher.rs 🔗

@@ -445,6 +445,7 @@ mod tests {
             let lowercase_path = path.to_lowercase().chars().collect::<Vec<_>>();
             let char_bag = CharBag::from(lowercase_path.as_slice());
             path_entries.push(PathMatchCandidate {
+                is_dir: false,
                 char_bag,
                 path: &path_arcs[i],
             });
@@ -468,6 +469,7 @@ mod tests {
                 path: Arc::from(candidate.path),
                 path_prefix: "".into(),
                 distance_to_relative_ancestor: usize::MAX,
+                is_dir: false,
             },
         );
 

crates/fuzzy/src/paths.rs 🔗

@@ -13,6 +13,7 @@ use crate::{
 
 #[derive(Clone, Debug)]
 pub struct PathMatchCandidate<'a> {
+    pub is_dir: bool,
     pub path: &'a Path,
     pub char_bag: CharBag,
 }
@@ -24,6 +25,7 @@ pub struct PathMatch {
     pub worktree_id: usize,
     pub path: Arc<Path>,
     pub path_prefix: Arc<str>,
+    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,
@@ -119,6 +121,7 @@ pub fn match_fixed_path_set(
             score,
             worktree_id,
             positions: Vec::new(),
+            is_dir: candidate.is_dir,
             path: Arc::from(candidate.path),
             path_prefix: Arc::default(),
             distance_to_relative_ancestor: usize::MAX,
@@ -195,6 +198,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
                                     worktree_id,
                                     positions: Vec::new(),
                                     path: Arc::from(candidate.path),
+                                    is_dir: candidate.is_dir,
                                     path_prefix: candidate_set.prefix(),
                                     distance_to_relative_ancestor: relative_to.as_ref().map_or(
                                         usize::MAX,

crates/language/src/language.rs 🔗

@@ -1560,6 +1560,22 @@ impl CodeLabel {
             self.runs.push((start_ix..end_ix, highlight));
         }
     }
+
+    pub fn text(&self) -> &str {
+        self.text.as_str()
+    }
+}
+
+impl From<String> for CodeLabel {
+    fn from(value: String) -> Self {
+        Self::plain(value, None)
+    }
+}
+
+impl From<&str> for CodeLabel {
+    fn from(value: &str) -> Self {
+        Self::plain(value.to_string(), None)
+    }
 }
 
 impl Ord for LanguageMatcher {

crates/project/Cargo.toml 🔗

@@ -69,7 +69,6 @@ snippet_provider.workspace = true
 terminal.workspace = true
 text.workspace = true
 util.workspace = true
-unicase.workspace = true
 which.workspace = true
 
 [dev-dependencies]

crates/project/src/project.rs 🔗

@@ -114,10 +114,9 @@ use task::{
 };
 use terminals::Terminals;
 use text::{Anchor, BufferId, LineEnding};
-use unicase::UniCase;
 use util::{
-    debug_panic, defer, maybe, merge_json_value_into, parse_env_output, post_inc,
-    NumericPrefixWithSuffix, ResultExt, TryFutureExt as _,
+    debug_panic, defer, maybe, merge_json_value_into, parse_env_output, paths::compare_paths,
+    post_inc, ResultExt, TryFutureExt as _,
 };
 use worktree::{CreatedEntry, Snapshot, Traversal};
 use worktree_store::{WorktreeStore, WorktreeStoreEvent};
@@ -413,6 +412,28 @@ pub struct InlayHint {
     pub resolve_state: ResolveState,
 }
 
+/// The user's intent behind a given completion confirmation
+#[derive(PartialEq, Eq, Hash, Debug, Clone, Copy)]
+pub enum CompletionIntent {
+    /// The user intends to 'commit' this result, if possible
+    /// completion confirmations should run side effects
+    Complete,
+    /// The user intends to continue 'composing' this completion
+    /// completion confirmations should not run side effects and
+    /// let the user continue composing their action
+    Compose,
+}
+
+impl CompletionIntent {
+    pub fn is_complete(&self) -> bool {
+        self == &Self::Complete
+    }
+
+    pub fn is_compose(&self) -> bool {
+        self == &Self::Compose
+    }
+}
+
 /// A completion provided by a language server
 #[derive(Clone)]
 pub struct Completion {
@@ -429,7 +450,7 @@ pub struct Completion {
     /// The raw completion provided by the language server.
     pub lsp_completion: lsp::CompletionItem,
     /// An optional callback to invoke when this completion is confirmed.
-    pub confirm: Option<Arc<dyn Send + Sync + Fn(&mut WindowContext)>>,
+    pub confirm: Option<Arc<dyn Send + Sync + Fn(CompletionIntent, &mut WindowContext)>>,
     /// If true, the editor will show a new completion menu after this completion is confirmed.
     pub show_new_completions_on_confirm: bool,
 }
@@ -11011,10 +11032,12 @@ impl<'a> Iterator for PathMatchCandidateSetIter<'a> {
     fn next(&mut self) -> Option<Self::Item> {
         self.traversal.next().map(|entry| match entry.kind {
             EntryKind::Dir => fuzzy::PathMatchCandidate {
+                is_dir: true,
                 path: &entry.path,
                 char_bag: CharBag::from_iter(entry.path.to_string_lossy().to_lowercase().chars()),
             },
             EntryKind::File(char_bag) => fuzzy::PathMatchCandidate {
+                is_dir: false,
                 path: &entry.path,
                 char_bag,
             },
@@ -11565,86 +11588,3 @@ fn sort_search_matches(search_matches: &mut Vec<SearchMatchCandidate>, cx: &AppC
         }),
     });
 }
-
-pub fn compare_paths(
-    (path_a, a_is_file): (&Path, bool),
-    (path_b, b_is_file): (&Path, bool),
-) -> cmp::Ordering {
-    let mut components_a = path_a.components().peekable();
-    let mut components_b = path_b.components().peekable();
-    loop {
-        match (components_a.next(), components_b.next()) {
-            (Some(component_a), Some(component_b)) => {
-                let a_is_file = components_a.peek().is_none() && a_is_file;
-                let b_is_file = components_b.peek().is_none() && b_is_file;
-                let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
-                    let maybe_numeric_ordering = maybe!({
-                        let path_a = Path::new(component_a.as_os_str());
-                        let num_and_remainder_a = if a_is_file {
-                            path_a.file_stem()
-                        } else {
-                            path_a.file_name()
-                        }
-                        .and_then(|s| s.to_str())
-                        .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
-
-                        let path_b = Path::new(component_b.as_os_str());
-                        let num_and_remainder_b = if b_is_file {
-                            path_b.file_stem()
-                        } else {
-                            path_b.file_name()
-                        }
-                        .and_then(|s| s.to_str())
-                        .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
-
-                        num_and_remainder_a.partial_cmp(&num_and_remainder_b)
-                    });
-
-                    maybe_numeric_ordering.unwrap_or_else(|| {
-                        let name_a = UniCase::new(component_a.as_os_str().to_string_lossy());
-                        let name_b = UniCase::new(component_b.as_os_str().to_string_lossy());
-
-                        name_a.cmp(&name_b)
-                    })
-                });
-                if !ordering.is_eq() {
-                    return ordering;
-                }
-            }
-            (Some(_), None) => break cmp::Ordering::Greater,
-            (None, Some(_)) => break cmp::Ordering::Less,
-            (None, None) => break cmp::Ordering::Equal,
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    fn compare_paths_with_dots() {
-        let mut paths = vec![
-            (Path::new("test_dirs"), false),
-            (Path::new("test_dirs/1.46"), false),
-            (Path::new("test_dirs/1.46/bar_1"), true),
-            (Path::new("test_dirs/1.46/bar_2"), true),
-            (Path::new("test_dirs/1.45"), false),
-            (Path::new("test_dirs/1.45/foo_2"), true),
-            (Path::new("test_dirs/1.45/foo_1"), true),
-        ];
-        paths.sort_by(|&a, &b| compare_paths(a, b));
-        assert_eq!(
-            paths,
-            vec![
-                (Path::new("test_dirs"), false),
-                (Path::new("test_dirs/1.45"), false),
-                (Path::new("test_dirs/1.45/foo_1"), true),
-                (Path::new("test_dirs/1.45/foo_2"), true),
-                (Path::new("test_dirs/1.46"), false),
-                (Path::new("test_dirs/1.46/bar_1"), true),
-                (Path::new("test_dirs/1.46/bar_2"), true),
-            ]
-        );
-    }
-}

crates/util/src/paths.rs 🔗

@@ -1,3 +1,4 @@
+use std::cmp;
 use std::sync::OnceLock;
 use std::{
     ffi::OsStr,
@@ -8,6 +9,9 @@ use std::{
 use globset::{Glob, GlobSet, GlobSetBuilder};
 use regex::Regex;
 use serde::{Deserialize, Serialize};
+use unicase::UniCase;
+
+use crate::{maybe, NumericPrefixWithSuffix};
 
 /// Returns the path to the user's home directory.
 pub fn home_dir() -> &'static PathBuf {
@@ -266,10 +270,88 @@ impl PathMatcher {
     }
 }
 
+pub fn compare_paths(
+    (path_a, a_is_file): (&Path, bool),
+    (path_b, b_is_file): (&Path, bool),
+) -> cmp::Ordering {
+    let mut components_a = path_a.components().peekable();
+    let mut components_b = path_b.components().peekable();
+    loop {
+        match (components_a.next(), components_b.next()) {
+            (Some(component_a), Some(component_b)) => {
+                let a_is_file = components_a.peek().is_none() && a_is_file;
+                let b_is_file = components_b.peek().is_none() && b_is_file;
+                let ordering = a_is_file.cmp(&b_is_file).then_with(|| {
+                    let maybe_numeric_ordering = maybe!({
+                        let path_a = Path::new(component_a.as_os_str());
+                        let num_and_remainder_a = if a_is_file {
+                            path_a.file_stem()
+                        } else {
+                            path_a.file_name()
+                        }
+                        .and_then(|s| s.to_str())
+                        .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
+
+                        let path_b = Path::new(component_b.as_os_str());
+                        let num_and_remainder_b = if b_is_file {
+                            path_b.file_stem()
+                        } else {
+                            path_b.file_name()
+                        }
+                        .and_then(|s| s.to_str())
+                        .and_then(NumericPrefixWithSuffix::from_numeric_prefixed_str)?;
+
+                        num_and_remainder_a.partial_cmp(&num_and_remainder_b)
+                    });
+
+                    maybe_numeric_ordering.unwrap_or_else(|| {
+                        let name_a = UniCase::new(component_a.as_os_str().to_string_lossy());
+                        let name_b = UniCase::new(component_b.as_os_str().to_string_lossy());
+
+                        name_a.cmp(&name_b)
+                    })
+                });
+                if !ordering.is_eq() {
+                    return ordering;
+                }
+            }
+            (Some(_), None) => break cmp::Ordering::Greater,
+            (None, Some(_)) => break cmp::Ordering::Less,
+            (None, None) => break cmp::Ordering::Equal,
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
 
+    #[test]
+    fn compare_paths_with_dots() {
+        let mut paths = vec![
+            (Path::new("test_dirs"), false),
+            (Path::new("test_dirs/1.46"), false),
+            (Path::new("test_dirs/1.46/bar_1"), true),
+            (Path::new("test_dirs/1.46/bar_2"), true),
+            (Path::new("test_dirs/1.45"), false),
+            (Path::new("test_dirs/1.45/foo_2"), true),
+            (Path::new("test_dirs/1.45/foo_1"), true),
+        ];
+        paths.sort_by(|&a, &b| compare_paths(a, b));
+        assert_eq!(
+            paths,
+            vec![
+                (Path::new("test_dirs"), false),
+                (Path::new("test_dirs/1.45"), false),
+                (Path::new("test_dirs/1.45/foo_1"), true),
+                (Path::new("test_dirs/1.45/foo_2"), true),
+                (Path::new("test_dirs/1.46"), false),
+                (Path::new("test_dirs/1.46/bar_1"), true),
+                (Path::new("test_dirs/1.46/bar_2"), true),
+            ]
+        );
+    }
+
     #[test]
     fn path_with_position_parsing_positive() {
         let input_and_expected = [