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}