file_command.rs

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