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