file_command.rs

  1use super::{SlashCommand, SlashCommandOutput};
  2use anyhow::{anyhow, Result};
  3use assistant_slash_command::SlashCommandOutputSection;
  4use fs::Fs;
  5use fuzzy::PathMatch;
  6use gpui::{AppContext, Model, RenderOnce, SharedString, Task, View, WeakView};
  7use language::{LineEnding, LspAdapterDelegate};
  8use project::{PathMatchCandidateSet, Worktree};
  9use std::{
 10    fmt::Write,
 11    ops::Range,
 12    path::{Path, PathBuf},
 13    sync::{atomic::AtomicBool, Arc},
 14};
 15use ui::{prelude::*, ButtonLike, ElevationIndex};
 16use util::{paths::PathMatcher, ResultExt};
 17use workspace::Workspace;
 18
 19pub(crate) struct FileSlashCommand;
 20
 21impl FileSlashCommand {
 22    fn search_paths(
 23        &self,
 24        query: String,
 25        cancellation_flag: Arc<AtomicBool>,
 26        workspace: &View<Workspace>,
 27        cx: &mut AppContext,
 28    ) -> Task<Vec<PathMatch>> {
 29        if query.is_empty() {
 30            let workspace = workspace.read(cx);
 31            let project = workspace.project().read(cx);
 32            let entries = workspace.recent_navigation_history(Some(10), cx);
 33            let path_prefix: Arc<str> = "".into();
 34            Task::ready(
 35                entries
 36                    .into_iter()
 37                    .filter_map(|(entry, _)| {
 38                        let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
 39                        let mut full_path = PathBuf::from(worktree.read(cx).root_name());
 40                        full_path.push(&entry.path);
 41                        Some(PathMatch {
 42                            score: 0.,
 43                            positions: Vec::new(),
 44                            worktree_id: entry.worktree_id.to_usize(),
 45                            path: full_path.into(),
 46                            path_prefix: path_prefix.clone(),
 47                            distance_to_relative_ancestor: 0,
 48                        })
 49                    })
 50                    .collect(),
 51            )
 52        } else {
 53            let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
 54            let candidate_sets = worktrees
 55                .into_iter()
 56                .map(|worktree| {
 57                    let worktree = worktree.read(cx);
 58                    PathMatchCandidateSet {
 59                        snapshot: worktree.snapshot(),
 60                        include_ignored: worktree
 61                            .root_entry()
 62                            .map_or(false, |entry| entry.is_ignored),
 63                        include_root_name: true,
 64                        candidates: project::Candidates::Entries,
 65                    }
 66                })
 67                .collect::<Vec<_>>();
 68
 69            let executor = cx.background_executor().clone();
 70            cx.foreground_executor().spawn(async move {
 71                fuzzy::match_path_sets(
 72                    candidate_sets.as_slice(),
 73                    query.as_str(),
 74                    None,
 75                    false,
 76                    100,
 77                    &cancellation_flag,
 78                    executor,
 79                )
 80                .await
 81            })
 82        }
 83    }
 84}
 85
 86impl SlashCommand for FileSlashCommand {
 87    fn name(&self) -> String {
 88        "file".into()
 89    }
 90
 91    fn description(&self) -> String {
 92        "insert file".into()
 93    }
 94
 95    fn menu_text(&self) -> String {
 96        "Insert File".into()
 97    }
 98
 99    fn requires_argument(&self) -> bool {
100        true
101    }
102
103    fn complete_argument(
104        &self,
105        query: String,
106        cancellation_flag: Arc<AtomicBool>,
107        workspace: Option<WeakView<Workspace>>,
108        cx: &mut AppContext,
109    ) -> Task<Result<Vec<String>>> {
110        let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
111            return Task::ready(Err(anyhow!("workspace was dropped")));
112        };
113
114        let paths = self.search_paths(query, cancellation_flag, &workspace, cx);
115        cx.background_executor().spawn(async move {
116            Ok(paths
117                .await
118                .into_iter()
119                .map(|path_match| {
120                    format!(
121                        "{}{}",
122                        path_match.path_prefix,
123                        path_match.path.to_string_lossy()
124                    )
125                })
126                .collect())
127        })
128    }
129
130    fn run(
131        self: Arc<Self>,
132        argument: Option<&str>,
133        workspace: WeakView<Workspace>,
134        _delegate: Arc<dyn LspAdapterDelegate>,
135        cx: &mut WindowContext,
136    ) -> Task<Result<SlashCommandOutput>> {
137        let Some(workspace) = workspace.upgrade() else {
138            return Task::ready(Err(anyhow!("workspace was dropped")));
139        };
140
141        let Some(argument) = argument else {
142            return Task::ready(Err(anyhow!("missing path")));
143        };
144
145        let fs = workspace.read(cx).app_state().fs.clone();
146        let task = collect_files(
147            workspace.read(cx).visible_worktrees(cx).collect(),
148            argument,
149            fs,
150            cx,
151        );
152
153        cx.foreground_executor().spawn(async move {
154            let (text, ranges) = task.await?;
155            Ok(SlashCommandOutput {
156                text,
157                sections: ranges
158                    .into_iter()
159                    .map(|(range, path, entry_type)| SlashCommandOutputSection {
160                        range,
161                        render_placeholder: Arc::new(move |id, unfold, _cx| {
162                            EntryPlaceholder {
163                                path: Some(path.clone()),
164                                is_directory: entry_type == EntryType::Directory,
165                                line_range: None,
166                                id,
167                                unfold,
168                            }
169                            .into_any_element()
170                        }),
171                    })
172                    .collect(),
173                run_commands_in_text: false,
174            })
175        })
176    }
177}
178
179#[derive(Clone, Copy, PartialEq)]
180enum EntryType {
181    File,
182    Directory,
183}
184
185fn collect_files(
186    worktrees: Vec<Model<Worktree>>,
187    glob_input: &str,
188    fs: Arc<dyn Fs>,
189    cx: &mut AppContext,
190) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
191    let Ok(matcher) = PathMatcher::new(glob_input) else {
192        return Task::ready(Err(anyhow!("invalid path")));
193    };
194
195    let path = PathBuf::try_from(glob_input).ok();
196    let file_path = if let Some(path) = &path {
197        worktrees.iter().find_map(|worktree| {
198            let worktree = worktree.read(cx);
199            let worktree_root_path = Path::new(worktree.root_name());
200            let relative_path = path.strip_prefix(worktree_root_path).ok()?;
201            worktree.absolutize(&relative_path).ok()
202        })
203    } else {
204        None
205    };
206
207    if let Some(abs_path) = file_path {
208        if abs_path.is_file() {
209            let filename = path
210                .as_ref()
211                .map(|p| p.to_string_lossy().to_string())
212                .unwrap_or_default();
213            return cx.background_executor().spawn(async move {
214                let mut text = String::new();
215                collect_file_content(&mut text, fs, filename.clone(), abs_path.clone().into())
216                    .await?;
217                let text_range = 0..text.len();
218                Ok((
219                    text,
220                    vec![(text_range, path.unwrap_or_default(), EntryType::File)],
221                ))
222            });
223        }
224    }
225
226    let snapshots = worktrees
227        .iter()
228        .map(|worktree| worktree.read(cx).snapshot())
229        .collect::<Vec<_>>();
230    cx.background_executor().spawn(async move {
231        let mut text = String::new();
232        let mut ranges = Vec::new();
233        for snapshot in snapshots {
234            let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
235            let mut folded_directory_names_stack = Vec::new();
236            let mut is_top_level_directory = true;
237            for entry in snapshot.entries(false, 0) {
238                let mut path_buf = PathBuf::new();
239                path_buf.push(snapshot.root_name());
240                path_buf.push(&entry.path);
241                if !matcher.is_match(&path_buf) {
242                    continue;
243                }
244
245                while let Some((dir, _, _)) = directory_stack.last() {
246                    if entry.path.starts_with(dir) {
247                        break;
248                    }
249                    let (_, entry_name, start) = directory_stack.pop().unwrap();
250                    ranges.push((
251                        start..text.len().saturating_sub(1),
252                        PathBuf::from(entry_name),
253                        EntryType::Directory,
254                    ));
255                }
256
257                let filename = entry
258                    .path
259                    .file_name()
260                    .unwrap_or_default()
261                    .to_str()
262                    .unwrap_or_default()
263                    .to_string();
264
265                if entry.is_dir() {
266                    // Auto-fold directories that contain no files
267                    let mut child_entries = snapshot.child_entries(&entry.path);
268                    if let Some(child) = child_entries.next() {
269                        if child_entries.next().is_none() && child.kind.is_dir() {
270                            if is_top_level_directory {
271                                is_top_level_directory = false;
272                                folded_directory_names_stack
273                                    .push(path_buf.to_string_lossy().to_string());
274                            } else {
275                                folded_directory_names_stack.push(filename.to_string());
276                            }
277                            continue;
278                        }
279                    } else {
280                        // Skip empty directories
281                        folded_directory_names_stack.clear();
282                        continue;
283                    }
284                    let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
285                    let entry_start = text.len();
286                    if prefix_paths.is_empty() {
287                        if is_top_level_directory {
288                            text.push_str(&path_buf.to_string_lossy());
289                            is_top_level_directory = false;
290                        } else {
291                            text.push_str(&filename);
292                        }
293                        directory_stack.push((entry.path.clone(), filename, entry_start));
294                    } else {
295                        let entry_name = format!("{}/{}", prefix_paths, &filename);
296                        text.push_str(&entry_name);
297                        directory_stack.push((entry.path.clone(), entry_name, entry_start));
298                    }
299                    text.push('\n');
300                } else if entry.is_file() {
301                    if let Some(abs_path) = snapshot.absolutize(&entry.path).log_err() {
302                        let prev_len = text.len();
303                        collect_file_content(
304                            &mut text,
305                            fs.clone(),
306                            filename.clone(),
307                            abs_path.into(),
308                        )
309                        .await?;
310                        ranges.push((
311                            prev_len..text.len(),
312                            PathBuf::from(filename),
313                            EntryType::File,
314                        ));
315                        text.push('\n');
316                    }
317                }
318            }
319
320            while let Some((dir, _, start)) = directory_stack.pop() {
321                let mut root_path = PathBuf::new();
322                root_path.push(snapshot.root_name());
323                root_path.push(&dir);
324                ranges.push((start..text.len(), root_path, EntryType::Directory));
325            }
326        }
327        Ok((text, ranges))
328    })
329}
330
331async fn collect_file_content(
332    buffer: &mut String,
333    fs: Arc<dyn Fs>,
334    filename: String,
335    abs_path: Arc<Path>,
336) -> Result<()> {
337    let mut content = fs.load(&abs_path).await?;
338    LineEnding::normalize(&mut content);
339    buffer.reserve(filename.len() + content.len() + 9);
340    buffer.push_str(&codeblock_fence_for_path(
341        Some(&PathBuf::from(filename)),
342        None,
343    ));
344    buffer.push_str(&content);
345    if !buffer.ends_with('\n') {
346        buffer.push('\n');
347    }
348    buffer.push_str("```");
349    anyhow::Ok(())
350}
351
352#[derive(IntoElement)]
353pub struct EntryPlaceholder {
354    pub path: Option<PathBuf>,
355    pub is_directory: bool,
356    pub line_range: Option<Range<u32>>,
357    pub id: ElementId,
358    pub unfold: Arc<dyn Fn(&mut WindowContext)>,
359}
360
361impl RenderOnce for EntryPlaceholder {
362    fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
363        let unfold = self.unfold;
364        let title = if let Some(path) = self.path.as_ref() {
365            SharedString::from(path.to_string_lossy().to_string())
366        } else {
367            SharedString::from("untitled")
368        };
369        let icon = if self.is_directory {
370            IconName::Folder
371        } else {
372            IconName::File
373        };
374
375        ButtonLike::new(self.id)
376            .style(ButtonStyle::Filled)
377            .layer(ElevationIndex::ElevatedSurface)
378            .child(Icon::new(icon))
379            .child(Label::new(title))
380            .when_some(self.line_range, |button, line_range| {
381                button.child(Label::new(":")).child(Label::new(format!(
382                    "{}-{}",
383                    line_range.start, line_range.end
384                )))
385            })
386            .on_click(move |_, cx| unfold(cx))
387    }
388}
389
390pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
391    let mut text = String::new();
392    write!(text, "```").unwrap();
393
394    if let Some(path) = path {
395        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
396            write!(text, "{} ", extension).unwrap();
397        }
398
399        write!(text, "{}", path.display()).unwrap();
400    } else {
401        write!(text, "untitled").unwrap();
402    }
403
404    if let Some(row_range) = row_range {
405        write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap();
406    }
407
408    text.push('\n');
409    text
410}