file_context_picker.rs

  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}