file_command.rs

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