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.get(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.root_entry().is_some_and(|entry| entry.is_ignored),
243                    include_root_name: true,
244                    candidates: project::Candidates::Entries,
245                }
246            })
247            .collect::<Vec<_>>();
248
249        let executor = cx.background_executor().clone();
250        cx.foreground_executor().spawn(async move {
251            fuzzy::match_path_sets(
252                candidate_sets.as_slice(),
253                query.as_str(),
254                None,
255                false,
256                100,
257                &cancellation_flag,
258                executor,
259            )
260            .await
261            .into_iter()
262            .map(|mat| FileMatch {
263                mat,
264                is_recent: false,
265            })
266            .collect::<Vec<_>>()
267        })
268    }
269}
270
271pub fn extract_file_name_and_directory(
272    path: &Path,
273    path_prefix: &str,
274) -> (SharedString, Option<SharedString>) {
275    if path == Path::new("") {
276        (
277            SharedString::from(
278                path_prefix
279                    .trim_end_matches(std::path::MAIN_SEPARATOR)
280                    .to_string(),
281            ),
282            None,
283        )
284    } else {
285        let file_name = path
286            .file_name()
287            .unwrap_or_default()
288            .to_string_lossy()
289            .to_string()
290            .into();
291
292        let mut directory = path_prefix
293            .trim_end_matches(std::path::MAIN_SEPARATOR)
294            .to_string();
295        if !directory.ends_with('/') {
296            directory.push('/');
297        }
298        if let Some(parent) = path.parent().filter(|parent| parent != &Path::new("")) {
299            directory.push_str(&parent.to_string_lossy());
300            directory.push('/');
301        }
302
303        (file_name, Some(directory.into()))
304    }
305}
306
307pub fn render_file_context_entry(
308    id: ElementId,
309    worktree_id: WorktreeId,
310    path: &Arc<Path>,
311    path_prefix: &Arc<str>,
312    is_directory: bool,
313    context_store: WeakEntity<ContextStore>,
314    cx: &App,
315) -> Stateful<Div> {
316    let (file_name, directory) = extract_file_name_and_directory(path, path_prefix);
317
318    let added = context_store.upgrade().and_then(|context_store| {
319        let project_path = ProjectPath {
320            worktree_id,
321            path: path.clone(),
322        };
323        if is_directory {
324            context_store
325                .read(cx)
326                .path_included_in_directory(&project_path, cx)
327        } else {
328            context_store.read(cx).file_path_included(&project_path, cx)
329        }
330    });
331
332    let file_icon = if is_directory {
333        FileIcons::get_folder_icon(false, path, cx)
334    } else {
335        FileIcons::get_icon(path, cx)
336    }
337    .map(Icon::from_path)
338    .unwrap_or_else(|| Icon::new(IconName::File));
339
340    h_flex()
341        .id(id)
342        .gap_1p5()
343        .w_full()
344        .child(file_icon.size(IconSize::Small).color(Color::Muted))
345        .child(
346            h_flex()
347                .gap_1()
348                .child(Label::new(file_name))
349                .children(directory.map(|directory| {
350                    Label::new(directory)
351                        .size(LabelSize::Small)
352                        .color(Color::Muted)
353                })),
354        )
355        .when_some(added, |el, added| match added {
356            FileInclusion::Direct => el.child(
357                h_flex()
358                    .w_full()
359                    .justify_end()
360                    .gap_0p5()
361                    .child(
362                        Icon::new(IconName::Check)
363                            .size(IconSize::Small)
364                            .color(Color::Success),
365                    )
366                    .child(Label::new("Added").size(LabelSize::Small)),
367            ),
368            FileInclusion::InDirectory { full_path } => {
369                let directory_full_path = full_path.to_string_lossy().into_owned();
370
371                el.child(
372                    h_flex()
373                        .w_full()
374                        .justify_end()
375                        .gap_0p5()
376                        .child(
377                            Icon::new(IconName::Check)
378                                .size(IconSize::Small)
379                                .color(Color::Success),
380                        )
381                        .child(Label::new("Included").size(LabelSize::Small)),
382                )
383                .tooltip(Tooltip::text(format!("in {directory_full_path}")))
384            }
385        })
386}