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                    WorktreeId::from_usize(mat.worktree_id),
193                    &mat.path,
194                    &mat.path_prefix,
195                    mat.is_dir,
196                    self.context_store.clone(),
197                    cx,
198                )),
199        )
200    }
201}
202
203pub struct FileMatch {
204    pub mat: PathMatch,
205    pub is_recent: bool,
206}
207
208pub(crate) fn search_files(
209    query: String,
210    cancellation_flag: Arc<AtomicBool>,
211    workspace: &Entity<Workspace>,
212    cx: &App,
213) -> Task<Vec<FileMatch>> {
214    if query.is_empty() {
215        let workspace = workspace.read(cx);
216        let project = workspace.project().read(cx);
217        let recent_matches = workspace
218            .recent_navigation_history(Some(10), cx)
219            .into_iter()
220            .filter_map(|(project_path, _)| {
221                let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
222                Some(FileMatch {
223                    mat: PathMatch {
224                        score: 0.,
225                        positions: Vec::new(),
226                        worktree_id: project_path.worktree_id.to_usize(),
227                        path: project_path.path,
228                        path_prefix: worktree.read(cx).root_name().into(),
229                        distance_to_relative_ancestor: 0,
230                        is_dir: false,
231                    },
232                    is_recent: true,
233                })
234            });
235
236        let file_matches = project.worktrees(cx).flat_map(|worktree| {
237            let worktree = worktree.read(cx);
238            let path_prefix: Arc<str> = worktree.root_name().into();
239            worktree.entries(false, 0).map(move |entry| FileMatch {
240                mat: PathMatch {
241                    score: 0.,
242                    positions: Vec::new(),
243                    worktree_id: worktree.id().to_usize(),
244                    path: entry.path.clone(),
245                    path_prefix: path_prefix.clone(),
246                    distance_to_relative_ancestor: 0,
247                    is_dir: entry.is_dir(),
248                },
249                is_recent: false,
250            })
251        });
252
253        Task::ready(recent_matches.chain(file_matches).collect())
254    } else {
255        let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
256        let candidate_sets = worktrees
257            .into_iter()
258            .map(|worktree| {
259                let worktree = worktree.read(cx);
260
261                PathMatchCandidateSet {
262                    snapshot: worktree.snapshot(),
263                    include_ignored: worktree
264                        .root_entry()
265                        .map_or(false, |entry| entry.is_ignored),
266                    include_root_name: true,
267                    candidates: project::Candidates::Entries,
268                }
269            })
270            .collect::<Vec<_>>();
271
272        let executor = cx.background_executor().clone();
273        cx.foreground_executor().spawn(async move {
274            fuzzy::match_path_sets(
275                candidate_sets.as_slice(),
276                query.as_str(),
277                None,
278                false,
279                100,
280                &cancellation_flag,
281                executor,
282            )
283            .await
284            .into_iter()
285            .map(|mat| FileMatch {
286                mat,
287                is_recent: false,
288            })
289            .collect::<Vec<_>>()
290        })
291    }
292}
293
294pub fn extract_file_name_and_directory(
295    path: &Path,
296    path_prefix: &str,
297) -> (SharedString, Option<SharedString>) {
298    if path == Path::new("") {
299        (
300            SharedString::from(
301                path_prefix
302                    .trim_end_matches(std::path::MAIN_SEPARATOR)
303                    .to_string(),
304            ),
305            None,
306        )
307    } else {
308        let file_name = path
309            .file_name()
310            .unwrap_or_default()
311            .to_string_lossy()
312            .to_string()
313            .into();
314
315        let mut directory = path_prefix
316            .trim_end_matches(std::path::MAIN_SEPARATOR)
317            .to_string();
318        if !directory.ends_with('/') {
319            directory.push('/');
320        }
321        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
322            directory.push_str(&parent.to_string_lossy());
323            directory.push('/');
324        }
325
326        (file_name, Some(directory.into()))
327    }
328}
329
330pub fn render_file_context_entry(
331    id: ElementId,
332    worktree_id: WorktreeId,
333    path: &Arc<Path>,
334    path_prefix: &Arc<str>,
335    is_directory: bool,
336    context_store: WeakEntity<ContextStore>,
337    cx: &App,
338) -> Stateful<Div> {
339    let (file_name, directory) = extract_file_name_and_directory(&path, path_prefix);
340
341    let added = context_store.upgrade().and_then(|context_store| {
342        let project_path = ProjectPath {
343            worktree_id,
344            path: path.clone(),
345        };
346        if is_directory {
347            context_store.read(cx).includes_directory(&project_path)
348        } else {
349            context_store
350                .read(cx)
351                .will_include_file_path(&project_path, cx)
352        }
353    });
354
355    let file_icon = if is_directory {
356        FileIcons::get_folder_icon(false, cx)
357    } else {
358        FileIcons::get_icon(&path, cx)
359    }
360    .map(Icon::from_path)
361    .unwrap_or_else(|| Icon::new(IconName::File));
362
363    h_flex()
364        .id(id)
365        .gap_1p5()
366        .w_full()
367        .child(file_icon.size(IconSize::Small).color(Color::Muted))
368        .child(
369            h_flex()
370                .gap_1()
371                .child(Label::new(file_name))
372                .children(directory.map(|directory| {
373                    Label::new(directory)
374                        .size(LabelSize::Small)
375                        .color(Color::Muted)
376                })),
377        )
378        .when_some(added, |el, added| match added {
379            FileInclusion::Direct(_) => el.child(
380                h_flex()
381                    .w_full()
382                    .justify_end()
383                    .gap_0p5()
384                    .child(
385                        Icon::new(IconName::Check)
386                            .size(IconSize::Small)
387                            .color(Color::Success),
388                    )
389                    .child(Label::new("Added").size(LabelSize::Small)),
390            ),
391            FileInclusion::InDirectory(directory_project_path) => {
392                // TODO: Consider using worktree full_path to include worktree name.
393                let directory_path = directory_project_path.path.to_string_lossy().into_owned();
394
395                el.child(
396                    h_flex()
397                        .w_full()
398                        .justify_end()
399                        .gap_0p5()
400                        .child(
401                            Icon::new(IconName::Check)
402                                .size(IconSize::Small)
403                                .color(Color::Success),
404                        )
405                        .child(Label::new("Included").size(LabelSize::Small)),
406                )
407                .tooltip(Tooltip::text(format!("in {directory_path}")))
408            }
409        })
410}