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, AppContext, DismissEvent, Empty, FocusHandle, FocusableView, Stateful, Task, View,
 15    WeakModel, WeakView,
 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: View<Picker<FileContextPickerDelegate>>,
 31}
 32
 33impl FileContextPicker {
 34    pub fn new(
 35        context_picker: WeakView<ContextPicker>,
 36        workspace: WeakView<Workspace>,
 37        editor: WeakView<Editor>,
 38        context_store: WeakModel<ContextStore>,
 39        confirm_behavior: ConfirmBehavior,
 40        cx: &mut ViewContext<Self>,
 41    ) -> Self {
 42        let delegate = FileContextPickerDelegate::new(
 43            context_picker,
 44            workspace,
 45            editor,
 46            context_store,
 47            confirm_behavior,
 48        );
 49        let picker = cx.new_view(|cx| Picker::uniform_list(delegate, cx));
 50
 51        Self { picker }
 52    }
 53}
 54
 55impl FocusableView for FileContextPicker {
 56    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 57        self.picker.focus_handle(cx)
 58    }
 59}
 60
 61impl Render for FileContextPicker {
 62    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 63        self.picker.clone()
 64    }
 65}
 66
 67pub struct FileContextPickerDelegate {
 68    context_picker: WeakView<ContextPicker>,
 69    workspace: WeakView<Workspace>,
 70    editor: WeakView<Editor>,
 71    context_store: WeakModel<ContextStore>,
 72    confirm_behavior: ConfirmBehavior,
 73    matches: Vec<PathMatch>,
 74    selected_index: usize,
 75}
 76
 77impl FileContextPickerDelegate {
 78    pub fn new(
 79        context_picker: WeakView<ContextPicker>,
 80        workspace: WeakView<Workspace>,
 81        editor: WeakView<Editor>,
 82        context_store: WeakModel<ContextStore>,
 83        confirm_behavior: ConfirmBehavior,
 84    ) -> Self {
 85        Self {
 86            context_picker,
 87            workspace,
 88            editor,
 89            context_store,
 90            confirm_behavior,
 91            matches: Vec::new(),
 92            selected_index: 0,
 93        }
 94    }
 95
 96    fn search(
 97        &mut self,
 98        query: String,
 99        cancellation_flag: Arc<AtomicBool>,
100        workspace: &View<Workspace>,
101        cx: &mut ViewContext<Picker<Self>>,
102    ) -> Task<Vec<PathMatch>> {
103        if query.is_empty() {
104            let workspace = workspace.read(cx);
105            let project = workspace.project().read(cx);
106            let recent_matches = workspace
107                .recent_navigation_history(Some(10), cx)
108                .into_iter()
109                .filter_map(|(project_path, _)| {
110                    let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
111                    Some(PathMatch {
112                        score: 0.,
113                        positions: Vec::new(),
114                        worktree_id: project_path.worktree_id.to_usize(),
115                        path: project_path.path,
116                        path_prefix: worktree.read(cx).root_name().into(),
117                        distance_to_relative_ancestor: 0,
118                        is_dir: false,
119                    })
120                });
121
122            let file_matches = project.worktrees(cx).flat_map(|worktree| {
123                let worktree = worktree.read(cx);
124                let path_prefix: Arc<str> = worktree.root_name().into();
125                worktree.files(true, 0).map(move |entry| PathMatch {
126                    score: 0.,
127                    positions: Vec::new(),
128                    worktree_id: worktree.id().to_usize(),
129                    path: entry.path.clone(),
130                    path_prefix: path_prefix.clone(),
131                    distance_to_relative_ancestor: 0,
132                    is_dir: false,
133                })
134            });
135
136            Task::ready(recent_matches.chain(file_matches).collect())
137        } else {
138            let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
139            let candidate_sets = worktrees
140                .into_iter()
141                .map(|worktree| {
142                    let worktree = worktree.read(cx);
143
144                    PathMatchCandidateSet {
145                        snapshot: worktree.snapshot(),
146                        include_ignored: worktree
147                            .root_entry()
148                            .map_or(false, |entry| entry.is_ignored),
149                        include_root_name: true,
150                        candidates: project::Candidates::Files,
151                    }
152                })
153                .collect::<Vec<_>>();
154
155            let executor = cx.background_executor().clone();
156            cx.foreground_executor().spawn(async move {
157                fuzzy::match_path_sets(
158                    candidate_sets.as_slice(),
159                    query.as_str(),
160                    None,
161                    false,
162                    100,
163                    &cancellation_flag,
164                    executor,
165                )
166                .await
167            })
168        }
169    }
170}
171
172impl PickerDelegate for FileContextPickerDelegate {
173    type ListItem = ListItem;
174
175    fn match_count(&self) -> usize {
176        self.matches.len()
177    }
178
179    fn selected_index(&self) -> usize {
180        self.selected_index
181    }
182
183    fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext<Picker<Self>>) {
184        self.selected_index = ix;
185    }
186
187    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
188        "Search files…".into()
189    }
190
191    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Picker<Self>>) -> Task<()> {
192        let Some(workspace) = self.workspace.upgrade() else {
193            return Task::ready(());
194        };
195
196        let search_task = self.search(query, Arc::<AtomicBool>::default(), &workspace, cx);
197
198        cx.spawn(|this, mut cx| async move {
199            // TODO: This should be probably be run in the background.
200            let paths = search_task.await;
201
202            this.update(&mut cx, |this, _cx| {
203                this.delegate.matches = paths;
204            })
205            .log_err();
206        })
207    }
208
209    fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext<Picker<Self>>) {
210        let Some(mat) = self.matches.get(self.selected_index) else {
211            return;
212        };
213
214        let Some(file_name) = mat
215            .path
216            .file_name()
217            .map(|os_str| os_str.to_string_lossy().into_owned())
218        else {
219            return;
220        };
221
222        let full_path = mat.path.display().to_string();
223
224        let project_path = ProjectPath {
225            worktree_id: WorktreeId::from_usize(mat.worktree_id),
226            path: mat.path.clone(),
227        };
228
229        let Some(editor) = self.editor.upgrade() else {
230            return;
231        };
232
233        editor.update(cx, |editor, cx| {
234            editor.transact(cx, |editor, cx| {
235                // Move empty selections left by 1 column to select the `@`s, so they get overwritten when we insert.
236                {
237                    let mut selections = editor.selections.all::<MultiBufferPoint>(cx);
238
239                    for selection in selections.iter_mut() {
240                        if selection.is_empty() {
241                            let old_head = selection.head();
242                            let new_head = MultiBufferPoint::new(
243                                old_head.row,
244                                old_head.column.saturating_sub(1),
245                            );
246                            selection.set_head(new_head, SelectionGoal::None);
247                        }
248                    }
249
250                    editor.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections));
251                }
252
253                let start_anchors = {
254                    let snapshot = editor.buffer().read(cx).snapshot(cx);
255                    editor
256                        .selections
257                        .all::<Point>(cx)
258                        .into_iter()
259                        .map(|selection| snapshot.anchor_before(selection.start))
260                        .collect::<Vec<_>>()
261                };
262
263                editor.insert(&full_path, cx);
264
265                let end_anchors = {
266                    let snapshot = editor.buffer().read(cx).snapshot(cx);
267                    editor
268                        .selections
269                        .all::<Point>(cx)
270                        .into_iter()
271                        .map(|selection| snapshot.anchor_after(selection.end))
272                        .collect::<Vec<_>>()
273                };
274
275                editor.insert("\n", cx); // Needed to end the fold
276
277                let placeholder = FoldPlaceholder {
278                    render: render_fold_icon_button(IconName::File, file_name.into()),
279                    ..Default::default()
280                };
281
282                let render_trailer = move |_row, _unfold, _cx: &mut WindowContext| Empty.into_any();
283
284                let buffer = editor.buffer().read(cx).snapshot(cx);
285                let mut rows_to_fold = BTreeSet::new();
286                let crease_iter = start_anchors
287                    .into_iter()
288                    .zip(end_anchors)
289                    .map(|(start, end)| {
290                        rows_to_fold.insert(MultiBufferRow(start.to_point(&buffer).row));
291
292                        Crease::inline(
293                            start..end,
294                            placeholder.clone(),
295                            fold_toggle("tool-use"),
296                            render_trailer,
297                        )
298                    });
299
300                editor.insert_creases(crease_iter, cx);
301
302                for buffer_row in rows_to_fold {
303                    editor.fold_at(&FoldAt { buffer_row }, cx);
304                }
305            });
306        });
307
308        let Some(task) = self
309            .context_store
310            .update(cx, |context_store, cx| {
311                context_store.add_file_from_path(project_path, cx)
312            })
313            .ok()
314        else {
315            return;
316        };
317
318        let confirm_behavior = self.confirm_behavior;
319        cx.spawn(|this, mut cx| async move {
320            match task.await.notify_async_err(&mut cx) {
321                None => anyhow::Ok(()),
322                Some(()) => this.update(&mut cx, |this, cx| match confirm_behavior {
323                    ConfirmBehavior::KeepOpen => {}
324                    ConfirmBehavior::Close => this.delegate.dismissed(cx),
325                }),
326            }
327        })
328        .detach_and_log_err(cx);
329    }
330
331    fn dismissed(&mut self, cx: &mut ViewContext<Picker<Self>>) {
332        self.context_picker
333            .update(cx, |_, cx| {
334                cx.emit(DismissEvent);
335            })
336            .ok();
337    }
338
339    fn render_match(
340        &self,
341        ix: usize,
342        selected: bool,
343        cx: &mut ViewContext<Picker<Self>>,
344    ) -> Option<Self::ListItem> {
345        let path_match = &self.matches[ix];
346
347        Some(
348            ListItem::new(ix)
349                .inset(true)
350                .toggle_state(selected)
351                .child(render_file_context_entry(
352                    ElementId::NamedInteger("file-ctx-picker".into(), ix),
353                    &path_match.path,
354                    &path_match.path_prefix,
355                    self.context_store.clone(),
356                    cx,
357                )),
358        )
359    }
360}
361
362pub fn render_file_context_entry(
363    id: ElementId,
364    path: &Path,
365    path_prefix: &Arc<str>,
366    context_store: WeakModel<ContextStore>,
367    cx: &WindowContext,
368) -> Stateful<Div> {
369    let (file_name, directory) = if path == Path::new("") {
370        (SharedString::from(path_prefix.clone()), None)
371    } else {
372        let file_name = path
373            .file_name()
374            .unwrap_or_default()
375            .to_string_lossy()
376            .to_string()
377            .into();
378
379        let mut directory = format!("{}/", path_prefix);
380
381        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
382            directory.push_str(&parent.to_string_lossy());
383            directory.push('/');
384        }
385
386        (file_name, Some(directory))
387    };
388
389    let added = context_store
390        .upgrade()
391        .and_then(|context_store| context_store.read(cx).will_include_file_path(path, cx));
392
393    let file_icon = FileIcons::get_icon(&path, cx)
394        .map(Icon::from_path)
395        .unwrap_or_else(|| Icon::new(IconName::File));
396
397    h_flex()
398        .id(id)
399        .gap_1()
400        .w_full()
401        .child(file_icon.size(IconSize::Small))
402        .child(
403            h_flex()
404                .gap_2()
405                .child(Label::new(file_name))
406                .children(directory.map(|directory| {
407                    Label::new(directory)
408                        .size(LabelSize::Small)
409                        .color(Color::Muted)
410                })),
411        )
412        .child(div().w_full())
413        .when_some(added, |el, added| match added {
414            FileInclusion::Direct(_) => el.child(
415                h_flex()
416                    .gap_1()
417                    .child(
418                        Icon::new(IconName::Check)
419                            .size(IconSize::Small)
420                            .color(Color::Success),
421                    )
422                    .child(Label::new("Added").size(LabelSize::Small)),
423            ),
424            FileInclusion::InDirectory(dir_name) => {
425                let dir_name = dir_name.to_string_lossy().into_owned();
426
427                el.child(
428                    h_flex()
429                        .gap_1()
430                        .child(
431                            Icon::new(IconName::Check)
432                                .size(IconSize::Small)
433                                .color(Color::Success),
434                        )
435                        .child(Label::new("Included").size(LabelSize::Small)),
436                )
437                .tooltip(move |cx| Tooltip::text(format!("in {dir_name}"), cx))
438            }
439        })
440}
441
442fn render_fold_icon_button(
443    icon: IconName,
444    label: SharedString,
445) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut WindowContext) -> AnyElement> {
446    Arc::new(move |fold_id, _fold_range, _cx| {
447        ButtonLike::new(fold_id)
448            .style(ButtonStyle::Filled)
449            .layer(ElevationIndex::ElevatedSurface)
450            .child(Icon::new(icon))
451            .child(Label::new(label.clone()).single_line())
452            .into_any_element()
453    })
454}
455
456fn fold_toggle(
457    name: &'static str,
458) -> impl Fn(
459    MultiBufferRow,
460    bool,
461    Arc<dyn Fn(bool, &mut WindowContext) + Send + Sync>,
462    &mut WindowContext,
463) -> AnyElement {
464    move |row, is_folded, fold, _cx| {
465        Disclosure::new((name, row.0 as u64), !is_folded)
466            .toggle_state(is_folded)
467            .on_click(move |_e, cx| fold(!is_folded, cx))
468            .into_any_element()
469    }
470}