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