Improve slash commands (#16195)

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
This commit is contained in:
Mikayla Maki 2024-08-13 23:06:07 -07:00 committed by GitHub
parent 5cb4de4ec6
commit 97469cd049
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 326 additions and 190 deletions

1
Cargo.lock generated
View File

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

View File

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

View File

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

View File

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

View File

@ -43,6 +43,7 @@ impl DiagnosticsSlashCommand {
worktree_id: entry.worktree_id.to_usize(), worktree_id: entry.worktree_id.to_usize(),
path: entry.path.clone(), path: entry.path.clone(),
path_prefix: path_prefix.clone(), path_prefix: path_prefix.clone(),
is_dir: false, // Diagnostics can't be produced for directories
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
}) })
.collect(), .collect(),
@ -146,7 +147,7 @@ impl SlashCommand for DiagnosticsSlashCommand {
Ok(matches Ok(matches
.into_iter() .into_iter()
.map(|completion| ArgumentCompletion { .map(|completion| ArgumentCompletion {
label: completion.clone(), label: completion.clone().into(),
new_text: completion, new_text: completion,
run_command: true, run_command: true,
}) })
@ -168,58 +169,66 @@ impl SlashCommand for DiagnosticsSlashCommand {
let options = Options::parse(argument); let options = Options::parse(argument);
let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx); let task = collect_diagnostics(workspace.read(cx).project().clone(), options, cx);
cx.spawn(move |_| async move { cx.spawn(move |_| async move {
let Some((text, sections)) = task.await? else { 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 { Ok(SlashCommandOutput {
text, text,
sections: 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(),
run_commands_in_text: false, run_commands_in_text: false,
}) })
}) })

View File

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

View File

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

View File

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

View File

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

View File

@ -48,7 +48,7 @@ impl SlashCommand for TerminalSlashCommand {
_cx: &mut WindowContext, _cx: &mut WindowContext,
) -> Task<Result<Vec<ArgumentCompletion>>> { ) -> Task<Result<Vec<ArgumentCompletion>>> {
Task::ready(Ok(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(), new_text: LINE_COUNT_ARG.to_string(),
run_command: true, run_command: true,
}])) }]))

View File

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

View File

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

View File

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

View File

