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