1use std::collections::BTreeSet;
2use std::ops::Range;
3use std::path::Path;
4use std::sync::atomic::AtomicBool;
5use std::sync::Arc;
6
7use editor::actions::FoldAt;
8use editor::display_map::{Crease, FoldId};
9use editor::scroll::Autoscroll;
10use editor::{Anchor, AnchorRangeExt, Editor, FoldPlaceholder, ToPoint};
11use file_icons::FileIcons;
12use fuzzy::PathMatch;
13use gpui::{
14 AnyElement, App, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, Stateful,
15 Task, WeakEntity,
16};
17use multi_buffer::{MultiBufferPoint, MultiBufferRow};
18use picker::{Picker, PickerDelegate};
19use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
20use rope::Point;
21use text::SelectionGoal;
22use ui::{prelude::*, ButtonLike, Disclosure, ListItem, TintColor, Tooltip};
23use util::ResultExt as _;
24use workspace::{notifications::NotifyResultExt, Workspace};
25
26use crate::context_picker::{ConfirmBehavior, ContextPicker};
27use crate::context_store::{ContextStore, FileInclusion};
28
29pub struct FileContextPicker {
30 picker: Entity<Picker<FileContextPickerDelegate>>,
31}
32
33impl FileContextPicker {
34 pub fn new(
35 context_picker: WeakEntity<ContextPicker>,
36 workspace: WeakEntity<Workspace>,
37 editor: WeakEntity<Editor>,
38 context_store: WeakEntity<ContextStore>,
39 confirm_behavior: ConfirmBehavior,
40 window: &mut Window,
41 cx: &mut Context<Self>,
42 ) -> Self {
43 let delegate = FileContextPickerDelegate::new(
44 context_picker,
45 workspace,
46 editor,
47 context_store,
48 confirm_behavior,
49 );
50 let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
51
52 Self { picker }
53 }
54}
55
56impl Focusable for FileContextPicker {
57 fn focus_handle(&self, cx: &App) -> FocusHandle {
58 self.picker.focus_handle(cx)
59 }
60}
61
62impl Render for FileContextPicker {
63 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
64 self.picker.clone()
65 }
66}
67
68pub struct FileContextPickerDelegate {
69 context_picker: WeakEntity<ContextPicker>,
70 workspace: WeakEntity<Workspace>,
71 editor: WeakEntity<Editor>,
72 context_store: WeakEntity<ContextStore>,
73 confirm_behavior: ConfirmBehavior,
74 matches: Vec<PathMatch>,
75 selected_index: usize,
76}
77
78impl FileContextPickerDelegate {
79 pub fn new(
80 context_picker: WeakEntity<ContextPicker>,
81 workspace: WeakEntity<Workspace>,
82 editor: WeakEntity<Editor>,
83 context_store: WeakEntity<ContextStore>,
84 confirm_behavior: ConfirmBehavior,
85 ) -> Self {
86 Self {
87 context_picker,
88 workspace,
89 editor,
90 context_store,
91 confirm_behavior,
92 matches: Vec::new(),
93 selected_index: 0,
94 }
95 }
96
97 fn search(
98 &mut self,
99 query: String,
100 cancellation_flag: Arc<AtomicBool>,
101 workspace: &Entity<Workspace>,
102 cx: &mut Context<Picker<Self>>,
103 ) -> Task<Vec<PathMatch>> {
104 if query.is_empty() {
105 let workspace = workspace.read(cx);
106 let project = workspace.project().read(cx);
107 let recent_matches = workspace
108 .recent_navigation_history(Some(10), cx)
109 .into_iter()
110 .filter_map(|(project_path, _)| {
111 let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
112 Some(PathMatch {
113 score: 0.,
114 positions: Vec::new(),
115 worktree_id: project_path.worktree_id.to_usize(),
116 path: project_path.path,
117 path_prefix: worktree.read(cx).root_name().into(),
118 distance_to_relative_ancestor: 0,
119 is_dir: false,
120 })
121 });
122
123 let file_matches = project.worktrees(cx).flat_map(|worktree| {
124 let worktree = worktree.read(cx);
125 let path_prefix: Arc<str> = worktree.root_name().into();
126 worktree.entries(false, 0).map(move |entry| PathMatch {
127 score: 0.,
128 positions: Vec::new(),
129 worktree_id: worktree.id().to_usize(),
130 path: entry.path.clone(),
131 path_prefix: path_prefix.clone(),
132 distance_to_relative_ancestor: 0,
133 is_dir: entry.is_dir(),
134 })
135 });
136
137 Task::ready(recent_matches.chain(file_matches).collect())
138 } else {
139 let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
140 let candidate_sets = worktrees
141 .into_iter()
142 .map(|worktree| {
143 let worktree = worktree.read(cx);
144
145 PathMatchCandidateSet {
146 snapshot: worktree.snapshot(),
147 include_ignored: worktree
148 .root_entry()
149 .map_or(false, |entry| entry.is_ignored),
150 include_root_name: true,
151 candidates: project::Candidates::Entries,
152 }
153 })
154 .collect::<Vec<_>>();
155
156 let executor = cx.background_executor().clone();
157 cx.foreground_executor().spawn(async move {
158 fuzzy::match_path_sets(
159 candidate_sets.as_slice(),
160 query.as_str(),
161 None,
162 false,
163 100,
164 &cancellation_flag,
165 executor,
166 )
167 .await
168 })
169 }
170 }
171}
172
173impl PickerDelegate for FileContextPickerDelegate {
174 type ListItem = ListItem;
175
176 fn match_count(&self) -> usize {
177 self.matches.len()
178 }
179
180 fn selected_index(&self) -> usize {
181 self.selected_index
182 }
183
184 fn set_selected_index(
185 &mut self,
186 ix: usize,
187 _window: &mut Window,
188 _cx: &mut Context<Picker<Self>>,
189 ) {
190 self.selected_index = ix;
191 }
192
193 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
194 "Search files & directories…".into()
195 }
196
197 fn update_matches(
198 &mut self,
199 query: String,
200 window: &mut Window,
201 cx: &mut Context<Picker<Self>>,
202 ) -> Task<()> {
203 let Some(workspace) = self.workspace.upgrade() else {
204 return Task::ready(());
205 };
206
207 let search_task = self.search(query, Arc::<AtomicBool>::default(), &workspace, cx);
208
209 cx.spawn_in(window, async move |this, cx| {
210 // TODO: This should be probably be run in the background.
211 let paths = search_task.await;
212
213 this.update(cx, |this, _cx| {
214 this.delegate.matches = paths;
215 })
216 .log_err();
217 })
218 }
219
220 fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
221 let Some(mat) = self.matches.get(self.selected_index) else {
222 return;
223 };
224
225 let file_name = mat
226 .path
227 .file_name()
228 .map(|os_str| os_str.to_string_lossy().into_owned())
229 .unwrap_or(mat.path_prefix.to_string());
230
231 let full_path = mat.path.display().to_string();
232
233 let project_path = ProjectPath {
234 worktree_id: WorktreeId::from_usize(mat.worktree_id),
235 path: mat.path.clone(),
236 };
237
238 let is_directory = mat.is_dir;
239
240 let Some(editor_entity) = self.editor.upgrade() else {
241 return;
242 };
243
244 editor_entity.update(cx, |editor, cx| {
245 editor.transact(window, cx, |editor, window, cx| {
246 // Move empty selections left by 1 column to select the `@`s, so they get overwritten when we insert.
247 {
248 let mut selections = editor.selections.all::<MultiBufferPoint>(cx);
249
250 for selection in selections.iter_mut() {
251 if selection.is_empty() {
252 let old_head = selection.head();
253 let new_head = MultiBufferPoint::new(
254 old_head.row,
255 old_head.column.saturating_sub(1),
256 );
257 selection.set_head(new_head, SelectionGoal::None);
258 }
259 }
260
261 editor.change_selections(Some(Autoscroll::fit()), window, cx, |s| {
262 s.select(selections)
263 });
264 }
265
266 let start_anchors = {
267 let snapshot = editor.buffer().read(cx).snapshot(cx);
268 editor
269 .selections
270 .all::<Point>(cx)
271 .into_iter()
272 .map(|selection| snapshot.anchor_before(selection.start))
273 .collect::<Vec<_>>()
274 };
275
276 editor.insert(&full_path, window, cx);
277
278 let end_anchors = {
279 let snapshot = editor.buffer().read(cx).snapshot(cx);
280 editor
281 .selections
282 .all::<Point>(cx)
283 .into_iter()
284 .map(|selection| snapshot.anchor_after(selection.end))
285 .collect::<Vec<_>>()
286 };
287
288 editor.insert("\n", window, cx); // Needed to end the fold
289
290 let file_icon = if is_directory {
291 FileIcons::get_folder_icon(false, cx)
292 } else {
293 FileIcons::get_icon(&Path::new(&full_path), cx)
294 }
295 .unwrap_or_else(|| SharedString::new(""));
296
297 let placeholder = FoldPlaceholder {
298 render: render_fold_icon_button(
299 file_icon,
300 file_name.into(),
301 editor_entity.downgrade(),
302 ),
303 ..Default::default()
304 };
305
306 let render_trailer =
307 move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
308
309 let buffer = editor.buffer().read(cx).snapshot(cx);
310 let mut rows_to_fold = BTreeSet::new();
311 let crease_iter = start_anchors
312 .into_iter()
313 .zip(end_anchors)
314 .map(|(start, end)| {
315 rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row));
316
317 Crease::inline(
318 start..end,
319 placeholder.clone(),
320 fold_toggle("tool-use"),
321 render_trailer,
322 )
323 });
324
325 editor.insert_creases(crease_iter, cx);
326
327 for buffer_row in rows_to_fold {
328 editor.fold_at(&FoldAt { buffer_row }, window, cx);
329 }
330 });
331 });
332
333 let Some(task) = self
334 .context_store
335 .update(cx, |context_store, cx| {
336 if is_directory {
337 context_store.add_directory(project_path, cx)
338 } else {
339 context_store.add_file_from_path(project_path, cx)
340 }
341 })
342 .ok()
343 else {
344 return;
345 };
346
347 let confirm_behavior = self.confirm_behavior;
348 cx.spawn_in(window, async move |this, cx| {
349 match task.await.notify_async_err(cx) {
350 None => anyhow::Ok(()),
351 Some(()) => this.update_in(cx, |this, window, cx| match confirm_behavior {
352 ConfirmBehavior::KeepOpen => {}
353 ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
354 }),
355 }
356 })
357 .detach_and_log_err(cx);
358 }
359
360 fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
361 self.context_picker
362 .update(cx, |_, cx| {
363 cx.emit(DismissEvent);
364 })
365 .ok();
366 }
367
368 fn render_match(
369 &self,
370 ix: usize,
371 selected: bool,
372 _window: &mut Window,
373 cx: &mut Context<Picker<Self>>,
374 ) -> Option<Self::ListItem> {
375 let path_match = &self.matches[ix];
376
377 Some(
378 ListItem::new(ix)
379 .inset(true)
380 .toggle_state(selected)
381 .child(render_file_context_entry(
382 ElementId::NamedInteger("file-ctx-picker".into(), ix),
383 &path_match.path,
384 &path_match.path_prefix,
385 path_match.is_dir,
386 self.context_store.clone(),
387 cx,
388 )),
389 )
390 }
391}
392
393pub fn render_file_context_entry(
394 id: ElementId,
395 path: &Path,
396 path_prefix: &Arc<str>,
397 is_directory: bool,
398 context_store: WeakEntity<ContextStore>,
399 cx: &App,
400) -> Stateful<Div> {
401 let (file_name, directory) = if path == Path::new("") {
402 (SharedString::from(path_prefix.clone()), None)
403 } else {
404 let file_name = path
405 .file_name()
406 .unwrap_or_default()
407 .to_string_lossy()
408 .to_string()
409 .into();
410
411 let mut directory = format!("{}/", path_prefix);
412
413 if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
414 directory.push_str(&parent.to_string_lossy());
415 directory.push('/');
416 }
417
418 (file_name, Some(directory))
419 };
420
421 let added = context_store.upgrade().and_then(|context_store| {
422 if is_directory {
423 context_store
424 .read(cx)
425 .includes_directory(path)
426 .map(FileInclusion::Direct)
427 } else {
428 context_store.read(cx).will_include_file_path(path, cx)
429 }
430 });
431
432 let file_icon = if is_directory {
433 FileIcons::get_folder_icon(false, cx)
434 } else {
435 FileIcons::get_icon(&path, cx)
436 }
437 .map(Icon::from_path)
438 .unwrap_or_else(|| Icon::new(IconName::File));
439
440 h_flex()
441 .id(id)
442 .gap_1p5()
443 .w_full()
444 .child(file_icon.size(IconSize::Small).color(Color::Muted))
445 .child(
446 h_flex()
447 .gap_1()
448 .child(Label::new(file_name))
449 .children(directory.map(|directory| {
450 Label::new(directory)
451 .size(LabelSize::Small)
452 .color(Color::Muted)
453 })),
454 )
455 .when_some(added, |el, added| match added {
456 FileInclusion::Direct(_) => el.child(
457 h_flex()
458 .w_full()
459 .justify_end()
460 .gap_0p5()
461 .child(
462 Icon::new(IconName::Check)
463 .size(IconSize::Small)
464 .color(Color::Success),
465 )
466 .child(Label::new("Added").size(LabelSize::Small)),
467 ),
468 FileInclusion::InDirectory(dir_name) => {
469 let dir_name = dir_name.to_string_lossy().into_owned();
470
471 el.child(
472 h_flex()
473 .w_full()
474 .justify_end()
475 .gap_0p5()
476 .child(
477 Icon::new(IconName::Check)
478 .size(IconSize::Small)
479 .color(Color::Success),
480 )
481 .child(Label::new("Included").size(LabelSize::Small)),
482 )
483 .tooltip(Tooltip::text(format!("in {dir_name}")))
484 }
485 })
486}
487
488fn render_fold_icon_button(
489 icon: SharedString,
490 label: SharedString,
491 editor: WeakEntity<Editor>,
492) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
493 Arc::new(move |fold_id, fold_range, cx| {
494 let is_in_text_selection = editor.upgrade().is_some_and(|editor| {
495 editor.update(cx, |editor, cx| {
496 let snapshot = editor
497 .buffer()
498 .update(cx, |multi_buffer, cx| multi_buffer.snapshot(cx));
499
500 let is_in_pending_selection = || {
501 editor
502 .selections
503 .pending
504 .as_ref()
505 .is_some_and(|pending_selection| {
506 pending_selection
507 .selection
508 .range()
509 .includes(&fold_range, &snapshot)
510 })
511 };
512
513 let mut is_in_complete_selection = || {
514 editor
515 .selections
516 .disjoint_in_range::<usize>(fold_range.clone(), cx)
517 .into_iter()
518 .any(|selection| {
519 // This is needed to cover a corner case, if we just check for an existing
520 // selection in the fold range, having a cursor at the start of the fold
521 // marks it as selected. Non-empty selections don't cause this.
522 let length = selection.end - selection.start;
523 length > 0
524 })
525 };
526
527 is_in_pending_selection() || is_in_complete_selection()
528 })
529 });
530
531 ButtonLike::new(fold_id)
532 .style(ButtonStyle::Filled)
533 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
534 .toggle_state(is_in_text_selection)
535 .child(
536 h_flex()
537 .gap_1()
538 .child(
539 Icon::from_path(icon.clone())
540 .size(IconSize::Small)
541 .color(Color::Muted),
542 )
543 .child(
544 Label::new(label.clone())
545 .size(LabelSize::Small)
546 .single_line(),
547 ),
548 )
549 .into_any_element()
550 })
551}
552
553fn fold_toggle(
554 name: &'static str,
555) -> impl Fn(
556 MultiBufferRow,
557 bool,
558 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
559 &mut Window,
560 &mut App,
561) -> AnyElement {
562 move |row, is_folded, fold, _window, _cx| {
563 Disclosure::new((name, row.0 as u64), !is_folded)
564 .toggle_state(is_folded)
565 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
566 .into_any_element()
567 }
568}