file_context_picker.rs

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