1use super::{SlashCommand, SlashCommandOutput};
2use anyhow::{anyhow, Result};
3use assistant_slash_command::SlashCommandOutputSection;
4use fs::Fs;
5use fuzzy::PathMatch;
6use gpui::{AppContext, Model, RenderOnce, SharedString, 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::*, ButtonLike, ElevationIndex};
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)| SlashCommandOutputSection {
160 range,
161 render_placeholder: Arc::new(move |id, unfold, _cx| {
162 EntryPlaceholder {
163 path: Some(path.clone()),
164 is_directory: entry_type == EntryType::Directory,
165 line_range: None,
166 id,
167 unfold,
168 }
169 .into_any_element()
170 }),
171 })
172 .collect(),
173 run_commands_in_text: false,
174 })
175 })
176 }
177}
178
179#[derive(Clone, Copy, PartialEq)]
180enum EntryType {
181 File,
182 Directory,
183}
184
185fn collect_files(
186 worktrees: Vec<Model<Worktree>>,
187 glob_input: &str,
188 fs: Arc<dyn Fs>,
189 cx: &mut AppContext,
190) -> Task<Result<(String, Vec<(Range<usize>, PathBuf, EntryType)>)>> {
191 let Ok(matcher) = PathMatcher::new(glob_input) else {
192 return Task::ready(Err(anyhow!("invalid path")));
193 };
194
195 let path = PathBuf::try_from(glob_input).ok();
196 let file_path = if let Some(path) = &path {
197 worktrees.iter().find_map(|worktree| {
198 let worktree = worktree.read(cx);
199 let worktree_root_path = Path::new(worktree.root_name());
200 let relative_path = path.strip_prefix(worktree_root_path).ok()?;
201 worktree.absolutize(&relative_path).ok()
202 })
203 } else {
204 None
205 };
206
207 if let Some(abs_path) = file_path {
208 if abs_path.is_file() {
209 let filename = path
210 .as_ref()
211 .map(|p| p.to_string_lossy().to_string())
212 .unwrap_or_default();
213 return cx.background_executor().spawn(async move {
214 let mut text = String::new();
215 collect_file_content(&mut text, fs, filename.clone(), abs_path.clone().into())
216 .await?;
217 let text_range = 0..text.len();
218 Ok((
219 text,
220 vec![(text_range, path.unwrap_or_default(), EntryType::File)],
221 ))
222 });
223 }
224 }
225
226 let snapshots = worktrees
227 .iter()
228 .map(|worktree| worktree.read(cx).snapshot())
229 .collect::<Vec<_>>();
230 cx.background_executor().spawn(async move {
231 let mut text = String::new();
232 let mut ranges = Vec::new();
233 for snapshot in snapshots {
234 let mut directory_stack: Vec<(Arc<Path>, String, usize)> = Vec::new();
235 let mut folded_directory_names_stack = Vec::new();
236 let mut is_top_level_directory = true;
237 for entry in snapshot.entries(false, 0) {
238 let mut path_buf = PathBuf::new();
239 path_buf.push(snapshot.root_name());
240 path_buf.push(&entry.path);
241 if !matcher.is_match(&path_buf) {
242 continue;
243 }
244
245 while let Some((dir, _, _)) = directory_stack.last() {
246 if entry.path.starts_with(dir) {
247 break;
248 }
249 let (_, entry_name, start) = directory_stack.pop().unwrap();
250 ranges.push((
251 start..text.len().saturating_sub(1),
252 PathBuf::from(entry_name),
253 EntryType::Directory,
254 ));
255 }
256
257 let filename = entry
258 .path
259 .file_name()
260 .unwrap_or_default()
261 .to_str()
262 .unwrap_or_default()
263 .to_string();
264
265 if entry.is_dir() {
266 // Auto-fold directories that contain no files
267 let mut child_entries = snapshot.child_entries(&entry.path);
268 if let Some(child) = child_entries.next() {
269 if child_entries.next().is_none() && child.kind.is_dir() {
270 if is_top_level_directory {
271 is_top_level_directory = false;
272 folded_directory_names_stack
273 .push(path_buf.to_string_lossy().to_string());
274 } else {
275 folded_directory_names_stack.push(filename.to_string());
276 }
277 continue;
278 }
279 } else {
280 // Skip empty directories
281 folded_directory_names_stack.clear();
282 continue;
283 }
284 let prefix_paths = folded_directory_names_stack.drain(..).as_slice().join("/");
285 let entry_start = text.len();
286 if prefix_paths.is_empty() {
287 if is_top_level_directory {
288 text.push_str(&path_buf.to_string_lossy());
289 is_top_level_directory = false;
290 } else {
291 text.push_str(&filename);
292 }
293 directory_stack.push((entry.path.clone(), filename, entry_start));
294 } else {
295 let entry_name = format!("{}/{}", prefix_paths, &filename);
296 text.push_str(&entry_name);
297 directory_stack.push((entry.path.clone(), entry_name, entry_start));
298 }
299 text.push('\n');
300 } else if entry.is_file() {
301 if let Some(abs_path) = snapshot.absolutize(&entry.path).log_err() {
302 let prev_len = text.len();
303 collect_file_content(
304 &mut text,
305 fs.clone(),
306 filename.clone(),
307 abs_path.into(),
308 )
309 .await?;
310 ranges.push((
311 prev_len..text.len(),
312 PathBuf::from(filename),
313 EntryType::File,
314 ));
315 text.push('\n');
316 }
317 }
318 }
319
320 while let Some((dir, _, start)) = directory_stack.pop() {
321 let mut root_path = PathBuf::new();
322 root_path.push(snapshot.root_name());
323 root_path.push(&dir);
324 ranges.push((start..text.len(), root_path, EntryType::Directory));
325 }
326 }
327 Ok((text, ranges))
328 })
329}
330
331async fn collect_file_content(
332 buffer: &mut String,
333 fs: Arc<dyn Fs>,
334 filename: String,
335 abs_path: Arc<Path>,
336) -> Result<()> {
337 let mut content = fs.load(&abs_path).await?;
338 LineEnding::normalize(&mut content);
339 buffer.reserve(filename.len() + content.len() + 9);
340 buffer.push_str(&codeblock_fence_for_path(
341 Some(&PathBuf::from(filename)),
342 None,
343 ));
344 buffer.push_str(&content);
345 if !buffer.ends_with('\n') {
346 buffer.push('\n');
347 }
348 buffer.push_str("```");
349 anyhow::Ok(())
350}
351
352#[derive(IntoElement)]
353pub struct EntryPlaceholder {
354 pub path: Option<PathBuf>,
355 pub is_directory: bool,
356 pub line_range: Option<Range<u32>>,
357 pub id: ElementId,
358 pub unfold: Arc<dyn Fn(&mut WindowContext)>,
359}
360
361impl RenderOnce for EntryPlaceholder {
362 fn render(self, _cx: &mut WindowContext) -> impl IntoElement {
363 let unfold = self.unfold;
364 let title = if let Some(path) = self.path.as_ref() {
365 SharedString::from(path.to_string_lossy().to_string())
366 } else {
367 SharedString::from("untitled")
368 };
369 let icon = if self.is_directory {
370 IconName::Folder
371 } else {
372 IconName::File
373 };
374
375 ButtonLike::new(self.id)
376 .style(ButtonStyle::Filled)
377 .layer(ElevationIndex::ElevatedSurface)
378 .child(Icon::new(icon))
379 .child(Label::new(title))
380 .when_some(self.line_range, |button, line_range| {
381 button.child(Label::new(":")).child(Label::new(format!(
382 "{}-{}",
383 line_range.start, line_range.end
384 )))
385 })
386 .on_click(move |_, cx| unfold(cx))
387 }
388}
389
390pub fn codeblock_fence_for_path(path: Option<&Path>, row_range: Option<Range<u32>>) -> String {
391 let mut text = String::new();
392 write!(text, "```").unwrap();
393
394 if let Some(path) = path {
395 if let Some(extension) = path.extension().and_then(|ext| ext.to_str()) {
396 write!(text, "{} ", extension).unwrap();
397 }
398
399 write!(text, "{}", path.display()).unwrap();
400 } else {
401 write!(text, "untitled").unwrap();
402 }
403
404 if let Some(row_range) = row_range {
405 write!(text, ":{}-{}", row_range.start + 1, row_range.end + 1).unwrap();
406 }
407
408 text.push('\n');
409 text
410}