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