From 97469cd0499f6ae5e5022fdfec4043635b58e1ad Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Tue, 13 Aug 2024 23:06:07 -0700 Subject: [PATCH] Improve slash commands (#16195) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2024-08-13 at 8 12 43 PM Release Notes: - N/A --- Cargo.lock | 1 - assets/keymaps/default-linux.json | 2 +- assets/keymaps/default-macos.json | 2 +- crates/assistant/src/slash_command.rs | 64 +++++----- .../src/slash_command/diagnostics_command.rs | 103 ++++++++-------- .../src/slash_command/docs_command.rs | 13 +- .../src/slash_command/file_command.rs | 46 ++++++- .../src/slash_command/prompt_command.rs | 2 +- .../src/slash_command/tabs_command.rs | 4 +- .../src/slash_command/terminal_command.rs | 2 +- .../src/assistant_slash_command.rs | 2 +- crates/editor/src/actions.rs | 7 ++ crates/editor/src/editor.rs | 23 +++- crates/editor/src/element.rs | 7 ++ .../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, 326 insertions(+), 190 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e8298d384b..7d847d41cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8085,7 +8085,6 @@ dependencies = [ "tempfile", "terminal", "text", - "unicase", "unindent", "util", "which 6.0.2", diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 6ed0989dde..b1b84ce2e6 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -437,7 +437,7 @@ "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", - "tab": "editor::ConfirmCompletion" + "tab": "editor::ComposeCompletion" } }, { diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 2f85973dfd..e0236c8563 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -474,7 +474,7 @@ "context": "Editor && showing_completions", "bindings": { "enter": "editor::ConfirmCompletion", - "tab": "editor::ConfirmCompletion" + "tab": "editor::ComposeCompletion" } }, { diff --git a/crates/assistant/src/slash_command.rs b/crates/assistant/src/slash_command.rs index 847a1bd39b..54f69279aa 100644 --- a/crates/assistant/src/slash_command.rs +++ b/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), diff --git a/crates/assistant/src/slash_command/diagnostics_command.rs b/crates/assistant/src/slash_command/diagnostics_command.rs index f393f329b0..c4a251f384 100644 --- a/crates/assistant/src/slash_command/diagnostics_command.rs +++ b/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, + }); }; + 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(); + } + + 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.warning_count > 0 { + write!(label, " {} warnings", summary.warning_count).unwrap(); + } + } + + label.into() + } + PlaceholderType::File(file_path) => file_path.into(), + PlaceholderType::Diagnostic(_, message) => message.into(), + }, + }) + .collect(); + 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 - } - }, - 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.warning_count > 0 { - write!(label, " {} warnings", summary.warning_count) - .unwrap(); - } - } - - label.into() - } - PlaceholderType::File(file_path) => file_path.into(), - PlaceholderType::Diagnostic(_, message) => message.into(), - }, - }) - .collect(), + sections, run_commands_in_text: false, }) }) diff --git a/crates/assistant/src/slash_command/docs_command.rs b/crates/assistant/src/slash_command/docs_command.rs index fc959a05ac..ecb2c658fc 100644 --- a/crates/assistant/src/slash_command/docs_command.rs +++ b/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, }]); diff --git a/crates/assistant/src/slash_command/file_command.rs b/crates/assistant/src/slash_command/file_command.rs index 4b5ae9bf35..093659c122 100644 --- a/crates/assistant/src/slash_command/file_command.rs +++ b/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::>(); + let path_prefix: Arc = 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()) }) diff --git a/crates/assistant/src/slash_command/prompt_command.rs b/crates/assistant/src/slash_command/prompt_command.rs index 0eba5a6dc1..235a138e33 100644 --- a/crates/assistant/src/slash_command/prompt_command.rs +++ b/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, }) diff --git a/crates/assistant/src/slash_command/tabs_command.rs b/crates/assistant/src/slash_command/tabs_command.rs index 94d097b85e..848659d530 100644 --- a/crates/assistant/src/slash_command/tabs_command.rs +++ b/crates/assistant/src/slash_command/tabs_command.rs @@ -47,7 +47,7 @@ impl SlashCommand for TabsSlashCommand { ) -> Task>> { 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, }) diff --git a/crates/assistant/src/slash_command/terminal_command.rs b/crates/assistant/src/slash_command/terminal_command.rs index fe4610a14b..8eaad4068f 100644 --- a/crates/assistant/src/slash_command/terminal_command.rs +++ b/crates/assistant/src/slash_command/terminal_command.rs @@ -48,7 +48,7 @@ impl SlashCommand for TerminalSlashCommand { _cx: &mut WindowContext, ) -> Task>> { 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, }])) diff --git a/crates/assistant_slash_command/src/assistant_slash_command.rs b/crates/assistant_slash_command/src/assistant_slash_command.rs index 5954ea2bb6..f8fddb225d 100644 --- a/crates/assistant_slash_command/src/assistant_slash_command.rs +++ b/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. diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index c060efd4e6..b8c616af82 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -64,6 +64,12 @@ pub struct ConfirmCompletion { pub item_ix: Option, } +#[derive(PartialEq, Clone, Deserialize, Default)] +pub struct ComposeCompletion { + #[serde(default)] + pub item_ix: Option, +} + #[derive(PartialEq, Clone, Deserialize, Default)] pub struct ConfirmCodeAction { #[serde(default)] @@ -140,6 +146,7 @@ impl_actions!( [ ConfirmCodeAction, ConfirmCompletion, + ComposeCompletion, ExpandExcerpts, ExpandExcerptsUp, ExpandExcerptsDown, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d07fc83820..5007513f68 100644 --- a/crates/editor/src/editor.rs +++ b/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, ) -> Option>> { + self.do_completion(action.item_ix, CompletionIntent::Complete, cx) + } + + pub fn compose_completion( + &mut self, + action: &ComposeCompletion, + cx: &mut ViewContext, + ) -> Option>> { + self.do_completion(action.item_ix, CompletionIntent::Compose, cx) + } + + fn do_completion( + &mut self, + item_ix: Option, + intent: CompletionIntent, + cx: &mut ViewContext, + ) -> Option>> { 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 { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index bba95094b1..8ec6e806af 100644 --- a/crates/editor/src/element.rs +++ b/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); diff --git a/crates/extension/src/extension_slash_command.rs b/crates/extension/src/extension_slash_command.rs index ce16a25942..d8a5acef29 100644 --- a/crates/extension/src/extension_slash_command.rs +++ b/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, }) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 54732d4bcb..98456c461d 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/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, }), ] ); diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 97834c72c4..3a6522f308 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/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; diff --git a/crates/fuzzy/src/matcher.rs b/crates/fuzzy/src/matcher.rs index d3b02086c9..ae56b84f1e 100644 --- a/crates/fuzzy/src/matcher.rs +++ b/crates/fuzzy/src/matcher.rs @@ -445,6 +445,7 @@ mod tests { let lowercase_path = path.to_lowercase().chars().collect::>(); 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, }, ); diff --git a/crates/fuzzy/src/paths.rs b/crates/fuzzy/src/paths.rs index 73192517e5..2b4eec98ef 100644 --- a/crates/fuzzy/src/paths.rs +++ b/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, pub path_prefix: Arc, + pub is_dir: bool, /// Number of steps removed from a shared parent with the relative path /// Used to order closer paths first in the search list pub distance_to_relative_ancestor: usize, @@ -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, diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index e700f5e538..725180c211 100644 --- a/crates/language/src/language.rs +++ b/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 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 { diff --git a/crates/project/Cargo.toml b/crates/project/Cargo.toml index 1c3c6a05dc..ac4da605b1 100644 --- a/crates/project/Cargo.toml +++ b/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] diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index f2dcc192ee..e3ad2d74a7 100644 --- a/crates/project/src/project.rs +++ b/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>, + pub confirm: Option>, /// 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.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, 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), - ] - ); - } -} diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index df9133888b..43937ff328 100644 --- a/crates/util/src/paths.rs +++ b/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 = [