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, Editor, FoldPlaceholder, ToPoint};
 11use file_icons::FileIcons;
 12use fuzzy::PathMatch;
 13use gpui::{
 14    AnyElement, App, DismissEvent, Empty, Entity, FocusHandle, Focusable, Stateful, Task,
 15    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, ElevationIndex, ListItem, 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) = self.editor.upgrade() else {
242            return;
243        };
244
245        editor.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(file_icon, file_name.into()),
296                    ..Default::default()
297                };
298
299                let render_trailer =
300                    move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
301
302                let buffer = editor.buffer().read(cx).snapshot(cx);
303                let mut rows_to_fold = BTreeSet::new();
304                let crease_iter = start_anchors
305                    .into_iter()
306                    .zip(end_anchors)
307                    .map(|(start, end)| {
308                        rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row));
309
310                        Crease::inline(
311                            start..end,
312                            placeholder.clone(),
313                            fold_toggle("tool-use"),
314                            render_trailer,
315                        )
316                    });
317
318                editor.insert_creases(crease_iter, cx);
319
320                for buffer_row in rows_to_fold {
321                    editor.fold_at(&FoldAt { buffer_row }, window, cx);
322                }
323            });
324        });
325
326        let Some(task) = self
327            .context_store
328            .update(cx, |context_store, cx| {
329                context_store.add_file_from_path(project_path, cx)
330            })
331            .ok()
332        else {
333            return;
334        };
335
336        let confirm_behavior = self.confirm_behavior;
337        cx.spawn_in(window, |this, mut cx| async move {
338            match task.await.notify_async_err(&mut cx) {
339                None => anyhow::Ok(()),
340                Some(()) => this.update_in(&mut cx, |this, window, cx| match confirm_behavior {
341                    ConfirmBehavior::KeepOpen => {}
342                    ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
343                }),
344            }
345        })
346        .detach_and_log_err(cx);
347    }
348
349    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
350        self.context_picker
351            .update(cx, |_, cx| {
352                cx.emit(DismissEvent);
353            })
354            .ok();
355    }
356
357    fn render_match(
358        &self,
359        ix: usize,
360        selected: bool,
361        _window: &mut Window,
362        cx: &mut Context<Picker<Self>>,
363    ) -> Option<Self::ListItem> {
364        let path_match = &self.matches[ix];
365
366        Some(
367            ListItem::new(ix)
368                .inset(true)
369                .toggle_state(selected)
370                .child(render_file_context_entry(
371                    ElementId::NamedInteger("file-ctx-picker".into(), ix),
372                    &path_match.path,
373                    &path_match.path_prefix,
374                    self.context_store.clone(),
375                    cx,
376                )),
377        )
378    }
379}
380
381pub fn render_file_context_entry(
382    id: ElementId,
383    path: &Path,
384    path_prefix: &Arc<str>,
385    context_store: WeakEntity<ContextStore>,
386    cx: &App,
387) -> Stateful<Div> {
388    let (file_name, directory) = if path == Path::new("") {
389        (SharedString::from(path_prefix.clone()), None)
390    } else {
391        let file_name = path
392            .file_name()
393            .unwrap_or_default()
394            .to_string_lossy()
395            .to_string()
396            .into();
397
398        let mut directory = format!("{}/", path_prefix);
399
400        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
401            directory.push_str(&parent.to_string_lossy());
402            directory.push('/');
403        }
404
405        (file_name, Some(directory))
406    };
407
408    let added = context_store
409        .upgrade()
410        .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx));
411
412    let file_icon = FileIcons::get_icon(&path, cx)
413        .map(Icon::from_path)
414        .unwrap_or_else(|| Icon::new(IconName::File));
415
416    h_flex()
417        .id(id)
418        .gap_1p5()
419        .w_full()
420        .child(file_icon.size(IconSize::Small).color(Color::Muted))
421        .child(
422            h_flex()
423                .gap_1()
424                .child(Label::new(file_name))
425                .children(directory.map(|directory| {
426                    Label::new(directory)
427                        .size(LabelSize::Small)
428                        .color(Color::Muted)
429                })),
430        )
431        .when_some(added, |el, added| match added {
432            FileInclusion::Direct(_) => el.child(
433                h_flex()
434                    .w_full()
435                    .justify_end()
436                    .gap_0p5()
437                    .child(
438                        Icon::new(IconName::Check)
439                            .size(IconSize::Small)
440                            .color(Color::Success),
441                    )
442                    .child(Label::new("Added").size(LabelSize::Small)),
443            ),
444            FileInclusion::InDirectory(dir_name) => {
445                let dir_name = dir_name.to_string_lossy().into_owned();
446
447                el.child(
448                    h_flex()
449                        .w_full()
450                        .justify_end()
451                        .gap_0p5()
452                        .child(
453                            Icon::new(IconName::Check)
454                                .size(IconSize::Small)
455                                .color(Color::Success),
456                        )
457                        .child(Label::new("Included").size(LabelSize::Small)),
458                )
459                .tooltip(Tooltip::text(format!("in {dir_name}")))
460            }
461        })
462}
463
464fn render_fold_icon_button(
465    icon: SharedString,
466    label: SharedString,
467) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut Window, &mut App) -> AnyElement> {
468    Arc::new(move |fold_id, _fold_range, _window, _cx| {
469        ButtonLike::new(fold_id)
470            .style(ButtonStyle::Filled)
471            .layer(ElevationIndex::ElevatedSurface)
472            .child(
473                h_flex()
474                    .gap_1()
475                    .child(
476                        Icon::from_path(icon.clone())
477                            .size(IconSize::Small)
478                            .color(Color::Muted),
479                    )
480                    .child(
481                        Label::new(label.clone())
482                            .size(LabelSize::Small)
483                            .single_line(),
484                    ),
485            )
486            .into_any_element()
487    })
488}
489
490fn fold_toggle(
491    name: &'static str,
492) -> impl Fn(
493    MultiBufferRow,
494    bool,
495    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
496    &mut Window,
497    &mut App,
498) -> AnyElement {
499    move |row, is_folded, fold, _window, _cx| {
500        Disclosure::new((name, row.0 as u64), !is_folded)
501            .toggle_state(is_folded)
502            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
503            .into_any_element()
504    }
505}