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 visible_worktrees = workspace.visible_worktrees(cx).collect::<Vec<_>>();
201        let include_root_name = visible_worktrees.len() > 1;
202
203        let recent_matches = workspace
204            .recent_navigation_history(Some(10), cx)
205            .into_iter()
206            .map(|(project_path, _)| {
207                let path_prefix = if include_root_name {
208                    project
209                        .worktree_for_id(project_path.worktree_id, cx)
210                        .map(|wt| wt.read(cx).root_name().into())
211                        .unwrap_or_else(|| RelPath::empty().into())
212                } else {
213                    RelPath::empty().into()
214                };
215
216                FileMatch {
217                    mat: PathMatch {
218                        score: 0.,
219                        positions: Vec::new(),
220                        worktree_id: project_path.worktree_id.to_usize(),
221                        path: project_path.path,
222                        path_prefix,
223                        distance_to_relative_ancestor: 0,
224                        is_dir: false,
225                    },
226                    is_recent: true,
227                }
228            });
229
230        let file_matches = visible_worktrees.into_iter().flat_map(|worktree| {
231            let worktree = worktree.read(cx);
232            let path_prefix: Arc<RelPath> = if include_root_name {
233                worktree.root_name().into()
234            } else {
235                RelPath::empty().into()
236            };
237            worktree.entries(false, 0).map(move |entry| FileMatch {
238                mat: PathMatch {
239                    score: 0.,
240                    positions: Vec::new(),
241                    worktree_id: worktree.id().to_usize(),
242                    path: entry.path.clone(),
243                    path_prefix: path_prefix.clone(),
244                    distance_to_relative_ancestor: 0,
245                    is_dir: entry.is_dir(),
246                },
247                is_recent: false,
248            })
249        });
250
251        Task::ready(recent_matches.chain(file_matches).collect())
252    } else {
253        let worktrees = workspace.read(cx).visible_worktrees(cx).collect::<Vec<_>>();
254        let include_root_name = worktrees.len() > 1;
255        let candidate_sets = worktrees
256            .into_iter()
257            .map(|worktree| {
258                let worktree = worktree.read(cx);
259
260                PathMatchCandidateSet {
261                    snapshot: worktree.snapshot(),
262                    include_ignored: worktree.root_entry().is_some_and(|entry| entry.is_ignored),
263                    include_root_name,
264                    candidates: project::Candidates::Entries,
265                }
266            })
267            .collect::<Vec<_>>();
268
269        let executor = cx.background_executor().clone();
270        cx.foreground_executor().spawn(async move {
271            fuzzy::match_path_sets(
272                candidate_sets.as_slice(),
273                query.as_str(),
274                &None,
275                false,
276                100,
277                &cancellation_flag,
278                executor,
279            )
280            .await
281            .into_iter()
282            .map(|mat| FileMatch {
283                mat,
284                is_recent: false,
285            })
286            .collect::<Vec<_>>()
287        })
288    }
289}
290
291pub fn extract_file_name_and_directory(
292    path: &RelPath,
293    path_prefix: &RelPath,
294    path_style: PathStyle,
295) -> (SharedString, Option<SharedString>) {
296    // If path is empty, this means we're matching with the root directory itself
297    // so we use the path_prefix as the name
298    if path.is_empty() && !path_prefix.is_empty() {
299        return (path_prefix.display(path_style).to_string().into(), None);
300    }
301
302    let full_path = path_prefix.join(path);
303    let file_name = full_path.file_name().unwrap_or_default();
304    let display_path = full_path.display(path_style);
305    let (directory, file_name) = display_path.split_at(display_path.len() - file_name.len());
306    (
307        file_name.to_string().into(),
308        Some(SharedString::new(directory)).filter(|dir| !dir.is_empty()),
309    )
310}
311
312pub fn render_file_context_entry(
313    id: ElementId,
314    worktree_id: WorktreeId,
315    path: &Arc<RelPath>,
316    path_prefix: &Arc<RelPath>,
317    is_directory: bool,
318    path_style: PathStyle,
319    context_store: WeakEntity<ContextStore>,
320    cx: &App,
321) -> Stateful<Div> {
322    let (file_name, directory) = extract_file_name_and_directory(path, path_prefix, path_style);
323
324    let added = context_store.upgrade().and_then(|context_store| {
325        let project_path = ProjectPath {
326            worktree_id,
327            path: path.clone(),
328        };
329        if is_directory {
330            context_store
331                .read(cx)
332                .path_included_in_directory(&project_path, cx)
333        } else {
334            context_store.read(cx).file_path_included(&project_path, cx)
335        }
336    });
337
338    let file_icon = if is_directory {
339        FileIcons::get_folder_icon(false, path.as_std_path(), cx)
340    } else {
341        FileIcons::get_icon(path.as_std_path(), cx)
342    }
343    .map(Icon::from_path)
344    .unwrap_or_else(|| Icon::new(IconName::File));
345
346    h_flex()
347        .id(id)
348        .gap_1p5()
349        .w_full()
350        .child(file_icon.size(IconSize::Small).color(Color::Muted))
351        .child(
352            h_flex()
353                .gap_1()
354                .child(Label::new(file_name))
355                .children(directory.map(|directory| {
356                    Label::new(directory)
357                        .size(LabelSize::Small)
358                        .color(Color::Muted)
359                })),
360        )
361        .when_some(added, |el, added| match added {
362            FileInclusion::Direct => el.child(
363                h_flex()
364                    .w_full()
365                    .justify_end()
366                    .gap_0p5()
367                    .child(
368                        Icon::new(IconName::Check)
369                            .size(IconSize::Small)
370                            .color(Color::Success),
371                    )
372                    .child(Label::new("Added").size(LabelSize::Small)),
373            ),
374            FileInclusion::InDirectory { full_path } => {
375                let directory_full_path = full_path.to_string_lossy().into_owned();
376
377                el.child(
378                    h_flex()
379                        .w_full()
380                        .justify_end()
381                        .gap_0p5()
382                        .child(
383                            Icon::new(IconName::Check)
384                                .size(IconSize::Small)
385                                .color(Color::Success),
386                        )
387                        .child(Label::new("Included").size(LabelSize::Small)),
388                )
389                .tooltip(Tooltip::text(format!("in {directory_full_path}")))
390            }
391        })
392}