* renames `/tabs` to `/tab` * allows to insert multiple tabs when fuzzy matching by the names * improve slash command completion API, introduce a notion of multiple arguments * properly fire off commands on arguments' completions with `run_command: true` Release Notes: - N/A --------- Co-authored-by: Marshall Bowers <marshall@zed.dev>
416 lines
15 KiB
Rust
416 lines
15 KiB
Rust
use super::{diagnostics_command::write_single_file_diagnostics, SlashCommand, SlashCommandOutput};
|
|
use anyhow::{anyhow, Result};
|
|
use assistant_slash_command::{ArgumentCompletion, SlashCommandOutputSection};
|
|
use fuzzy::PathMatch;
|
|
use gpui::{AppContext, Model, Task, View, WeakView};
|
|
use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
|
|
use project::{PathMatchCandidateSet, Project};
|
|
use std::{
|
|
fmt::Write,
|
|
ops::Range,
|
|
path::{Path, PathBuf},
|
|
sync::{atomic::AtomicBool, Arc},
|
|
};
|
|
use ui::prelude::*;
|
|
use util::{paths::PathMatcher, ResultExt};
|
|
use workspace::Workspace;
|
|
|
|
pub(crate) struct FileSlashCommand;
|
|
|
|
impl FileSlashCommand {
|
|
fn search_paths(
|
|
&self,
|
|
query: String,
|
|
cancellation_flag: Arc<AtomicBool>,
|
|
workspace: &View<Workspace>,
|
|
cx: &mut AppContext,
|
|
) -> Task<Vec<PathMatch>> {
|
|
if query.is_empty() {
|
|
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, 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);
|
|
Some(PathMatch {
|
|
score: 0.,
|
|
positions: Vec::new(),
|
|
worktree_id: entry.worktree_id.to_usize(),
|
|
path: full_path.into(),
|
|
path_prefix: path_prefix.clone(),
|
|
distance_to_relative_ancestor: 0,
|
|
is_dir,
|
|
})
|
|
})
|
|
.collect(),
|
|
)
|
|
} else {
|
|
let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
|
|
let candidate_sets = worktrees
|
|
.into_iter()
|
|
.map(|worktree| {
|
|
let worktree = worktree.read(cx);
|
|
|
|
PathMatchCandidateSet {
|
|
snapshot: worktree.snapshot(),
|
|
include_ignored: worktree
|
|
.root_entry()
|
|
.map_or(false, |entry| entry.is_ignored),
|
|
include_root_name: true,
|
|
candidates: project::Candidates::Entries,
|
|
}
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
let executor = cx.background_executor().clone();
|
|
cx.foreground_executor().spawn(async move {
|
|
fuzzy::match_path_sets(
|
|
candidate_sets.as_slice(),
|
|
query.as_str(),
|
|
None,
|
|
false,
|
|
100,
|
|
&cancellation_flag,
|
|
executor,
|
|
)
|
|
.await
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SlashCommand for FileSlashCommand {
|
|
fn name(&self) -> String {
|
|
"file".into()
|
|
}
|
|
|
|
fn description(&self) -> String {
|
|
"insert file".into()
|
|
}
|
|
|
|
fn menu_text(&self) -> String {
|
|
"Insert File".into()
|
|
}
|
|
|
|
fn requires_argument(&self) -> bool {
|
|
true
|
|
}
|
|
|
|
fn complete_argument(
|
|
self: Arc<Self>,
|
|
arguments: &[String],
|
|
cancellation_flag: Arc<AtomicBool>,
|
|
workspace: Option<WeakView<Workspace>>,
|
|
cx: &mut WindowContext,
|
|
) -> Task<Result<Vec<ArgumentCompletion>>> {
|
|
let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
|
|
return Task::ready(Err(anyhow!("workspace was dropped")));
|
|
};
|
|
|
|
let paths = self.search_paths(
|
|
arguments.last().cloned().unwrap_or_default(),
|
|
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()
|
|
.filter_map(|path_match| {
|
|
let text = format!(
|
|
"{}{}",
|
|
path_match.path_prefix,
|
|
path_match.path.to_string_lossy()
|
|
);
|
|
|
|
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())
|
|
})
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
arguments: &[String],
|
|
workspace: WeakView<Workspace>,
|
|
_delegate: Option<Arc<dyn LspAdapterDelegate>>,
|
|
cx: &mut WindowContext,
|
|
) -> Task<Result<SlashCommandOutput>> {
|
|
let Some(workspace) = workspace.upgrade() else {
|
|
return Task::ready(Err(anyhow!("workspace was dropped")));
|
|
};
|
|
|
|
let Some(argument) = arguments.first() else {
|
|
return Task::ready(Err(anyhow!("missing path")));
|
|
};
|
|
|
|
let task = collect_files(workspace.read(cx).project().clone(), argument, cx);
|
|
|
|
cx.foreground_executor().spawn(async move {
|
|
let (text, ranges) = task.await?;
|
|
Ok(SlashCommandOutput {
|
|
text,
|
|
sections: ranges
|
|
.into_iter()
|
|
.map(|(range, path, entry_type)| {
|
|
build_entry_output_section(
|
|
range,
|
|
Some(&path),
|
|
entry_type == EntryType::Directory,
|
|
None,
|
|
)
|
|
})
|
|
.collect(),
|
|
run_commands_in_text: true,
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum EntryType {
|
|
File,
|
|
Directory,
|
|
}
|
|
|
|
fn collect_files(
|
|
project: Model<Project>,
|
|
glob_input: &str,
|
|
cx: &mut AppContext,
|
|
) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
|
|
let Ok(matcher) = PathMatcher::new(&[glob_input.to_owned()]) else {
|
|
return Task::ready(Err(anyhow!("invalid path")));
|
|
};
|
|
|
|
let project_handle = project.downgrade();
|
|
let snapshots = project
|
|
.read(cx)
|
|
.worktrees(cx)
|
|
.map(|worktree| worktree.read(cx).snapshot())
|
|
.collect::<Vec<_>>();
|
|
cx.spawn(|mut cx| async move {
|
|
let mut text = String::new();
|
|
let mut ranges = Vec::new();
|
|
for snapshot in snapshots {
|
|
let worktree_id = snapshot.id();
|
|
let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
|
|
let mut folded_directory_names_stack = Vec::new();
|
|
let mut is_top_level_directory = true;
|
|
for entry in snapshot.entries(false, 0) {
|
|
let mut path_including_worktree_name = PathBuf::new();
|
|
path_including_worktree_name.push(snapshot.root_name());
|
|
path_including_worktree_name.push(&entry.path);
|
|
if !matcher.is_match(&path_including_worktree_name) {
|
|
continue;
|
|
}
|
|
|
|
while let Some((dir, _, _)) = directory_stack.last() {
|
|
if entry.path.starts_with(dir) {
|
|
break;
|
|
}
|
|
let (_, entry_name, start) = directory_stack.pop().unwrap();
|
|
ranges.push((
|
|
start..text.len().saturating_sub(1),
|
|
PathBuf::from(entry_name),
|
|
EntryType::Directory,
|
|
));
|
|
}
|
|
|
|
let filename = entry
|
|
.path
|
|
.file_name()
|
|
.unwrap_or_default()
|
|
.to_str()
|
|
.unwrap_or_default()
|
|
.to_string();
|
|
|
|
if entry.is_dir() {
|
|
// Auto-fold directories that contain no files
|
|
let mut child_entries = snapshot.child_entries(&entry.path);
|
|
if let Some(child) = child_entries.next() {
|
|
if child_entries.next().is_none() && child.kind.is_dir() {
|
|
if is_top_level_directory {
|
|
is_top_level_directory = false;
|
|
folded_directory_names_stack.push(
|
|
path_including_worktree_name.to_string_lossy().to_string(),
|
|
);
|
|
} else {
|
|
folded_directory_names_stack.push(filename.to_string());
|
|
}
|
|
continue;
|
|
}
|
|
} else {
|
|
// Skip empty directories
|
|
folded_directory_names_stack.clear();
|
|
continue;
|
|
}
|
|
let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
|
|
let entry_start = text.len();
|
|
if prefix_paths.is_empty() {
|
|
if is_top_level_directory {
|
|
text.push_str(&path_including_worktree_name.to_string_lossy());
|
|
is_top_level_directory = false;
|
|
} else {
|
|
text.push_str(&filename);
|
|
}
|
|
directory_stack.push((entry.path.clone(), filename, entry_start));
|
|
} else {
|
|
let entry_name = format!("{}/{}", prefix_paths, &filename);
|
|
text.push_str(&entry_name);
|
|
directory_stack.push((entry.path.clone(), entry_name, entry_start));
|
|
}
|
|
text.push('\n');
|
|
} else if entry.is_file() {
|
|
let Some(open_buffer_task) = project_handle
|
|
.update(&mut cx, |project, cx| {
|
|
project.open_buffer((worktree_id, &entry.path), cx)
|
|
})
|
|
.ok()
|
|
else {
|
|
continue;
|
|
};
|
|
if let Some(buffer) = open_buffer_task.await.log_err() {
|
|
let buffer_snapshot =
|
|
cx.read_model(&buffer, |buffer, _| buffer.snapshot())?;
|
|
let prev_len = text.len();
|
|
collect_file_content(
|
|
&mut text,
|
|
&buffer_snapshot,
|
|
path_including_worktree_name.to_string_lossy().to_string(),
|
|
);
|
|
text.push('\n');
|
|
if !write_single_file_diagnostics(
|
|
&mut text,
|
|
Some(&path_including_worktree_name),
|
|
&buffer_snapshot,
|
|
) {
|
|
text.pop();
|
|
}
|
|
ranges.push((
|
|
prev_len..text.len(),
|
|
path_including_worktree_name,
|
|
EntryType::File,
|
|
));
|
|
text.push('\n');
|
|
}
|
|
}
|
|
}
|
|
|
|
while let Some((dir, _, start)) = directory_stack.pop() {
|
|
let mut root_path = PathBuf::new();
|
|
root_path.push(snapshot.root_name());
|
|
root_path.push(&dir);
|
|
ranges.push((start..text.len(), root_path, EntryType::Directory));
|
|
}
|
|
}
|
|
Ok((text, ranges))
|
|
})
|
|
}
|
|
|
|
fn collect_file_content(buffer: &mut String, snapshot: &BufferSnapshot, filename: String) {
|
|
let mut content = snapshot.text();
|
|
LineEnding::normalize(&mut content);
|
|
buffer.reserve(filename.len() + content.len() + 9);
|
|
buffer.push_str(&codeblock_fence_for_path(
|
|
Some(&PathBuf::from(filename)),
|
|
None,
|
|
));
|
|
buffer.push_str(&content);
|
|
if !buffer.ends_with('\n') {
|
|
buffer.push('\n');
|
|
}
|
|
buffer.push_str("```");
|
|
}
|
|
|
|
pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
|
|
let mut text = String::new();
|
|
write!(text, "```").unwrap();
|
|
|
|
if let Some(path) = path {
|
|
if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
|
|
write!(text, "{} ", extension).unwrap();
|
|
}
|
|
|
|
write!(text, "{}", path.display()).unwrap();
|
|
} else {
|
|
write!(text, "untitled").unwrap();
|
|
}
|
|
|
|
if let Some(row_range) = row_range {
|
|
write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap();
|
|
}
|
|
|
|
text.push('\n');
|
|
text
|
|
}
|
|
|
|
pub fn build_entry_output_section(
|
|
range: Range<usize>,
|
|
path: Option<&Path>,
|
|
is_directory: bool,
|
|
line_range: Option<Range<u32>>,
|
|
) -> SlashCommandOutputSection<usize> {
|
|
let mut label = if let Some(path) = path {
|
|
path.to_string_lossy().to_string()
|
|
} else {
|
|
"untitled".to_string()
|
|
};
|
|
if let Some(line_range) = line_range {
|
|
write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
|
|
}
|
|
|
|
let icon = if is_directory {
|
|
IconName::Folder
|
|
} else {
|
|
IconName::File
|
|
};
|
|
|
|
SlashCommandOutputSection {
|
|
range,
|
|
icon,
|
|
label: label.into(),
|
|
}
|
|
}
|