file_command.rs

  1use anyhow::{anyhow, Context as _, Result};
  2use assistant_slash_command::{
  3    AfterCompletion, ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent,
  4    SlashCommandOutput, SlashCommandOutputSection, SlashCommandResult,
  5};
  6use futures::channel::mpsc;
  7use fuzzy::PathMatch;
  8use gpui::{AppContext, Model, Task, View, WeakView};
  9use language::{BufferSnapshot, CodeLabel, HighlightId, LineEnding, LspAdapterDelegate};
 10use project::{PathMatchCandidateSet, Project};
 11use serde::{Deserialize, Serialize};
 12use smol::stream::StreamExt;
 13use std::{
 14    fmt::Write,
 15    ops::{Range, RangeInclusive},
 16    path::{Path, PathBuf},
 17    sync::{atomic::AtomicBool, Arc},
 18};
 19use ui::prelude::*;
 20use util::ResultExt;
 21use workspace::Workspace;
 22
 23use crate::slash_command::diagnostics_command::collect_buffer_diagnostics;
 24
 25pub(crate) struct FileSlashCommand;
 26
 27impl FileSlashCommand {
 28    fn search_paths(
 29        &self,
 30        query: String,
 31        cancellation_flag: Arc<AtomicBool>,
 32        workspace: &View<Workspace>,
 33        cx: &mut AppContext,
 34    ) -> Task<Vec<PathMatch>> {
 35        if query.is_empty() {
 36            let workspace = workspace.read(cx);
 37            let project = workspace.project().read(cx);
 38            let entries = workspace.recent_navigation_history(Some(10), cx);
 39
 40            let entries = entries
 41                .into_iter()
 42                .map(|entries| (entries.0, false))
 43                .chain(project.worktrees(cx).flat_map(|worktree| {
 44                    let worktree = worktree.read(cx);
 45                    let id = worktree.id();
 46                    worktree.child_entries(Path::new("")).map(move |entry| {
 47                        (
 48                            project::ProjectPath {
 49                                worktree_id: id,
 50                                path: entry.path.clone(),
 51                            },
 52                            entry.kind.is_dir(),
 53                        )
 54                    })
 55                }))
 56                .collect::<Vec<_>>();
 57
 58            let path_prefix: Arc<str> = Arc::default();
 59            Task::ready(
 60                entries
 61                    .into_iter()
 62                    .filter_map(|(entry, is_dir)| {
 63                        let worktree = project.worktree_for_id(entry.worktree_id, cx)?;
 64                        let mut full_path = PathBuf::from(worktree.read(cx).root_name());
 65                        full_path.push(&entry.path);
 66                        Some(PathMatch {
 67                            score: 0.,
 68                            positions: Vec::new(),
 69                            worktree_id: entry.worktree_id.to_usize(),
 70                            path: full_path.into(),
 71                            path_prefix: path_prefix.clone(),
 72                            distance_to_relative_ancestor: 0,
 73                            is_dir,
 74                        })
 75                    })
 76                    .collect(),
 77            )
 78        } else {
 79            let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
 80            let candidate_sets = worktrees
 81                .into_iter()
 82                .map(|worktree| {
 83                    let worktree = worktree.read(cx);
 84
 85                    PathMatchCandidateSet {
 86                        snapshot: worktree.snapshot(),
 87                        include_ignored: worktree
 88                            .root_entry()
 89                            .map_or(false, |entry| entry.is_ignored),
 90                        include_root_name: true,
 91                        candidates: project::Candidates::Entries,
 92                    }
 93                })
 94                .collect::<Vec<_>>();
 95
 96            let executor = cx.background_executor().clone();
 97            cx.foreground_executor().spawn(async move {
 98                fuzzy::match_path_sets(
 99                    candidate_sets.as_slice(),
100                    query.as_str(),
101                    None,
102                    false,
103                    100,
104                    &cancellation_flag,
105                    executor,
106                )
107                .await
108            })
109        }
110    }
111}
112
113impl SlashCommand for FileSlashCommand {
114    fn name(&self) -> String {
115        "file".into()
116    }
117
118    fn description(&self) -> String {
119        "Insert file".into()
120    }
121
122    fn menu_text(&self) -> String {
123        self.description()
124    }
125
126    fn requires_argument(&self) -> bool {
127        true
128    }
129
130    fn complete_argument(
131        self: Arc<Self>,
132        arguments: &[String],
133        cancellation_flag: Arc<AtomicBool>,
134        workspace: Option<WeakView<Workspace>>,
135        cx: &mut WindowContext,
136    ) -> Task<Result<Vec<ArgumentCompletion>>> {
137        let Some(workspace) = workspace.and_then(|workspace| workspace.upgrade()) else {
138            return Task::ready(Err(anyhow!("workspace was dropped")));
139        };
140
141        let paths = self.search_paths(
142            arguments.last().cloned().unwrap_or_default(),
143            cancellation_flag,
144            &workspace,
145            cx,
146        );
147        let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
148        cx.background_executor().spawn(async move {
149            Ok(paths
150                .await
151                .into_iter()
152                .filter_map(|path_match| {
153                    let text = format!(
154                        "{}{}",
155                        path_match.path_prefix,
156                        path_match.path.to_string_lossy()
157                    );
158
159                    let mut label = CodeLabel::default();
160                    let file_name = path_match.path.file_name()?.to_string_lossy();
161                    let label_text = if path_match.is_dir {
162                        format!("{}/ ", file_name)
163                    } else {
164                        format!("{} ", file_name)
165                    };
166
167                    label.push_str(label_text.as_str(), None);
168                    label.push_str(&text, comment_id);
169                    label.filter_range = 0..file_name.len();
170
171                    Some(ArgumentCompletion {
172                        label,
173                        new_text: text,
174                        after_completion: AfterCompletion::Compose,
175                        replace_previous_arguments: false,
176                    })
177                })
178                .collect())
179        })
180    }
181
182    fn run(
183        self: Arc<Self>,
184        arguments: &[String],
185        _context_slash_command_output_sections: &[SlashCommandOutputSection<language::Anchor>],
186        _context_buffer: BufferSnapshot,
187        workspace: WeakView<Workspace>,
188        _delegate: Option<Arc<dyn LspAdapterDelegate>>,
189        cx: &mut WindowContext,
190    ) -> Task<SlashCommandResult> {
191        let Some(workspace) = workspace.upgrade() else {
192            return Task::ready(Err(anyhow!("workspace was dropped")));
193        };
194
195        if arguments.is_empty() {
196            return Task::ready(Err(anyhow!("missing path")));
197        };
198
199        collect_files(workspace.read(cx).project().clone(), arguments, cx)
200    }
201}
202
203fn collect_files(
204    project: Model<Project>,
205    glob_inputs: &[String],
206    cx: &mut AppContext,
207) -> Task<SlashCommandResult> {
208    let Ok(matchers) = glob_inputs
209        .into_iter()
210        .map(|glob_input| {
211            custom_path_matcher::PathMatcher::new(&[glob_input.to_owned()])
212                .with_context(|| format!("invalid path {glob_input}"))
213        })
214        .collect::<anyhow::Result<Vec<custom_path_matcher::PathMatcher>>>()
215    else {
216        return Task::ready(Err(anyhow!("invalid path")));
217    };
218
219    let project_handle = project.downgrade();
220    let snapshots = project
221        .read(cx)
222        .worktrees(cx)
223        .map(|worktree| worktree.read(cx).snapshot())
224        .collect::<Vec<_>>();
225
226    let (events_tx, events_rx) = mpsc::unbounded();
227    cx.spawn(|mut cx| async move {
228        for snapshot in snapshots {
229            let worktree_id = snapshot.id();
230            let mut directory_stack: Vec<Arc<Path>> = Vec::new();
231            let mut folded_directory_names_stack = Vec::new();
232            let mut is_top_level_directory = true;
233
234            for entry in snapshot.entries(false, 0) {
235                let mut path_including_worktree_name = PathBuf::new();
236                path_including_worktree_name.push(snapshot.root_name());
237                path_including_worktree_name.push(&entry.path);
238
239                if !matchers
240                    .iter()
241                    .any(|matcher| matcher.is_match(&path_including_worktree_name))
242                {
243                    continue;
244                }
245
246                while let Some(dir) = directory_stack.last() {
247                    if entry.path.starts_with(dir) {
248                        break;
249                    }
250                    directory_stack.pop().unwrap();
251                    events_tx
252                        .unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
253                    events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
254                        SlashCommandContent::Text {
255                            text: "\n".into(),
256                            run_commands_in_text: false,
257                        },
258                    )))?;
259                }
260
261                let filename = entry
262                    .path
263                    .file_name()
264                    .unwrap_or_default()
265                    .to_str()
266                    .unwrap_or_default()
267                    .to_string();
268
269                if entry.is_dir() {
270                    // Auto-fold directories that contain no files
271                    let mut child_entries = snapshot.child_entries(&entry.path);
272                    if let Some(child) = child_entries.next() {
273                        if child_entries.next().is_none() && child.kind.is_dir() {
274                            if is_top_level_directory {
275                                is_top_level_directory = false;
276                                folded_directory_names_stack.push(
277                                    path_including_worktree_name.to_string_lossy().to_string(),
278                                );
279                            } else {
280                                folded_directory_names_stack.push(filename.to_string());
281                            }
282                            continue;
283                        }
284                    } else {
285                        // Skip empty directories
286                        folded_directory_names_stack.clear();
287                        continue;
288                    }
289                    let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
290                    if prefix_paths.is_empty() {
291                        let label = if is_top_level_directory {
292                            is_top_level_directory = false;
293                            path_including_worktree_name.to_string_lossy().to_string()
294                        } else {
295                            filename
296                        };
297                        events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
298                            icon: IconName::Folder,
299                            label: label.clone().into(),
300                            metadata: None,
301                        }))?;
302                        events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
303                            SlashCommandContent::Text {
304                                text: label,
305                                run_commands_in_text: false,
306                            },
307                        )))?;
308                        directory_stack.push(entry.path.clone());
309                    } else {
310                        let entry_name = format!("{}/{}", prefix_paths, &filename);
311                        events_tx.unbounded_send(Ok(SlashCommandEvent::StartSection {
312                            icon: IconName::Folder,
313                            label: entry_name.clone().into(),
314                            metadata: None,
315                        }))?;
316                        events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
317                            SlashCommandContent::Text {
318                                text: entry_name,
319                                run_commands_in_text: false,
320                            },
321                        )))?;
322                        directory_stack.push(entry.path.clone());
323                    }
324                    events_tx.unbounded_send(Ok(SlashCommandEvent::Content(
325                        SlashCommandContent::Text {
326                            text: "\n".into(),
327                            run_commands_in_text: false,
328                        },
329                    )))?;
330                } else if entry.is_file() {
331                    let Some(open_buffer_task) = project_handle
332                        .update(&mut cx, |project, cx| {
333                            project.open_buffer((worktree_id, &entry.path), cx)
334                        })
335                        .ok()
336                    else {
337                        continue;
338                    };
339                    if let Some(buffer) = open_buffer_task.await.log_err() {
340                        let mut output = SlashCommandOutput::default();
341                        let snapshot = buffer.read_with(&cx, |buffer, _| buffer.snapshot())?;
342                        append_buffer_to_output(
343                            &snapshot,
344                            Some(&path_including_worktree_name),
345                            &mut output,
346                        )
347                        .log_err();
348                        let mut buffer_events = output.to_event_stream();
349                        while let Some(event) = buffer_events.next().await {
350                            events_tx.unbounded_send(event)?;
351                        }
352                    }
353                }
354            }
355
356            while let Some(_) = directory_stack.pop() {
357                events_tx.unbounded_send(Ok(SlashCommandEvent::EndSection { metadata: None }))?;
358            }
359        }
360        Ok(events_rx.boxed())
361    })
362}
363
364pub fn codeblock_fence_for_path(
365    path: Option<&Path>,
366    row_range: Option<RangeInclusive<u32>>,
367) -> String {
368    let mut text = String::new();
369    write!(text, "```").unwrap();
370
371    if let Some(path) = path {
372        if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
373            write!(text, "{} ", extension).unwrap();
374        }
375
376        write!(text, "{}", path.display()).unwrap();
377    } else {
378        write!(text, "untitled").unwrap();
379    }
380
381    if let Some(row_range) = row_range {
382        write!(text, ":{}-{}", row_range.start() + 1, row_range.end() + 1).unwrap();
383    }
384
385    text.push('\n');
386    text
387}
388
389#[derive(Serialize, Deserialize)]
390pub struct FileCommandMetadata {
391    pub path: String,
392}
393
394pub fn build_entry_output_section(
395    range: Range<usize>,
396    path: Option<&Path>,
397    is_directory: bool,
398    line_range: Option<Range<u32>>,
399) -> SlashCommandOutputSection<usize> {
400    let mut label = if let Some(path) = path {
401        path.to_string_lossy().to_string()
402    } else {
403        "untitled".to_string()
404    };
405    if let Some(line_range) = line_range {
406        write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
407    }
408
409    let icon = if is_directory {
410        IconName::Folder
411    } else {
412        IconName::File
413    };
414
415    SlashCommandOutputSection {
416        range,
417        icon,
418        label: label.into(),
419        metadata: if is_directory {
420            None
421        } else {
422            path.and_then(|path| {
423                serde_json::to_value(FileCommandMetadata {
424                    path: path.to_string_lossy().to_string(),
425                })
426                .ok()
427            })
428        },
429    }
430}
431
432/// This contains a small fork of the util::paths::PathMatcher, that is stricter about the prefix
433/// check. Only subpaths pass the prefix check, rather than any prefix.
434mod custom_path_matcher {
435    use std::{fmt::Debug as _, path::Path};
436
437    use globset::{Glob, GlobSet, GlobSetBuilder};
438
439    #[derive(Clone, Debug, Default)]
440    pub struct PathMatcher {
441        sources: Vec<String>,
442        sources_with_trailing_slash: Vec<String>,
443        glob: GlobSet,
444    }
445
446    impl std::fmt::Display for PathMatcher {
447        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
448            self.sources.fmt(f)
449        }
450    }
451
452    impl PartialEq for PathMatcher {
453        fn eq(&self, other: &Self) -> bool {
454            self.sources.eq(&other.sources)
455        }
456    }
457
458    impl Eq for PathMatcher {}
459
460    impl PathMatcher {
461        pub fn new(globs: &[String]) -> Result<Self, globset::Error> {
462            let globs = globs
463                .into_iter()
464                .map(|glob| Glob::new(&glob))
465                .collect::<Result<Vec<_>, _>>()?;
466            let sources = globs.iter().map(|glob| glob.glob().to_owned()).collect();
467            let sources_with_trailing_slash = globs
468                .iter()
469                .map(|glob| glob.glob().to_string() + std::path::MAIN_SEPARATOR_STR)
470                .collect();
471            let mut glob_builder = GlobSetBuilder::new();
472            for single_glob in globs {
473                glob_builder.add(single_glob);
474            }
475            let glob = glob_builder.build()?;
476            Ok(PathMatcher {
477                glob,
478                sources,
479                sources_with_trailing_slash,
480            })
481        }
482
483        pub fn is_match<P: AsRef<Path>>(&self, other: P) -> bool {
484            let other_path = other.as_ref();
485            self.sources
486                .iter()
487                .zip(self.sources_with_trailing_slash.iter())
488                .any(|(source, with_slash)| {
489                    let as_bytes = other_path.as_os_str().as_encoded_bytes();
490                    let with_slash = if source.ends_with("/") {
491                        source.as_bytes()
492                    } else {
493                        with_slash.as_bytes()
494                    };
495
496                    as_bytes.starts_with(with_slash) || as_bytes.ends_with(source.as_bytes())
497                })
498                || self.glob.is_match(other_path)
499                || self.check_with_end_separator(other_path)
500        }
501
502        fn check_with_end_separator(&self, path: &Path) -> bool {
503            let path_str = path.to_string_lossy();
504            let separator = std::path::MAIN_SEPARATOR_STR;
505            if path_str.ends_with(separator) {
506                return false;
507            } else {
508                self.glob.is_match(path_str.to_string() + separator)
509            }
510        }
511    }
512}
513
514pub fn append_buffer_to_output(
515    buffer: &BufferSnapshot,
516    path: Option<&Path>,
517    output: &mut SlashCommandOutput,
518) -> Result<()> {
519    let prev_len = output.text.len();
520
521    let mut content = buffer.text();
522    LineEnding::normalize(&mut content);
523    output.text.push_str(&codeblock_fence_for_path(path, None));
524    output.text.push_str(&content);
525    if !output.text.ends_with('\n') {
526        output.text.push('\n');
527    }
528    output.text.push_str("```");
529    output.text.push('\n');
530
531    let section_ix = output.sections.len();
532    collect_buffer_diagnostics(output, buffer, false);
533
534    output.sections.insert(
535        section_ix,
536        build_entry_output_section(prev_len..output.text.len(), path, false, None),
537    );
538
539    output.text.push('\n');
540
541    Ok(())
542}
543
544#[cfg(test)]
545mod test {
546    use assistant_slash_command::SlashCommandOutput;
547    use fs::FakeFs;
548    use gpui::TestAppContext;
549    use pretty_assertions::assert_eq;
550    use project::Project;
551    use serde_json::json;
552    use settings::SettingsStore;
553
554    use crate::slash_command::file_command::collect_files;
555
556    pub fn init_test(cx: &mut gpui::TestAppContext) {
557        if std::env::var("RUST_LOG").is_ok() {
558            env_logger::try_init().ok();
559        }
560
561        cx.update(|cx| {
562            let settings_store = SettingsStore::test(cx);
563            cx.set_global(settings_store);
564            // release_channel::init(SemanticVersion::default(), cx);
565            language::init(cx);
566            Project::init_settings(cx);
567        });
568    }
569
570    #[gpui::test]
571    async fn test_file_exact_matching(cx: &mut TestAppContext) {
572        init_test(cx);
573        let fs = FakeFs::new(cx.executor());
574
575        fs.insert_tree(
576            "/root",
577            json!({
578                "dir": {
579                    "subdir": {
580                       "file_0": "0"
581                    },
582                    "file_1": "1",
583                    "file_2": "2",
584                    "file_3": "3",
585                },
586                "dir.rs": "4"
587            }),
588        )
589        .await;
590
591        let project = Project::test(fs, ["/root".as_ref()], cx).await;
592
593        let result_1 = cx
594            .update(|cx| collect_files(project.clone(), &["root/dir".to_string()], cx))
595            .await
596            .unwrap();
597        let result_1 = SlashCommandOutput::from_event_stream(result_1)
598            .await
599            .unwrap();
600
601        assert!(result_1.text.starts_with("root/dir"));
602        // 4 files + 2 directories
603        assert_eq!(result_1.sections.len(), 6);
604
605        let result_2 = cx
606            .update(|cx| collect_files(project.clone(), &["root/dir/".to_string()], cx))
607            .await
608            .unwrap();
609        let result_2 = SlashCommandOutput::from_event_stream(result_2)
610            .await
611            .unwrap();
612
613        assert_eq!(result_1, result_2);
614
615        let result = cx
616            .update(|cx| collect_files(project.clone(), &["root/dir*".to_string()], cx))
617            .await
618            .unwrap();
619        let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
620
621        assert!(result.text.starts_with("root/dir"));
622        // 5 files + 2 directories
623        assert_eq!(result.sections.len(), 7);
624
625        // Ensure that the project lasts until after the last await
626        drop(project);
627    }
628
629    #[gpui::test]
630    async fn test_file_sub_directory_rendering(cx: &mut TestAppContext) {
631        init_test(cx);
632        let fs = FakeFs::new(cx.executor());
633
634        fs.insert_tree(
635            "/zed",
636            json!({
637                "assets": {
638                    "dir1": {
639                        ".gitkeep": ""
640                    },
641                    "dir2": {
642                        ".gitkeep": ""
643                    },
644                    "themes": {
645                        "ayu": {
646                            "LICENSE": "1",
647                        },
648                        "andromeda": {
649                            "LICENSE": "2",
650                        },
651                        "summercamp": {
652                            "LICENSE": "3",
653                        },
654                    },
655                },
656            }),
657        )
658        .await;
659
660        let project = Project::test(fs, ["/zed".as_ref()], cx).await;
661
662        let result = cx
663            .update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
664            .await
665            .unwrap();
666        let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
667
668        // Sanity check
669        assert!(result.text.starts_with("zed/assets/themes\n"));
670        assert_eq!(result.sections.len(), 7);
671
672        // Ensure that full file paths are included in the real output
673        assert!(result.text.contains("zed/assets/themes/andromeda/LICENSE"));
674        assert!(result.text.contains("zed/assets/themes/ayu/LICENSE"));
675        assert!(result.text.contains("zed/assets/themes/summercamp/LICENSE"));
676
677        assert_eq!(result.sections[5].label, "summercamp");
678
679        // Ensure that things are in descending order, with properly relativized paths
680        assert_eq!(
681            result.sections[0].label,
682            "zed/assets/themes/andromeda/LICENSE"
683        );
684        assert_eq!(result.sections[1].label, "andromeda");
685        assert_eq!(result.sections[2].label, "zed/assets/themes/ayu/LICENSE");
686        assert_eq!(result.sections[3].label, "ayu");
687        assert_eq!(
688            result.sections[4].label,
689            "zed/assets/themes/summercamp/LICENSE"
690        );
691
692        // Ensure that the project lasts until after the last await
693        drop(project);
694    }
695
696    #[gpui::test]
697    async fn test_file_deep_sub_directory_rendering(cx: &mut TestAppContext) {
698        init_test(cx);
699        let fs = FakeFs::new(cx.executor());
700
701        fs.insert_tree(
702            "/zed",
703            json!({
704                "assets": {
705                    "themes": {
706                        "LICENSE": "1",
707                        "summercamp": {
708                            "LICENSE": "1",
709                            "subdir": {
710                                "LICENSE": "1",
711                                "subsubdir": {
712                                    "LICENSE": "3",
713                                }
714                            }
715                        },
716                    },
717                },
718            }),
719        )
720        .await;
721
722        let project = Project::test(fs, ["/zed".as_ref()], cx).await;
723
724        let result = cx
725            .update(|cx| collect_files(project.clone(), &["zed/assets/themes".to_string()], cx))
726            .await
727            .unwrap();
728        let result = SlashCommandOutput::from_event_stream(result).await.unwrap();
729
730        assert!(result.text.starts_with("zed/assets/themes\n"));
731        assert_eq!(result.sections[0].label, "zed/assets/themes/LICENSE");
732        assert_eq!(
733            result.sections[1].label,
734            "zed/assets/themes/summercamp/LICENSE"
735        );
736        assert_eq!(
737            result.sections[2].label,
738            "zed/assets/themes/summercamp/subdir/LICENSE"
739        );
740        assert_eq!(
741            result.sections[3].label,
742            "zed/assets/themes/summercamp/subdir/subsubdir/LICENSE"
743        );
744        assert_eq!(result.sections[4].label, "subsubdir");
745        assert_eq!(result.sections[5].label, "subdir");
746        assert_eq!(result.sections[6].label, "summercamp");
747        assert_eq!(result.sections[7].label, "zed/assets/themes");
748
749        assert_eq!(result.text, "zed/assets/themes\n```zed/assets/themes/LICENSE\n1\n```\n\nsummercamp\n```zed/assets/themes/summercamp/LICENSE\n1\n```\n\nsubdir\n```zed/assets/themes/summercamp/subdir/LICENSE\n1\n```\n\nsubsubdir\n```zed/assets/themes/summercamp/subdir/subsubdir/LICENSE\n3\n```\n\n");
750
751        // Ensure that the project lasts until after the last await
752        drop(project);
753    }
754}