@ -372,6 +372,13 @@ impl EditorElement {
cx.propagate(); 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| { register_action(view, cx, |editor, action, cx| {
if let Some(task) = editor.confirm_code_action(action, cx) { if let Some(task) = editor.confirm_code_action(action, cx) {
task.detach_and_log_err(cx); task.detach_and_log_err(cx);

View File

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

View File

@ -318,6 +318,7 @@ fn matching_history_item_paths<'a>(
.chain(currently_opened) .chain(currently_opened)
.filter_map(|found_path| { .filter_map(|found_path| {
let candidate = PathMatchCandidate { let candidate = PathMatchCandidate {
is_dir: false, // You can't open directories as project items
path: &found_path.project.path, path: &found_path.project.path,
// Only match history items names, otherwise their paths may match too many queries, producing false positives. // 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, // 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(), positions: Vec::new(),
worktree_id: worktree_id.to_usize(), worktree_id: worktree_id.to_usize(),
path, path,
is_dir: false, // File finder doesn't support directories
path_prefix: "".into(), path_prefix: "".into(),
distance_to_relative_ancestor: usize::MAX, distance_to_relative_ancestor: usize::MAX,
}; };
@ -688,6 +690,7 @@ impl FileFinderDelegate {
worktree_id: worktree.read(cx).id().to_usize(), worktree_id: worktree.read(cx).id().to_usize(),
path: Arc::from(relative_path), path: Arc::from(relative_path),
path_prefix: "".into(), path_prefix: "".into(),
is_dir: false, // File finder doesn't support directories
distance_to_relative_ancestor: usize::MAX, distance_to_relative_ancestor: usize::MAX,
})); }));
} }
@ -1001,6 +1004,7 @@ mod tests {
path: Arc::from(Path::new("b0.5")), path: Arc::from(Path::new("b0.5")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 1.0, score: 1.0,
@ -1009,6 +1013,7 @@ mod tests {
path: Arc::from(Path::new("c1.0")), path: Arc::from(Path::new("c1.0")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 1.0, score: 1.0,
@ -1017,6 +1022,7 @@ mod tests {
path: Arc::from(Path::new("a1.0")), path: Arc::from(Path::new("a1.0")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 0.5, score: 0.5,
@ -1025,6 +1031,7 @@ mod tests {
path: Arc::from(Path::new("a0.5")), path: Arc::from(Path::new("a0.5")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 1.0, score: 1.0,
@ -1033,6 +1040,7 @@ mod tests {
path: Arc::from(Path::new("b1.0")), path: Arc::from(Path::new("b1.0")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
]; ];
file_finder_sorted_output.sort_by(|a, b| b.cmp(a)); 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: Arc::from(Path::new("a1.0")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 1.0, score: 1.0,
@ -1055,6 +1064,7 @@ mod tests {
path: Arc::from(Path::new("b1.0")), path: Arc::from(Path::new("b1.0")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 1.0, score: 1.0,
@ -1063,6 +1073,7 @@ mod tests {
path: Arc::from(Path::new("c1.0")), path: Arc::from(Path::new("c1.0")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 0.5, score: 0.5,
@ -1071,6 +1082,7 @@ mod tests {
path: Arc::from(Path::new("a0.5")), path: Arc::from(Path::new("a0.5")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
ProjectPanelOrdMatch(PathMatch { ProjectPanelOrdMatch(PathMatch {
score: 0.5, score: 0.5,
@ -1079,6 +1091,7 @@ mod tests {
path: Arc::from(Path::new("b0.5")), path: Arc::from(Path::new("b0.5")),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: 0, distance_to_relative_ancestor: 0,
is_dir: false,
}), }),
] ]
); );

View File

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

View File

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

View File

@ -13,6 +13,7 @@ use crate::{
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct PathMatchCandidate<'a> { pub struct PathMatchCandidate<'a> {
pub is_dir: bool,
pub path: &'a Path, pub path: &'a Path,
pub char_bag: CharBag, pub char_bag: CharBag,
} }
@ -24,6 +25,7 @@ pub struct PathMatch {
pub worktree_id: usize, pub worktree_id: usize,
pub path: Arc<Path>, pub path: Arc<Path>,
pub path_prefix: Arc<str>, pub path_prefix: Arc<str>,
pub is_dir: bool,
/// Number of steps removed from a shared parent with the relative path /// Number of steps removed from a shared parent with the relative path
/// Used to order closer paths first in the search list /// Used to order closer paths first in the search list
pub distance_to_relative_ancestor: usize, pub distance_to_relative_ancestor: usize,
@ -119,6 +121,7 @@ pub fn match_fixed_path_set(
score, score,
worktree_id, worktree_id,
positions: Vec::new(), positions: Vec::new(),
is_dir: candidate.is_dir,
path: Arc::from(candidate.path), path: Arc::from(candidate.path),
path_prefix: Arc::default(), path_prefix: Arc::default(),
distance_to_relative_ancestor: usize::MAX, distance_to_relative_ancestor: usize::MAX,
@ -195,6 +198,7 @@ pub async fn match_path_sets<'a, Set: PathMatchCandidateSet<'a>>(
worktree_id, worktree_id,
positions: Vec::new(), positions: Vec::new(),
path: Arc::from(candidate.path), path: Arc::from(candidate.path),
is_dir: candidate.is_dir,
path_prefix: candidate_set.prefix(), path_prefix: candidate_set.prefix(),
distance_to_relative_ancestor: relative_to.as_ref().map_or( distance_to_relative_ancestor: relative_to.as_ref().map_or(
usize::MAX, usize::MAX,

View File

@ -1560,6 +1560,22 @@ impl CodeLabel {
self.runs.push((start_ix..end_ix, highlight)); 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 { impl Ord for LanguageMatcher {

View File

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

View File

@ -114,10 +114,9 @@ use task::{
}; };
use terminals::Terminals; use terminals::Terminals;
use text::{Anchor, BufferId, LineEnding}; use text::{Anchor, BufferId, LineEnding};
use unicase::UniCase;
use util::{ use util::{
debug_panic, defer, maybe, merge_json_value_into, parse_env_output, post_inc, debug_panic, defer, maybe, merge_json_value_into, parse_env_output, paths::compare_paths,
NumericPrefixWithSuffix, ResultExt, TryFutureExt as _, post_inc, ResultExt, TryFutureExt as _,
}; };
use worktree::{CreatedEntry, Snapshot, Traversal}; use worktree::{CreatedEntry, Snapshot, Traversal};
use worktree_store::{WorktreeStore, WorktreeStoreEvent}; use worktree_store::{WorktreeStore, WorktreeStoreEvent};
@ -413,6 +412,28 @@ pub struct InlayHint {
pub resolve_state: ResolveState, 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 /// A completion provided by a language server
#[derive(Clone)] #[derive(Clone)]
pub struct Completion { pub struct Completion {
@ -429,7 +450,7 @@ pub struct Completion {
/// The raw completion provided by the language server. /// The raw completion provided by the language server.
pub lsp_completion: lsp::CompletionItem, pub lsp_completion: lsp::CompletionItem,
/// An optional callback to invoke when this completion is confirmed. /// 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. /// If true, the editor will show a new completion menu after this completion is confirmed.
pub show_new_completions_on_confirm: bool, pub show_new_completions_on_confirm: bool,
} }
@ -11011,10 +11032,12 @@ impl<'a> Iterator for PathMatchCandidateSetIter<'a> {
fn next(&mut self) -> Option<Self::Item> { fn next(&mut self) -> Option<Self::Item> {
self.traversal.next().map(|entry| match entry.kind { self.traversal.next().map(|entry| match entry.kind {
EntryKind::Dir => fuzzy::PathMatchCandidate { EntryKind::Dir => fuzzy::PathMatchCandidate {
is_dir: true,
path: &entry.path, path: &entry.path,
char_bag: CharBag::from_iter(entry.path.to_string_lossy().to_lowercase().chars()), char_bag: CharBag::from_iter(entry.path.to_string_lossy().to_lowercase().chars()),
}, },
EntryKind::File(char_bag) => fuzzy::PathMatchCandidate { EntryKind::File(char_bag) => fuzzy::PathMatchCandidate {
is_dir: false,
path: &entry.path, path: &entry.path,
char_bag, 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),
]
);
}
}

View File

@ -1,3 +1,4 @@
use std::cmp;
use std::sync::OnceLock; use std::sync::OnceLock;
use std::{ use std::{
ffi::OsStr, ffi::OsStr,
@ -8,6 +9,9 @@ use std::{
use globset::{Glob, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use unicase::UniCase;
use crate::{maybe, NumericPrefixWithSuffix};
/// Returns the path to the user's home directory. /// Returns the path to the user's home directory.
pub fn home_dir() -> &'static PathBuf { 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn path_with_position_parsing_positive() { fn path_with_position_parsing_positive() {
let input_and_expected = [ let input_and_expected = [