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> = "".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<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: 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 snapshot = cx.read_model(&buffer, |buffer, _| buffer.snapshot())?;
277 let prev_len = text.len();
278 collect_file_content(&mut text, &snapshot, filename.clone());
279 text.push('\n');
280 if !write_single_file_diagnostics(
281 &mut text,
282 Some(&path_including_worktree_name),
283 &snapshot,
284 ) {
285 text.pop();
286 }
287 ranges.push((
288 prev_len..text.len(),
289 PathBuf::from(filename),
290 EntryType::File,
291 ));
292 text.push('\n');
293 }
294 }
295 }
296
297 while let Some((dir, _, start)) = directory_stack.pop() {
298 let mut root_path = PathBuf::new();
299 root_path.push(snapshot.root_name());
300 root_path.push(&dir);
301 ranges.push((start..text.len(), root_path, EntryType::Directory));
302 }
303 }
304 Ok((text, ranges))
305 })
306}
307
308fn collect_file_content(buffer: &mut String, snapshot: &BufferSnapshot, filename: String) {
309 let mut content = snapshot.text();
310 LineEnding::normalize(&mut content);
311 buffer.reserve(filename.len() + content.len() + 9);
312 buffer.push_str(&codeblock_fence_for_path(
313 Some(&PathBuf::from(filename)),
314 None,
315 ));
316 buffer.push_str(&content);
317 if !buffer.ends_with('\n') {
318 buffer.push('\n');
319 }
320 buffer.push_str("```");
321}
322
323pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
324 let mut text = String::new();
325 write!(text, "```").unwrap();
326
327 if let Some(path) = path {
328 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
329 write!(text, "{} ", extension).unwrap();
330 }
331
332 write!(text, "{}", path.display()).unwrap();
333 } else {
334 write!(text, "untitled").unwrap();
335 }
336
337 if let Some(row_range) = row_range {
338 write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap();
339 }
340
341 text.push('\n');
342 text
343}
344
345pub fn build_entry_output_section(
346 range: Range<usize>,
347 path: Option<&Path>,
348 is_directory: bool,
349 line_range: Option<Range<u32>>,
350) -> SlashCommandOutputSection<usize> {
351 let mut label = if let Some(path) = path {
352 path.to_string_lossy().to_string()
353 } else {
354 "untitled".to_string()
355 };
356 if let Some(line_range) = line_range {
357 write!(label, ":{}-{}", line_range.start, line_range.end).unwrap();
358 }
359
360 let icon = if is_directory {
361 IconName::Folder
362 } else {
363 IconName::File
364 };
365
366 SlashCommandOutputSection {
367 range,
368 icon,
369 label: label.into(),
370 }
371}