diff --git a/crates/assistant/src/slash_command/active_command.rs b/crates/assistant/src/slash_command/active_command.rs index 7a80cad88f..609dc83011 100644 --- a/crates/assistant/src/slash_command/active_command.rs +++ b/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, } diff --git a/crates/assistant/src/slash_command/file_command.rs b/crates/assistant/src/slash_command/file_command.rs index 1508612faa..787c98c358 100644 --- a/crates/assistant/src/slash_command/file_command.rs +++ b/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::>(); @@ -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>, + glob_input: &str, + fs: Arc, + cx: &mut AppContext, +) -> Task, 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::>(); + 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, 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, + filename: String, + abs_path: Arc, +) -> 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, + pub is_directory: bool, pub line_range: Option>, pub id: ElementId, pub unfold: Arc, } -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!( diff --git a/crates/assistant/src/slash_command/search_command.rs b/crates/assistant/src/slash_command/search_command.rs index f55af0a9b8..d0c2deeb23 100644 --- a/crates/assistant/src/slash_command/search_command.rs +++ b/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, } diff --git a/crates/assistant/src/slash_command/tabs_command.rs b/crates/assistant/src/slash_command/tabs_command.rs index f690411468..5945a9fd3f 100644 --- a/crates/assistant/src/slash_command/tabs_command.rs +++ b/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, }