assistant: Add glob matching for `file` slash command (#13137)

Bennet Bo Fenner created

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

Change summary

crates/assistant/src/slash_command/active_command.rs |   5 
crates/assistant/src/slash_command/file_command.rs   | 261 +++++++++++--
crates/assistant/src/slash_command/search_command.rs |   5 
crates/assistant/src/slash_command/tabs_command.rs   |   5 
4 files changed, 220 insertions(+), 56 deletions(-)

Detailed changes

crates/assistant/src/slash_command/active_command.rs 🔗

@@ -1,5 +1,5 @@
 use super::{
-    file_command::{codeblock_fence_for_path, FilePlaceholder},
+    file_command::{codeblock_fence_for_path, EntryPlaceholder},
     SlashCommand, SlashCommandOutput,
 };
 use anyhow::{anyhow, Result};
@@ -84,9 +84,10 @@ impl SlashCommand for ActiveSlashCommand {
                     sections: vec![SlashCommandOutputSection {
                         range,
                         render_placeholder: Arc::new(move |id, unfold, _| {
-                            FilePlaceholder {
+                            EntryPlaceholder {
                                 id,
                                 path: path.clone(),
+                                is_directory: false,
                                 line_range: None,
                                 unfold,
                             }

crates/assistant/src/slash_command/file_command.rs 🔗

@@ -1,10 +1,11 @@
 use super::{SlashCommand, SlashCommandOutput};
 use anyhow::{anyhow, Result};
 use assistant_slash_command::SlashCommandOutputSection;
+use fs::Fs;
 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 project::PathMatchCandidateSet;
+use project::{PathMatchCandidateSet, Worktree};
 use std::{
     fmt::Write,
     ops::Range,
@@ -12,6 +13,7 @@ use std::{
     sync::{atomic::AtomicBool, Arc},
 };
 use ui::{prelude::*, ButtonLike, ElevationIndex};
+use util::{paths::PathMatcher, ResultExt};
 use workspace::Workspace;
 
 pub(crate) struct FileSlashCommand;
@@ -59,7 +61,7 @@ impl FileSlashCommand {
                             .root_entry()
                             .map_or(false, |entry| entry.is_ignored),
                         include_root_name: true,
-                        candidates: project::Candidates::Files,
+                        candidates: project::Candidates::Entries,
                     }
                 })
                 .collect::<Vec<_>>();
@@ -140,69 +142,223 @@ impl SlashCommand for FileSlashCommand {
             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 text = cx.background_executor().spawn({
-            let path = path.clone();
-            async move {
-                let mut content = fs.load(&abs_path).await?;
-                LineEnding::normalize(&mut content);
-                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)
-            }
-        });
+        let task = collect_files(
+            workspace.read(cx).visible_worktrees(cx).collect(),
+            argument,
+            fs,
+            cx,
+        );
+
         cx.foreground_executor().spawn(async move {
-            let text = text.await?;
-            let range = 0..text.len();
+            let (text, ranges) = task.await?;
             Ok(SlashCommandOutput {
                 text,
-                sections: vec![SlashCommandOutputSection {
-                    range,
-                    render_placeholder: Arc::new(move |id, unfold, _cx| {
-                        FilePlaceholder {
-                            path: Some(path.clone()),
-                            line_range: None,
-                            id,
-                            unfold,
-                        }
-                        .into_any_element()
-                    }),
-                }],
+                sections: ranges
+                    .into_iter()
+                    .map(|(range, path, entry_type)| SlashCommandOutputSection {
+                        range,
+                        render_placeholder: Arc::new(move |id, unfold, _cx| {
+                            EntryPlaceholder {
+                                path: Some(path.clone()),
+                                is_directory: entry_type == EntryType::Directory,
+                                line_range: None,
+                                id,
+                                unfold,
+                            }
+                            .into_any_element()
+                        }),
+                    })
+                    .collect(),
                 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)]
-pub struct FilePlaceholder {
+pub struct EntryPlaceholder {
     pub path: Option<PathBuf>,
+    pub is_directory: bool,
     pub line_range: Option<Range<u32>>,
     pub id: ElementId,
     pub unfold: Arc<dyn Fn(&mut WindowContext)>,
 }
 
-impl RenderOnce for FilePlaceholder {
+impl RenderOnce for EntryPlaceholder {
     fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
         let unfold = self.unfold;
         let title = if let Some(path) = self.path.as_ref() {
@@ -210,11 +366,16 @@ impl RenderOnce for FilePlaceholder {
         } else {
             SharedString::from("untitled")
         };
+        let icon = if self.is_directory {
+            IconName::Folder
+        } else {
+            IconName::File
+        };
 
         ButtonLike::new(self.id)
             .style(ButtonStyle::Filled)
             .layer(ElevationIndex::ElevatedSurface)
-            .child(Icon::new(IconName::File))
+            .child(Icon::new(icon))
             .child(Label::new(title))
             .when_some(self.line_range, |button, line_range| {
                 button.child(Label::new(":")).child(Label::new(format!(

crates/assistant/src/slash_command/search_command.rs 🔗

@@ -1,5 +1,5 @@
 use super::{
-    file_command::{codeblock_fence_for_path, FilePlaceholder},
+    file_command::{codeblock_fence_for_path, EntryPlaceholder},
     SlashCommand, SlashCommandOutput,
 };
 use anyhow::Result;
@@ -155,9 +155,10 @@ impl SlashCommand for SearchSlashCommand {
                         sections.push(SlashCommandOutputSection {
                             range: section_start_ix..section_end_ix,
                             render_placeholder: Arc::new(move |id, unfold, _| {
-                                FilePlaceholder {
+                                EntryPlaceholder {
                                     id,
                                     path: Some(full_path.clone()),
+                                    is_directory: false,
                                     line_range: Some(start_row..end_row),
                                     unfold,
                                 }

crates/assistant/src/slash_command/tabs_command.rs 🔗

@@ -1,5 +1,5 @@
 use super::{
-    file_command::{codeblock_fence_for_path, FilePlaceholder},
+    file_command::{codeblock_fence_for_path, EntryPlaceholder},
     SlashCommand, SlashCommandOutput,
 };
 use anyhow::{anyhow, Result};
@@ -93,9 +93,10 @@ impl SlashCommand for TabsSlashCommand {
                     sections.push(SlashCommandOutputSection {
                         range: section_start_ix..section_end_ix,
                         render_placeholder: Arc::new(move |id, unfold, _| {
-                            FilePlaceholder {
+                            EntryPlaceholder {
                                 id,
                                 path: full_path.clone(),
+                                is_directory: false,
                                 line_range: None,
                                 unfold,
                             }