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, 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::*;
 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: Arc<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)| {
160                        build_entry_output_section(
161                            range,
162                            Some(&path),
163                            entry_type == EntryType::Directory,
164                            None,
165                        )
166                    })
167                    .collect(),
168                run_commands_in_text: false,
169            })
170        })
171    }
172}
173
174#[derive(Clone, Copy, PartialEq)]
175enum EntryType {
176    File,
177    Directory,
178}
179
180fn collect_files(
181    worktrees: Vec<Model<Worktree>>,
182    glob_input: &str,
183    fs: Arc<dyn Fs>,
184    cx: &mut AppContext,
185) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
186    let Ok(matcher) = PathMatcher::new(&[glob_input.to_owned()]) else {
187        return Task::ready(Err(anyhow!("invalid path")));
188    };
189
190    let path = PathBuf::try_from(glob_input).ok();
191    let file_path = if let Some(path) = &path {
192        worktrees.iter().find_map(|worktree| {
193            let worktree = worktree.read(cx);
194            let worktree_root_path = Path::new(worktree.root_name());
195            let relative_path = path.strip_prefix(worktree_root_path).ok()?;
196            worktree.absolutize(&relative_path).ok()
197        })
198    } else {
199        None
200    };
201
202    if let Some(abs_path) = file_path {
203        if abs_path.is_file() {
204            let filename = path
205                .as_ref()
206                .map(|p| p.to_string_lossy().to_string())
207                .unwrap_or_default();
208            return cx.background_executor().spawn(async move {
209                let mut text = String::new();
210                collect_file_content(&mut text, fs, filename.clone(), abs_path.clone().into())
211                    .await?;
212                let text_range = 0..text.len();
213                Ok((
214                    text,
215                    vec![(text_range, path.unwrap_or_default(), EntryType::File)],
216                ))
217            });
218        }
219    }
220
221    let snapshots = worktrees
222        .iter()
223        .map(|worktree| worktree.read(cx).snapshot())
224        .collect::<Vec<_>>();
225    cx.background_executor().spawn(async move {
226        let mut text = String::new();
227        let mut ranges = Vec::new();
228        for snapshot in snapshots {
229            let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
230            let mut folded_directory_names_stack = Vec::new();
231            let mut is_top_level_directory = true;
232            for entry in snapshot.entries(false, 0) {
233                let mut path_buf = PathBuf::new();
234                path_buf.push(snapshot.root_name());
235                path_buf.push(&entry.path);
236                if !matcher.is_match(&path_buf) {
237                    continue;
238                }
239
240                while let Some((dir, _, _)) = directory_stack.last() {
241                    if entry.path.starts_with(dir) {
242                        break;
243                    }
244                    let (_, entry_name, start) = directory_stack.pop().unwrap();
245                    ranges.push((
246                        start..text.len().saturating_sub(1),
247                        PathBuf::from(entry_name),
248                        EntryType::Directory,
249                    ));
250                }
251
252                let filename = entry
253                    .path
254                    .file_name()
255                    .unwrap_or_default()
256                    .to_str()
257                    .unwrap_or_default()
258                    .to_string();
259
260                if entry.is_dir() {
261                    // Auto-fold directories that contain no files
262                    let mut child_entries = snapshot.child_entries(&entry.path);
263                    if let Some(child) = child_entries.next() {
264                        if child_entries.next().is_none() && child.kind.is_dir() {
265                            if is_top_level_directory {
266                                is_top_level_directory = false;
267                                folded_directory_names_stack
268                                    .push(path_buf.to_string_lossy().to_string());
269                            } else {
270                                folded_directory_names_stack.push(filename.to_string());
271                            }
272                            continue;
273                        }
274                    } else {
275                        // Skip empty directories
276                        folded_directory_names_stack.clear();
277                        continue;
278                    }
279                    let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
280                    let entry_start = text.len();
281                    if prefix_paths.is_empty() {
282                        if is_top_level_directory {
283                            text.push_str(&path_buf.to_string_lossy());
284                            is_top_level_directory = false;
285                        } else {
286                            text.push_str(&filename);
287                        }
288                        directory_stack.push((entry.path.clone(), filename, entry_start));
289                    } else {
290                        let entry_name = format!("{}/{}", prefix_paths, &filename);
291                        text.push_str(&entry_name);
292                        directory_stack.push((entry.path.clone(), entry_name, entry_start));
293                    }
294                    text.push('\n');
295                } else if entry.is_file() {
296                    if let Some(abs_path) = snapshot.absolutize(&entry.path).log_err() {
297                        let prev_len = text.len();
298                        collect_file_content(
299                            &mut text,
300                            fs.clone(),
301                            filename.clone(),
302                            abs_path.into(),
303                        )
304                        .await?;
305                        ranges.push((
306                            prev_len..text.len(),
307                            PathBuf::from(filename),
308                            EntryType::File,
309                        ));
310                        text.push('\n');
311                    }
312                }
313            }
314
315            while let Some((dir, _, start)) = directory_stack.pop() {
316                let mut root_path = PathBuf::new();
317                root_path.push(snapshot.root_name());
318                root_path.push(&dir);
319                ranges.push((start..text.len(), root_path, EntryType::Directory));
320            }
321        }
322        Ok((text, ranges))
323    })
324}
325
326async fn collect_file_content(
327    buffer: &mut String,
328    fs: Arc<dyn Fs>,
329    filename: String,
330    abs_path: Arc<Path>,
331) -> Result<()> {
332    let mut content = fs.load(&abs_path).await?;
333    LineEnding::normalize(&mut content);
334    buffer.reserve(filename.len() + content.len() + 9);
335    buffer.push_str(&codeblock_fence_for_path(
336        Some(&PathBuf::from(filename)),
337        None,
338    ));
339    buffer.push_str(&content);
340    if !buffer.ends_with('\n') {
341        buffer.push('\n');
342    }
343    buffer.push_str("```");
344    anyhow::Ok(())
345}
346
347pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
348    let mut text = String::new();
349    write!(text, "```").unwrap();
350
351    if let Some(path) = path {
352        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
353            write!(text, "{} ", extension).unwrap();
354        }
355
356        write!(text, "{}", path.display()).unwrap();
357    } else {
358        write!(text, "untitled").unwrap();
359    }
360
361    if let Some(row_range) = row_range {
362        write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap();
363    }
364
365    text.push('\n');
366    text
367}
368
369pub fn build_entry_output_section(
370    range: Range<usize>,
371    path: Option<&Path>,
372    is_directory: bool,
373    line_range: Option<Range<u32>>,
374) -> SlashCommandOutputSection<usize> {
375    let mut label = if let Some(path) = path {
376        path.to_string_lossy().to_string()
377    } else {
378        "untitled".to_string()
379    };
380    if let Some(line_range) = line_range {
381        write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
382    }
383
384    let icon = if is_directory {
385        IconName::Folder
386    } else {
387        IconName::File
388    };
389
390    SlashCommandOutputSection {
391        range,
392        icon,
393        label: label.into(),
394    }
395}