assistant: Add glob matching for file
slash command (#13137)
This PR adds support for glob matching when using the `file` slash command inside the assistant panel: https://github.com/zed-industries/zed/assets/53836821/696612d2-486c-4ab0-bf3c-d23a3eeefd25 Release Notes: - N/A
This commit is contained in:
parent
c793bbde84
commit
d5735dab9a
4 changed files with 220 additions and 56 deletions
|
@ -1,5 +1,5 @@
|
||||||
use super::{
|
use super::{
|
||||||
file_command::{codeblock_fence_for_path, FilePlaceholder},
|
file_command::{codeblock_fence_for_path, EntryPlaceholder},
|
||||||
SlashCommand, SlashCommandOutput,
|
SlashCommand, SlashCommandOutput,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
@ -84,9 +84,10 @@ impl SlashCommand for ActiveSlashCommand {
|
||||||
sections: vec![SlashCommandOutputSection {
|
sections: vec![SlashCommandOutputSection {
|
||||||
range,
|
range,
|
||||||
render_placeholder: Arc::new(move |id, unfold, _| {
|
render_placeholder: Arc::new(move |id, unfold, _| {
|
||||||
FilePlaceholder {
|
EntryPlaceholder {
|
||||||
id,
|
id,
|
||||||
path: path.clone(),
|
path: path.clone(),
|
||||||
|
is_directory: false,
|
||||||
line_range: None,
|
line_range: None,
|
||||||
unfold,
|
unfold,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
use super::{SlashCommand, SlashCommandOutput};
|
use super::{SlashCommand, SlashCommandOutput};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use assistant_slash_command::SlashCommandOutputSection;
|
use assistant_slash_command::SlashCommandOutputSection;
|
||||||
|
use fs::Fs;
|
||||||
use fuzzy::PathMatch;
|
use fuzzy::PathMatch;
|
||||||
use gpui::{AppContext, RenderOnce, SharedString, Task, View, WeakView};
|
use gpui::{AppContext, Model, RenderOnce, SharedString, Task, View, WeakView};
|
||||||
use language::{LineEnding, LspAdapterDelegate};
|
use language::{LineEnding, LspAdapterDelegate};
|
||||||
use project::PathMatchCandidateSet;
|
use project::{PathMatchCandidateSet, Worktree};
|
||||||
use std::{
|
use std::{
|
||||||
fmt::Write,
|
fmt::Write,
|
||||||
ops::Range,
|
ops::Range,
|
||||||
|
@ -12,6 +13,7 @@ use std::{
|
||||||
sync::{atomic::AtomicBool, Arc},
|
sync::{atomic::AtomicBool, Arc},
|
||||||
};
|
};
|
||||||
use ui::{prelude::*, ButtonLike, ElevationIndex};
|
use ui::{prelude::*, ButtonLike, ElevationIndex};
|
||||||
|
use util::{paths::PathMatcher, ResultExt};
|
||||||
use workspace::Workspace;
|
use workspace::Workspace;
|
||||||
|
|
||||||
pub(crate) struct FileSlashCommand;
|
pub(crate) struct FileSlashCommand;
|
||||||
|
@ -59,7 +61,7 @@ impl FileSlashCommand {
|
||||||
.root_entry()
|
.root_entry()
|
||||||
.map_or(false, |entry| entry.is_ignored),
|
.map_or(false, |entry| entry.is_ignored),
|
||||||
include_root_name: true,
|
include_root_name: true,
|
||||||
candidates: project::Candidates::Files,
|
candidates: project::Candidates::Entries,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
@ -140,69 +142,223 @@ impl SlashCommand for FileSlashCommand {
|
||||||
return Task::ready(Err(anyhow!("missing path")));
|
return Task::ready(Err(anyhow!("missing path")));
|
||||||
};
|
};
|
||||||
|
|
||||||
let path = PathBuf::from(argument);
|
|
||||||
let abs_path = workspace
|
|
||||||
.read(cx)
|
|
||||||
.visible_worktrees(cx)
|
|
||||||
.find_map(|worktree| {
|
|
||||||
let worktree = worktree.read(cx);
|
|
||||||
let worktree_root_path = Path::new(worktree.root_name());
|
|
||||||
let relative_path = path.strip_prefix(worktree_root_path).ok()?;
|
|
||||||
worktree.absolutize(&relative_path).ok()
|
|
||||||
});
|
|
||||||
|
|
||||||
let Some(abs_path) = abs_path else {
|
|
||||||
return Task::ready(Err(anyhow!("missing path")));
|
|
||||||
};
|
|
||||||
|
|
||||||
let fs = workspace.read(cx).app_state().fs.clone();
|
let fs = workspace.read(cx).app_state().fs.clone();
|
||||||
let text = cx.background_executor().spawn({
|
let task = collect_files(
|
||||||
let path = path.clone();
|
workspace.read(cx).visible_worktrees(cx).collect(),
|
||||||
async move {
|
argument,
|
||||||
let mut content = fs.load(&abs_path).await?;
|
fs,
|
||||||
LineEnding::normalize(&mut content);
|
cx,
|
||||||
let mut output = String::new();
|
);
|
||||||
output.push_str(&codeblock_fence_for_path(Some(&path), None));
|
|
||||||
output.push_str(&content);
|
|
||||||
if !output.ends_with('\n') {
|
|
||||||
output.push('\n');
|
|
||||||
}
|
|
||||||
output.push_str("```");
|
|
||||||
anyhow::Ok(output)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
cx.foreground_executor().spawn(async move {
|
cx.foreground_executor().spawn(async move {
|
||||||
let text = text.await?;
|
let (text, ranges) = task.await?;
|
||||||
let range = 0..text.len();
|
|
||||||
Ok(SlashCommandOutput {
|
Ok(SlashCommandOutput {
|
||||||
text,
|
text,
|
||||||
sections: vec![SlashCommandOutputSection {
|
sections: ranges
|
||||||
range,
|
.into_iter()
|
||||||
render_placeholder: Arc::new(move |id, unfold, _cx| {
|
.map(|(range, path, entry_type)| SlashCommandOutputSection {
|
||||||
FilePlaceholder {
|
range,
|
||||||
path: Some(path.clone()),
|
render_placeholder: Arc::new(move |id, unfold, _cx| {
|
||||||
line_range: None,
|
EntryPlaceholder {
|
||||||
id,
|
path: Some(path.clone()),
|
||||||
unfold,
|
is_directory: entry_type == EntryType::Directory,
|
||||||
}
|
line_range: None,
|
||||||
.into_any_element()
|
id,
|
||||||
}),
|
unfold,
|
||||||
}],
|
}
|
||||||
|
.into_any_element()
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
run_commands_in_text: false,
|
run_commands_in_text: false,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq)]
|
||||||
|
enum EntryType {
|
||||||
|
File,
|
||||||
|
Directory,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn collect_files(
|
||||||
|
worktrees: Vec<Model<Worktree>>,
|
||||||
|
glob_input: &str,
|
||||||
|
fs: Arc<dyn Fs>,
|
||||||
|
cx: &mut AppContext,
|
||||||
|
) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
|
||||||
|
let Ok(matcher) = PathMatcher::new(glob_input) else {
|
||||||
|
return Task::ready(Err(anyhow!("invalid path")));
|
||||||
|
};
|
||||||
|
|
||||||
|
let path = PathBuf::try_from(glob_input).ok();
|
||||||
|
let file_path = if let Some(path) = &path {
|
||||||
|
worktrees.iter().find_map(|worktree| {
|
||||||
|
let worktree = worktree.read(cx);
|
||||||
|
let worktree_root_path = Path::new(worktree.root_name());
|
||||||
|
let relative_path = path.strip_prefix(worktree_root_path).ok()?;
|
||||||
|
worktree.absolutize(&relative_path).ok()
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(abs_path) = file_path {
|
||||||
|
if abs_path.is_file() {
|
||||||
|
let filename = path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
return cx.background_executor().spawn(async move {
|
||||||
|
let mut text = String::new();
|
||||||
|
collect_file_content(&mut text, fs, filename.clone(), abs_path.clone().into())
|
||||||
|
.await?;
|
||||||
|
let text_range = 0..text.len();
|
||||||
|
Ok((
|
||||||
|
text,
|
||||||
|
vec![(text_range, path.unwrap_or_default(), EntryType::File)],
|
||||||
|
))
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let snapshots = worktrees
|
||||||
|
.iter()
|
||||||
|
.map(|worktree| worktree.read(cx).snapshot())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
cx.background_executor().spawn(async move {
|
||||||
|
let mut text = String::new();
|
||||||
|
let mut ranges = Vec::new();
|
||||||
|
for snapshot in snapshots {
|
||||||
|
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_buf = PathBuf::new();
|
||||||
|
path_buf.push(snapshot.root_name());
|
||||||
|
path_buf.push(&entry.path);
|
||||||
|
if !matcher.is_match(&path_buf) {
|
||||||
|
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_buf.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_buf.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() {
|
||||||
|
if let Some(abs_path) = snapshot.absolutize(&entry.path).log_err() {
|
||||||
|
let prev_len = text.len();
|
||||||
|
collect_file_content(
|
||||||
|
&mut text,
|
||||||
|
fs.clone(),
|
||||||
|
filename.clone(),
|
||||||
|
abs_path.into(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
ranges.push((
|
||||||
|
prev_len..text.len(),
|
||||||
|
PathBuf::from(filename),
|
||||||
|
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn collect_file_content(
|
||||||
|
buffer: &mut String,
|
||||||
|
fs: Arc<dyn Fs>,
|
||||||
|
filename: String,
|
||||||
|
abs_path: Arc<Path>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut content = fs.load(&abs_path).await?;
|
||||||
|
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("```");
|
||||||
|
anyhow::Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(IntoElement)]
|
#[derive(IntoElement)]
|
||||||
pub struct FilePlaceholder {
|
pub struct EntryPlaceholder {
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
|
pub is_directory: bool,
|
||||||
pub line_range: Option<Range<u32>>,
|
pub line_range: Option<Range<u32>>,
|
||||||
pub id: ElementId,
|
pub id: ElementId,
|
||||||
pub unfold: Arc<dyn Fn(&mut WindowContext)>,
|
pub unfold: Arc<dyn Fn(&mut WindowContext)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenderOnce for FilePlaceholder {
|
impl RenderOnce for EntryPlaceholder {
|
||||||
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
|
fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
|
||||||
let unfold = self.unfold;
|
let unfold = self.unfold;
|
||||||
let title = if let Some(path) = self.path.as_ref() {
|
let title = if let Some(path) = self.path.as_ref() {
|
||||||
|
@ -210,11 +366,16 @@ impl RenderOnce for FilePlaceholder {
|
||||||
} else {
|
} else {
|
||||||
SharedString::from("untitled")
|
SharedString::from("untitled")
|
||||||
};
|
};
|
||||||
|
let icon = if self.is_directory {
|
||||||
|
IconName::Folder
|
||||||
|
} else {
|
||||||
|
IconName::File
|
||||||
|
};
|
||||||
|
|
||||||
ButtonLike::new(self.id)
|
ButtonLike::new(self.id)
|
||||||
.style(ButtonStyle::Filled)
|
.style(ButtonStyle::Filled)
|
||||||
.layer(ElevationIndex::ElevatedSurface)
|
.layer(ElevationIndex::ElevatedSurface)
|
||||||
.child(Icon::new(IconName::File))
|
.child(Icon::new(icon))
|
||||||
.child(Label::new(title))
|
.child(Label::new(title))
|
||||||
.when_some(self.line_range, |button, line_range| {
|
.when_some(self.line_range, |button, line_range| {
|
||||||
button.child(Label::new(":")).child(Label::new(format!(
|
button.child(Label::new(":")).child(Label::new(format!(
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use super::{
|
use super::{
|
||||||
file_command::{codeblock_fence_for_path, FilePlaceholder},
|
file_command::{codeblock_fence_for_path, EntryPlaceholder},
|
||||||
SlashCommand, SlashCommandOutput,
|
SlashCommand, SlashCommandOutput,
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
@ -155,9 +155,10 @@ impl SlashCommand for SearchSlashCommand {
|
||||||
sections.push(SlashCommandOutputSection {
|
sections.push(SlashCommandOutputSection {
|
||||||
range: section_start_ix..section_end_ix,
|
range: section_start_ix..section_end_ix,
|
||||||
render_placeholder: Arc::new(move |id, unfold, _| {
|
render_placeholder: Arc::new(move |id, unfold, _| {
|
||||||
FilePlaceholder {
|
EntryPlaceholder {
|
||||||
id,
|
id,
|
||||||
path: Some(full_path.clone()),
|
path: Some(full_path.clone()),
|
||||||
|
is_directory: false,
|
||||||
line_range: Some(start_row..end_row),
|
line_range: Some(start_row..end_row),
|
||||||
unfold,
|
unfold,
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use super::{
|
use super::{
|
||||||
file_command::{codeblock_fence_for_path, FilePlaceholder},
|
file_command::{codeblock_fence_for_path, EntryPlaceholder},
|
||||||
SlashCommand, SlashCommandOutput,
|
SlashCommand, SlashCommandOutput,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
|
@ -93,9 +93,10 @@ impl SlashCommand for TabsSlashCommand {
|
||||||
sections.push(SlashCommandOutputSection {
|
sections.push(SlashCommandOutputSection {
|
||||||
range: section_start_ix..section_end_ix,
|
range: section_start_ix..section_end_ix,
|
||||||
render_placeholder: Arc::new(move |id, unfold, _| {
|
render_placeholder: Arc::new(move |id, unfold, _| {
|
||||||
FilePlaceholder {
|
EntryPlaceholder {
|
||||||
id,
|
id,
|
||||||
path: full_path.clone(),
|
path: full_path.clone(),
|
||||||
|
is_directory: false,
|
||||||
line_range: None,
|
line_range: None,
|
||||||
unfold,
|
unfold,
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue