file_context_picker.rs

  1use std::path::Path;
  2use std::sync::Arc;
  3use std::sync::atomic::AtomicBool;
  4
  5use file_icons::FileIcons;
  6use fuzzy::PathMatch;
  7use gpui::{
  8    App, AppContext, DismissEvent, Entity, FocusHandle, Focusable, Stateful, Task, WeakEntity,
  9};
 10use picker::{Picker, PickerDelegate};
 11use project::{PathMatchCandidateSet, ProjectPath, WorktreeId};
 12use ui::{ListItem, Tooltip, prelude::*};
 13use util::ResultExt as _;
 14use workspace::{Workspace, notifications::NotifyResultExt};
 15
 16use crate::context_picker::{ConfirmBehavior, ContextPicker};
 17use crate::context_store::{ContextStore, FileInclusion};
 18
 19pub struct FileContextPicker {
 20    picker: Entity<Picker<FileContextPickerDelegate>>,
 21}
 22
 23impl FileContextPicker {
 24    pub fn new(
 25        context_picker: WeakEntity<ContextPicker>,
 26        workspace: WeakEntity<Workspace>,
 27        context_store: WeakEntity<ContextStore>,
 28        confirm_behavior: ConfirmBehavior,
 29        window: &mut Window,
 30        cx: &mut Context<Self>,
 31    ) -> Self {
 32        let delegate = FileContextPickerDelegate::new(
 33            context_picker,
 34            workspace,
 35            context_store,
 36            confirm_behavior,
 37        );
 38        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
 39
 40        Self { picker }
 41    }
 42}
 43
 44impl Focusable for FileContextPicker {
 45    fn focus_handle(&self, cx: &App) -> FocusHandle {
 46        self.picker.focus_handle(cx)
 47    }
 48}
 49
 50impl Render for FileContextPicker {
 51    fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
 52        self.picker.clone()
 53    }
 54}
 55
 56pub struct FileContextPickerDelegate {
 57    context_picker: WeakEntity<ContextPicker>,
 58    workspace: WeakEntity<Workspace>,
 59    context_store: WeakEntity<ContextStore>,
 60    confirm_behavior: ConfirmBehavior,
 61    matches: Vec<FileMatch>,
 62    selected_index: usize,
 63}
 64
 65impl FileContextPickerDelegate {
 66    pub fn new(
 67        context_picker: WeakEntity<ContextPicker>,
 68        workspace: WeakEntity<Workspace>,
 69        context_store: WeakEntity<ContextStore>,
 70        confirm_behavior: ConfirmBehavior,
 71    ) -> Self {
 72        Self {
 73            context_picker,
 74            workspace,
 75            context_store,
 76            confirm_behavior,
 77            matches: Vec::new(),
 78            selected_index: 0,
 79        }
 80    }
 81}
 82
 83impl PickerDelegate for FileContextPickerDelegate {
 84    type ListItem = ListItem;
 85
 86    fn match_count(&self) -> usize {
 87        self.matches.len()
 88    }
 89
 90    fn selected_index(&self) -> usize {
 91        self.selected_index
 92    }
 93
 94    fn set_selected_index(
 95        &mut self,
 96        ix: usize,
 97        _window: &mut Window,
 98        _cx: &mut Context<Picker<Self>>,
 99    ) {
100        self.selected_index = ix;
101    }
102
103    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
104        "Search files & directories…".into()
105    }
106
107    fn update_matches(
108        &mut self,
109        query: String,
110        window: &mut Window,
111        cx: &mut Context<Picker<Self>>,
112    ) -> Task<()> {
113        let Some(workspace) = self.workspace.upgrade() else {
114            return Task::ready(());
115        };
116
117        let search_task = search_files(query, Arc::<AtomicBool>::default(), &workspace, cx);
118
119        cx.spawn_in(window, async move |this, cx| {
120            // TODO: This should be probably be run in the background.
121            let paths = search_task.await;
122
123            this.update(cx, |this, _cx| {
124                this.delegate.matches = paths;
125            })
126            .log_err();
127        })
128    }
129
130    fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
131        let Some(FileMatch { mat, .. }) = self.matches.get(self.selected_index) else {
132            return;
133        };
134
135        let project_path = ProjectPath {
136            worktree_id: WorktreeId::from_usize(mat.worktree_id),
137            path: mat.path.clone(),
138        };
139
140        let is_directory = mat.is_dir;
141
142        let Some(task) = self
143            .context_store
144            .update(cx, |context_store, cx| {
145                if is_directory {
146                    context_store.add_directory(project_path, true, cx)
147                } else {
148                    context_store.add_file_from_path(project_path, true, cx)
149                }
150            })
151            .ok()
152        else {
153            return;
154        };
155
156        let confirm_behavior = self.confirm_behavior;
157        cx.spawn_in(window, async move |this, cx| {
158            match task.await.notify_async_err(cx) {
159                None => anyhow::Ok(()),
160                Some(()) => this.update_in(cx, |this, window, cx| match confirm_behavior {
161                    ConfirmBehavior::KeepOpen => {}
162                    ConfirmBehavior::Close => this.delegate.dismissed(window, cx),
163                }),
164            }
165        })
166        .detach_and_log_err(cx);
167    }
168
169    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
170        self.context_picker
171            .update(cx, |_, cx| {
172                cx.emit(DismissEvent);
173            })
174            .ok();
175    }
176
177    fn render_match(
178        &self,
179        ix: usize,
180        selected: bool,
181        _window: &mut Window,
182        cx: &mut Context<Picker<Self>>,
183    ) -> Option<Self::ListItem> {
184        let FileMatch { mat, .. } = &self.matches[ix];
185
186        Some(
187            ListItem::new(ix)
188                .inset(true)
189                .toggle_state(selected)
190                .child(render_file_context_entry(
191                    ElementId::NamedInteger("file-ctx-picker".into(), ix),
192                    &mat.path,
193                    &mat.path_prefix,
194                    mat.is_dir,
195                    self.context_store.clone(),
196                    cx,
197                )),
198        )
199    }
200}
201
202pub struct FileMatch {
203    pub mat: PathMatch,
204    pub is_recent: bool,
205}
206
207pub(crate) fn search_files(
208    query: String,
209    cancellation_flag: Arc<AtomicBool>,
210    workspace: &Entity<Workspace>,
211    cx: &App,
212) -> Task<Vec<FileMatch>> {
213    if query.is_empty() {
214        let workspace = workspace.read(cx);
215        let project = workspace.project().read(cx);
216        let recent_matches = workspace
217            .recent_navigation_history(Some(10), cx)
218            .into_iter()
219            .filter_map(|(project_path, _)| {
220                let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
221                Some(FileMatch {
222                    mat: PathMatch {
223                        score: 0.,
224                        positions: Vec::new(),
225                        worktree_id: project_path.worktree_id.to_usize(),
226                        path: project_path.path,
227                        path_prefix: worktree.read(cx).root_name().into(),
228                        distance_to_relative_ancestor: 0,
229                        is_dir: false,
230                    },
231                    is_recent: true,
232                })
233            });
234
235        let file_matches = project.worktrees(cx).flat_map(|worktree| {
236            let worktree = worktree.read(cx);
237            let path_prefix: Arc<str> = worktree.root_name().into();
238            worktree.entries(false, 0).map(move |entry| FileMatch {
239                mat: PathMatch {
240                    score: 0.,
241                    positions: Vec::new(),
242                    worktree_id: worktree.id().to_usize(),
243                    path: entry.path.clone(),
244                    path_prefix: path_prefix.clone(),
245                    distance_to_relative_ancestor: 0,
246                    is_dir: entry.is_dir(),
247                },
248                is_recent: false,
249            })
250        });
251
252        Task::ready(recent_matches.chain(file_matches).collect())
253    } else {
254        let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
255        let candidate_sets = worktrees
256            .into_iter()
257            .map(|worktree| {
258                let worktree = worktree.read(cx);
259
260                PathMatchCandidateSet {
261                    snapshot: worktree.snapshot(),
262                    include_ignored: worktree
263                        .root_entry()
264                        .map_or(false, |entry| entry.is_ignored),
265                    include_root_name: true,
266                    candidates: project::Candidates::Entries,
267                }
268            })
269            .collect::<Vec<_>>();
270
271        let executor = cx.background_executor().clone();
272        cx.foreground_executor().spawn(async move {
273            fuzzy::match_path_sets(
274                candidate_sets.as_slice(),
275                query.as_str(),
276                None,
277                false,
278                100,
279                &cancellation_flag,
280                executor,
281            )
282            .await
283            .into_iter()
284            .map(|mat| FileMatch {
285                mat,
286                is_recent: false,
287            })
288            .collect::<Vec<_>>()
289        })
290    }
291}
292
293pub fn extract_file_name_and_directory(
294    path: &Path,
295    path_prefix: &str,
296) -> (SharedString, Option<SharedString>) {
297    if path == Path::new("") {
298        (
299            SharedString::from(
300                path_prefix
301                    .trim_end_matches(std::path::MAIN_SEPARATOR)
302                    .to_string(),
303            ),
304            None,
305        )
306    } else {
307        let file_name = path
308            .file_name()
309            .unwrap_or_default()
310            .to_string_lossy()
311            .to_string()
312            .into();
313
314        let mut directory = path_prefix
315            .trim_end_matches(std::path::MAIN_SEPARATOR)
316            .to_string();
317        if !directory.ends_with('/') {
318            directory.push('/');
319        }
320        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
321            directory.push_str(&parent.to_string_lossy());
322            directory.push('/');
323        }
324
325        (file_name, Some(directory.into()))
326    }
327}
328
329pub fn render_file_context_entry(
330    id: ElementId,
331    path: &Path,
332    path_prefix: &Arc<str>,
333    is_directory: bool,
334    context_store: WeakEntity<ContextStore>,
335    cx: &App,
336) -> Stateful<Div> {
337    let (file_name, directory) = extract_file_name_and_directory(path, path_prefix);
338
339    let added = context_store.upgrade().and_then(|context_store| {
340        if is_directory {
341            context_store.read(cx).includes_directory(path)
342        } else {
343            context_store.read(cx).will_include_file_path(path, cx)
344        }
345    });
346
347    let file_icon = if is_directory {
348        FileIcons::get_folder_icon(false, cx)
349    } else {
350        FileIcons::get_icon(&path, cx)
351    }
352    .map(Icon::from_path)
353    .unwrap_or_else(|| Icon::new(IconName::File));
354
355    h_flex()
356        .id(id)
357        .gap_1p5()
358        .w_full()
359        .child(file_icon.size(IconSize::Small).color(Color::Muted))
360        .child(
361            h_flex()
362                .gap_1()
363                .child(Label::new(file_name))
364                .children(directory.map(|directory| {
365                    Label::new(directory)
366                        .size(LabelSize::Small)
367                        .color(Color::Muted)
368                })),
369        )
370        .when_some(added, |el, added| match added {
371            FileInclusion::Direct(_) => el.child(
372                h_flex()
373                    .w_full()
374                    .justify_end()
375                    .gap_0p5()
376                    .child(
377                        Icon::new(IconName::Check)
378                            .size(IconSize::Small)
379                            .color(Color::Success),
380                    )
381                    .child(Label::new("Added").size(LabelSize::Small)),
382            ),
383            FileInclusion::InDirectory(dir_name) => {
384                let dir_name = dir_name.to_string_lossy().into_owned();
385
386                el.child(
387                    h_flex()
388                        .w_full()
389                        .justify_end()
390                        .gap_0p5()
391                        .child(
392                            Icon::new(IconName::Check)
393                                .size(IconSize::Small)
394                                .color(Color::Success),
395                        )
396                        .child(Label::new("Included").size(LabelSize::Small)),
397                )
398                .tooltip(Tooltip::text(format!("in {dir_name}")))
399            }
400        })
401}