1use super::{diagnostics_command::write_single_file_diagnostics, SlashCommand, SlashCommandOutput};
2use anyhow::{anyhow, Result};
3use assistant_slash_command::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> = "".into();
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<String>>> {
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 format!(
120 "{}{}",
121 path_match.path_prefix,
122 path_match.path.to_string_lossy()
123 )
124 })
125 .collect())
126 })
127 }
128
129 fn run(
130 self: Arc<Self>,
131 argument: Option<&str>,
132 workspace: WeakView<Workspace>,
133 _delegate: Arc<dyn LspAdapterDelegate>,
134 cx: &mut WindowContext,
135 ) -> Task<Result<SlashCommandOutput>> {
136 let Some(workspace) = workspace.upgrade() else {
137 return Task::ready(Err(anyhow!("workspace was dropped")));
138 };
139
140 let Some(argument) = argument else {
141 return Task::ready(Err(anyhow!("missing path")));
142 };
143
144 let task = collect_files(workspace.read(cx).project().clone(), argument, cx);
145
146 cx.foreground_executor().spawn(async move {
147 let (text, ranges) = task.await?;
148 Ok(SlashCommandOutput {
149 text,
150 sections: ranges
151 .into_iter()
152 .map(|(range, path, entry_type)| {
153 build_entry_output_section(
154 range,
155 Some(&path),
156 entry_type == EntryType::Directory,
157 None,
158 )
159 })
160 .collect(),
161 run_commands_in_text: true,
162 })
163 })
164 }
165}
166
167#[derive(Clone, Copy, PartialEq)]
168enum EntryType {
169 File,
170 Directory,
171}
172
173fn collect_files(
174 project: Model<Project>,
175 glob_input: &str,
176 cx: &mut AppContext,
177) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
178 let Ok(matcher) = PathMatcher::new(&[glob_input.to_owned()]) else {
179 return Task::ready(Err(anyhow!("invalid path")));
180 };
181
182 let project_handle = project.downgrade();
183 let snapshots = project
184 .read(cx)
185 .worktrees()
186 .map(|worktree| worktree.read(cx).snapshot())
187 .collect::<Vec<_>>();
188 cx.spawn(|mut cx| async move {
189 let mut text = String::new();
190 let mut ranges = Vec::new();
191 for snapshot in snapshots {
192 let worktree_id = snapshot.id();
193 let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
194 let mut folded_directory_names_stack = Vec::new();
195 let mut is_top_level_directory = true;
196 for entry in snapshot.entries(false, 0) {
197 let mut path_including_worktree_name = PathBuf::new();
198 path_including_worktree_name.push(snapshot.root_name());
199 path_including_worktree_name.push(&entry.path);
200 if !matcher.is_match(&path_including_worktree_name) {
201 continue;
202 }
203
204 while let Some((dir, _, _)) = directory_stack.last() {
205 if entry.path.starts_with(dir) {
206 break;
207 }
208 let (_, entry_name, start) = directory_stack.pop().unwrap();
209 ranges.push((
210 start..text.len().saturating_sub(1),
211 PathBuf::from(entry_name),
212 EntryType::Directory,
213 ));
214 }
215
216 let filename = entry
217 .path
218 .file_name()
219 .unwrap_or_default()
220 .to_str()
221 .unwrap_or_default()
222 .to_string();
223
224 if entry.is_dir() {
225 // Auto-fold directories that contain no files
226 let mut child_entries = snapshot.child_entries(&entry.path);
227 if let Some(child) = child_entries.next() {
228 if child_entries.next().is_none() && child.kind.is_dir() {
229 if is_top_level_directory {
230 is_top_level_directory = false;
231 folded_directory_names_stack.push(
232 path_including_worktree_name.to_string_lossy().to_string(),
233 );
234 } else {
235 folded_directory_names_stack.push(filename.to_string());
236 }
237 continue;
238 }
239 } else {
240 // Skip empty directories
241 folded_directory_names_stack.clear();
242 continue;
243 }
244 let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
245 let entry_start = text.len();
246 if prefix_paths.is_empty() {
247 if is_top_level_directory {
248 text.push_str(&path_including_worktree_name.to_string_lossy());
249 is_top_level_directory = false;
250 } else {
251 text.push_str(&filename);
252 }
253 directory_stack.push((entry.path.clone(), filename, entry_start));
254 } else {
255 let entry_name = format!("{}/{}", prefix_paths, &filename);
256 text.push_str(&entry_name);
257 directory_stack.push((entry.path.clone(), entry_name, entry_start));
258 }
259 text.push('\n');
260 } else if entry.is_file() {
261 let Some(open_buffer_task) = project_handle
262 .update(&mut cx, |project, cx| {
263 project.open_buffer((worktree_id, &entry.path), cx)
264 })
265 .ok()
266 else {
267 continue;
268 };
269 if let Some(buffer) = open_buffer_task.await.log_err() {
270 let snapshot = cx.read_model(&buffer, |buffer, _| buffer.snapshot())?;
271 let prev_len = text.len();
272 collect_file_content(&mut text, &snapshot, filename.clone());
273 text.push('\n');
274 if !write_single_file_diagnostics(
275 &mut text,
276 Some(&path_including_worktree_name),
277 &snapshot,
278 ) {
279 text.pop();
280 }
281 ranges.push((
282 prev_len..text.len(),
283 PathBuf::from(filename),
284 EntryType::File,
285 ));
286 text.push('\n');
287 }
288 }
289 }
290
291 while let Some((dir, _, start)) = directory_stack.pop() {
292 let mut root_path = PathBuf::new();
293 root_path.push(snapshot.root_name());
294 root_path.push(&dir);
295 ranges.push((start..text.len(), root_path, EntryType::Directory));
296 }
297 }
298 Ok((text, ranges))
299 })
300}
301
302fn collect_file_content(buffer: &mut String, snapshot: &BufferSnapshot, filename: String) {
303 let mut content = snapshot.text();
304 LineEnding::normalize(&mut content);
305 buffer.reserve(filename.len() + content.len() + 9);
306 buffer.push_str(&codeblock_fence_for_path(
307 Some(&PathBuf::from(filename)),
308 None,
309 ));
310 buffer.push_str(&content);
311 if !buffer.ends_with('\n') {
312 buffer.push('\n');
313 }
314 buffer.push_str("```");
315}
316
317pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
318 let mut text = String::new();
319 write!(text, "```").unwrap();
320
321 if let Some(path) = path {
322 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
323 write!(text, "{} ", extension).unwrap();
324 }
325
326 write!(text, "{}", path.display()).unwrap();
327 } else {
328 write!(text, "untitled").unwrap();
329 }
330
331 if let Some(row_range) = row_range {
332 write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap();
333 }
334
335 text.push('\n');
336 text
337}
338
339pub fn build_entry_output_section(
340 range: Range<usize>,
341 path: Option<&Path>,
342 is_directory: bool,
343 line_range: Option<Range<u32>>,
344) -> SlashCommandOutputSection<usize> {
345 let mut label = if let Some(path) = path {
346 path.to_string_lossy().to_string()
347 } else {
348 "untitled".to_string()
349 };
350 if let Some(line_range) = line_range {
351 write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
352 }
353
354 let icon = if is_directory {
355 IconName::Folder
356 } else {
357 IconName::File
358 };
359
360 SlashCommandOutputSection {
361 range,
362 icon,
363 label: label.into(),
364 }
365}