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 agent::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        self.context_store
134            .update(cx, |context_store, cx| {
135                if is_directory {
136                    context_store
137                        .add_directory(&project_path, true, cx)
138                        .log_err();
139                } else {
140                    context_store
141                        .add_file_from_path(project_path.clone(), true, cx)
142                        .detach_and_log_err(cx);
143                }
144            })
145            .ok();
146    }
147
148    fn dismissed(&mut self, _: &mut Window, cx: &mut Context<Picker<Self>>) {
149        self.context_picker
150            .update(cx, |_, cx| {
151                cx.emit(DismissEvent);
152            })
153            .ok();
154    }
155
156    fn render_match(
157        &self,
158        ix: usize,
159        selected: bool,
160        _window: &mut Window,
161        cx: &mut Context<Picker<Self>>,
162    ) -> Option<Self::ListItem> {
163        let FileMatch { mat, .. } = &self.matches[ix];
164
165        Some(
166            ListItem::new(ix)
167                .inset(true)
168                .toggle_state(selected)
169                .child(render_file_context_entry(
170                    ElementId::named_usize("file-ctx-picker", ix),
171                    WorktreeId::from_usize(mat.worktree_id),
172                    &mat.path,
173                    &mat.path_prefix,
174                    mat.is_dir,
175                    self.context_store.clone(),
176                    cx,
177                )),
178        )
179    }
180}
181
182pub struct FileMatch {
183    pub mat: PathMatch,
184    pub is_recent: bool,
185}
186
187pub(crate) fn search_files(
188    query: String,
189    cancellation_flag: Arc<AtomicBool>,
190    workspace: &Entity<Workspace>,
191    cx: &App,
192) -> Task<Vec<FileMatch>> {
193    if query.is_empty() {
194        let workspace = workspace.read(cx);
195        let project = workspace.project().read(cx);
196        let recent_matches = workspace
197            .recent_navigation_history(Some(10), cx)
198            .into_iter()
199            .filter_map(|(project_path, _)| {
200                let worktree = project.worktree_for_id(project_path.worktree_id, cx)?;
201                Some(FileMatch {
202                    mat: PathMatch {
203                        score: 0.,
204                        positions: Vec::new(),
205                        worktree_id: project_path.worktree_id.to_usize(),
206                        path: project_path.path,
207                        path_prefix: worktree.read(cx).root_name().into(),
208                        distance_to_relative_ancestor: 0,
209                        is_dir: false,
210                    },
211                    is_recent: true,
212                })
213            });
214
215        let file_matches = project.worktrees(cx).flat_map(|worktree| {
216            let worktree = worktree.read(cx);
217            let path_prefix: Arc<str> = worktree.root_name().into();
218            worktree.entries(false, 0).map(move |entry| FileMatch {
219                mat: PathMatch {
220                    score: 0.,
221                    positions: Vec::new(),
222                    worktree_id: worktree.id().to_usize(),
223                    path: entry.path.clone(),
224                    path_prefix: path_prefix.clone(),
225                    distance_to_relative_ancestor: 0,
226                    is_dir: entry.is_dir(),
227                },
228                is_recent: false,
229            })
230        });
231
232        Task::ready(recent_matches.chain(file_matches).collect())
233    } else {
234        let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
235        let candidate_sets = worktrees
236            .into_iter()
237            .map(|worktree| {
238                let worktree = worktree.read(cx);
239
240                PathMatchCandidateSet {
241                    snapshot: worktree.snapshot(),
242                    include_ignored: worktree
243                        .root_entry()
244                        .map_or(false, |entry| entry.is_ignored),
245                    include_root_name: true,
246                    candidates: project::Candidates::Entries,
247                }
248            })
249            .collect::<Vec<_>>();
250
251        let executor = cx.background_executor().clone();
252        cx.foreground_executor().spawn(async move {
253            fuzzy::match_path_sets(
254                candidate_sets.as_slice(),
255                query.as_str(),
256                None,
257                false,
258                100,
259                &cancellation_flag,
260                executor,
261            )
262            .await
263            .into_iter()
264            .map(|mat| FileMatch {
265                mat,
266                is_recent: false,
267            })
268            .collect::<Vec<_>>()
269        })
270    }
271}
272
273pub fn extract_file_name_and_directory(
274    path: &Path,
275    path_prefix: &str,
276) -> (SharedString, Option<SharedString>) {
277    if path == Path::new("") {
278        (
279            SharedString::from(
280                path_prefix
281                    .trim_end_matches(std::path::MAIN_SEPARATOR)
282                    .to_string(),
283            ),
284            None,
285        )
286    } else {
287        let file_name = path
288            .file_name()
289            .unwrap_or_default()
290            .to_string_lossy()
291            .to_string()
292            .into();
293
294        let mut directory = path_prefix
295            .trim_end_matches(std::path::MAIN_SEPARATOR)
296            .to_string();
297        if !directory.ends_with('/') {
298            directory.push('/');
299        }
300        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
301            directory.push_str(&parent.to_string_lossy());
302            directory.push('/');
303        }
304
305        (file_name, Some(directory.into()))
306    }
307}
308
309pub fn render_file_context_entry(
310    id: ElementId,
311    worktree_id: WorktreeId,
312    path: &Arc<Path>,
313    path_prefix: &Arc<str>,
314    is_directory: bool,
315    context_store: WeakEntity<ContextStore>,
316    cx: &App,
317) -> Stateful<Div> {
318    let (file_name, directory) = extract_file_name_and_directory(path, path_prefix);
319
320    let added = context_store.upgrade().and_then(|context_store| {
321        let project_path = ProjectPath {
322            worktree_id,
323            path: path.clone(),
324        };
325        if is_directory {
326            context_store
327                .read(cx)
328                .path_included_in_directory(&project_path, cx)
329        } else {
330            context_store.read(cx).file_path_included(&project_path, cx)
331        }
332    });
333
334    let file_icon = if is_directory {
335        FileIcons::get_folder_icon(false, cx)
336    } else {
337        FileIcons::get_icon(path, cx)
338    }
339    .map(Icon::from_path)
340    .unwrap_or_else(|| Icon::new(IconName::File));
341
342    h_flex()
343        .id(id)
344        .gap_1p5()
345        .w_full()
346        .child(file_icon.size(IconSize::Small).color(Color::Muted))
347        .child(
348            h_flex()
349                .gap_1()
350                .child(Label::new(file_name))
351                .children(directory.map(|directory| {
352                    Label::new(directory)
353                        .size(LabelSize::Small)
354                        .color(Color::Muted)
355                })),
356        )
357        .when_some(added, |el, added| match added {
358            FileInclusion::Direct => el.child(
359                h_flex()
360                    .w_full()
361                    .justify_end()
362                    .gap_0p5()
363                    .child(
364                        Icon::new(IconName::Check)
365                            .size(IconSize::Small)
366                            .color(Color::Success),
367                    )
368                    .child(Label::new("Added").size(LabelSize::Small)),
369            ),
370            FileInclusion::InDirectory { full_path } => {
371                let directory_full_path = full_path.to_string_lossy().into_owned();
372
373                el.child(
374                    h_flex()
375                        .w_full()
376                        .justify_end()
377                        .gap_0p5()
378                        .child(
379                            Icon::new(IconName::Check)
380                                .size(IconSize::Small)
381                                .color(Color::Success),
382                        )
383                        .child(Label::new("Included").size(LabelSize::Small)),
384                )
385                .tooltip(Tooltip::text(format!("in {directory_full_path}")))
386            }
387        })
388}