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