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