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

View file

@ -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,
})
})

View file

@ -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,
}]);

View file

@ -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())
})

View file

@ -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,
})

View file

@ -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,
})

View file

@ -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,
}]))